Skip to content

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 by central_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:

<topic_base>/<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/ccu-haus/ccu-haus-HmIP-RF/000A0000000001/4/values/STATE
openccu-loom/ccu-garage/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).

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/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/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 third path segment 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():

http://<daemon-host>:8120/RPC2/<central_name>

Inbound XML-RPC calls dispatch to the correct CentralUnit by path. Inspection via daemon log:

journalctl -u openccu-loom -f | grep "callback.xmlrpc"

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

curl -s http://localhost:8119/api/v1/health | jq .

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:

journalctl -u openccu-loom -o json | \
  jq 'select(.central == "ccu-garage")'

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):

curl -s 'http://localhost:8119/api/v1/devices?central=ccu-haus' | jq '.devices | length'

Per-central audit log (the since filter expects an RFC3339 timestamp):

curl -s 'http://localhost:8119/api/v1/audit?central=ccu-haus&since=2026-07-11T00:00:00Z' | jq .

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:

curl -s -u admin:secret http://localhost:8119/api/v1/backups

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: