A .NET library that connects to a KNX bus via KNXnet/IP tunnelling or routing (multicast), monitors all group-address telegrams, and fans them out to a configurable set of sinks for persistence, alerting and streaming.
dotnet add package CasCap.Api.KnxThe library is built around three background services that together form a pipeline:
-
KnxMonitorBgService– Loads the ETS group-address metadata, waits for it to become healthy, logs the activeServiceFamilyandShardingMode, 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 oneKnxBusconnection per configured area/line combination; in Routing mode it joins the multicast group advertised by the KNX IP router (andShardingModeis forced toUnified). Incoming telegrams pass through service-family-specific deduplication filters before being decoded and published via theIKnxTelegramBroker<KnxEvent>. -
KnxProcessorBgService– Reads from that broker and fans eachKnxEventout to every registeredIEventSink<KnxEvent>implementation in parallel, waiting for all sinks to complete before processing the next event. -
KnxSenderBgService– Dequeues outbound write requests from a separate outgoing broker and sends them to the bus, enabling bidirectional control.
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.
The library parses KNX group address names (as exported from ETS) into strongly-typed DTOs. For parsing to succeed, every group address must follow a hyphen-separated segment pattern where each segment maps to a known enum value. During construction every recognised segment is consumed from a list; after a successful parse the list should be empty — any leftover segments indicate an unrecognised token.
[Floor]-Category-[Room[(Location)]]-[Orientation]-[HorizontalPos]-[VerticalPos]-[LightStyle]-Function-[Outdoor|Indoor]-[[Identifier]]
Segments in square brackets are optional. The parser is order-independent — it matches each segment against frozen dictionaries of known values and removes it from the list, so segments can appear in any order. Category is the only mandatory segment (addresses without a recognised category are skipped entirely).
| Segment | Values | Notes |
|---|---|---|
| Floor | KG (basement), EG (ground), OG (upper), DG (top) |
German abbreviations from the KNX standard. |
| Category | SYS, ENV, BI, BL, HZ, PM, LI, SD |
Determines which function enum is used. See table below. |
| Room | Office, StorageRoom, Loggia, Entrance, MasterBedroom, MasterBathroom, FamilyBathroom, ChildRoom, ChildRoom1, ChildRoom2, Bedroom, Kitchen, LivingRoom, GuestWC, OpenPlanLiving, Study, Hallway, GuestRoom, GuestBathroom, BoilerRoom, LaundryRoom, Garage |
Appending text in parentheses adds a free-text location, e.g. Entrance(FrontDoor). |
| Orientation | North, East, South, West |
Compass direction of the device. |
| HorizontalPos | Left / L, Middle, Right / R, Corner |
L and R are accepted as aliases. |
| VerticalPos | Top, Middle, Bottom |
Vertical qualifier for multi-level fixtures. |
| LightStyle | L (generic), DL (downlighter), WL (wall light), PL (pendulum), LED (LED stripe) |
Only recognised for LI (lighting) addresses. |
| Function | Category-specific — see below | Segments ending in _FB mark the address as a feedback value. |
| Outdoor / Indoor | Outdoor, Indoor |
Sets the IsOutside flag. Defaults to indoor when absent. |
| Identifier | Text in [square brackets] |
e.g. SYS-[DateTime] → Identifier = DateTime. |
| Category | Enum | Functions |
|---|---|---|
BL (shutter/blind) |
ShutterFunction |
POS, POS_FB, POSSLATS, POSSLATS_FB, DIRECTION, MOVE, SCENE, STEP, WIND, RAIN, DIAG, TT |
BI (binary contact) |
ContactFunction |
STATE |
LI (lighting) |
LightingFunction |
SW, SW_FB, DIM, VAL, VFB, STAIRWAY, SEQ1, SEQ1_FB, BITSCENE1, RGB, RGB_FB, RGB_DIM, HSV, HSV_FB, SCENE, LUX, LOCK |
SD (power outlet) |
PowerOutletFunction |
SD_SW, SD_FB |
HZ (heating/HVAC) |
HvacFunction |
SETP, SETP_UPDATE, FB, OFFSET, WINDOW, TEMP, OUTPUT, DIAG, HUMIDITY |
SYS (system) |
SystemFunction |
(none — identifier-based) |
ENV (environment) |
EnvironmentFunction |
(none — identifier-based) |
PM (presence/motion) |
PresenceFunction |
INVERT |
| Group Address Name | Floor | Category | Room | Orientation | LightStyle | Function | Feedback | Notes |
|---|---|---|---|---|---|---|---|---|
DG-LI-Office-SW |
DG | LI | Office | SW | Simple light switch | |||
EG-LI-Entrance-DL-SW_FB |
EG | LI | Entrance | DL | SW_FB | yes | Downlighter switch feedback | |
OG-BL-FamilyBathroom-West-POS |
OG | BL | FamilyBathroom | West | POS | Blind position, west-facing | ||
EG-BI-Entrance(FrontDoor)-East-STATE |
EG | BI | Entrance | East | STATE | Door contact with location | ||
DG-HZ-StorageRoom-SETP |
DG | HZ | StorageRoom | SETP | Heating setpoint | |||
DG-BI-STATE |
DG | BI | STATE | Binary contact, no room | ||||
SYS-[DateTime] |
SYS | System identifier | ||||||
EG-PM-Kitchen-South |
EG | PM | Kitchen | South | Presence sensor, no function | |||
EG-LI-Kitchen-LED-Left-SW |
EG | LI | Kitchen | LED | SW | LED strip, left position | ||
OG-BL-MasterBedroom-South-Left-MOVE |
OG | BL | MasterBedroom | South | MOVE | Blind move, south-left |
Addresses that share all segments except the function suffix are automatically grouped into KnxGroupAddressGroup records. For example, OG-BL-FamilyBathroom-West-MOVE, OG-BL-FamilyBathroom-West-POS and OG-BL-FamilyBathroom-West-SCENE all belong to the group OG-BL-FamilyBathroom-West.
- Export format — Export group addresses from ETS as XML (the library deserialises
KnxGroupAddressXmlExport). - DPT assignment — Every group address must have a KNX Datapoint Type assigned in ETS (format
DPST-x-y). Addresses without a DPT are skipped during parsing. - Unique names — Each group address name must be unique across the entire project. Duplicate names cause a startup exception.
- Placeholder filtering — Addresses whose name contains a
?character are treated as ETS placeholders and excluded from the loaded lookup. - Validation — Run
GroupAddressTests.ParseGroupAddressNamingConvention()after modifying ETS names. The test asserts that every address is fully parsed (zero leftover segments).
All site-specific values are configured in appsettings.json under CasCap:KnxConfig. The C# defaults are intentionally left empty/placeholder so that the library works out-of-the-box for any KNX installation — simply fill in your own values.
| Setting | Description | Example |
|---|---|---|
TunnelingAreaLineFilter |
Area/line combinations to monitor (tunneling only) | ["1.1", "1.2", "1.3"] |
StateChangeAlerts |
Group address names mapped to LLM-friendly descriptions for state change alerts | {"SYS-[RM_Alarm_FB]": "Smoke detector alarm", ...} |
DayNightTimeZoneLocation |
IANA time zone for sunrise/sunset | "Berlin" |
DayNightLatitude |
Latitude for day/night calculation | 52.520008 |
DayNightLongitude |
Longitude for day/night calculation | 13.404954 |
The Translations section in KnxConfig maps English enum values to localised equivalents. At agent startup the TranslationExtensions.BuildTranslationGlossary() method generates a Markdown glossary table that can be injected into the LLM system prompt, allowing end users to query in German (or any configured language) while MCP tools use English internally.
"Translations": {
"de": {
"Floors": { "DG": "Dachgeschoss", "OG": "Obergeschoss", ... },
"Rooms": { "Kitchen": "Küche", "Office": "Büro", ... },
"Orientations": { "North": "Nord", "East": "Ost", ... },
"Categories": { "LI": "Beleuchtung", "HZ": "Heizung", ... }
},
"es": {
"Floors": { "DG": "Ático", "OG": "Planta alta", "EG": "Planta baja", "KG": "Sótano" },
"Rooms": { "Kitchen": "Cocina", "Office": "Oficina", "LivingRoom": "Salón", ... },
"Orientations": { "North": "Norte", "East": "Este", "South": "Sur", "West": "Oeste" },
"Categories": { "LI": "Iluminación", "HZ": "Calefacción", "BL": "Persianas", "SD": "Enchufe" }
}
}| Sink | Description |
|---|---|
| Console | Logs every telegram via Serilog (configurable verbosity filter) |
| Redis | Persists the latest decoded value for each group address to Redis |
| Channel | Exposes telegrams on an in-process Channel<KnxTelegram> for MCP/AI tooling |
| Azure Tables | Writes historical readings and a rolling snapshot to Azure Table Storage |
| Azure CEMI Tables | Stores raw CEMI L-Data frames (batched) to a separate Azure Table |
| State Change | Tracks last-known state per group address and sends SMS alerts on transitions |
| OpenTelemetry | Emits OpenTelemetry metrics |
| gRPC | Streams telegrams to connected gRPC clients |
The three key configuration properties — ServiceFamily, ShardingMode and TelegramBrokerMode — are interdependent. The table below shows every valid combination, what it means for Kubernetes resources, and whether external callers (MCP tools / Agents) can send commands to the KNX bus.
ServiceFamily |
ShardingMode |
TelegramBrokerMode |
K8s resource | External access | Notes |
|---|---|---|---|---|---|
| Tunneling | Unified | Redis (default) | Deployment (1+ replicas) | ✅ Any pod or external service publishes to the Redis outgoing stream; the leader's KnxSenderBgService consumes it. |
Recommended production configuration. Redlock elects the active pod; standbys are warm. |
| Tunneling | Unified | Channel | — (local dev) | ❌ Channel<T> is in-process only — callers in other pods cannot reach it. |
Local development only. Override via CasCap__KnxConfig__TelegramBrokerMode=Channel. |
| Tunneling | Partitioned | Redis (required) | StatefulSet | ✅ Same Redis stream mechanism. Each pod owns one KNX line. | |
| Tunneling | Partitioned | Channel | — | ❌ Invalid | Partitioned mode requires cross-pod communication; Channel cannot provide it. |
| Routing | Unified (forced) | Redis (default) | Deployment (1+ replicas) | ✅ Same as Tunneling + Unified + Redis. | ShardingMode is forced to Unified at startup (with a warning if configured otherwise). |
| Routing | Unified (forced) | Channel | — (local dev) | ❌ Same limitation as above. | Local development only. |
| Routing | Partitioned | — | — | ❌ Invalid | Routing receives all backbone traffic on one multicast connection — partitioning is meaningless. Forced to Unified at startup. |
Why is Redis the default? The .NET application has no knowledge of its replica count — that is a Kubernetes concern. By defaulting to
Redis, the application works correctly in any deployment topology (single-replica, multi-replica, external Agents). The Helm chart orappsettings.jsoncan override toChannelfor local development where Redis is not available.
The ShardingMode property on KnxConfig controls how KnxMonitorBgService pods are distributed across KNX lines. The mode is set via appsettings.json under CasCap:KnxConfig:ShardingMode.
| 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. |
| 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. NotSupportedException. |
- Group address lookup — all replicas load the ETS XML metadata concurrently (stateless, no lock required).
- Configuration logged — after the lookup completes,
ServiceFamilyandShardingModeare emitted atInformationlevel so the active configuration is visible in every pod's log stream. IfServiceFamilyisRoutingandShardingModewas set toPartitioned, it is overridden toUnifiedwith a warning. - Leadership election — each replica attempts to acquire a Redlock on the resource
knx:monitor. Timing is governed by the sharedRedlockConfig(RedlockExpiryMs,RedlockWaitMs,RedlockRetryMs). - 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.
- Leader crashes — the Redlock expires (worst-case
RedlockExpiryMs, default 15 s), and a standby replica acquires the lock and connects.
The KnxConnectionHealthCheck tracks an IsLeader flag alongside the existing ConnectionActive flag. This prevents standby replicas from being marked unhealthy (and restarted by Kubernetes) while they are legitimately waiting for the distributed lock:
IsLeader |
ConnectionActive |
Health status |
|---|---|---|
false |
— | Healthy — standby, waiting for distributed lock |
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.
The filters applied in Bus_GroupMessageReceived depend on the configured ServiceFamily.
When running with multiple bus connections, two filters are applied before processing a telegram:
-
Line affinity — only process a telegram if the source device's area/line matches the receiving bus connection's area/line. KNX line couplers route telegrams across lines, which means every connection receives cross-line traffic. This cheap integer comparison prevents N−1 duplicate publishes per routed telegram.
-
Rival tunneling address — during discovery, all tunneling slot individual addresses are recorded. When a telegram arrives whose source is a discovered tunneling address but not one of our active connections, it originated from another deployment sharing the same KNX interfaces (e.g. production on slot 1, dev on slot 2) and is dropped.
In routing mode a single multicast connection receives all group telegrams from the entire KNX backbone. Neither the line-affinity nor the rival-tunneling-address filter applies — the bus connection's individual address is the router's backbone address, not a TP device, so source area/line comparisons would incorrectly drop every incoming telegram. Both filters are therefore skipped when ServiceFamily is Routing.
flowchart TD
BUS["KNX Bus\n(KNXnet/IP Tunnelling or Routing)"]
subgraph Monitor["KnxMonitorBgService"]
LOOKUP["Load group address XML"]
LOG_CONFIG["Log ServiceFamily &\nShardingMode"]
REDLOCK{"Acquire Redlock\n(Unified mode)"}
DISCOVER["Discover KNX IP devices"]
CONNECT["Connect per Area/Line\nor join multicast"]
RECEIVE["Bus_GroupMessageReceived"]
SVC_CHECK{"Tunneling?"}
FILTER_LINE{"Line affinity\nfilter"}
FILTER_RIVAL{"Rival address\nfilter"}
DECODE["Decode value & build KnxEvent"]
end
CHANNEL_IN["IKnxTelegramBroker<KnxEvent>\n(Channel or Redis)"]
subgraph Processor["KnxProcessorBgService"]
READ["ReadAllAsync"]
DISPATCH["Fan-out to all sinks\nTask.WhenAll"]
end
SINK_CONSOLE["Console Sink\n(Serilog)"]
SINK_REDIS["Redis Sink\n(latest state)"]
SINK_CHANNEL["Channel Sink\n(in-process)"]
SINK_AZTABLES["Azure Tables Sink\n(history + snapshot)"]
SINK_CEMI["Azure CEMI Tables Sink\n(raw frames, batched)"]
SINK_STATECHANGE["State Change Sink\n(SMS alerts)"]
SINK_OTEL["OpenTelemetry Sink\n(metrics)"]
SINK_GRPC["gRPC Sink\n(streaming)"]
CHANNEL_OUT["IKnxTelegramBroker<KnxOutgoingTelegram>\n(Channel or Redis)"]
subgraph Sender["KnxSenderBgService"]
SEND["Dequeue & write to bus"]
end
LOOKUP --> LOG_CONFIG --> REDLOCK --> DISCOVER --> CONNECT
BUS -->|GroupEventArgs| RECEIVE
CONNECT --> RECEIVE --> SVC_CHECK
SVC_CHECK -->|yes| FILTER_LINE
SVC_CHECK -->|routing| DECODE
FILTER_LINE -->|same line| FILTER_RIVAL
FILTER_LINE -.->|cross-line| DROP1(("drop"))
FILTER_RIVAL -->|our address| DECODE
FILTER_RIVAL -.->|rival address| DROP2(("drop"))
DECODE --> CHANNEL_IN
CHANNEL_IN --> READ --> DISPATCH
DISPATCH --> SINK_CONSOLE
DISPATCH --> SINK_REDIS
DISPATCH --> SINK_CHANNEL
DISPATCH --> SINK_AZTABLES
DISPATCH --> SINK_CEMI
DISPATCH --> SINK_STATECHANGE
DISPATCH --> SINK_OTEL
DISPATCH --> SINK_GRPC
CHANNEL_OUT --> SEND --> BUS
{
"CasCap": {
"KnxConfig": {
"ServiceFamily": "Tunneling",
"GroupAddressXmlFilePath": "/etc/knx/knxgroupaddresses.xml",
"TunnelingAreaLineFilter": ["1.1"],
"DayNightTimeZoneLocation": "Berlin",
"DayNightLatitude": 52.520008,
"DayNightLongitude": 13.404954,
"AzureTableStorageConnectionString": "https://<account>.table.core.windows.net",
"Sinks": {
"AvailableSinks": {
"Console": { "Enabled": true },
"Metrics": { "Enabled": true }
}
}
}
}
}{
"CasCap": {
"KnxConfig": {
"ServiceFamily": "Tunneling",
"ShardingMode": "Unified",
"HealthCheck": "Readiness",
"GroupAddressXmlFilePath": "/etc/knx/knxgroupaddresses.xml",
"TunnelingAreaLineFilter": ["1.1", "1.2", "1.3"],
"StateChangeAlerts": {"SYS-[RM_Alarm_FB]": "Smoke detector alarm"},
"DayNightEnabled": true,
"DayNightTimeZoneLocation": "Berlin",
"DayNightLatitude": 52.520008,
"DayNightLongitude": 13.404954,
"DayNightGroupAddressName": "SYS-[Night_Day]",
"DayNightSunriseHourOverrideWeekday": 6,
"DayNightSunriseHourOverrideWeekend": 7,
"BusSenderLoggingEnabled": true,
"StateChangePollingDelayMs": 100,
"StateChangeMaxPollIterations": 20,
"QueuePollingDelayMs": 50,
"QueueMaxConcurrency": 5,
"DiscoveryRetryDelayMs": 10000,
"ConnectionPollingDelayMs": 1000,
"ConnectionLogEscalationInterval": 10,
"ConnectionHealthPollingDelayMs": 1000,
"ReconnectBackoffMs": 1000,
"ReconnectMaxBackoffMs": 60000,
"TelegramBrokerMode": "Redis",
"TelegramConsumerGroupStartId": "0",
"AzureTableStorageConnectionString": "https://<account>.table.core.windows.net",
"HealthCheckAzureTableStorage": "None",
"Sinks": {
"AvailableSinks": {
"Console": { "Enabled": true },
"Memory": { "Enabled": true },
"Metrics": { "Enabled": true },
"AzureTables": { "Enabled": true },
"AzureTablesCemi": { "Enabled": true },
"Redis": {
"Enabled": true,
"Settings": {
"SnapshotValues": "all"
}
},
"CommsStream": { "Enabled": true },
"SignalR": { "Enabled": true }
}
},
"Translations": {
"de": {
"Floors": { "DG": "Dachgeschoss", "OG": "Obergeschoss", "EG": "Erdgeschoss", "KG": "Keller" },
"Rooms": { "Kitchen": "Küche", "Office": "Büro", "LivingRoom": "Wohnzimmer" },
"Orientations": { "North": "Nord", "East": "Ost", "South": "Süd", "West": "West" },
"Categories": { "LI": "Beleuchtung", "HZ": "Heizung", "BL": "Rollladen", "SD": "Steckdose" }
}
}
}
}
}This project is released under The Unlicense. See the LICENSE file for details.