payload.Source — Author Guide¶
Who this page is for
Contributors extending the model layer. It is internal developer documentation — end users and administrators do not need it. For the big picture see Architecture.
The payload.Source interface is the single contract every domain object implements so that the north-bound adapters (REST, WebSocket, MQTT, Matter) can project it uniformly. This guide walks a contributor through making a new model type satisfy that contract.
This guide is for contributors adding a new domain object to the OpenCCU-Loom model layer. It explains how to make that object satisfy the universal payload.Source contract introduced in ADR 0007.
The contract is short:
type Source interface {
Info() InfoPayload
Config() ConfigPayload
State() StatePayload
ServiceMethodNames() []string
Invoke(ctx context.Context, name string, params map[string]any, priority hmenum.CommandPriority) error
}
Every type listed in tests/contract/source_completeness_test.go must satisfy payload.Source — adding a new type without doing so fails that test at compile time.
Decision tree¶
When you add a new model type, walk this tree:
- Does the type embed an existing
Source-bearing type by pointer? (e.g.*generic.Switch,*generic.Float,*Cover,*Sysvar) - Yes →
Sourceis inherited by method promotion. Done. Verify with the contract test. -
No → continue.
-
Does your type have its own service methods that should be reachable from the bridge / REST?
- Yes → embed
payload.ServiceRegistryas an anonymous field. Register handlers in the constructor: -
No → still embed
payload.ServiceRegistry. Its zero value gives you a no-opServiceMethodNames(returns nil) andInvoke(returnspayload.ErrUnknownServiceMethod) for free. -
Watch out for the embed-shadow gotcha. If your type already embeds
*generic.Float(or any other type that itself embedspayload.ServiceRegistry), and you add apayload.ServiceRegistryfield on the outer type to register your own service methods, the outer registry shadows the inner one. That is the right behaviour — your custom-DP'sset_positionand the underlying*generic.Float'sset_valueshould not collide on the same registry. SeeCoverfor the canonical example (internal/model/custom/cover/cover.go). -
Implement the three read methods in a separate file
payload.gonext to your type, each returning a typed payload struct:
// internal/model/custom/foo/payload.go
package foo
import "github.com/SukramJ/openccu-loom/internal/payload"
var _ payload.Source = (*Foo)(nil)
func (f *Foo) State() payload.StatePayload {
if f == nil {
return nil
}
return &payload.FooState{ /* fields with json tags */ }
}
func (f *Foo) Info() payload.InfoPayload { ... }
func (f *Foo) Config() payload.ConfigPayload { ... }
InfoPayload, ConfigPayload and StatePayload are marker types; the concrete payloads are domain structs in internal/payload (state.go, config.go, …) whose json tags are the wire keys. internal/model/custom/lock/payload.go (Lock.State returning *payload.LockState) is a compact reference.
-
Use HA-friendly key names. State fields should match what HA's MQTT platform expects (
hvac_mode,current_temperature,current_position,lock_state, …). The MQTT bridge publishes the aggregate as thevalof an mqtt-smarthome status object (ADR 0083) and points HA at it withvalue_template: "{{ value_json.val.<field> }}"— your key names show up verbatim in HA templates. -
Decide per field whether "unknown" is absent or a default. An optional field that may be unknown is a pointer with
omitempty, so it is absent from the JSON rather than a misleading zero. Where Home Assistant templates would warn on a missing key, publish a documented default instead (the lock reportsUNLOCKEDbefore its first event). -
Service-method handlers are simple functions. Decode
paramswith the helpers ininternal/payload/params.go: payload.ParamBoolpayload.ParamFloat64payload.ParamInt32payload.ParamString
Each returns a wrapped payload.ErrServiceMissingParam / payload.ErrServiceInvalidParam on bad input. Your handler returns that error verbatim — the bridge logs it.
- Add the type to the contract test. Append a line to
tests/contract/source_completeness_test.go:
This is the tripwire that prevents future drift.
HA-Discovery: implement HADiscoveryEntityBuilder¶
Custom-DP types that should surface as a Home Assistant MQTT-Discovery entity implement payload.HADiscoveryEntityBuilder (ADR 0010, internal/payload/discovery_entity.go):
The entity describes itself on the shared Home Assistant model (hamodel, from the go-hamqtt module): a description, the bindings and the platform's own vocabulary. It carries no topics — it names hamodel.Slot coordinates, and the bridge renders them through its own topic layout, so the topic schema stays the bridge's business. The bridge also renders the frame (unique_id, availability, device, origin, name) from the model, because Home Assistant drops undeclared keys silently and keys its registries on unique_id and the device identifiers. Return a literal nil to decline (a data point with no Home Assistant surface); a typed nil pointer is not nil to the bridge.
HADiscoveryEntityBuilder replaces the older HADiscoveryComponentBuilder on the channel-aggregate plane. References: internal/model/custom/climate/payload.go and internal/model/custom/valve/payload.go.
What you do not need to do¶
- No bridge edits. The bridge is a routing shell since ADR 0008 step B + ADR 0010. New custom-DP types do not require touching
internal/north/mqtt/discovery_aggregate.goor any other file underinternal/north/mqtt/. - No REST handler edits for the basic invoke path — the generic
cdps/<dp>/<op>/invokeroute dispatches throughSource.Invoke. - No HA-Discovery template edits for the read side — state is consumed via
value_json.val.<field>templates that already match the StatePayload key set you choose. - No service-method-command-topic wiring. The CommandSubscriber subscribes a wildcard (
{base}/set/+/+/+/+/custom/+/+) and dispatches by method name.Source.Invokedoes the rest.
References¶
- ADR 0007 — Strong Model:
SourceInterface for Read + Write - ADR 0008 — AggregatedState Default-Flip and Legacy-Path Removal
internal/payload/source.go— interface definitioninternal/payload/registry.go—ServiceRegistryhelperinternal/payload/params.go— service-method param decoderstests/contract/source_completeness_test.go— completeness tripwire- aiohomematic reference:
aiohomematic/property_decorators.pyandaiohomematic/support/mixins.py(PayloadMixin) for the Python origin of the pattern.