From 40384beb805dc152510e6aa834d7350ac76b986e Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 02:46:35 +0200 Subject: [PATCH 01/18] build: drop the container build mode argument - Call the shared container build without the removed -BuildMode argument --- build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.ps1 b/build.ps1 index 8276744..4ba83dc 100644 --- a/build.ps1 +++ b/build.ps1 @@ -6,4 +6,4 @@ $scripts = @( ) $implementation = $scripts | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 if (-not $implementation) { throw "Invoke-Build.ps1 was not found. Checked: $($scripts -join ', ')" } -& $implementation -RepositoryRoot $PSScriptRoot -BuildMode Application @args +& $implementation -RepositoryRoot $PSScriptRoot @args From 875df7e4182f10a63198d7a7c314f9eceef4f63f Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 02:46:50 +0200 Subject: [PATCH 02/18] docs(skills): remove attribution footer - Strip the attribution footer from the skill description and body --- .github/skills/sync-appsettings-to-configmap/SKILL.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/skills/sync-appsettings-to-configmap/SKILL.md b/.github/skills/sync-appsettings-to-configmap/SKILL.md index c0c6254..e0517d4 100644 --- a/.github/skills/sync-appsettings-to-configmap/SKILL.md +++ b/.github/skills/sync-appsettings-to-configmap/SKILL.md @@ -1,6 +1,6 @@ --- name: sync-appsettings-to-configmap -description: 'Synchronize an appsettings JSON file into an existing ConfigMap in a caller-configured private GitOps repository. Use after local deployment configuration changes, before an application rollout, or when checking configuration drift. - Brought to you by f2calv/SmartHaus' +description: 'Synchronize an appsettings JSON file into an existing ConfigMap in a caller-configured private GitOps repository. Use after local deployment configuration changes, before an application rollout, or when checking configuration drift.' argument-hint: '[mode={check|sync}]' user-invocable: true --- @@ -135,5 +135,3 @@ Run the synthetic Pester regression suite after changing the script: | Target is already modified | Review the existing GitOps change before synchronizing again | | Semantic verification fails | Restore the target from Git and inspect the `yq` version and source syntax | | Argo CD does not reconcile | Commit and push through the normal GitOps workflow, then inspect the Application | - -> Brought to you by f2calv/SmartHaus From 68494ec2906c6b5616c533bc4385be203281dd1f Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 08:29:00 +0200 Subject: [PATCH 03/18] fix(api): align typed JSON and validation contracts - Serialize enum names in typed responses and MVC input - Restore standard validation Problem Details - Verify HTTP contracts and generated Swagger with offline host tests --- .../ApiContractProbeController.cs | 36 +++++++++ .../ApiContractWebApplicationFactory.cs | 15 ++++ src/CasCap.App.Server.Tests/README.md | 9 ++- .../Tests/Integration/WebApiContractTests.cs | 73 +++++++++++++++++++ src/CasCap.App.Server/AppHost.WebApi.cs | 31 +------- src/CasCap.App.Server/README.md | 6 ++ 6 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 src/CasCap.App.Server.Tests/Infrastructure/ApiContractProbeController.cs create mode 100644 src/CasCap.App.Server.Tests/Infrastructure/ApiContractWebApplicationFactory.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Integration/WebApiContractTests.cs diff --git a/src/CasCap.App.Server.Tests/Infrastructure/ApiContractProbeController.cs b/src/CasCap.App.Server.Tests/Infrastructure/ApiContractProbeController.cs new file mode 100644 index 0000000..25652c4 --- /dev/null +++ b/src/CasCap.App.Server.Tests/Infrastructure/ApiContractProbeController.cs @@ -0,0 +1,36 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; + +namespace CasCap.Tests.Infrastructure; + +/// Exercises the real host JSON and validation pipeline without device dependencies. +[ApiController] +[ApiVersion(1.0)] +[Route("api/v{version:apiVersion}/contract-probe")] +public sealed class ApiContractProbeController : ControllerBase +{ + /// Returns an enum-bearing payload through the typed HTTP result pipeline. + [HttpGet] + public Ok Get() => TypedResults.Ok(new Payload { Name = "example", Day = DayOfWeek.Monday }); + + /// Validates and echoes a controller-bound JSON payload. + [HttpPost] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public Ok Post([FromBody] Payload request) => TypedResults.Ok(request); + + /// Provides a deterministic validation and enum-serialization contract. + public sealed record Payload + { + /// Gets the required synthetic name. + /// example + [Required] + public string? Name { get; init; } + + /// Gets the day represented as a string on the JSON wire. + /// Monday + public DayOfWeek Day { get; init; } + } +} diff --git a/src/CasCap.App.Server.Tests/Infrastructure/ApiContractWebApplicationFactory.cs b/src/CasCap.App.Server.Tests/Infrastructure/ApiContractWebApplicationFactory.cs new file mode 100644 index 0000000..177b275 --- /dev/null +++ b/src/CasCap.App.Server.Tests/Infrastructure/ApiContractWebApplicationFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Hosting; + +namespace CasCap.Tests.Infrastructure; + +/// Adds synthetic contract probes to the credential-free application host. +public sealed class ApiContractWebApplicationFactory : CasCapAppWebApplicationFactory +{ + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => + services.AddControllers().AddApplicationPart(typeof(ApiContractProbeController).Assembly)); + } +} diff --git a/src/CasCap.App.Server.Tests/README.md b/src/CasCap.App.Server.Tests/README.md index 6aaf01e..7cbe351 100644 --- a/src/CasCap.App.Server.Tests/README.md +++ b/src/CasCap.App.Server.Tests/README.md @@ -23,7 +23,8 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | `HostStartupConfigurationTests` | 1 | 1 | Integration | Options binding, Key Vault disablement, feature selection, and safe core DI services | | `McpAuthenticationTests` | 2 | 2 | Integration | Anonymous rejection and Basic-authenticated MCP endpoint access | | `SystemControllerTests` | 4 | 4 | Integration | `GET /api/system` and Basic authentication behavior | -| Total | 18 | 24 | | Source-declared, including skipped cases | +| `WebApiContractTests` | 4 | 8 | Integration | String-enum JSON output/input, validation Problem Details, and generated Swagger schemas | +| Total | 22 | 32 | | Source-declared, including skipped cases | ## Trait Categories @@ -64,12 +65,15 @@ CasCap.App.Server.Tests/ │ ├── McpAuthenticationTests.cs │ └── SystemControllerTests.cs ├── Infrastructure/ +│ ├── ApiContractProbeController.cs +│ ├── ApiContractWebApplicationFactory.cs │ ├── CasCapAppWebApplicationFactory.cs │ ├── StrictAuthCasCapAppWebApplicationFactory.cs │ └── WebApiTestBase.cs ├── Tests/ │ ├── Integration/ -│ │ └── HostStartupConfigurationTests.cs +│ │ ├── HostStartupConfigurationTests.cs +│ │ └── WebApiContractTests.cs │ └── Unit/ │ └── FeatureConfigTests.cs ├── GlobalUsings.cs @@ -98,3 +102,4 @@ Test collections run serially through `xunit.runner.json`. ## License This project is released under the [MIT License](../../LICENSE). See the [LICENSE](../../LICENSE) file for details. + diff --git a/src/CasCap.App.Server.Tests/Tests/Integration/WebApiContractTests.cs b/src/CasCap.App.Server.Tests/Tests/Integration/WebApiContractTests.cs new file mode 100644 index 0000000..dd34898 --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Integration/WebApiContractTests.cs @@ -0,0 +1,73 @@ +using CasCap.Tests.Infrastructure; +using System.Net; +using System.Text; + +namespace CasCap.Tests; + +/// Checks typed JSON, MVC input validation and generated OpenAPI through the real host. +[Trait("Category", "Integration")] +public sealed class WebApiContractTests(ApiContractWebApplicationFactory factory) + : IClassFixture +{ + [Fact] + public async Task TypedResults_EmitStringEnums() + { + using var client = factory.CreateClient(); + using var response = await client.GetAsync("/api/v1/contract-probe", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal("Monday", body.RootElement.GetProperty("day").GetString()); + Assert.Equal("example", body.RootElement.GetProperty("name").GetString()); + } + + [Theory] + [InlineData("{\"name\":\"example\",\"day\":\"Friday\"}", "Friday")] + [InlineData("{\"name\":\"example\",\"day\":\"Monday\"}", "Monday")] + public async Task ControllerBinding_AcceptsStringEnums(string json, string expectedDay) + { + using var client = factory.CreateClient(); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/api/v1/contract-probe", content, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(expectedDay, body.RootElement.GetProperty("day").GetString()); + } + + [Theory] + [InlineData("{}")] + [InlineData("{")] + [InlineData("")] + [InlineData("{\"name\":\"example\",\"day\":\"NotADay\"}")] + public async Task InvalidRequests_ReturnValidationProblem(string json) + { + using var client = factory.CreateClient(); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync("/api/v1/contract-probe", content, TestContext.Current.CancellationToken); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(400, body.RootElement.GetProperty("status").GetInt32()); + Assert.NotEmpty(body.RootElement.GetProperty("errors").EnumerateObject()); + } + + [Fact] + public async Task OpenApi_DescribesStringEnumsAndTypedResponses() + { + using var client = factory.CreateAuthorizedClient(); + using var response = await client.GetAsync("/swagger/v1.json", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + var operation = body.RootElement.GetProperty("paths").GetProperty("/api/v1/contract-probe"); + Assert.True(operation.GetProperty("get").GetProperty("responses").TryGetProperty("200", out _)); + Assert.True(operation.GetProperty("post").GetProperty("responses").TryGetProperty("400", out _)); + var schemas = body.RootElement.GetProperty("components").GetProperty("schemas"); + var day = schemas.GetProperty("DayOfWeek"); + Assert.Equal("string", day.GetProperty("type").GetString()); + Assert.Contains(day.GetProperty("enum").EnumerateArray(), value => value.GetString() == "Monday"); + } +} diff --git a/src/CasCap.App.Server/AppHost.WebApi.cs b/src/CasCap.App.Server/AppHost.WebApi.cs index 6e7eb73..a047b4c 100644 --- a/src/CasCap.App.Server/AppHost.WebApi.cs +++ b/src/CasCap.App.Server/AppHost.WebApi.cs @@ -1,7 +1,6 @@ using CasCap.Common.Authentication; using CasCap.Constants; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Mvc; namespace CasCap; @@ -21,7 +20,6 @@ private static void AddWebApi( builder.Services.AddApiVersioning(options => { - options.DefaultApiVersion = new ApiVersion(1.0); options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; options.ApiVersionReader = new UrlSegmentApiVersionReader(); @@ -47,32 +45,10 @@ private static void AddWebApi( options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); - builder.Services.Configure(options => + builder.Services.ConfigureHttpJsonOptions(options => { - options.InvalidModelStateResponseFactory = context => - { - var apiLogger = context.HttpContext.RequestServices - .GetRequiredService() - .CreateLogger("ApiModelValidation"); - - var errors = context.ModelState - .Where(state => state.Value?.Errors.Count > 0) - .ToDictionary( - state => state.Key, - state => state.Value!.Errors.Select(error => error.ErrorMessage).ToArray()); - - apiLogger.LogWarning( - "{ClassName} invalid model state for {Method} {Path} with QueryString={QueryString} ContentType={ContentType} ContentLength={ContentLength} Errors={Errors}", - nameof(Program), - context.HttpContext.Request.Method, - context.HttpContext.Request.Path, - context.HttpContext.Request.QueryString.ToString(), - context.HttpContext.Request.ContentType, - context.HttpContext.Request.ContentLength, - errors); - - return new BadRequestObjectResult(context.ModelState); - }; + options.SerializerOptions.WriteIndented = builder.Environment.IsDevelopment(); + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); if (enabledFeatures.Contains(FeatureNames.Buderus)) @@ -96,7 +72,6 @@ private static void AddWebApi( if (enabledFeatures.Contains(FeatureNames.Ubiquiti)) mvcBuilder.AddApplicationPart(typeof(UbiquitiController).Assembly); - builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo diff --git a/src/CasCap.App.Server/README.md b/src/CasCap.App.Server/README.md index 7ba0933..e6ca63b 100644 --- a/src/CasCap.App.Server/README.md +++ b/src/CasCap.App.Server/README.md @@ -70,6 +70,12 @@ The following services are registered regardless of feature flags: All controllers are added dynamically based on `EnabledFeatures`. API versioning defaults to v1. +JSON endpoints use typed HTTP results. Enum values are written as strings, and controller JSON +input accepts string enum names. Invalid request bodies return HTTP 400 with +`application/problem+json` and a `ValidationProblemDetails` body containing an `errors` dictionary. +Consumers should read enum names rather than numeric values and use `errors` for field failures. +MVC input binding and typed-result output share this contract through their respective JSON options. + | Source | Endpoints | | --- | --- | | `SystemController` (always) | `GET /api/system` | From dfb76fd870554e4d0307dda3850412dffc5b40c2 Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 08:29:00 +0200 Subject: [PATCH 04/18] fix(api): propagate cancellation and validate power commands - Forward request cancellation on device history routes - Require explicit power state before invoking device services - Preserve Problem Details media types without blanket JSON metadata - Cover real device controller bindings and generated Swagger offline --- .../Controllers/BuderusController.cs | 9 +- .../Controllers/DDnsController.cs | 1 - .../Controllers/DoorBirdController.cs | 1 - .../Controllers/EdgeHardwareController.cs | 5 +- .../Controllers/FroniusController.cs | 5 +- .../Controllers/BusController.cs | 1 - .../Controllers/ContactsController.cs | 1 - .../Controllers/DoorLocksController.cs | 1 - .../Controllers/HvacController.cs | 1 - .../Controllers/LightsController.cs | 1 - .../Controllers/PowerOutletsController.cs | 1 - .../Controllers/ShuttersController.cs | 1 - .../Controllers/MieleController.cs | 1 - .../Controllers/ShellyController.cs | 10 +- .../Controllers/SicceController.cs | 5 +- .../Controllers/UbiquitiController.cs | 1 - .../Controllers/WizController.cs | 6 +- .../DeviceApiWebApplicationFactory.cs | 112 ++++++++++++++++++ src/CasCap.App.Server.Tests/README.md | 5 +- .../Integration/DeviceApiContractTests.cs | 89 ++++++++++++++ src/CasCap.App.Server/README.md | 3 + 21 files changed, 228 insertions(+), 32 deletions(-) create mode 100644 src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Integration/DeviceApiContractTests.cs diff --git a/src/CasCap.Api.Buderus/Controllers/BuderusController.cs b/src/CasCap.Api.Buderus/Controllers/BuderusController.cs index 30eed1d..407a952 100644 --- a/src/CasCap.Api.Buderus/Controllers/BuderusController.cs +++ b/src/CasCap.Api.Buderus/Controllers/BuderusController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class BuderusController(IBuderusQueryService buderusQuerySvc) : ControllerBase { /// @@ -17,13 +16,13 @@ public async Task> GetSnapshot() /// [HttpGet("values")] - public Ok> Get() - => TypedResults.Ok(buderusQuerySvc.GetEvents()); + public Ok> Get(CancellationToken cancellationToken) + => TypedResults.Ok(buderusQuerySvc.GetEvents(cancellationToken: cancellationToken)); /// [HttpGet("{id}")] - public Ok> GetById(string id) - => TypedResults.Ok(buderusQuerySvc.GetEvents(id)); + public Ok> GetById(string id, CancellationToken cancellationToken) + => TypedResults.Ok(buderusQuerySvc.GetEvents(id, cancellationToken: cancellationToken)); /// [HttpPut("{*id}")] diff --git a/src/CasCap.Api.DDns/Controllers/DDnsController.cs b/src/CasCap.Api.DDns/Controllers/DDnsController.cs index 81c2262..9f180b4 100644 --- a/src/CasCap.Api.DDns/Controllers/DDnsController.cs +++ b/src/CasCap.Api.DDns/Controllers/DDnsController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class DDnsController(IDDnsQueryService dDnsQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.DoorBird/Controllers/DoorBirdController.cs b/src/CasCap.Api.DoorBird/Controllers/DoorBirdController.cs index b678438..e6a033f 100644 --- a/src/CasCap.Api.DoorBird/Controllers/DoorBirdController.cs +++ b/src/CasCap.Api.DoorBird/Controllers/DoorBirdController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class DoorBirdController(IDoorBirdQueryService doorBirdQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.EdgeHardware/Controllers/EdgeHardwareController.cs b/src/CasCap.Api.EdgeHardware/Controllers/EdgeHardwareController.cs index 784b1eb..06697e9 100644 --- a/src/CasCap.Api.EdgeHardware/Controllers/EdgeHardwareController.cs +++ b/src/CasCap.Api.EdgeHardware/Controllers/EdgeHardwareController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class EdgeHardwareController(IEdgeHardwareQueryService edgeHardwareQuerySvc) : ControllerBase { /// @@ -17,7 +16,7 @@ public async Task>> GetLatestSnapshots() /// [HttpGet("events")] - public Ok> GetEvents(int limit = 100) - => TypedResults.Ok(edgeHardwareQuerySvc.GetEvents(limit)); + public Ok> GetEvents(CancellationToken cancellationToken, int limit = 100) + => TypedResults.Ok(edgeHardwareQuerySvc.GetEvents(limit, cancellationToken)); } diff --git a/src/CasCap.Api.Fronius/Controllers/FroniusController.cs b/src/CasCap.Api.Fronius/Controllers/FroniusController.cs index 8f2a62b..4abd609 100644 --- a/src/CasCap.Api.Fronius/Controllers/FroniusController.cs +++ b/src/CasCap.Api.Fronius/Controllers/FroniusController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class FroniusController(IFroniusQueryService froniusQuerySvc) : ControllerBase { /// @@ -17,8 +16,8 @@ public async Task> GetInverterSnapshot() /// [HttpGet("readings")] - public Ok> GetInverterReadings(int limit = 100) - => TypedResults.Ok(froniusQuerySvc.GetInverterReadings(limit)); + public Ok> GetInverterReadings(CancellationToken cancellationToken, int limit = 100) + => TypedResults.Ok(froniusQuerySvc.GetInverterReadings(limit, cancellationToken)); /// [HttpGet("powerflow")] diff --git a/src/CasCap.Api.Knx/Controllers/BusController.cs b/src/CasCap.Api.Knx/Controllers/BusController.cs index c246744..ecf0b63 100644 --- a/src/CasCap.Api.Knx/Controllers/BusController.cs +++ b/src/CasCap.Api.Knx/Controllers/BusController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class BusController(IKnxQueryService knxQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.Knx/Controllers/ContactsController.cs b/src/CasCap.Api.Knx/Controllers/ContactsController.cs index 9e2ba4a..3cfbc5f 100644 --- a/src/CasCap.Api.Knx/Controllers/ContactsController.cs +++ b/src/CasCap.Api.Knx/Controllers/ContactsController.cs @@ -5,7 +5,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class ContactsController(IKnxQueryService knxQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.Knx/Controllers/DoorLocksController.cs b/src/CasCap.Api.Knx/Controllers/DoorLocksController.cs index 0b0a331..f0c0a95 100644 --- a/src/CasCap.Api.Knx/Controllers/DoorLocksController.cs +++ b/src/CasCap.Api.Knx/Controllers/DoorLocksController.cs @@ -5,7 +5,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class DoorLocksController(IKnxQueryService knxQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.Knx/Controllers/HvacController.cs b/src/CasCap.Api.Knx/Controllers/HvacController.cs index 05149a0..ebe79fb 100644 --- a/src/CasCap.Api.Knx/Controllers/HvacController.cs +++ b/src/CasCap.Api.Knx/Controllers/HvacController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class HvacController(IKnxQueryService knxQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.Knx/Controllers/LightsController.cs b/src/CasCap.Api.Knx/Controllers/LightsController.cs index 9abf47e..7ad114a 100644 --- a/src/CasCap.Api.Knx/Controllers/LightsController.cs +++ b/src/CasCap.Api.Knx/Controllers/LightsController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class LightsController(IKnxQueryService knxQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.Knx/Controllers/PowerOutletsController.cs b/src/CasCap.Api.Knx/Controllers/PowerOutletsController.cs index c9b5ce4..1923814 100644 --- a/src/CasCap.Api.Knx/Controllers/PowerOutletsController.cs +++ b/src/CasCap.Api.Knx/Controllers/PowerOutletsController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class PowerOutletsController(IKnxQueryService knxQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.Knx/Controllers/ShuttersController.cs b/src/CasCap.Api.Knx/Controllers/ShuttersController.cs index 4f77c3d..657e0a7 100644 --- a/src/CasCap.Api.Knx/Controllers/ShuttersController.cs +++ b/src/CasCap.Api.Knx/Controllers/ShuttersController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class ShuttersController(IKnxQueryService knxQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.Miele/Controllers/MieleController.cs b/src/CasCap.Api.Miele/Controllers/MieleController.cs index d893f8f..c31f9af 100644 --- a/src/CasCap.Api.Miele/Controllers/MieleController.cs +++ b/src/CasCap.Api.Miele/Controllers/MieleController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class MieleController(IMieleQueryService mieleQuerySvc) : ControllerBase { /// diff --git a/src/CasCap.Api.Shelly/Controllers/ShellyController.cs b/src/CasCap.Api.Shelly/Controllers/ShellyController.cs index 2b83398..595c736 100644 --- a/src/CasCap.Api.Shelly/Controllers/ShellyController.cs +++ b/src/CasCap.Api.Shelly/Controllers/ShellyController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + namespace CasCap.Controllers; /// @@ -7,7 +9,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class ShellyController(IShellyQueryService shellyQuerySvc) : ControllerBase { /// @@ -17,8 +18,8 @@ public async Task>> GetSnapshots() /// [HttpGet("readings")] - public Ok> GetReadings(int limit = 100) - => TypedResults.Ok(shellyQuerySvc.GetReadings(limit)); + public Ok> GetReadings(CancellationToken cancellationToken, int limit = 100) + => TypedResults.Ok(shellyQuerySvc.GetReadings(limit, cancellationToken)); /// [HttpGet("status/{deviceId}")] @@ -31,6 +32,7 @@ public async Task> GetDeviceStatus(string deviceI /// The Shelly device ID to control. /// When , turns the relay on; when , turns it off. [HttpPost("relay/{deviceId}")] - public async Task> SetRelayState(string deviceId, [FromQuery] bool on) + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> SetRelayState(string deviceId, [FromQuery, BindRequired] bool on) => TypedResults.Ok(await shellyQuerySvc.SetRelayState(deviceId, on)); } diff --git a/src/CasCap.Api.Sicce/Controllers/SicceController.cs b/src/CasCap.Api.Sicce/Controllers/SicceController.cs index 531e927..082960f 100644 --- a/src/CasCap.Api.Sicce/Controllers/SicceController.cs +++ b/src/CasCap.Api.Sicce/Controllers/SicceController.cs @@ -7,7 +7,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class SicceController(ISicceQueryService sicceQuerySvc) : ControllerBase { /// @@ -17,8 +16,8 @@ public async Task> GetSnapshot() /// [HttpGet("readings")] - public Ok> GetReadings(int limit = 100) - => TypedResults.Ok(sicceQuerySvc.GetReadings(limit)); + public Ok> GetReadings(CancellationToken cancellationToken, int limit = 100) + => TypedResults.Ok(sicceQuerySvc.GetReadings(limit, cancellationToken)); /// [HttpGet("device")] diff --git a/src/CasCap.Api.Ubiquiti/Controllers/UbiquitiController.cs b/src/CasCap.Api.Ubiquiti/Controllers/UbiquitiController.cs index c16b278..76bbebe 100644 --- a/src/CasCap.Api.Ubiquiti/Controllers/UbiquitiController.cs +++ b/src/CasCap.Api.Ubiquiti/Controllers/UbiquitiController.cs @@ -9,7 +9,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed partial class UbiquitiController(ILogger logger, IUbiquitiQueryService ubiquitiQuerySvc) : ControllerBase { private const long MaxWebhookRequestBytes = 5 * 1024 * 1024; diff --git a/src/CasCap.Api.Wiz/Controllers/WizController.cs b/src/CasCap.Api.Wiz/Controllers/WizController.cs index e56a221..2462486 100644 --- a/src/CasCap.Api.Wiz/Controllers/WizController.cs +++ b/src/CasCap.Api.Wiz/Controllers/WizController.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + namespace CasCap.Controllers; /// @@ -7,7 +9,6 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -[Produces("application/json")] public sealed class WizController(IWizQueryService wizQuerySvc) : ControllerBase { /// @@ -32,6 +33,7 @@ public async Task> SetPilot(string bulbIdentifier, [FromBody] WizSetPil /// [HttpPut("bulbs/{bulbIdentifier}/power")] - public async Task> SetPowerState(string bulbIdentifier, [FromQuery] bool on, CancellationToken cancellationToken) + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> SetPowerState(string bulbIdentifier, [FromQuery, BindRequired] bool on, CancellationToken cancellationToken) => TypedResults.Ok(await wizQuerySvc.SetPowerState(bulbIdentifier, on, cancellationToken)); } diff --git a/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs b/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs new file mode 100644 index 0000000..43e5c17 --- /dev/null +++ b/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs @@ -0,0 +1,112 @@ +using CasCap.Abstractions; +using CasCap.Controllers; +using CasCap.Models.Dtos; +using Microsoft.AspNetCore.Hosting; + +namespace CasCap.Tests.Infrastructure; + +/// Exercises device controllers with recording services instead of device connections. +public sealed class DeviceApiWebApplicationFactory : CasCapAppWebApplicationFactory +{ + public List HistoryTokens { get; } = []; + + public List PowerCommands { get; } = []; + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => + { + services.AddControllers() + .AddApplicationPart(typeof(BuderusController).Assembly) + .AddApplicationPart(typeof(FroniusController).Assembly) + .AddApplicationPart(typeof(EdgeHardwareController).Assembly) + .AddApplicationPart(typeof(SicceController).Assembly) + .AddApplicationPart(typeof(ShellyController).Assembly) + .AddApplicationPart(typeof(WizController).Assembly); + services.AddSingleton(new BuderusQueries(this)); + services.AddSingleton(new FroniusQueries(this)); + services.AddSingleton(new EdgeQueries(this)); + services.AddSingleton(new SicceQueries(this)); + services.AddSingleton(new ShellyQueries(this)); + services.AddSingleton(new WizQueries(this)); + }); + } + + private IAsyncEnumerable RecordHistory(CancellationToken cancellationToken) + { + HistoryTokens.Add(cancellationToken); + return Empty(); + } + + private static async IAsyncEnumerable Empty() + { + await Task.CompletedTask; + yield break; + } + + private sealed class BuderusQueries(DeviceApiWebApplicationFactory factory) : IBuderusQueryService + { + public IAsyncEnumerable GetEvents(string? id = null, int limit = 1000, CancellationToken cancellationToken = default) + => factory.RecordHistory(cancellationToken); + public Task GetSnapshot() => throw new NotSupportedException(); + public Task SetDataPoint(string datapointId, string value, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + + private sealed class FroniusQueries(DeviceApiWebApplicationFactory factory) : IFroniusQueryService + { + public IAsyncEnumerable GetInverterReadings(int limit = 100, CancellationToken cancellationToken = default) + => factory.RecordHistory(cancellationToken); + public Task GetInverterSnapshot() => throw new NotSupportedException(); + public Task GetPowerFlowRealtimeData() => throw new NotSupportedException(); + public Task GetInverterRealtimeData(string dataCollection = "CommonInverterData") => throw new NotSupportedException(); + public Task?> GetInverterInfo() => throw new NotSupportedException(); + public Task GetActiveDeviceInfo() => throw new NotSupportedException(); + public Task?> GetMeterRealtimeData(string scope = "System", int? deviceId = null) => throw new NotSupportedException(); + public Task?> GetStorageRealtimeData(string scope = "System", int? deviceId = null) => throw new NotSupportedException(); + } + + private sealed class EdgeQueries(DeviceApiWebApplicationFactory factory) : IEdgeHardwareQueryService + { + public IAsyncEnumerable GetEvents(int limit = 100, CancellationToken cancellationToken = default) + => factory.RecordHistory(cancellationToken); + public Task> GetLatestSnapshots() => throw new NotSupportedException(); + } + + private sealed class SicceQueries(DeviceApiWebApplicationFactory factory) : ISicceQueryService + { + public IAsyncEnumerable GetReadings(int limit = 100, CancellationToken cancellationToken = default) + => factory.RecordHistory(cancellationToken); + public Task GetSnapshot() => throw new NotSupportedException(); + public Task GetDeviceInfo() => throw new NotSupportedException(); + } + + private sealed class ShellyQueries(DeviceApiWebApplicationFactory factory) : IShellyQueryService + { + public IAsyncEnumerable GetReadings(int limit = 100, CancellationToken cancellationToken = default) + => factory.RecordHistory(cancellationToken); + public Task> GetSnapshots() => throw new NotSupportedException(); + public Task GetDeviceStatus(string deviceId) => throw new NotSupportedException(); + public Task SetRelayState(string deviceId, bool turnOn) + { + factory.PowerCommands.Add(turnOn); + return Task.FromResult(new ShellyRelayControlResponse { IsOk = true }); + } + } + + private sealed class WizQueries(DeviceApiWebApplicationFactory factory) : IWizQueryService + { + public Task SetPowerState(string bulbIdentifier, bool on, CancellationToken cancellationToken = default) + { + factory.PowerCommands.Add(on); + return Task.FromResult(true); + } + public IReadOnlyDictionary GetDiscoveredBulbs() => throw new NotSupportedException(); + public Task> DiscoverBulbs(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetPilot(string bulbIdentifier, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GetSystemConfig(string bulbIdentifier, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SetPilot(string bulbIdentifier, WizSetPilotRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +} diff --git a/src/CasCap.App.Server.Tests/README.md b/src/CasCap.App.Server.Tests/README.md index 7cbe351..f565e25 100644 --- a/src/CasCap.App.Server.Tests/README.md +++ b/src/CasCap.App.Server.Tests/README.md @@ -19,12 +19,13 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | --- | --- | --- | --- | --- | | `FeatureServiceRegistrationTests` | 4 | 4 | Integration | `IBgFeature` registrations and enabled-feature filtering | | `FeatureConfigTests` | 3 | 6 | Configuration | Empty values, unknown-name rejection, and case-insensitive parsing | +| `DeviceApiContractTests` | 4 | 15 | Integration | Request cancellation on six device-history routes, required power values, and generated Swagger metadata | | `HealthTests` | 4 | 7 | Integration | Health, liveness, readiness, and startup endpoints | | `HostStartupConfigurationTests` | 1 | 1 | Integration | Options binding, Key Vault disablement, feature selection, and safe core DI services | | `McpAuthenticationTests` | 2 | 2 | Integration | Anonymous rejection and Basic-authenticated MCP endpoint access | | `SystemControllerTests` | 4 | 4 | Integration | `GET /api/system` and Basic authentication behavior | | `WebApiContractTests` | 4 | 8 | Integration | String-enum JSON output/input, validation Problem Details, and generated Swagger schemas | -| Total | 22 | 32 | | Source-declared, including skipped cases | +| Total | 26 | 47 | | Source-declared, including skipped cases | ## Trait Categories @@ -68,10 +69,12 @@ CasCap.App.Server.Tests/ │ ├── ApiContractProbeController.cs │ ├── ApiContractWebApplicationFactory.cs │ ├── CasCapAppWebApplicationFactory.cs +│ ├── DeviceApiWebApplicationFactory.cs │ ├── StrictAuthCasCapAppWebApplicationFactory.cs │ └── WebApiTestBase.cs ├── Tests/ │ ├── Integration/ +│ │ ├── DeviceApiContractTests.cs │ │ ├── HostStartupConfigurationTests.cs │ │ └── WebApiContractTests.cs │ └── Unit/ diff --git a/src/CasCap.App.Server.Tests/Tests/Integration/DeviceApiContractTests.cs b/src/CasCap.App.Server.Tests/Tests/Integration/DeviceApiContractTests.cs new file mode 100644 index 0000000..66d441e --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Integration/DeviceApiContractTests.cs @@ -0,0 +1,89 @@ +using CasCap.Tests.Infrastructure; +using System.Net; + +namespace CasCap.Tests; + +/// Checks device request binding and cancellation without live devices or storage. +[Trait("Category", "Integration")] +public sealed class DeviceApiContractTests(DeviceApiWebApplicationFactory factory) + : IClassFixture +{ + [Theory] + [InlineData("/api/v1/Buderus/values")] + [InlineData("/api/v1/Buderus/example")] + [InlineData("/api/v1/Fronius/readings")] + [InlineData("/api/v1/EdgeHardware/events")] + [InlineData("/api/v1/Sicce/readings")] + [InlineData("/api/v1/Shelly/readings")] + public async Task HistoryRequests_ForwardRequestCancellation(string path) + { + using var client = factory.CreateClient(); + var before = factory.HistoryTokens.Count; + using var response = await client.GetAsync(path, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + Assert.Equal("[]", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(before + 1, factory.HistoryTokens.Count); + Assert.True(factory.HistoryTokens[^1].CanBeCanceled); + } + + [Theory] + [InlineData("POST", "/api/v1/Shelly/relay/example")] + [InlineData("PUT", "/api/v1/Wiz/bulbs/example/power")] + [InlineData("POST", "/api/v1/Shelly/relay/example?on=invalid")] + [InlineData("PUT", "/api/v1/Wiz/bulbs/example/power?on=invalid")] + public async Task MissingOrInvalidPowerState_DoesNotInvokeDevice(string method, string path) + { + using var client = factory.CreateClient(); + var before = factory.PowerCommands.Count; + using var request = new HttpRequestMessage(new HttpMethod(method), path); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.True(body.RootElement.GetProperty("errors").TryGetProperty("on", out _)); + Assert.Equal(before, factory.PowerCommands.Count); + } + + [Theory] + [InlineData("POST", "/api/v1/Shelly/relay/example?on=false", false)] + [InlineData("PUT", "/api/v1/Wiz/bulbs/example/power?on=false", false)] + [InlineData("POST", "/api/v1/Shelly/relay/example?on=true", true)] + [InlineData("PUT", "/api/v1/Wiz/bulbs/example/power?on=true", true)] + public async Task ExplicitPowerState_IsForwarded(string method, string path, bool expected) + { + using var client = factory.CreateClient(); + var before = factory.PowerCommands.Count; + using var request = new HttpRequestMessage(new HttpMethod(method), path); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + Assert.Equal(before + 1, factory.PowerCommands.Count); + Assert.Equal(expected, factory.PowerCommands[^1]); + } + + [Fact] + public async Task OpenApi_DescribesDeviceSuccessAndValidation() + { + using var client = factory.CreateAuthorizedClient(); + using var response = await client.GetAsync("/swagger/v1.json", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var paths = body.RootElement.GetProperty("paths"); + + var shelly = Assert.Single(paths.EnumerateObject(), path => + path.Name.Equals("/api/v1/Shelly/relay/{deviceId}", StringComparison.OrdinalIgnoreCase)); + var wiz = Assert.Single(paths.EnumerateObject(), path => + path.Name.Equals("/api/v1/Wiz/bulbs/{bulbIdentifier}/power", StringComparison.OrdinalIgnoreCase)); + var buderus = Assert.Single(paths.EnumerateObject(), path => + path.Name.Equals("/api/v1/Buderus/values", StringComparison.OrdinalIgnoreCase)); + Assert.True(shelly.Value.GetProperty("post") + .GetProperty("responses").TryGetProperty("400", out _)); + Assert.True(wiz.Value.GetProperty("put") + .GetProperty("responses").TryGetProperty("400", out _)); + Assert.True(buderus.Value.GetProperty("get") + .GetProperty("responses").GetProperty("200").GetProperty("content") + .TryGetProperty("application/json", out _)); + } +} diff --git a/src/CasCap.App.Server/README.md b/src/CasCap.App.Server/README.md index e6ca63b..4624d7a 100644 --- a/src/CasCap.App.Server/README.md +++ b/src/CasCap.App.Server/README.md @@ -75,6 +75,9 @@ input accepts string enum names. Invalid request bodies return HTTP 400 with `application/problem+json` and a `ValidationProblemDetails` body containing an `errors` dictionary. Consumers should read enum names rather than numeric values and use `errors` for field failures. MVC input binding and typed-result output share this contract through their respective JSON options. +Shelly relay and Wiz power commands require an explicit `on=true` or `on=false` query value; +missing or invalid values return validation Problem Details without invoking the device service. +Device-history actions forward request cancellation to their query services. | Source | Endpoints | | --- | --- | From 74a4a4a8590cd36fdbf08922d6e00615019e5bc7 Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 08:29:00 +0200 Subject: [PATCH 05/18] refactor(knx): bind bus workers to shared leadership - Supervise monitoring and sending within one ownership lifetime - Await publication and connection cleanup before reacquisition - Cover offline ownership regressions and document delivery limits --- src/CasCap.Api.Knx.Tests/README.md | 21 +- .../Tests/Unit/KnxLeadershipTests.cs | 366 +++++++++++ .../Abstractions/IKnxConnectionNotifier.cs | 2 +- .../Abstractions/IKnxMonitor.cs | 11 + .../Extensions/ServiceCollectionExtensions.cs | 3 +- src/CasCap.Api.Knx/GlobalUsings.cs | 1 - .../HealthChecks/KnxConnectionHealthCheck.cs | 2 +- src/CasCap.Api.Knx/README.md | 40 +- .../Services/KnxConnectionNotifier.cs | 2 +- .../Services/KnxGroupAddressLookupService.cs | 41 +- .../Services/KnxMonitorBgService.cs | 605 +---------------- .../Services/KnxMonitorService.cs | 610 ++++++++++++++++++ .../Services/KnxSenderBgService.cs | 37 +- .../Services/RedisKnxTelegramBroker{T}.cs | 22 +- 14 files changed, 1131 insertions(+), 632 deletions(-) create mode 100644 src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs create mode 100644 src/CasCap.Api.Knx/Abstractions/IKnxMonitor.cs create mode 100644 src/CasCap.Api.Knx/Services/KnxMonitorService.cs diff --git a/src/CasCap.Api.Knx.Tests/README.md b/src/CasCap.Api.Knx.Tests/README.md index afbec9a..f83f74f 100644 --- a/src/CasCap.Api.Knx.Tests/README.md +++ b/src/CasCap.Api.Knx.Tests/README.md @@ -1,6 +1,6 @@ # CasCap.Api.Knx.Tests -Offline broker/shutter tests and configuration/storage integration tests for +Offline leadership/broker/shutter tests and configuration/storage integration tests for [CasCap.Api.Knx](../CasCap.Api.Knx/README.md) and its [storage sinks](../CasCap.Api.Knx.Sinks/README.md). @@ -18,9 +18,15 @@ Source-declared counts include skipped cases and expand inline theories; no exec | `CemiDecodingTests` | 1 | 1 | `Integration` | | `ChannelKnxTelegramBrokerTests` | 4 | 4 | None | | `KnxShutterMovementTests` | 6 | 12 | None | -| Total | 17 | 23 | | +| `KnxLeadershipTests` | 8 | 12 | `Leadership` | +| Total | 25 | 35 | | -`Integration` is the only declared trait category. Broker and shutter tests have no category trait. +Declared trait categories are `Integration` and `Leadership`. Broker and shutter tests have no category trait. + +Leadership cases use synthetic ETS XML, fake runners/monitor sessions and in-memory brokers. They +verify standby behavior, routing/sharding selection, fresh ownership sessions, cancellation, +publication failure, late-telegram rejection and awaited sender/session cleanup. They do not +contact Redis, discover interfaces, send physical telegrams or prove live failover. ## Skipped Tests @@ -38,10 +44,13 @@ CasCap.Api.Knx.Tests/ ├── CemiDecodingTests.cs ├── GroupAddressTests.cs ├── KnxShutterMovementTests.cs -└── TestBase.cs +├── TestBase.cs +└── Tests/ + └── Unit/ + └── KnxLeadershipTests.cs ``` -Tests currently live at the project root rather than under `Tests/`. +Older tests live at the project root; new leadership tests use `Tests/Unit/`. ## Prerequisites @@ -55,7 +64,7 @@ Tests currently live at the project root rather than under `Tests/`. ```bash # Offline broker and shutter cases -dotnet test --project src/CasCap.Api.Knx.Tests/CasCap.Api.Knx.Tests.csproj -- --filter-not-trait "Category=Integration" +dotnet test --project src/CasCap.Api.Knx.Tests/CasCap.Api.Knx.Tests.csproj -c Debug --filter-not-trait "Category=Integration" ``` Use .NET 10, xUnit v3 and Microsoft.Testing.Platform from the repository root. Remove the filter diff --git a/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs b/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs new file mode 100644 index 0000000..46375c0 --- /dev/null +++ b/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs @@ -0,0 +1,366 @@ +using CasCap.Abstractions; +using CasCap.Common.Models; +using CasCap.HealthChecks; +using CasCap.Models.Dtos; +using Knx.Falcon; +using Knx.Falcon.KnxnetIp; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using System.Runtime.CompilerServices; + +namespace CasCap.Tests; + +/// Verifies ownership, callback publishing and shutdown without Redis or a KNX network. +[Trait("Category", "Leadership")] +public sealed class KnxLeadershipTests +{ + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(5); + + [Theory] + [InlineData(ServiceFamily.Tunneling, ShardingMode.Unified)] + [InlineData(ServiceFamily.Routing, ShardingMode.Partitioned)] + public async Task ExecuteAsync_DelegatesExistingResourceWithoutStartingStandbyWork(ServiceFamily family, ShardingMode sharding) + { + using var fixture = new Fixture(family, sharding); + var runner = new CapturingRunner(); + var created = 0; + var sut = fixture.CreateWorker(runner, () => + { + created++; + return new TestMonitor(); + }); + + await sut.ExecuteAsync(TestContext.Current.CancellationToken); + + Assert.Equal("Knx", sut.FeatureName); + Assert.Equal("knx:monitor", runner.Resource); + Assert.Equal(TestContext.Current.CancellationToken, runner.HostToken); + Assert.NotNull(runner.Work); + Assert.Equal(0, created); + Assert.False(fixture.Outgoing.Started.Task.IsCompleted); + } + + [Fact] + public async Task ExecuteAsync_RejectsUnsupportedTunnelingShardingBeforeElection() + { + using var fixture = new Fixture(ServiceFamily.Tunneling, ShardingMode.Partitioned); + var runner = new CapturingRunner(); + var sut = fixture.CreateWorker(runner, () => new TestMonitor()); + + await Assert.ThrowsAsync(() => sut.ExecuteAsync(TestContext.Current.CancellationToken)); + + Assert.Null(runner.Work); + } + + [Theory] + [InlineData("connect")] + [InlineData("run")] + [InlineData("complete")] + public async Task Ownership_FailureDisposesSessionAndCancelsSender(string failure) + { + using var fixture = new Fixture(); + fixture.Health.ConnectionActive = true; + fixture.Outgoing.AllowCleanup.SetResult(); + var monitor = new TestMonitor { Failure = failure }; + monitor.AllowDispose.SetResult(); + var runner = new CapturingRunner(); + await fixture.CreateWorker(runner, () => monitor).ExecuteAsync(TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync(() => + runner.Work!(TestContext.Current.CancellationToken).WaitAsync(_timeout, TestContext.Current.CancellationToken)); + + Assert.NotEmpty(exception.Message); + Assert.True(monitor.Disposed); + if (fixture.Outgoing.Started.Task.IsCompleted) + Assert.True(fixture.Outgoing.Token.IsCancellationRequested); + if (failure == "connect") + Assert.False(fixture.Outgoing.Started.Task.IsCompleted); + } + + [Fact] + public async Task Ownership_JoinsSenderBeforeDisposalAndCreatesFreshSessions() + { + using var fixture = new Fixture(); + fixture.Health.ConnectionActive = true; + var sessions = new List(); + var runner = new CapturingRunner(); + await fixture.CreateWorker(runner, () => + { + var monitor = new TestMonitor(); + sessions.Add(monitor); + return monitor; + }).ExecuteAsync(TestContext.Current.CancellationToken); + + using var firstOwnership = new CancellationTokenSource(); + var first = runner.Work!(firstOwnership.Token); + await fixture.Outgoing.Started.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + var firstSession = Assert.Single(sessions); + Assert.Equal(firstOwnership.Token, firstSession.ConnectToken); + + await firstOwnership.CancelAsync(); + await fixture.Outgoing.Cleaning.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + Assert.False(first.IsCompleted); + Assert.False(firstSession.Disposing.Task.IsCompleted); + Assert.True(fixture.Outgoing.Token.IsCancellationRequested); + + fixture.Outgoing.AllowCleanup.SetResult(); + await firstSession.Disposing.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + Assert.False(first.IsCompleted); + firstSession.AllowDispose.SetResult(); + await Assert.ThrowsAnyAsync(() => + first.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + Assert.True(firstSession.Disposed); + + using var secondOwnership = new CancellationTokenSource(); + var second = runner.Work(secondOwnership.Token); + Assert.Equal(2, sessions.Count); + Assert.NotSame(firstSession, sessions[1]); + Assert.Equal(secondOwnership.Token, sessions[1].ConnectToken); + sessions[1].AllowDispose.SetResult(); + await secondOwnership.CancelAsync(); + await Assert.ThrowsAnyAsync(() => + second.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + Assert.True(sessions[1].Disposed); + } + + [Theory] + [InlineData(ServiceFamily.Tunneling)] + [InlineData(ServiceFamily.Routing)] + public async Task ConnectAsync_CancelledOwnershipDoesNotStartDiscovery(ServiceFamily family) + { + using var fixture = new Fixture(family); + await using var monitor = fixture.CreateMonitor(new IncomingBroker()); + using var ownership = new CancellationTokenSource(); + await ownership.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => monitor.ConnectAsync(ownership.Token)); + + Assert.False(fixture.Health.IsLeader); + Assert.False(fixture.Health.ConnectionActive); + } + + [Fact] + public async Task RunAsync_JoinsPublicationAndRejectsLateTelegrams() + { + using var fixture = new Fixture(); + var broker = new IncomingBroker(); + await using var monitor = fixture.CreateMonitor(broker); + using var ownership = new CancellationTokenSource(); + var telegram = CreateTelegram(); + Assert.True(monitor.TryQueueTelegram(telegram)); + var running = monitor.RunAsync(ownership.Token); + await broker.Started.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + fixture.Health.IsLeader = true; + + await ownership.CancelAsync(); + await broker.Cleaning.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + + Assert.False(fixture.Health.IsLeader); + Assert.False(fixture.Health.ConnectionActive); + Assert.False(monitor.TryQueueTelegram(telegram)); + Assert.False(running.IsCompleted); + Assert.True(broker.Token.IsCancellationRequested); + broker.AllowCleanup.SetResult(); + await Assert.ThrowsAnyAsync(() => + running.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + Assert.Same(telegram, broker.Telegram); + } + + [Fact] + public async Task RunAsync_PublicationFaultTerminatesSiblingReaders() + { + using var fixture = new Fixture(); + var broker = new IncomingBroker { Fail = true }; + await using var monitor = fixture.CreateMonitor(broker); + Assert.True(monitor.TryQueueTelegram(CreateTelegram())); + fixture.Health.IsLeader = true; + + var exception = await Assert.ThrowsAsync(() => + monitor.RunAsync(TestContext.Current.CancellationToken).WaitAsync(_timeout, TestContext.Current.CancellationToken)); + + Assert.Equal("Publication failed.", exception.Message); + Assert.False(fixture.Health.IsLeader); + Assert.False(fixture.Health.ConnectionActive); + Assert.False(monitor.TryQueueTelegram(CreateTelegram())); + } + + [Fact] + public async Task Sender_WaitsForConnectivityWithoutConsumingCommands() + { + using var fixture = new Fixture(); + using var ownership = new CancellationTokenSource(); + var running = fixture.CreateSender().ExecuteAsync(ownership.Token); + + Assert.False(fixture.Outgoing.Started.Task.IsCompleted); + Assert.False(running.IsCompleted); + await ownership.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + running.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + Assert.False(fixture.Outgoing.Started.Task.IsCompleted); + } + + private static KnxEvent CreateTelegram() + => new(DateTime.UnixEpoch, new KnxGroupEvent(), + new KnxGroupAddressParsed(new KnxGroupAddressXml { Name = "EG-LI-Kitchen-SW", Address = "0/0/1", DPTs = "DPST-1-1" }), + new GroupValue(true), true, null); + + private static TaskCompletionSource Gate() => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private sealed class Fixture : IDisposable + { + private readonly string _path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + public KnxConnectionHealthCheck Health { get; } = new(); + public OutgoingBroker Outgoing { get; } = new(); + public IOptions Config { get; } + public KnxGroupAddressLookupService Lookup { get; } + + public Fixture(ServiceFamily family = ServiceFamily.Tunneling, ShardingMode sharding = ShardingMode.Unified) + { + File.WriteAllText(_path, """ + + + + + + + + """); + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + [nameof(KnxConfig.ServiceFamily)] = family.ToString(), + [nameof(KnxConfig.ShardingMode)] = sharding.ToString(), + [nameof(KnxConfig.GroupAddressXmlFilePath)] = _path + }).Build(); + Config = Options.Create(configuration.Get()!); + Lookup = new KnxGroupAddressLookupService(NullLogger.Instance, + Config, new TestEnvironment(), new KnxGroupAddressLookupHealthCheck(), TimeProvider.System); + } + + public KnxMonitorBgService CreateWorker(ILeadershipRunner runner, Func factory) + => new(NullLogger.Instance, Config, Lookup, runner, factory, CreateSender()); + + public KnxSenderBgService CreateSender() + => new(NullLogger.Instance, Config, TimeProvider.System, Health, Outgoing); + + public KnxMonitorService CreateMonitor(IKnxTelegramBroker incoming) + => new(NullLogger.Instance, Config, TimeProvider.System, Lookup, Health, incoming, new KnxConnectionNotifier()); + + public void Dispose() => File.Delete(_path); + } + + private sealed class CapturingRunner : ILeadershipRunner + { + public string? Resource { get; private set; } + public CancellationToken HostToken { get; private set; } + public Func? Work { get; private set; } + + public Task RunAsync(string resource, Func runAsync, CancellationToken cancellationToken) + { + Resource = resource; + HostToken = cancellationToken; + Work = runAsync; + return Task.CompletedTask; + } + } + + private sealed class TestMonitor : IKnxMonitor + { + public string? Failure { get; init; } + public CancellationToken ConnectToken { get; private set; } + public TaskCompletionSource Disposing { get; } = Gate(); + public TaskCompletionSource AllowDispose { get; } = Gate(); + public bool Disposed { get; private set; } + + public Task ConnectAsync(CancellationToken cancellationToken) + { + ConnectToken = cancellationToken; + return Failure == "connect" + ? Task.FromException(new InvalidOperationException("Connection failed.")) + : Task.CompletedTask; + } + + public Task RunAsync(CancellationToken cancellationToken) + => Failure switch + { + "run" => Task.FromException(new InvalidOperationException("Monitor failed.")), + "complete" => Task.CompletedTask, + _ => Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken) + }; + + public async ValueTask DisposeAsync() + { + Disposing.TrySetResult(); + await AllowDispose.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + Disposed = true; + } + } + + private sealed class OutgoingBroker : IKnxTelegramBroker + { + public TaskCompletionSource Started { get; } = Gate(); + public TaskCompletionSource Cleaning { get; } = Gate(); + public TaskCompletionSource AllowCleanup { get; } = Gate(); + public CancellationToken Token { get; private set; } + + public ValueTask PublishAsync(KnxOutgoingTelegram item, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public async IAsyncEnumerable SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + Token = cancellationToken; + Started.TrySetResult(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + yield break; + } + finally + { + Cleaning.TrySetResult(); + await AllowCleanup.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + } + } + } + + private sealed class IncomingBroker : IKnxTelegramBroker + { + public bool Fail { get; init; } + public KnxEvent? Telegram { get; private set; } + public CancellationToken Token { get; private set; } + public TaskCompletionSource Started { get; } = Gate(); + public TaskCompletionSource Cleaning { get; } = Gate(); + public TaskCompletionSource AllowCleanup { get; } = Gate(); + + public async ValueTask PublishAsync(KnxEvent item, CancellationToken cancellationToken = default) + { + Telegram = item; + Token = cancellationToken; + Started.SetResult(); + if (Fail) + throw new InvalidOperationException("Publication failed."); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + finally + { + Cleaning.SetResult(); + await AllowCleanup.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + } + } + + public IAsyncEnumerable SubscribeAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + + private sealed class TestEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = Environments.Production; + public string ApplicationName { get; set; } = nameof(KnxLeadershipTests); + public string ContentRootPath { get; set; } = string.Empty; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/src/CasCap.Api.Knx/Abstractions/IKnxConnectionNotifier.cs b/src/CasCap.Api.Knx/Abstractions/IKnxConnectionNotifier.cs index 66cab08..324f6c2 100644 --- a/src/CasCap.Api.Knx/Abstractions/IKnxConnectionNotifier.cs +++ b/src/CasCap.Api.Knx/Abstractions/IKnxConnectionNotifier.cs @@ -4,7 +4,7 @@ namespace CasCap.Abstractions; /// Provides a stream of KNX bus connection state changes for downstream consumers. /// /// -/// publishes state changes to this notifier +/// publishes state changes to this notifier /// when a bus connection drops or is re-established. /// public interface IKnxConnectionNotifier diff --git a/src/CasCap.Api.Knx/Abstractions/IKnxMonitor.cs b/src/CasCap.Api.Knx/Abstractions/IKnxMonitor.cs new file mode 100644 index 0000000..36bbfef --- /dev/null +++ b/src/CasCap.Api.Knx/Abstractions/IKnxMonitor.cs @@ -0,0 +1,11 @@ +namespace CasCap.Abstractions; + +/// Owns KNX connections and callbacks for a single leadership run. +public interface IKnxMonitor : IAsyncDisposable +{ + /// Discovers and connects buses using the ownership token. + public Task ConnectAsync(CancellationToken cancellationToken); + + /// Monitors connections and publishes telegrams until ownership ends. + public Task RunAsync(CancellationToken cancellationToken); +} diff --git a/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs b/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs index 6fb9bfe..8fb8775 100644 --- a/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs +++ b/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs @@ -106,8 +106,9 @@ public static void AddKnx(this IServiceCollection services, IConfiguration confi services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => () => ActivatorUtilities.CreateInstance(sp)); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } diff --git a/src/CasCap.Api.Knx/GlobalUsings.cs b/src/CasCap.Api.Knx/GlobalUsings.cs index e4c1c32..a523dfe 100644 --- a/src/CasCap.Api.Knx/GlobalUsings.cs +++ b/src/CasCap.Api.Knx/GlobalUsings.cs @@ -20,7 +20,6 @@ global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; -global using RedLockNet; global using System.Collections.Concurrent; global using System.Collections.Frozen; global using System.ComponentModel; diff --git a/src/CasCap.Api.Knx/HealthChecks/KnxConnectionHealthCheck.cs b/src/CasCap.Api.Knx/HealthChecks/KnxConnectionHealthCheck.cs index 5254712..a4e374c 100644 --- a/src/CasCap.Api.Knx/HealthChecks/KnxConnectionHealthCheck.cs +++ b/src/CasCap.Api.Knx/HealthChecks/KnxConnectionHealthCheck.cs @@ -11,7 +11,7 @@ namespace CasCap.HealthChecks; /// Reports only when the replica is the active leader /// but has lost the bus connection. /// -/// is only set to by +/// is only set to by /// after is already , /// so services only need to gate on this single health check. /// diff --git a/src/CasCap.Api.Knx/README.md b/src/CasCap.Api.Knx/README.md index 94b38b4..5ad8384 100644 --- a/src/CasCap.Api.Knx/README.md +++ b/src/CasCap.Api.Knx/README.md @@ -34,12 +34,12 @@ publish commands to an existing bus-owning process; it does not create a bus con ## Bus Processing -The library is built around three background services that together form a pipeline: +The library's monitor, processor and sender form a pipeline: -1. **`KnxMonitorBgService`** – Loads the ETS group-address metadata, waits for it to become healthy, logs the active `ServiceFamily` and `ShardingMode`, then enters a Redlock-based leadership election (in Unified sharding mode). The elected leader discovers KNX IP devices on the local network and connects to the bus using the configured service family. In **Tunneling** mode it establishes one `KnxBus` connection per configured area/line combination; in **Routing** mode it joins the multicast group advertised by the KNX IP router (and `ShardingMode` is forced to `Unified`). Incoming telegrams pass through service-family-specific deduplication filters before being decoded and published via the [`IKnxTelegramBroker`](https://github.com/f2calv/SmartHaus/blob/main/src/CasCap.Api.Knx/Abstractions/IKnxTelegramBroker%7BT%7D.cs). +1. **`KnxMonitorBgService`** – Loads ETS metadata on every replica and uses the shared leadership runner in Unified mode. Each ownership callback creates a disposable `KnxMonitorService` through [`IKnxMonitor`](./Abstractions/IKnxMonitor.cs). In **Tunneling** mode it establishes one `KnxBus` connection per configured area/line combination; in **Routing** mode it joins the multicast group advertised by the KNX IP router (and `ShardingMode` is forced to `Unified`). Incoming telegrams pass through service-family-specific deduplication filters before being decoded and published via the [`IKnxTelegramBroker`](https://github.com/f2calv/SmartHaus/blob/main/src/CasCap.Api.Knx/Abstractions/IKnxTelegramBroker%7BT%7D.cs). 2. **`KnxProcessorBgService`** – Reads from that broker and fans each `KnxEvent` out to every registered [`IEventSink`](https://github.com/f2calv/CasCap.Common/blob/main/src/CasCap.Common.Abstractions/Abstractions/IEventSink%7BT%7D.cs) implementation in parallel, waiting for all sinks to complete before processing the next event. -3. **`KnxSenderBgService`** – Dequeues outbound write requests from a separate outgoing broker and sends them to the bus, enabling bidirectional control. +3. **`KnxSenderBgService`** – Runs inside the monitor's ownership callback, not as an independently launched feature. It waits for connectivity before consuming outbound requests and passes the ownership-linked cancellation token to bus writes. Standbys do not consume outgoing commands. Supporting services include `KnxAutomationBgService` (queue processing and day/night signalling). A REST API (`KnxController`) and a gRPC endpoint (`RpcTelegramService`) allow external callers to read state and send commands. @@ -185,16 +185,32 @@ The `ShardingMode` property on `KnxConfig` controls how `KnxMonitorBgService` po | Mode | Kubernetes Resource | Description | | --- | --- | --- | -| **Unified** (default) | `Deployment` (1+ replicas) | A single active pod processes telegrams from **all** configured lines. Additional replicas remain on standby via a Redlock-based leadership election and take over immediately if the active pod crashes. This is the only mode that works during local development. **Forced** when `ServiceFamily` is `Routing`. | +| **Unified** (default) | `Deployment` (1+ replicas) | A single active pod processes telegrams from **all** configured lines. Additional replicas remain on standby via Redlock election. Takeover depends on lease expiry, contention timing and connection startup, not immediate failover. **Forced** when `ServiceFamily` is `Routing`. | | **Partitioned** | `StatefulSet` | Each pod connects to exactly **one** KNX line, determined by mapping the pod ordinal to an entry in `TunnelingAreaLineFilter` (e.g. `haus-knx-0` → `1.1`, `haus-knx-1` → `1.2`). The replica count must equal the number of configured lines. Only valid when `ServiceFamily` is `Tunneling`. ⚠️ *Not yet implemented — selecting this mode throws `NotSupportedException`.* | #### Unified Mode Startup Sequence 1. **Group address lookup** — all replicas load the ETS XML metadata concurrently (stateless, no lock required). 2. **Configuration logged** — after the lookup completes, `ServiceFamily` and `ShardingMode` are emitted at `Information` level so the active configuration is visible in every pod's log stream. If `ServiceFamily` is `Routing` and `ShardingMode` was set to `Partitioned`, it is overridden to `Unified` with a warning. -3. **Leadership election** — each replica attempts to acquire a Redlock on the resource `knx:monitor`. Timing is governed by the shared `RedlockConfig` (`RedlockExpiryMs`, `RedlockWaitMs`, `RedlockRetryMs`). -4. **Leader runs** — the elected leader discovers KNX IP devices, connects to the bus, and enters the monitoring loop. Standby replicas are warm (lookup already cached) and retry the lock until they acquire it. -5. **Leader crashes** — the Redlock expires (worst-case `RedlockExpiryMs`, default 15 s), and a standby replica acquires the lock and connects. +3. **Leadership election** — the shared runner acquires the unchanged resource `knx:monitor`, using the existing caching factory/database/key format and `CachingConfig.Redlock` configuration. The built-in `LeaderElection` profile defaults to 30 s expiry, 60 s acquisition wait and 5 s retry; application overrides still apply. Root cache-miss timings are unchanged. +4. **Leader runs** — each ownership run gets fresh connection, callback-buffer and reconnect state. Health monitoring, reconnection, incoming publication and outbound delivery are supervised together. A fatal worker fault cancels and joins siblings before disposal. Initial connection failures and subsequent drops are retried for both tunneling and routing. +5. **Ownership ends** — the runner observes lease loss at `Redlock.LeadershipCheckIntervalMs` (default 1000 ms) and cancels the ownership token. Health is withdrawn, callbacks stop accepting telegrams, and all publication/write work is awaited before handlers are detached and buses asynchronously disposed. Cleanup is also applied to partial startup failures. Only then may this replica start a fresh ownership run. Host shutdown owns the forced-termination budget; cleanup has no independent timeout. + +This checkout uses the provisional .NET 8+ runner from sibling Debug project references. Release +packages do not yet contain that API; validate with `SmartHaus.Debug.slnx` until the shared package +release is available. + +Lease cancellation cannot retract a telegram already accepted by Redis or a device. Callback +buffers are in-memory and are not replayed across ownership runs. Outgoing acknowledgement and +retry behavior remains broker-specific; this lifecycle does not provide exactly-once delivery or +downstream fencing. Offline tests cover supervision and cleanup, not live lease renewal, partitions +or physical bus behavior. + +The sender can use surviving connections while another line reconnects; the overall connection +health check still requires every configured connection. Redis operations already dispatched are +awaited even when cancellation arrives. Stream acknowledgement follows processing, but pending +entries are not automatically reclaimed after ownership changes; reliable replay/idempotency is a +separate delivery concern. #### Standby Health Check @@ -206,7 +222,9 @@ The `KnxConnectionHealthCheck` tracks an `IsLeader` flag alongside the existing | `true` | `true` | **Healthy** — bus connection online | | `true` | `false` | **Unhealthy** — leader has lost bus connectivity | -When the leader's lock is released (e.g. `RunServiceAsync` returns or the pod is shutting down), both `IsLeader` and `ConnectionActive` are reset to `false`, returning the replica to standby-healthy status. +Cancellation immediately resets `IsLeader` and `ConnectionActive`, returning the replica to +standby-healthy status while cleanup finishes. Fatal run failures and disposal also clear them. +An empty connection map is not considered connected. ### Telegram Filtering @@ -233,7 +251,7 @@ flowchart TD subgraph Monitor["KnxMonitorBgService"] LOOKUP["Load group address XML"] LOG_CONFIG["Log ServiceFamily &\nShardingMode"] - REDLOCK{"Acquire Redlock\n(Unified mode)"} + REDLOCK{"Shared leadership runner\n(Unified mode)"} DISCOVER["Discover KNX IP devices"] CONNECT["Connect per Area/Line\nor join multicast"] RECEIVE["Bus_GroupMessageReceived"] @@ -241,6 +259,7 @@ flowchart TD FILTER_LINE{"Line affinity\nfilter"} FILTER_RIVAL{"Rival address\nfilter"} DECODE["Decode value & build KnxEvent"] + PUBLISH["Ownership-local buffer\nawaited publisher"] end CHANNEL_IN["IKnxTelegramBroker<KnxEvent>\n(Channel or Redis)"] @@ -271,7 +290,7 @@ flowchart TD FILTER_LINE -.->|cross-line| DROP1(("drop")) FILTER_RIVAL -->|our address| DECODE FILTER_RIVAL -.->|rival address| DROP2(("drop")) - DECODE --> CHANNEL_IN + DECODE --> PUBLISH --> CHANNEL_IN CHANNEL_IN --> READ --> DISPATCH DISPATCH --> SINK_CONSOLE DISPATCH --> SINK_REDIS @@ -279,6 +298,7 @@ flowchart TD DISPATCH --> SINK_CEMI DISPATCH --> SINK_OTEL CHANNEL_OUT --> SEND --> BUS + CONNECT -.->|same ownership token| SEND ``` ## Configuration Examples diff --git a/src/CasCap.Api.Knx/Services/KnxConnectionNotifier.cs b/src/CasCap.Api.Knx/Services/KnxConnectionNotifier.cs index 380ed14..7e2503b 100644 --- a/src/CasCap.Api.Knx/Services/KnxConnectionNotifier.cs +++ b/src/CasCap.Api.Knx/Services/KnxConnectionNotifier.cs @@ -4,7 +4,7 @@ namespace CasCap.Services; /// Broadcasts KNX bus connection state changes via a . /// /// -/// Registered as a singleton. calls +/// Registered as a singleton. calls /// internally; external consumers read from /// . /// diff --git a/src/CasCap.Api.Knx/Services/KnxGroupAddressLookupService.cs b/src/CasCap.Api.Knx/Services/KnxGroupAddressLookupService.cs index 41d7f0f..d2b5ac0 100644 --- a/src/CasCap.Api.Knx/Services/KnxGroupAddressLookupService.cs +++ b/src/CasCap.Api.Knx/Services/KnxGroupAddressLookupService.cs @@ -6,7 +6,7 @@ namespace CasCap.Services; /// Handles the exported KNX Group Address file from ETS software, the file contents /// are parsed, validated and converted into DTO. /// -public sealed class KnxGroupAddressLookupService(ILogger logger, IOptions config, IHostEnvironment environment, KnxGroupAddressLookupHealthCheck knxGroupAddressLookupHealthCheck) +public sealed class KnxGroupAddressLookupService(ILogger logger, IOptions config, IHostEnvironment environment, KnxGroupAddressLookupHealthCheck knxGroupAddressLookupHealthCheck, TimeProvider timeProvider) { private Dictionary dLookupByAddress { get; set; } = []; @@ -15,6 +15,22 @@ public sealed class KnxGroupAddressLookupService(ILoggerLoads metadata and waits for its readiness before a replica contends for bus ownership. + public async Task WaitUntilReadyAsync(CancellationToken cancellationToken) + { + await GetLookup(cancellationToken).ConfigureAwait(false); + var attempt = 1; + while (!knxGroupAddressLookupHealthCheck.GroupAddressesLoaded) + { + logger.Log(attempt % config.Value.ConnectionLogEscalationInterval == 0 ? LogLevel.Warning : LogLevel.Trace, + "{ClassName} group address lookup not yet loaded, attempt {Attempt}, retry in {RetryMs}ms...", + nameof(KnxGroupAddressLookupService), attempt, config.Value.ConnectionPollingDelayMs); + await Task.Delay(TimeSpan.FromMilliseconds(config.Value.ConnectionPollingDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); + attempt++; + } + cancellationToken.ThrowIfCancellationRequested(); + } + /// /// Populates and returns the dictionary of parsed group addresses keyed by address. /// @@ -28,14 +44,21 @@ public ValueTask> GetLookup(Cancellati private async ValueTask> PopulateAndReturnLookupAsync(CancellationToken cancellationToken) { await semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); - if (dLookupByAddress.Count > 0) { semaphoreSlim.Release(); return dLookupByAddress; } - await PopulateLookup(cancellationToken).ConfigureAwait(false); - if (dLookupByAddress.Count > 0) - knxGroupAddressLookupHealthCheck.GroupAddressesLoaded = true; - else - logger.LogCritical("{ClassName} group address lookup is empty", nameof(KnxGroupAddressLookupService)); - semaphoreSlim.Release(); - return dLookupByAddress; + try + { + if (dLookupByAddress.Count > 0) + return dLookupByAddress; + await PopulateLookup(cancellationToken).ConfigureAwait(false); + if (dLookupByAddress.Count > 0) + knxGroupAddressLookupHealthCheck.GroupAddressesLoaded = true; + else + logger.LogCritical("{ClassName} group address lookup is empty", nameof(KnxGroupAddressLookupService)); + return dLookupByAddress; + } + finally + { + semaphoreSlim.Release(); + } } /// diff --git a/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs b/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs index 863b156..05c7e12 100644 --- a/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs +++ b/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs @@ -1,600 +1,53 @@ -using Knx.Falcon; -using Knx.Falcon.Configuration; using Knx.Falcon.KnxnetIp; -using Knx.Falcon.Sdk; namespace CasCap.Services; -/// -/// Connects to the KNX bus and receives, parses and then publishes telegrams via the -/// for . -/// When a line connection drops, it is automatically re-established without affecting other lines. -/// -public sealed partial class KnxMonitorBgService( +/// Runs KNX monitoring and outbound delivery within one shared leadership lifetime. +public sealed class KnxMonitorBgService( ILogger logger, - IOptions redlockConfig, IOptions config, - TimeProvider timeProvider, KnxGroupAddressLookupService knxGroupAddressLookupSvc, - KnxGroupAddressLookupHealthCheck knxGroupAddressLookupHealthCheck, - KnxConnectionHealthCheck knxConnectionHealthCheck, - IKnxTelegramBroker incomingBroker, - KnxConnectionNotifier connectionNotifier, - IDistributedLockFactory lockFactory) : IBgFeature + ILeadershipRunner leadershipRunner, + Func monitorFactory, + KnxSenderBgService knxSenderBgSvc) : IBgFeature { - - /// - /// Discovery parameters per area/line, retained for reconnection without re-discovery. - /// - private readonly Dictionary> _lineParameters = []; - - /// - /// Channel used by to signal that a line needs reconnection. - /// - private readonly Channel _reconnectChannel = Channel.CreateUnbounded( - new UnboundedChannelOptions { SingleReader = true }); - - /// - /// All discovered tunneling interface individual addresses (including slots occupied by other deployments). - /// Telegrams whose source is in this set but not in are dropped. - /// - private readonly HashSet _allDiscoveredAddresses = []; - - /// - /// Individual addresses of our active tunneling connections (e.g. "1.1.2"). - /// Thread-safe because it is mutated on the connection management path and read from event handler threads. - /// - private readonly ConcurrentDictionary _connectedAddresses = new(); - /// public string FeatureName => "Knx"; /// public async Task ExecuteAsync(CancellationToken cancellationToken) { - logger.LogInformation("{ClassName} starting", nameof(KnxMonitorBgService)); - try - { - //load group addresses before entering leadership election — this is a stateless, - //read-only operation that all replicas can perform concurrently - await WaitForGroupAddressLookupAsync(cancellationToken).ConfigureAwait(false); - - logger.LogInformation("{ClassName} ServiceFamily={ServiceFamily}, ShardingMode={ShardingMode}, TelegramBrokerMode={TelegramBrokerMode}", - nameof(KnxMonitorBgService), config.Value.ServiceFamily, config.Value.ShardingMode, config.Value.TelegramBrokerMode); - - //routing uses a single multicast connection — partitioned sharding is not applicable - var effectiveShardingMode = config.Value.ServiceFamily == ServiceFamily.Routing - ? ShardingMode.Unified - : config.Value.ShardingMode; - - if (effectiveShardingMode != config.Value.ShardingMode) - logger.LogWarning("{ClassName} ShardingMode overridden to {EffectiveShardingMode} because ServiceFamily is {ServiceFamily}", - nameof(KnxMonitorBgService), effectiveShardingMode, config.Value.ServiceFamily); - - switch (effectiveShardingMode) - { - case ShardingMode.Unified: - await AcquireLock(cancellationToken).ConfigureAwait(false); - break; - case ShardingMode.Partitioned: - throw new NotSupportedException( - $"{nameof(KnxConfig.ShardingMode)} '{nameof(ShardingMode.Partitioned)}' is not yet supported. " + - $"Use {nameof(ShardingMode.Unified)}."); - default: - throw new NotSupportedException( - $"{nameof(KnxConfig.ShardingMode)} '{effectiveShardingMode}' is not supported."); - } - } - catch (Exception ex) when (ex is not OperationCanceledException and not TaskCanceledException) { throw; } - logger.LogInformation("{ClassName} exiting", nameof(KnxMonitorBgService)); - } - - /// - /// Loads the group address XML lookup and polls until the health check reports ready. - /// Safe to run on all replicas concurrently before leadership election. - /// - private async Task WaitForGroupAddressLookupAsync(CancellationToken cancellationToken) - { - await knxGroupAddressLookupSvc.GetLookup(cancellationToken).ConfigureAwait(false); - - var attempt = 1; - while (!cancellationToken.IsCancellationRequested && !knxGroupAddressLookupHealthCheck.GroupAddressesLoaded) - { - logger.Log(attempt % config.Value.ConnectionLogEscalationInterval == 0 ? LogLevel.Warning : LogLevel.Trace, - "{ClassName} group address lookup not yet loaded, attempt {Attempt}, retry in {RetryMs}ms...", - nameof(KnxMonitorBgService), attempt, config.Value.ConnectionPollingDelayMs); - await Task.Delay(config.Value.ConnectionPollingDelayMs, cancellationToken).ConfigureAwait(false); - attempt++; - } - } - - /// - /// Acquires a distributed Redlock and runs the service while the lock is held. - /// If the lock cannot be acquired (another replica is active), retries until cancellation. - /// - private async Task AcquireLock(CancellationToken cancellationToken) - { - var resource = "knx:monitor"; - var (expiry, wait, retry) = redlockConfig.Value.GetTimings(RedlockProfiles.LeaderElection); - - var lockAttempt = 1; - while (!cancellationToken.IsCancellationRequested) - { - await using (var redLock = await lockFactory.CreateLockAsync(resource, expiry, wait, retry, cancellationToken).ConfigureAwait(false)) - { - if (redLock.IsAcquired) - { - knxConnectionHealthCheck.IsLeader = true; - LogLockAcquired(logger, nameof(KnxMonitorBgService), lockAttempt); - await RunServiceAsync(cancellationToken).ConfigureAwait(false); - knxConnectionHealthCheck.IsLeader = false; - knxConnectionHealthCheck.ConnectionActive = false; - LogLockReleased(logger, nameof(KnxMonitorBgService)); - } - else - { - lockAttempt++; - LogLockFailed(logger, nameof(KnxMonitorBgService), lockAttempt); - } - } - LogLockPending(logger, nameof(KnxMonitorBgService), lockAttempt + 1); - } - } - - private Task RunServiceAsync(CancellationToken cancellationToken) - => ConnectAndMonitor(cancellationToken); - - private async Task ConnectAndMonitor(CancellationToken cancellationToken) - { - switch (config.Value.ServiceFamily) - { - case ServiceFamily.Tunneling: - await ConnectAndMonitorTunneling(cancellationToken).ConfigureAwait(false); - break; - case ServiceFamily.Routing: - await ConnectAndMonitorRouting(cancellationToken).ConfigureAwait(false); - break; - default: - throw new NotSupportedException( - $"{nameof(KnxConfig.ServiceFamily)} '{config.Value.ServiceFamily}' is not supported. " + - $"Use {nameof(ServiceFamily.Tunneling)} or {nameof(ServiceFamily.Routing)}."); - } - } - - private async Task ConnectAndMonitorTunneling(CancellationToken cancellationToken) - { - //gather information on possible Areas+Lines - var ipTunnelingConnectorParametersAll = await GetIpTunnelingConnectorParameters(cancellationToken).ConfigureAwait(false); - - //group discovered parameters by area/line and store for reconnection - _lineParameters.Clear(); - foreach (var parameters in ipTunnelingConnectorParametersAll) - { - var areaLine = new KnxAreaLine - { - Area = parameters.IndividualAddress!.Value.AreaAddress, - Line = parameters.IndividualAddress.Value.LineAddress - }; - if (!_lineParameters.TryGetValue(areaLine, out var list)) - { - list = []; - _lineParameters[areaLine] = list; - } - list.Add(parameters); - } - - //compute the set of all discovered tunneling addresses for source filtering - _allDiscoveredAddresses.Clear(); - _connectedAddresses.Clear(); - foreach (var parameters in _lineParameters.Values.SelectMany(list => list)) - if (parameters.IndividualAddress.HasValue) - _allDiscoveredAddresses.Add(parameters.IndividualAddress.Value.ToString()); - - //populate BusConnections with null entries for each discovered area/line - KnxStatics.BusConnections.Clear(); - foreach (var areaLine in _lineParameters.Keys) - KnxStatics.BusConnections[areaLine] = null; - - //attempt initial connection to each area/line - foreach (var areaLine in _lineParameters.Keys) - await ConnectLine(areaLine, cancellationToken).ConfigureAwait(false); - - //run the health check monitor and reconnection handler concurrently; - //await-await-WhenAny propagates the first faulted task immediately so the - //service crashes and the pod restarts rather than running in a degraded state. - await await Task.WhenAny( - MonitorHealthAsync(cancellationToken), - ProcessReconnectionRequestsAsync(cancellationToken)).ConfigureAwait(false); - } - - private async Task ConnectAndMonitorRouting(CancellationToken cancellationToken) - { - var routingParameters = await GetIpRoutingConnectorParameters(cancellationToken).ConfigureAwait(false); - - //routing uses a single multicast connection covering the entire KNX backbone - var routingAreaLine = new KnxAreaLine { Area = 0, Line = 0 }; - KnxStatics.BusConnections.Clear(); - KnxStatics.BusConnections[routingAreaLine] = null; - - var bus = await TryConnectRouting(routingParameters, cancellationToken).ConfigureAwait(false); - if (bus is not null) - KnxStatics.BusConnections[routingAreaLine] = bus; - - await MonitorHealthAsync(cancellationToken).ConfigureAwait(false); - } - - /// - /// Periodically polls all bus connections and updates the . - /// - private async Task MonitorHealthAsync(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - var allConnected = KnxStatics.BusConnections.Values - .All(bus => bus?.ConnectionState == BusConnectionState.Connected); - knxConnectionHealthCheck.ConnectionActive = allConnected; - await Task.Delay(config.Value.ConnectionHealthPollingDelayMs, cancellationToken).ConfigureAwait(false); - } - } + await knxGroupAddressLookupSvc.WaitUntilReadyAsync(cancellationToken).ConfigureAwait(false); - /// - /// Reads reconnection requests from - /// and re-establishes the dropped connection, with exponential back-off. - /// - private async Task ProcessReconnectionRequestsAsync(CancellationToken cancellationToken) - { - await foreach (var areaLine in _reconnectChannel.Reader.ReadAllAsync(cancellationToken)) - { - LogReconnectionRequested(logger, nameof(KnxMonitorBgService), areaLine); + logger.LogInformation("{ClassName} ServiceFamily={ServiceFamily}, ShardingMode={ShardingMode}, TelegramBrokerMode={TelegramBrokerMode}", + nameof(KnxMonitorBgService), config.Value.ServiceFamily, config.Value.ShardingMode, config.Value.TelegramBrokerMode); - var backoffMs = config.Value.ReconnectBackoffMs; - var maxBackoffMs = config.Value.ReconnectMaxBackoffMs; - var connected = false; - while (!connected && !cancellationToken.IsCancellationRequested) - { - await Task.Delay(backoffMs, cancellationToken).ConfigureAwait(false); - connected = await ConnectLine(areaLine, cancellationToken).ConfigureAwait(false); - if (!connected) - { - LogReconnectionFailed(logger, nameof(KnxMonitorBgService), areaLine, backoffMs); - backoffMs = Math.Min(backoffMs * 2, maxBackoffMs); - } - } + var effectiveShardingMode = config.Value.ServiceFamily == ServiceFamily.Routing + ? ShardingMode.Unified + : config.Value.ShardingMode; + if (effectiveShardingMode != config.Value.ShardingMode) + logger.LogWarning("{ClassName} ShardingMode overridden to {EffectiveShardingMode} because ServiceFamily is {ServiceFamily}", + nameof(KnxMonitorBgService), effectiveShardingMode, config.Value.ServiceFamily); + if (effectiveShardingMode != ShardingMode.Unified) + throw new NotSupportedException( + $"{nameof(KnxConfig.ShardingMode)} '{effectiveShardingMode}' is not supported. Use {nameof(ShardingMode.Unified)}."); - if (connected) - { - LogReconnectionSucceeded(logger, nameof(KnxMonitorBgService), areaLine); - connectionNotifier.Notify(new KnxConnectionStateChange - { - AreaLine = areaLine, - Connected = true, - TimestampUtc = timeProvider.GetUtcNow().UtcDateTime, - }); - } - } + await leadershipRunner.RunAsync("knx:monitor", RunOwnedAsync, cancellationToken).ConfigureAwait(false); } - /// - /// Attempts to connect to a single area/line using the stored discovery parameters. - /// Disposes any existing bus connection and unwires event handlers before reconnecting. - /// - /// if a connection was established; otherwise . - private async Task ConnectLine(KnxAreaLine areaLine, CancellationToken cancellationToken) + private async Task RunOwnedAsync(CancellationToken cancellationToken) { - if (!_lineParameters.TryGetValue(areaLine, out var parametersList)) - { - LogNoStoredParameters(logger, nameof(KnxMonitorBgService), areaLine); - return false; - } + await using var monitor = monitorFactory(); + await monitor.ConnectAsync(cancellationToken).ConfigureAwait(false); - LogConnectingToAreaLine(logger, nameof(KnxMonitorBgService), areaLine); - - //dispose the old bus connection if one exists - if (KnxStatics.BusConnections.TryGetValue(areaLine, out var oldBus) && oldBus is not null) - { - _connectedAddresses.TryRemove(oldBus.InterfaceConfiguration.IndividualAddress.ToString(), out _); - DisconnectBus(oldBus); - KnxStatics.BusConnections[areaLine] = null; - } - - //iterate over the individual addresses and attempt a connection - foreach (var parameters in parametersList) - { - var bus = await TryConnect(parameters, cancellationToken).ConfigureAwait(false); - if (bus is not null) + Func[] workers = [monitor.RunAsync, knxSenderBgSvc.ExecuteAsync]; + await Parallel.ForEachAsync(workers, + new ParallelOptions { MaxDegreeOfParallelism = workers.Length, CancellationToken = cancellationToken }, + async (worker, token) => { - KnxStatics.BusConnections[areaLine] = bus; - _connectedAddresses[parameters.IndividualAddress!.Value.ToString()] = 0; - return true; - } - } - - return false; + await worker(token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + throw new InvalidOperationException("A KNX ownership worker stopped unexpectedly."); + }).ConfigureAwait(false); } - - /// - /// Attempts a single connection to the given tunneling connector parameters. - /// - private async Task TryConnect(IpTunnelingConnectorParameters parameters, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - return null; - var bus = new KnxBus(parameters); - logger.LogInformation("{ClassName} connecting to {Name} {HostAddress} ({IndividualAddress})...", - nameof(KnxMonitorBgService), parameters.Name, parameters.HostAddress, parameters.IndividualAddress); - try - { - await bus.ConnectAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogWarning("{ClassName} connection to {Name} {HostAddress} ({IndividualAddress}) failed, {Reason}", - nameof(KnxMonitorBgService), parameters.Name, parameters.HostAddress, parameters.IndividualAddress, - ex.InnerException?.Message ?? ex.Message); - await bus.DisposeAsync().ConfigureAwait(false); - return null; - } - - if (bus.ConnectionState != BusConnectionState.Connected) - { - await bus.DisposeAsync().ConfigureAwait(false); - return null; - } - - logger.LogInformation("{ClassName} connection to {Name} {HostAddress} ({IndividualAddress}) succeeded!", - nameof(KnxMonitorBgService), parameters.Name, parameters.HostAddress, parameters.IndividualAddress); - - //wire-up event handlers - bus.InterfaceConfigurationChanged += Bus_InterfaceConfigurationChanged; - bus.GroupMessageReceived += Bus_GroupMessageReceived; - bus.ConnectionStateChanged += Bus_ConnectionStateChanged; - - LogInterfaceFeatures(logger, nameof(KnxMonitorBgService), bus.InterfaceFeatures?.ToString() ?? string.Empty); - LogInterfaceConfiguration(logger, nameof(KnxMonitorBgService), bus.InterfaceConfiguration?.ToString() ?? string.Empty); - - return bus; - } - - /// - /// Unwires event handlers and disposes a instance. - /// - private void DisconnectBus(KnxBus bus) - { - bus.InterfaceConfigurationChanged -= Bus_InterfaceConfigurationChanged; - bus.GroupMessageReceived -= Bus_GroupMessageReceived; - bus.ConnectionStateChanged -= Bus_ConnectionStateChanged; - try - { - bus.Dispose(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "{ClassName} error disposing bus {IndividualAddress}", - nameof(KnxMonitorBgService), bus.InterfaceConfiguration.IndividualAddress); - } - } - - private async Task> GetIpTunnelingConnectorParameters(CancellationToken cancellationToken) - { - var attempt = 1; - while (!cancellationToken.IsCancellationRequested) - { - logger.LogDebug("{ClassName} start discovery of KNX IP devices...", nameof(KnxMonitorBgService)); - var ipDeviceDiscoveryResults = await KnxBus.DiscoverIpDevicesAsync(cancellationToken) - .Where(p => p.Supports(config.Value.ServiceFamily, 1), cancellationToken) - .ToArray(cancellationToken).ConfigureAwait(false); - - if (ipDeviceDiscoveryResults.Length == 0) - { - logger.Log(attempt % 10 == 0 ? LogLevel.Warning : LogLevel.Debug, - "{ClassName} no KNX IP devices detected, attempt '{Attempt}', retry in {RetryMs}ms...", - nameof(KnxMonitorBgService), attempt, config.Value.DiscoveryRetryDelayMs); - await Task.Delay(config.Value.DiscoveryRetryDelayMs, cancellationToken).ConfigureAwait(false); - attempt++; - continue; - } - - var index = 1; - var allowedParameters = new List(); - var allParameters = ipDeviceDiscoveryResults - .SelectMany(ipDevice => ipDevice.GetTunnelingConnections()) - .Where(p => p.IndividualAddress.HasValue) - .OrderBy(p => p.IndividualAddress); - foreach (var parameters in allParameters) - { - var displayName = $"{parameters.Name} {parameters.HostAddress} ({parameters.IndividualAddress!.Value})"; - var allowed = config.Value.TunnelingAreaLineFilter.Any(p => parameters.IndividualAddress.Value.ToString().StartsWith($"{p}.")); - if (allowed) - allowedParameters.Add(parameters); - logger.LogInformation("{ClassName} {Index}: {DisplayName} {Skipped}", nameof(KnxMonitorBgService), index++, - displayName, allowed ? string.Empty : " (Skipped)"); - } - return allowedParameters; - } - throw new OperationCanceledException(cancellationToken); - } - - private async Task GetIpRoutingConnectorParameters(CancellationToken cancellationToken) - { - var attempt = 1; - while (!cancellationToken.IsCancellationRequested) - { - logger.LogDebug("{ClassName} start discovery of KNX IP routing devices...", nameof(KnxMonitorBgService)); - var ipDeviceDiscoveryResults = await KnxBus.DiscoverIpDevicesAsync(cancellationToken) - .Where(p => p.Supports(config.Value.ServiceFamily, 1), cancellationToken) - .ToArray(cancellationToken).ConfigureAwait(false); - var routingDevice = ipDeviceDiscoveryResults.FirstOrDefault(); - if (routingDevice is not null) - { - var parameters = new IpRoutingConnectorParameters(routingDevice.MulticastAddress) - { - LocalIPAddress = routingDevice.LocalIPAddress - }; - logger.LogInformation("{ClassName} routing device found: {FriendlyName} multicast={MulticastAddress}", - nameof(KnxMonitorBgService), routingDevice.FriendlyName, routingDevice.MulticastAddress); - return parameters; - } - - logger.Log(attempt % 10 == 0 ? LogLevel.Warning : LogLevel.Debug, - "{ClassName} no KNX IP routing devices detected, attempt {Attempt}, retry in {RetryMs}ms...", - nameof(KnxMonitorBgService), attempt, config.Value.DiscoveryRetryDelayMs); - await Task.Delay(config.Value.DiscoveryRetryDelayMs, cancellationToken).ConfigureAwait(false); - attempt++; - } - throw new OperationCanceledException(cancellationToken); - } - - private async Task TryConnectRouting(IpRoutingConnectorParameters parameters, CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - return null; - var bus = new KnxBus(parameters); - logger.LogInformation("{ClassName} connecting to routing multicast {MulticastAddress}...", - nameof(KnxMonitorBgService), parameters.MulticastAddress); - try - { - await bus.ConnectAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogWarning("{ClassName} routing connection to {MulticastAddress} failed, {Reason}", - nameof(KnxMonitorBgService), parameters.MulticastAddress, - ex.InnerException?.Message ?? ex.Message); - await bus.DisposeAsync().ConfigureAwait(false); - return null; - } - - if (bus.ConnectionState != BusConnectionState.Connected) - { - await bus.DisposeAsync().ConfigureAwait(false); - return null; - } - - logger.LogInformation("{ClassName} routing connection to {MulticastAddress} succeeded!", - nameof(KnxMonitorBgService), parameters.MulticastAddress); - - bus.InterfaceConfigurationChanged += Bus_InterfaceConfigurationChanged; - bus.GroupMessageReceived += Bus_GroupMessageReceived; - bus.ConnectionStateChanged += Bus_ConnectionStateChanged; - - return bus; - } - - /// - /// Fires when a message is received on the bus pertinent to a group address. - /// - private async void Bus_GroupMessageReceived(object? sender, GroupEventArgs e) - { - if (sender is null) return; - if (e.Value is null) return;//occurs when we download to devices - - var bus = (KnxBus)sender; - var busAddr = bus.InterfaceConfiguration.IndividualAddress; - - //tunneling-specific filters — routing receives all backbone traffic so these don't apply - if (config.Value.ServiceFamily == ServiceFamily.Tunneling) - { - //only process telegrams whose source area/line matches this bus connection's area/line; - //without this, routed telegrams are received by every connection and published multiple times - if (e.SourceAddress.AreaAddress != busAddr.AreaAddress || e.SourceAddress.LineAddress != busAddr.LineAddress) - { - LogDroppedCrossLine(logger, nameof(KnxMonitorBgService), e.SourceAddress, e.DestinationAddress, busAddr); - return; - } - - //drop telegrams from rival tunneling connections (e.g. another deployment sharing the same interfaces) - var sourceAddress = e.SourceAddress.ToString(); - if (_allDiscoveredAddresses.Contains(sourceAddress) && !_connectedAddresses.ContainsKey(sourceAddress)) - { - LogDroppedRivalAddress(logger, nameof(KnxMonitorBgService), sourceAddress, e.DestinationAddress, busAddr); - return; - } - } - - var groupAddress = e.DestinationAddress.ToString();//i.e. 1/2/3 - var kga = knxGroupAddressLookupSvc.GetKGAByAddress(groupAddress); - if (kga is null) return; - var tpl = e.Value.DecodeValue(kga, logger); - if (tpl.ValueDecoded is not null) - { - var knxEvent = new KnxEvent(timeProvider.GetUtcNow().UtcDateTime, e.ToKnxGroupEvent(), kga, e.Value, tpl.ValueDecoded, tpl.ValueLabelDecoded); - await incomingBroker.PublishAsync(knxEvent).ConfigureAwait(false); - } - } - - private void Bus_ConnectionStateChanged(object? sender, EventArgs e) - { - if (sender is null) return; - var bus = (KnxBus)sender; - LogConnectionStateChanged(logger, nameof(KnxMonitorBgService), bus.ConnectionState.ToString()); - - if (bus.ConnectionState is BusConnectionState.Connected) - return; - - //find which area/line this bus belongs to and request reconnection - var areaLine = KnxStatics.BusConnections - .FirstOrDefault(kvp => ReferenceEquals(kvp.Value, bus)).Key; - if (areaLine is not null) - { - LogConnectionDropped(logger, nameof(KnxMonitorBgService), areaLine); - connectionNotifier.Notify(new KnxConnectionStateChange - { - AreaLine = areaLine, - Connected = false, - TimestampUtc = timeProvider.GetUtcNow().UtcDateTime, - }); - _reconnectChannel.Writer.TryWrite(areaLine); - } - } - - private void Bus_InterfaceConfigurationChanged(object? sender, EventArgs e) - => LogInterfaceConfigurationChanged(logger, nameof(KnxMonitorBgService)); - - [LoggerMessage(Level = LogLevel.Trace, Message = "{ClassName} InterfaceFeatures {InterfaceFeatures}")] - private static partial void LogInterfaceFeatures(ILogger logger, string className, string interfaceFeatures); - - [LoggerMessage(Level = LogLevel.Trace, Message = "{ClassName} InterfaceConfiguration {InterfaceConfiguration}")] - private static partial void LogInterfaceConfiguration(ILogger logger, string className, string interfaceConfiguration); - - [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} ConnectionStateChanged, ConnectionState={ConnectionState}")] - private static partial void LogConnectionStateChanged(ILogger logger, string className, string connectionState); - - [LoggerMessage(Level = LogLevel.Debug, Message = "{ClassName} dropped telegram [cross-line] from {SourceAddress} to {DestinationAddress} on {BusAddress}")] - private static partial void LogDroppedCrossLine(ILogger logger, string className, IndividualAddress sourceAddress, GroupAddress destinationAddress, IndividualAddress busAddress); - - [LoggerMessage(Level = LogLevel.Debug, Message = "{ClassName} dropped telegram [rival tunneling address] from {SourceAddress} to {DestinationAddress} on {BusAddress}")] - private static partial void LogDroppedRivalAddress(ILogger logger, string className, string sourceAddress, GroupAddress destinationAddress, IndividualAddress busAddress); - - [LoggerMessage(Level = LogLevel.Warning, Message = "{ClassName} reconnection requested for {AreaLine}")] - private static partial void LogReconnectionRequested(ILogger logger, string className, KnxAreaLine areaLine); - - [LoggerMessage(Level = LogLevel.Warning, Message = "{ClassName} reconnection to {AreaLine} failed, retrying in {BackoffMs}ms")] - private static partial void LogReconnectionFailed(ILogger logger, string className, KnxAreaLine areaLine, int backoffMs); - - [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} reconnection to {AreaLine} succeeded")] - private static partial void LogReconnectionSucceeded(ILogger logger, string className, KnxAreaLine areaLine); - - [LoggerMessage(Level = LogLevel.Error, Message = "{ClassName} no stored discovery parameters for {AreaLine}")] - private static partial void LogNoStoredParameters(ILogger logger, string className, KnxAreaLine areaLine); - - [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} connecting to {AreaLine}")] - private static partial void LogConnectingToAreaLine(ILogger logger, string className, KnxAreaLine areaLine); - - [LoggerMessage(Level = LogLevel.Warning, Message = "{ClassName} connection dropped for {AreaLine}, queuing reconnection")] - private static partial void LogConnectionDropped(ILogger logger, string className, KnxAreaLine areaLine); - - [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} distributed lock acquired on attempt {Attempt}")] - private static partial void LogLockAcquired(ILogger logger, string className, int attempt); - - [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} distributed lock released")] - private static partial void LogLockReleased(ILogger logger, string className); - - [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} distributed lock failed on attempt {Attempt}")] - private static partial void LogLockFailed(ILogger logger, string className, int attempt); - - [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} distributed lock pending attempt {Attempt}")] - private static partial void LogLockPending(ILogger logger, string className, int attempt); - - [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} InterfaceConfigurationChanged")] - private static partial void LogInterfaceConfigurationChanged(ILogger logger, string className); } diff --git a/src/CasCap.Api.Knx/Services/KnxMonitorService.cs b/src/CasCap.Api.Knx/Services/KnxMonitorService.cs new file mode 100644 index 0000000..4e4e1a5 --- /dev/null +++ b/src/CasCap.Api.Knx/Services/KnxMonitorService.cs @@ -0,0 +1,610 @@ +using Knx.Falcon; +using Knx.Falcon.Configuration; +using Knx.Falcon.KnxnetIp; +using Knx.Falcon.Sdk; + +namespace CasCap.Services; + +/// +/// Owns bus connections, reconnection requests and incoming telegrams for one leadership run. +/// +public sealed partial class KnxMonitorService( + ILogger logger, + IOptions config, + TimeProvider timeProvider, + KnxGroupAddressLookupService knxGroupAddressLookupSvc, + KnxConnectionHealthCheck knxConnectionHealthCheck, + IKnxTelegramBroker incomingBroker, + KnxConnectionNotifier connectionNotifier) : IKnxMonitor +{ + private readonly object _callbackGate = new(); + private readonly ConcurrentDictionary _ownedBuses = new(); + // TODO: Bound callback ingress once measured bursts and an explicit overflow/recovery policy + // allow it. Falcon's synchronous events cannot await backpressure; silent drops are unsafe. + private readonly Channel _telegrams = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true }); + private CancellationToken _ownershipToken; + private CancellationTokenRegistration _ownershipRegistration; + private IpRoutingConnectorParameters? _routingParameters; + private volatile bool _stopping; + /// + /// Discovery parameters per area/line, retained for reconnection without re-discovery. + /// + private readonly Dictionary> _lineParameters = []; + + /// + /// Channel used by to signal that a line needs reconnection. + /// + private readonly Channel _reconnectChannel = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true }); + + /// + /// All discovered tunneling interface individual addresses (including slots occupied by other deployments). + /// Telegrams whose source is in this set but not in are dropped. + /// + private readonly HashSet _allDiscoveredAddresses = []; + + /// + /// Individual addresses of our active tunneling connections (e.g. "1.1.2"). + /// Thread-safe because it is mutated on the connection management path and read from event handler threads. + /// + private readonly ConcurrentDictionary _connectedAddresses = new(); + + /// + public async Task ConnectAsync(CancellationToken cancellationToken) + { + _ownershipToken = cancellationToken; + knxConnectionHealthCheck.IsLeader = true; + _ownershipRegistration = cancellationToken.Register(WithdrawOwnership); + cancellationToken.ThrowIfCancellationRequested(); + switch (config.Value.ServiceFamily) + { + case ServiceFamily.Tunneling: + await ConnectTunnelingAsync(cancellationToken).ConfigureAwait(false); + break; + case ServiceFamily.Routing: + await ConnectRoutingAsync(cancellationToken).ConfigureAwait(false); + break; + default: + throw new NotSupportedException( + $"{nameof(KnxConfig.ServiceFamily)} '{config.Value.ServiceFamily}' is not supported. " + + $"Use {nameof(ServiceFamily.Tunneling)} or {nameof(ServiceFamily.Routing)}."); + } + } + + /// + public async Task RunAsync(CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register(WithdrawOwnership); + Func[] workers = + [MonitorHealthAsync, ProcessReconnectionRequestsAsync, PublishTelegramsAsync]; + try + { + await Parallel.ForEachAsync(workers, + new ParallelOptions { MaxDegreeOfParallelism = workers.Length, CancellationToken = cancellationToken }, + async (worker, token) => + { + await worker(token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + throw new InvalidOperationException("A KNX connection worker stopped unexpectedly."); + }).ConfigureAwait(false); + } + finally + { + WithdrawOwnership(); + } + } + + private void WithdrawOwnership() + { + lock (_callbackGate) + { + _stopping = true; + knxConnectionHealthCheck.ConnectionActive = false; + knxConnectionHealthCheck.IsLeader = false; + } + } + + /// + public async ValueTask DisposeAsync() + { + WithdrawOwnership(); + await _ownershipRegistration.DisposeAsync().ConfigureAwait(false); + _telegrams.Writer.TryComplete(); + _reconnectChannel.Writer.TryComplete(); + KnxStatics.BusConnections.Clear(); + List? failures = null; + foreach (var bus in _ownedBuses.Keys) + try + { + await DisconnectBusAsync(bus).ConfigureAwait(false); + } + catch (Exception ex) + { + (failures ??= []).Add(ex); + } + _lineParameters.Clear(); + _allDiscoveredAddresses.Clear(); + _connectedAddresses.Clear(); + if (failures is not null) + throw new AggregateException("KNX connection cleanup failed.", failures); + } + + private async Task PublishTelegramsAsync(CancellationToken cancellationToken) + { + await foreach (var telegram in _telegrams.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + await incomingBroker.PublishAsync(telegram, cancellationToken).ConfigureAwait(false); + } + } + + /// Buffers a decoded telegram only while this ownership run accepts callbacks. + internal bool TryQueueTelegram(KnxEvent telegram) + { + lock (_callbackGate) + return !_stopping && !_ownershipToken.IsCancellationRequested && _telegrams.Writer.TryWrite(telegram); + } + + private async Task ConnectTunnelingAsync(CancellationToken cancellationToken) + { + //gather information on possible Areas+Lines + var ipTunnelingConnectorParametersAll = await GetIpTunnelingConnectorParameters(cancellationToken).ConfigureAwait(false); + + //group discovered parameters by area/line and store for reconnection + _lineParameters.Clear(); + foreach (var parameters in ipTunnelingConnectorParametersAll) + { + var areaLine = new KnxAreaLine + { + Area = parameters.IndividualAddress!.Value.AreaAddress, + Line = parameters.IndividualAddress.Value.LineAddress + }; + if (!_lineParameters.TryGetValue(areaLine, out var list)) + { + list = []; + _lineParameters[areaLine] = list; + } + list.Add(parameters); + } + + //compute the set of all discovered tunneling addresses for source filtering + _allDiscoveredAddresses.Clear(); + _connectedAddresses.Clear(); + foreach (var parameters in _lineParameters.Values.SelectMany(list => list)) + if (parameters.IndividualAddress.HasValue) + _allDiscoveredAddresses.Add(parameters.IndividualAddress.Value.ToString()); + + //populate BusConnections with null entries for each discovered area/line + KnxStatics.BusConnections.Clear(); + foreach (var areaLine in _lineParameters.Keys) + KnxStatics.BusConnections[areaLine] = null; + + //attempt initial connection to each area/line + foreach (var areaLine in _lineParameters.Keys) + if (!await ConnectLine(areaLine, cancellationToken).ConfigureAwait(false)) + _reconnectChannel.Writer.TryWrite(areaLine); + } + + private async Task ConnectRoutingAsync(CancellationToken cancellationToken) + { + _routingParameters = await GetIpRoutingConnectorParameters(cancellationToken).ConfigureAwait(false); + + //routing uses a single multicast connection covering the entire KNX backbone + var routingAreaLine = new KnxAreaLine { Area = 0, Line = 0 }; + KnxStatics.BusConnections.Clear(); + KnxStatics.BusConnections[routingAreaLine] = null; + + var bus = await TryConnectRouting(_routingParameters, cancellationToken).ConfigureAwait(false); + if (bus is not null) + KnxStatics.BusConnections[routingAreaLine] = bus; + + else + _reconnectChannel.Writer.TryWrite(routingAreaLine); + } + + /// + /// Periodically polls all bus connections and updates the . + /// + private async Task MonitorHealthAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var allConnected = !KnxStatics.BusConnections.IsEmpty && KnxStatics.BusConnections.Values + .All(bus => bus?.ConnectionState == BusConnectionState.Connected); + lock (_callbackGate) + knxConnectionHealthCheck.ConnectionActive = !_stopping && allConnected; + await Task.Delay(TimeSpan.FromMilliseconds(config.Value.ConnectionHealthPollingDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Reads reconnection requests from + /// and re-establishes the dropped connection, with exponential back-off. + /// + private async Task ProcessReconnectionRequestsAsync(CancellationToken cancellationToken) + { + await foreach (var areaLine in _reconnectChannel.Reader.ReadAllAsync(cancellationToken)) + { + LogReconnectionRequested(logger, nameof(KnxMonitorService), areaLine); + + var backoffMs = config.Value.ReconnectBackoffMs; + var maxBackoffMs = config.Value.ReconnectMaxBackoffMs; + var connected = false; + while (!connected && !cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromMilliseconds(backoffMs), timeProvider, cancellationToken).ConfigureAwait(false); + connected = config.Value.ServiceFamily == ServiceFamily.Routing + ? await ReconnectRoutingAsync(areaLine, cancellationToken).ConfigureAwait(false) + : await ConnectLine(areaLine, cancellationToken).ConfigureAwait(false); + if (!connected) + { + LogReconnectionFailed(logger, nameof(KnxMonitorService), areaLine, backoffMs); + backoffMs = Math.Min(backoffMs * 2, maxBackoffMs); + } + } + + if (connected) + { + cancellationToken.ThrowIfCancellationRequested(); + LogReconnectionSucceeded(logger, nameof(KnxMonitorService), areaLine); + connectionNotifier.Notify(new KnxConnectionStateChange + { + AreaLine = areaLine, + Connected = true, + TimestampUtc = timeProvider.GetUtcNow().UtcDateTime, + }); + } + } + } + + /// + /// Attempts to connect to a single area/line using the stored discovery parameters. + /// Disposes any existing bus connection and unwires event handlers before reconnecting. + /// + /// if a connection was established; otherwise . + private async Task ConnectLine(KnxAreaLine areaLine, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_lineParameters.TryGetValue(areaLine, out var parametersList)) + { + LogNoStoredParameters(logger, nameof(KnxMonitorService), areaLine); + return false; + } + + LogConnectingToAreaLine(logger, nameof(KnxMonitorService), areaLine); + + //dispose the old bus connection if one exists + if (KnxStatics.BusConnections.TryGetValue(areaLine, out var oldBus) && oldBus is not null) + { + _connectedAddresses.TryRemove(oldBus.InterfaceConfiguration.IndividualAddress.ToString(), out _); + await DisconnectBusAsync(oldBus).ConfigureAwait(false); + KnxStatics.BusConnections[areaLine] = null; + } + + //iterate over the individual addresses and attempt a connection + foreach (var parameters in parametersList) + { + var bus = await TryConnect(parameters, cancellationToken).ConfigureAwait(false); + if (bus is not null) + { + KnxStatics.BusConnections[areaLine] = bus; + _connectedAddresses[parameters.IndividualAddress!.Value.ToString()] = 0; + return true; + } + } + + return false; + } + + private async Task ReconnectRoutingAsync(KnxAreaLine areaLine, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (KnxStatics.BusConnections.TryGetValue(areaLine, out var oldBus) && oldBus is not null) + { + await DisconnectBusAsync(oldBus).ConfigureAwait(false); + KnxStatics.BusConnections[areaLine] = null; + } + var bus = await TryConnectRouting(_routingParameters!, cancellationToken).ConfigureAwait(false); + KnxStatics.BusConnections[areaLine] = bus; + return bus is not null; + } + + /// + /// Attempts a single connection to the given tunneling connector parameters. + /// + private async Task TryConnect(IpTunnelingConnectorParameters parameters, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var bus = new KnxBus(parameters); + logger.LogInformation("{ClassName} connecting to {Name} {HostAddress} ({IndividualAddress})...", + nameof(KnxMonitorService), parameters.Name, parameters.HostAddress, parameters.IndividualAddress); + try + { + await bus.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await bus.DisposeAsync().ConfigureAwait(false); + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "{ClassName} tunneling connection failed", nameof(KnxMonitorService)); + await bus.DisposeAsync().ConfigureAwait(false); + return null; + } + + if (cancellationToken.IsCancellationRequested || bus.ConnectionState != BusConnectionState.Connected) + { + await bus.DisposeAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return null; + } + + logger.LogInformation("{ClassName} connection to {Name} {HostAddress} ({IndividualAddress}) succeeded!", + nameof(KnxMonitorService), parameters.Name, parameters.HostAddress, parameters.IndividualAddress); + + _ownedBuses.TryAdd(bus, 0); + //wire-up event handlers + bus.InterfaceConfigurationChanged += Bus_InterfaceConfigurationChanged; + bus.GroupMessageReceived += Bus_GroupMessageReceived; + bus.ConnectionStateChanged += Bus_ConnectionStateChanged; + + LogInterfaceFeatures(logger, nameof(KnxMonitorService), bus.InterfaceFeatures?.ToString() ?? string.Empty); + LogInterfaceConfiguration(logger, nameof(KnxMonitorService), bus.InterfaceConfiguration?.ToString() ?? string.Empty); + + return bus; + } + + /// + /// Unwires event handlers and disposes a instance. + /// + private async ValueTask DisconnectBusAsync(KnxBus bus) + { + _ownedBuses.TryRemove(bus, out _); + bus.InterfaceConfigurationChanged -= Bus_InterfaceConfigurationChanged; + bus.GroupMessageReceived -= Bus_GroupMessageReceived; + bus.ConnectionStateChanged -= Bus_ConnectionStateChanged; + await bus.DisposeAsync().ConfigureAwait(false); + } + + private async Task> GetIpTunnelingConnectorParameters(CancellationToken cancellationToken) + { + var attempt = 1; + while (!cancellationToken.IsCancellationRequested) + { + logger.LogDebug("{ClassName} start discovery of KNX IP devices...", nameof(KnxMonitorService)); + var ipDeviceDiscoveryResults = await KnxBus.DiscoverIpDevicesAsync(cancellationToken) + .Where(p => p.Supports(config.Value.ServiceFamily, 1), cancellationToken) + .ToArray(cancellationToken).ConfigureAwait(false); + + if (ipDeviceDiscoveryResults.Length == 0) + { + logger.Log(attempt % 10 == 0 ? LogLevel.Warning : LogLevel.Debug, + "{ClassName} no KNX IP devices detected, attempt '{Attempt}', retry in {RetryMs}ms...", + nameof(KnxMonitorService), attempt, config.Value.DiscoveryRetryDelayMs); + await Task.Delay(TimeSpan.FromMilliseconds(config.Value.DiscoveryRetryDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); + attempt++; + continue; + } + + var index = 1; + var allowedParameters = new List(); + var allParameters = ipDeviceDiscoveryResults + .SelectMany(ipDevice => ipDevice.GetTunnelingConnections()) + .Where(p => p.IndividualAddress.HasValue) + .OrderBy(p => p.IndividualAddress); + foreach (var parameters in allParameters) + { + var displayName = $"{parameters.Name} {parameters.HostAddress} ({parameters.IndividualAddress!.Value})"; + var allowed = config.Value.TunnelingAreaLineFilter.Any(p => parameters.IndividualAddress.Value.ToString().StartsWith($"{p}.")); + if (allowed) + allowedParameters.Add(parameters); + logger.LogInformation("{ClassName} {Index}: {DisplayName} {Skipped}", nameof(KnxMonitorService), index++, + displayName, allowed ? string.Empty : " (Skipped)"); + } + return allowedParameters; + } + throw new OperationCanceledException(cancellationToken); + } + + private async Task GetIpRoutingConnectorParameters(CancellationToken cancellationToken) + { + var attempt = 1; + while (!cancellationToken.IsCancellationRequested) + { + logger.LogDebug("{ClassName} start discovery of KNX IP routing devices...", nameof(KnxMonitorService)); + var ipDeviceDiscoveryResults = await KnxBus.DiscoverIpDevicesAsync(cancellationToken) + .Where(p => p.Supports(config.Value.ServiceFamily, 1), cancellationToken) + .ToArray(cancellationToken).ConfigureAwait(false); + var routingDevice = ipDeviceDiscoveryResults.FirstOrDefault(); + if (routingDevice is not null) + { + var parameters = new IpRoutingConnectorParameters(routingDevice.MulticastAddress) + { + LocalIPAddress = routingDevice.LocalIPAddress + }; + logger.LogInformation("{ClassName} routing device found: {FriendlyName} multicast={MulticastAddress}", + nameof(KnxMonitorService), routingDevice.FriendlyName, routingDevice.MulticastAddress); + return parameters; + } + + logger.Log(attempt % 10 == 0 ? LogLevel.Warning : LogLevel.Debug, + "{ClassName} no KNX IP routing devices detected, attempt {Attempt}, retry in {RetryMs}ms...", + nameof(KnxMonitorService), attempt, config.Value.DiscoveryRetryDelayMs); + await Task.Delay(TimeSpan.FromMilliseconds(config.Value.DiscoveryRetryDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); + attempt++; + } + throw new OperationCanceledException(cancellationToken); + } + + private async Task TryConnectRouting(IpRoutingConnectorParameters parameters, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var bus = new KnxBus(parameters); + logger.LogInformation("{ClassName} connecting to routing multicast {MulticastAddress}...", + nameof(KnxMonitorService), parameters.MulticastAddress); + try + { + await bus.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await bus.DisposeAsync().ConfigureAwait(false); + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "{ClassName} routing connection failed", nameof(KnxMonitorService)); + await bus.DisposeAsync().ConfigureAwait(false); + return null; + } + + if (cancellationToken.IsCancellationRequested || bus.ConnectionState != BusConnectionState.Connected) + { + await bus.DisposeAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return null; + } + + logger.LogInformation("{ClassName} routing connection to {MulticastAddress} succeeded!", + nameof(KnxMonitorService), parameters.MulticastAddress); + + _ownedBuses.TryAdd(bus, 0); + bus.InterfaceConfigurationChanged += Bus_InterfaceConfigurationChanged; + bus.GroupMessageReceived += Bus_GroupMessageReceived; + bus.ConnectionStateChanged += Bus_ConnectionStateChanged; + + return bus; + } + + /// + /// Fires when a message is received on the bus pertinent to a group address. + /// + private void Bus_GroupMessageReceived(object? sender, GroupEventArgs e) + { + lock (_callbackGate) + { + if (_stopping || _ownershipToken.IsCancellationRequested || sender is not KnxBus bus || !_ownedBuses.ContainsKey(bus)) + return; + try + { + QueueTelegram(bus, e); + } + catch (Exception ex) + { + // Falcon callbacks cannot be awaited; surface decoding faults through the owned reader. + _telegrams.Writer.TryComplete(ex); + } + } + } + + private void QueueTelegram(KnxBus bus, GroupEventArgs e) + { + if (e.Value is null) return;//occurs when we download to devices + + var busAddr = bus.InterfaceConfiguration.IndividualAddress; + + //tunneling-specific filters — routing receives all backbone traffic so these don't apply + if (config.Value.ServiceFamily == ServiceFamily.Tunneling) + { + //only process telegrams whose source area/line matches this bus connection's area/line; + //without this, routed telegrams are received by every connection and published multiple times + if (e.SourceAddress.AreaAddress != busAddr.AreaAddress || e.SourceAddress.LineAddress != busAddr.LineAddress) + { + LogDroppedCrossLine(logger, nameof(KnxMonitorService), e.SourceAddress, e.DestinationAddress, busAddr); + return; + } + + //drop telegrams from rival tunneling connections (e.g. another deployment sharing the same interfaces) + var sourceAddress = e.SourceAddress.ToString(); + if (_allDiscoveredAddresses.Contains(sourceAddress) && !_connectedAddresses.ContainsKey(sourceAddress)) + { + LogDroppedRivalAddress(logger, nameof(KnxMonitorService), sourceAddress, e.DestinationAddress, busAddr); + return; + } + } + + var groupAddress = e.DestinationAddress.ToString();//i.e. 1/2/3 + var kga = knxGroupAddressLookupSvc.GetKGAByAddress(groupAddress); + if (kga is null) return; + var tpl = e.Value.DecodeValue(kga, logger); + if (tpl.ValueDecoded is not null) + { + var knxEvent = new KnxEvent(timeProvider.GetUtcNow().UtcDateTime, e.ToKnxGroupEvent(), kga, e.Value, tpl.ValueDecoded, tpl.ValueLabelDecoded); + TryQueueTelegram(knxEvent); + } + } + + private void Bus_ConnectionStateChanged(object? sender, EventArgs e) + { + lock (_callbackGate) + HandleConnectionStateChanged(sender); + } + + private void HandleConnectionStateChanged(object? sender) + { + if (_stopping || _ownershipToken.IsCancellationRequested || sender is not KnxBus bus || !_ownedBuses.ContainsKey(bus)) + return; + LogConnectionStateChanged(logger, nameof(KnxMonitorService), bus.ConnectionState.ToString()); + + if (bus.ConnectionState is BusConnectionState.Connected) + return; + + //find which area/line this bus belongs to and request reconnection + var areaLine = KnxStatics.BusConnections + .FirstOrDefault(kvp => ReferenceEquals(kvp.Value, bus)).Key; + if (areaLine is not null) + { + knxConnectionHealthCheck.ConnectionActive = false; + LogConnectionDropped(logger, nameof(KnxMonitorService), areaLine); + connectionNotifier.Notify(new KnxConnectionStateChange + { + AreaLine = areaLine, + Connected = false, + TimestampUtc = timeProvider.GetUtcNow().UtcDateTime, + }); + _reconnectChannel.Writer.TryWrite(areaLine); + } + } + + private void Bus_InterfaceConfigurationChanged(object? sender, EventArgs e) + => LogInterfaceConfigurationChanged(logger, nameof(KnxMonitorService)); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{ClassName} InterfaceFeatures {InterfaceFeatures}")] + private static partial void LogInterfaceFeatures(ILogger logger, string className, string interfaceFeatures); + + [LoggerMessage(Level = LogLevel.Trace, Message = "{ClassName} InterfaceConfiguration {InterfaceConfiguration}")] + private static partial void LogInterfaceConfiguration(ILogger logger, string className, string interfaceConfiguration); + + [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} ConnectionStateChanged, ConnectionState={ConnectionState}")] + private static partial void LogConnectionStateChanged(ILogger logger, string className, string connectionState); + + [LoggerMessage(Level = LogLevel.Debug, Message = "{ClassName} dropped telegram [cross-line] from {SourceAddress} to {DestinationAddress} on {BusAddress}")] + private static partial void LogDroppedCrossLine(ILogger logger, string className, IndividualAddress sourceAddress, GroupAddress destinationAddress, IndividualAddress busAddress); + + [LoggerMessage(Level = LogLevel.Debug, Message = "{ClassName} dropped telegram [rival tunneling address] from {SourceAddress} to {DestinationAddress} on {BusAddress}")] + private static partial void LogDroppedRivalAddress(ILogger logger, string className, string sourceAddress, GroupAddress destinationAddress, IndividualAddress busAddress); + + [LoggerMessage(Level = LogLevel.Warning, Message = "{ClassName} reconnection requested for {AreaLine}")] + private static partial void LogReconnectionRequested(ILogger logger, string className, KnxAreaLine areaLine); + + [LoggerMessage(Level = LogLevel.Warning, Message = "{ClassName} reconnection to {AreaLine} failed, retrying in {BackoffMs}ms")] + private static partial void LogReconnectionFailed(ILogger logger, string className, KnxAreaLine areaLine, int backoffMs); + + [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} reconnection to {AreaLine} succeeded")] + private static partial void LogReconnectionSucceeded(ILogger logger, string className, KnxAreaLine areaLine); + + [LoggerMessage(Level = LogLevel.Error, Message = "{ClassName} no stored discovery parameters for {AreaLine}")] + private static partial void LogNoStoredParameters(ILogger logger, string className, KnxAreaLine areaLine); + + [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} connecting to {AreaLine}")] + private static partial void LogConnectingToAreaLine(ILogger logger, string className, KnxAreaLine areaLine); + + [LoggerMessage(Level = LogLevel.Warning, Message = "{ClassName} connection dropped for {AreaLine}, queuing reconnection")] + private static partial void LogConnectionDropped(ILogger logger, string className, KnxAreaLine areaLine); + + [LoggerMessage(Level = LogLevel.Information, Message = "{ClassName} InterfaceConfigurationChanged")] + private static partial void LogInterfaceConfigurationChanged(ILogger logger, string className); +} diff --git a/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs b/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs index e699c76..e0a5f96 100644 --- a/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs +++ b/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs @@ -6,34 +6,25 @@ namespace CasCap.Services; /// This service handles sending outbound telegrams to the KNX bus, these outbound telegrams are /// dequeued from the for . /// -public sealed partial class KnxSenderBgService(ILogger logger, IOptions config, IKnxTelegramBroker outgoingBroker) : IBgFeature +public sealed partial class KnxSenderBgService( + ILogger logger, + IOptions config, + TimeProvider timeProvider, + KnxConnectionHealthCheck knxConnectionHealthCheck, + IKnxTelegramBroker outgoingBroker) { - /// - public string FeatureName => "Knx"; - - /// + /// Consumes and sends outbound telegrams only for the current bus ownership run. public async Task ExecuteAsync(CancellationToken cancellationToken) { - logger.LogInformation("{ClassName} starting", nameof(KnxSenderBgService)); - try - { - await RunServiceAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException and not TaskCanceledException) { throw; } - logger.LogInformation("{ClassName} exiting", nameof(KnxSenderBgService)); - } - - private Task RunServiceAsync(CancellationToken cancellationToken) => RunSender(cancellationToken); - - private async Task RunSender(CancellationToken cancellationToken) - { + await WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); await foreach (var msg in outgoingBroker.SubscribeAsync(cancellationToken)) { - if (cancellationToken.IsCancellationRequested) break; + await WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); var busAddresses = new List(KnxStatics.BusConnections.Count); foreach (var busConnection in KnxStatics.BusConnections) { + cancellationToken.ThrowIfCancellationRequested(); var bus = busConnection.Value; if (bus is null || bus.ConnectionState != BusConnectionState.Connected) { @@ -59,6 +50,14 @@ private async Task RunSender(CancellationToken cancellationToken) } } + private async Task WaitForConnectionAsync(CancellationToken cancellationToken) + { + while (!knxConnectionHealthCheck.ConnectionActive + && !KnxStatics.BusConnections.Values.Any(bus => bus?.ConnectionState == BusConnectionState.Connected)) + await Task.Delay(TimeSpan.FromMilliseconds(config.Value.ConnectionPollingDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + [LoggerMessage(Level = LogLevel.Error, Message = "{ClassName} unable to send to {GroupAddress} as bus connection {Bus} broken!")] private static partial void LogBusConnectionBroken(ILogger logger, string className, string groupAddress, string bus); diff --git a/src/CasCap.Api.Knx/Services/RedisKnxTelegramBroker{T}.cs b/src/CasCap.Api.Knx/Services/RedisKnxTelegramBroker{T}.cs index 133af92..ba03515 100644 --- a/src/CasCap.Api.Knx/Services/RedisKnxTelegramBroker{T}.cs +++ b/src/CasCap.Api.Knx/Services/RedisKnxTelegramBroker{T}.cs @@ -9,7 +9,8 @@ namespace CasCap.Services; /// /// extensions and published to a date-partitioned Redis stream. Each day gets its own /// stream key ({baseKey}:{yyMMdd}) with a configurable TTL so old streams are -/// automatically evicted. Consumers read via a consumer group ensuring at-least-once delivery. +/// automatically evicted. Consumers acknowledge messages after processing; pending-message +/// recovery is not implemented, so this does not guarantee at-least-once delivery across failures. /// /// The telegram type being transported. public sealed partial class RedisKnxTelegramBroker( @@ -34,19 +35,21 @@ int streamExpiryDays /// public async ValueTask PublishAsync(T item, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var streamKey = $"{baseStreamKey}:{timeProvider.GetUtcNow().UtcDateTime:yyMMdd}"; var json = item.ToJson(); await _db.StreamAddAsync(streamKey, [new NameValueEntry("data", json)]).ConfigureAwait(false); - await _db.KeyExpireAsync(streamKey, TimeSpan.FromDays(streamExpiryDays), flags: CommandFlags.FireAndForget).ConfigureAwait(false); + await _db.KeyExpireAsync(streamKey, TimeSpan.FromDays(streamExpiryDays)).ConfigureAwait(false); LogPublished(logger, typeof(T).Name, streamKey); } /// public async IAsyncEnumerable SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var currentDate = timeProvider.GetUtcNow().UtcDateTime.Date; var streamKey = $"{baseStreamKey}:{currentDate:yyMMdd}"; - await EnsureConsumerGroupAsync(streamKey).ConfigureAwait(false); + await EnsureConsumerGroupAsync(streamKey, cancellationToken).ConfigureAwait(false); while (!cancellationToken.IsCancellationRequested) { @@ -60,29 +63,31 @@ public async IAsyncEnumerable SubscribeAsync([EnumeratorCancellation] Cancell $"{baseStreamKey}:{nowDate:yyMMdd}"); currentDate = nowDate; streamKey = $"{baseStreamKey}:{currentDate:yyMMdd}"; - await EnsureConsumerGroupAsync(streamKey).ConfigureAwait(false); + await EnsureConsumerGroupAsync(streamKey, cancellationToken).ConfigureAwait(false); } StreamEntry[] entries; try { + cancellationToken.ThrowIfCancellationRequested(); entries = await _db.StreamReadGroupAsync(streamKey, consumerGroup, _consumerName, readPosition, count: readCount).ConfigureAwait(false); } catch (RedisServerException ex) when (ex.Message.Contains("NOGROUP")) { _groupCreatedKeys.Remove(streamKey); - await EnsureConsumerGroupAsync(streamKey).ConfigureAwait(false); + await EnsureConsumerGroupAsync(streamKey, cancellationToken).ConfigureAwait(false); continue; } if (entries.Length == 0) { - await Task.Delay(pollingDelayMs, cancellationToken).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromMilliseconds(pollingDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); continue; } foreach (var entry in entries) { + cancellationToken.ThrowIfCancellationRequested(); var dataField = entry.Values.FirstOrDefault(v => v.Name == "data"); if (dataField.Value.IsNullOrEmpty) continue; @@ -101,6 +106,8 @@ public async IAsyncEnumerable SubscribeAsync([EnumeratorCancellation] Cancell if (item is not null) { + // TODO: Add pending-entry recovery together with command idempotency before + // promising failover replay. Blindly reclaiming an accepted write can repeat it. yield return item; await _db.StreamAcknowledgeAsync(streamKey, consumerGroup, entry.Id).ConfigureAwait(false); } @@ -113,8 +120,9 @@ public async IAsyncEnumerable SubscribeAsync([EnumeratorCancellation] Cancell /// /// Ensures the consumer group exists on the given stream key, creating it if necessary. /// - private async Task EnsureConsumerGroupAsync(string streamKey) + private async Task EnsureConsumerGroupAsync(string streamKey, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); if (!_groupCreatedKeys.Add(streamKey)) return; try { From 508b37765989ed65edb5c2e8f2b6945de78b5e62 Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 12:59:00 +0200 Subject: [PATCH 06/18] refactor(ddns): bind updates to shared leadership - Delegate lease ownership to the shared runner - Preserve cancellation through IP discovery and update work - Cover delegation and cancellation without infrastructure --- .../Extensions/ServiceCollectionExtensions.cs | 3 + src/CasCap.Api.DDns/GlobalUsings.cs | 1 - src/CasCap.Api.DDns/README.md | 30 ++- src/CasCap.Api.DDns/Services/DDnsBgService.cs | 194 +----------------- .../Services/DDnsFindMyIpClientService.cs | 2 +- .../Services/DDnsUpdateService.cs | 165 +++++++++++++++ .../CasCap.App.Server.Tests.csproj | 1 + src/CasCap.App.Server.Tests/README.md | 7 +- .../Tests/Unit/DDnsLeadershipTests.cs | 119 +++++++++++ 9 files changed, 325 insertions(+), 197 deletions(-) create mode 100644 src/CasCap.Api.DDns/Services/DDnsUpdateService.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Unit/DDnsLeadershipTests.cs diff --git a/src/CasCap.Api.DDns/Extensions/ServiceCollectionExtensions.cs b/src/CasCap.Api.DDns/Extensions/ServiceCollectionExtensions.cs index aa67015..41d2e91 100644 --- a/src/CasCap.Api.DDns/Extensions/ServiceCollectionExtensions.cs +++ b/src/CasCap.Api.DDns/Extensions/ServiceCollectionExtensions.cs @@ -37,6 +37,9 @@ public static void AddDDns(this IServiceCollection services, IConfiguration conf services.AddSingleton(sp => sp.GetRequiredService()); if (!lite) + { + services.AddSingleton(); services.AddSingleton(); + } } } diff --git a/src/CasCap.Api.DDns/GlobalUsings.cs b/src/CasCap.Api.DDns/GlobalUsings.cs index 5c5d122..c74c80e 100644 --- a/src/CasCap.Api.DDns/GlobalUsings.cs +++ b/src/CasCap.Api.DDns/GlobalUsings.cs @@ -15,7 +15,6 @@ global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; -global using RedLockNet; global using System.ComponentModel.DataAnnotations; global using System.Net; global using System.Text.Json.Serialization; diff --git a/src/CasCap.Api.DDns/README.md b/src/CasCap.Api.DDns/README.md index fa53115..c0baed3 100644 --- a/src/CasCap.Api.DDns/README.md +++ b/src/CasCap.Api.DDns/README.md @@ -13,11 +13,16 @@ Custom hosts register it with `services.AddDDns(configuration)` from `Microsoft.Extensions.DependencyInjection`, and start its [`IBgFeature`](https://github.com/f2calv/CasCap.Common/blob/main/src/CasCap.Common.Abstractions/Abstractions/IBgFeature.cs) worker through the shared feature runner. +The current leadership API is provisional: build in Debug against the sibling shared-library +checkout. Its new runner is not yet available in the published caching package, so Release builds +must wait for the coordinated package release rather than substituting an invented package version. + ## Services | Service | Description | | --- | --- | -| `DDnsBgService` | Polls the public IP and updates the configured Azure DNS zone records when it changes (uses RedLock for distributed locking) | +| `DDnsBgService` | Delegates ownership to the shared `ILeadershipRunner` using the existing logical resource | +| `DDnsUpdateService` | Polls the public IP and updates matching Azure DNS A records within one ownership run | | `DDnsFindMyIpClientService` | HTTP client for discovering the current external IP address via ipify | | `DDnsQueryService` | Read-only query service exposing current external IP via the controller | @@ -35,6 +40,25 @@ feature runner. | --- | --- | | `AddDDns` | Registers all Dynamic DNS services, HTTP client, and background worker | +## Leadership Lifecycle + +DDNS uses the caching registration's existing Redis factory and +`CachingConfig.Redlock.Profiles.LeaderElection` timings. There is no separate DDNS lock configuration. +`Redlock.LeadershipCheckIntervalMs` defaults to one second; it observes ownership, not renewal. + +Only the acquired owner runs the update service. Observed loss cancels HTTP discovery, Azure +enumeration and writes through the ownership token, and the shared runner awaits completion before +releasing or reacquiring. Every new run clears its subscription/IP shortcut state to revalidate DNS. +The feature host also awaits worker cleanup during shutdown. + +This is coordination, not downstream fencing: a lease check cannot make an Azure DNS write atomic +with ownership. Polling and cancellation have a detection/response delay. Align host shutdown and +container termination budgets; the runner does not abandon cleanup after a timeout. + +`AddDDns(..., lite: true)` registers read-only query/client services without the update worker or a +leadership dependency. Focused credential-free tests cover delegation and HTTP cancellation; they do +not execute Azure DNS writes or prove Redis renewal under partitions. + ## Configuration ### `DDnsConfig` (`CasCap:DDnsConfig`) @@ -93,13 +117,13 @@ feature runner. | Project | Purpose | | --- | --- | | `CasCap.Api.Azure.Auth` | Azure authentication and token credential helpers ([`IAzureAuthConfig`](https://github.com/f2calv/CasCap.Api.Azure/blob/main/src/CasCap.Api.Azure.Auth/Abstractions/IAzureAuthConfig.cs), `TokenCredentialExtensions`) | -| `CasCap.Common.Caching` | Distributed locking (RedLock) | +| `CasCap.Common.Caching` | Shared leadership runner, lock factory and Redlock options | | `CasCap.Common.Configuration` | Configuration binding and validation | | `CasCap.Common.Extensions` | Shared extension helpers | | `CasCap.Common.Logging` | Structured logging helpers | | `CasCap.Common.Net` | HTTP client base class | | `CasCap.Common.Serialization.Json` | JSON serialisation helpers | -| `CasCap.Common.Services` | `HttpClientBase` | +| `CasCap.Common.Services` | Feature-host lifecycle | ## License diff --git a/src/CasCap.Api.DDns/Services/DDnsBgService.cs b/src/CasCap.Api.DDns/Services/DDnsBgService.cs index 471cc9c..e54865b 100644 --- a/src/CasCap.Api.DDns/Services/DDnsBgService.cs +++ b/src/CasCap.Api.DDns/Services/DDnsBgService.cs @@ -1,29 +1,12 @@ -using Azure.ResourceManager; -using Azure.ResourceManager.Dns; -using Azure.ResourceManager.Resources; -using Microsoft.Extensions.Hosting; - namespace CasCap.Services; -/// -/// Finds out the current DSL IP and updates the Azure DNS records accordingly. -/// See . -/// See . -/// +/// Runs Dynamic DNS updates within the shared leadership lifecycle. public sealed class DDnsBgService( ILogger logger, - IOptions redlockConfig, - IOptions azureAuthConfig, - IOptions dDnsConfig, - TimeProvider timeProvider, - IHostEnvironment env, - DDnsFindMyIpClientService findMyIpClientSvc, - IDistributedLockFactory lockFactory, - IEventSink commsSink + ILeadershipRunner leadershipRunner, + DDnsUpdateService dDnsUpdateSvc ) : IBgFeature { - private readonly SubscriptionCollection _subscriptions = new ArmClient(azureAuthConfig.Value.TokenCredential).GetSubscriptions(); - /// public string FeatureName => DDnsConfig.FeatureName; @@ -31,176 +14,7 @@ IEventSink commsSink public async Task ExecuteAsync(CancellationToken cancellationToken) { logger.LogInformation("{ClassName} starting", nameof(DDnsBgService)); - try - { - await AcquireLock(cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException and not TaskCanceledException) { throw; } + await leadershipRunner.RunAsync("ddns", dDnsUpdateSvc.RunAsync, cancellationToken); logger.LogInformation("{ClassName} exiting", nameof(DDnsBgService)); } - - private async Task AcquireLock(CancellationToken cancellationToken) - { - var resource = "ddns"; - var (expiry, wait, retry) = redlockConfig.Value.GetTimings(RedlockProfiles.LeaderElection); - - var lockAttempt = 1; - while (!cancellationToken.IsCancellationRequested) - { - await using (var redLock = await lockFactory.CreateLockAsync(resource, expiry, wait, retry, cancellationToken)) - { - if (redLock.IsAcquired) - { - logger.LogInformation("{ClassName} distributed lock acquired on attempt {Attempt}", - nameof(DDnsBgService), lockAttempt); - await RunServiceAsync(cancellationToken); - logger.LogInformation("{ClassName} distributed lock released", nameof(DDnsBgService)); - } - else - { - lockAttempt++; - logger.LogInformation("{ClassName} distributed lock failed on attempt {Attempt}", - nameof(DDnsBgService), lockAttempt); - } - } - logger.LogInformation("{ClassName} distributed lock pending attempt {Attempt}", - nameof(DDnsBgService), lockAttempt + 1); - } - } - - private async Task RunServiceAsync(CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - var currentIpAddress = await findMyIpClientSvc.GetIp(cancellationToken); - if (currentIpAddress is not null && (oldIp is null || oldIp.ToString() != currentIpAddress.ToString())) - { - await UpdateListDnsZoneResources(currentIpAddress, cancellationToken); - } - await Task.Delay(dDnsConfig.Value.RefreshDelayMs, cancellationToken); - } - } - - private SubscriptionResource? subscription; - private IPAddress? oldIp; - - private async Task UpdateListDnsZoneResources(IPAddress newIp, CancellationToken cancellationToken) - { - await SetSubscription(); - if (subscription is null) - return; - - var aRecords = await GetDnsARecordCollection(cancellationToken); - if (aRecords is null) - return; - - IPAddress? priorIp = null; - var updatedRecords = new List(); - foreach (var aRecord in aRecords) - { - var doUpdate = false; - var data = aRecord.Data; - if (data.Metadata is not null - && data.Metadata.Any(p => p.Key.Equals(dDnsConfig.Value.DnsMetaDataKey, StringComparison.OrdinalIgnoreCase))) - { - //iterate over the IPs within the A record - as you can have multiple! - foreach (var record in data.DnsARecords) - { - priorIp = record.IPv4Address; - if (!priorIp.Equals(newIp)) - { - record.IPv4Address = newIp; - doUpdate = true; - } - } - } - - if (doUpdate) - { - _ = await aRecords.CreateOrUpdateAsync(WaitUntil.Completed, data.Name, data, cancellationToken: cancellationToken); - logger.LogInformation("{ClassName} DNS A Record {ARecordName} updated from {PriorIp} to {NewIp}", - nameof(DDnsBgService), data.Name, priorIp, newIp); - updatedRecords.Add(data.Name); - } - } - - if (updatedRecords.Count > 0) - { - var recordList = string.Join(", ", updatedRecords); - var commsEvent = new CommsEvent - { - Source = nameof(DDnsBgService), - Message = $"DNS updated {updatedRecords.Count} A record(s) from {priorIp} to {newIp}: {recordList}", - Environment = env.GetAcronym(), - TimestampUtc = timeProvider.GetUtcNow().UtcDateTime, - }; - await commsSink.WriteEvent(commsEvent, cancellationToken); - } - - //now we have completed all A records update the static - oldIp = priorIp; - - async Task SetSubscription() - { - logger.LogDebug("{ClassName} querying Azure for correct subscription...", - nameof(DDnsBgService)); - foreach (var sub in _subscriptions) - { - try - { - var rg = await sub.GetResourceGroupAsync(dDnsConfig.Value.DnsResourceGroupName, cancellationToken); - if (rg.GetRawResponse().Status == 200) - { - subscription = sub; - logger.LogInformation("{ClassName} selected Azure subscription {DisplayName} ({SubscriptionId})", - nameof(DDnsBgService), sub.Data.DisplayName, sub.Data.SubscriptionId); - break; - } - } - catch (RequestFailedException ex) when (ex.Status is StatusCodes.Status403Forbidden - or StatusCodes.Status404NotFound) - { - logger.LogWarning("{ClassName} cannot access Resource Group {DnsResourceGroupName} in Azure subscription {SubscriptionId}, StatusCode={StatusCode}", - nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName, - sub.Data.SubscriptionId, ex.Status); - } - } - - if (subscription is null) - logger.LogError("{ClassName} unable to find subscription containing Resource Group {DnsResourceGroupName}", - nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName); - } - } - - private async Task GetDnsARecordCollection(CancellationToken cancellationToken) - { - // first we need to get the resource group - ResourceGroupResource resourceGroup; - try - { - resourceGroup = await subscription!.GetResourceGroupAsync(dDnsConfig.Value.DnsResourceGroupName, cancellationToken); - logger.LogDebug("{ClassName} Azure Resource Group {DnsResourceGroupName} located", - nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName); - } - catch (Exception ex) - { - logger.LogError(ex, "{ClassName} Azure Resource Group {DnsResourceGroupName} not found", - nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName); - return null; - } - - // Now we get the DnsZone collection from the resource group - DnsZoneResource dnsZone; - try - { - dnsZone = await resourceGroup.GetDnsZoneAsync(dDnsConfig.Value.DnsZoneName, cancellationToken); - return dnsZone.GetDnsARecords(); - } - catch (Exception ex) - { - logger.LogError(ex, "{ClassName} Azure DNS Zone {DnsZoneName} not found", - nameof(DDnsBgService), dDnsConfig.Value.DnsZoneName); - return null; - } - } } diff --git a/src/CasCap.Api.DDns/Services/DDnsFindMyIpClientService.cs b/src/CasCap.Api.DDns/Services/DDnsFindMyIpClientService.cs index 4985681..8e70591 100644 --- a/src/CasCap.Api.DDns/Services/DDnsFindMyIpClientService.cs +++ b/src/CasCap.Api.DDns/Services/DDnsFindMyIpClientService.cs @@ -33,7 +33,7 @@ public DDnsFindMyIpClientService(ILogger logger, _logger.LogWarning("{ClassName} unable to determine current IP address", nameof(DDnsFindMyIpClientService)); } } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "{ClassName} something is broken..", nameof(DDnsFindMyIpClientService)); } diff --git a/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs b/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs new file mode 100644 index 0000000..cc89b9b --- /dev/null +++ b/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs @@ -0,0 +1,165 @@ +using Azure.ResourceManager; +using Azure.ResourceManager.Dns; +using Azure.ResourceManager.Resources; +using Microsoft.Extensions.Hosting; + +namespace CasCap.Services; + +/// +/// Finds out the current DSL IP and updates the Azure DNS records accordingly. +/// See . +/// See . +/// +public sealed class DDnsUpdateService( + ILogger logger, + IOptions azureAuthConfig, + IOptions dDnsConfig, + TimeProvider timeProvider, + DDnsFindMyIpClientService findMyIpClientSvc, + IHostEnvironment env, + IEventSink commsSink + ) +{ + /// Revalidates DNS state for a new ownership run and polls until leadership cancellation. + /// The current leadership run's cancellation token. + public async Task RunAsync(CancellationToken cancellationToken) + { + subscription = null; + oldIp = null; + while (!cancellationToken.IsCancellationRequested) + { + var currentIpAddress = await findMyIpClientSvc.GetIp(cancellationToken); + if (currentIpAddress is not null && (oldIp is null || oldIp.ToString() != currentIpAddress.ToString())) + { + await UpdateListDnsZoneResources(currentIpAddress, cancellationToken); + } + await Task.Delay(TimeSpan.FromMilliseconds(dDnsConfig.Value.RefreshDelayMs), timeProvider, cancellationToken); + } + } + + private SubscriptionResource? subscription; + private IPAddress? oldIp; + + private async Task UpdateListDnsZoneResources(IPAddress newIp, CancellationToken cancellationToken) + { + await SetSubscription(); + if (subscription is null) + return; + + var aRecords = await GetDnsARecordCollection(cancellationToken); + if (aRecords is null) + return; + + IPAddress? priorIp = null; + var updatedRecords = new List(); + await foreach (var aRecord in aRecords.GetAllAsync(cancellationToken: cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + var doUpdate = false; + var data = aRecord.Data; + if (data.Metadata is not null + && data.Metadata.Any(p => p.Key.Equals(dDnsConfig.Value.DnsMetaDataKey, StringComparison.OrdinalIgnoreCase))) + { + //iterate over the IPs within the A record - as you can have multiple! + foreach (var record in data.DnsARecords) + { + priorIp = record.IPv4Address; + if (!priorIp.Equals(newIp)) + { + record.IPv4Address = newIp; + doUpdate = true; + } + } + } + + if (doUpdate) + { + cancellationToken.ThrowIfCancellationRequested(); + _ = await aRecords.CreateOrUpdateAsync(WaitUntil.Completed, data.Name, data, cancellationToken: cancellationToken); + logger.LogInformation("{ClassName} DNS A Record {ARecordName} updated from {PriorIp} to {NewIp}", + nameof(DDnsBgService), data.Name, priorIp, newIp); + updatedRecords.Add(data.Name); + } + } + + if (updatedRecords.Count > 0) + { + var recordList = string.Join(", ", updatedRecords); + var commsEvent = new CommsEvent + { + Source = nameof(DDnsBgService), + Message = $"DNS updated {updatedRecords.Count} A record(s) from {priorIp} to {newIp}: {recordList}", + Environment = env.GetAcronym(), + TimestampUtc = timeProvider.GetUtcNow().UtcDateTime, + }; + await commsSink.WriteEvent(commsEvent, cancellationToken); + } + + //now we have completed all A records update the static + oldIp = priorIp; + + async Task SetSubscription() + { + logger.LogDebug("{ClassName} querying Azure for correct subscription...", + nameof(DDnsBgService)); + var subscriptions = new ArmClient(azureAuthConfig.Value.TokenCredential).GetSubscriptions(); + await foreach (var sub in subscriptions.GetAllAsync(cancellationToken: cancellationToken)) + { + try + { + var rg = await sub.GetResourceGroupAsync(dDnsConfig.Value.DnsResourceGroupName, cancellationToken); + if (rg.GetRawResponse().Status == 200) + { + subscription = sub; + logger.LogInformation("{ClassName} selected Azure subscription {DisplayName} ({SubscriptionId})", + nameof(DDnsBgService), sub.Data.DisplayName, sub.Data.SubscriptionId); + break; + } + } + catch (RequestFailedException ex) when (ex.Status is StatusCodes.Status403Forbidden + or StatusCodes.Status404NotFound) + { + logger.LogWarning("{ClassName} cannot access Resource Group {DnsResourceGroupName} in Azure subscription {SubscriptionId}, StatusCode={StatusCode}", + nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName, + sub.Data.SubscriptionId, ex.Status); + } + } + + if (subscription is null) + logger.LogError("{ClassName} unable to find subscription containing Resource Group {DnsResourceGroupName}", + nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName); + } + } + + private async Task GetDnsARecordCollection(CancellationToken cancellationToken) + { + // first we need to get the resource group + ResourceGroupResource resourceGroup; + try + { + resourceGroup = await subscription!.GetResourceGroupAsync(dDnsConfig.Value.DnsResourceGroupName, cancellationToken); + logger.LogDebug("{ClassName} Azure Resource Group {DnsResourceGroupName} located", + nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "{ClassName} Azure Resource Group {DnsResourceGroupName} not found", + nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName); + return null; + } + + // Now we get the DnsZone collection from the resource group + DnsZoneResource dnsZone; + try + { + dnsZone = await resourceGroup.GetDnsZoneAsync(dDnsConfig.Value.DnsZoneName, cancellationToken); + return dnsZone.GetDnsARecords(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "{ClassName} Azure DNS Zone {DnsZoneName} not found", + nameof(DDnsBgService), dDnsConfig.Value.DnsZoneName); + return null; + } + } +} diff --git a/src/CasCap.App.Server.Tests/CasCap.App.Server.Tests.csproj b/src/CasCap.App.Server.Tests/CasCap.App.Server.Tests.csproj index 85a8650..d910dd9 100644 --- a/src/CasCap.App.Server.Tests/CasCap.App.Server.Tests.csproj +++ b/src/CasCap.App.Server.Tests/CasCap.App.Server.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/src/CasCap.App.Server.Tests/README.md b/src/CasCap.App.Server.Tests/README.md index f565e25..bc7b40e 100644 --- a/src/CasCap.App.Server.Tests/README.md +++ b/src/CasCap.App.Server.Tests/README.md @@ -19,13 +19,14 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | --- | --- | --- | --- | --- | | `FeatureServiceRegistrationTests` | 4 | 4 | Integration | `IBgFeature` registrations and enabled-feature filtering | | `FeatureConfigTests` | 3 | 6 | Configuration | Empty values, unknown-name rejection, and case-insensitive parsing | +| `DDnsLeadershipTests` | 2 | 3 | Leadership | Existing resource delegation and cancellation through IP discovery, without Azure/Redis calls | | `DeviceApiContractTests` | 4 | 15 | Integration | Request cancellation on six device-history routes, required power values, and generated Swagger metadata | | `HealthTests` | 4 | 7 | Integration | Health, liveness, readiness, and startup endpoints | | `HostStartupConfigurationTests` | 1 | 1 | Integration | Options binding, Key Vault disablement, feature selection, and safe core DI services | | `McpAuthenticationTests` | 2 | 2 | Integration | Anonymous rejection and Basic-authenticated MCP endpoint access | | `SystemControllerTests` | 4 | 4 | Integration | `GET /api/system` and Basic authentication behavior | | `WebApiContractTests` | 4 | 8 | Integration | String-enum JSON output/input, validation Problem Details, and generated Swagger schemas | -| Total | 26 | 47 | | Source-declared, including skipped cases | +| Total | 28 | 50 | | Source-declared, including skipped cases | ## Trait Categories @@ -33,6 +34,7 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | --- | --- | | `Configuration` | Exercises deterministic configuration parsing without starting the host | | `Integration` | Boots the in-process ASP.NET Core host and exercises its services or endpoints | +| `Leadership` | Credential-free DDNS ownership wiring and cancellation | `InitializeConfiguration` owns the standard provider chain and unconditionally adds the required `appsettings.json`, user secrets, and environment variables. It therefore cannot be exercised with @@ -78,6 +80,7 @@ CasCap.App.Server.Tests/ │ │ ├── HostStartupConfigurationTests.cs │ │ └── WebApiContractTests.cs │ └── Unit/ +│ ├── DDnsLeadershipTests.cs │ └── FeatureConfigTests.cs ├── GlobalUsings.cs └── xunit.runner.json @@ -90,6 +93,7 @@ CasCap.App.Server.Tests/ | Project | Purpose | | --- | --- | | `CasCap.App.Server` | ASP.NET Core host under test | +| `CasCap.Api.DDns` | DDNS ownership and cancellation contract under test | | `CasCap.Common.Testing` | Shared xUnit logging and test utilities | ### Packages @@ -105,4 +109,3 @@ Test collections run serially through `xunit.runner.json`. ## License This project is released under the [MIT License](../../LICENSE). See the [LICENSE](../../LICENSE) file for details. - diff --git a/src/CasCap.App.Server.Tests/Tests/Unit/DDnsLeadershipTests.cs b/src/CasCap.App.Server.Tests/Tests/Unit/DDnsLeadershipTests.cs new file mode 100644 index 0000000..89bd475 --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Unit/DDnsLeadershipTests.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net.Http; + +namespace CasCap.Tests.Unit; + +/// Verifies DDNS leadership wiring and cancellation without DNS, Azure or Redis access. +[Trait("Category", "Leadership")] +public sealed class DDnsLeadershipTests +{ + private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task ExecuteAsync_DelegatesTheExistingResourceAndWorker() + { + using var handler = new BlockingHandler(); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/") }; + var worker = CreateWorker(client); + var runner = new TestRunner(); + var sut = new DDnsBgService(NullLogger.Instance, runner, worker); + + await sut.ExecuteAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DDnsConfig.FeatureName, sut.FeatureName); + Assert.Equal("ddns", runner.Resource); + Assert.Equal(TestContext.Current.CancellationToken, runner.HostToken); + Assert.Same(worker, runner.Work!.Target); + Assert.Equal(nameof(DDnsUpdateService.RunAsync), runner.Work.Method.Name); + Assert.False(handler.Started.Task.IsCompleted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Cancellation_ReachesIpDiscoveryAndPropagates(bool runWorker) + { + using var handler = new BlockingHandler(); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/") }; + using var stopping = new CancellationTokenSource(); + var running = runWorker + ? CreateWorker(client).RunAsync(stopping.Token) + : CreateFinder(client).GetIp(stopping.Token); + await handler.Started.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + + await stopping.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + running.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + Assert.True(handler.Token.IsCancellationRequested); + } + + private static DDnsUpdateService CreateWorker(HttpClient client) + => new(NullLogger.Instance, + Options.Create(new AzureAuthConfig { KeyVaultName = "example" }), + Options.Create(new DDnsConfig + { + BaseAddress = "https://example.com/", + DnsResourceGroupName = "example", + DnsZoneName = "example.com", + DnsMetaDataKey = "example", + RefreshDelayMs = 30_000 + }), + TimeProvider.System, CreateFinder(client), new TestEnvironment(), new TestSink()); + + private static DDnsFindMyIpClientService CreateFinder(HttpClient client) + => new(NullLogger.Instance, new TestClientFactory(client)); + + private sealed class TestRunner : ILeadershipRunner + { + public string? Resource { get; private set; } + public Func? Work { get; private set; } + public CancellationToken HostToken { get; private set; } + + public Task RunAsync(string resource, Func runAsync, CancellationToken cancellationToken) + { + Resource = resource; + Work = runAsync; + HostToken = cancellationToken; + return Task.CompletedTask; + } + } + + private sealed class BlockingHandler : HttpMessageHandler + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public CancellationToken Token { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Token = cancellationToken; + Started.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("The request must be cancelled."); + } + } + + private sealed class TestClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class TestEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = Environments.Development; + public string ApplicationName { get; set; } = "Example"; + public string ContentRootPath { get; set; } = string.Empty; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } + + private sealed class TestSink : IEventSink + { + public string SinkType => "Test"; + + public Task WriteEvent(CommsEvent @event, CancellationToken cancellationToken = default) + => throw new InvalidOperationException("Cancellation tests must not reach DNS notifications."); + } +} From ec215c77bcd793e2ce562f551bffc6f30d2457f0 Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 12:59:00 +0200 Subject: [PATCH 07/18] fix(webapi): validate device commands and preserve cancellation - Move datapoint write outcomes into the query service and retain MCP boolean output - Propagate cancellation through preflight reads and recursive discovery - Validate optional pilot ranges and cover HTTP boundaries without hardware --- .../Abstractions/IBuderusQueryService.cs | 4 +- .../Controllers/BuderusController.cs | 9 +- .../Models/BuderusDataPointWriteResult.cs | 13 ++ src/CasCap.Api.Buderus/README.md | 7 + .../Services/BuderusKm200ClientService.cs | 13 +- .../Services/BuderusQueryService.cs | 16 +- .../Controllers/WizController.cs | 1 + .../Models/Dtos/WizSetPilotRequest.cs | 7 + src/CasCap.Api.Wiz/README.md | 5 + .../DeviceApiWebApplicationFactory.cs | 19 ++- src/CasCap.App.Server.Tests/README.md | 7 +- .../Integration/DeviceApiContractTests.cs | 76 +++++++++ .../Tests/Unit/BuderusQueryServiceTests.cs | 158 ++++++++++++++++++ .../Services/Mcp/HeatPumpMcpQueryService.cs | 4 +- 14 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 src/CasCap.Api.Buderus/Models/BuderusDataPointWriteResult.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Unit/BuderusQueryServiceTests.cs diff --git a/src/CasCap.Api.Buderus/Abstractions/IBuderusQueryService.cs b/src/CasCap.Api.Buderus/Abstractions/IBuderusQueryService.cs index e71e5f4..770c0f5 100644 --- a/src/CasCap.Api.Buderus/Abstractions/IBuderusQueryService.cs +++ b/src/CasCap.Api.Buderus/Abstractions/IBuderusQueryService.cs @@ -26,6 +26,6 @@ public interface IBuderusQueryService /// The full KM200 datapoint path, e.g. /dhwCircuits/dhw1/setTemperature. /// The new value to write. For numeric datapoints supply a decimal string (e.g. "55.0"); for enumeration datapoints supply the option string (e.g. "Always_On"). /// Cancellation token. - /// if the device accepted the write; otherwise. - public Task SetDataPoint(string datapointId, string value, CancellationToken cancellationToken = default); + /// The accepted write or a validation/device rejection message. + public Task SetDataPoint(string datapointId, string value, CancellationToken cancellationToken = default); } diff --git a/src/CasCap.Api.Buderus/Controllers/BuderusController.cs b/src/CasCap.Api.Buderus/Controllers/BuderusController.cs index 407a952..4bda2ad 100644 --- a/src/CasCap.Api.Buderus/Controllers/BuderusController.cs +++ b/src/CasCap.Api.Buderus/Controllers/BuderusController.cs @@ -26,13 +26,10 @@ public Ok> GetById(string id, CancellationToken c /// [HttpPut("{*id}")] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task>> SetDataPoint(string id, [FromBody] SetDataPointRequest request, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(id)) - return TypedResults.BadRequest("Datapoint id must not be empty."); - // Ensure the id starts with '/' so it matches the KM200 path convention - var datapointId = id.StartsWith('/') ? id : $"/{id}"; - var success = await buderusQuerySvc.SetDataPoint(datapointId, request.Value, cancellationToken); - return success ? TypedResults.Ok() : TypedResults.BadRequest($"Failed to write datapoint '{datapointId}'. The datapoint may not exist or may not be writeable."); + var result = await buderusQuerySvc.SetDataPoint(id, request.Value, cancellationToken); + return result.Error is { } error ? TypedResults.BadRequest(error) : TypedResults.Ok(); } } diff --git a/src/CasCap.Api.Buderus/Models/BuderusDataPointWriteResult.cs b/src/CasCap.Api.Buderus/Models/BuderusDataPointWriteResult.cs new file mode 100644 index 0000000..5b909fa --- /dev/null +++ b/src/CasCap.Api.Buderus/Models/BuderusDataPointWriteResult.cs @@ -0,0 +1,13 @@ +namespace CasCap.Models; + +/// Describes an accepted datapoint write or its validation/device rejection. +public sealed record BuderusDataPointWriteResult +{ + /// Gets the rejection message, or null when the write was accepted. + /// The datapoint is not writeable. + public string? Error { get; init; } + + /// Gets whether the device accepted the write. + /// true + public bool Succeeded => Error is null; +} diff --git a/src/CasCap.Api.Buderus/README.md b/src/CasCap.Api.Buderus/README.md index 38c9192..1ecb1b1 100644 --- a/src/CasCap.Api.Buderus/README.md +++ b/src/CasCap.Api.Buderus/README.md @@ -43,6 +43,13 @@ An empty mapping disables polling. Recursive discovery is available separately t A REST API (`BuderusController`) exposes endpoints for listing all current datapoint values, querying events by datapoint ID, and retrieving the latest snapshot. +Datapoint writes through +[`IBuderusQueryService`](https://github.com/f2calv/SmartHaus/blob/main/src/CasCap.Api.Buderus/Abstractions/IBuderusQueryService.cs) +return a `BuderusDataPointWriteResult` with `Succeeded` and an optional rejection `Error`. +The query service validates and normalizes the path; the controller maps the result to HTTP. +The MCP write tool continues to return a boolean. Reads, write preflight reads and recursive +discovery accept cancellation; cancellation propagates instead of returning partial discovery results. + ### Sinks | Sink | Description | diff --git a/src/CasCap.Api.Buderus/Services/BuderusKm200ClientService.cs b/src/CasCap.Api.Buderus/Services/BuderusKm200ClientService.cs index c858fd4..eb815d4 100644 --- a/src/CasCap.Api.Buderus/Services/BuderusKm200ClientService.cs +++ b/src/CasCap.Api.Buderus/Services/BuderusKm200ClientService.cs @@ -72,8 +72,8 @@ public async Task> GetAllDataPoints(CancellationToken async Task GetDP(string requestUriOrId) { - if (cancellationToken.IsCancellationRequested) return null; - var dp = await GetDataPoint(requestUriOrId).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var dp = await GetDataPoint(requestUriOrId, cancellationToken).ConfigureAwait(false); if (dp is not null) { datapoints.Add(dp); @@ -87,11 +87,12 @@ public async Task> GetAllDataPoints(CancellationToken /// Gets a specific datapoint from the KM200 device. /// Datapoint URI or ID. + /// Cancellation token. /// Datapoint object, or null if not found. - public async Task GetDataPoint(string requestUriOrId) + public async Task GetDataPoint(string requestUriOrId, CancellationToken cancellationToken = default) { var requestUri = BuildRequestUri(requestUriOrId); - var tpl = await GetAsync(requestUri.ToString()).ConfigureAwait(false); + var tpl = await GetAsync(requestUri.ToString(), cancellationToken: cancellationToken).ConfigureAwait(false); if (tpl.result is null || tpl.result.IndexOf("Sorry, the requested file does not exist on this server.") > -1) _logger.LogWarning("url '{RequestUri}' not found", requestUri); else @@ -119,7 +120,7 @@ public async Task> GetAllDataPoints(CancellationToken /// public async Task SetDataPoint(string datapointId, object value, CancellationToken cancellationToken = default) { - var dp = await GetDataPoint(datapointId).ConfigureAwait(false); + var dp = await GetDataPoint(datapointId, cancellationToken).ConfigureAwait(false); if (dp is null) { _logger.LogWarning("{ClassName} datapoint '{DatapointId}' not found, cannot write", nameof(BuderusKm200ClientService), datapointId); @@ -141,7 +142,7 @@ public async Task SetDataPoint(string datapointId, object value, Cancellat _logger.LogInformation("{ClassName} writing to '{DatapointId}' value={Value}", nameof(BuderusKm200ClientService), datapointId, value); - var response = await Client.SendAsync(request, cancellationToken).ConfigureAwait(false); + using var response = await Client.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning("{ClassName} failed to write to '{DatapointId}', StatusCode={StatusCode}", diff --git a/src/CasCap.Api.Buderus/Services/BuderusQueryService.cs b/src/CasCap.Api.Buderus/Services/BuderusQueryService.cs index 8c91907..c424ea6 100644 --- a/src/CasCap.Api.Buderus/Services/BuderusQueryService.cs +++ b/src/CasCap.Api.Buderus/Services/BuderusQueryService.cs @@ -47,13 +47,21 @@ public async Task GetSnapshot() /// (e.g. "Always_On"). /// /// Cancellation token. - /// if the device accepted the write; otherwise. - public async Task SetDataPoint( + /// The accepted write or a validation/device rejection message. + public async Task SetDataPoint( string datapointId, string value, CancellationToken cancellationToken = default) { - logger.LogInformation("{ClassName} writing {DatapointId}={Value}", nameof(BuderusQueryService), datapointId, value); - return await km200ClientSvc.SetDataPoint(datapointId, value, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(datapointId)) + return new BuderusDataPointWriteResult { Error = "Datapoint id must not be empty." }; + var normalizedId = datapointId.StartsWith('/') ? datapointId : $"/{datapointId}"; + logger.LogInformation("{ClassName} writing requested datapoint", nameof(BuderusQueryService)); + return await km200ClientSvc.SetDataPoint(normalizedId, value, cancellationToken).ConfigureAwait(false) + ? new BuderusDataPointWriteResult() + : new BuderusDataPointWriteResult + { + Error = $"Failed to write datapoint '{normalizedId}'. The datapoint may not exist or may not be writeable.", + }; } } diff --git a/src/CasCap.Api.Wiz/Controllers/WizController.cs b/src/CasCap.Api.Wiz/Controllers/WizController.cs index 2462486..0ce039d 100644 --- a/src/CasCap.Api.Wiz/Controllers/WizController.cs +++ b/src/CasCap.Api.Wiz/Controllers/WizController.cs @@ -28,6 +28,7 @@ public async Task> GetPilot(string bulbIdentifier, Cancellatio /// [HttpPut("bulbs/{bulbIdentifier}/pilot")] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> SetPilot(string bulbIdentifier, [FromBody] WizSetPilotRequest request, CancellationToken cancellationToken) => TypedResults.Ok(await wizQuerySvc.SetPilot(bulbIdentifier, request, cancellationToken)); diff --git a/src/CasCap.Api.Wiz/Models/Dtos/WizSetPilotRequest.cs b/src/CasCap.Api.Wiz/Models/Dtos/WizSetPilotRequest.cs index d9537c9..08bba2c 100644 --- a/src/CasCap.Api.Wiz/Models/Dtos/WizSetPilotRequest.cs +++ b/src/CasCap.Api.Wiz/Models/Dtos/WizSetPilotRequest.cs @@ -10,6 +10,7 @@ public sealed record WizSetPilotRequest /// Brightness percentage, 10–100. [Description("Brightness percentage, 10–100.")] + [Range(10, 100)] [JsonPropertyName("dimming")] public int? Dimming { get; init; } @@ -20,26 +21,31 @@ public sealed record WizSetPilotRequest /// Red colour channel, 0–255. [Description("Red colour channel, 0–255.")] + [Range(0, 255)] [JsonPropertyName("r")] public int? R { get; init; } /// Green colour channel, 0–255. [Description("Green colour channel, 0–255.")] + [Range(0, 255)] [JsonPropertyName("g")] public int? G { get; init; } /// Blue colour channel, 0–255. [Description("Blue colour channel, 0–255.")] + [Range(0, 255)] [JsonPropertyName("b")] public int? B { get; init; } /// Cold white channel, 0–255. [Description("Cold white channel, 0–255.")] + [Range(0, 255)] [JsonPropertyName("c")] public int? C { get; init; } /// Warm white channel, 0–255. [Description("Warm white channel, 0–255.")] + [Range(0, 255)] [JsonPropertyName("w")] public int? W { get; init; } @@ -50,6 +56,7 @@ public sealed record WizSetPilotRequest /// Scene playback speed, 20–200. [Description("Scene playback speed, 20–200.")] + [Range(20, 200)] [JsonPropertyName("speed")] public int? Speed { get; init; } } diff --git a/src/CasCap.Api.Wiz/README.md b/src/CasCap.Api.Wiz/README.md index a1d56cd..3fc5e4d 100644 --- a/src/CasCap.Api.Wiz/README.md +++ b/src/CasCap.Api.Wiz/README.md @@ -49,6 +49,11 @@ alone do not activate them; register a sink explicitly in a custom host, or use **`WizQueryService`** is a facade implementing [`IWizQueryService`](https://github.com/f2calv/SmartHaus/blob/main/src/CasCap.Api.Wiz/Abstractions/IWizQueryService.cs) that delegates to the client service with logging. +The pilot HTTP endpoint validates supplied brightness (`10`-`100`), colour/white channels +(`0`-`255`) and scene speed (`20`-`200`) before invoking the device service. Invalid values return +validation Problem Details. Every pilot field remains optional for partial updates; temperature +and scene IDs are not constrained to guessed device-independent ranges. + ## Configuration Registered via `IServiceCollection.AddWiz()`. Configuration section: `CasCap:WizConfig`. diff --git a/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs b/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs index 43e5c17..c13d391 100644 --- a/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs +++ b/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs @@ -12,6 +12,12 @@ public sealed class DeviceApiWebApplicationFactory : CasCapAppWebApplicationFact public List PowerCommands { get; } = []; + public List PilotCommands { get; } = []; + + public BuderusDataPointWriteResult DataPointResult { get; set; } = new(); + + public List<(string Id, string Value, CancellationToken Token)> DataPointCommands { get; } = []; + /// protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -51,8 +57,11 @@ private sealed class BuderusQueries(DeviceApiWebApplicationFactory factory) : IB public IAsyncEnumerable GetEvents(string? id = null, int limit = 1000, CancellationToken cancellationToken = default) => factory.RecordHistory(cancellationToken); public Task GetSnapshot() => throw new NotSupportedException(); - public Task SetDataPoint(string datapointId, string value, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + public Task SetDataPoint(string datapointId, string value, CancellationToken cancellationToken = default) + { + factory.DataPointCommands.Add((datapointId, value, cancellationToken)); + return Task.FromResult(factory.DataPointResult); + } } private sealed class FroniusQueries(DeviceApiWebApplicationFactory factory) : IFroniusQueryService @@ -107,6 +116,10 @@ public Task SetPowerState(string bulbIdentifier, bool on, CancellationToke public Task> DiscoverBulbs(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetPilot(string bulbIdentifier, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetSystemConfig(string bulbIdentifier, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task SetPilot(string bulbIdentifier, WizSetPilotRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task SetPilot(string bulbIdentifier, WizSetPilotRequest request, CancellationToken cancellationToken = default) + { + factory.PilotCommands.Add(request); + return Task.FromResult(true); + } } } diff --git a/src/CasCap.App.Server.Tests/README.md b/src/CasCap.App.Server.Tests/README.md index bc7b40e..114ed31 100644 --- a/src/CasCap.App.Server.Tests/README.md +++ b/src/CasCap.App.Server.Tests/README.md @@ -17,22 +17,24 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | Class | Methods | Cases | Category | Description | | --- | --- | --- | --- | --- | +| `BuderusQueryServiceTests` | 5 | 9 | Device Commands | Path validation/normalization, preflight/discovery cancellation, and MCP boolean rejection using fake HTTP | | `FeatureServiceRegistrationTests` | 4 | 4 | Integration | `IBgFeature` registrations and enabled-feature filtering | | `FeatureConfigTests` | 3 | 6 | Configuration | Empty values, unknown-name rejection, and case-insensitive parsing | | `DDnsLeadershipTests` | 2 | 3 | Leadership | Existing resource delegation and cancellation through IP discovery, without Azure/Redis calls | -| `DeviceApiContractTests` | 4 | 15 | Integration | Request cancellation on six device-history routes, required power values, and generated Swagger metadata | +| `DeviceApiContractTests` | 7 | 46 | Integration | History cancellation, explicit power values, typed write outcomes, pilot range boundaries/validation, and Swagger metadata | | `HealthTests` | 4 | 7 | Integration | Health, liveness, readiness, and startup endpoints | | `HostStartupConfigurationTests` | 1 | 1 | Integration | Options binding, Key Vault disablement, feature selection, and safe core DI services | | `McpAuthenticationTests` | 2 | 2 | Integration | Anonymous rejection and Basic-authenticated MCP endpoint access | | `SystemControllerTests` | 4 | 4 | Integration | `GET /api/system` and Basic authentication behavior | | `WebApiContractTests` | 4 | 8 | Integration | String-enum JSON output/input, validation Problem Details, and generated Swagger schemas | -| Total | 28 | 50 | | Source-declared, including skipped cases | +| Total | 36 | 90 | | Source-declared, including skipped cases | ## Trait Categories | Category | Meaning | | --- | --- | | `Configuration` | Exercises deterministic configuration parsing without starting the host | +| `Device Commands` | Uses synthetic configuration and fake HTTP; never contacts live devices | | `Integration` | Boots the in-process ASP.NET Core host and exercises its services or endpoints | | `Leadership` | Credential-free DDNS ownership wiring and cancellation | @@ -80,6 +82,7 @@ CasCap.App.Server.Tests/ │ │ ├── HostStartupConfigurationTests.cs │ │ └── WebApiContractTests.cs │ └── Unit/ +│ ├── BuderusQueryServiceTests.cs │ ├── DDnsLeadershipTests.cs │ └── FeatureConfigTests.cs ├── GlobalUsings.cs diff --git a/src/CasCap.App.Server.Tests/Tests/Integration/DeviceApiContractTests.cs b/src/CasCap.App.Server.Tests/Tests/Integration/DeviceApiContractTests.cs index 66d441e..0fff41e 100644 --- a/src/CasCap.App.Server.Tests/Tests/Integration/DeviceApiContractTests.cs +++ b/src/CasCap.App.Server.Tests/Tests/Integration/DeviceApiContractTests.cs @@ -86,4 +86,80 @@ public async Task OpenApi_DescribesDeviceSuccessAndValidation() .GetProperty("responses").GetProperty("200").GetProperty("content") .TryGetProperty("application/json", out _)); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DataPointWrite_MapsServiceOutcome(bool rejected) + { + factory.DataPointResult = new BuderusDataPointWriteResult { Error = rejected ? "Synthetic device rejection." : null }; + using var client = factory.CreateClient(); + using var content = new StringContent("{\"value\":\"50\"}", System.Text.Encoding.UTF8, "application/json"); + using var response = await client.PutAsync("/api/v1/Buderus/example/value", content, TestContext.Current.CancellationToken); + + Assert.Equal(rejected ? HttpStatusCode.BadRequest : HttpStatusCode.OK, response.StatusCode); + var command = factory.DataPointCommands[^1]; + Assert.Equal("example/value", command.Id); + Assert.Equal("50", command.Value); + Assert.True(command.Token.CanBeCanceled); + if (rejected) + Assert.Equal(factory.DataPointResult.Error, + JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken))); + } + + [Theory] + [InlineData("{\"dimming\":9}")] + [InlineData("{\"dimming\":101}")] + [InlineData("{\"r\":-1}")] + [InlineData("{\"r\":256}")] + [InlineData("{\"g\":-1}")] + [InlineData("{\"g\":256}")] + [InlineData("{\"b\":-1}")] + [InlineData("{\"b\":256}")] + [InlineData("{\"c\":-1}")] + [InlineData("{\"c\":256}")] + [InlineData("{\"w\":-1}")] + [InlineData("{\"w\":256}")] + [InlineData("{\"speed\":19}")] + [InlineData("{\"speed\":201}")] + public async Task InvalidPilotRanges_DoNotInvokeDevice(string json) + { + using var client = factory.CreateClient(); + var before = factory.PilotCommands.Count; + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + using var response = await client.PutAsync("/api/v1/Wiz/bulbs/example/pilot", content, TestContext.Current.CancellationToken); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.NotEmpty(body.RootElement.GetProperty("errors").EnumerateObject()); + Assert.Equal(before, factory.PilotCommands.Count); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"dimming\":10}")] + [InlineData("{\"dimming\":100}")] + [InlineData("{\"r\":0}")] + [InlineData("{\"r\":255}")] + [InlineData("{\"g\":0}")] + [InlineData("{\"g\":255}")] + [InlineData("{\"b\":0}")] + [InlineData("{\"b\":255}")] + [InlineData("{\"c\":0}")] + [InlineData("{\"c\":255}")] + [InlineData("{\"w\":0}")] + [InlineData("{\"w\":255}")] + [InlineData("{\"speed\":20}")] + [InlineData("{\"speed\":200}")] + public async Task PilotRanges_AcceptBoundariesAndOmittedOptionalValues(string json) + { + using var client = factory.CreateClient(); + var before = factory.PilotCommands.Count; + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + using var response = await client.PutAsync("/api/v1/Wiz/bulbs/example/pilot", content, TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + + Assert.Equal(before + 1, factory.PilotCommands.Count); + } } diff --git a/src/CasCap.App.Server.Tests/Tests/Unit/BuderusQueryServiceTests.cs b/src/CasCap.App.Server.Tests/Tests/Unit/BuderusQueryServiceTests.cs new file mode 100644 index 0000000..599b0cd --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Unit/BuderusQueryServiceTests.cs @@ -0,0 +1,158 @@ +using CasCap.Abstractions; +using CasCap.Common.Models; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net; + +namespace CasCap.Tests; + +/// Checks datapoint validation, normalization and read cancellation without a device. +[Trait("Category", "Device Commands")] +public sealed class BuderusQueryServiceTests +{ + [Theory] + [InlineData("example/value")] + [InlineData("/example/value")] + public async Task RejectedWrite_ReportsNormalizedPath(string id) + { + using var handler = new RecordingHandler(); + using var client = new HttpClient(handler); + var service = CreateService(client); + + var result = await service.SetDataPoint(id, "50", TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Equal("Failed to write datapoint '/example/value'. The datapoint may not exist or may not be writeable.", result.Error); + Assert.Equal("/example/value", handler.RequestUri?.AbsolutePath); + Assert.Equal(1, handler.Calls); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public async Task BlankPath_IsRejectedBeforeDeviceAccess(string id) + { + using var handler = new RecordingHandler(); + using var client = new HttpClient(handler); + + var result = await CreateService(client).SetDataPoint(id, "50", TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Equal("Datapoint id must not be empty.", result.Error); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task CancelledPreflightRead_PropagatesWithoutWriting() + { + using var handler = new RecordingHandler { Block = true }; + using var client = new HttpClient(handler); + using var stopping = new CancellationTokenSource(); + var task = CreateService(client).SetDataPoint("example/value", "50", stopping.Token); + await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + await stopping.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.Equal(1, handler.Calls); + Assert.Equal(HttpMethod.Get, handler.Method); + Assert.True(handler.Token.IsCancellationRequested); + } + + [Fact] + public async Task McpWrite_PreservesBooleanFailureContract() + { + using var handler = new RecordingHandler(); + using var client = new HttpClient(handler); + var tool = new HeatPumpMcpQueryService(CreateService(client)); + + var accepted = await tool.SetHeatPumpDataPoint("example/value", "50", TestContext.Current.CancellationToken); + + Assert.False(accepted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DiscoveryCancellation_Propagates(bool alreadyCancelled) + { + using var handler = new RecordingHandler { Block = true }; + using var client = new HttpClient(handler); + using var stopping = new CancellationTokenSource(); + if (alreadyCancelled) + await stopping.CancelAsync(); + + var task = CreateDevice(client).GetAllDataPoints(stopping.Token); + if (!alreadyCancelled) + { + await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await stopping.CancelAsync(); + } + + await Assert.ThrowsAnyAsync(() => + task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.Equal(alreadyCancelled ? 0 : 1, handler.Calls); + } + + private static BuderusQueryService CreateService(HttpClient client) + => new(NullLogger.Instance, CreateDevice(client), new UnusedQuery()); + + private static BuderusKm200ClientService CreateDevice(HttpClient client) + { + var options = Options.Create(new BuderusConfig + { + BaseAddress = "http://example.com", + HealthCheckUri = "system", + Port = 80, + GatewayPassword = "example", + PrivatePassword = "example", + AzureTableStorageConnectionString = string.Empty, + HealthCheck = KubernetesProbeTypes.None, + HealthCheckAzureTableStorage = KubernetesProbeTypes.None, + Sinks = new SinkConfig { AvailableSinks = [] }, + ConnectionPollingDelayMs = 1000, + ConnectionLogEscalationInterval = 10, + MetricNamePrefix = "example", + OtelServiceName = "example", + }); + return new BuderusKm200ClientService(NullLogger.Instance, options, new TestClientFactory(client)); + } + + private sealed class TestClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class UnusedQuery : IBuderusQuery + { + public Task GetSnapshot() => throw new NotSupportedException(); + public IAsyncEnumerable GetEvents(string? id = null, int limit = 1000, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public bool Block { get; init; } + public int Calls { get; private set; } + public Uri? RequestUri { get; private set; } + public HttpMethod? Method { get; private set; } + public CancellationToken Token { get; private set; } + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + RequestUri = request.RequestUri; + Method = request.Method; + Token = cancellationToken; + Started.TrySetResult(); + if (Block) + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Sorry, the requested file does not exist on this server."), + }; + } + } +} diff --git a/src/CasCap.Backend/Services/Mcp/HeatPumpMcpQueryService.cs b/src/CasCap.Backend/Services/Mcp/HeatPumpMcpQueryService.cs index 29b541e..d8b71eb 100644 --- a/src/CasCap.Backend/Services/Mcp/HeatPumpMcpQueryService.cs +++ b/src/CasCap.Backend/Services/Mcp/HeatPumpMcpQueryService.cs @@ -14,11 +14,11 @@ public sealed partial class HeatPumpMcpQueryService(IBuderusQueryService buderus /// [McpServerTool(ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] [Description("Writes a value to a writeable heat pump datapoint.")] - public Task SetHeatPumpDataPoint( + public async Task SetHeatPumpDataPoint( [Description("Datapoint path, e.g. /dhwCircuits/dhw1/setTemperature.")] string datapointId, [Description("Numeric string (e.g. 55.0) or enum option (e.g. Always_On).")] string value, CancellationToken cancellationToken = default) - => buderusQuerySvc.SetDataPoint(datapointId, value, cancellationToken); + => (await buderusQuerySvc.SetDataPoint(datapointId, value, cancellationToken)).Succeeded; } From b59330fb16e3b4ff85f0e1f22015edfe8cf5712e Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 12:59:00 +0200 Subject: [PATCH 08/18] refactor(webapi): move camera webhook handling into the service - Keep controllers to binding and typed response mapping - Validate payloads before dispatch without swallowing sink failures - Forward ring cancellation and verify webhook HTTP contracts --- .../Abstractions/IUbiquitiQueryService.cs | 19 +- .../Controllers/UbiquitiController.cs | 104 ++--------- .../Models/UbiquitiWebhookResult.cs | 9 + src/CasCap.Api.Ubiquiti/README.md | 16 +- .../Services/UbiquitiQueryService.cs | 54 +++++- .../DeviceApiWebApplicationFactory.cs | 18 +- src/CasCap.App.Server.Tests/README.md | 9 +- .../Integration/UbiquitiApiContractTests.cs | 91 ++++++++++ .../Tests/Unit/UbiquitiQueryServiceTests.cs | 167 ++++++++++++++++++ 9 files changed, 387 insertions(+), 100 deletions(-) create mode 100644 src/CasCap.Api.Ubiquiti/Models/UbiquitiWebhookResult.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Integration/UbiquitiApiContractTests.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Unit/UbiquitiQueryServiceTests.cs diff --git a/src/CasCap.Api.Ubiquiti/Abstractions/IUbiquitiQueryService.cs b/src/CasCap.Api.Ubiquiti/Abstractions/IUbiquitiQueryService.cs index e5480bb..b886e92 100644 --- a/src/CasCap.Api.Ubiquiti/Abstractions/IUbiquitiQueryService.cs +++ b/src/CasCap.Api.Ubiquiti/Abstractions/IUbiquitiQueryService.cs @@ -19,11 +19,28 @@ public interface IUbiquitiQueryService /// Optional smart detection confidence score (0.0–1.0). /// Optional UniFi Protect payload containing trigger metadata and a thumbnail. /// Cancellation token. - public Task SendAlert( + /// Acceptance or a payload validation error; sink failures propagate. + public Task SendAlert( UbiquitiEventType type, string? cameraId = null, string? cameraName = null, double? score = null, UbiquitiWebhookRequest? webhook = null, CancellationToken cancellationToken = default); + + /// Sends a person, vehicle, animal or package smart-detection alert. + /// Case-insensitive smart detection type. + /// Optional source camera identifier. + /// Optional camera display name. + /// Optional confidence score. + /// Optional alarm metadata and thumbnail. + /// Cancellation token. + /// Acceptance or a detection/payload validation error; sink failures propagate. + public Task SendSmartDetectionAlert( + string type, + string? cameraId = null, + string? cameraName = null, + double? score = null, + UbiquitiWebhookRequest? webhook = null, + CancellationToken cancellationToken = default); } diff --git a/src/CasCap.Api.Ubiquiti/Controllers/UbiquitiController.cs b/src/CasCap.Api.Ubiquiti/Controllers/UbiquitiController.cs index 76bbebe..a9bcd65 100644 --- a/src/CasCap.Api.Ubiquiti/Controllers/UbiquitiController.cs +++ b/src/CasCap.Api.Ubiquiti/Controllers/UbiquitiController.cs @@ -9,7 +9,7 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] -public sealed partial class UbiquitiController(ILogger logger, IUbiquitiQueryService ubiquitiQuerySvc) : ControllerBase +public sealed class UbiquitiController(IUbiquitiQueryService ubiquitiQuerySvc) : ControllerBase { private const long MaxWebhookRequestBytes = 5 * 1024 * 1024; @@ -20,54 +20,29 @@ public async Task> GetSnapshot() #region Webhook callbacks - /// - /// Webhook endpoint for UniFi Protect motion detection events. - /// Configure the camera or controller to POST/GET to this URL when motion is detected. - /// - /// Optional camera identifier from the webhook payload. - /// Optional camera display name from the webhook payload. - /// Optional UniFi Protect Alarm Manager payload. - /// Cancellation token. + /// [AllowAnonymous] [HttpGet("event/motion")] [HttpPost("event/motion")] [RequestSizeLimit(MaxWebhookRequestBytes)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task, BadRequest>> MotionDetected( [FromQuery] string? camera_id = null, [FromQuery] string? camera_name = null, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] UbiquitiWebhookRequest? webhook = null, CancellationToken cancellationToken = default) { - try - { - await ubiquitiQuerySvc.SendAlert( - UbiquitiEventType.Motion, - camera_id, - camera_name, - webhook: webhook, - cancellationToken: cancellationToken); - return TypedResults.Ok("ok"); - } - catch (FormatException) - { - LogInvalidWebhook(logger, nameof(UbiquitiController), HttpContext.Request.ContentLength); - return TypedResults.BadRequest("Invalid UniFi Protect webhook payload."); - } + var result = await ubiquitiQuerySvc.SendAlert( + UbiquitiEventType.Motion, camera_id, camera_name, webhook: webhook, cancellationToken: cancellationToken); + return result.Error is { } error ? TypedResults.BadRequest(error) : TypedResults.Ok("ok"); } - /// - /// Webhook endpoint for UniFi Protect smart detection events (person, vehicle, animal, package). - /// - /// The smart detection type. Must be one of: person, vehicle, animal, package. - /// Optional camera identifier from the webhook payload. - /// Optional camera display name from the webhook payload. - /// Optional confidence score (0.0–1.0). - /// Optional UniFi Protect Alarm Manager payload. - /// Cancellation token. + /// [AllowAnonymous] [HttpGet("event/smart")] [HttpPost("event/smart")] [RequestSizeLimit(MaxWebhookRequestBytes)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task, BadRequest>> SmartDetect( [FromQuery] string type, [FromQuery] string? camera_id = null, @@ -76,53 +51,8 @@ public async Task, BadRequest>> SmartDetect( [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] UbiquitiWebhookRequest? webhook = null, CancellationToken cancellationToken = default) { - logger.LogDebug( - "{ClassName} smart detect webhook received Type={Type}, CameraId={CameraId}, CameraName={CameraName}, Score={Score}, Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}", - nameof(UbiquitiController), - type, - camera_id, - camera_name, - score, - HttpContext.Request.Method, - HttpContext.Request.ContentType, - HttpContext.Request.ContentLength); - - var eventType = type?.ToLowerInvariant() switch - { - "person" => UbiquitiEventType.SmartDetectPerson, - "vehicle" => UbiquitiEventType.SmartDetectVehicle, - "animal" => UbiquitiEventType.SmartDetectAnimal, - "package" => UbiquitiEventType.SmartDetectPackage, - _ => (UbiquitiEventType?)null, - }; - if (eventType is null) - { - logger.LogWarning( - "{ClassName} unknown smart detect type {Type} for {Method} {Path} with QueryString={QueryString}", - nameof(UbiquitiController), - type, - HttpContext.Request.Method, - HttpContext.Request.Path, - HttpContext.Request.QueryString.ToString()); - return TypedResults.BadRequest($"Unknown smart detection type '{type}'. Expected: person, vehicle, animal, package."); - } - - try - { - await ubiquitiQuerySvc.SendAlert( - eventType.Value, - camera_id, - camera_name, - score, - webhook, - cancellationToken); - return TypedResults.Ok("ok"); - } - catch (FormatException) - { - LogInvalidWebhook(logger, nameof(UbiquitiController), HttpContext.Request.ContentLength); - return TypedResults.BadRequest("Invalid UniFi Protect webhook payload."); - } + var result = await ubiquitiQuerySvc.SendSmartDetectionAlert(type, camera_id, camera_name, score, webhook, cancellationToken); + return result.Error is { } error ? TypedResults.BadRequest(error) : TypedResults.Ok("ok"); } /// @@ -130,19 +60,19 @@ await ubiquitiQuerySvc.SendAlert( /// /// Optional camera identifier from the webhook payload. /// Optional camera display name from the webhook payload. + /// Cancellation token. [AllowAnonymous] [HttpGet("event/ring")] [HttpPost("event/ring")] - public async Task> Ring([FromQuery] string? camera_id = null, [FromQuery] string? camera_name = null) + public async Task, BadRequest>> Ring( + [FromQuery] string? camera_id = null, + [FromQuery] string? camera_name = null, + CancellationToken cancellationToken = default) { - await ubiquitiQuerySvc.SendAlert(UbiquitiEventType.Ring, camera_id, camera_name); - return TypedResults.Ok("ok"); + var result = await ubiquitiQuerySvc.SendAlert(UbiquitiEventType.Ring, camera_id, camera_name, cancellationToken: cancellationToken); + return result.Error is { } error ? TypedResults.BadRequest(error) : TypedResults.Ok("ok"); } #endregion - [LoggerMessage( - Level = LogLevel.Warning, - Message = "{ClassName} rejected an invalid UniFi Protect webhook payload with ContentLength={ContentLength}")] - private static partial void LogInvalidWebhook(ILogger logger, string className, long? contentLength); } diff --git a/src/CasCap.Api.Ubiquiti/Models/UbiquitiWebhookResult.cs b/src/CasCap.Api.Ubiquiti/Models/UbiquitiWebhookResult.cs new file mode 100644 index 0000000..d081234 --- /dev/null +++ b/src/CasCap.Api.Ubiquiti/Models/UbiquitiWebhookResult.cs @@ -0,0 +1,9 @@ +namespace CasCap.Models; + +/// Describes webhook acceptance or a payload validation failure. +public sealed record UbiquitiWebhookResult +{ + /// Gets the validation error, or null after successful dispatch. + /// Invalid UniFi Protect webhook payload. + public string? Error { get; init; } +} diff --git a/src/CasCap.Api.Ubiquiti/README.md b/src/CasCap.Api.Ubiquiti/README.md index b349a81..07b9fa1 100644 --- a/src/CasCap.Api.Ubiquiti/README.md +++ b/src/CasCap.Api.Ubiquiti/README.md @@ -26,7 +26,15 @@ Smart-detection types are `person`, `vehicle`, `animal` and `package`. The library is built around webhook endpoints that form the core pipeline: -**`UbiquitiController`** – Exposes a `GET /` snapshot query endpoint and REST API webhook endpoints (`/event/motion`, `/event/smart?type=person|vehicle|animal|package`, `/event/ring`) that the UniFi Protect controller or third-party automation tools call when events occur. Each webhook creates a `UbiquitiEvent` containing the event type, camera identifier, camera name, timestamp, and optional smart detection confidence score, then dispatches it in parallel to every registered [`IEventSink`](https://github.com/f2calv/CasCap.Common/blob/main/src/CasCap.Common.Abstractions/Abstractions/IEventSink%7BT%7D.cs). +**`UbiquitiController`** – Exposes a `GET /` snapshot query endpoint and REST API webhook endpoints (`/event/motion`, `/event/smart?type=person|vehicle|animal|package`, `/event/ring`) that the UniFi Protect controller or third-party automation tools call when events occur. +The controller delegates to +[`IUbiquitiQueryService`](./Abstractions/IUbiquitiQueryService.cs), which validates the detection +type, decodes the optional thumbnail and constructs each `UbiquitiEvent` before dispatching to +every registered [`IEventSink`](https://github.com/f2calv/CasCap.Common/blob/main/src/CasCap.Common.Abstractions/Abstractions/IEventSink%7BT%7D.cs). +The service returns `UbiquitiWebhookResult`; invalid detection types or malformed thumbnail/timestamp +values become HTTP 400 string responses. Automatic binding errors use validation Problem Details. +Sink failures are not reclassified as caller errors, and cancellation propagates through all +webhooks, including doorbell rings. All payload parsing finishes before any sink receives an event. ### Sinks @@ -58,8 +66,10 @@ flowchart TD RING["/event/ring"] end - BUILD["Build UbiquitiEvent\n(EventId, EventType, CameraId, CameraName, Timestamp, Score)"] - DISPATCH["Fan-out to all sinks\nTask.WhenAll"] + subgraph QueryService["UbiquitiQueryService"] + BUILD["Validate payload and build UbiquitiEvent\n(EventId, EventType, CameraId, CameraName, Timestamp, Score)"] + DISPATCH["Fan-out to all sinks\nTask.WhenAll"] + end SINK_CONSOLE["Console Sink\n(logger)"] SINK_MEMORY["Memory Sink\n(in-memory snapshot)"] diff --git a/src/CasCap.Api.Ubiquiti/Services/UbiquitiQueryService.cs b/src/CasCap.Api.Ubiquiti/Services/UbiquitiQueryService.cs index 7947a93..953f7ec 100644 --- a/src/CasCap.Api.Ubiquiti/Services/UbiquitiQueryService.cs +++ b/src/CasCap.Api.Ubiquiti/Services/UbiquitiQueryService.cs @@ -18,7 +18,7 @@ public async Task GetSnapshot() } /// - public async Task SendAlert( + public async Task SendAlert( UbiquitiEventType type, string? cameraId = null, string? cameraName = null, @@ -26,8 +26,20 @@ public async Task SendAlert( UbiquitiWebhookRequest? webhook = null, CancellationToken cancellationToken = default) { - var (thumbnail, thumbnailMimeType) = DecodeThumbnail(webhook?.Alarm?.Thumbnail); - var timestampUtc = GetTimestampUtc(webhook?.Timestamp); + cancellationToken.ThrowIfCancellationRequested(); + byte[]? thumbnail; + string? thumbnailMimeType; + DateTime timestampUtc; + try + { + (thumbnail, thumbnailMimeType) = DecodeThumbnail(webhook?.Alarm?.Thumbnail); + timestampUtc = GetTimestampUtc(webhook?.Timestamp); + } + catch (FormatException) + { + logger.LogWarning("{ClassName} rejected an invalid UniFi Protect webhook payload", nameof(UbiquitiQueryService)); + return new UbiquitiWebhookResult { Error = "Invalid UniFi Protect webhook payload." }; + } var webhookCameraIds = webhook?.Alarm?.Triggers .Select(trigger => trigger.Device) .Where(id => id is not null) @@ -37,17 +49,19 @@ public async Task SendAlert( if (cameraId is not null || webhookCameraIds.Length == 0) { await DispatchAsync(cameraId, cancellationToken).ConfigureAwait(false); - return; + return new UbiquitiWebhookResult(); } foreach (var webhookCameraId in webhookCameraIds) await DispatchAsync(webhookCameraId, cancellationToken).ConfigureAwait(false); + return new UbiquitiWebhookResult(); async Task DispatchAsync(string? sourceCameraId, CancellationToken token) { + token.ThrowIfCancellationRequested(); var eventCameraId = MaskCameraId(sourceCameraId); - logger.LogInformation("{ClassName} sending alert for event type {EventType} from camera {CameraName}", - nameof(UbiquitiQueryService), type, cameraName ?? eventCameraId ?? "unknown"); + logger.LogInformation("{ClassName} sending alert for event type {EventType}", + nameof(UbiquitiQueryService), type); var ubiquitiEvent = new UbiquitiEvent { @@ -68,6 +82,34 @@ async Task DispatchAsync(string? sourceCameraId, CancellationToken token) } } + /// + public Task SendSmartDetectionAlert( + string type, + string? cameraId = null, + string? cameraName = null, + double? score = null, + UbiquitiWebhookRequest? webhook = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var eventType = type?.ToLowerInvariant() switch + { + "person" => UbiquitiEventType.SmartDetectPerson, + "vehicle" => UbiquitiEventType.SmartDetectVehicle, + "animal" => UbiquitiEventType.SmartDetectAnimal, + "package" => UbiquitiEventType.SmartDetectPackage, + _ => (UbiquitiEventType?)null, + }; + if (eventType is { } detection) + return SendAlert(detection, cameraId, cameraName, score, webhook, cancellationToken); + + logger.LogWarning("{ClassName} rejected an unknown smart detection type", nameof(UbiquitiQueryService)); + return Task.FromResult(new UbiquitiWebhookResult + { + Error = $"Unknown smart detection type '{type}'. Expected: person, vehicle, animal, package.", + }); + } + private static (byte[]? Bytes, string? MimeType) DecodeThumbnail(string? thumbnail) { if (string.IsNullOrWhiteSpace(thumbnail)) diff --git a/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs b/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs index c13d391..01a8a00 100644 --- a/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs +++ b/src/CasCap.App.Server.Tests/Infrastructure/DeviceApiWebApplicationFactory.cs @@ -18,6 +18,8 @@ public sealed class DeviceApiWebApplicationFactory : CasCapAppWebApplicationFact public List<(string Id, string Value, CancellationToken Token)> DataPointCommands { get; } = []; + public List<(UbiquitiEvent Event, CancellationToken Token)> CameraEvents { get; } = []; + /// protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -30,16 +32,30 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) .AddApplicationPart(typeof(EdgeHardwareController).Assembly) .AddApplicationPart(typeof(SicceController).Assembly) .AddApplicationPart(typeof(ShellyController).Assembly) - .AddApplicationPart(typeof(WizController).Assembly); + .AddApplicationPart(typeof(WizController).Assembly) + .AddApplicationPart(typeof(UbiquitiController).Assembly); services.AddSingleton(new BuderusQueries(this)); services.AddSingleton(new FroniusQueries(this)); services.AddSingleton(new EdgeQueries(this)); services.AddSingleton(new SicceQueries(this)); services.AddSingleton(new ShellyQueries(this)); services.AddSingleton(new WizQueries(this)); + services.AddSingleton(provider => new UbiquitiQueryService( + provider.GetRequiredService>(), TimeProvider.System, [new CameraSink(this)])); }); } + private sealed class CameraSink(DeviceApiWebApplicationFactory factory) : IEventSink + { + public string SinkType => "Recording"; + + public Task WriteEvent(UbiquitiEvent @event, CancellationToken cancellationToken = default) + { + factory.CameraEvents.Add((@event, cancellationToken)); + return Task.CompletedTask; + } + } + private IAsyncEnumerable RecordHistory(CancellationToken cancellationToken) { HistoryTokens.Add(cancellationToken); diff --git a/src/CasCap.App.Server.Tests/README.md b/src/CasCap.App.Server.Tests/README.md index 114ed31..ea049d5 100644 --- a/src/CasCap.App.Server.Tests/README.md +++ b/src/CasCap.App.Server.Tests/README.md @@ -26,8 +26,10 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | `HostStartupConfigurationTests` | 1 | 1 | Integration | Options binding, Key Vault disablement, feature selection, and safe core DI services | | `McpAuthenticationTests` | 2 | 2 | Integration | Anonymous rejection and Basic-authenticated MCP endpoint access | | `SystemControllerTests` | 4 | 4 | Integration | `GET /api/system` and Basic authentication behavior | +| `UbiquitiApiContractTests` | 4 | 11 | Integration | GET/POST webhooks, optional bodies, validation, request cancellation, and generated Swagger | +| `UbiquitiQueryServiceTests` | 7 | 21 | Webhook Parsing | Detection types, thumbnails/timestamps, camera selection, sink failures, and cancellation using in-memory sinks | | `WebApiContractTests` | 4 | 8 | Integration | String-enum JSON output/input, validation Problem Details, and generated Swagger schemas | -| Total | 36 | 90 | | Source-declared, including skipped cases | +| Total | 47 | 122 | | Source-declared, including skipped cases | ## Trait Categories @@ -37,6 +39,7 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | `Device Commands` | Uses synthetic configuration and fake HTTP; never contacts live devices | | `Integration` | Boots the in-process ASP.NET Core host and exercises its services or endpoints | | `Leadership` | Credential-free DDNS ownership wiring and cancellation | +| `Webhook Parsing` | Deterministic payload parsing and in-memory dispatch, without camera or storage calls | `InitializeConfiguration` owns the standard provider chain and unconditionally adds the required `appsettings.json`, user secrets, and environment variables. It therefore cannot be exercised with @@ -80,11 +83,13 @@ CasCap.App.Server.Tests/ │ ├── Integration/ │ │ ├── DeviceApiContractTests.cs │ │ ├── HostStartupConfigurationTests.cs +│ │ ├── UbiquitiApiContractTests.cs │ │ └── WebApiContractTests.cs │ └── Unit/ │ ├── BuderusQueryServiceTests.cs │ ├── DDnsLeadershipTests.cs -│ └── FeatureConfigTests.cs +│ ├── FeatureConfigTests.cs +│ └── UbiquitiQueryServiceTests.cs ├── GlobalUsings.cs └── xunit.runner.json ``` diff --git a/src/CasCap.App.Server.Tests/Tests/Integration/UbiquitiApiContractTests.cs b/src/CasCap.App.Server.Tests/Tests/Integration/UbiquitiApiContractTests.cs new file mode 100644 index 0000000..3bb2ddc --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Integration/UbiquitiApiContractTests.cs @@ -0,0 +1,91 @@ +using CasCap.Tests.Infrastructure; +using System.Net; +using System.Text; + +namespace CasCap.Tests; + +/// Exercises webhook HTTP contracts with real parsing and an in-memory sink. +[Trait("Category", "Integration")] +public sealed class UbiquitiApiContractTests(DeviceApiWebApplicationFactory factory) + : IClassFixture +{ + [Theory] + [InlineData("GET", "motion", UbiquitiEventType.Motion)] + [InlineData("POST", "motion", UbiquitiEventType.Motion)] + [InlineData("GET", "smart?type=PeRsOn", UbiquitiEventType.SmartDetectPerson)] + [InlineData("POST", "smart?type=PeRsOn", UbiquitiEventType.SmartDetectPerson)] + [InlineData("GET", "ring", UbiquitiEventType.Ring)] + [InlineData("POST", "ring", UbiquitiEventType.Ring)] + public async Task WebhookRoutes_AcceptOptionalBodiesAndForwardCancellation(string method, string route, UbiquitiEventType expected) + { + using var client = factory.CreateClient(); + var before = factory.CameraEvents.Count; + using var request = new HttpRequestMessage(new HttpMethod(method), $"/api/v1/Ubiquiti/event/{route}"); + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal("\"ok\"", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(before + 1, factory.CameraEvents.Count); + Assert.Equal(expected, factory.CameraEvents[^1].Event.UbiquitiEventType); + Assert.True(factory.CameraEvents[^1].Token.CanBeCanceled); + } + + [Theory] + [InlineData("motion")] + [InlineData("smart?type=person")] + public async Task InvalidThumbnail_ReturnsPayloadRejection(string route) + { + using var client = factory.CreateClient(); + var before = factory.CameraEvents.Count; + using var content = new StringContent("{\"alarm\":{\"thumbnail\":\"invalid-base64!\"}}", Encoding.UTF8, "application/json"); + using var response = await client.PostAsync($"/api/v1/Ubiquiti/event/{route}", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("\"Invalid UniFi Protect webhook payload.\"", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(before, factory.CameraEvents.Count); + } + + [Theory] + [InlineData("?type=unsupported", false)] + [InlineData("", true)] + public async Task InvalidDetection_ReturnsExplicitOrAutomaticValidation(string query, bool automatic) + { + using var client = factory.CreateClient(); + var before = factory.CameraEvents.Count; + using var response = await client.GetAsync($"/api/v1/Ubiquiti/event/smart{query}", TestContext.Current.CancellationToken); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(before, factory.CameraEvents.Count); + if (automatic) + { + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.True(body.RootElement.GetProperty("errors").TryGetProperty("type", out _)); + } + else + Assert.Equal("Unknown smart detection type 'unsupported'. Expected: person, vehicle, animal, package.", body.RootElement.GetString()); + } + + [Fact] + public async Task OpenApi_DescribesBothWebhookMethodsAndOutcomes() + { + using var client = factory.CreateAuthorizedClient(); + using var response = await client.GetAsync("/swagger/v1.json", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var paths = document.RootElement.GetProperty("paths"); + + foreach (var route in new[] { "motion", "smart", "ring" }) + { + var path = Assert.Single(paths.EnumerateObject(), property => + property.Name.Equals($"/api/v1/Ubiquiti/event/{route}", StringComparison.OrdinalIgnoreCase)).Value; + foreach (var method in new[] { "get", "post" }) + { + var responses = path.GetProperty(method).GetProperty("responses"); + Assert.Equal("string", responses.GetProperty("200").GetProperty("content").GetProperty("application/json").GetProperty("schema").GetProperty("type").GetString()); + Assert.True(responses.TryGetProperty("400", out _)); + } + } + } +} diff --git a/src/CasCap.App.Server.Tests/Tests/Unit/UbiquitiQueryServiceTests.cs b/src/CasCap.App.Server.Tests/Tests/Unit/UbiquitiQueryServiceTests.cs new file mode 100644 index 0000000..239f946 --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Unit/UbiquitiQueryServiceTests.cs @@ -0,0 +1,167 @@ +using CasCap.Models.Dtos; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CasCap.Tests; + +/// Checks webhook parsing and dispatch using in-memory sinks only. +[Trait("Category", "Webhook Parsing")] +public sealed class UbiquitiQueryServiceTests +{ + [Theory] + [InlineData("person", UbiquitiEventType.SmartDetectPerson)] + [InlineData("PeRsOn", UbiquitiEventType.SmartDetectPerson)] + [InlineData("vehicle", UbiquitiEventType.SmartDetectVehicle)] + [InlineData("VEHICLE", UbiquitiEventType.SmartDetectVehicle)] + [InlineData("animal", UbiquitiEventType.SmartDetectAnimal)] + [InlineData("Animal", UbiquitiEventType.SmartDetectAnimal)] + [InlineData("package", UbiquitiEventType.SmartDetectPackage)] + [InlineData("PACKAGE", UbiquitiEventType.SmartDetectPackage)] + public async Task SmartDetection_ParsesSupportedTypes(string type, UbiquitiEventType expected) + { + var sink = new RecordingSink(); + var webhook = new UbiquitiWebhookRequest + { + Timestamp = 1000, + Alarm = new() { Thumbnail = "data:image/png;base64,AQID" }, + }; + + var result = await CreateService(sink).SendSmartDetectionAlert( + type, "camera-example", "Example", 0.9, webhook, TestContext.Current.CancellationToken); + + Assert.Null(result.Error); + var alert = Assert.Single(sink.Events); + Assert.Equal(expected, alert.UbiquitiEventType); + Assert.Equal("camera-example", alert.SourceCameraId); + Assert.Equal("***mple", alert.CameraId); + Assert.Equal("Example", alert.CameraName); + Assert.Equal(0.9, alert.Score); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1000).UtcDateTime, alert.DateCreatedUtc); + Assert.Equal(new byte[] { 1, 2, 3 }, alert.Thumbnail); + Assert.Equal("image/png", alert.ThumbnailMimeType); + Assert.Equal(TestContext.Current.CancellationToken, sink.Token); + } + + [Theory] + [InlineData("")] + [InlineData("unsupported")] + public async Task UnknownDetection_DoesNotDispatch(string type) + { + var sink = new RecordingSink(); + + var result = await CreateService(sink).SendSmartDetectionAlert(type, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal($"Unknown smart detection type '{type}'. Expected: person, vehicle, animal, package.", result.Error); + Assert.Empty(sink.Events); + } + + [Theory] + [InlineData("invalid-base64!", null)] + [InlineData("data:image/jpeg;base64", null)] + [InlineData("data:image/jpeg,AQID", null)] + [InlineData("data:audio/wav;base64,AQID", null)] + [InlineData(null, long.MaxValue)] + public async Task InvalidPayload_DoesNotDispatch(string? thumbnail, long? timestamp) + { + var sink = new RecordingSink(); + var webhook = new UbiquitiWebhookRequest { Timestamp = timestamp, Alarm = new() { Thumbnail = thumbnail } }; + + var result = await CreateService(sink).SendAlert( + UbiquitiEventType.Motion, webhook: webhook, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("Invalid UniFi Protect webhook payload.", result.Error); + Assert.Empty(sink.Events); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CameraSelection_PreservesExplicitOverrideAndDeduplicatesTriggers(bool explicitCamera) + { + var sink = new RecordingSink(); + var result = await CreateService(sink).SendAlert( + UbiquitiEventType.Motion, explicitCamera ? "camera-override" : null, + webhook: TriggerPayload(), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Null(result.Error); + string[] expected = explicitCamera ? ["camera-override"] : ["camera-one", "camera-two"]; + Assert.Equal(expected, sink.Events.Select(alert => alert.SourceCameraId)); + Assert.All(sink.Events, alert => Assert.Equal(FixedClock.UtcNow.UtcDateTime, alert.DateCreatedUtc)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SinkFailures_AreNotPayloadRejections(bool cancelled) + { + Exception failure = cancelled ? new OperationCanceledException() : new FormatException("Synthetic sink failure."); + var sink = new RecordingSink { Failure = failure }; + + var thrown = await Assert.ThrowsAsync(failure.GetType(), async () => + await CreateService(sink).SendAlert(UbiquitiEventType.Motion, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Same(failure, thrown); + } + + [Fact] + public async Task CancelledRequest_DoesNotDispatch() + { + var sink = new RecordingSink(); + using var stopping = new CancellationTokenSource(); + await stopping.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + CreateService(sink).SendAlert(UbiquitiEventType.Ring, cancellationToken: stopping.Token)); + + Assert.Empty(sink.Events); + } + + [Fact] + public async Task CancellationBetweenCameras_StopsFurtherDispatch() + { + using var stopping = new CancellationTokenSource(); + var sink = new RecordingSink { AfterWrite = stopping.Cancel }; + + await Assert.ThrowsAnyAsync(() => + CreateService(sink).SendAlert(UbiquitiEventType.Motion, webhook: TriggerPayload(), cancellationToken: stopping.Token)); + + Assert.Single(sink.Events); + Assert.Equal(stopping.Token, sink.Token); + } + + private static UbiquitiQueryService CreateService(RecordingSink sink) + => new(NullLogger.Instance, new FixedClock(), [sink]); + + private static UbiquitiWebhookRequest TriggerPayload() + => new() + { + Alarm = new() + { + Triggers = [new() { Device = "camera-one" }, new() { Device = "camera-one" }, new() { Device = "camera-two" }], + }, + }; + + private sealed class FixedClock : TimeProvider + { + public static DateTimeOffset UtcNow { get; } = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => UtcNow; + } + + private sealed class RecordingSink : IEventSink + { + public string SinkType => "Recording"; + public List Events { get; } = []; + public CancellationToken Token { get; private set; } + public Exception? Failure { get; init; } + public Action? AfterWrite { get; init; } + + public Task WriteEvent(UbiquitiEvent @event, CancellationToken cancellationToken = default) + { + Token = cancellationToken; + if (Failure is { } failure) + return Task.FromException(failure); + Events.Add(@event); + AfterWrite?.Invoke(); + return Task.CompletedTask; + } + } +} From 76bf3c738564d086d68666143caf10c4deca982d Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Thu, 22 Oct 2026 18:40:08 +0200 Subject: [PATCH 09/18] fix(miele): standardize appliance API contracts - Validate typed action and program bodies before provider dispatch - Propagate cancellation and map missing, rejected and failed outcomes explicitly - Prevent command replay and verify fake-provider HTTP and MCP contracts --- requests/miele-controller.http | 6 +- .../Abstractions/IMieleQueryService.cs | 29 +- .../Controllers/MieleController.cs | 66 +++-- .../Exceptions/MieleApiException.cs | 11 + .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../Models/Dtos/MieleActionRequest.cs | 93 ++++++ .../Models/Dtos/MieleProgramRequest.cs | 32 ++ .../Dtos/MieleTargetTemperatureRequest.cs | 18 ++ src/CasCap.Api.Miele/Models/_Enums.cs | 13 + src/CasCap.Api.Miele/README.md | 49 +++- .../Services/MieleApiExceptionHandler.cs | 23 ++ .../Services/MieleClientService.cs | 239 +++++++-------- .../Services/MieleQueryService.cs | 64 ++-- .../MieleApiWebApplicationFactory.cs | 35 +++ .../Infrastructure/MieleHttpHandler.cs | 75 +++++ src/CasCap.App.Server.Tests/README.md | 12 +- .../Integration/MieleApiContractTests.cs | 273 ++++++++++++++++++ .../Integration/MieleMcpContractTests.cs | 111 +++++++ src/CasCap.Backend/README.md | 6 + .../Services/Mcp/AppliancesMcpQueryService.cs | 53 ++-- 20 files changed, 999 insertions(+), 215 deletions(-) create mode 100644 src/CasCap.Api.Miele/Exceptions/MieleApiException.cs create mode 100644 src/CasCap.Api.Miele/Models/Dtos/MieleActionRequest.cs create mode 100644 src/CasCap.Api.Miele/Models/Dtos/MieleProgramRequest.cs create mode 100644 src/CasCap.Api.Miele/Models/Dtos/MieleTargetTemperatureRequest.cs create mode 100644 src/CasCap.Api.Miele/Services/MieleApiExceptionHandler.cs create mode 100644 src/CasCap.App.Server.Tests/Infrastructure/MieleApiWebApplicationFactory.cs create mode 100644 src/CasCap.App.Server.Tests/Infrastructure/MieleHttpHandler.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Integration/MieleApiContractTests.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Integration/MieleMcpContractTests.cs diff --git a/requests/miele-controller.http b/requests/miele-controller.http index c60cc57..bc49d8e 100644 --- a/requests/miele-controller.http +++ b/requests/miele-controller.http @@ -1,5 +1,7 @@ ### Miele Controller (internal Web API) ### Base URL is the Haus application server +# Commands use typed bodies; unknown fields (including runOnTime) return 400. +# Missing resources return 404; provider failures return sanitized 502 Problem Details. @baseUrl = {{$dotenv HAUS_BASE_URL}} @deviceId = {{$dotenv MIELE_DEVICE_ID}} @@ -53,5 +55,7 @@ Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "programId": 1 + "programId": 24, + "duration": [1, 15], + "temperature": 220 } diff --git a/src/CasCap.Api.Miele/Abstractions/IMieleQueryService.cs b/src/CasCap.Api.Miele/Abstractions/IMieleQueryService.cs index 70d3449..4fbc1b9 100644 --- a/src/CasCap.Api.Miele/Abstractions/IMieleQueryService.cs +++ b/src/CasCap.Api.Miele/Abstractions/IMieleQueryService.cs @@ -9,59 +9,68 @@ public interface IMieleQueryService /// Returns all Miele appliances linked to the user account with their identification and current state. /// /// Language code for localized values (e.g. 'en', 'de'). Default 'en'. - public Task?> GetDevices(string language = "en"); + /// Cancellation token. + public Task?> GetDevices(string language = "en", CancellationToken cancellationToken = default); /// /// Returns a compact summary of all Miele appliances with serial number, state and type. /// /// Language code for localized values (e.g. 'en', 'de'). Default 'en'. - public Task GetShortDevices(string language = "en"); + /// Cancellation token. + public Task GetShortDevices(string language = "en", CancellationToken cancellationToken = default); /// /// Returns full identification and state for a single Miele appliance. /// /// The device ID (serial number) to query. /// Language code for localized values (e.g. 'en', 'de'). Default 'en'. - public Task GetDevice(string deviceId, string language = "en"); + /// Cancellation token. + public Task GetDevice(string deviceId, string language = "en", CancellationToken cancellationToken = default); /// /// Returns identification information for a single Miele appliance. /// /// The device ID (serial number) to query. /// Language code for localized values (e.g. 'en', 'de'). Default 'en'. - public Task GetIdent(string deviceId, string language = "en"); + /// Cancellation token. + public Task GetIdent(string deviceId, string language = "en", CancellationToken cancellationToken = default); /// /// Returns the current operational state of a single Miele appliance. /// /// The device ID (serial number) to query. /// Language code for localized values (e.g. 'en', 'de'). Default 'en'. - public Task GetState(string deviceId, string language = "en"); + /// Cancellation token. + public Task GetState(string deviceId, string language = "en", CancellationToken cancellationToken = default); /// /// Returns the currently available actions for a Miele appliance. /// /// The device ID (serial number) to query. - public Task GetActions(string deviceId); + /// Cancellation token. + public Task GetActions(string deviceId, CancellationToken cancellationToken = default); /// /// Invokes an action on a Miele appliance (e.g. powerOn, powerOff, start, stop). /// /// The device ID (serial number) to act on. - /// The action payload object. - public Task PutAction(string deviceId, object action); + /// The typed action payload. + /// Cancellation token. + public Task PutAction(string deviceId, MieleActionRequest action, CancellationToken cancellationToken = default); /// /// Returns the available programs for a Miele appliance. /// /// The device ID (serial number) to query. /// Language code for localized values (e.g. 'en', 'de'). Default 'en'. - public Task GetPrograms(string deviceId, string language = "en"); + /// Cancellation token. + public Task GetPrograms(string deviceId, string language = "en", CancellationToken cancellationToken = default); /// /// Selects and starts a program on a Miele appliance. The device must be set to MobileStart or MobileControl. /// /// The device ID (serial number) to act on. /// The program selection payload. - public Task PutProgram(string deviceId, object programRequest); + /// Cancellation token. + public Task PutProgram(string deviceId, MieleProgramRequest programRequest, CancellationToken cancellationToken = default); } diff --git a/src/CasCap.Api.Miele/Controllers/MieleController.cs b/src/CasCap.Api.Miele/Controllers/MieleController.cs index c31f9af..5b32c13 100644 --- a/src/CasCap.Api.Miele/Controllers/MieleController.cs +++ b/src/CasCap.Api.Miele/Controllers/MieleController.cs @@ -7,50 +7,80 @@ namespace CasCap.Controllers; [ApiVersion(1.0)] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] +[ProducesResponseType(StatusCodes.Status502BadGateway)] public sealed class MieleController(IMieleQueryService mieleQuerySvc) : ControllerBase { /// [HttpGet("devices")] - public async Task>> GetDevices([FromQuery] string language = "en") - => TypedResults.Ok(await mieleQuerySvc.GetDevices(language)); + public async Task>, NotFound>> GetDevices( + [FromQuery] string language = "en", CancellationToken cancellationToken = default) + => await mieleQuerySvc.GetDevices(language, cancellationToken) is { } devices + ? TypedResults.Ok(devices) : TypedResults.NotFound(); /// [HttpGet("devices/short")] - public async Task> GetShortDevices([FromQuery] string language = "en") - => TypedResults.Ok(await mieleQuerySvc.GetShortDevices(language)); + public async Task, NotFound>> GetShortDevices( + [FromQuery] string language = "en", CancellationToken cancellationToken = default) + => await mieleQuerySvc.GetShortDevices(language, cancellationToken) is { } devices + ? TypedResults.Ok(devices) : TypedResults.NotFound(); /// [HttpGet("devices/{deviceId}")] - public async Task> GetDevice(string deviceId, [FromQuery] string language = "en") - => TypedResults.Ok(await mieleQuerySvc.GetDevice(deviceId, language)); + public async Task, NotFound>> GetDevice( + string deviceId, [FromQuery] string language = "en", CancellationToken cancellationToken = default) + => await mieleQuerySvc.GetDevice(deviceId, language, cancellationToken) is { } device + ? TypedResults.Ok(device) : TypedResults.NotFound(); /// [HttpGet("devices/{deviceId}/ident")] - public async Task> GetIdent(string deviceId, [FromQuery] string language = "en") - => TypedResults.Ok(await mieleQuerySvc.GetIdent(deviceId, language)); + public async Task, NotFound>> GetIdent( + string deviceId, [FromQuery] string language = "en", CancellationToken cancellationToken = default) + => await mieleQuerySvc.GetIdent(deviceId, language, cancellationToken) is { } ident + ? TypedResults.Ok(ident) : TypedResults.NotFound(); /// [HttpGet("devices/{deviceId}/state")] - public async Task> GetState(string deviceId, [FromQuery] string language = "en") - => TypedResults.Ok(await mieleQuerySvc.GetState(deviceId, language)); + public async Task, NotFound>> GetState( + string deviceId, [FromQuery] string language = "en", CancellationToken cancellationToken = default) + => await mieleQuerySvc.GetState(deviceId, language, cancellationToken) is { } state + ? TypedResults.Ok(state) : TypedResults.NotFound(); /// [HttpGet("devices/{deviceId}/actions")] - public async Task> GetActions(string deviceId) - => TypedResults.Ok(await mieleQuerySvc.GetActions(deviceId)); + public async Task, NotFound>> GetActions(string deviceId, CancellationToken cancellationToken = default) + => await mieleQuerySvc.GetActions(deviceId, cancellationToken) is { } actions + ? TypedResults.Ok(actions) : TypedResults.NotFound(); /// [HttpPut("devices/{deviceId}/actions")] - public async Task> PutAction(string deviceId, [FromBody] object action) - => TypedResults.Ok(await mieleQuerySvc.PutAction(deviceId, action)); + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task, BadRequest, NotFound>> PutAction( + string deviceId, [FromBody] MieleActionRequest action, CancellationToken cancellationToken = default) + => await mieleQuerySvc.PutAction(deviceId, action, cancellationToken) switch + { + MieleCommandResult.Accepted => TypedResults.Ok(true), + MieleCommandResult.Rejected => TypedResults.BadRequest("The appliance rejected the action."), + MieleCommandResult.NotFound => TypedResults.NotFound(), + _ => throw new System.Diagnostics.UnreachableException(), + }; /// [HttpGet("devices/{deviceId}/programs")] - public async Task> GetPrograms(string deviceId, [FromQuery] string language = "en") - => TypedResults.Ok(await mieleQuerySvc.GetPrograms(deviceId, language)); + public async Task, NotFound>> GetPrograms( + string deviceId, [FromQuery] string language = "en", CancellationToken cancellationToken = default) + => await mieleQuerySvc.GetPrograms(deviceId, language, cancellationToken) is { } programs + ? TypedResults.Ok(programs) : TypedResults.NotFound(); /// [HttpPut("devices/{deviceId}/programs")] - public async Task> PutProgram(string deviceId, [FromBody] object programRequest) - => TypedResults.Ok(await mieleQuerySvc.PutProgram(deviceId, programRequest)); + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task, BadRequest, NotFound>> PutProgram( + string deviceId, [FromBody] MieleProgramRequest programRequest, CancellationToken cancellationToken = default) + => await mieleQuerySvc.PutProgram(deviceId, programRequest, cancellationToken) switch + { + MieleCommandResult.Accepted => TypedResults.Ok(true), + MieleCommandResult.Rejected => TypedResults.BadRequest("The appliance rejected the program."), + MieleCommandResult.NotFound => TypedResults.NotFound(), + _ => throw new System.Diagnostics.UnreachableException(), + }; } diff --git a/src/CasCap.Api.Miele/Exceptions/MieleApiException.cs b/src/CasCap.Api.Miele/Exceptions/MieleApiException.cs new file mode 100644 index 0000000..a0d8808 --- /dev/null +++ b/src/CasCap.Api.Miele/Exceptions/MieleApiException.cs @@ -0,0 +1,11 @@ +using CasCap.Common.Exceptions; +using System.Net; + +namespace CasCap.Exceptions; + +/// Thrown when the upstream Miele API cannot provide a valid response. +public sealed class MieleApiException(string message, HttpStatusCode? statusCode = null) : GenericException(message) +{ + /// Gets the upstream HTTP status when a response was received. + public HttpStatusCode? StatusCode { get; } = statusCode; +} diff --git a/src/CasCap.Api.Miele/Extensions/ServiceCollectionExtensions.cs b/src/CasCap.Api.Miele/Extensions/ServiceCollectionExtensions.cs index dc4cbd6..bc5a300 100644 --- a/src/CasCap.Api.Miele/Extensions/ServiceCollectionExtensions.cs +++ b/src/CasCap.Api.Miele/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using CasCap.Common.Models; + namespace CasCap.Extensions; /// Extension methods for configuring Miele services. @@ -23,10 +25,12 @@ public static void AddMiele(this IServiceCollection services, IConfiguration con client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", opts.OAuthToken); }) .SetHandlerLifetime(Timeout.InfiniteTimeSpan) - .AddStandardResilience(nameof(MieleConnectionHealthCheck)) + .AddStandardResilience(nameof(MieleConnectionHealthCheck), HttpRetrySafety.Never) ; services.AddSingleton(); + services.AddProblemDetails(); + services.AddExceptionHandler(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/CasCap.Api.Miele/Models/Dtos/MieleActionRequest.cs b/src/CasCap.Api.Miele/Models/Dtos/MieleActionRequest.cs new file mode 100644 index 0000000..832f3c0 --- /dev/null +++ b/src/CasCap.Api.Miele/Models/Dtos/MieleActionRequest.cs @@ -0,0 +1,93 @@ +using System.Text.Json.Serialization; + +namespace CasCap.Models.Dtos; + +/// A partial appliance action supported by the Miele API. +/// Supported actions depend on the appliance and its current state. +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record MieleActionRequest : IValidatableObject +{ + // TODO: Add runOnTime only after Miele resolves its array schema versus scalar example: + // https://developer.miele.com/docs/third-party-api/swagger.json. + // Both shapes were considered; reject the field rather than guess a device command. + + /// Process action: start, stop, pause, or a cooling/freezing action (1-7). + /// 1 + [Description("Process action: 1 start, 2 stop, 3 pause, 4 start superfreezing, 5 stop superfreezing, 6 start supercooling, 7 stop supercooling.")] + [Range(1, 7)] + public int? ProcessAction { get; init; } + + /// Enables (1) or disables (2) the light. + /// 1 + [Description("Light action: 1 enable, 2 disable.")] + [Range(1, 2)] + public int? Light { get; init; } + + /// Enables (1) or disables (2) ambient lighting. + /// 2 + [Description("Ambient light action: 1 enable, 2 disable.")] + [Range(1, 2)] + public int? AmbientLight { get; init; } + + /// Ambient colour name or hexadecimal RGB value. + /// #8B0000 + [Description("Ambient colour name or hexadecimal RGB string.")] + public string? Colors { get; init; } + + /// Personal appliance display name. + /// Example appliance + [Description("Personal appliance display name.")] + public string? DeviceName { get; init; } + + /// Operation mode: normal (0), Sabbath (1), party (2), holiday (3). + /// 0 + [Description("Operation mode: 0 normal, 1 Sabbath, 2 party, 3 holiday.")] + [Range(0, 3)] + public int? Modes { get; init; } + + /// Requests appliance power-on. + /// true + [Description("Requests appliance power-on.")] + public bool? PowerOn { get; init; } + + /// Requests appliance power-off. + /// true + [Description("Requests appliance power-off.")] + public bool? PowerOff { get; init; } + + /// Appliance-specific program identifier. + /// 4 + [Description("Appliance-specific program identifier.")] + public int? ProgramId { get; init; } + + /// Relative start delay as hours and minutes. + /// [2,45] + [Description("Relative start delay as exactly two integers: hours and minutes.")] + [MinLength(2), MaxLength(2)] + public int[]? StartTime { get; init; } + + /// Target temperature values for explicitly selected zones. + /// [{"zone":2,"value":-20}] + [Description("Target temperatures by zone; values depend on the appliance.")] + [MinLength(1)] + public MieleTargetTemperatureRequest[]? TargetTemperature { get; init; } + + /// Ventilation level (1-4). + /// 3 + [Description("Ventilation level from 1 to 4; availability depends on the appliance.")] + [Range(1, 4)] + public int? VentilationStep { get; init; } + + /// + public IEnumerable Validate(ValidationContext validationContext) + { + if (ProcessAction is null && Light is null && AmbientLight is null && Colors is null && + DeviceName is null && Modes is null && PowerOn is null && PowerOff is null && + ProgramId is null && StartTime is null && TargetTemperature is null && VentilationStep is null) + yield return new ValidationResult("At least one supported appliance action is required."); + if (StartTime is { Length: 2 } time && (time[0] < 0 || time[1] is < 0 or > 59)) + yield return new ValidationResult("Start time must contain non-negative hours and minutes from 0 to 59.", [nameof(StartTime)]); + if (TargetTemperature is { } temperatures && temperatures.Any(temperature => temperature is null)) + yield return new ValidationResult("Every target temperature must specify a zone and value.", [nameof(TargetTemperature)]); + } +} diff --git a/src/CasCap.Api.Miele/Models/Dtos/MieleProgramRequest.cs b/src/CasCap.Api.Miele/Models/Dtos/MieleProgramRequest.cs new file mode 100644 index 0000000..018df8d --- /dev/null +++ b/src/CasCap.Api.Miele/Models/Dtos/MieleProgramRequest.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace CasCap.Models.Dtos; + +/// Selects an appliance program with optional duration and temperature. +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record MieleProgramRequest : IValidatableObject +{ + /// Appliance-specific program identifier. + /// 24 + [Description("Required appliance-specific program identifier.")] + [Required] + public int? ProgramId { get; init; } + + /// Optional program duration as hours and minutes. + /// [1,15] + [Description("Optional duration as exactly two integers: hours and minutes.")] + [MinLength(2), MaxLength(2)] + public int[]? Duration { get; init; } + + /// Optional program temperature, when supported by the appliance. + /// 220 + [Description("Optional program temperature; supported values depend on the appliance.")] + public int? Temperature { get; init; } + + /// + public IEnumerable Validate(ValidationContext validationContext) + { + if (Duration is { Length: 2 } time && (time[0] < 0 || time[1] is < 0 or > 59)) + yield return new ValidationResult("Duration must contain non-negative hours and minutes from 0 to 59.", [nameof(Duration)]); + } +} diff --git a/src/CasCap.Api.Miele/Models/Dtos/MieleTargetTemperatureRequest.cs b/src/CasCap.Api.Miele/Models/Dtos/MieleTargetTemperatureRequest.cs new file mode 100644 index 0000000..2503763 --- /dev/null +++ b/src/CasCap.Api.Miele/Models/Dtos/MieleTargetTemperatureRequest.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace CasCap.Models.Dtos; + +/// A target temperature for a selected appliance zone. +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record MieleTargetTemperatureRequest +{ + /// Appliance-specific zone identifier. + /// 2 + [Description("Appliance-specific zone identifier.")] + public required int Zone { get; init; } + + /// Target temperature; supported values depend on the appliance. + /// -20 + [Description("Target temperature; supported values depend on the appliance.")] + public required int Value { get; init; } +} diff --git a/src/CasCap.Api.Miele/Models/_Enums.cs b/src/CasCap.Api.Miele/Models/_Enums.cs index 4c32edd..3f157a6 100644 --- a/src/CasCap.Api.Miele/Models/_Enums.cs +++ b/src/CasCap.Api.Miele/Models/_Enums.cs @@ -1,5 +1,18 @@ namespace CasCap.Models; +/// The provider's outcome for an appliance command. +public enum MieleCommandResult +{ + /// The provider accepted the command. + Accepted, + + /// The provider rejected the command for this appliance or its current state. + Rejected, + + /// The appliance was not found. + NotFound, +} + /// Types of Miele appliance events. public enum MieleEventType { diff --git a/src/CasCap.Api.Miele/README.md b/src/CasCap.Api.Miele/README.md index af4ef4b..abd1136 100644 --- a/src/CasCap.Api.Miele/README.md +++ b/src/CasCap.Api.Miele/README.md @@ -21,9 +21,56 @@ var devices = await host.Services.GetRequiredService().GetDe ``` The client also exposes `GetDevice`, `GetIdent`, `GetState`, `GetActions` and `GetPrograms`. -`PutAction` and `PutProgram` change appliance state. Requests use the bearer token in `OAuthToken`; +`PutAction` and `PutProgram` change appliance state using +[`MieleActionRequest`](Models/Dtos/MieleActionRequest.cs) and +[`MieleProgramRequest`](Models/Dtos/MieleProgramRequest.cs). Requests use the bearer token in `OAuthToken`; the library does not acquire or refresh tokens. Obtain and rotate them outside the application. +## Commands and Failures + +All nine operations on [`IMieleQueryService`](Abstractions/IMieleQueryService.cs) accept cancellation, +forwarded through the HTTP controller and appliance MCP facade to the provider. Read methods return +`null` only for provider HTTP 404; empty or malformed successful responses and other unsuccessful +statuses throw [`MieleApiException`](Exceptions/MieleApiException.cs). + +Commands return `MieleCommandResult.Accepted`, `Rejected` or `NotFound`. Provider HTTP 400, 405, +409 and 422 mean rejection; HTTP 404 means absence. Authentication, rate-limit, transport, timeout +and other provider failures throw the domain exception. Caller cancellation propagates unchanged. +The named REST client's automatic retries are disabled for both reads and writes: an appliance PUT +may start a program, so replaying an outcome-uncertain command is unsafe. + +The application HTTP contract is: + +| Outcome | HTTP response | +| --- | --- | +| Successful read | 200 with the typed device/action/program payload | +| Accepted command | 200 with `true` | +| Missing resource | 404 | +| Invalid request body | 400 Validation Problem Details, without a provider call | +| Provider rejects a command | 400 with a sanitized rejection string | +| Provider failure | 502 Problem Details, without provider content or identifiers | + +Command bodies reject unknown fields and are validated before dispatch, including direct client +and MCP calls. Optional fields are omitted rather than sent as JSON null. An action requires at +least one supported field; program selection requires an explicit `programId`. Temperature and +program identifiers remain appliance-specific rather than imposing guessed bounds. + +```json +{"processAction":1} +``` + +```json +{"programId":24,"duration":[1,15],"temperature":220} +``` + +These are illustrative commands, not instructions to run them against an appliance. Obtain +currently supported actions and programs before selecting values. + +The request contracts were checked against the official +[Miele API specification, version 1.1.0](https://developer.miele.com/docs/third-party-api/swagger.json). +`runOnTime` is deliberately unsupported: the schema describes an integer array while its request +example uses a scalar. Both forms are rejected until the provider contract is clarified. + ## Event Flow Starting `MieleEventStreamBgService` requires the host's [`IBgFeature`](https://github.com/f2calv/CasCap.Common/blob/main/src/CasCap.Common.Abstractions/Abstractions/IBgFeature.cs) runner and health-check diff --git a/src/CasCap.Api.Miele/Services/MieleApiExceptionHandler.cs b/src/CasCap.Api.Miele/Services/MieleApiExceptionHandler.cs new file mode 100644 index 0000000..0dbf6f3 --- /dev/null +++ b/src/CasCap.Api.Miele/Services/MieleApiExceptionHandler.cs @@ -0,0 +1,23 @@ +using CasCap.Exceptions; +using Microsoft.AspNetCore.Diagnostics; + +namespace CasCap.Services; + +/// Maps Miele upstream failures to sanitized gateway Problem Details. +public sealed class MieleApiExceptionHandler : IExceptionHandler +{ + /// + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + if (exception is not MieleApiException) + return false; + + await TypedResults.Problem(new ProblemDetails + { + Status = StatusCodes.Status502BadGateway, + Title = "Miele API unavailable", + Detail = "The appliance provider could not complete the request.", + }).ExecuteAsync(httpContext); + return true; + } +} diff --git a/src/CasCap.Api.Miele/Services/MieleClientService.cs b/src/CasCap.Api.Miele/Services/MieleClientService.cs index 4d79269..5bce98c 100644 --- a/src/CasCap.Api.Miele/Services/MieleClientService.cs +++ b/src/CasCap.Api.Miele/Services/MieleClientService.cs @@ -1,4 +1,7 @@ -using CasCap.Common.Services; +using CasCap.Exceptions; +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; namespace CasCap.Services; @@ -9,8 +12,13 @@ namespace CasCap.Services; /// See for the full API specification. /// Uses OAuth 2.0 Bearer token authentication. /// -public sealed class MieleClientService : HttpClientBase +public sealed partial class MieleClientService : HttpClientBase { + private static readonly JsonSerializerOptions _requestJsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + /// /// Initializes a new instance of the class. /// @@ -28,99 +36,44 @@ public MieleClientService(ILogger logger, /// Returns all information about all appliances linked to the user account. /// /// Optional language code (e.g. "en", "de"). - public async Task?> GetDevices(string language = "en") - { - var requestUri = $"devices?language={language}"; - try - { - var tpl = await base.GetAsync, object>(requestUri); - return tpl.result; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{ClassName} failed to get devices", nameof(MieleClientService)); - } - return null; - } + /// Cancellation token. + public Task?> GetDevices(string language = "en", CancellationToken cancellationToken = default) + => ReadAsync>($"devices?language={Uri.EscapeDataString(language)}", cancellationToken); /// /// Returns a reduced information set about all appliances for lightweight clients. /// /// Optional language code (e.g. "en", "de"). - public async Task GetShortDevices(string language = "en") - { - var requestUri = $"short/devices?language={language}"; - try - { - var tpl = await base.GetAsync(requestUri); - return tpl.result; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{ClassName} failed to get short devices", nameof(MieleClientService)); - } - return null; - } + /// Cancellation token. + public Task GetShortDevices(string language = "en", CancellationToken cancellationToken = default) + => ReadAsync($"short/devices?language={Uri.EscapeDataString(language)}", cancellationToken); /// /// Returns all information about a single device. /// /// The device ID to query. /// Optional language code (e.g. "en", "de"). - public async Task GetDevice(string deviceId, string language = "en") - { - var requestUri = $"devices/{deviceId}?language={language}"; - try - { - var tpl = await base.GetAsync(requestUri); - return tpl.result; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{ClassName} failed to get device '{DeviceId}'", nameof(MieleClientService), deviceId); - } - return null; - } + /// Cancellation token. + public Task GetDevice(string deviceId, string language = "en", CancellationToken cancellationToken = default) + => ReadAsync($"{DevicePath(deviceId)}?language={Uri.EscapeDataString(language)}", cancellationToken); /// /// Returns the identification information of a single device. /// /// The device ID to query. /// Optional language code (e.g. "en", "de"). - public async Task GetIdent(string deviceId, string language = "en") - { - var requestUri = $"devices/{deviceId}/ident?language={language}"; - try - { - var tpl = await base.GetAsync(requestUri); - return tpl.result; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{ClassName} failed to get ident for device '{DeviceId}'", nameof(MieleClientService), deviceId); - } - return null; - } + /// Cancellation token. + public Task GetIdent(string deviceId, string language = "en", CancellationToken cancellationToken = default) + => ReadAsync($"{DevicePath(deviceId)}/ident?language={Uri.EscapeDataString(language)}", cancellationToken); /// /// Returns the state information of a single device. /// /// The device ID to query. /// Optional language code (e.g. "en", "de"). - public async Task GetState(string deviceId, string language = "en") - { - var requestUri = $"devices/{deviceId}/state?language={language}"; - try - { - var tpl = await base.GetAsync(requestUri); - return tpl.result; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{ClassName} failed to get state for device '{DeviceId}'", nameof(MieleClientService), deviceId); - } - return null; - } + /// Cancellation token. + public Task GetState(string deviceId, string language = "en", CancellationToken cancellationToken = default) + => ReadAsync($"{DevicePath(deviceId)}/state?language={Uri.EscapeDataString(language)}", cancellationToken); #endregion @@ -130,47 +83,19 @@ public MieleClientService(ILogger logger, /// Returns the current available actions for a device. /// /// The device ID to query. - public async Task GetActions(string deviceId) - { - var requestUri = $"devices/{deviceId}/actions"; - try - { - var tpl = await base.GetAsync(requestUri); - return tpl.result; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{ClassName} failed to get actions for device '{DeviceId}'", nameof(MieleClientService), deviceId); - } - return null; - } + /// Cancellation token. + public Task GetActions(string deviceId, CancellationToken cancellationToken = default) + => ReadAsync($"{DevicePath(deviceId)}/actions", cancellationToken); /// /// Invokes an action on a single device. /// /// The device ID to act on. /// The action payload (e.g. processAction, powerOn, light). - /// if the action was accepted (HTTP 204). - public async Task PutAction(string deviceId, object action) - { - var requestUri = $"devices/{deviceId}/actions"; - try - { - var response = await Client.PutAsJsonAsync(requestUri, action); - if (response.IsSuccessStatusCode) - { - _logger.LogInformation("{ClassName} action sent to device '{DeviceId}'", nameof(MieleClientService), deviceId); - return true; - } - _logger.LogWarning("{ClassName} action rejected for device '{DeviceId}': {StatusCode}", - nameof(MieleClientService), deviceId, response.StatusCode); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{ClassName} failed to put action for device '{DeviceId}'", nameof(MieleClientService), deviceId); - } - return false; - } + /// Cancellation token. + /// Acceptance, appliance absence or explicit command rejection. + public Task PutAction(string deviceId, MieleActionRequest action, CancellationToken cancellationToken = default) + => WriteAsync($"{DevicePath(deviceId)}/actions", action, cancellationToken); #endregion @@ -181,47 +106,93 @@ public async Task PutAction(string deviceId, object action) /// /// The device ID to query. /// Optional language code (e.g. "en", "de"). - public async Task GetPrograms(string deviceId, string language = "en") + /// Cancellation token. + public Task GetPrograms(string deviceId, string language = "en", CancellationToken cancellationToken = default) + => ReadAsync($"{DevicePath(deviceId)}/programs?language={Uri.EscapeDataString(language)}", cancellationToken); + + /// + /// Selects a program and starts the appliance. The device must be set to MobileStart or MobileControl. + /// + /// The device ID to act on. + /// The program selection payload. + /// Cancellation token. + /// Acceptance, appliance absence or explicit command rejection. + public Task PutProgram(string deviceId, MieleProgramRequest programRequest, CancellationToken cancellationToken = default) + => WriteAsync($"{DevicePath(deviceId)}/programs", programRequest, cancellationToken); + + #endregion + + private async Task ReadAsync(string uri, CancellationToken cancellationToken) where T : class { - var requestUri = $"devices/{deviceId}/programs?language={language}"; + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + if (!response.IsSuccessStatusCode) + throw Failure("The Miele API returned an unsuccessful response.", response.StatusCode); try { - var tpl = await base.GetAsync(requestUri); - return tpl.result; + return await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false) + ?? throw Failure("The Miele API returned an empty result.", response.StatusCode); } - catch (Exception ex) + catch (JsonException) { - _logger.LogWarning(ex, "{ClassName} failed to get programs for device '{DeviceId}'", nameof(MieleClientService), deviceId); + throw Failure("The Miele API returned invalid JSON.", response.StatusCode); } - return null; } - /// - /// Selects a program and starts the appliance. The device must be set to MobileStart or MobileControl. - /// - /// The device ID to act on. - /// The program selection payload. - /// if the program was accepted (HTTP 204). - public async Task PutProgram(string deviceId, object programRequest) + private async Task WriteAsync(string uri, T payload, CancellationToken cancellationToken) where T : class + { + cancellationToken.ThrowIfCancellationRequested(); + Validator.ValidateObject(payload, new ValidationContext(payload), validateAllProperties: true); + using var request = new HttpRequestMessage(HttpMethod.Put, uri) + { + Content = JsonContent.Create(payload, options: _requestJsonOptions), + }; + using var response = await SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + return MieleCommandResult.Accepted; + if (response.StatusCode == HttpStatusCode.NotFound) + return MieleCommandResult.NotFound; + if (response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.MethodNotAllowed or HttpStatusCode.Conflict or HttpStatusCode.UnprocessableEntity) + { + LogRejection(_logger, nameof(MieleClientService), response.StatusCode); + return MieleCommandResult.Rejected; + } + throw Failure("The Miele API returned an unsuccessful response.", response.StatusCode); + } + + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - var requestUri = $"devices/{deviceId}/programs"; try { - var response = await Client.PutAsJsonAsync(requestUri, programRequest); - if (response.IsSuccessStatusCode) - { - _logger.LogInformation("{ClassName} program started on device '{DeviceId}'", nameof(MieleClientService), deviceId); - return true; - } - _logger.LogWarning("{ClassName} program rejected for device '{DeviceId}': {StatusCode}", - nameof(MieleClientService), deviceId, response.StatusCode); + return await Client.SendAsync(request, cancellationToken).ConfigureAwait(false); } - catch (Exception ex) + catch (HttpRequestException) { - _logger.LogWarning(ex, "{ClassName} failed to put program for device '{DeviceId}'", nameof(MieleClientService), deviceId); + throw Failure("The Miele API could not be reached."); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw Failure("The Miele API request timed out."); } - return false; } - #endregion + private MieleApiException Failure(string message, HttpStatusCode? statusCode = null) + { + LogFailure(_logger, nameof(MieleClientService), statusCode); + return new MieleApiException(message, statusCode); + } + + private static string DevicePath(string deviceId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(deviceId); + return $"devices/{Uri.EscapeDataString(deviceId)}"; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "{ClassName} Miele API request failed with upstream status {StatusCode}")] + private static partial void LogFailure(ILogger logger, string className, HttpStatusCode? statusCode); + + [LoggerMessage(Level = LogLevel.Warning, Message = "{ClassName} Miele API rejected a command with upstream status {StatusCode}")] + private static partial void LogRejection(ILogger logger, string className, HttpStatusCode statusCode); } diff --git a/src/CasCap.Api.Miele/Services/MieleQueryService.cs b/src/CasCap.Api.Miele/Services/MieleQueryService.cs index 06292ff..b6dd006 100644 --- a/src/CasCap.Api.Miele/Services/MieleQueryService.cs +++ b/src/CasCap.Api.Miele/Services/MieleQueryService.cs @@ -12,55 +12,65 @@ public sealed class MieleQueryService( { /// public async Task?> GetDevices( - string language = "en") + string language = "en", + CancellationToken cancellationToken = default) { - logger.LogDebug("{ClassName} retrieving devices", nameof(MieleQueryService)); - return await clientSvc.GetDevices(language); + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug("{ClassName} retrieving devices", nameof(MieleQueryService)); + return await clientSvc.GetDevices(language, cancellationToken).ConfigureAwait(false); } /// - public async Task GetShortDevices( - string language = "en") - => await clientSvc.GetShortDevices(language); + public Task GetShortDevices( + string language = "en", + CancellationToken cancellationToken = default) + => clientSvc.GetShortDevices(language, cancellationToken); /// - public async Task GetDevice( + public Task GetDevice( string deviceId, - string language = "en") - => await clientSvc.GetDevice(deviceId, language); + string language = "en", + CancellationToken cancellationToken = default) + => clientSvc.GetDevice(deviceId, language, cancellationToken); /// - public async Task GetIdent( + public Task GetIdent( string deviceId, - string language = "en") - => await clientSvc.GetIdent(deviceId, language); + string language = "en", + CancellationToken cancellationToken = default) + => clientSvc.GetIdent(deviceId, language, cancellationToken); /// - public async Task GetState( + public Task GetState( string deviceId, - string language = "en") - => await clientSvc.GetState(deviceId, language); + string language = "en", + CancellationToken cancellationToken = default) + => clientSvc.GetState(deviceId, language, cancellationToken); /// - public async Task GetActions( - string deviceId) - => await clientSvc.GetActions(deviceId); + public Task GetActions( + string deviceId, + CancellationToken cancellationToken = default) + => clientSvc.GetActions(deviceId, cancellationToken); /// - public async Task PutAction( + public Task PutAction( string deviceId, - object action) - => await clientSvc.PutAction(deviceId, action); + MieleActionRequest action, + CancellationToken cancellationToken = default) + => clientSvc.PutAction(deviceId, action, cancellationToken); /// - public async Task GetPrograms( + public Task GetPrograms( string deviceId, - string language = "en") - => await clientSvc.GetPrograms(deviceId, language); + string language = "en", + CancellationToken cancellationToken = default) + => clientSvc.GetPrograms(deviceId, language, cancellationToken); /// - public async Task PutProgram( + public Task PutProgram( string deviceId, - object programRequest) - => await clientSvc.PutProgram(deviceId, programRequest); + MieleProgramRequest programRequest, + CancellationToken cancellationToken = default) + => clientSvc.PutProgram(deviceId, programRequest, cancellationToken); } diff --git a/src/CasCap.App.Server.Tests/Infrastructure/MieleApiWebApplicationFactory.cs b/src/CasCap.App.Server.Tests/Infrastructure/MieleApiWebApplicationFactory.cs new file mode 100644 index 0000000..ae7e2b7 --- /dev/null +++ b/src/CasCap.App.Server.Tests/Infrastructure/MieleApiWebApplicationFactory.cs @@ -0,0 +1,35 @@ +using CasCap.Controllers; +using CasCap.Extensions; +using CasCap.HealthChecks; +using Microsoft.AspNetCore.Hosting; + +namespace CasCap.Tests.Infrastructure; + +/// Runs the real Miele client/query/controller stack against a fake provider. +public sealed class MieleApiWebApplicationFactory : CasCapAppWebApplicationFactory +{ + /// Gets the fake provider used by the named HTTP client. + public MieleHttpHandler Provider { get; } = new(); + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + var values = new Dictionary + { + ["CasCap:MieleConfig:OAuthToken"] = "example", + ["CasCap:MieleConfig:HealthCheckUri"] = "https://example.com/v1/", + ["CasCap:MieleConfig:EventStreamUrl"] = "https://example.com/events", + ["CasCap:MieleConfig:Sinks:AvailableSinks:Memory:Enabled"] = "false", + }; + builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(values)); + builder.ConfigureServices(services => + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + services.AddMiele(configuration); + services.AddHttpClient(nameof(MieleConnectionHealthCheck)) + .ConfigurePrimaryHttpMessageHandler(() => Provider); + services.AddControllers().AddApplicationPart(typeof(MieleController).Assembly); + }); + } +} diff --git a/src/CasCap.App.Server.Tests/Infrastructure/MieleHttpHandler.cs b/src/CasCap.App.Server.Tests/Infrastructure/MieleHttpHandler.cs new file mode 100644 index 0000000..d0e446f --- /dev/null +++ b/src/CasCap.App.Server.Tests/Infrastructure/MieleHttpHandler.cs @@ -0,0 +1,75 @@ +using System.Net; + +namespace CasCap.Tests.Infrastructure; + +/// A recording provider stub; no request leaves the in-process handler. +public sealed class MieleHttpHandler : HttpMessageHandler +{ + /// Gets captured request paths, JSON bodies and cancellation tokens. + public List<(string Path, string? Body, CancellationToken Token)> Calls { get; } = []; + + /// Signals when the current request reaches the provider stub. + public TaskCompletionSource Started { get; private set; } = NewSignal(); + + /// Signals when a blocked provider request is cancelled. + public TaskCompletionSource Cancelled { get; private set; } = NewSignal(); + + /// Gets whether the provider response content was disposed. + public bool ResponseDisposed { get; private set; } + + private HttpStatusCode _status = HttpStatusCode.OK; + private string _json = "{}"; + private bool _block; + private Exception? _failure; + + /// Configures a deterministic response and clears previous requests/signals. + public void Reset(HttpStatusCode status = HttpStatusCode.OK, string json = "{}", bool block = false, Exception? failure = null) + { + _status = status; + _json = json; + _block = block; + _failure = failure; + Calls.Clear(); + Started = NewSignal(); + Cancelled = NewSignal(); + ResponseDisposed = false; + } + + /// + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + Calls.Add((request.RequestUri!.PathAndQuery, body, cancellationToken)); + Started.TrySetResult(); + if (_failure is { } failure) + throw failure; + if (_block) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Cancelled.TrySetResult(); + throw; + } + } + return new HttpResponseMessage(_status) + { + Content = new ResponseContent(_json, () => ResponseDisposed = true), + }; + } + + private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private sealed class ResponseContent(string json, Action disposed) : StringContent(json, System.Text.Encoding.UTF8, "application/json") + { + protected override void Dispose(bool disposing) + { + if (disposing) + disposed(); + base.Dispose(disposing); + } + } +} diff --git a/src/CasCap.App.Server.Tests/README.md b/src/CasCap.App.Server.Tests/README.md index ea049d5..e12ecb5 100644 --- a/src/CasCap.App.Server.Tests/README.md +++ b/src/CasCap.App.Server.Tests/README.md @@ -25,11 +25,13 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | `HealthTests` | 4 | 7 | Integration | Health, liveness, readiness, and startup endpoints | | `HostStartupConfigurationTests` | 1 | 1 | Integration | Options binding, Key Vault disablement, feature selection, and safe core DI services | | `McpAuthenticationTests` | 2 | 2 | Integration | Anonymous rejection and Basic-authenticated MCP endpoint access | +| `MieleApiContractTests` | 11 | 59 | Integration | Typed bodies, HTTP outcomes, validation, cancellation, no command replay, sanitized failures, disposal, and generated Swagger using fake HTTP | +| `MieleMcpContractTests` | 3 | 24 | Integration | SDK-generated input schemas, nine facade cancellation paths, and boolean command outcomes using the fake provider | | `SystemControllerTests` | 4 | 4 | Integration | `GET /api/system` and Basic authentication behavior | | `UbiquitiApiContractTests` | 4 | 11 | Integration | GET/POST webhooks, optional bodies, validation, request cancellation, and generated Swagger | | `UbiquitiQueryServiceTests` | 7 | 21 | Webhook Parsing | Detection types, thumbnails/timestamps, camera selection, sink failures, and cancellation using in-memory sinks | | `WebApiContractTests` | 4 | 8 | Integration | String-enum JSON output/input, validation Problem Details, and generated Swagger schemas | -| Total | 47 | 122 | | Source-declared, including skipped cases | +| Total | 61 | 205 | | Source-declared, including skipped cases | ## Trait Categories @@ -59,6 +61,10 @@ Use a .NET 10 SDK with xUnit v3 and Microsoft.Testing.Platform from the reposito The inventory is derived from source, not an observed test run. `Integration` here includes in-process host tests and must not be assumed to mean live-device access. +The Miele fixture replaces the named client's primary handler while retaining its configured +resilience pipeline. No appliance is contacted. MCP coverage here verifies SDK-generated schemas +and facade calls, not a complete MCP initialization/discovery/invocation protocol exchange. + ```bash dotnet test --project src/CasCap.App.Server.Tests/CasCap.App.Server.Tests.csproj ``` @@ -77,12 +83,16 @@ CasCap.App.Server.Tests/ │ ├── ApiContractWebApplicationFactory.cs │ ├── CasCapAppWebApplicationFactory.cs │ ├── DeviceApiWebApplicationFactory.cs +│ ├── MieleApiWebApplicationFactory.cs +│ ├── MieleHttpHandler.cs │ ├── StrictAuthCasCapAppWebApplicationFactory.cs │ └── WebApiTestBase.cs ├── Tests/ │ ├── Integration/ │ │ ├── DeviceApiContractTests.cs │ │ ├── HostStartupConfigurationTests.cs +│ │ ├── MieleApiContractTests.cs +│ │ ├── MieleMcpContractTests.cs │ │ ├── UbiquitiApiContractTests.cs │ │ └── WebApiContractTests.cs │ └── Unit/ diff --git a/src/CasCap.App.Server.Tests/Tests/Integration/MieleApiContractTests.cs b/src/CasCap.App.Server.Tests/Tests/Integration/MieleApiContractTests.cs new file mode 100644 index 0000000..a40dbfd --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Integration/MieleApiContractTests.cs @@ -0,0 +1,273 @@ +using CasCap.Models.Dtos; +using CasCap.Services; +using CasCap.Tests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using System.ComponentModel.DataAnnotations; +using System.Net; +using System.Text; + +namespace CasCap.Tests; + +/// Checks appliance HTTP contracts against a fake provider, never live appliances. +[Trait("Category", "Integration")] +public sealed class MieleApiContractTests(MieleApiWebApplicationFactory factory) : IClassFixture +{ + [Theory] + [InlineData("devices", "{}")] + [InlineData("devices/short", "[]")] + [InlineData("devices/example", "{\"ident\":null,\"state\":null}")] + [InlineData("devices/example/ident", "{\"deviceName\":\"Example appliance\"}")] + [InlineData("devices/example/state", "{\"signalInfo\":false}")] + [InlineData("devices/example/actions", "{\"processAction\":[1,2]}")] + [InlineData("devices/example/programs", "[]")] + public async Task Reads_PreserveSuccessfulJsonAndDistinguishMissingAndFailed(string route, string json) + { + using var client = factory.CreateClient(); + foreach (var status in new[] { HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.ServiceUnavailable }) + { + factory.Provider.Reset(status, json); + using var response = await client.GetAsync($"/api/v1/Miele/{route}", TestContext.Current.CancellationToken); + + Assert.Equal(status == HttpStatusCode.ServiceUnavailable ? HttpStatusCode.BadGateway : status, response.StatusCode); + Assert.Single(factory.Provider.Calls); + Assert.True(factory.Provider.ResponseDisposed); + if (status == HttpStatusCode.OK) + { + using var expected = JsonDocument.Parse(json); + using var actual = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(expected.RootElement.ValueKind, actual.RootElement.ValueKind); + if (expected.RootElement.ValueKind == JsonValueKind.Array) + Assert.Empty(actual.RootElement.EnumerateArray()); + else if (route == "devices") + Assert.Empty(actual.RootElement.EnumerateObject()); + else + foreach (var property in expected.RootElement.EnumerateObject()) + Assert.True(JsonElement.DeepEquals(property.Value, actual.RootElement.GetProperty(property.Name)), property.Name); + } + if (status == HttpStatusCode.ServiceUnavailable) + await AssertGatewayProblem(response); + } + } + + [Theory] + [InlineData("actions", "{\"powerOn\":false}")] + [InlineData("programs", "{\"programId\":24,\"duration\":[1,15],\"temperature\":220}")] + public async Task Commands_MapProviderOutcomesWithoutReplaying(string route, string json) + { + using var client = factory.CreateClient(); + foreach (var status in new[] + { + HttpStatusCode.NoContent, HttpStatusCode.BadRequest, HttpStatusCode.MethodNotAllowed, HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity, HttpStatusCode.NotFound, HttpStatusCode.Unauthorized, HttpStatusCode.ServiceUnavailable, + }) + { + factory.Provider.Reset(status); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await client.PutAsync($"/api/v1/Miele/devices/example/{route}", content, TestContext.Current.CancellationToken); + var expected = status switch + { + HttpStatusCode.NoContent => HttpStatusCode.OK, + HttpStatusCode.NotFound => HttpStatusCode.NotFound, + HttpStatusCode.Unauthorized or HttpStatusCode.ServiceUnavailable => HttpStatusCode.BadGateway, + _ => HttpStatusCode.BadRequest, + }; + + Assert.Equal(expected, response.StatusCode); + var call = Assert.Single(factory.Provider.Calls); + Assert.True(factory.Provider.ResponseDisposed); + if (expected == HttpStatusCode.OK) + { + Assert.Equal("true", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(json, call.Body); + } + if (expected == HttpStatusCode.BadGateway) + await AssertGatewayProblem(response); + } + } + + [Theory] + [InlineData("actions", "{}")] + [InlineData("actions", "{\"processAction\":0}")] + [InlineData("actions", "{\"processAction\":8}")] + [InlineData("actions", "{\"light\":0}")] + [InlineData("actions", "{\"ambientLight\":3}")] + [InlineData("actions", "{\"modes\":4}")] + [InlineData("actions", "{\"ventilationStep\":5}")] + [InlineData("actions", "{\"startTime\":[1]}")] + [InlineData("actions", "{\"startTime\":[-1,0]}")] + [InlineData("actions", "{\"startTime\":[1,60]}")] + [InlineData("actions", "{\"targetTemperature\":[{\"zone\":2}]}")] + [InlineData("actions", "{\"targetTemperature\":[{\"value\":-20}]}")] + [InlineData("actions", "{\"targetTemperature\":[]}")] + [InlineData("actions", "{\"targetTemperature\":[null]}")] + [InlineData("actions", "{\"runOnTime\":5}")] + [InlineData("actions", "{\"runOnTime\":[0,5]}")] + [InlineData("actions", "{\"powerOn\":true,\"unknown\":1}")] + [InlineData("programs", "{}")] + [InlineData("programs", "{\"programId\":null}")] + [InlineData("programs", "{\"programId\":24,\"duration\":[1]}")] + [InlineData("programs", "{\"programId\":24,\"duration\":[1,2,3]}")] + [InlineData("programs", "{\"programId\":24,\"duration\":[0,60]}")] + public async Task InvalidCommands_DoNotReachProvider(string route, string json) + { + using var client = factory.CreateClient(); + factory.Provider.Reset(); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await client.PutAsync($"/api/v1/Miele/devices/example/{route}", content, TestContext.Current.CancellationToken); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.NotEmpty(body.RootElement.GetProperty("errors").EnumerateObject()); + Assert.Empty(factory.Provider.Calls); + } + + [Theory] + [InlineData("actions", "{\"processAction\":7,\"light\":2,\"ambientLight\":1,\"colors\":\"blue\",\"deviceName\":\"Example appliance\",\"modes\":0,\"powerOn\":false,\"powerOff\":true,\"programId\":4,\"startTime\":[0,59],\"targetTemperature\":[{\"zone\":2,\"value\":-20}],\"ventilationStep\":4}")] + [InlineData("programs", "{\"programId\":0}")] + [InlineData("programs", "{\"programId\":24,\"duration\":[0,0],\"temperature\":-20}")] + public async Task ValidCommands_PreserveSupportedValues(string route, string json) + { + using var client = factory.CreateClient(); + factory.Provider.Reset(HttpStatusCode.NoContent); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await client.PutAsync($"/api/v1/Miele/devices/example/{route}", content, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var call = Assert.Single(factory.Provider.Calls); + using var expected = JsonDocument.Parse(json); + using var actual = JsonDocument.Parse(Assert.IsType(call.Body)); + Assert.True(JsonElement.DeepEquals(expected.RootElement, actual.RootElement)); + } + + [Theory] + [InlineData(false, "{}")] + [InlineData(false, "{\"processAction\":8}")] + [InlineData(false, "{\"startTime\":[0,60]}")] + [InlineData(false, "{\"targetTemperature\":[null]}")] + [InlineData(true, "{}")] + [InlineData(true, "{\"programId\":24,\"duration\":[-1,0]}")] + public async Task DirectClientValidation(bool program, string json) + { + var client = factory.Services.GetRequiredService(); + factory.Provider.Reset(); + var options = JsonSerializerOptions.Web; + + await Assert.ThrowsAsync(() => program + ? client.PutProgram("example", JsonSerializer.Deserialize(json, options)!, TestContext.Current.CancellationToken) + : client.PutAction("example", JsonSerializer.Deserialize(json, options)!, TestContext.Current.CancellationToken)); + + Assert.Empty(factory.Provider.Calls); + } + + [Fact] + public async Task ProgrammingFailuresAreNotProviderFailures() + { + var client = factory.Services.GetRequiredService(); + var failure = new InvalidOperationException("Synthetic programming fault."); + factory.Provider.Reset(failure: failure); + + var actual = await Assert.ThrowsAsync(() => client.GetDevices(cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Same(failure, actual); + Assert.Single(factory.Provider.Calls); + } + + [Theory] + [InlineData("application/json")] + [InlineData("text/plain")] + [InlineData("text/html")] + public async Task GatewayProblemAcceptHeaders(string accept) + { + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Accept.ParseAdd(accept); + factory.Provider.Reset(HttpStatusCode.ServiceUnavailable); + using var response = await client.GetAsync("/api/v1/Miele/devices", TestContext.Current.CancellationToken); + + await AssertGatewayProblem(response); + } + + [Theory] + [InlineData("GET", "devices", null)] + [InlineData("GET", "devices/short", null)] + [InlineData("GET", "devices/example", null)] + [InlineData("GET", "devices/example/ident", null)] + [InlineData("GET", "devices/example/state", null)] + [InlineData("GET", "devices/example/actions", null)] + [InlineData("GET", "devices/example/programs", null)] + [InlineData("PUT", "devices/example/actions", "{\"powerOn\":true}")] + [InlineData("PUT", "devices/example/programs", "{\"programId\":24}")] + public async Task RequestCancellation_StopsTheProviderRequest(string method, string route, string? json) + { + using var client = factory.CreateClient(); + factory.Provider.Reset(block: true); + using var stopping = new CancellationTokenSource(); + using var request = new HttpRequestMessage(new HttpMethod(method), $"/api/v1/Miele/{route}"); + if (json is not null) + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + var task = client.SendAsync(request, stopping.Token); + await factory.Provider.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + await stopping.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => task); + await factory.Provider.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Single(factory.Provider.Calls); + } + + [Theory] + [InlineData("{")] + [InlineData("null")] + [InlineData("")] + public async Task InvalidProviderResponse_ReturnsGatewayProblem(string json) + { + using var client = factory.CreateClient(); + factory.Provider.Reset(json: json); + using var response = await client.GetAsync("/api/v1/Miele/devices", TestContext.Current.CancellationToken); + + await AssertGatewayProblem(response); + Assert.True(factory.Provider.ResponseDisposed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TransportFailures_ReturnSanitizedGatewayProblem(bool timeout) + { + using var client = factory.CreateClient(); + Exception failure = timeout ? new TaskCanceledException("Synthetic private provider detail.") : new HttpRequestException("Synthetic private provider detail."); + factory.Provider.Reset(failure: failure); + using var response = await client.GetAsync("/api/v1/Miele/devices", TestContext.Current.CancellationToken); + + await AssertGatewayProblem(response); + } + + [Fact] + public async Task OpenApi_DescribesTypedCommandsAndErrorOutcomes() + { + using var client = factory.CreateAuthorizedClient(); + using var response = await client.GetAsync("/swagger/v1.json", TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + var paths = body.RootElement.GetProperty("paths"); + foreach (var (route, schema) in new[] { ("actions", "MieleActionRequest"), ("programs", "MieleProgramRequest") }) + { + var path = Assert.Single(paths.EnumerateObject(), property => + property.Name.Equals($"/api/v1/Miele/devices/{{deviceId}}/{route}", StringComparison.OrdinalIgnoreCase)).Value.GetProperty("put"); + Assert.EndsWith($"/{schema}", path.GetProperty("requestBody").GetProperty("content") + .GetProperty("application/json").GetProperty("schema").GetProperty("$ref").GetString()); + foreach (var status in new[] { "200", "400", "404", "502" }) + Assert.True(path.GetProperty("responses").TryGetProperty(status, out _)); + } + } + + private static async Task AssertGatewayProblem(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.BadGateway, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(502, body.RootElement.GetProperty("status").GetInt32()); + Assert.Equal("Miele API unavailable", body.RootElement.GetProperty("title").GetString()); + Assert.Equal("The appliance provider could not complete the request.", body.RootElement.GetProperty("detail").GetString()); + } +} diff --git a/src/CasCap.App.Server.Tests/Tests/Integration/MieleMcpContractTests.cs b/src/CasCap.App.Server.Tests/Tests/Integration/MieleMcpContractTests.cs new file mode 100644 index 0000000..5c87038 --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Integration/MieleMcpContractTests.cs @@ -0,0 +1,111 @@ +using CasCap.Abstractions; +using CasCap.Services; +using CasCap.Tests.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; +using System.Net; + +namespace CasCap.Tests; + +/// Checks generated MCP schemas and facade behavior against the fake appliance provider. +[Trait("Category", "Integration")] +public sealed class MieleMcpContractTests(MieleApiWebApplicationFactory factory) : IClassFixture +{ + [Theory] + [InlineData(nameof(AppliancesMcpQueryService.GetAllAppliances), "get_all_appliances", null)] + [InlineData(nameof(AppliancesMcpQueryService.GetAllAppliancesSummary), "get_all_appliances_summary", null)] + [InlineData(nameof(AppliancesMcpQueryService.GetAppliance), "get_appliance", null)] + [InlineData(nameof(AppliancesMcpQueryService.GetApplianceIdentification), "get_appliance_identification", null)] + [InlineData(nameof(AppliancesMcpQueryService.GetApplianceState), "get_appliance_state", null)] + [InlineData(nameof(AppliancesMcpQueryService.GetApplianceActions), "get_appliance_actions", null)] + [InlineData(nameof(AppliancesMcpQueryService.GetAppliancePrograms), "get_appliance_programs", null)] + [InlineData(nameof(AppliancesMcpQueryService.ExecuteApplianceAction), "execute_appliance_action", "action")] + [InlineData(nameof(AppliancesMcpQueryService.StartApplianceProgram), "start_appliance_program", "programRequest")] + public void ToolSchemas(string methodName, string expectedName, string? commandParameter) + { + var method = typeof(AppliancesMcpQueryService).GetMethod(methodName)!; + var tool = McpServerTool.Create(method, _ => null!).ProtocolTool; + var properties = tool.InputSchema.GetProperty("properties"); + + Assert.Equal(expectedName, tool.Name); + Assert.False(properties.TryGetProperty("cancellationToken", out _)); + if (commandParameter is null) + return; + + var command = properties.GetProperty(commandParameter); + Assert.Equal("object", command.GetProperty("type").GetString()); + var fields = command.GetProperty("properties"); + Assert.True(fields.TryGetProperty("programId", out _)); + Assert.False(fields.TryGetProperty("runOnTime", out _)); + Assert.False(tool.Annotations!.ReadOnlyHint); + Assert.True(tool.Annotations.DestructiveHint); + Assert.False(tool.Annotations.IdempotentHint); + if (commandParameter == "action") + { + Assert.True(fields.TryGetProperty("powerOn", out _)); + Assert.True(fields.TryGetProperty("targetTemperature", out _)); + } + else + Assert.Contains(command.GetProperty("required").EnumerateArray(), field => field.GetString() == "programId"); + } + + [Theory] + [InlineData(nameof(AppliancesMcpQueryService.GetAllAppliances))] + [InlineData(nameof(AppliancesMcpQueryService.GetAllAppliancesSummary))] + [InlineData(nameof(AppliancesMcpQueryService.GetAppliance))] + [InlineData(nameof(AppliancesMcpQueryService.GetApplianceIdentification))] + [InlineData(nameof(AppliancesMcpQueryService.GetApplianceState))] + [InlineData(nameof(AppliancesMcpQueryService.GetApplianceActions))] + [InlineData(nameof(AppliancesMcpQueryService.GetAppliancePrograms))] + [InlineData(nameof(AppliancesMcpQueryService.ExecuteApplianceAction))] + [InlineData(nameof(AppliancesMcpQueryService.StartApplianceProgram))] + public async Task Cancellation(string methodName) + { + var tools = CreateTools(); + factory.Provider.Reset(block: true); + using var stopping = new CancellationTokenSource(); + var token = stopping.Token; + Task operation = methodName switch + { + nameof(AppliancesMcpQueryService.GetAllAppliances) => tools.GetAllAppliances(cancellationToken: token), + nameof(AppliancesMcpQueryService.GetAllAppliancesSummary) => tools.GetAllAppliancesSummary(cancellationToken: token), + nameof(AppliancesMcpQueryService.GetAppliance) => tools.GetAppliance("example", cancellationToken: token), + nameof(AppliancesMcpQueryService.GetApplianceIdentification) => tools.GetApplianceIdentification("example", cancellationToken: token), + nameof(AppliancesMcpQueryService.GetApplianceState) => tools.GetApplianceState("example", cancellationToken: token), + nameof(AppliancesMcpQueryService.GetApplianceActions) => tools.GetApplianceActions("example", token), + nameof(AppliancesMcpQueryService.GetAppliancePrograms) => tools.GetAppliancePrograms("example", cancellationToken: token), + nameof(AppliancesMcpQueryService.ExecuteApplianceAction) => tools.ExecuteApplianceAction("example", new() { PowerOn = true }, token), + nameof(AppliancesMcpQueryService.StartApplianceProgram) => tools.StartApplianceProgram("example", new() { ProgramId = 24 }, token), + _ => throw new ArgumentOutOfRangeException(nameof(methodName)), + }; + await factory.Provider.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + await stopping.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => operation); + await factory.Provider.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Single(factory.Provider.Calls); + } + + [Theory] + [InlineData(false, HttpStatusCode.NoContent, true)] + [InlineData(false, HttpStatusCode.BadRequest, false)] + [InlineData(false, HttpStatusCode.NotFound, false)] + [InlineData(true, HttpStatusCode.NoContent, true)] + [InlineData(true, HttpStatusCode.BadRequest, false)] + [InlineData(true, HttpStatusCode.NotFound, false)] + public async Task CommandResults(bool program, HttpStatusCode status, bool expected) + { + var tools = CreateTools(); + factory.Provider.Reset(status); + var result = program + ? await tools.StartApplianceProgram("example", new() { ProgramId = 24 }, TestContext.Current.CancellationToken) + : await tools.ExecuteApplianceAction("example", new() { PowerOn = true }, TestContext.Current.CancellationToken); + + Assert.Equal(expected, result); + Assert.Single(factory.Provider.Calls); + } + + private AppliancesMcpQueryService CreateTools() + => new(factory.Services.GetRequiredService()); +} diff --git a/src/CasCap.Backend/README.md b/src/CasCap.Backend/README.md index 5d66d36..bba9308 100644 --- a/src/CasCap.Backend/README.md +++ b/src/CasCap.Backend/README.md @@ -153,6 +153,12 @@ the whole backend assembly, maintain an agent session or execute a model while s This table is the available SmartHaus MCP catalogue. Which tools an agent can call is selected by its active definition in agentizr; SmartHaus deliberately carries no agent-to-tool assignment map. +The nine appliance tools forward cancellation to the provider. The two command tools retain their +boolean outputs and use typed action/program arguments: accepted commands return `true`, explicit +rejection or absence returns `false`, and upstream failures propagate as errors. See the +[Miele command contract](../CasCap.Api.Miele/README.md#commands-and-failures) for validation, +supported fields and no-replay behavior. + ## Communications Flow `CommunicationsBgService` is the only SmartHaus service that exchanges messages with Signalizr. diff --git a/src/CasCap.Backend/Services/Mcp/AppliancesMcpQueryService.cs b/src/CasCap.Backend/Services/Mcp/AppliancesMcpQueryService.cs index 5ee90b6..66073a0 100644 --- a/src/CasCap.Backend/Services/Mcp/AppliancesMcpQueryService.cs +++ b/src/CasCap.Backend/Services/Mcp/AppliancesMcpQueryService.cs @@ -11,16 +11,18 @@ public sealed partial class AppliancesMcpQueryService(IMieleQueryService mieleQu [Description("Lists all household appliances with full identification and current state.")] public Task?> GetAllAppliances( [Description("Language code, e.g. en, de.")] - string language = "en") - => mieleQuerySvc.GetDevices(language); + string language = "en", + CancellationToken cancellationToken = default) + => mieleQuerySvc.GetDevices(language, cancellationToken); /// [McpServerTool(ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] [Description("Compact summary of all household appliances — serial number, type and status only.")] public Task GetAllAppliancesSummary( [Description("Language code, e.g. en, de.")] - string language = "en") - => mieleQuerySvc.GetShortDevices(language); + string language = "en", + CancellationToken cancellationToken = default) + => mieleQuerySvc.GetShortDevices(language, cancellationToken); /// [McpServerTool(ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] @@ -29,8 +31,9 @@ public sealed partial class AppliancesMcpQueryService(IMieleQueryService mieleQu [Description("Device serial number.")] string deviceId, [Description("Language code, e.g. en, de.")] - string language = "en") - => mieleQuerySvc.GetDevice(deviceId, language); + string language = "en", + CancellationToken cancellationToken = default) + => mieleQuerySvc.GetDevice(deviceId, language, cancellationToken); /// [McpServerTool(ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] @@ -39,8 +42,9 @@ public sealed partial class AppliancesMcpQueryService(IMieleQueryService mieleQu [Description("Device serial number.")] string deviceId, [Description("Language code, e.g. en, de.")] - string language = "en") - => mieleQuerySvc.GetIdent(deviceId, language); + string language = "en", + CancellationToken cancellationToken = default) + => mieleQuerySvc.GetIdent(deviceId, language, cancellationToken); /// [McpServerTool(ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] @@ -49,26 +53,29 @@ public sealed partial class AppliancesMcpQueryService(IMieleQueryService mieleQu [Description("Device serial number.")] string deviceId, [Description("Language code, e.g. en, de.")] - string language = "en") - => mieleQuerySvc.GetState(deviceId, language); + string language = "en", + CancellationToken cancellationToken = default) + => mieleQuerySvc.GetState(deviceId, language, cancellationToken); /// [McpServerTool(ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] [Description("Currently available actions for one appliance (e.g. start, stop, powerOn, powerOff).")] public Task GetApplianceActions( [Description("Device serial number.")] - string deviceId) - => mieleQuerySvc.GetActions(deviceId); + string deviceId, + CancellationToken cancellationToken = default) + => mieleQuerySvc.GetActions(deviceId, cancellationToken); /// [McpServerTool(ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false)] [Description("Executes an action on one appliance — powerOn, powerOff, start or stop.")] - public Task ExecuteApplianceAction( + public async Task ExecuteApplianceAction( [Description("Device serial number.")] string deviceId, - [Description("Action payload object.")] - object action) - => mieleQuerySvc.PutAction(deviceId, action); + [Description("Typed appliance action; supply at least one supported field. runOnTime is not supported.")] + MieleActionRequest action, + CancellationToken cancellationToken = default) + => await mieleQuerySvc.PutAction(deviceId, action, cancellationToken).ConfigureAwait(false) == MieleCommandResult.Accepted; /// [McpServerTool(ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] @@ -77,16 +84,18 @@ public Task ExecuteApplianceAction( [Description("Device serial number.")] string deviceId, [Description("Language code, e.g. en, de.")] - string language = "en") - => mieleQuerySvc.GetPrograms(deviceId, language); + string language = "en", + CancellationToken cancellationToken = default) + => mieleQuerySvc.GetPrograms(deviceId, language, cancellationToken); /// [McpServerTool(ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false)] [Description("Selects and starts a program on one appliance. Requires MobileStart or MobileControl mode.")] - public Task StartApplianceProgram( + public async Task StartApplianceProgram( [Description("Device serial number.")] string deviceId, - [Description("Program selection payload.")] - object programRequest) - => mieleQuerySvc.PutProgram(deviceId, programRequest); + [Description("Program selection with required programId, optional duration [hours, minutes] and optional temperature.")] + MieleProgramRequest programRequest, + CancellationToken cancellationToken = default) + => await mieleQuerySvc.PutProgram(deviceId, programRequest, cancellationToken).ConfigureAwait(false) == MieleCommandResult.Accepted; } From edb3d29fa103c5e87ddfd6db154b9f3c25032d23 Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Fri, 23 Oct 2026 12:59:00 +0200 Subject: [PATCH 10/18] fix(webapi): align request examples and discover DDns controllers - Correct KNX and Buderus payloads and remove unrelated credential forwarding - Describe physical actions and callback side effects - Discover enabled DDns endpoints and cover both feature states --- requests/README.md | 18 +++- requests/buderus-controller.http | 4 +- requests/doorbird-client.http | 4 + requests/doorbird-controller.http | 13 +-- requests/knx-controller.http | 85 ++++++++++--------- requests/miele-client.http | 4 +- requests/miele-controller.http | 4 +- requests/shelly-client.http | 6 +- requests/shelly-controller.http | 4 +- requests/sicce-client.http | 12 --- requests/ubiquiti-controller.http | 3 + requests/ubiquiti-demo.http | 16 ++-- requests/wiz-controller.http | 8 +- src/CasCap.Api.DDns/README.md | 5 +- .../CasCapAppWebApplicationFactory.cs | 8 +- src/CasCap.App.Server.Tests/README.md | 4 +- .../HostStartupConfigurationTests.cs | 17 ++++ src/CasCap.App.Server/AppHost.WebApi.cs | 2 + 18 files changed, 126 insertions(+), 91 deletions(-) diff --git a/requests/README.md b/requests/README.md index 972ee44..88b22c8 100644 --- a/requests/README.md +++ b/requests/README.md @@ -22,5 +22,19 @@ request files. | `knx-controller.http` | KNX bus, HVAC, lighting, outlet, and shutter endpoints | | `ubiquiti-demo.http` | Local Docker Compose demo webhook requests | -Some requests change device or application state. Review the method, URL, and payload before sending -them. +## Execution Safety + +Review each request's `# @note`, URL, credentials and payload before sending it. A `GET` is not +necessarily read-only: DoorBird device commands can unlock doors or reboot hardware, while +DoorBird and Ubiquiti event callbacks publish events. Depending on enabled sinks, callbacks can +capture media, invoke analysis and send notifications. Demo events also enter the configured +pipeline; synthetic identifiers do not make them a dry run. + +KNX names are synthetic examples using the current category and room names. Choose existing +group/address names from the read endpoints before sending a command. Examples with `dryRun=true` +resolve proposed changes without queuing or sending KNX commands; the other command examples can +operate physical devices. Body fields and validation apply to dry runs too. + +Controller endpoints are available only when their feature is enabled. In particular, enabling +`DDns` exposes its read-only IP query but also enables the DNS update worker; do not enable it merely +to try the request without first configuring the feature's write permissions and target zone. diff --git a/requests/buderus-controller.http b/requests/buderus-controller.http index b66f363..efaae26 100644 --- a/requests/buderus-controller.http +++ b/requests/buderus-controller.http @@ -16,11 +16,11 @@ GET {{baseUrl}}/api/v1/buderus/dhwCircuits Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Set a datapoint value -# @note +# @note Writes a heating setpoint directly to the configured heat pump. PUT {{baseUrl}}/api/v1/buderus/heatingCircuits/hc1/temperatureRoomSetpoint Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "value": 21.0 + "value": "21.0" } diff --git a/requests/doorbird-client.http b/requests/doorbird-client.http index 07dd3fc..0a95579 100644 --- a/requests/doorbird-client.http +++ b/requests/doorbird-client.http @@ -18,14 +18,17 @@ GET {{baseUrl}}/bha-api/video.cgi Authorization: Basic {{auth}} ### Unlock door (relay 1) +# @note Activates the device's default door relay and can unlock a physical door. GET {{baseUrl}}/bha-api/open-door.cgi Authorization: Basic {{auth}} ### Unlock door (specific relay) +# @note Activates relay 2 and can unlock a physical door. GET {{baseUrl}}/bha-api/open-door.cgi?r=2 Authorization: Basic {{auth}} ### Turn on IR light +# @note Activates the physical device's infrared light. GET {{baseUrl}}/bha-api/light-on.cgi Authorization: Basic {{auth}} @@ -38,5 +41,6 @@ GET {{baseUrl}}/bha-api/sip.cgi Authorization: Basic {{auth}} ### Restart device +# @note Reboots the physical intercom and temporarily interrupts its services. GET {{baseUrl}}/bha-api/restart.cgi Authorization: Basic {{auth}} diff --git a/requests/doorbird-controller.http b/requests/doorbird-controller.http index 1255f76..cc76360 100644 --- a/requests/doorbird-controller.http +++ b/requests/doorbird-controller.http @@ -16,12 +16,12 @@ GET {{baseUrl}}/api/v1/doorbird/video Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Unlock front door relay -# @note +# @note Activates the configured door relay and publishes an event; configured sinks may send notifications. POST {{baseUrl}}/api/v1/doorbird/relay Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Turn light on -# @note +# @note Activates the physical intercom's infrared light. POST {{baseUrl}}/api/v1/doorbird/light Authorization: Basic {{$dotenv HAUS_API_AUTH}} @@ -30,7 +30,7 @@ GET {{baseUrl}}/api/v1/doorbird/info Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Restart device -# @note +# @note Reboots the physical intercom and temporarily interrupts its services. POST {{baseUrl}}/api/v1/doorbird/restart Authorization: Basic {{$dotenv HAUS_API_AUTH}} @@ -59,20 +59,23 @@ GET {{baseUrl}}/api/v1/doorbird/notifications Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Subscribe to notification -# @note +# @note Registers a device notification callback; replace the synthetic URL with an approved receiver. POST {{baseUrl}}/api/v1/doorbird/notifications/subscribe?subscriberUrl=http://example.com/callback&eventType=doorbell Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Unsubscribe from notification -# @note +# @note Removes a device notification callback subscription. POST {{baseUrl}}/api/v1/doorbird/notifications/unsubscribe?subscriberUrl=http://example.com/callback&eventType=doorbell Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Event callback - doorbell ring (no auth required) +# @note Captures an image and publishes an event; configured sinks may capture clips, invoke analysis and send notifications. GET {{baseUrl}}/api/v1/doorbird/event/ring ### Event callback - motion detected (no auth required) +# @note Captures an image and publishes an event; configured sinks may capture clips, invoke analysis and send notifications. GET {{baseUrl}}/api/v1/doorbird/event/motion ### Event callback - RFID (no auth required) +# @note Captures an image and publishes an event; configured sinks may capture clips, invoke analysis and send notifications. GET {{baseUrl}}/api/v1/doorbird/event/rfid diff --git a/requests/knx-controller.http b/requests/knx-controller.http index 02052b4..2c52665 100644 --- a/requests/knx-controller.http +++ b/requests/knx-controller.http @@ -1,6 +1,7 @@ ### KNX Controllers (internal Web API) ### Base URL is the Haus application server ### Covers: Bus, Hvac, Lights, PowerOutlets, Shutters +# Group/address names below are synthetic. Select existing names from the read endpoints before sending commands. @baseUrl = {{$dotenv HAUS_BASE_URL}} @@ -13,11 +14,11 @@ GET {{baseUrl}}/api/v1/bus/addresses Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Get group addresses (filtered) -GET {{baseUrl}}/api/v1/bus/addresses?groupAddressFilter=Lights +GET {{baseUrl}}/api/v1/bus/addresses?groupAddressFilter=Active Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Filter group addresses by criteria -GET {{baseUrl}}/api/v1/bus/addresses/filter?category=Lights&floor=EG +GET {{baseUrl}}/api/v1/bus/addresses/filter?category=LI&floor=EG Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Get group addresses grouped @@ -41,22 +42,22 @@ GET {{baseUrl}}/api/v1/bus/state Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Validate a group address -GET {{baseUrl}}/api/v1/bus/validate/address/1/1/1 +GET {{baseUrl}}/api/v1/bus/validate/address/EG-LI-LivingRoom-DL-SW Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Validate a group name -GET {{baseUrl}}/api/v1/bus/validate/group/EG_Wohnzimmer_Licht +GET {{baseUrl}}/api/v1/bus/validate/group/EG-LI-LivingRoom-DL Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Send command to KNX bus -# @note +# @note Sends a command to the KNX bus and can change a physical actuator. POST {{baseUrl}}/api/v1/bus/state/send Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Licht", - "value": true + "groupAddressName": "EG-LI-LivingRoom-DL-SW", + "actualValue": "True" } ### @@ -68,33 +69,33 @@ GET {{baseUrl}}/api/v1/hvac Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### List HVAC zones by room -GET {{baseUrl}}/api/v1/hvac?room=Wohnzimmer +GET {{baseUrl}}/api/v1/hvac?room=LivingRoom Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Get a specific HVAC zone -GET {{baseUrl}}/api/v1/hvac/EG_Wohnzimmer_Heizung +GET {{baseUrl}}/api/v1/hvac/EG-HZ-LivingRoom Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Set HVAC state -# @note +# @note Queues or sends a KNX heating setpoint change for the selected zone. POST {{baseUrl}}/api/v1/hvac Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Heizung", - "desiredTemperature": 21.0 + "groupName": "EG-HZ-LivingRoom", + "setpointAdjust": 21.0 } ### Set HVAC state (dry run) -# @note +# @note Resolves the proposed heating change without queuing or sending KNX commands. POST {{baseUrl}}/api/v1/hvac?dryRun=true Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Heizung", - "desiredTemperature": 21.0 + "groupName": "EG-HZ-LivingRoom", + "setpointAdjust": 21.0 } ### @@ -106,37 +107,37 @@ GET {{baseUrl}}/api/v1/lights Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### List lights by room -GET {{baseUrl}}/api/v1/lights?room=Wohnzimmer +GET {{baseUrl}}/api/v1/lights?room=LivingRoom Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Get a specific light -GET {{baseUrl}}/api/v1/lights/EG_Wohnzimmer_Licht +GET {{baseUrl}}/api/v1/lights/EG-LI-LivingRoom-DL Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Set light state (on) -# @note +# @note Queues or sends a KNX command to turn the selected light on. POST {{baseUrl}}/api/v1/lights/state Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Licht", - "on": true + "groupName": "EG-LI-LivingRoom-DL", + "isOn": true } ### Set light state (dry run) -# @note +# @note Resolves the proposed lighting change without queuing or sending KNX commands. POST {{baseUrl}}/api/v1/lights/state?dryRun=true Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Licht", - "on": true + "groupName": "EG-LI-LivingRoom-DL", + "isOn": true } ### Turn all lights off -# @note +# @note Turns off all eligible KNX lights, not just the example room. POST {{baseUrl}}/api/v1/lights/state/all-off Authorization: Basic {{$dotenv HAUS_API_AUTH}} @@ -149,33 +150,33 @@ GET {{baseUrl}}/api/v1/poweroutlets Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### List power outlets by room -GET {{baseUrl}}/api/v1/poweroutlets?room=Wohnzimmer +GET {{baseUrl}}/api/v1/poweroutlets?room=LivingRoom Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Get a specific power outlet -GET {{baseUrl}}/api/v1/poweroutlets/EG_Wohnzimmer_Steckdose +GET {{baseUrl}}/api/v1/poweroutlets/EG-SD-LivingRoom Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Set power outlet state -# @note +# @note Queues or sends a KNX command to energize the selected power outlet. POST {{baseUrl}}/api/v1/poweroutlets/state Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Steckdose", - "on": true + "groupName": "EG-SD-LivingRoom", + "isOn": true } ### Set power outlet state (dry run) -# @note +# @note Resolves the proposed outlet change without queuing or sending KNX commands. POST {{baseUrl}}/api/v1/poweroutlets/state?dryRun=true Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Steckdose", - "on": true + "groupName": "EG-SD-LivingRoom", + "isOn": true } ### @@ -187,41 +188,41 @@ GET {{baseUrl}}/api/v1/shutters Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### List shutters by room -GET {{baseUrl}}/api/v1/shutters?room=Wohnzimmer +GET {{baseUrl}}/api/v1/shutters?room=LivingRoom Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Get a specific shutter -GET {{baseUrl}}/api/v1/shutters/EG_Wohnzimmer_Jalousie +GET {{baseUrl}}/api/v1/shutters/EG-BL-LivingRoom Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Set shutter state -# @note +# @note Queues or sends a KNX command that physically moves the selected shutter. POST {{baseUrl}}/api/v1/shutters/state Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Jalousie", - "position": 50 + "groupName": "EG-BL-LivingRoom", + "vPosition": 50 } ### Set shutter state (dry run) -# @note +# @note Resolves the proposed shutter change without queuing or sending KNX commands. POST {{baseUrl}}/api/v1/shutters/state?dryRun=true Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json { - "groupName": "EG_Wohnzimmer_Jalousie", - "position": 50 + "groupName": "EG-BL-LivingRoom", + "vPosition": 50 } ### Close all shutters -# @note +# @note Physically closes all eligible KNX shutters, not just the example room. POST {{baseUrl}}/api/v1/shutters/state/all-close Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Open all shutters -# @note +# @note Physically opens all eligible KNX shutters, not just the example room. POST {{baseUrl}}/api/v1/shutters/state/all-open Authorization: Basic {{$dotenv HAUS_API_AUTH}} diff --git a/requests/miele-client.http b/requests/miele-client.http index 25e7bb2..47a140c 100644 --- a/requests/miele-client.http +++ b/requests/miele-client.http @@ -37,7 +37,7 @@ GET {{baseUrl}}/devices/{{deviceId}}/actions Authorization: Bearer {{token}} ### Execute device action (e.g. start) -# @note +# @note Sends a start command to the selected physical appliance. PUT {{baseUrl}}/devices/{{deviceId}}/actions Authorization: Bearer {{token}} Content-Type: application/json @@ -52,7 +52,7 @@ Authorization: Bearer {{token}} Accept-Language: en ### Start a program -# @note +# @note Starts the selected appliance program; confirm the program is supported before sending. PUT {{baseUrl}}/devices/{{deviceId}}/programs Authorization: Bearer {{token}} Content-Type: application/json diff --git a/requests/miele-controller.http b/requests/miele-controller.http index bc49d8e..88495ac 100644 --- a/requests/miele-controller.http +++ b/requests/miele-controller.http @@ -35,7 +35,7 @@ GET {{baseUrl}}/api/v1/miele/devices/{{deviceId}}/actions Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Execute device action (e.g. start) -# @note +# @note Sends a start command to the selected physical appliance. PUT {{baseUrl}}/api/v1/miele/devices/{{deviceId}}/actions Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json @@ -49,7 +49,7 @@ GET {{baseUrl}}/api/v1/miele/devices/{{deviceId}}/programs Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Start a program -# @note +# @note Starts an appliance program with the supplied duration and temperature; confirm appliance support first. PUT {{baseUrl}}/api/v1/miele/devices/{{deviceId}}/programs Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json diff --git a/requests/shelly-client.http b/requests/shelly-client.http index 56cab9e..dab163b 100644 --- a/requests/shelly-client.http +++ b/requests/shelly-client.http @@ -8,21 +8,21 @@ @deviceId = {{$dotenv SHELLY_DEVICE_ID}} ### Get device status -# @note +# @note Read-only status query; sends the configured credentials to the Shelly endpoint. POST {{baseUrl}}/device/status Content-Type: application/x-www-form-urlencoded auth_key={{authKey}}&id={{deviceId}} ### Turn relay on -# @note +# @note Energizes relay channel 0 on the selected physical device. POST {{baseUrl}}/device/relay/control Content-Type: application/x-www-form-urlencoded auth_key={{authKey}}&id={{deviceId}}&channel=0&turn=on ### Turn relay off -# @note +# @note De-energizes relay channel 0 on the selected physical device. POST {{baseUrl}}/device/relay/control Content-Type: application/x-www-form-urlencoded diff --git a/requests/shelly-controller.http b/requests/shelly-controller.http index 56573d4..6bf0664 100644 --- a/requests/shelly-controller.http +++ b/requests/shelly-controller.http @@ -21,11 +21,11 @@ GET {{baseUrl}}/api/v1/shelly/status/{{deviceId}} Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Turn relay on -# @note +# @note Energizes the configured relay channel on the selected physical device. POST {{baseUrl}}/api/v1/shelly/relay/{{deviceId}}?on=true Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Turn relay off -# @note +# @note De-energizes the configured relay channel on the selected physical device. POST {{baseUrl}}/api/v1/shelly/relay/{{deviceId}}?on=false Authorization: Basic {{$dotenv HAUS_API_AUTH}} diff --git a/requests/sicce-client.http b/requests/sicce-client.http index d6e08b8..a37c82a 100644 --- a/requests/sicce-client.http +++ b/requests/sicce-client.http @@ -8,15 +8,3 @@ Vendor-Key: {{$dotenv SICCE_VENDOR_KEY}} GET https://sicce-test.thingscloud.it/api/v3?device_token={{$dotenv SICCE_DEVICE_TOKEN}} Vendor-Key: {{$dotenv SICCE_VENDOR_KEY}} - -### - -# @note -POST https://example.com/comments HTTP/1.1 -Vendor-Key: {{$dotenv SICCE_VENDOR_KEY}} -Content-Type: application/json - -{ - "name": "sample", - "time": "Wed, 21 Oct 2015 18:27:50 GMT" -} diff --git a/requests/ubiquiti-controller.http b/requests/ubiquiti-controller.http index bea02f7..3b45c65 100644 --- a/requests/ubiquiti-controller.http +++ b/requests/ubiquiti-controller.http @@ -8,10 +8,13 @@ GET {{baseUrl}}/api/v1/ubiquiti Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Event callback - motion detected (no auth required) +# @note Publishes a camera event; configured sinks may capture media, invoke analysis and send notifications. GET {{baseUrl}}/api/v1/ubiquiti/event/motion?camera_id=ABC123&camera_name=Front+Door ### Event callback - smart detection (no auth required) +# @note Publishes a camera event; configured sinks may capture media, invoke analysis and send notifications. GET {{baseUrl}}/api/v1/ubiquiti/event/smart?type=person&camera_id=ABC123&camera_name=Front+Door&score=0.95 ### Event callback - doorbell ring (no auth required) +# @note Publishes a camera event; configured sinks may capture media, invoke analysis and send notifications. GET {{baseUrl}}/api/v1/ubiquiti/event/ring?camera_id=ABC123&camera_name=Front+Door diff --git a/requests/ubiquiti-demo.http b/requests/ubiquiti-demo.http index 036b22e..0b6d60f 100644 --- a/requests/ubiquiti-demo.http +++ b/requests/ubiquiti-demo.http @@ -3,10 +3,10 @@ ### ============================================================================= ### ### Test requests for the Ubiquiti UniFi Protect webhook endpoints running in -### the demo Docker Compose stack (docker-compose.demo.yml). +### the demo profile in docker-compose.yml. ### ### Usage: -### 1. Start the demo: docker compose -f docker-compose.demo.yml up --build +### 1. Start the demo: docker compose --profile demo up --build ### 2. Wait for the smarthaus service to become healthy ### 3. Run these requests from VS Code (REST Client extension) ### ============================================================================= @@ -16,31 +16,31 @@ ### ---------- Motion Detection ---------- ### Simulate a motion detection event from a front door camera -# @note +# @note Publishes a synthetic motion event; configured sinks may capture media, invoke analysis and send notifications. POST {{baseUrl}}/event/motion?camera_id=abc123&camera_name=FrontDoor ### ---------- Smart Detection ---------- ### Simulate a person detected at the front door (high confidence) -# @note +# @note Publishes a synthetic person event; configured sinks may capture media, invoke analysis and send notifications. POST {{baseUrl}}/event/smart?type=person&camera_id=abc123&camera_name=FrontDoor&score=0.95 ### Simulate a vehicle detected in the driveway -# @note +# @note Publishes a synthetic vehicle event; configured sinks may capture media, invoke analysis and send notifications. POST {{baseUrl}}/event/smart?type=vehicle&camera_id=def456&camera_name=Driveway&score=0.88 ### Simulate an animal detected in the garden -# @note +# @note Publishes a synthetic animal event; configured sinks may capture media, invoke analysis and send notifications. POST {{baseUrl}}/event/smart?type=animal&camera_id=ghi789&camera_name=Garden&score=0.72 ### Simulate a package detected at the front door -# @note +# @note Publishes a synthetic package event; configured sinks may capture media, invoke analysis and send notifications. POST {{baseUrl}}/event/smart?type=package&camera_id=abc123&camera_name=FrontDoor&score=0.91 ### ---------- Doorbell Ring ---------- ### Simulate a doorbell ring event -# @note +# @note Publishes a synthetic doorbell event; configured sinks may capture media, invoke analysis and send notifications. POST {{baseUrl}}/event/ring?camera_id=abc123&camera_name=FrontDoor ### ---------- Query ---------- diff --git a/requests/wiz-controller.http b/requests/wiz-controller.http index 3e1f0d2..b59c782 100644 --- a/requests/wiz-controller.http +++ b/requests/wiz-controller.http @@ -9,7 +9,7 @@ GET {{baseUrl}}/api/v1/wiz/bulbs Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Trigger bulb discovery -# @note +# @note Broadcasts discovery on the local network and updates the application's discovered-bulb state. POST {{baseUrl}}/api/v1/wiz/bulbs/discover Authorization: Basic {{$dotenv HAUS_API_AUTH}} @@ -18,7 +18,7 @@ GET {{baseUrl}}/api/v1/wiz/bulbs/{{bulbIdentifier}}/pilot Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Set pilot state for a bulb -# @note +# @note Changes the selected physical bulb's scene, brightness and playback speed. PUT {{baseUrl}}/api/v1/wiz/bulbs/{{bulbIdentifier}}/pilot Authorization: Basic {{$dotenv HAUS_API_AUTH}} Content-Type: application/json @@ -30,11 +30,11 @@ Content-Type: application/json } ### Turn bulb on -# @note +# @note Turns the selected physical bulb on. PUT {{baseUrl}}/api/v1/wiz/bulbs/{{bulbIdentifier}}/power?on=true Authorization: Basic {{$dotenv HAUS_API_AUTH}} ### Turn bulb off -# @note +# @note Turns the selected physical bulb off. PUT {{baseUrl}}/api/v1/wiz/bulbs/{{bulbIdentifier}}/power?on=false Authorization: Basic {{$dotenv HAUS_API_AUTH}} diff --git a/src/CasCap.Api.DDns/README.md b/src/CasCap.Api.DDns/README.md index c0baed3..056f2ef 100644 --- a/src/CasCap.Api.DDns/README.md +++ b/src/CasCap.Api.DDns/README.md @@ -28,7 +28,10 @@ must wait for the coordinated package release rather than substituting an invent ## Controller -`DDnsController` exposes read-only query endpoints for the Dynamic DNS service via the Haus internal Web API: +[`DDnsController`](Controllers/DDnsController.cs) exposes read-only query endpoints for the Dynamic DNS +service via the Haus internal Web API. The SmartHaus host discovers this controller only when `DDns` +is enabled. Enabling the feature also registers its DNS update worker; the HTTP query itself does not +update DNS records. | Method | Route | Description | | --- | --- | --- | diff --git a/src/CasCap.App.Server.Tests/Infrastructure/CasCapAppWebApplicationFactory.cs b/src/CasCap.App.Server.Tests/Infrastructure/CasCapAppWebApplicationFactory.cs index e117bb2..553dd15 100644 --- a/src/CasCap.App.Server.Tests/Infrastructure/CasCapAppWebApplicationFactory.cs +++ b/src/CasCap.App.Server.Tests/Infrastructure/CasCapAppWebApplicationFactory.cs @@ -36,7 +36,7 @@ namespace CasCap.Tests.Infrastructure; /// where those dependencies are absent. /// /// -public class CasCapAppWebApplicationFactory : WebApplicationFactory +public class CasCapAppWebApplicationFactory(string enabledFeatures = FeatureNames.Test) : WebApplicationFactory { /// The installation name injected into the test configuration. internal const string TestHausName = "TestHaus"; @@ -57,7 +57,7 @@ public class CasCapAppWebApplicationFactory : WebApplicationFactory public const string EnabledFeaturesConfigKey = "CasCap:FeatureConfig:EnabledFeatures"; - private static readonly IReadOnlyDictionary _startupEnvironment = + private readonly IReadOnlyDictionary _startupEnvironment = new Dictionary { ["AppConfig__KeyVaultName"] = "skip", @@ -65,7 +65,7 @@ public class CasCapAppWebApplicationFactory : WebApplicationFactory @@ -115,7 +115,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // (Knx, Buderus, DoorBird …) are activated, keeping startup fast and // independent of external hardware. [EnabledFeaturesConfigKey] - = FeatureNames.Test, + = enabledFeatures, // ── ApiAuthConfig ────────────────────────────────────────────────────── [$"{ApiAuthConfig.ConfigurationSectionName}:Username"] = TestUsername, diff --git a/src/CasCap.App.Server.Tests/README.md b/src/CasCap.App.Server.Tests/README.md index e12ecb5..8aecad9 100644 --- a/src/CasCap.App.Server.Tests/README.md +++ b/src/CasCap.App.Server.Tests/README.md @@ -23,7 +23,7 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | `DDnsLeadershipTests` | 2 | 3 | Leadership | Existing resource delegation and cancellation through IP discovery, without Azure/Redis calls | | `DeviceApiContractTests` | 7 | 46 | Integration | History cancellation, explicit power values, typed write outcomes, pilot range boundaries/validation, and Swagger metadata | | `HealthTests` | 4 | 7 | Integration | Health, liveness, readiness, and startup endpoints | -| `HostStartupConfigurationTests` | 1 | 1 | Integration | Options binding, Key Vault disablement, feature selection, and safe core DI services | +| `HostStartupConfigurationTests` | 2 | 3 | Integration | Options binding, Key Vault disablement, feature-gated DDns controller discovery, and safe core DI services | | `McpAuthenticationTests` | 2 | 2 | Integration | Anonymous rejection and Basic-authenticated MCP endpoint access | | `MieleApiContractTests` | 11 | 59 | Integration | Typed bodies, HTTP outcomes, validation, cancellation, no command replay, sanitized failures, disposal, and generated Swagger using fake HTTP | | `MieleMcpContractTests` | 3 | 24 | Integration | SDK-generated input schemas, nine facade cancellation paths, and boolean command outcomes using the fake provider | @@ -31,7 +31,7 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | `UbiquitiApiContractTests` | 4 | 11 | Integration | GET/POST webhooks, optional bodies, validation, request cancellation, and generated Swagger | | `UbiquitiQueryServiceTests` | 7 | 21 | Webhook Parsing | Detection types, thumbnails/timestamps, camera selection, sink failures, and cancellation using in-memory sinks | | `WebApiContractTests` | 4 | 8 | Integration | String-enum JSON output/input, validation Problem Details, and generated Swagger schemas | -| Total | 61 | 205 | | Source-declared, including skipped cases | +| Total | 62 | 207 | | Source-declared, including skipped cases | ## Trait Categories diff --git a/src/CasCap.App.Server.Tests/Tests/Integration/HostStartupConfigurationTests.cs b/src/CasCap.App.Server.Tests/Tests/Integration/HostStartupConfigurationTests.cs index 1780961..79a2d0e 100644 --- a/src/CasCap.App.Server.Tests/Tests/Integration/HostStartupConfigurationTests.cs +++ b/src/CasCap.App.Server.Tests/Tests/Integration/HostStartupConfigurationTests.cs @@ -1,4 +1,7 @@ +using CasCap.Controllers; using CasCap.Tests.Infrastructure; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; namespace CasCap.Tests.Integration; @@ -6,6 +9,20 @@ namespace CasCap.Tests.Integration; [Trait("Category", "Integration")] public class HostStartupConfigurationTests : WebApiTestBase { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DDnsControllerDiscovery(bool enabled) + { + // Regression: DDns services were registered but its controller assembly was not discovered. + await using var factory = new CasCapAppWebApplicationFactory( + enabled ? FeatureNames.DDns : FeatureNames.Test); + var actions = factory.Services.GetRequiredService() + .ActionDescriptors.Items.OfType(); + + Assert.Equal(enabled, actions.Any(action => action.ControllerTypeInfo.AsType() == typeof(DDnsController))); + } + [Fact] public void Startup_BindsConfigurationAndResolvesCoreServices() { diff --git a/src/CasCap.App.Server/AppHost.WebApi.cs b/src/CasCap.App.Server/AppHost.WebApi.cs index a047b4c..2cd3df1 100644 --- a/src/CasCap.App.Server/AppHost.WebApi.cs +++ b/src/CasCap.App.Server/AppHost.WebApi.cs @@ -53,6 +53,8 @@ private static void AddWebApi( if (enabledFeatures.Contains(FeatureNames.Buderus)) mvcBuilder.AddApplicationPart(typeof(BuderusController).Assembly); + if (enabledFeatures.Contains(FeatureNames.DDns)) + mvcBuilder.AddApplicationPart(typeof(DDnsController).Assembly); if (enabledFeatures.Contains(FeatureNames.DoorBird)) mvcBuilder.AddApplicationPart(typeof(DoorBirdController).Assembly); if (enabledFeatures.Contains(FeatureNames.Fronius)) From 9d0b2f88872f5febfff43e16fd0d360ae4854cb7 Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Fri, 23 Oct 2026 16:52:09 +0200 Subject: [PATCH 11/18] fix(runtime): close ownership and resilience gaps - Forward standby command queues without requiring local KNX connectivity - Scope autonomous scheduling to ownership and stop the host before stalled child cleanup - Map Polly resilience failures and preserve requested cancellation - Add deterministic ownership and recovery regressions with updated documentation --- src/CasCap.Api.DDns/README.md | 5 +- .../Services/DDnsFindMyIpClientService.cs | 2 +- src/CasCap.Api.Knx.Tests/README.md | 13 +- .../Tests/Unit/KnxLeadershipTests.cs | 237 +++++++++++++++++- .../Tests/Unit/KnxTestClock.cs | 91 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 4 +- src/CasCap.Api.Knx/README.md | 18 +- .../Services/KnxAutomationBgService.cs | 92 +++---- .../Services/KnxMonitorBgService.cs | 26 +- .../Services/KnxMonitorService.cs | 22 +- .../Services/KnxWorkerLifetime.cs | 37 +++ src/CasCap.Api.Miele/README.md | 3 + .../Services/MieleClientService.cs | 12 + src/CasCap.App.Server.Tests/README.md | 10 +- .../Tests/Unit/DDnsLeadershipTests.cs | 31 +++ .../Tests/Unit/MieleClientFailureTests.cs | 73 ++++++ 16 files changed, 571 insertions(+), 105 deletions(-) create mode 100644 src/CasCap.Api.Knx.Tests/Tests/Unit/KnxTestClock.cs create mode 100644 src/CasCap.Api.Knx/Services/KnxWorkerLifetime.cs create mode 100644 src/CasCap.App.Server.Tests/Tests/Unit/MieleClientFailureTests.cs diff --git a/src/CasCap.Api.DDns/README.md b/src/CasCap.Api.DDns/README.md index 056f2ef..3d42076 100644 --- a/src/CasCap.Api.DDns/README.md +++ b/src/CasCap.Api.DDns/README.md @@ -53,13 +53,16 @@ Only the acquired owner runs the update service. Observed loss cancels HTTP disc enumeration and writes through the ownership token, and the shared runner awaits completion before releasing or reacquiring. Every new run clears its subscription/IP shortcut state to revalidate DNS. The feature host also awaits worker cleanup during shutdown. +An IP-discovery HTTP timeout with an uncancelled ownership token returns no address and is retried +on the next refresh; requested ownership/host cancellation still propagates instead of being +treated as a recoverable timeout. This is coordination, not downstream fencing: a lease check cannot make an Azure DNS write atomic with ownership. Polling and cancellation have a detection/response delay. Align host shutdown and container termination budgets; the runner does not abandon cleanup after a timeout. `AddDDns(..., lite: true)` registers read-only query/client services without the update worker or a -leadership dependency. Focused credential-free tests cover delegation and HTTP cancellation; they do +leadership dependency. Focused credential-free tests cover delegation, HTTP cancellation and timeout recovery; they do not execute Azure DNS writes or prove Redis renewal under partitions. ## Configuration diff --git a/src/CasCap.Api.DDns/Services/DDnsFindMyIpClientService.cs b/src/CasCap.Api.DDns/Services/DDnsFindMyIpClientService.cs index 8e70591..a69e1a4 100644 --- a/src/CasCap.Api.DDns/Services/DDnsFindMyIpClientService.cs +++ b/src/CasCap.Api.DDns/Services/DDnsFindMyIpClientService.cs @@ -33,7 +33,7 @@ public DDnsFindMyIpClientService(ILogger logger, _logger.LogWarning("{ClassName} unable to determine current IP address", nameof(DDnsFindMyIpClientService)); } } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { _logger.LogWarning(ex, "{ClassName} something is broken..", nameof(DDnsFindMyIpClientService)); } diff --git a/src/CasCap.Api.Knx.Tests/README.md b/src/CasCap.Api.Knx.Tests/README.md index f83f74f..161e049 100644 --- a/src/CasCap.Api.Knx.Tests/README.md +++ b/src/CasCap.Api.Knx.Tests/README.md @@ -18,15 +18,17 @@ Source-declared counts include skipped cases and expand inline theories; no exec | `CemiDecodingTests` | 1 | 1 | `Integration` | | `ChannelKnxTelegramBrokerTests` | 4 | 4 | None | | `KnxShutterMovementTests` | 6 | 12 | None | -| `KnxLeadershipTests` | 8 | 12 | `Leadership` | -| Total | 25 | 35 | | +| `KnxLeadershipTests` | 11 | 18 | `Leadership` | +| Total | 28 | 41 | | Declared trait categories are `Integration` and `Leadership`. Broker and shutter tests have no category trait. Leadership cases use synthetic ETS XML, fake runners/monitor sessions and in-memory brokers. They verify standby behavior, routing/sharding selection, fresh ownership sessions, cancellation, -publication failure, late-telegram rejection and awaited sender/session cleanup. They do not -contact Redis, discover interfaces, send physical telegrams or prove live failover. +publication failure, late-telegram rejection, standby HTTP-command forwarding, awaited queue work, +ownership-bound day/night handover/cadence, per-write recovery and host-stop signalling before gated cleanup. The +scheduler clock is stepped without wall-clock waits. These cases do not contact Redis, discover +interfaces, send physical telegrams, exercise Falcon disposal bounds or prove live failover. ## Skipped Tests @@ -47,7 +49,8 @@ CasCap.Api.Knx.Tests/ ├── TestBase.cs └── Tests/ └── Unit/ - └── KnxLeadershipTests.cs + ├── KnxLeadershipTests.cs + └── KnxTestClock.cs ``` Older tests live at the project root; new leadership tests use `Tests/Unit/`. diff --git a/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs b/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs index 46375c0..8f6d320 100644 --- a/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs +++ b/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using System.Runtime.CompilerServices; +using System.Threading.Channels; namespace CasCap.Tests; @@ -111,7 +112,9 @@ await fixture.CreateWorker(runner, () => await Assert.ThrowsAnyAsync(() => first.WaitAsync(_timeout, TestContext.Current.CancellationToken)); Assert.True(firstSession.Disposed); + Assert.False(fixture.Lifetime.Stopping.Task.IsCompleted); + fixture.Health.ConnectionActive = true; using var secondOwnership = new CancellationTokenSource(); var second = runner.Work(secondOwnership.Token); Assert.Equal(2, sessions.Count); @@ -201,6 +204,158 @@ await Assert.ThrowsAnyAsync(() => Assert.False(fixture.Outgoing.Started.Task.IsCompleted); } + [Fact] + public async Task Automation_ForwardsStandbyHttpCommandsAndJoinsPublication() + { + // Regression: a healthy standby must forward its process-local HTTP queue without a bus. + using var fixture = new Fixture(); + using var host = new CancellationTokenSource(); + var query = fixture.CreateQuery(); + var response = await query.SetLightState(new KnxLightStateChangeRequest + { + GroupName = "EG-LI-Kitchen", + IsOn = true + }, cancellationToken: host.Token); + Assert.Equal(StateChangeOutcome.Queued, Assert.Single(response.Results).Outcome); + Assert.Equal(1, fixture.Queue.Count); + fixture.Outgoing.BlockPublication = true; + var running = fixture.CreateAutomation(query).ExecuteAsync(host.Token); + var published = await fixture.Outgoing.Published.Reader.ReadAsync(TestContext.Current.CancellationToken) + .AsTask().WaitAsync(_timeout, TestContext.Current.CancellationToken); + + Assert.Equal("EG-LI-Kitchen-SW", published.Kga.Name); + Assert.False(fixture.Health.ConnectionActive); + Assert.Empty(KnxStatics.BusConnections); + await host.CancelAsync(); + await fixture.Outgoing.PublicationCleaning.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + Assert.False(running.IsCompleted); + fixture.Outgoing.AllowPublicationCleanup.SetResult(); + await Assert.ThrowsAnyAsync(() => + running.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + Assert.Equal(0, fixture.Queue.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Automation_ScheduleHandoverAwaitsOwnedPublication(bool bedroomOverride) + { + // Regression: an old owner's autonomous schedule must stop before another run starts. + using var fixture = new Fixture(dayNight: true, bedroomOverride: bedroomOverride); + await fixture.Lookup.WaitUntilReadyAsync(TestContext.Current.CancellationToken); + fixture.Health.ConnectionActive = true; + fixture.Outgoing.AllowCleanup.SetResult(); + fixture.Outgoing.BlockPublication = true; + var clock = new KnxTestClock(); + var automation = fixture.CreateAutomation(timeProvider: clock); + using var firstOwnership = new CancellationTokenSource(); + var first = automation.RunOwnedAsync(fixture.WorkerLifetime, firstOwnership.Token); + var firstWrite = await fixture.Outgoing.Published.Reader.ReadAsync(TestContext.Current.CancellationToken) + .AsTask().WaitAsync(_timeout, TestContext.Current.CancellationToken); + Assert.Equal("SYS-[Night_Day]", firstWrite.Kga.Name); + await firstOwnership.CancelAsync(); + await fixture.Outgoing.PublicationCleaning.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + Assert.False(first.IsCompleted); + Assert.Equal(0, fixture.Queue.Count); + fixture.Outgoing.AllowPublicationCleanup.SetResult(); + await Assert.ThrowsAnyAsync(() => + first.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + Assert.False(fixture.Outgoing.Published.Reader.TryRead(out _)); + Assert.False(fixture.Lifetime.Stopping.Task.IsCompleted); + + fixture.Outgoing.BlockPublication = false; + using var nextOwnership = new CancellationTokenSource(); + var next = automation.RunOwnedAsync(fixture.WorkerLifetime, nextOwnership.Token); + var expected = bedroomOverride ? 2 : 1; + for (var i = 0; i < expected; i++) + { + var nextWrite = await fixture.Outgoing.Published.Reader.ReadAsync(TestContext.Current.CancellationToken) + .AsTask().WaitAsync(_timeout, TestContext.Current.CancellationToken); + Assert.Equal(i == 0 ? "SYS-[Night_Day]" : "SYS-[Night_Day]-Bedroom", nextWrite.Kga.Name); + } + var cadence = TimeSpan.FromMilliseconds(fixture.Config.Value.DayNightPollingDelayMs); + await clock.WaitForDelayAsync(cadence); + clock.Advance(cadence - TimeSpan.FromMilliseconds(1)); + Assert.False(fixture.Outgoing.Published.Reader.TryRead(out _)); + clock.Advance(TimeSpan.FromMilliseconds(1)); + for (var i = 0; i < expected; i++) + await fixture.Outgoing.Published.Reader.ReadAsync(TestContext.Current.CancellationToken) + .AsTask().WaitAsync(_timeout, TestContext.Current.CancellationToken); + await clock.WaitForDelayAsync(cadence); + fixture.Outgoing.Failure = new InvalidOperationException("Synthetic publication failure."); + clock.Advance(cadence); + for (var i = 0; i < expected; i++) + await fixture.Outgoing.Published.Reader.ReadAsync(TestContext.Current.CancellationToken) + .AsTask().WaitAsync(_timeout, TestContext.Current.CancellationToken); + await clock.WaitForDelayAsync(cadence); + Assert.False(next.IsCompleted); + Assert.False(fixture.Lifetime.Stopping.Task.IsCompleted); + fixture.Outgoing.Failure = null; + clock.Advance(cadence); + for (var i = 0; i < expected; i++) + await fixture.Outgoing.Published.Reader.ReadAsync(TestContext.Current.CancellationToken) + .AsTask().WaitAsync(_timeout, TestContext.Current.CancellationToken); + await nextOwnership.CancelAsync(); + await Assert.ThrowsAnyAsync(() => + next.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + Assert.False(fixture.Outgoing.Published.Reader.TryRead(out _)); + clock.Advance(cadence); + Assert.False(fixture.Outgoing.Published.Reader.TryRead(out _)); + Assert.Equal(0, fixture.Queue.Count); + } + + [Theory] + [InlineData("connect")] + [InlineData("outer")] + [InlineData("inner")] + public async Task Ownership_FaultStopsHostBeforeStalledCleanup(string failure) + { + // Regression: fatal child faults must begin host shutdown before joining stalled siblings. + using var fixture = new Fixture(); + fixture.Health.ConnectionActive = true; + fixture.Health.IsLeader = true; + var fail = Gate(); + var broker = new IncomingBroker { Fail = true, FailureGate = fail.Task }; + var realMonitor = fixture.CreateMonitor(broker); + Func? run = null; + if (failure == "inner") + run = async token => + { + await fixture.Outgoing.Started.Task.WaitAsync(token); + await realMonitor.RunAsync(token); + }; + var session = new TestMonitor + { + Failure = failure == "connect" ? "connect" : "run", + FailureGate = fail.Task, + Run = run + }; + var runner = new CapturingRunner(); + await fixture.CreateWorker(runner, () => session) + .ExecuteAsync(TestContext.Current.CancellationToken); + if (failure == "inner") + Assert.True(realMonitor.TryQueueTelegram(CreateTelegram())); + + var running = runner.Work!(TestContext.Current.CancellationToken); + if (failure != "connect") + await fixture.Outgoing.Started.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + fail.SetResult(); + await fixture.Lifetime.Stopping.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + Assert.False(running.IsCompleted); + Assert.False(fixture.Health.ConnectionActive); + Assert.False(fixture.Health.IsLeader); + if (failure == "connect") + await session.Disposing.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + else + await fixture.Outgoing.Cleaning.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + + fixture.Outgoing.AllowCleanup.SetResult(); + session.AllowDispose.SetResult(); + await Assert.ThrowsAsync(() => + running.WaitAsync(_timeout, TestContext.Current.CancellationToken)); + await realMonitor.DisposeAsync(); + } + private static KnxEvent CreateTelegram() => new(DateTime.UnixEpoch, new KnxGroupEvent(), new KnxGroupAddressParsed(new KnxGroupAddressXml { Name = "EG-LI-Kitchen-SW", Address = "0/0/1", DPTs = "DPST-1-1" }), @@ -210,20 +365,28 @@ private static KnxEvent CreateTelegram() private sealed class Fixture : IDisposable { - private readonly string _path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + private readonly string _path = Path.Combine(Environment.CurrentDirectory, $"knx-test-{Guid.NewGuid():N}.xml"); public KnxConnectionHealthCheck Health { get; } = new(); public OutgoingBroker Outgoing { get; } = new(); + public StateChangeQueue Queue { get; } = new(); + public TestLifetime Lifetime { get; } = new(); + public KnxWorkerLifetime WorkerLifetime { get; } public IOptions Config { get; } public KnxGroupAddressLookupService Lookup { get; } - public Fixture(ServiceFamily family = ServiceFamily.Tunneling, ShardingMode sharding = ShardingMode.Unified) + public Fixture(ServiceFamily family = ServiceFamily.Tunneling, ShardingMode sharding = ShardingMode.Unified, + bool dayNight = false, bool bedroomOverride = false) { - File.WriteAllText(_path, """ + WorkerLifetime = new(Health, Lifetime); + File.WriteAllText(_path, $$""" + + + {{(bedroomOverride ? """""" : "")}} @@ -232,7 +395,10 @@ public Fixture(ServiceFamily family = ServiceFamily.Tunneling, ShardingMode shar { [nameof(KnxConfig.ServiceFamily)] = family.ToString(), [nameof(KnxConfig.ShardingMode)] = sharding.ToString(), - [nameof(KnxConfig.GroupAddressXmlFilePath)] = _path + [nameof(KnxConfig.GroupAddressXmlFilePath)] = _path, + [nameof(KnxConfig.DayNightEnabled)] = dayNight.ToString(), + [nameof(KnxConfig.DayNightTimeZoneLocation)] = TimeZoneInfo.GetSystemTimeZones()[0].DisplayName, + [nameof(KnxConfig.StateChangeMaxPollIterations)] = "1" }).Build(); Config = Options.Create(configuration.Get()!); Lookup = new KnxGroupAddressLookupService(NullLogger.Instance, @@ -240,13 +406,21 @@ public Fixture(ServiceFamily family = ServiceFamily.Tunneling, ShardingMode shar } public KnxMonitorBgService CreateWorker(ILeadershipRunner runner, Func factory) - => new(NullLogger.Instance, Config, Lookup, runner, factory, CreateSender()); + => new(NullLogger.Instance, Config, Lookup, runner, factory, CreateAutomation(), WorkerLifetime); + + public KnxQueryService CreateQuery() + => new(NullLogger.Instance, Config, + new KnxMemoryStateService(NullLogger.Instance), Lookup, Queue, Outgoing); + + public KnxAutomationBgService CreateAutomation(IKnxQueryService? query = null, TimeProvider? timeProvider = null) + => new(NullLogger.Instance, Config, timeProvider ?? TimeProvider.System, + Lookup, CreateSender(), Queue, query ?? CreateQuery()); public KnxSenderBgService CreateSender() => new(NullLogger.Instance, Config, TimeProvider.System, Health, Outgoing); public KnxMonitorService CreateMonitor(IKnxTelegramBroker incoming) - => new(NullLogger.Instance, Config, TimeProvider.System, Lookup, Health, incoming, new KnxConnectionNotifier()); + => new(NullLogger.Instance, Config, TimeProvider.System, Lookup, WorkerLifetime, incoming, new KnxConnectionNotifier()); public void Dispose() => File.Delete(_path); } @@ -269,6 +443,8 @@ public Task RunAsync(string resource, Func runAsync, Ca private sealed class TestMonitor : IKnxMonitor { public string? Failure { get; init; } + public Task FailureGate { get; init; } = Task.CompletedTask; + public Func? Run { get; init; } public CancellationToken ConnectToken { get; private set; } public TaskCompletionSource Disposing { get; } = Gate(); public TaskCompletionSource AllowDispose { get; } = Gate(); @@ -282,13 +458,21 @@ public Task ConnectAsync(CancellationToken cancellationToken) : Task.CompletedTask; } - public Task RunAsync(CancellationToken cancellationToken) - => Failure switch + public async Task RunAsync(CancellationToken cancellationToken) + { + if (Run is not null) + { + await Run(cancellationToken); + return; + } + await FailureGate.WaitAsync(cancellationToken); + await (Failure switch { "run" => Task.FromException(new InvalidOperationException("Monitor failed.")), "complete" => Task.CompletedTask, _ => Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken) - }; + }); + } public async ValueTask DisposeAsync() { @@ -304,9 +488,29 @@ private sealed class OutgoingBroker : IKnxTelegramBroker public TaskCompletionSource Cleaning { get; } = Gate(); public TaskCompletionSource AllowCleanup { get; } = Gate(); public CancellationToken Token { get; private set; } + public Channel Published { get; } = Channel.CreateUnbounded(); + public bool BlockPublication { get; set; } + public Exception? Failure { get; set; } + public TaskCompletionSource PublicationCleaning { get; } = Gate(); + public TaskCompletionSource AllowPublicationCleanup { get; } = Gate(); - public ValueTask PublishAsync(KnxOutgoingTelegram item, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + public async ValueTask PublishAsync(KnxOutgoingTelegram item, CancellationToken cancellationToken = default) + { + await Published.Writer.WriteAsync(item, cancellationToken); + if (Failure is not null) + throw Failure; + if (!BlockPublication) + return; + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + finally + { + PublicationCleaning.TrySetResult(); + await AllowPublicationCleanup.Task.WaitAsync(_timeout, TestContext.Current.CancellationToken); + } + } public async IAsyncEnumerable SubscribeAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -328,6 +532,7 @@ public async IAsyncEnumerable SubscribeAsync([EnumeratorCan private sealed class IncomingBroker : IKnxTelegramBroker { public bool Fail { get; init; } + public Task FailureGate { get; init; } = Task.CompletedTask; public KnxEvent? Telegram { get; private set; } public CancellationToken Token { get; private set; } public TaskCompletionSource Started { get; } = Gate(); @@ -339,6 +544,7 @@ public async ValueTask PublishAsync(KnxEvent item, CancellationToken cancellatio Telegram = item; Token = cancellationToken; Started.SetResult(); + await FailureGate.WaitAsync(cancellationToken); if (Fail) throw new InvalidOperationException("Publication failed."); try @@ -356,6 +562,15 @@ public IAsyncEnumerable SubscribeAsync(CancellationToken cancellationT => throw new NotSupportedException(); } + private sealed class TestLifetime : IHostApplicationLifetime + { + public TaskCompletionSource Stopping { get; } = Gate(); + public CancellationToken ApplicationStarted => CancellationToken.None; + public CancellationToken ApplicationStopping => CancellationToken.None; + public CancellationToken ApplicationStopped => CancellationToken.None; + public void StopApplication() => Stopping.TrySetResult(); + } + private sealed class TestEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = Environments.Production; diff --git a/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxTestClock.cs b/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxTestClock.cs new file mode 100644 index 0000000..e7bfac7 --- /dev/null +++ b/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxTestClock.cs @@ -0,0 +1,91 @@ +using System.Threading.Channels; + +namespace CasCap.Tests; + +/// Steps the one-shot delays used by KNX automation without sleeping. +internal sealed class KnxTestClock : TimeProvider +{ + private readonly object _gate = new(); + private readonly List _timers = []; + private readonly Channel _registrations = Channel.CreateUnbounded(); + private DateTimeOffset _now = new(2026, 6, 1, 12, 0, 0, TimeSpan.Zero); + + /// + public override DateTimeOffset GetUtcNow() + { + lock (_gate) + return _now; + } + + /// + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new StepTimer(this, callback, state); + timer.Change(dueTime, period); + return timer; + } + + /// Waits until an expected delay has actually been registered. + internal async Task WaitForDelayAsync(TimeSpan delay) + { + while (await _registrations.Reader.ReadAsync(TestContext.Current.CancellationToken) + .AsTask().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken) != delay) { } + } + + /// Advances the clock and invokes due callbacks outside its lock. + internal void Advance(TimeSpan amount) + { + List due; + lock (_gate) + { + _now += amount; + due = [.. _timers.Where(timer => timer.Due <= _now)]; + foreach (var timer in due) + _timers.Remove(timer); + } + foreach (var timer in due) + timer.Fire(); + } + + private sealed class StepTimer(KnxTestClock clock, TimerCallback callback, object? state) : ITimer + { + public DateTimeOffset Due { get; private set; } + private bool _disposed; + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + if (period != Timeout.InfiniteTimeSpan) + throw new NotSupportedException("The KNX test clock supports only one-shot delays."); + lock (clock._gate) + { + if (_disposed) + return false; + clock._timers.Remove(this); + if (dueTime != Timeout.InfiniteTimeSpan) + { + Due = clock._now + dueTime; + clock._timers.Add(this); + } + clock._registrations.Writer.TryWrite(dueTime); + return true; + } + } + + public void Fire() => callback(state); + + public void Dispose() + { + lock (clock._gate) + { + _disposed = true; + clock._timers.Remove(this); + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs b/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs index 8fb8775..72168c1 100644 --- a/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs +++ b/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs @@ -105,12 +105,14 @@ public static void AddKnx(this IServiceCollection services, IConfiguration confi } services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton>(sp => () => ActivatorUtilities.CreateInstance(sp)); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); } services.AddSingleton(); diff --git a/src/CasCap.Api.Knx/README.md b/src/CasCap.Api.Knx/README.md index 5ad8384..ffa74f2 100644 --- a/src/CasCap.Api.Knx/README.md +++ b/src/CasCap.Api.Knx/README.md @@ -41,7 +41,15 @@ The library's monitor, processor and sender form a pipeline: 3. **`KnxSenderBgService`** – Runs inside the monitor's ownership callback, not as an independently launched feature. It waits for connectivity before consuming outbound requests and passes the ownership-linked cancellation token to bus writes. Standbys do not consume outgoing commands. -Supporting services include `KnxAutomationBgService` (queue processing and day/night signalling). A REST API (`KnxController`) and a gRPC endpoint (`RpcTelegramService`) allow external callers to read state and send commands. +`KnxAutomationBgService` forwards its process-local command queue on every replica as soon as ETS +metadata is ready, including healthy standbys without a local bus connection. Its optional day/night +schedule instead runs inside the monitor's ownership callback, alongside the sender. The first +day/night update follows connectivity and subsequent updates retain `DayNightPollingDelayMs`. +Scheduled writes are awaited with the ownership token, including the optional bedroom override; +they do not enter the host-lived local queue. + +A REST API (`KnxController`) and a gRPC endpoint (`RpcTelegramService`) allow external callers to read +state and send commands. ## Group Address Naming Convention @@ -193,7 +201,7 @@ The `ShardingMode` property on `KnxConfig` controls how `KnxMonitorBgService` po 1. **Group address lookup** — all replicas load the ETS XML metadata concurrently (stateless, no lock required). 2. **Configuration logged** — after the lookup completes, `ServiceFamily` and `ShardingMode` are emitted at `Information` level so the active configuration is visible in every pod's log stream. If `ServiceFamily` is `Routing` and `ShardingMode` was set to `Partitioned`, it is overridden to `Unified` with a warning. 3. **Leadership election** — the shared runner acquires the unchanged resource `knx:monitor`, using the existing caching factory/database/key format and `CachingConfig.Redlock` configuration. The built-in `LeaderElection` profile defaults to 30 s expiry, 60 s acquisition wait and 5 s retry; application overrides still apply. Root cache-miss timings are unchanged. -4. **Leader runs** — each ownership run gets fresh connection, callback-buffer and reconnect state. Health monitoring, reconnection, incoming publication and outbound delivery are supervised together. A fatal worker fault cancels and joins siblings before disposal. Initial connection failures and subsequent drops are retried for both tunneling and routing. +4. **Leader runs** — each ownership run gets fresh connection, callback-buffer and reconnect state. Health monitoring, reconnection, incoming publication, outbound delivery and enabled day/night scheduling are supervised together. A fatal child failure withdraws readiness and requests host shutdown immediately, before cancelling/joining siblings and disposing connections. Ownership-only cancellation does not stop the host. Initial connection failures and subsequent drops are retried for both tunneling and routing. 5. **Ownership ends** — the runner observes lease loss at `Redlock.LeadershipCheckIntervalMs` (default 1000 ms) and cancels the ownership token. Health is withdrawn, callbacks stop accepting telegrams, and all publication/write work is awaited before handlers are detached and buses asynchronously disposed. Cleanup is also applied to partial startup failures. Only then may this replica start a fresh ownership run. Host shutdown owns the forced-termination budget; cleanup has no independent timeout. This checkout uses the provisional .NET 8+ runner from sibling Debug project references. Release @@ -206,6 +214,12 @@ retry behavior remains broker-specific; this lifecycle does not provide exactly- downstream fencing. Offline tests cover supervision and cleanup, not live lease renewal, partitions or physical bus behavior. +Local command processing retains bounded concurrency and awaits in-flight work during shutdown. +Dequeue remains destructive: cancellation or process failure after dequeue can lose that item; +no automatic requeue or stronger delivery guarantee is added. Falcon disposal is awaited without an +application timeout or cancellation token. Its internal bound has not been verified; immediate +host-stop signalling starts the host's termination budget even if sibling or SDK cleanup stalls. + The sender can use surviving connections while another line reconnects; the overall connection health check still requires every configured connection. Redis operations already dispatched are awaited even when cancellation arrives. Stream acknowledgement follows processing, but pending diff --git a/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs b/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs index f9405d8..23ae900 100644 --- a/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs +++ b/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs @@ -3,32 +3,19 @@ namespace CasCap.Services; /// -/// The is the primary automation background service -/// that runs up to two concurrent tasks after the KNX connection becomes active: -/// -/// -/// Queue processor — continuously drains the and -/// dispatches direct writes and resolved state changes to the KNX bus. -/// -/// -/// Day/night detection — when enabled, pushes DPT_DayNight (Day=0, Night=1) -/// status into the bus every using sunrise/sunset times from the -/// library. -/// -/// +/// Forwards local commands on every metadata-ready replica and runs day/night scheduling only +/// within the monitor's ownership lifetime. /// public sealed class KnxAutomationBgService( ILogger logger, IOptions knxConfig, TimeProvider timeProvider, KnxGroupAddressLookupService knxGroupAddressLookupSvc, - KnxConnectionHealthCheck knxConnectionHealthCheck, + KnxSenderBgService knxSenderBgSvc, IStateChangeQueue stateChangeQueue, IKnxQueryService knxQuerySvc ) : IBgFeature { - private readonly SemaphoreSlim _queueSemaphore = new(knxConfig.Value.QueueMaxConcurrency, knxConfig.Value.QueueMaxConcurrency); - /// public string FeatureName => "Knx"; @@ -36,24 +23,22 @@ IKnxQueryService knxQuerySvc public async Task ExecuteAsync(CancellationToken cancellationToken) { logger.LogInformation("{ClassName} starting", nameof(KnxAutomationBgService)); - try - { - await WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); - - var tasks = new List { ProcessQueueAsync(cancellationToken) }; + await knxGroupAddressLookupSvc.WaitUntilReadyAsync(cancellationToken).ConfigureAwait(false); + await Parallel.ForEachAsync(ReadQueueAsync(cancellationToken), + new ParallelOptions { MaxDegreeOfParallelism = knxConfig.Value.QueueMaxConcurrency, CancellationToken = cancellationToken }, + async (item, token) => await ProcessItemAsync(item, token).ConfigureAwait(false)).ConfigureAwait(false); + logger.LogInformation("{ClassName} exiting", nameof(KnxAutomationBgService)); + } - if (knxConfig.Value.DayNightEnabled) - tasks.Add(RunDayNightAsync(cancellationToken)); - else - logger.LogWarning("{ClassName} day/night detection disabled", - nameof(KnxAutomationBgService)); + /// Runs the sender and optional scheduler as children of the acquired monitor lease. + internal Task RunOwnedAsync(KnxWorkerLifetime workerLifetime, CancellationToken cancellationToken) + { + if (!knxConfig.Value.DayNightEnabled) + return knxSenderBgSvc.ExecuteAsync(cancellationToken); - //await-await-WhenAny propagates the first faulted task immediately so the - //service crashes and the pod restarts rather than running in a degraded state. - await await Task.WhenAny(tasks).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException and not TaskCanceledException) { throw; } - logger.LogInformation("{ClassName} exiting", nameof(KnxAutomationBgService)); + Func[] workers = + [knxSenderBgSvc.ExecuteAsync, token => RunDayNightAsync(workerLifetime, token)]; + return workerLifetime.RunAsync(workers, cancellationToken); } /// @@ -61,16 +46,16 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) /// intervals and escalating to /// every attempts. /// - private async Task WaitForConnectionAsync(CancellationToken cancellationToken) + private async Task WaitForConnectionAsync(KnxWorkerLifetime workerLifetime, CancellationToken cancellationToken) { var attempt = 1; - while (!knxConnectionHealthCheck.ConnectionActive) + while (!workerLifetime.Health.ConnectionActive) { cancellationToken.ThrowIfCancellationRequested(); logger.Log(attempt % knxConfig.Value.ConnectionLogEscalationInterval == 0 ? LogLevel.Warning : LogLevel.Trace, "{ClassName} readiness probe not yet healthy, attempt {Attempt}, retry in {RetryMs}ms...", nameof(KnxAutomationBgService), attempt, knxConfig.Value.ConnectionPollingDelayMs); - await Task.Delay(knxConfig.Value.ConnectionPollingDelayMs, cancellationToken).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromMilliseconds(knxConfig.Value.ConnectionPollingDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); attempt++; } logger.LogInformation("{ClassName} KNX connection active after {Attempt} attempt(s)", @@ -81,18 +66,17 @@ private async Task WaitForConnectionAsync(CancellationToken cancellationToken) /// Continuously drains the and dispatches each item /// for processing, respecting . /// - private async Task ProcessQueueAsync(CancellationToken cancellationToken) + private async IAsyncEnumerable ReadQueueAsync([EnumeratorCancellation] CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { if (!stateChangeQueue.TryDequeue(out var item)) { - await Task.Delay(knxConfig.Value.QueuePollingDelayMs, cancellationToken).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromMilliseconds(knxConfig.Value.QueuePollingDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); continue; } - await _queueSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - _ = ProcessItemAsync(item, cancellationToken); + yield return item; } } @@ -131,10 +115,6 @@ private async Task ProcessItemAsync(KnxStateChangeItem item, CancellationToken c logger.LogError(ex, "{ClassName} error processing state change for {GroupName}", nameof(KnxAutomationBgService), item.GroupName); } - finally - { - _queueSemaphore.Release(); - } } /// @@ -143,18 +123,19 @@ private async Task ProcessItemAsync(KnxStateChangeItem item, CancellationToken c /// Weekday/weekend sunrise hour overrides are applied from . /// The first push occurs immediately on startup. /// - private async Task RunDayNightAsync(CancellationToken cancellationToken) + private async Task RunDayNightAsync(KnxWorkerLifetime workerLifetime, CancellationToken cancellationToken) { + await WaitForConnectionAsync(workerLifetime, cancellationToken).ConfigureAwait(false); var tz = TimeZoneInfo.GetSystemTimeZones() .FirstOrDefault(p => p.DisplayName.Contains(knxConfig.Value.DayNightTimeZoneLocation, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"Unable to find TimeZoneInfo for location '{knxConfig.Value.DayNightTimeZoneLocation}'"); while (!cancellationToken.IsCancellationRequested) { - UpdateDayNight(); - await Task.Delay(knxConfig.Value.DayNightPollingDelayMs, cancellationToken).ConfigureAwait(false); + await UpdateDayNightAsync().ConfigureAwait(false); + await Task.Delay(TimeSpan.FromMilliseconds(knxConfig.Value.DayNightPollingDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); } - void UpdateDayNight() + async Task UpdateDayNightAsync() { var kga = knxGroupAddressLookupSvc.GetKGAByName(knxConfig.Value.DayNightGroupAddressName); if (kga is null) @@ -167,7 +148,7 @@ void UpdateDayNight() var solarNoon = TimeZoneInfo.ConvertTimeFromUtc(solarTimes.SolarNoon.ToUniversalTime(), tz); //send default day night - EnqueueDayNightUpdate(kga); + await SendDayNightUpdateAsync(kga).ConfigureAwait(false); //lets now override sunrise { @@ -194,32 +175,33 @@ void UpdateDayNight() //TODO: create a collection of destination group address here? var kgaOGEltern = knxGroupAddressLookupSvc.GetKGAByName("SYS-[Night_Day]-Bedroom"); if (kgaOGEltern is not null) - EnqueueDayNightUpdate(kgaOGEltern); + await SendDayNightUpdateAsync(kgaOGEltern).ConfigureAwait(false); - void EnqueueDayNightUpdate(KnxGroupAddressParsed kga) + async Task SendDayNightUpdateAsync(KnxGroupAddressParsed kga) { //1.024 DPT_DayNight standard is Day=0 (false), Night=1 (true) var dayNightValue = !IsDay(); - stateChangeQueue.Enqueue(new KnxStateChangeItem + cancellationToken.ThrowIfCancellationRequested(); + await ProcessItemAsync(new KnxStateChangeItem { GroupName = kga.Name, - DirectWrites = [(kga.Name, dayNightValue)], - }); + DirectWrites = [(kga.Name, dayNightValue)] + }, cancellationToken).ConfigureAwait(false); var logLevel = string.IsNullOrEmpty(knxConfig.Value.BusLoggingGroupAddressFilter) || kga.Name.Contains(knxConfig.Value.BusLoggingGroupAddressFilter, StringComparison.OrdinalIgnoreCase) ? LogLevel.Information : LogLevel.Trace; if (IsDay()) - logger.Log(logLevel, "{ClassName} enqueued Day/Night value {ActualValue} to {GroupAddress}, " + logger.Log(logLevel, "{ClassName} processed Day/Night value {ActualValue} to {GroupAddress}, " + "local time is {LocalTime:yyyy-MM-dd HH:mm:ss} sunset in {MinutesUntilSunset} minute(s) at {Sunset:HH:mm:ss.fffzzz}, " + "solar noon in {MinutesUntilSolarNoon} minute(s) at {SolarNoon:HH:mm:ss.fffzzz}", nameof(KnxAutomationBgService), dayNightValue, kga.Name, localTime, (int)sunset.Subtract(localTime).TotalMinutes, sunset, (int)solarNoon.Subtract(localTime).TotalMinutes, solarNoon); else - logger.Log(logLevel, "{ClassName} enqueued Day/Night value {ActualValue} to {GroupAddress}, " + logger.Log(logLevel, "{ClassName} processed Day/Night value {ActualValue} to {GroupAddress}, " + "local time is {LocalTime:yyyy-MM-dd HH:mm:ss} sunrise in {MinutesUntilSunrise} minute(s) at {Sunrise:HH:mm:ss.fffzzz}", nameof(KnxAutomationBgService), dayNightValue, kga.Name, localTime, (int)sunrise.Subtract(localTime).TotalMinutes, sunrise); diff --git a/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs b/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs index 05c7e12..78fcd66 100644 --- a/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs +++ b/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs @@ -9,7 +9,8 @@ public sealed class KnxMonitorBgService( KnxGroupAddressLookupService knxGroupAddressLookupSvc, ILeadershipRunner leadershipRunner, Func monitorFactory, - KnxSenderBgService knxSenderBgSvc) : IBgFeature + KnxAutomationBgService knxAutomationBgSvc, + KnxWorkerLifetime workerLifetime) : IBgFeature { /// public string FeatureName => "Knx"; @@ -38,16 +39,17 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) private async Task RunOwnedAsync(CancellationToken cancellationToken) { await using var monitor = monitorFactory(); - await monitor.ConnectAsync(cancellationToken).ConfigureAwait(false); - - Func[] workers = [monitor.RunAsync, knxSenderBgSvc.ExecuteAsync]; - await Parallel.ForEachAsync(workers, - new ParallelOptions { MaxDegreeOfParallelism = workers.Length, CancellationToken = cancellationToken }, - async (worker, token) => - { - await worker(token).ConfigureAwait(false); - token.ThrowIfCancellationRequested(); - throw new InvalidOperationException("A KNX ownership worker stopped unexpectedly."); - }).ConfigureAwait(false); + try + { + await monitor.ConnectAsync(cancellationToken).ConfigureAwait(false); + Func[] workers = + [monitor.RunAsync, token => knxAutomationBgSvc.RunOwnedAsync(workerLifetime, token)]; + await workerLifetime.RunAsync(workers, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + workerLifetime.StopApplication(); + throw; + } } } diff --git a/src/CasCap.Api.Knx/Services/KnxMonitorService.cs b/src/CasCap.Api.Knx/Services/KnxMonitorService.cs index 4e4e1a5..8e66aa0 100644 --- a/src/CasCap.Api.Knx/Services/KnxMonitorService.cs +++ b/src/CasCap.Api.Knx/Services/KnxMonitorService.cs @@ -13,10 +13,11 @@ public sealed partial class KnxMonitorService( IOptions config, TimeProvider timeProvider, KnxGroupAddressLookupService knxGroupAddressLookupSvc, - KnxConnectionHealthCheck knxConnectionHealthCheck, + KnxWorkerLifetime workerLifetime, IKnxTelegramBroker incomingBroker, KnxConnectionNotifier connectionNotifier) : IKnxMonitor { + private KnxConnectionHealthCheck ConnectionHealth => workerLifetime.Health; private readonly object _callbackGate = new(); private readonly ConcurrentDictionary _ownedBuses = new(); // TODO: Bound callback ingress once measured bursts and an explicit overflow/recovery policy @@ -54,7 +55,7 @@ public sealed partial class KnxMonitorService( public async Task ConnectAsync(CancellationToken cancellationToken) { _ownershipToken = cancellationToken; - knxConnectionHealthCheck.IsLeader = true; + ConnectionHealth.IsLeader = true; _ownershipRegistration = cancellationToken.Register(WithdrawOwnership); cancellationToken.ThrowIfCancellationRequested(); switch (config.Value.ServiceFamily) @@ -80,14 +81,7 @@ public async Task RunAsync(CancellationToken cancellationToken) [MonitorHealthAsync, ProcessReconnectionRequestsAsync, PublishTelegramsAsync]; try { - await Parallel.ForEachAsync(workers, - new ParallelOptions { MaxDegreeOfParallelism = workers.Length, CancellationToken = cancellationToken }, - async (worker, token) => - { - await worker(token).ConfigureAwait(false); - token.ThrowIfCancellationRequested(); - throw new InvalidOperationException("A KNX connection worker stopped unexpectedly."); - }).ConfigureAwait(false); + await workerLifetime.RunAsync(workers, cancellationToken).ConfigureAwait(false); } finally { @@ -100,8 +94,8 @@ private void WithdrawOwnership() lock (_callbackGate) { _stopping = true; - knxConnectionHealthCheck.ConnectionActive = false; - knxConnectionHealthCheck.IsLeader = false; + ConnectionHealth.ConnectionActive = false; + ConnectionHealth.IsLeader = false; } } @@ -213,7 +207,7 @@ private async Task MonitorHealthAsync(CancellationToken cancellationToken) var allConnected = !KnxStatics.BusConnections.IsEmpty && KnxStatics.BusConnections.Values .All(bus => bus?.ConnectionState == BusConnectionState.Connected); lock (_callbackGate) - knxConnectionHealthCheck.ConnectionActive = !_stopping && allConnected; + ConnectionHealth.ConnectionActive = !_stopping && allConnected; await Task.Delay(TimeSpan.FromMilliseconds(config.Value.ConnectionHealthPollingDelayMs), timeProvider, cancellationToken).ConfigureAwait(false); } } @@ -557,7 +551,7 @@ private void HandleConnectionStateChanged(object? sender) .FirstOrDefault(kvp => ReferenceEquals(kvp.Value, bus)).Key; if (areaLine is not null) { - knxConnectionHealthCheck.ConnectionActive = false; + ConnectionHealth.ConnectionActive = false; LogConnectionDropped(logger, nameof(KnxMonitorService), areaLine); connectionNotifier.Notify(new KnxConnectionStateChange { diff --git a/src/CasCap.Api.Knx/Services/KnxWorkerLifetime.cs b/src/CasCap.Api.Knx/Services/KnxWorkerLifetime.cs new file mode 100644 index 0000000..1857312 --- /dev/null +++ b/src/CasCap.Api.Knx/Services/KnxWorkerLifetime.cs @@ -0,0 +1,37 @@ +namespace CasCap.Services; + +/// Supervises KNX children and starts host shutdown before awaiting failed-run cleanup. +public sealed class KnxWorkerLifetime( + KnxConnectionHealthCheck health, + IHostApplicationLifetime applicationLifetime) +{ + /// Gets the readiness state shared by the owned connection and sender. + public KnxConnectionHealthCheck Health => health; + + /// Withdraws readiness and starts the host's shutdown budget after a fatal failure. + public void StopApplication() + { + health.ConnectionActive = false; + health.IsLeader = false; + applicationLifetime.StopApplication(); + } + + /// Runs required children, cancelling and joining siblings after any child exits. + public Task RunAsync(Func[] workers, CancellationToken cancellationToken) + => Parallel.ForEachAsync(workers, + new ParallelOptions { MaxDegreeOfParallelism = workers.Length, CancellationToken = cancellationToken }, + async (worker, token) => + { + try + { + await worker(token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + throw new InvalidOperationException("A KNX ownership worker stopped unexpectedly."); + } + catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested) + { + StopApplication(); + throw; + } + }); +} diff --git a/src/CasCap.Api.Miele/README.md b/src/CasCap.Api.Miele/README.md index abd1136..ecd15c7 100644 --- a/src/CasCap.Api.Miele/README.md +++ b/src/CasCap.Api.Miele/README.md @@ -36,6 +36,9 @@ statuses throw [`MieleApiException`](Exceptions/MieleApiException.cs). Commands return `MieleCommandResult.Accepted`, `Rejected` or `NotFound`. Provider HTTP 400, 405, 409 and 422 mean rejection; HTTP 404 means absence. Authentication, rate-limit, transport, timeout and other provider failures throw the domain exception. Caller cancellation propagates unchanged. +The standard resilience pipeline's `TimeoutRejectedException` and `BrokenCircuitException` +(including explicitly isolated circuits) also become sanitized domain failures rather than +escaping as HTTP 500 errors. The named REST client's automatic retries are disabled for both reads and writes: an appliance PUT may start a program, so replaying an outcome-uncertain command is unsafe. diff --git a/src/CasCap.Api.Miele/Services/MieleClientService.cs b/src/CasCap.Api.Miele/Services/MieleClientService.cs index 5bce98c..0b0ef28 100644 --- a/src/CasCap.Api.Miele/Services/MieleClientService.cs +++ b/src/CasCap.Api.Miele/Services/MieleClientService.cs @@ -1,4 +1,6 @@ using CasCap.Exceptions; +using Polly.CircuitBreaker; +using Polly.Timeout; using System.Net; using System.Text.Json; using System.Text.Json.Serialization; @@ -176,6 +178,16 @@ private async Task SendAsync(HttpRequestMessage request, Ca { throw Failure("The Miele API request timed out."); } + catch (TimeoutRejectedException) + { + cancellationToken.ThrowIfCancellationRequested(); + throw Failure("The Miele API request timed out."); + } + catch (BrokenCircuitException) + { + cancellationToken.ThrowIfCancellationRequested(); + throw Failure("The Miele API is temporarily unavailable."); + } } private MieleApiException Failure(string message, HttpStatusCode? statusCode = null) diff --git a/src/CasCap.App.Server.Tests/README.md b/src/CasCap.App.Server.Tests/README.md index 8aecad9..a376e8c 100644 --- a/src/CasCap.App.Server.Tests/README.md +++ b/src/CasCap.App.Server.Tests/README.md @@ -20,18 +20,19 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | `BuderusQueryServiceTests` | 5 | 9 | Device Commands | Path validation/normalization, preflight/discovery cancellation, and MCP boolean rejection using fake HTTP | | `FeatureServiceRegistrationTests` | 4 | 4 | Integration | `IBgFeature` registrations and enabled-feature filtering | | `FeatureConfigTests` | 3 | 6 | Configuration | Empty values, unknown-name rejection, and case-insensitive parsing | -| `DDnsLeadershipTests` | 2 | 3 | Leadership | Existing resource delegation and cancellation through IP discovery, without Azure/Redis calls | +| `DDnsLeadershipTests` | 3 | 4 | Leadership | Existing resource delegation, cancellation and timeout recovery through IP discovery, without Azure/Redis calls | | `DeviceApiContractTests` | 7 | 46 | Integration | History cancellation, explicit power values, typed write outcomes, pilot range boundaries/validation, and Swagger metadata | | `HealthTests` | 4 | 7 | Integration | Health, liveness, readiness, and startup endpoints | | `HostStartupConfigurationTests` | 2 | 3 | Integration | Options binding, Key Vault disablement, feature-gated DDns controller discovery, and safe core DI services | | `McpAuthenticationTests` | 2 | 2 | Integration | Anonymous rejection and Basic-authenticated MCP endpoint access | | `MieleApiContractTests` | 11 | 59 | Integration | Typed bodies, HTTP outcomes, validation, cancellation, no command replay, sanitized failures, disposal, and generated Swagger using fake HTTP | +| `MieleClientFailureTests` | 2 | 7 | Device Commands | Real Polly timeout/circuit exception translation and requested cancellation through an offline handler | | `MieleMcpContractTests` | 3 | 24 | Integration | SDK-generated input schemas, nine facade cancellation paths, and boolean command outcomes using the fake provider | | `SystemControllerTests` | 4 | 4 | Integration | `GET /api/system` and Basic authentication behavior | | `UbiquitiApiContractTests` | 4 | 11 | Integration | GET/POST webhooks, optional bodies, validation, request cancellation, and generated Swagger | | `UbiquitiQueryServiceTests` | 7 | 21 | Webhook Parsing | Detection types, thumbnails/timestamps, camera selection, sink failures, and cancellation using in-memory sinks | | `WebApiContractTests` | 4 | 8 | Integration | String-enum JSON output/input, validation Problem Details, and generated Swagger schemas | -| Total | 62 | 207 | | Source-declared, including skipped cases | +| Total | 65 | 215 | | Source-declared, including skipped cases | ## Trait Categories @@ -40,7 +41,7 @@ Backend services, domain behavior, MCP contracts, and agent evaluation live in | `Configuration` | Exercises deterministic configuration parsing without starting the host | | `Device Commands` | Uses synthetic configuration and fake HTTP; never contacts live devices | | `Integration` | Boots the in-process ASP.NET Core host and exercises its services or endpoints | -| `Leadership` | Credential-free DDNS ownership wiring and cancellation | +| `Leadership` | Credential-free DDNS ownership wiring, cancellation and timeout recovery | | `Webhook Parsing` | Deterministic payload parsing and in-memory dispatch, without camera or storage calls | `InitializeConfiguration` owns the standard provider chain and unconditionally adds the required @@ -64,6 +65,8 @@ in-process host tests and must not be assumed to mean live-device access. The Miele fixture replaces the named client's primary handler while retaining its configured resilience pipeline. No appliance is contacted. MCP coverage here verifies SDK-generated schemas and facade calls, not a complete MCP initialization/discovery/invocation protocol exchange. +The separate `MieleClientFailureTests` are self-contained unit cases: their handler throws actual +Polly timeout and circuit-breaker exception types, without starting a host or contacting a provider. ```bash dotnet test --project src/CasCap.App.Server.Tests/CasCap.App.Server.Tests.csproj @@ -99,6 +102,7 @@ CasCap.App.Server.Tests/ │ ├── BuderusQueryServiceTests.cs │ ├── DDnsLeadershipTests.cs │ ├── FeatureConfigTests.cs +│ ├── MieleClientFailureTests.cs │ └── UbiquitiQueryServiceTests.cs ├── GlobalUsings.cs └── xunit.runner.json diff --git a/src/CasCap.App.Server.Tests/Tests/Unit/DDnsLeadershipTests.cs b/src/CasCap.App.Server.Tests/Tests/Unit/DDnsLeadershipTests.cs index 89bd475..e2c1f4f 100644 --- a/src/CasCap.App.Server.Tests/Tests/Unit/DDnsLeadershipTests.cs +++ b/src/CasCap.App.Server.Tests/Tests/Unit/DDnsLeadershipTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using System.Net.Http; +using System.Net; namespace CasCap.Tests.Unit; @@ -50,6 +51,20 @@ await Assert.ThrowsAnyAsync(() => Assert.True(handler.Token.IsCancellationRequested); } + [Fact] + public async Task DiscoveryTimeout_AllowsNextRefresh() + { + // Regression: HttpClient timeout is recoverable when the ownership token is not cancelled. + using var handler = new TimeoutThenSuccessHandler(); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/") }; + var finder = CreateFinder(client); + + Assert.Null(await finder.GetIp(TestContext.Current.CancellationToken)); + Assert.Equal(IPAddress.Parse("192.0.2.1"), await finder.GetIp(TestContext.Current.CancellationToken)); + Assert.Equal(2, handler.Calls); + Assert.False(TestContext.Current.CancellationToken.IsCancellationRequested); + } + private static DDnsUpdateService CreateWorker(HttpClient client) => new(NullLogger.Instance, Options.Create(new AzureAuthConfig { KeyVaultName = "example" }), @@ -101,6 +116,22 @@ private sealed class TestClientFactory(HttpClient client) : IHttpClientFactory public HttpClient CreateClient(string name) => client; } + private sealed class TimeoutThenSuccessHandler : HttpMessageHandler + { + public int Calls { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + return Calls == 1 + ? Task.FromException(new TaskCanceledException("Synthetic HTTP timeout.", new TimeoutException())) + : Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"ip":"192.0.2.1"}""", System.Text.Encoding.UTF8, "application/json") + }); + } + } + private sealed class TestEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = Environments.Development; diff --git a/src/CasCap.App.Server.Tests/Tests/Unit/MieleClientFailureTests.cs b/src/CasCap.App.Server.Tests/Tests/Unit/MieleClientFailureTests.cs new file mode 100644 index 0000000..df8783b --- /dev/null +++ b/src/CasCap.App.Server.Tests/Tests/Unit/MieleClientFailureTests.cs @@ -0,0 +1,73 @@ +using CasCap.Exceptions; +using CasCap.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Polly.CircuitBreaker; +using Polly.Timeout; + +namespace CasCap.Tests.Unit; + +/// Checks domain translation of real Polly exceptions through an offline HTTP handler. +[Trait("Category", "Device Commands")] +public sealed class MieleClientFailureTests +{ + [Theory] + [InlineData("timeout", false)] + [InlineData("timeout", true)] + [InlineData("circuit", false)] + [InlineData("circuit", true)] + [InlineData("isolated-circuit", false)] + [InlineData("isolated-circuit", true)] + public async Task ResilienceFailures_MapToSanitizedDomainErrors(string failure, bool write) + { + // Regression: disabled retries do not disable Polly timeouts or circuit breakers. + using var handler = new MieleHttpHandler(); + handler.Reset(failure: failure switch + { + "timeout" => new TimeoutRejectedException("Synthetic private provider detail."), + "circuit" => new BrokenCircuitException("Synthetic private provider detail."), + _ => new IsolatedCircuitException("Synthetic private provider detail.") + }); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/") }; + var sut = CreateClient(client); + + var exception = await Assert.ThrowsAsync(() => write + ? sut.PutAction("example", new() { PowerOn = true }, TestContext.Current.CancellationToken) + : (Task)sut.GetDevices(cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Null(exception.StatusCode); + Assert.Equal(failure == "timeout" + ? "The Miele API request timed out." + : "The Miele API is temporarily unavailable.", exception.Message); + Assert.Single(handler.Calls); + } + + [Fact] + public async Task RequestedCancellation_IsNotTranslated() + { + using var handler = new MieleHttpHandler(); + handler.Reset(block: true); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/") }; + using var owner = new CancellationTokenSource(); + var running = CreateClient(client).GetDevices(cancellationToken: owner.Token); + await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await owner.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + running.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Assert.True(Assert.Single(handler.Calls).Token.IsCancellationRequested); + } + + private static MieleClientService CreateClient(HttpClient client) + => new(NullLogger.Instance, Options.Create(new MieleConfig + { + OAuthToken = "example", + HealthCheckUri = "https://example.com/", + HealthCheckExpectedHttpStatusCodes = [404], + HealthCheck = KubernetesProbeTypes.None + }), new TestClientFactory(client)); + + private sealed class TestClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } +} From 9a8c99c0a536a8d43fae49a301bfc6c6c4e607bb Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Fri, 23 Oct 2026 16:52:09 +0200 Subject: [PATCH 12/18] test(clients): pass test cancellation to integration calls - Supply the current test token to satisfy xUnit cancellation analysis --- .../BuderusKm200ClientServiceTests.cs | 24 +++++++++---------- .../MieleClientServiceTests.cs | 24 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/CasCap.Api.Buderus.Tests/BuderusKm200ClientServiceTests.cs b/src/CasCap.Api.Buderus.Tests/BuderusKm200ClientServiceTests.cs index 7c747ae..2a5e9c3 100644 --- a/src/CasCap.Api.Buderus.Tests/BuderusKm200ClientServiceTests.cs +++ b/src/CasCap.Api.Buderus.Tests/BuderusKm200ClientServiceTests.cs @@ -16,7 +16,7 @@ public class BuderusKm200ClientServiceTests(ITestOutputHelper output) : TestBase [Fact] public async Task GetDataPoint_DhwSetTemperature_ReturnsValue() { - var dp = await svc.GetDataPoint("/dhwCircuits/dhw1/setTemperature"); + var dp = await svc.GetDataPoint("/dhwCircuits/dhw1/setTemperature", TestContext.Current.CancellationToken); Assert.NotNull(dp); Assert.Equal("/dhwCircuits/dhw1/setTemperature", dp.Id); @@ -29,7 +29,7 @@ public async Task GetDataPoint_DhwSetTemperature_ReturnsValue() [Fact] public async Task GetDataPoint_DhwActualTemp_ReturnsValue() { - var dp = await svc.GetDataPoint("/dhwCircuits/dhw1/actualTemp"); + var dp = await svc.GetDataPoint("/dhwCircuits/dhw1/actualTemp", TestContext.Current.CancellationToken); Assert.NotNull(dp); Assert.Equal(MyDatapointType.floatValue, dp.Type); @@ -40,7 +40,7 @@ public async Task GetDataPoint_DhwActualTemp_ReturnsValue() [Fact] public async Task GetDataPoint_HeatingCircuit1NormalTemp_ReturnsValue() { - var dp = await svc.GetDataPoint("/heatingCircuits/hc1/temperatureLevels/normal"); + var dp = await svc.GetDataPoint("/heatingCircuits/hc1/temperatureLevels/normal", TestContext.Current.CancellationToken); Assert.NotNull(dp); Assert.Equal(MyDatapointType.floatValue, dp.Type); @@ -52,7 +52,7 @@ public async Task GetDataPoint_HeatingCircuit1NormalTemp_ReturnsValue() [Fact] public async Task GetDataPoint_HeatingCircuit2NormalTemp_ReturnsValue() { - var dp = await svc.GetDataPoint("/heatingCircuits/hc2/temperatureLevels/normal"); + var dp = await svc.GetDataPoint("/heatingCircuits/hc2/temperatureLevels/normal", TestContext.Current.CancellationToken); Assert.NotNull(dp); Assert.Equal(MyDatapointType.floatValue, dp.Type); @@ -64,7 +64,7 @@ public async Task GetDataPoint_HeatingCircuit2NormalTemp_ReturnsValue() [Fact] public async Task GetDataPoint_OperationMode_ReturnsStringValue() { - var dp = await svc.GetDataPoint("/dhwCircuits/dhw1/operationMode"); + var dp = await svc.GetDataPoint("/dhwCircuits/dhw1/operationMode", TestContext.Current.CancellationToken); Assert.NotNull(dp); Assert.Equal(MyDatapointType.stringValue, dp.Type); @@ -76,7 +76,7 @@ public async Task GetDataPoint_OperationMode_ReturnsStringValue() [Fact] public async Task GetDataPoint_NonExistentPath_ReturnsNull() { - var dp = await svc.GetDataPoint("/dhwCircuits/dhw99/setTemperature"); + var dp = await svc.GetDataPoint("/dhwCircuits/dhw99/setTemperature", TestContext.Current.CancellationToken); Assert.Null(dp); _output.WriteLine("Non-existent datapoint correctly returned null"); @@ -109,7 +109,7 @@ public async Task SetDataPoint_DhwSetTemperature_Roundtrip() const string datapointId = "/dhwCircuits/dhw1/setTemperature"; // Read current value - var original = await svc.GetDataPoint(datapointId); + var original = await svc.GetDataPoint(datapointId, TestContext.Current.CancellationToken); Assert.NotNull(original); _output.WriteLine($"Original {datapointId} = {original.Value} {original.UnitOfMeasure}"); @@ -118,7 +118,7 @@ public async Task SetDataPoint_DhwSetTemperature_Roundtrip() Assert.True(success, $"SetDataPoint returned false for '{datapointId}'"); // Confirm the value is unchanged - var after = await svc.GetDataPoint(datapointId); + var after = await svc.GetDataPoint(datapointId, TestContext.Current.CancellationToken); Assert.NotNull(after); Assert.Equal(original.Value?.ToString(), after.Value?.ToString()); @@ -130,7 +130,7 @@ public async Task SetDataPoint_HeatingCircuit2NormalTemp_Roundtrip() { const string datapointId = "/heatingCircuits/hc2/temperatureLevels/normal"; - var original = await svc.GetDataPoint(datapointId); + var original = await svc.GetDataPoint(datapointId, TestContext.Current.CancellationToken); Assert.NotNull(original); Assert.Equal(1, original.Writeable); _output.WriteLine($"Original {datapointId} = {original.Value} {original.UnitOfMeasure}"); @@ -138,7 +138,7 @@ public async Task SetDataPoint_HeatingCircuit2NormalTemp_Roundtrip() var success = await svc.SetDataPoint(datapointId, original.Value!, TestContext.Current.CancellationToken); Assert.True(success, $"SetDataPoint returned false for '{datapointId}'"); - var after = await svc.GetDataPoint(datapointId); + var after = await svc.GetDataPoint(datapointId, TestContext.Current.CancellationToken); Assert.NotNull(after); Assert.Equal(original.Value?.ToString(), after.Value?.ToString()); @@ -150,7 +150,7 @@ public async Task SetDataPoint_DhwOperationMode_Roundtrip() { const string datapointId = "/dhwCircuits/dhw1/operationMode"; - var original = await svc.GetDataPoint(datapointId); + var original = await svc.GetDataPoint(datapointId, TestContext.Current.CancellationToken); Assert.NotNull(original); Assert.Equal(MyDatapointType.stringValue, original.Type); Assert.Equal(1, original.Writeable); @@ -160,7 +160,7 @@ public async Task SetDataPoint_DhwOperationMode_Roundtrip() var success = await svc.SetDataPoint(datapointId, original.Value!, TestContext.Current.CancellationToken); Assert.True(success, $"SetDataPoint returned false for '{datapointId}'"); - var after = await svc.GetDataPoint(datapointId); + var after = await svc.GetDataPoint(datapointId, TestContext.Current.CancellationToken); Assert.NotNull(after); Assert.Equal(original.Value?.ToString(), after.Value?.ToString()); diff --git a/src/CasCap.Api.Miele.Tests/MieleClientServiceTests.cs b/src/CasCap.Api.Miele.Tests/MieleClientServiceTests.cs index 7bc23a9..9d695bb 100644 --- a/src/CasCap.Api.Miele.Tests/MieleClientServiceTests.cs +++ b/src/CasCap.Api.Miele.Tests/MieleClientServiceTests.cs @@ -43,7 +43,7 @@ private MieleClientService CreateService() public async Task GetDevices_ReturnsAllDevices() { var svc = CreateService(); - var result = await svc.GetDevices(); + var result = await svc.GetDevices(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotEmpty(result); @@ -57,7 +57,7 @@ public async Task GetDevices_ReturnsAllDevices() public async Task GetShortDevices_ReturnsSummary() { var svc = CreateService(); - var result = await svc.GetShortDevices(); + var result = await svc.GetShortDevices(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotEmpty(result); @@ -71,12 +71,12 @@ public async Task GetShortDevices_ReturnsSummary() public async Task GetDevice_ReturnsSingleDevice() { var svc = CreateService(); - var devices = await svc.GetDevices(); + var devices = await svc.GetDevices(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(devices); Assert.NotEmpty(devices); var deviceId = devices.Keys.First(); - var result = await svc.GetDevice(deviceId); + var result = await svc.GetDevice(deviceId, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result.ident); Assert.NotNull(result.state); @@ -88,11 +88,11 @@ public async Task GetDevice_ReturnsSingleDevice() public async Task GetIdent_ReturnsDeviceIdentification() { var svc = CreateService(); - var devices = await svc.GetDevices(); + var devices = await svc.GetDevices(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(devices); var deviceId = devices.Keys.First(); - var result = await svc.GetIdent(deviceId); + var result = await svc.GetIdent(deviceId, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result.type); @@ -103,11 +103,11 @@ public async Task GetIdent_ReturnsDeviceIdentification() public async Task GetState_ReturnsDeviceState() { var svc = CreateService(); - var devices = await svc.GetDevices(); + var devices = await svc.GetDevices(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(devices); var deviceId = devices.Keys.First(); - var result = await svc.GetState(deviceId); + var result = await svc.GetState(deviceId, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result.status); @@ -122,11 +122,11 @@ public async Task GetState_ReturnsDeviceState() public async Task GetActions_ReturnsAvailableActions() { var svc = CreateService(); - var devices = await svc.GetDevices(); + var devices = await svc.GetDevices(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(devices); var deviceId = devices.Keys.First(); - var result = await svc.GetActions(deviceId); + var result = await svc.GetActions(deviceId, TestContext.Current.CancellationToken); Assert.NotNull(result); _output.WriteLine($"Device {deviceId}: PowerOn={result.powerOn}, PowerOff={result.powerOff}, ProcessActions={result.processAction?.Length ?? 0}"); @@ -140,11 +140,11 @@ public async Task GetActions_ReturnsAvailableActions() public async Task GetPrograms_ReturnsAvailablePrograms() { var svc = CreateService(); - var devices = await svc.GetDevices(); + var devices = await svc.GetDevices(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(devices); var deviceId = devices.Keys.First(); - var result = await svc.GetPrograms(deviceId); + var result = await svc.GetPrograms(deviceId, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); foreach (var program in result) From ba78c11c2fb4c93cf86a839d609abd8a5f8a28af Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Fri, 23 Oct 2026 17:52:08 +0200 Subject: [PATCH 13/18] fix(wiz): initialize Azure persistence before discovery - Create both tables asynchronously with the worker cancellation token - Document provisioning permissions and fail-fast initialization --- src/CasCap.Api.Wiz.Sinks/README.md | 4 +++- .../Services/Sinks/WizSinkAzureTablesService.cs | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/CasCap.Api.Wiz.Sinks/README.md b/src/CasCap.Api.Wiz.Sinks/README.md index b2ed345..886d427 100644 --- a/src/CasCap.Api.Wiz.Sinks/README.md +++ b/src/CasCap.Api.Wiz.Sinks/README.md @@ -46,7 +46,9 @@ state changes. Discovery ignores MAC/RSSI-only changes when deciding whether to ``` To add Azure persistence, enable `AzureTables`, set `WizConfig.AzureTableStorageConnectionString` -and configure authentication. +and configure authentication. Before discovery begins, the sink asynchronously creates its line-item +and snapshot tables if missing. The configured identity must have permission to create and write +those tables; initialization failures propagate instead of allowing discovery to run without persistence. ## Dependencies diff --git a/src/CasCap.Api.Wiz.Sinks/Services/Sinks/WizSinkAzureTablesService.cs b/src/CasCap.Api.Wiz.Sinks/Services/Sinks/WizSinkAzureTablesService.cs index 55ae78d..78bc6e6 100644 --- a/src/CasCap.Api.Wiz.Sinks/Services/Sinks/WizSinkAzureTablesService.cs +++ b/src/CasCap.Api.Wiz.Sinks/Services/Sinks/WizSinkAzureTablesService.cs @@ -28,6 +28,12 @@ public WizSinkAzureTablesService(ILogger logger, _snapshotTableClient = StorageExtensions.CreateTableClient(connectionString, snapshotTableName, azureAuthConfig.Value.TokenCredential); } + /// + public Task InitializeAsync(CancellationToken cancellationToken) => + Task.WhenAll( + _lineItemTableClient.CreateIfNotExistsAsync(cancellationToken), + _snapshotTableClient.CreateIfNotExistsAsync(cancellationToken)); + /// public async Task WriteEvent(WizEvent @event, CancellationToken cancellationToken = default) { From ece80b1ac840606cc2346948946f42b301b9e29f Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Sat, 24 Oct 2026 17:11:15 +0200 Subject: [PATCH 14/18] fix(deps): consume verified releases and remove vulnerable dependency paths - Align shared, runtime, Azure and communications packages - Require patched JSON and XML dependencies in their owning libraries - Remove unused health UI packages and restore audit enforcement --- Directory.Build.props | 5 +- Directory.Packages.props | 46 ++++++++++--------- .../CasCap.Api.Buderus.csproj | 1 + src/CasCap.Api.Knx/CasCap.Api.Knx.csproj | 1 + .../CasCap.App.Server.csproj | 2 - 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 8262b2a..305d78d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -52,10 +52,7 @@ - - - - CS1574;NETSDK1233;NU1901;NU1902;NU1903 + CS1574;NETSDK1233 diff --git a/Directory.Packages.props b/Directory.Packages.props index 94f856f..b1e8154 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,32 +8,30 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + @@ -57,6 +55,8 @@ + + @@ -67,7 +67,7 @@ - + @@ -76,6 +76,8 @@ + + diff --git a/src/CasCap.Api.Buderus/CasCap.Api.Buderus.csproj b/src/CasCap.Api.Buderus/CasCap.Api.Buderus.csproj index db453a0..579ec24 100644 --- a/src/CasCap.Api.Buderus/CasCap.Api.Buderus.csproj +++ b/src/CasCap.Api.Buderus/CasCap.Api.Buderus.csproj @@ -19,6 +19,7 @@ + diff --git a/src/CasCap.Api.Knx/CasCap.Api.Knx.csproj b/src/CasCap.Api.Knx/CasCap.Api.Knx.csproj index 1a0edd7..8253e28 100644 --- a/src/CasCap.Api.Knx/CasCap.Api.Knx.csproj +++ b/src/CasCap.Api.Knx/CasCap.Api.Knx.csproj @@ -28,6 +28,7 @@ + diff --git a/src/CasCap.App.Server/CasCap.App.Server.csproj b/src/CasCap.App.Server/CasCap.App.Server.csproj index d97dc2f..5f49fa5 100644 --- a/src/CasCap.App.Server/CasCap.App.Server.csproj +++ b/src/CasCap.App.Server/CasCap.App.Server.csproj @@ -10,9 +10,7 @@ - - From 5ea5c1f732e76cffad9dc855e829ecd20d8fa37a Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Sat, 24 Oct 2026 17:11:47 +0200 Subject: [PATCH 15/18] docs(release): document supported Release builds and 1.1 migration - Replace obsolete package and test limitations with current prerequisites - Explain the incompatible Buderus write contract and HTTP migration +semver: feature --- README.md | 17 ++++++++++++----- src/CasCap.Api.Buderus/README.md | 7 +++++++ src/CasCap.Api.DDns/README.md | 5 ++--- src/CasCap.Api.Knx/README.md | 5 ++--- src/CasCap.Backend.Tests/README.md | 7 ------- src/CasCap.Backend/README.md | 10 ++++------ 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index eaabb82..88afba0 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,9 @@ The standalone device libraries can also be used without running SmartHaus. With Docker and Compose installed: -Current source limitation: the backend's Agent Runtime contracts and communications integration -have Debug project references without corresponding Release package references. A clean Release -build, including the source-built demo below, may therefore fail before startup. The Debug -source-dependency path in [Development](#development) is the available alternative; this README -does not report an executed demo. +The source-built demo uses the Release graph, resolving shared libraries, Agent Runtime contracts +and communications packages from NuGet. Debug source builds instead use the adjacent checkouts +listed in [Development](#development). This README does not report an executed demo. ```bash git clone https://github.com/f2calv/SmartHaus.git @@ -71,6 +69,15 @@ its own device, cloud authentication and configuration requirements. | Helm charts | [Application](charts/smarthaus/README.md), [Grafana dashboards](charts/smarthaus-dashboards/README.md) | Configurable workload aliases and dashboard provisioning | `oci://ghcr.io/f2calv/charts/smarthaus` | | Tests | [Buderus](src/CasCap.Api.Buderus.Tests/README.md), [DoorBird](src/CasCap.Api.DoorBird.Tests/README.md), [Fronius](src/CasCap.Api.Fronius.Tests/README.md), [KNX](src/CasCap.Api.Knx.Tests/README.md), [Miele](src/CasCap.Api.Miele.Tests/README.md), [host](src/CasCap.App.Server.Tests/README.md), [backend](src/CasCap.Backend.Tests/README.md) | Source-declared inventories and prerequisites | Not published | +## Compatibility + +The 1.1 release intentionally includes incompatible contracts despite its minor version. +Rebuild package consumers and migrate the +[Buderus query-service write result](src/CasCap.Api.Buderus/README.md#migrating-to-11). +HTTP callers must also check the current typed device responses, string-enum JSON and +[validated Miele command bodies](src/CasCap.Api.Miele/README.md#commands-and-failures); +unrecognized Miele fields are rejected rather than forwarded. + ## Use a Standalone Library For a host application with configuration and logging, install a device client: diff --git a/src/CasCap.Api.Buderus/README.md b/src/CasCap.Api.Buderus/README.md index 1ecb1b1..23fc144 100644 --- a/src/CasCap.Api.Buderus/README.md +++ b/src/CasCap.Api.Buderus/README.md @@ -50,6 +50,13 @@ The query service validates and normalizes the path; the controller maps the res The MCP write tool continues to return a boolean. Reads, write preflight reads and recursive discovery accept cancellation; cancellation propagates instead of returning partial discovery results. +### Migrating to 1.1 + +`IBuderusQueryService.SetDataPoint` now returns `Task` instead of +`Task`. Rebuild callers and interface implementations, inspect the returned `Succeeded` +property, and use `Error` for a rejected write. The lower-level client still returns a boolean. +This is source and binary incompatible with the previous boolean-returning query-service contract. + ### Sinks | Sink | Description | diff --git a/src/CasCap.Api.DDns/README.md b/src/CasCap.Api.DDns/README.md index 3d42076..d07bf37 100644 --- a/src/CasCap.Api.DDns/README.md +++ b/src/CasCap.Api.DDns/README.md @@ -13,9 +13,8 @@ Custom hosts register it with `services.AddDDns(configuration)` from `Microsoft.Extensions.DependencyInjection`, and start its [`IBgFeature`](https://github.com/f2calv/CasCap.Common/blob/main/src/CasCap.Common.Abstractions/Abstractions/IBgFeature.cs) worker through the shared feature runner. -The current leadership API is provisional: build in Debug against the sibling shared-library -checkout. Its new runner is not yet available in the published caching package, so Release builds -must wait for the coordinated package release rather than substituting an invented package version. +The leadership runner is available in `CasCap.Common.Caching` 4.17.0. Release builds resolve it +from NuGet; Debug builds use the adjacent shared-library source checkout. ## Services diff --git a/src/CasCap.Api.Knx/README.md b/src/CasCap.Api.Knx/README.md index ffa74f2..60da096 100644 --- a/src/CasCap.Api.Knx/README.md +++ b/src/CasCap.Api.Knx/README.md @@ -204,9 +204,8 @@ The `ShardingMode` property on `KnxConfig` controls how `KnxMonitorBgService` po 4. **Leader runs** — each ownership run gets fresh connection, callback-buffer and reconnect state. Health monitoring, reconnection, incoming publication, outbound delivery and enabled day/night scheduling are supervised together. A fatal child failure withdraws readiness and requests host shutdown immediately, before cancelling/joining siblings and disposing connections. Ownership-only cancellation does not stop the host. Initial connection failures and subsequent drops are retried for both tunneling and routing. 5. **Ownership ends** — the runner observes lease loss at `Redlock.LeadershipCheckIntervalMs` (default 1000 ms) and cancels the ownership token. Health is withdrawn, callbacks stop accepting telegrams, and all publication/write work is awaited before handlers are detached and buses asynchronously disposed. Cleanup is also applied to partial startup failures. Only then may this replica start a fresh ownership run. Host shutdown owns the forced-termination budget; cleanup has no independent timeout. -This checkout uses the provisional .NET 8+ runner from sibling Debug project references. Release -packages do not yet contain that API; validate with `SmartHaus.Debug.slnx` until the shared package -release is available. +The .NET 8+ leadership runner is published in `CasCap.Common.Caching` 4.17.0. Release builds +resolve it from NuGet; Debug builds use the adjacent shared-library source checkout. Lease cancellation cannot retract a telegram already accepted by Redis or a device. Callback buffers are in-memory and are not replayed across ownership runs. Outgoing acknowledgement and diff --git a/src/CasCap.Backend.Tests/README.md b/src/CasCap.Backend.Tests/README.md index f2955a7..806a433 100644 --- a/src/CasCap.Backend.Tests/README.md +++ b/src/CasCap.Backend.Tests/README.md @@ -41,13 +41,6 @@ Host endpoint and application-bootstrap coverage lives in There are no skipped tests. -### Source limitation - -The KNX entry in `LiteSinkRegistrationTests` expects Redis inclusion and Azure Tables exclusion. -`AddKnxWithExtraSinks` currently filters Redis instead, unlike the other integrations. The inventory -records the declared assertion, not a passing result; this source/test mismatch is not fixed by a -documentation update. - ## Agent Evaluation Agent definitions are evaluated in agentizr against scenarios supplied with the definition, before diff --git a/src/CasCap.Backend/README.md b/src/CasCap.Backend/README.md index bba9308..5341578 100644 --- a/src/CasCap.Backend/README.md +++ b/src/CasCap.Backend/README.md @@ -446,8 +446,8 @@ Real camera identifiers belong only in private configuration. | `CasCap.Api.Azure.CognitiveServices` | Azure AI Speech adapter | | `CasCap.Api.Azure.Storage` | Azure Blob Storage integration | -Debug builds use adjacent project references for these repositories. Release builds use published -packages where the project file defines a Release package reference. +Debug builds use adjacent project references for these repositories. Release builds resolve the +same dependencies from NuGet, including Agent Runtime contracts and communications packages. ### Feature Projects @@ -474,10 +474,8 @@ Build from the repository's Debug solution so adjacent source dependencies are u dotnet build SmartHaus.Debug.slnx --configuration Debug ``` -The current backend project defines Agent Runtime contracts and `CasCap.Comms.AI` references only -for Debug. Release lacks equivalent package references, so a clean Release/source-container build -is not established by these instructions. No build or integration execution was performed for -this documentation update. +Use `SmartHaus.Release.slnx --configuration Release` to validate the published-package graph +without adjacent source checkouts. The Dockerfile also builds this Release graph. The backend tests live in [CasCap.Backend.Tests](../CasCap.Backend.Tests/README.md). Generic Agent Runtime protocol, provider and agent-evaluation coverage belongs to agentizr; SmartHaus tests cover its domain tools, From 137a7471bc010a02225ddfc38df1f87bf1e563d9 Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Sat, 24 Oct 2026 17:11:47 +0200 Subject: [PATCH 16/18] build(deps): update the collector to its stable multi-platform image - Fold the collector update without selecting the 386-only tag --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index fbfbde8..2800a14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,7 @@ services: redis: condition: service_healthy otelcol: - image: otel/opentelemetry-collector:0.162.0 + image: otel/opentelemetry-collector:0.163.1 ports: - 4317:4317 - 4318:4318 From 987a0630cc9856b0563822dd28867016afc2d54f Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Sat, 24 Oct 2026 17:23:03 +0200 Subject: [PATCH 17/18] refactor(workers): simplify leadership worker operations - Separate DNS subscription lookup and KNX telegram dispatch - Isolate sunrise overrides and successful Miele response assertions - Preserve cancellation and provider contracts while resolving high analysis findings --- .../Services/DDnsUpdateService.cs | 52 +++++++++---------- .../Services/KnxAutomationBgService.cs | 40 ++++++-------- .../Services/KnxSenderBgService.cs | 50 ++++++++++-------- .../Integration/MieleApiContractTests.cs | 27 +++++----- .../Tests/Unit/MieleClientFailureTests.cs | 6 +-- 5 files changed, 87 insertions(+), 88 deletions(-) diff --git a/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs b/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs index cc89b9b..024a460 100644 --- a/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs +++ b/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs @@ -42,7 +42,7 @@ public async Task RunAsync(CancellationToken cancellationToken) private async Task UpdateListDnsZoneResources(IPAddress newIp, CancellationToken cancellationToken) { - await SetSubscription(); + await SetSubscriptionAsync(cancellationToken); if (subscription is null) return; @@ -97,38 +97,38 @@ private async Task UpdateListDnsZoneResources(IPAddress newIp, CancellationToken //now we have completed all A records update the static oldIp = priorIp; + } - async Task SetSubscription() + private async Task SetSubscriptionAsync(CancellationToken cancellationToken) + { + logger.LogDebug("{ClassName} querying Azure for correct subscription...", + nameof(DDnsBgService)); + var subscriptions = new ArmClient(azureAuthConfig.Value.TokenCredential).GetSubscriptions(); + await foreach (var sub in subscriptions.GetAllAsync(cancellationToken: cancellationToken)) { - logger.LogDebug("{ClassName} querying Azure for correct subscription...", - nameof(DDnsBgService)); - var subscriptions = new ArmClient(azureAuthConfig.Value.TokenCredential).GetSubscriptions(); - await foreach (var sub in subscriptions.GetAllAsync(cancellationToken: cancellationToken)) + try { - try - { - var rg = await sub.GetResourceGroupAsync(dDnsConfig.Value.DnsResourceGroupName, cancellationToken); - if (rg.GetRawResponse().Status == 200) - { - subscription = sub; - logger.LogInformation("{ClassName} selected Azure subscription {DisplayName} ({SubscriptionId})", - nameof(DDnsBgService), sub.Data.DisplayName, sub.Data.SubscriptionId); - break; - } - } - catch (RequestFailedException ex) when (ex.Status is StatusCodes.Status403Forbidden - or StatusCodes.Status404NotFound) + var rg = await sub.GetResourceGroupAsync(dDnsConfig.Value.DnsResourceGroupName, cancellationToken); + if (rg.GetRawResponse().Status == 200) { - logger.LogWarning("{ClassName} cannot access Resource Group {DnsResourceGroupName} in Azure subscription {SubscriptionId}, StatusCode={StatusCode}", - nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName, - sub.Data.SubscriptionId, ex.Status); + subscription = sub; + logger.LogInformation("{ClassName} selected Azure subscription {DisplayName} ({SubscriptionId})", + nameof(DDnsBgService), sub.Data.DisplayName, sub.Data.SubscriptionId); + break; } } - - if (subscription is null) - logger.LogError("{ClassName} unable to find subscription containing Resource Group {DnsResourceGroupName}", - nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName); + catch (RequestFailedException ex) when (ex.Status is StatusCodes.Status403Forbidden + or StatusCodes.Status404NotFound) + { + logger.LogWarning("{ClassName} cannot access Resource Group {DnsResourceGroupName} in Azure subscription {SubscriptionId}, StatusCode={StatusCode}", + nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName, + sub.Data.SubscriptionId, ex.Status); + } } + + if (subscription is null) + logger.LogError("{ClassName} unable to find subscription containing Resource Group {DnsResourceGroupName}", + nameof(DDnsBgService), dDnsConfig.Value.DnsResourceGroupName); } private async Task GetDnsARecordCollection(CancellationToken cancellationToken) diff --git a/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs b/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs index 23ae900..c5a324b 100644 --- a/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs +++ b/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs @@ -150,27 +150,7 @@ async Task UpdateDayNightAsync() //send default day night await SendDayNightUpdateAsync(kga).ConfigureAwait(false); - //lets now override sunrise - { - var sunriseOverride = sunrise; - var overrideActive = false; - if (knxConfig.Value.DayNightSunriseHourOverrideWeekend > 0 && IsWeekend() - && sunrise.Hour < knxConfig.Value.DayNightSunriseHourOverrideWeekend) - { - sunriseOverride = new DateTime(sunrise.Year, sunrise.Month, sunrise.Day, knxConfig.Value.DayNightSunriseHourOverrideWeekend, 0, 0, DateTimeKind.Utc); - overrideActive = true; - } - else if (knxConfig.Value.DayNightSunriseHourOverrideWeekday > 0 && IsWeekday() - && sunrise.Hour < knxConfig.Value.DayNightSunriseHourOverrideWeekday) - { - sunriseOverride = new DateTime(sunrise.Year, sunrise.Month, sunrise.Day, knxConfig.Value.DayNightSunriseHourOverrideWeekday, 0, 0, DateTimeKind.Utc); - overrideActive = true; - } - if (overrideActive) - logger.LogInformation("{ClassName} sunrise {Sunrise} is too early! Overridden with {SunriseOverride}", - nameof(KnxAutomationBgService), sunrise, sunriseOverride); - sunrise = sunriseOverride; - } + sunrise = GetSunriseOverride(sunrise, localTime); //TESTING: unique DayNight GA per sleeping room which can be overridden and not wake up people too early //TODO: create a collection of destination group address here? var kgaOGEltern = knxGroupAddressLookupSvc.GetKGAByName("SYS-[Night_Day]-Bedroom"); @@ -209,9 +189,21 @@ await ProcessItemAsync(new KnxStateChangeItem bool IsDay() => localTime > sunrise && localTime < sunset; - bool IsWeekend() => new[] { DayOfWeek.Saturday, DayOfWeek.Sunday }.Contains(localTime.DayOfWeek); - - bool IsWeekday() => !IsWeekend(); } } + + private DateTime GetSunriseOverride(DateTime sunrise, DateTime localTime) + { + var isWeekend = localTime.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; + var overrideHour = isWeekend + ? knxConfig.Value.DayNightSunriseHourOverrideWeekend + : knxConfig.Value.DayNightSunriseHourOverrideWeekday; + if (overrideHour <= 0 || sunrise.Hour >= overrideHour) + return sunrise; + + var sunriseOverride = new DateTime(sunrise.Year, sunrise.Month, sunrise.Day, overrideHour, 0, 0, DateTimeKind.Utc); + logger.LogInformation("{ClassName} sunrise {Sunrise} is too early! Overridden with {SunriseOverride}", + nameof(KnxAutomationBgService), sunrise, sunriseOverride); + return sunriseOverride; + } } diff --git a/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs b/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs index e0a5f96..c0b546a 100644 --- a/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs +++ b/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs @@ -20,34 +20,38 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) await foreach (var msg in outgoingBroker.SubscribeAsync(cancellationToken)) { await WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + await SendTelegramAsync(msg, cancellationToken).ConfigureAwait(false); + } + } - var busAddresses = new List(KnxStatics.BusConnections.Count); - foreach (var busConnection in KnxStatics.BusConnections) + private async Task SendTelegramAsync(KnxOutgoingTelegram msg, CancellationToken cancellationToken) + { + var busAddresses = new List(KnxStatics.BusConnections.Count); + foreach (var busConnection in KnxStatics.BusConnections) + { + cancellationToken.ThrowIfCancellationRequested(); + var bus = busConnection.Value; + if (bus is null || bus.ConnectionState != BusConnectionState.Connected) { - cancellationToken.ThrowIfCancellationRequested(); - var bus = busConnection.Value; - if (bus is null || bus.ConnectionState != BusConnectionState.Connected) - { - LogBusConnectionBroken(logger, nameof(KnxSenderBgService), msg.Kga.Name, busConnection.Key.ToString()); - continue; - } - if (config.Value.BusSenderLoggingEnabled - && (string.IsNullOrEmpty(config.Value.BusLoggingGroupAddressFilter) - || msg.Kga.Name.Contains(config.Value.BusLoggingGroupAddressFilter, StringComparison.OrdinalIgnoreCase))) - busAddresses.Add(bus.InterfaceConfiguration.IndividualAddress); - - var groupAddress = new GroupAddress(msg.Kga.GroupAddress); - var dptBase = msg.Kga.GetDptBase(); - var groupValue = dptBase.SizeInBit < 8 && msg.GroupValueData.Length == 1 - ? new GroupValue(msg.GroupValueData[0], (int)dptBase.SizeInBit) - : new GroupValue(msg.GroupValueData); - await bus.WriteGroupValueAsync(groupAddress, groupValue, cancellationToken: cancellationToken).ConfigureAwait(false); + LogBusConnectionBroken(logger, nameof(KnxSenderBgService), msg.Kga.Name, busConnection.Key.ToString()); + continue; } + if (config.Value.BusSenderLoggingEnabled + && (string.IsNullOrEmpty(config.Value.BusLoggingGroupAddressFilter) + || msg.Kga.Name.Contains(config.Value.BusLoggingGroupAddressFilter, StringComparison.OrdinalIgnoreCase))) + busAddresses.Add(bus.InterfaceConfiguration.IndividualAddress); - // Only log if we sent to at least one bus, otherwise the error logs above should be sufficient - if (busAddresses.Count > 0) - LogTelegramSent(logger, nameof(KnxSenderBgService), busAddresses.Count, msg.Kga.Name); + var groupAddress = new GroupAddress(msg.Kga.GroupAddress); + var dptBase = msg.Kga.GetDptBase(); + var groupValue = dptBase.SizeInBit < 8 && msg.GroupValueData.Length == 1 + ? new GroupValue(msg.GroupValueData[0], (int)dptBase.SizeInBit) + : new GroupValue(msg.GroupValueData); + await bus.WriteGroupValueAsync(groupAddress, groupValue, cancellationToken: cancellationToken).ConfigureAwait(false); } + + // Only log if we sent to at least one bus, otherwise the error logs above should be sufficient + if (busAddresses.Count > 0) + LogTelegramSent(logger, nameof(KnxSenderBgService), busAddresses.Count, msg.Kga.Name); } private async Task WaitForConnectionAsync(CancellationToken cancellationToken) diff --git a/src/CasCap.App.Server.Tests/Tests/Integration/MieleApiContractTests.cs b/src/CasCap.App.Server.Tests/Tests/Integration/MieleApiContractTests.cs index a40dbfd..11e4b5f 100644 --- a/src/CasCap.App.Server.Tests/Tests/Integration/MieleApiContractTests.cs +++ b/src/CasCap.App.Server.Tests/Tests/Integration/MieleApiContractTests.cs @@ -32,23 +32,26 @@ public async Task Reads_PreserveSuccessfulJsonAndDistinguishMissingAndFailed(str Assert.Single(factory.Provider.Calls); Assert.True(factory.Provider.ResponseDisposed); if (status == HttpStatusCode.OK) - { - using var expected = JsonDocument.Parse(json); - using var actual = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.Equal(expected.RootElement.ValueKind, actual.RootElement.ValueKind); - if (expected.RootElement.ValueKind == JsonValueKind.Array) - Assert.Empty(actual.RootElement.EnumerateArray()); - else if (route == "devices") - Assert.Empty(actual.RootElement.EnumerateObject()); - else - foreach (var property in expected.RootElement.EnumerateObject()) - Assert.True(JsonElement.DeepEquals(property.Value, actual.RootElement.GetProperty(property.Name)), property.Name); - } + await AssertSuccessfulJson(response, route, json); if (status == HttpStatusCode.ServiceUnavailable) await AssertGatewayProblem(response); } } + private static async Task AssertSuccessfulJson(HttpResponseMessage response, string route, string json) + { + using var expected = JsonDocument.Parse(json); + using var actual = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.Equal(expected.RootElement.ValueKind, actual.RootElement.ValueKind); + if (expected.RootElement.ValueKind == JsonValueKind.Array) + Assert.Empty(actual.RootElement.EnumerateArray()); + else if (route == "devices") + Assert.Empty(actual.RootElement.EnumerateObject()); + else + foreach (var property in expected.RootElement.EnumerateObject()) + Assert.True(JsonElement.DeepEquals(property.Value, actual.RootElement.GetProperty(property.Name)), property.Name); + } + [Theory] [InlineData("actions", "{\"powerOn\":false}")] [InlineData("programs", "{\"programId\":24,\"duration\":[1,15],\"temperature\":220}")] diff --git a/src/CasCap.App.Server.Tests/Tests/Unit/MieleClientFailureTests.cs b/src/CasCap.App.Server.Tests/Tests/Unit/MieleClientFailureTests.cs index df8783b..5a30ecd 100644 --- a/src/CasCap.App.Server.Tests/Tests/Unit/MieleClientFailureTests.cs +++ b/src/CasCap.App.Server.Tests/Tests/Unit/MieleClientFailureTests.cs @@ -28,7 +28,7 @@ public async Task ResilienceFailures_MapToSanitizedDomainErrors(string failure, _ => new IsolatedCircuitException("Synthetic private provider detail.") }); using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/") }; - var sut = CreateClient(client); + var sut = CreateMieleClient(client); var exception = await Assert.ThrowsAsync(() => write ? sut.PutAction("example", new() { PowerOn = true }, TestContext.Current.CancellationToken) @@ -48,7 +48,7 @@ public async Task RequestedCancellation_IsNotTranslated() handler.Reset(block: true); using var client = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/") }; using var owner = new CancellationTokenSource(); - var running = CreateClient(client).GetDevices(cancellationToken: owner.Token); + var running = CreateMieleClient(client).GetDevices(cancellationToken: owner.Token); await handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); await owner.CancelAsync(); @@ -57,7 +57,7 @@ await Assert.ThrowsAnyAsync(() => Assert.True(Assert.Single(handler.Calls).Token.IsCancellationRequested); } - private static MieleClientService CreateClient(HttpClient client) + private static MieleClientService CreateMieleClient(HttpClient client) => new(NullLogger.Instance, Options.Create(new MieleConfig { OAuthToken = "example", From 50c97b7ab6e416cc861e2dea24d145b1202147dd Mon Sep 17 00:00:00 2001 From: Alex Vincent Date: Sat, 24 Oct 2026 17:54:54 +0200 Subject: [PATCH 18/18] refactor(ddns): skip irrelevant records before address processing - Preserve metadata selection and cancellation while reducing nested control flow --- .../Services/DDnsUpdateService.cs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs b/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs index 024a460..e2300ed 100644 --- a/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs +++ b/src/CasCap.Api.DDns/Services/DDnsUpdateService.cs @@ -55,20 +55,20 @@ private async Task UpdateListDnsZoneResources(IPAddress newIp, CancellationToken await foreach (var aRecord in aRecords.GetAllAsync(cancellationToken: cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); - var doUpdate = false; var data = aRecord.Data; - if (data.Metadata is not null - && data.Metadata.Any(p => p.Key.Equals(dDnsConfig.Value.DnsMetaDataKey, StringComparison.OrdinalIgnoreCase))) + if (data.Metadata is null + || !data.Metadata.Any(p => p.Key.Equals(dDnsConfig.Value.DnsMetaDataKey, StringComparison.OrdinalIgnoreCase))) + continue; + + var doUpdate = false; + //iterate over the IPs within the A record - as you can have multiple! + foreach (var record in data.DnsARecords) { - //iterate over the IPs within the A record - as you can have multiple! - foreach (var record in data.DnsARecords) + priorIp = record.IPv4Address; + if (!priorIp.Equals(newIp)) { - priorIp = record.IPv4Address; - if (!priorIp.Equals(newIp)) - { - record.IPv4Address = newIp; - doUpdate = true; - } + record.IPv4Address = newIp; + doUpdate = true; } }