diff --git a/.github/skills/sync-appsettings-to-configmap/SKILL.md b/.github/skills/sync-appsettings-to-configmap/SKILL.md
index c0c6254d..e0517d45 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
diff --git a/Directory.Build.props b/Directory.Build.props
index 8262b2aa..305d78d2 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 94f856f7..b1e81542 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/README.md b/README.md
index eaabb82e..88afba02 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/build.ps1 b/build.ps1
index 8276744d..4ba83dce 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
diff --git a/docker-compose.yml b/docker-compose.yml
index fbfbde8f..2800a144 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
diff --git a/requests/README.md b/requests/README.md
index 972ee44a..88b22c8a 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 b66f3634..efaae268 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 07dd3fcf..0a95579e 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 1255f76e..cc763601 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 02052b45..2c526655 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 25e7bb28..47a140c7 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 c60cc57c..88495ac8 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}}
@@ -33,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
@@ -47,11 +49,13 @@ 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
{
- "programId": 1
+ "programId": 24,
+ "duration": [1, 15],
+ "temperature": 220
}
diff --git a/requests/shelly-client.http b/requests/shelly-client.http
index 56cab9e6..dab163b9 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 56573d4f..6bf06642 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 d6e08b84..a37c82a2 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 bea02f76..3b45c654 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 036b22e3..0b6d60fc 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 3e1f0d21..b59c782d 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.Buderus.Tests/BuderusKm200ClientServiceTests.cs b/src/CasCap.Api.Buderus.Tests/BuderusKm200ClientServiceTests.cs
index 7c747ae2..2a5e9c31 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.Buderus/Abstractions/IBuderusQueryService.cs b/src/CasCap.Api.Buderus/Abstractions/IBuderusQueryService.cs
index e71e5f4c..770c0f54 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/CasCap.Api.Buderus.csproj b/src/CasCap.Api.Buderus/CasCap.Api.Buderus.csproj
index db453a08..579ec24a 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.Buderus/Controllers/BuderusController.cs b/src/CasCap.Api.Buderus/Controllers/BuderusController.cs
index 30eed1d3..4bda2ad5 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,23 +16,20 @@ 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}")]
+ [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 00000000..5b909faf
--- /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 38c9192b..23fc1444 100644
--- a/src/CasCap.Api.Buderus/README.md
+++ b/src/CasCap.Api.Buderus/README.md
@@ -43,6 +43,20 @@ 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.
+
+### 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.Buderus/Services/BuderusKm200ClientService.cs b/src/CasCap.Api.Buderus/Services/BuderusKm200ClientService.cs
index c858fd4c..eb815d46 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 8c919077..c424ea6e 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.DDns/Controllers/DDnsController.cs b/src/CasCap.Api.DDns/Controllers/DDnsController.cs
index 81c22628..9f180b49 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.DDns/Extensions/ServiceCollectionExtensions.cs b/src/CasCap.Api.DDns/Extensions/ServiceCollectionExtensions.cs
index aa670158..41d2e91b 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 5c5d1226..c74c80e2 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 fa53115e..d07bf37e 100644
--- a/src/CasCap.Api.DDns/README.md
+++ b/src/CasCap.Api.DDns/README.md
@@ -13,17 +13,24 @@ 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 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
| 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 |
## 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 |
| --- | --- | --- |
@@ -35,6 +42,28 @@ 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.
+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, HTTP cancellation and timeout recovery; they do
+not execute Azure DNS writes or prove Redis renewal under partitions.
+
## Configuration
### `DDnsConfig` (`CasCap:DDnsConfig`)
@@ -93,13 +122,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 471cc9c4..e54865ba 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 4985681d..a69e1a44 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 || !cancellationToken.IsCancellationRequested)
{
_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 00000000..e2300ede
--- /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 SetSubscriptionAsync(cancellationToken);
+ 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 data = aRecord.Data;
+ 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)
+ {
+ 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;
+ }
+
+ 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))
+ {
+ 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.Api.DoorBird/Controllers/DoorBirdController.cs b/src/CasCap.Api.DoorBird/Controllers/DoorBirdController.cs
index b678438e..e6a033f2 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 784b1eb6..06697e9c 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 8f2a62bc..4abd609a 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.Tests/README.md b/src/CasCap.Api.Knx.Tests/README.md
index afbec9ae..161e049b 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,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 |
-| Total | 17 | 23 | |
+| `KnxLeadershipTests` | 11 | 18 | `Leadership` |
+| Total | 28 | 41 | |
-`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, 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
@@ -38,10 +46,14 @@ CasCap.Api.Knx.Tests/
├── CemiDecodingTests.cs
├── GroupAddressTests.cs
├── KnxShutterMovementTests.cs
-└── TestBase.cs
+├── TestBase.cs
+└── Tests/
+ └── Unit/
+ ├── KnxLeadershipTests.cs
+ └── KnxTestClock.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 +67,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 00000000..8f6d3209
--- /dev/null
+++ b/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxLeadershipTests.cs
@@ -0,0 +1,581 @@
+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;
+using System.Threading.Channels;
+
+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);
+ 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);
+ 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);
+ }
+
+ [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" }),
+ new GroupValue(true), true, null);
+
+ private static TaskCompletionSource Gate() => new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ private sealed class Fixture : IDisposable
+ {
+ 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,
+ bool dayNight = false, bool bedroomOverride = false)
+ {
+ WorkerLifetime = new(Health, Lifetime);
+ File.WriteAllText(_path, $$"""
+
+
+
+
+
+
+ {{(bedroomOverride ? """""" : "")}}
+
+
+
+ """);
+ var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ [nameof(KnxConfig.ServiceFamily)] = family.ToString(),
+ [nameof(KnxConfig.ShardingMode)] = sharding.ToString(),
+ [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,
+ Config, new TestEnvironment(), new KnxGroupAddressLookupHealthCheck(), TimeProvider.System);
+ }
+
+ public KnxMonitorBgService CreateWorker(ILeadershipRunner runner, Func factory)
+ => 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, WorkerLifetime, 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 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();
+ 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 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()
+ {
+ 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 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 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)
+ {
+ 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 Task FailureGate { get; init; } = Task.CompletedTask;
+ 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();
+ await FailureGate.WaitAsync(cancellationToken);
+ 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 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;
+ 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.Tests/Tests/Unit/KnxTestClock.cs b/src/CasCap.Api.Knx.Tests/Tests/Unit/KnxTestClock.cs
new file mode 100644
index 00000000..e7bfac78
--- /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/Abstractions/IKnxConnectionNotifier.cs b/src/CasCap.Api.Knx/Abstractions/IKnxConnectionNotifier.cs
index 66cab085..324f6c2f 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 00000000..36bbfef9
--- /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/CasCap.Api.Knx.csproj b/src/CasCap.Api.Knx/CasCap.Api.Knx.csproj
index 1a0edd7a..8253e282 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.Api.Knx/Controllers/BusController.cs b/src/CasCap.Api.Knx/Controllers/BusController.cs
index c246744e..ecf0b638 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 9e2ba4ab..3cfbc5f1 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 0b0a3314..f0c0a95a 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 05149a08..ebe79fb0 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 9abf47ee..7ad114aa 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 c9b5ce42..1923814d 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 4f77c3d2..657e0a7b 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.Knx/Extensions/ServiceCollectionExtensions.cs b/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs
index 6fb9bfef..72168c1e 100644
--- a/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs
+++ b/src/CasCap.Api.Knx/Extensions/ServiceCollectionExtensions.cs
@@ -105,11 +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();
+ services.AddSingleton(sp => sp.GetRequiredService());
}
services.AddSingleton();
diff --git a/src/CasCap.Api.Knx/GlobalUsings.cs b/src/CasCap.Api.Knx/GlobalUsings.cs
index e4c1c324..a523dfe7 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 52547129..a4e374ca 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 94b38b4e..60da0967 100644
--- a/src/CasCap.Api.Knx/README.md
+++ b/src/CasCap.Api.Knx/README.md
@@ -34,14 +34,22 @@ 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.
+`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
@@ -185,16 +193,37 @@ 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, 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.
+
+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
+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.
+
+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
+entries are not automatically reclaimed after ownership changes; reliable replay/idempotency is a
+separate delivery concern.
#### Standby Health Check
@@ -206,7 +235,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 +264,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 +272,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 +303,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 +311,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/KnxAutomationBgService.cs b/src/CasCap.Api.Knx/Services/KnxAutomationBgService.cs
index f9405d86..c5a324b2 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,59 +148,40 @@ 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
- {
- 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");
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);
@@ -227,9 +189,21 @@ void EnqueueDayNightUpdate(KnxGroupAddressParsed kga)
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/KnxConnectionNotifier.cs b/src/CasCap.Api.Knx/Services/KnxConnectionNotifier.cs
index 380ed14f..7e2503b4 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 41d7f0f6..d2b5ac05 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 863b156b..78fcd66b 100644
--- a/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs
+++ b/src/CasCap.Api.Knx/Services/KnxMonitorBgService.cs
@@ -1,600 +1,55 @@
-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,
+ KnxAutomationBgService knxAutomationBgSvc,
+ KnxWorkerLifetime workerLifetime) : 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());
+ await knxGroupAddressLookupSvc.WaitUntilReadyAsync(cancellationToken).ConfigureAwait(false);
- //populate BusConnections with null entries for each discovered area/line
- KnxStatics.BusConnections.Clear();
- foreach (var areaLine in _lineParameters.Keys)
- KnxStatics.BusConnections[areaLine] = null;
+ logger.LogInformation("{ClassName} ServiceFamily={ServiceFamily}, ShardingMode={ShardingMode}, TelegramBrokerMode={TelegramBrokerMode}",
+ nameof(KnxMonitorBgService), config.Value.ServiceFamily, config.Value.ShardingMode, config.Value.TelegramBrokerMode);
- //attempt initial connection to each area/line
- foreach (var areaLine in _lineParameters.Keys)
- await ConnectLine(areaLine, cancellationToken).ConfigureAwait(false);
+ 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)}.");
- //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);
+ await leadershipRunner.RunAsync("knx:monitor", RunOwnedAsync, cancellationToken).ConfigureAwait(false);
}
- private async Task ConnectAndMonitorRouting(CancellationToken cancellationToken)
+ private async Task RunOwnedAsync(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);
- }
- }
-
- ///
- /// 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);
-
- 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);
- }
- }
-
- if (connected)
- {
- LogReconnectionSucceeded(logger, nameof(KnxMonitorBgService), 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)
- {
- if (!_lineParameters.TryGetValue(areaLine, out var parametersList))
- {
- LogNoStoredParameters(logger, nameof(KnxMonitorBgService), areaLine);
- return 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)
- {
- KnxStatics.BusConnections[areaLine] = bus;
- _connectedAddresses[parameters.IndividualAddress!.Value.ToString()] = 0;
- return true;
- }
- }
-
- return 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);
+ await using var monitor = monitorFactory();
try
{
- await bus.ConnectAsync(cancellationToken).ConfigureAwait(false);
+ 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)
+ catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
- 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);
+ workerLifetime.StopApplication();
+ throw;
}
}
-
- 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 00000000..8e66aa0e
--- /dev/null
+++ b/src/CasCap.Api.Knx/Services/KnxMonitorService.cs
@@ -0,0 +1,604 @@
+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,
+ 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
+ // 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;
+ ConnectionHealth.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 workerLifetime.RunAsync(workers, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ WithdrawOwnership();
+ }
+ }
+
+ private void WithdrawOwnership()
+ {
+ lock (_callbackGate)
+ {
+ _stopping = true;
+ ConnectionHealth.ConnectionActive = false;
+ ConnectionHealth.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)
+ ConnectionHealth.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)
+ {
+ ConnectionHealth.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 e699c769..c0b546a7 100644
--- a/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs
+++ b/src/CasCap.Api.Knx/Services/KnxSenderBgService.cs
@@ -6,57 +6,60 @@ 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 WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
+ await foreach (var msg in outgoingBroker.SubscribeAsync(cancellationToken))
{
- await RunServiceAsync(cancellationToken).ConfigureAwait(false);
+ await WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
+ await SendTelegramAsync(msg, 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)
+ private async Task SendTelegramAsync(KnxOutgoingTelegram msg, CancellationToken cancellationToken)
{
- await foreach (var msg in outgoingBroker.SubscribeAsync(cancellationToken))
+ var busAddresses = new List(KnxStatics.BusConnections.Count);
+ foreach (var busConnection in KnxStatics.BusConnections)
{
- if (cancellationToken.IsCancellationRequested) break;
-
- 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)
{
- 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)
+ {
+ 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!")]
diff --git a/src/CasCap.Api.Knx/Services/KnxWorkerLifetime.cs b/src/CasCap.Api.Knx/Services/KnxWorkerLifetime.cs
new file mode 100644
index 00000000..18573121
--- /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.Knx/Services/RedisKnxTelegramBroker{T}.cs b/src/CasCap.Api.Knx/Services/RedisKnxTelegramBroker{T}.cs
index 133af92b..ba03515b 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
{
diff --git a/src/CasCap.Api.Miele.Tests/MieleClientServiceTests.cs b/src/CasCap.Api.Miele.Tests/MieleClientServiceTests.cs
index 7bc23a9c..9d695bbb 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)
diff --git a/src/CasCap.Api.Miele/Abstractions/IMieleQueryService.cs b/src/CasCap.Api.Miele/Abstractions/IMieleQueryService.cs
index 70d34497..4fbc1b92 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 d893f8f5..5b32c136 100644
--- a/src/CasCap.Api.Miele/Controllers/MieleController.cs
+++ b/src/CasCap.Api.Miele/Controllers/MieleController.cs
@@ -7,51 +7,80 @@ namespace CasCap.Controllers;
[ApiVersion(1.0)]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
-[Produces("application/json")]
+[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 00000000..a0d8808a
--- /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 dc4cbd66..bc5a300c 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 00000000..832f3c06
--- /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 00000000..018df8da
--- /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 00000000..2503763a
--- /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 4c32edd1..3f157a67 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 af4ef4bd..ecd15c73 100644
--- a/src/CasCap.Api.Miele/README.md
+++ b/src/CasCap.Api.Miele/README.md
@@ -21,9 +21,59 @@ 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 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.
+
+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 00000000..0dbf6f32
--- /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 4d79269c..0b0ef286 100644
--- a/src/CasCap.Api.Miele/Services/MieleClientService.cs
+++ b/src/CasCap.Api.Miele/Services/MieleClientService.cs
@@ -1,4 +1,9 @@
-using CasCap.Common.Services;
+using CasCap.Exceptions;
+using Polly.CircuitBreaker;
+using Polly.Timeout;
+using System.Net;
+using System.Text.Json;
+using System.Text.Json.Serialization;
namespace CasCap.Services;
@@ -9,8 +14,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 +38,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 +85,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 +108,103 @@ 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 (HttpRequestException)
+ {
+ throw Failure("The Miele API could not be reached.");
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw Failure("The Miele API request timed out.");
}
- catch (Exception ex)
+ catch (TimeoutRejectedException)
{
- _logger.LogWarning(ex, "{ClassName} failed to put program for device '{DeviceId}'", nameof(MieleClientService), deviceId);
+ cancellationToken.ThrowIfCancellationRequested();
+ throw Failure("The Miele API request timed out.");
+ }
+ catch (BrokenCircuitException)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ throw Failure("The Miele API is temporarily unavailable.");
}
- 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 06292ff8..b6dd0069 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.Api.Shelly/Controllers/ShellyController.cs b/src/CasCap.Api.Shelly/Controllers/ShellyController.cs
index 2b833988..595c736d 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 531e927e..082960f8 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/Abstractions/IUbiquitiQueryService.cs b/src/CasCap.Api.Ubiquiti/Abstractions/IUbiquitiQueryService.cs
index e5480bbc..b886e929 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);
+
+ ///