Skip to content

Architecture Decision Records

Architecture Decision Records (ADRs) capture the consequential design choices behind OpenCCU-Loom — the decision, its context, and its consequences — so future readers can understand why the code looks the way it does.

Who this page is for

Contributors and developers who need the rationale behind a design choice. ADRs are immutable once landed: a superseded decision gets a new ADR rather than an edit to the old one.

The table below catalogues every ADR. Each entry links to the record itself — except the Matter wire-stack decisions, which moved to go-fabric with the code they describe. Their numbers are kept here as forwarding pages so existing links and bookmarks keep resolving, and those numbers are never reused: the next ADR continues from the highest number in this table.

# Title Status Summary
0001 License: MIT superseded by 0085 The source was licensed MIT up to 0.91.0. Superseded by ADR 0085.
0002 Multi-CCU as a first-class feature accepted One daemon serves many CCUs, scoped by central_name, from 0.1.0.
0003 Embed openccu-data metadata artifacts accepted Translations, easymodes, and profiles are embedded from openccu-data.
0004 Python decorators ↔ Go cross-cutting accepted How decorator-based concerns from the Python reference map to Go.
0005 Visibility as outbound filter accepted Visibility is applied as an outbound filter on REST and MQTT.
0006 Naming conventions for REST + MQTT accepted Shared naming rules across the REST and MQTT surfaces.
0007 Strong model: Source interface accepted A single Source interface backs both reads and writes.
0008 AggregatedState default-flip accepted; topology part superseded by 0011 Flips the AggregatedState default and removes the legacy path.
0009 Service-method command topics accepted Service-method command topics in HA Discovery.
0010 Discovery payload from the model accepted HA-Discovery payload construction moves into the model.
0011 MQTT topic & payload architecture accepted The topic and payload structure for the MQTT plane.
0012 Matter bridge: pure-Go accepted The Matter bridge is implemented in pure Go, no CGo SDK.
0013 Matter wire-protocol design rules moved to go-fabric Moved to go-fabric — the record is maintained as go-fabric ADR 0001. This page forwards.
0014 Parameter ignore / un-ignore mechanics accepted How parameters are ignored and un-ignored.
0015 Split Ignored from NoCreate accepted Separates Ignored from NoCreate in DataPointUsage.
0016 Custom-DP-aware UI rendering accepted UI rendering is aware of custom data points.
0017 Logging and diagnostics accepted The logging and diagnostics model.
0018 Health tracker parity accepted The health tracker mirrors aiohomematic.
0019 Persistent VALUES cache accepted A persistent VALUES cache with a wire-DP lifecycle.
0020 External-client wire contract accepted The wire contract external clients depend on.
0021 mDNS self-advertisement accepted; TXT part superseded by 0058 mDNS self-advertisement for LAN auto-discovery.
0022 WebSocket resume cursor + kind accepted The WebSocket resume cursor and envelope kind discriminator.
0023 HmIP-Wired is a ProductGroup accepted HmIP-Wired is modeled as a ProductGroup, not an interface.
0024 Instance vs CCU identity accepted; InitInterfaceID formula superseded by 0060 Daemon-instance vs CCU identity, and the two interface ids.
0025 MCP server as a north-bound adapter accepted The MCP server is a north-bound adapter.
0026 MCP dev-mode deferred A proposed build-tag-gated MCP introspection surface; not built.
0027 Encrypt config secrets at rest accepted Config secrets are encrypted at rest.
0028 Contract digest & version guard accepted A contract digest and version guard couple API-schema changes to the types-repo release.
0029 Tier-model teardown for Unit.Stop accepted Unit.Stop tears down subsystems in tiered order.
0030 Event-bus dispatch striping: rejected rejected Per-central isolation already meets the goal; dispatch striping is rejected.
0031 IM opcode dispatch seam moved to go-fabric Moved to go-fabric — the record is maintained as go-fabric ADR 0002. This page forwards.
0032 Sigma resumption extraction moved to go-fabric Moved to go-fabric — the record is maintained as go-fabric ADR 0003. This page forwards.
0033 Groups cluster stays a stub moved to go-fabric Moved to go-fabric — the record is maintained as go-fabric ADR 0004. This page forwards.
0034 Adapter package taxonomy accepted internal/central/adapter stays one package with a documented taxonomy.
0035 Hub refresh-set extraction accepted The refresh-coordination sub-component is extracted from HubCoordinator.
0036 Matter Bridge decomposition moved to go-fabric Moved to go-fabric — the record is maintained as go-fabric ADR 0005. This page forwards.
0037 OTLP span exporter accepted A pluggable span exporter ships a lean OTLP/HTTP exporter, not the OTel-gRPC SDK.
0038 Cross-stack CI gate accepted The cross-stack model-snapshot parity gate runs in nightly CI.
0039 Subscribe dispatch seam moved to go-fabric Moved to go-fabric — the record is maintained as go-fabric ADR 0006. This page forwards.
0040 Measurement history accepted Measurement history is stored in embedded SQLite with an opt-in push exporter.
0041 Persist auth sessions accepted Auth sessions persist in SQLite as a save-through cache.
0042 Clear CCU cache and re-pull accepted Clearing CCU-derivable caches and re-pulling is a first-class operation.
0043 CCU as an authentication provider accepted Login can be delegated to a CCU's own user database.
0044 Single-port onboarding + HA Ingress auth accepted Onboarding runs on a single port with HA Ingress auth passthrough.
0045 Login + onboarding into the SPA accepted Login and first-run onboarding live in the Svelte SPA.
0046 SSDP CCU discovery accepted CCUs are discovered on the LAN via active SSDP/UPnP.
0047 North-bound bridge registry accepted North-bound bridges are Services owned by a Registry.
0048 chip-tool ↔ godevccu send/receive matrix moved to go-fabric Moved to go-fabric — the record is maintained as go-fabric ADR 0007. This page forwards.
0049 Matter one endpoint per device accepted Matter exposes one endpoint per physical device by default.
0050 MQTT transport → shared go-mqtt module accepted The in-tree MQTT transport is extracted into the external shared go-mqtt module.
0051 North-bound authorization model accepted Role-based MinRole gating unified across REST + WS, plus backup-at-rest sealing.
0052 Daemon-level alarm MQTT topics accepted The alarm engine publishes panel state/commands on daemon-level topics, not per-central ones.
0053 CCU metadata via go-openccu-data accepted Embedded OCCU extracts come from the versioned go-openccu-data module instead of a hand-synced copy.
0054 Remote ingress proxy add-on accepted OpenCCU-Loom Remote: an HA Ingress panel for remote instances via token injection, instead of the local-only Ingress passthrough.
0055 Heating groups via the CCU jpages proxy accepted Heating-group administration goes through the CCU's own jpages endpoints, the only interface that drives the member roster.
0056 Room areas, and the zone/area naming split accepted Operator-defined areas group CCU rooms one level up; the alarm partition is renamed "zone" to free the word.
0057 CCU add-on self-update accepted The CCU/OpenCCU add-on updates itself through the firmware's own install_addon path, with no WebUI round trip.
0058 mDNS TXT carries the configured CCU serials accepted Partially supersedes ADR 0021: the TXT record advertises the configured serials so clients can match before authenticating.
0059 The Security & Safety MQTT plane accepted Extends ADR 0052: hazard classes aggregate across centrals, so they get daemon-level topics too.
0060 loom-prefixed CCU-facing interface ids accepted Partially supersedes ADR 0024: the wire-boundary InitInterfaceID carries a loom prefix and drops the repeated central name.
0061 The migration Down path is unsupported accepted goose Down blocks exist to satisfy the tool; they are destructive and must never run in production.
0062 Suppression reasons are recomputed, not recorded accepted Extends ADR 0015: the Ignored mark says that a parameter is hidden, never which rule hid it, so the reason is re-derived from the same rule sets.
0063 Device profiles are maintained here, not generated accepted script/generate_profiles.py is removed and the catalogue becomes ordinary hand-maintained source; every deviation from the fork point is recorded in notes/parity/by_design.md.
0064 A garage drive projects as a Matter Closure, not a WindowCovering accepted Refines ADR 0049: the drive carries ClosureControl with named stops, so the WindowCovering projection and its lift-percentage machinery are removed.
0065 The composition root states its wiring, so a machine can check it accepted Each wire* function declares its seam into a manifest the daemon builds at start-up, turning "is X wired, and does it run before Y" from an approximate question into an exact one.
0066 Relicense to AGPL-3.0-only with a commercial exception rejected Rejected (2026-08-28). AGPL plus a written exception would have made monetisation ask first; the project stayed MIT, and no CLA obligation arose. Revisited by ADR 0085.
0067 The north surface is a model API, not an HA entity projection accepted The REST/WS contract stays Home-Assistant-agnostic and the entity projection stays in the Python consumer; the MQTT plane keeps its own projection and is explicitly not a precedent. Additive model fields remain welcome — the line is drawn at descriptors.
0068 What unique_id promises, and it differs per plane accepted Changeable on REST/WS with a documented transition and an old key derivable from the payload; changeable on MQTT discovery too, but only as a documented break with a swept orphan, since Home Assistant has no migration path there. A generic previous_unique_id was designed and rejected. Amended 2026-08-30.
0069 The master-profile surface reads a link-paramset archive accepted The profiles/ archive is link-paramset data: the generator reads the firmware's LINK easymode tree and skips its MASTER tree, and the reference writes it with the peer channel address as the paramset key. master_profiles.* is a link surface under a wrong name, and apply writes link-derived values into MASTER — inert only because the lookup never matches. masterprofile is deleted in favour of linkprofile.
0070 Extract the HA discovery model into a shared module closed Six projects carry their own copy of the MQTT/HA-discovery layer and the copies have drifted into real defects. Two modules are extracted: go-hamqtt (model, device-based discovery bundle, publisher runtime) and go-ha-catalog (the HA vocabulary, generated from the core checkout). This daemon migrates first, before any bridge — it is both the architectural source and the hardest consumer. Extends 0050. An amendment withdraws the unique-id re-key for this daemon, so 0068's guarantee holds: no entity is re-created. A second amendment records the bundle migration measured against a live instance: the registry entry survives, but only if the per-entity config is retracted before the bundle is published — the other order is refused with nothing but a log line. Closed 2026-09-14: all nine phases shipped — the closing section carries a per-phase outcome table, phase 9's deliberate decline of the device bundle (two UniFi consoles cannot be told apart), an erratum showing the LOC table understates every bridge row by 1.6x to 2.3x, and the two findings the fan-out produced that belong to no single repository.
0071 South profiles: one south-bound strategy per central accepted A central names its system type (ccu, openccu-lite, auto), which selects a south profile supplying readiness, events, hub bring-up, liveness, features and management ports; everything downstream of a source stays shared. The type is compared only in southProfileFor, and a guard enforces it. The occulited client is a transport package; the daemon stays MIT as a network client of a documented API.
0072 openccu-lite pushes over occulited's event stream accepted One server-sent-event stream per central feeds the shared callback handlers; heartbeats stamp liveness, interface state drives connection loss, and a reconnect or resync reconciles devices and values. "Every interface pushes" replaces "every interface supports push callbacks".
0073 A backend-neutral taxonomy for rooms, functions and other enums accepted Rooms and functions are trees of nodes named by path; names stay names on every existing field, paths are additive, an ambiguous name is refused, areas stay name-keyed. The room/function create answer's id is corrected to the integer it always was (REST API 12.0.0).
0074 Per-central features and what "absent" means accepted Each central carries a feature set with a reason for every absent key (not supported, missing scope, not ready). REST answers 422 feature_unavailable, MQTT does not declare, the SPA hides with the reason, MCP names the central. Refusing ports wrap the legacy sentinels.
0075 Credentials for token-based systems accepted api_token is a sealed, masked secret; TLS is pinned to a compared fingerprint; client pairing keeps the approved token in the daemon, and a central takes it by pairing id — no client ever receives it.
0076 Client token pairing mirrors the occulited protocol accepted External clients obtain a token by commit-reveal code pairing: both sides derive six digits, the administrator approves by typing them, admin is never pairable, and the approved token reaches the asking client exactly once.
0077 The add-on token onboards the local openccu-lite box accepted On a box that minted this add-on an API token, a first boot with no centrals adopts the local system by itself: the token is read from the file per request (rotation-safe api_token_file), the hostname and interfaces come from the box, and pairing remains only for backup/power and remote clients.
0078 The box's web server serves the Config UI (lite ingress) accepted The add-on ships a validated lighttpd fragment: /addons/loom/ is proxied to the loopback with the prefix stripped, so TLS and the operator's certificate terminate at the box, the shell lists the UI, and port 8119 can stay closed. Certificate reuse on the direct port stays opt-in (server.pem as combined cert+key pair). CCU3/OpenCCU ingress is a non-goal: their /etc/config/lighttpd/*.conf include exists but is unvalidated and carries no session gate, unlike occulited's.
0079 Box-shell single sign-on over the lite ingress accepted Accept the gate's X-Occulite-Session as a daemon identity by live verification against the onboarded box (GET /api/auth/v1/state) — never by perimeter trust. Fail-closed role mapping (box admin→admin, user→operator), SSO resolver last in the first-wins chain, packaged-on for the lite add-on only. ReGa-session reuse on CCU3/OpenCCU is rejected: no stripping gate, no verification API; ADR 0043 covers CCU accounts.
0080 Box API tokens through the lite gate accepted A box API token the gate accepted for the add-on (addon:<id> scope, openccu-lite ≥ 1.0.0-dev.36) is a daemon identity by live verification against the box, scheme occulite-token. The daemon re-checks the add-on's own scope (id read from the box's token-file name); Full access → admin, add-on scope → operator. One pairing at the box replaces a stored box password.
0081 The daemon describes its deployment and its login paths accepted One resolved deployment kind (lite-addon, ccu-addon, ha-addon, standalone), declared by the packaging in OPENCCU_LOOM_DEPLOYMENT and refined on a lite box from host facts. Published as deployment on GET /info, with one auth.* capability token per login path; the mDNS TXT record carries the short form (deploy, ingress, auth, a true tls) for clients that cannot read /info before login. The HA Ingress passthrough defaults on only for ha-addon.
0082 Device onboarding is the daemon's own, on every system accepted delay_new_device_creation defaults on and is an ordinary setting: a newly-paired device waits to be accepted (then built) and released (then published to MQTT, Matter and webhooks), on a CCU, an OpenCCU and an openccu-lite box alike. Devices the daemon already knows are never held, so an upgrade publishes what it published before. The CCU inbox is an input, not the surface.
0083 One topic convention for all six projects: mqtt-smarthome 2.0 (accepted; amended 2026-10-06 with the implementation's findings) accepted openccu-loom and the five go-*2mqtt bridges move, in one release wave, to <name>/<function>/<item> with connected (0/1/2), status, set, info, meta and maintenance; every status item is {val, ts, lc} with enum tokens and JSON booleans. Built once in go-hamqtt; unique_id and discovery identities do not change (ADR 0068), old retained topics are swept on start. Supersedes the topic-schema half of ADR 0070's clean break and ADR 0011's topic hierarchy. The amendment records the exceptions found in the code: the empty-payload restart, the JSON-schema light kept in Home Assistant's shape, the sweep's filters scoped to the old trees.
0084 Matter: the profiles move onto go-fabric's device layer, the bring-up onto its node facade (accepted 2026-10-12) accepted Three phases, each live-verified against Apple Home: (1) the assembler constructs every endpoint through device.New<Type> with the profiles' servers as WithServer, device-type validation becomes a guard, bridged NodeLabels persist, wire shape unchanged; (2) the five hand-written OnOff servers become cluster/onoff.Server with a CCU-writing Sink, the timed-off engine and the LT atomics are deleted; (3) startMatterBridge becomes node.New once go-fabric's facade exposes the host hooks this daemon needs (fabric and commissioning observers, per-window PASE credentials, TimeSync opt-in); the facade's window semantics are adopted then. WindowCovering and Thermostat stay hand-written.
0085 Apache License 2.0 and a contributor licence agreement (accepted 2026-10-15) accepted Supersedes ADR 0001 and revisits ADR 0066: the source moves to Apache-2.0 from the next release — the licence of matter.js, connectedhomeip and go-fabric, with its patent grant, for the Matter-side code this daemon carries — and contributions come under CLA.md so the project can be licensed as a whole later; 0.91.0 and earlier stay MIT, the DCO stays, TestLicenseHeaderOnEveryGoFile guards the header.
0086 The Matter pages and their API come from go-fabric-ui (accepted 2026-10-25) accepted The Matter view's six pages are @sukramj/fabric-ui's node page family and the routes are fabricui.Mount under /api/v1/node (REST 14.0.0, the /api/v1/matter/* routes removed without aliases); the module's contract is copied into openapi.yaml and held to the pinned version by a guard. loom keeps the exposure provider (which CCU channels are exposable, how they are named), feeds the node's events onto the WebSocket hub as node.* (wsapi 2.0, matter.* removed) instead of opening the handler's SSE stream, and keeps its own MCP / health status seam.
0087 The catalogue declares the shape, the CCU confirms the channels accepted A device profile keeps naming candidate channels and also declares which channel types each role accepts; the materializer binds a channel only if the device has it and its type is accepted, and a guard runs the catalogue against every fixture. The catalogue stays hand-maintained (ADR 0063).
  • Architecture — how these decisions show up in the package layout.
  • Matter parity contract — the host-side boundary. The projection decisions that stayed here are 0012 (cluster subset), 0021 (the daemon's own mDNS), 0049 (one endpoint per device) and 0064 (garage as Closure); the wire-side rules live in go-fabric.