Skip to content

Architecture

A map of how OpenCCU-Loom is put together: the hexagonal layering, the key packages, the multi-CCU model, the south-bound transports, and where to read deeper.

Who this page is for

Contributors and developers who need a mental model of the daemon before changing it. For the authoritative design intent, read SPECIFICATION.md; for implementation detail, read the code.

Hexagonal layering

OpenCCU-Loom follows a ports-and-adapters (hexagonal) design. North-bound adapters expose the domain to the outside world; the domain core holds all the logic; a south-bound adapter speaks the Homematic wire protocols to the CCU.

flowchart TB
    subgraph North["North-bound adapters (internal/north)"]
        mqtt[mqtt]
        rest[rest]
        ws[rest/ws]
        ui[ui · Svelte SPA + no-JS /health,/about]
        matteradapter[matteradapter · Matter glue]
        mcp[mcp]
    end
    subgraph Core["Domain core"]
        central[central · CentralRegistry / CentralUnit]
        model[model]
        client[client]
        store[store · SQLite]
        health[health]
    end
    subgraph South["South-bound transports (internal/client/transport)"]
        xmlrpc[xmlrpc]
        binrpc[binrpc]
        jsonrpc[jsonrpc]
        occulited[occulited]
    end
    fabric[[go-fabric module · Matter stack]]
    ccu[(CCU / openccu-lite)]

    North --> Core
    matteradapter --> fabric
    Core --> South
    South --> ccu
    ccu -. callbacks / event stream .-> Core

The dependency rule points inward: adapters depend on the core, never the reverse. Cross-cutting protocol interfaces (the DI contracts shared by central, client, and north-bound adapters) live in pkg/interfaces.

Key packages

The public, thin library surface lives under pkg/; everything daemon-internal lives under internal/.

Package Role
pkg/hmtypes, pkg/hmenum, pkg/hmerr Primitive types, enums, error sentinels.
pkg/hmevent Domain event types carried on the bus.
pkg/hmapi, pkg/hmproto, pkg/interfaces External DTOs, Homematic wire shapes, DI contracts.
internal/central CentralUnit, coordinators, registries, the callback servers, scheduler.
internal/client InterfaceClient, transports, reliability (circuit breaker, retry, throttle), ReGa runner.
internal/model Domain model: devices, channels, data points, custom/calculated/combined profiles, schedules, hub.
internal/north REST, WS, UI, MQTT, MCP and webhook adapters, plus matteradapter — the host side of the Matter bridge, whose stack is the external go-fabric module.
internal/store/matterendpoint Matter endpoint identity and the exposure allowlist, keyed by the Homematic source identity.
internal/wiring The wiring manifest the composition root declares its seams into (ADR 0065).
cmd/openccu-loom The composition root — see Composition root and wiring.
internal/store SQLite persistence (migrations, sessions, paramsets, devices, audit) plus in-memory caches.
internal/auth, internal/config, internal/health Auth backends, config loading, the unified health tracker.
internal/secret, internal/configstore Secret handling and config-at-rest.
internal/audit, internal/diagnostics, internal/metrics, internal/observability Change-log, diagnostics, Prometheus collectors, tracing helpers.

The full tree is described in CLAUDE.md.

Multi-CCU model

Multi-CCU is a first-class feature. One daemon serves many CCUs:

  • One CentralUnit is created per configured CCU.
  • A CentralRegistry holds them all and is shared with the north-bound adapters.
  • central_name is the scoping dimension threaded through every cross-cutting call.

No code path assumes a single central. The rationale is in ADR 0002; the user-facing view is Multi-CCU.

South-bound transports and callback servers

Every interface pushes — callbacks on a CCU, the occulited event stream on an openccu-lite box. There is no polling-only path.

Transport Interfaces Callback server
XML-RPC + JSON-RPC HmIP-RF, BidCos-RF, BidCos-Wired, VirtualDevices XML-RPC over HTTP on :8120
BIN-RPC CUxD BIN-RPC over raw TCP on :8129
occulited HTTP API (openccu-lite) HmIP-RF, BidCos-RF, BidCos-Wired, VirtualDevices none — one event stream per central (ADR 0072)

HmIP-Wired is not a separate south-bound interface — ADR 0023 models it as a ProductGroup carried on the HmIP-RF interface.

For CCU centrals, two listeners run, one per protocol, shared across all centrals:

  • XML-RPC over HTTP on callback.port (default :8120), routed by URL path /RPC2/<central_name>.
  • BIN-RPC over raw TCP on callback.bin_port (default :8129), routed by interface_id in the envelope.

The XML-RPC listener binds a fixed port or the first free port of callback.port_range; the BIN-RPC listener binds a fixed port only. A port of 0 is not dynamic: it is replaced by the defaults 8120 and 8129. The effective port is re-advertised to the CCU on every init() and every reconnect. CUxD always speaks BIN-RPC — never treat it as JSON-RPC.

South profiles

A central picks its south-bound strategy once, at construction, based on its system_type: the CCU profile (ReGa, JSON-RPC, init callbacks) or the openccu-lite profile (the box's occulited HTTP API — an XML-RPC proxy, a push event stream, a metadata store instead of ReGa). Every downstream consumer — the description/paramset pipeline, the callback handlers, the model, every north-bound adapter — is shared between the two; the only place a central's system type is inspected is the selection itself.

flowchart LR
    cfg["central.system_type"] --> select["southProfileFor()"]
    select --> ccu["CCU profile\nReGa · JSON-RPC · init callbacks"]
    select --> lite["openccu-lite profile\nocculited HTTP API · event stream · metadata store"]
    ccu --> shared["shared: pipeline, model, north-bound adapters"]
    lite --> shared

A profile answers four questions the same way regardless of which central it belongs to: where readiness comes from, where events arrive from, which system services (power, backup, firmware) are wired, and what the hub bring-up step (identity, names, rooms) reads before devices load. What a central cannot do on its openccu-lite system — no ReGa, so no system variables, programs or the inbox — is expressed as an absent feature, never as a special case sprinkled through the shared code. The decision is recorded in ADR 0071.

Event bus

Cross-domain communication inside the core runs over an internal, generic, typed, priority-aware event bus (internal/central/events). It has no re-entrancy: handlers subscribe with a priority and an unsubscribe handle.

unsubscribe := bus.Subscribe(func(e hmevent.DataPointValueChanged) {
    // handle
}, events.WithPriority(events.PriorityHigh))
defer unsubscribe()

Persistence

Persistence is SQLite via modernc.org/sqlite — pure Go, no CGo. The default build runs CGO_ENABLED=0 and ships as a single static binary. Schema migrations live under internal/store/sqlite/migrations/ and are applied with goose. Cache layers and boot-time CCU radio cost are documented in Caching.

Matter bridge

The Matter wire stack is not part of this repository. It lives in the go-fabric module — a semantic port of matter.js HEAD covering the TLV codec, the Interaction Model, PASE / CASE, MRP over IPv6 UDP, DNS-SD advertisement, the cluster servers and the endpoint assembler — which the daemon embeds as a library. What stays here is the host side: internal/north/matteradapter/ walks the device model and produces the flat endpoint specs go-fabric turns into a topology, internal/store/matterendpoint/ backs go-fabric's endpoint store with the persisted source → endpoint-id mapping and the operator's exposure allowlist, and each device profile projects itself in internal/model/custom/<dp>/matter.go. The binding contract and standing parity guards are described in the Matter parity contract; the boundary itself, and how a go-fabric release is taken, in The Matter boundary; the user-facing view is Matter.

Where to read deeper