Multi-CCU Operations Handbook¶
Who this page is for
Administrators running more than one CCU from a single daemon. For install and first-run see Installation & First Steps; for the full key reference see the Configuration reference.
OpenCCU-Loom is multi-CCU-capable: a single daemon process can serve any number of Homematic CCUs concurrently. This handbook covers the operational workflow — adding a second CCU to a running daemon, MQTT topic scoping, callback routing, and per-central_name diagnostics.
Design rationale and non-operational aspects are in ADR 0002. The mechanics described here have shipped since 0.1.0 and are pinned by contract tests.
Concepts¶
central_name— Unique identifier for one CCU within the daemon process. Attached to every event, every metric, every MQTT topic and every REST path. Free-form, but stable: changing it breaks continuity in the topic schema, backups, and persistence.- CentralRegistry — In-process map
central_name → CentralUnit. Holds every active CCU; every north-bound adapter (MQTT, REST, WS, UI, Matter) iterates or looks up bycentral_name. - Single-Central Convenience — When the daemon runs with exactly one CCU, many paths are addressable without a
central_name(e.g./api/v1/devices/...); the UI collapses the central selector.
1. Adding a second CCU to a running daemon¶
1.1 Update the configuration¶
centrals: in the config YAML is a list. Example: existing ccu-haus, adding ccu-garage.
centrals:
- name: ccu-haus # existing
host: 192.168.1.10
interfaces: [HmIP-RF, BidCos-RF]
- name: ccu-garage # new
host: 192.168.1.11
interfaces: [HmIP-RF]
# Optional: per-central reliability override
# reliability:
# command_throttle_inter_command_delay: 50ms
Required fields per central: name (unique), host, interfaces. Every other field falls back to the daemon default or is optional.
1.2 Reload strategy¶
OpenCCU-Loom has no hot-reload for centrals:. After editing the config:
systemctl restart openccu-loom # systemd deployment
docker compose restart openccu-loom # compose deployment
During the restart:
- Existing MQTT discovery configs under the topic base remain retained — Home Assistant does not lose any devices.
- Existing sessions (edit locks, OIDC sessions) stay persisted in SQLite.
- The XML-RPC callback subscription on the CCU survives for ~2 min; events arriving in this window buffer and replay on reconnect.
1.3 Verification¶
After the restart both CCUs should appear in the health aggregation:
curl -s http://localhost:8119/api/v1/health | jq '.components[] | select(.name | endswith("/central"))'
Expected output (status healthy):
{ "name": "ccu-haus/central", "status": "healthy", ... }
{ "name": "ccu-garage/central", "status": "healthy", ... }
In the config UI the central selector at the top-left now lists both entries.
2. MQTT topic scoping¶
Every MQTT topic carries central_name as the second path segment under the configured topic_base:
A <bucket> segment (values | master | calculated) sits between the channel and the parameter. Examples:
openccu-loom/ccu-haus/HmIP-RF/000A0000000001/4/values/STATE
openccu-loom/ccu-garage/HmIP-RF/000A0000000099/1/values/LEVEL
2.1 Home Assistant Discovery¶
HA discovery config topics embed central_name in the node_id. The shape is homeassistant/<component>/<node_id>/<object_id>/config, where node_id = <central>_<address> and object_id = <channel>_<suffix>, both lower-cased:
homeassistant/binary_sensor/ccu-haus_000a0000000001/4_state/config
homeassistant/sensor/ccu-garage_000a0000000099/1_level/config
That eliminates topic collisions when both CCUs host devices with the same address (which happens because HmIP-RF addresses are unique per CCU but not across CCUs).
2.2 Subscriptions¶
The MQTT bridge uses a single broker connection across all CCUs. Adding a second CCU does not start a new MQTT client; publishes are disambiguated by the topic path.
Subscriptions on set topics (<topic_base>/<central>/.../set) are registered per central. A set action targeting the wrong central path is rejected as an unknown address — there is no cross-central routing.
2.3 Migrating between CCUs¶
When a device moves from one CCU to another (re-paired), it surfaces under the new central_name. The old topic + discovery config remain retained — operational cleanup pattern:
# Example: device 000A0000000001 moved from ccu-haus → ccu-garage.
mosquitto_pub -h <broker> -t 'openccu-loom/ccu-haus/HmIP-RF/000A0000000001/#' \
-r -n -l < /dev/null
mosquitto_pub -h <broker> \
-t 'homeassistant/binary_sensor/ccu-haus_000a0000000001/4_state/config' \
-r -n -l < /dev/null
An automatic retain-cleanup migrator at first boot is part of the ADR-0011 wave; until that lands the manual pattern above is the recommended workflow.
3. Callback routing¶
Both callback listeners (XML-RPC and BIN-RPC) are shared across all CCUs in the daemon.
3.1 XML-RPC (HmIP-RF, BidCos-*, VirtualDevices)¶
One HTTP listener on callback.port (default :8120). The daemon registers one URL path per central during init():
Inbound XML-RPC calls dispatch to the correct CentralUnit by path. Inspection via daemon log:
Expected log fields: central=<name>, interface=<HmIP-RF|...>, method=event|newDevices|....
3.2 BIN-RPC (CUxD)¶
One TCP listener on callback.bin_port (default :8129). CUxD addresses inbound datagrams via the interface_id in the envelope; the BIN-RPC router resolves interface_id → central_name through the registry. Each central owns a separate interface-id namespace.
3.3 Dynamic ports¶
When callback.port: 0 or callback.port_range: "30000-30099" is set, the OS or the range allocator hands out fresh ports on every daemon restart. A configured port_range wins over callback.port. The effective port is announced to the CCU at init() time — not the configured 0. Multiple CCUs share the same listener; each CCU learns the new port at its first reconnect.
4. Diagnostics per central_name¶
4.1 Health endpoint¶
Each central exposes several <name>/<component> health components: <name>/central (heartbeat), one <name>/<interface> per interface (e.g. ccu-haus/HmIP-RF), and <name>/scheduler. The aggregate daemon status flips to unhealthy as soon as a single central is unhealthy — HA discovery frontends and CI healthchecks therefore detect a bad CCU immediately.
4.2 Structured logs¶
Every log record carries central=<name> as a structured field. Filter via jq:
With logging.format: text (dev mode) central=... shows up as an inline key=value at the end of the line.
4.3 Prometheus metrics¶
CCU-scoped metrics carry the central label:
openccu_loom_client_state{central="ccu-haus", interface="HmIP-RF", state="CONNECTED"} 1
openccu_loom_client_state{central="ccu-garage", interface="HmIP-RF", state="CONNECTED"} 1
openccu_loom_command_count_total{central="ccu-haus",interface="HmIP-RF",method="setValue"} 4173
Adapter-scoped metrics (MQTT publish rate, REST request duration) do not carry the label — they measure the north-bound side, which is process-global.
PromQL example: per-central round-trip-time:
histogram_quantile(0.95,
sum by (central, le) (rate(openccu_loom_command_duration_seconds_bucket[5m])))
4.4 REST inspection¶
Per-central device list (scope the daemon-global /devices with the central query param):
Per-central audit log (the since filter expects an RFC3339 timestamp):
In single-central mode the unscoped path /api/v1/devices is enough — the router redirects automatically.
4.5 WebSocket events¶
WebSocket subscription wss://<host>:8119/api/v1/events carries events from every central. Filter client-side on event.central_name:
ws.onmessage = (e) => {
const ev = JSON.parse(e.data);
if (ev.central_name !== "ccu-garage") return;
// ...
};
5. Backup + state layout¶
State for the whole daemon lives under data_dir as a small set of shared files — there is no per-central directory tree. Devices, paramsets and incidents are SQLite tables inside openccu-loom.db, scoped by a central_name column:
<data_dir>/
├── openccu-loom.db # devices, paramsets, incidents, users, tokens, sessions, sysvar cache
├── history.db # measurement history (opt-in; ADR 0040)
├── backups/ # generated backup archives
├── secret.key # config-secret encryption key
└── .env # optional env overlay
Backups are listed daemon-globally:
To create a backup scoped to a single CCU, POST /api/v1/backups with a central_name body; restore is by backup id via POST /api/v1/backups/{id}/restore and does not affect any other CCU:
curl -s -u admin:secret -X POST http://localhost:8119/api/v1/backups \
-H 'Content-Type: application/json' -d '{"central_name":"ccu-haus"}'
6. Common failure modes¶
| Symptom | Cause | Remedy |
|---|---|---|
| Both CCUs deliver events, one suddenly goes silent | XML-RPC callback path mismatch — typically when central_name was changed in config without a fresh init() round-trip | Daemon restart, or POST /api/v1/interfaces/{id}/reconnect for each of the central's interfaces |
| HA Discovery shows duplicated devices | Migration between CCUs without retain cleanup | Manual MQTT retain wipe (see §2.3) |
openccu_loom_client_state metric missing for a central | CCU never connected (host / firewall / credentials) | Check /health component; filter daemon log on central=<name> |
| Audit log mixes CCUs | Expected behaviour — the audit log is daemon-global with central_name as a column | Use ?central=... query or the UI filter |
| OIDC login fails on one central | OIDC provider is daemon-global, not per-central — login fails for every CCU the same way | Inspect the provider config under auth.oidc: |
7. Related documents¶
- ADR 0002 — Multi-CCU as a first-class feature
- ADR 0011 — MQTT topic + payload architecture
- MQTT topic schema
- Installation & First Steps — install + bootstrap config
- Configuration reference — every config key
example.config.yaml— annotated config reference