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 {
InfoPayload() map[string]any
ConfigPayload() map[string]any
StatePayload() map[string]any
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. Two recommended patterns:
### Pattern A — explicit map literals (custom DPs, hub DPs)
// internal/model/custom/foo/payload.go
package foo
import "github.com/SukramJ/openccu-loom/internal/payload"
var _ payload.Source = (*Foo)(nil)
func (f *Foo) InfoPayload() map[string]any {
if f == nil { return nil }
return map[string]any{
"address": f.Address,
"kind": string(f.Kind),
}
}
func (f *Foo) ConfigPayload() map[string]any { ... }
func (f *Foo) StatePayload() map[string]any { ... }
Use this for types with conditional or computed fields.
### Pattern B — struct-tag reflection (generic DPs, simple records)
Tag struct fields with payload:"<kind>" (or payload:"<kind>,alt=<name>") and let payload.For(...) build the map. See internal/payload/payload.go for tag syntax. The generic-DP layer in internal/model/generic/payload.go mixes this pattern with computed fields by overlaying both in the method body.
-
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 points HA at the aggregated state topic withvalue_template: "{{ value_json.<field> }}"— your key names show up verbatim in HA templates. -
Filter typed-nil values from your maps. Empty optional fields should be absent from the map rather than present with zero values. Mirrors aiohomematic's
if value is not Nonefilter and keeps the JSON tight. -
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 HADiscoveryPayloadBuilder¶
Custom-DP types that should surface as a HA-MQTT-Discovery entity also implement ADR 0010's HADiscoveryPayloadBuilder interface:
The returned body carries the HA-platform-specific keys (min_temp, mode_state_template, preset_modes, …); the bridge attaches name, unique_id, object_id, availability, device, origin on top.
Use the ctx helpers to assemble topic references:
ctx.AggregatedStateTopic()— the channel's aggregated state topic. Read references (*_state_topic) point here withvalue_template: "{{ value_json.<field> }}".ctx.ServiceMethodCommandTopic(method)— write references (*_command_topic) point at the per-service-method topic. The CommandSubscriber dispatches intoSource.Invoke(method, …).ctx.WireParameterCommandTopic(parameter)— fallback for HA-platform multiplex topics where onecommand_topicaccepts multiplepayload_*strings (Cover open/close/stop, Lock lock/unlock, Siren on/off). Use sparingly.ctx.WireParameterStateTopic(parameter)— same fallback for state.
Pattern (Climate as reference; see internal/model/custom/climate/payload.go):
var _ payload.HADiscoveryPayloadBuilder = (*Foo)(nil)
func (f *Foo) HADiscoveryPayload(ctx payload.HADiscoveryContext) (string, map[string]any) {
body := map[string]any{
"min_temp": f.MinTemp(),
"max_temp": f.MaxTemp(),
"mode_state_topic": ctx.AggregatedStateTopic(),
"mode_state_template": "{{ value_json.hvac_mode }}",
"mode_command_topic": ctx.ServiceMethodCommandTopic("set_mode"),
}
return "climate", body
}
The contract test tests/contract/source_completeness_test.go enforces every Custom-DP type implements Source; tests/contract/source_no_dual_source_test.go enforces the no-dual-state-source rule. Add your new type to both.
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.<field>templates that already match the StatePayload key set you choose. - No service-method-command-topic wiring. The CommandSubscriber subscribes a wildcard (
{base}/+/+/+/+/svc/+/set) 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.