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. Letters, digits,-and_only (it is a path segment of the callback URL), and 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¶
Centrals added, changed or removed through the web UI or the REST API (POST /api/v1/centrals, PUT/DELETE /api/v1/centrals/{name}) are adopted live, without a restart. After editing centrals: in the config file instead:
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.
- Browser and OIDC sessions stay persisted in SQLite; edit locks are held in memory and are released by the restart.
- 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 item carries central_name as the first item level, directly below the function (status, set, meta — ADR 0083):
<topic_base>/status/<central_name>/<central_name>-<interface>/<device>/<channel>/<bucket>/<parameter>
A <bucket> segment (values | master | calculated) sits between the channel and the parameter. Examples:
openccu-loom/status/ccu-haus/ccu-haus-HmIP-RF/000A0000000001/4/values/STATE
openccu-loom/status/ccu-garage/ccu-garage-HmIP-RF/000A0000000099/1/values/LEVEL
Each central also has its own reachability item, <topic_base>/status/<central_name>/online (val true or false), and the daemon as a whole reports <topic_base>/connected: 2 while at least one central is reachable, 1 while none is, 0 when the daemon is gone.
A central may not be named alarm, security, system, bridge or like a topic function (connected, status, set, get, info, meta, maintenance, ha): those names sit at the same topic level as the central and are refused at start-up.
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).
The examples above are for a daemon on the default topic_base. Two daemons — a different case from two CCUs behind one daemon — are kept apart by the base instead: when north.mqtt.topic_base is not openccu-loom, the node id carries it as a leading segment, so the first example becomes homeassistant/binary_sensor/house_ccu-haus_000a0000000001/4_state/config on a daemon with topic_base: house. The three daemon-level planes (alarm, security, daemon) carry no central at all, so beside the node id their unique_id carries the base too — without that, two daemons declare one identity to Home Assistant and it keeps whichever config it saw first, leaving the second daemon's alarm and security entities missing entirely. Give a second daemon its own base before pointing it at the same broker, and pick one that is not a prefix of any CCU name on the broker: the base and the central slug are joined with the same _, so topic_base: haus with a CCU named CCU renders the same node id as the default base with a CCU named Haus CCU. See the HA identity migration note for what moves when you change the base on an existing install.
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:
The supported way is script/clean-mqtt-discovery.sh, which enumerates the retained topics on the broker and clears the ones it finds. It defaults to a dry run; -y executes.
# Preview, then clear, everything openccu-loom retained on this broker.
./script/clean-mqtt-discovery.sh -h <broker> -c /etc/openccu-loom/config.yaml
./script/clean-mqtt-discovery.sh -h <broker> -c /etc/openccu-loom/config.yaml -y
By hand, it is two steps, and the first one is not optional. A retained message is cleared one topic at a time: MQTT forbids a wildcard in the topic of a PUBLISH (§4.7.0), so there is no "clear the subtree" publish and mosquitto_pub rejects one outright. List what the broker actually holds first, then clear each topic by the exact string the listing printed — copy the topic, do not compose it:
# Example: device 000A0000000001 moved from ccu-haus → ccu-garage.
# 1. List the retained topics for that device. `-W 2` ends the
# subscription after a two-second quiet window.
mosquitto_sub -h <broker> --retained-only -W 2 -v \
-t 'openccu-loom/status/ccu-haus/ccu-haus-HmIP-RF/000A0000000001/#' \
-t 'openccu-loom/meta/ccu-haus/ccu-haus-HmIP-RF/000A0000000001/#'
# 2. Clear each topic the listing printed. `-r -n` publishes an empty
# retained payload, which is how a broker drops a retained message.
# `-n` and `-l` are mutually exclusive — pass only `-n`.
mosquitto_pub -h <broker> -r -n \
-t 'openccu-loom/status/ccu-haus/ccu-haus-HmIP-RF/000A0000000001/4/values/STATE'
# 3. The device's HA Discovery config, same rule.
mosquitto_pub -h <broker> -r -n \
-t 'homeassistant/binary_sensor/ccu-haus_000a0000000001/4_state/config'
The interface level is the wire interface id, <central>-<interface> — ccu-haus-HmIP-RF, not HmIP-RF. That is what the daemon publishes and what the listing in step 1 prints; see the topic schema.
An automatic retain-cleanup migrator at first boot is part of the ADR-0011 wave; until that lands the pattern above is the recommended manual 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_range: "30000-30099" is set, the range allocator hands out a free port on every daemon restart; a configured port_range wins over callback.port. (callback.port: 0 is not dynamic — it falls back to 8120.) The effective port is announced to the CCU at init() time. 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¶
GET /metrics renders the metric registry as OpenMetrics text and is the authoritative list of metric names and labels in your version. Check the labels a series carries there before writing per-central queries: not every south-bound metric carries a central label, and the registry has no histograms, so latency percentiles cannot be computed from it.
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. Mixed fleets: CCU and openccu-lite together¶
A daemon can run any mix of CCUs and openccu-lite boxes side by side; every mechanism above — central_name scoping, MQTT topic namespacing, per-central diagnostics — applies identically to both. What differs is what each system offers, not how the daemon organises centrals.
- Navigation hides what nothing serves. A view backed by a feature no configured system offers at all (system variables and programs on a fleet made up entirely of openccu-lite boxes) leaves the navigation. The New devices view is not one of them: it lists the daemon's own hold, which every system has. A view some systems support and others do not (heating groups, backups, scope-gated system actions) stays, and a row for a system that cannot serve it names the reason instead of failing: "openccu-lite does not offer this" or "the API token lacks the scope
power". - Per-central features, not a fleet-wide switch.
GET /system/ccureports each central'ssystem_typeandfeaturesindependently, so a CCU in the fleet keeps every action a mixed view might otherwise hide because one openccu-lite box lacks it. - Rooms and functions can nest per system. A CCU's rooms/functions stay the flat list they always were; an openccu-lite box's are a tree. The overview, room pickers and alarm zone candidates group and filter by path (
Ground floor › Kitchen) so two same-named rooms on different systems in the fleet stay distinguishable — see Taxonomy. Areas stay keyed by(central, room name), so on a nested system one area assignment covers every node sharing that name. - MQTT hub entities follow the feature, not the system type. The four feature-gated hub entities (alarm messages, service messages, inbox, firmware update) are declared per central based on what it offers right now — see Feature-gated hub entities.
8. 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