Documentation
¶
Overview ¶
Package homegraph is the StackKits node side of the household home graph: it holds the Go types of the wire contract kombify.home_graph.v1 (Cloud serves it at GET /api/v1/household/{space}/home), synchronizes Home Assistant (floors, areas, labels, device placement) with it over the Home Assistant WebSocket registry API, plans the adoption of an existing Home Assistant installation, and imports Loxone rooms and controls read-only from the Miniserver structure file.
Target: HOME-GRAPH-TARGET.md in the kombify workspace, sections 3 to 8. Owner decision: D-2026-11-09-03.
Home Assistant access tokens and Loxone credentials stay on the node. No type in this package serializes one, no error carries one and nothing logs one. kombify deletes nothing in Home Assistant: plans contain no delete operation, and removal of any object needs the person's confirmation outside this package. Nothing here writes to a Loxone Miniserver.
Index ¶
- Constants
- func ApplyPlan(ctx context.Context, c Registry, plan Plan) (m Mapping, skipped []Drift, err error)
- func FetchLoxoneStructure(ctx context.Context, baseURL, user, pass string) ([]byte, error)
- func FetchLoxoneStructureWith(ctx context.Context, client *http.Client, baseURL, user, pass string) ([]byte, error)
- func NewID(prefix string) string
- func Resolve(plan AdoptionPlan, decisions map[string]Side) (Graph, Plan, error)
- func Sync(ctx context.Context, reg Registry, req SyncRequest, mapping Mapping, ...) (SyncResult, Mapping, error)
- type AdoptionPlan
- type AreaUpdate
- type Assignment
- type Client
- func (c *Client) Call(ctx context.Context, typ string, params map[string]any, out any) error
- func (c *Client) Close() error
- func (c *Client) CreateArea(ctx context.Context, name string, floorID *string, labels []string) (HAArea, error)
- func (c *Client) CreateFloor(ctx context.Context, name string, level int) (HAFloor, error)
- func (c *Client) CreateLabel(ctx context.Context, name string) (HALabel, error)
- func (c *Client) SetDeviceArea(ctx context.Context, deviceID, areaID string) error
- func (c *Client) Snapshot(ctx context.Context) (HASnapshot, error)
- func (c *Client) UpdateArea(ctx context.Context, id string, u AreaUpdate) error
- func (c *Client) UpdateFloor(ctx context.Context, id string, name *string, level *int) error
- type Conflict
- type ConflictKind
- type Device
- type Drift
- type ExternalRef
- type Floor
- type Graph
- type HAArea
- type HADevice
- type HAFloor
- type HALabel
- type HASnapshot
- type Identity
- type InventoryArea
- type InventoryFloor
- type LoxoneDevice
- type LoxoneImport
- type LoxoneRoom
- type Mapping
- type MappingStore
- type Match
- type Only
- type Op
- type OpKind
- type Plan
- type Registry
- type Restore
- type Room
- type Side
- type SyncDrift
- type SyncInventory
- type SyncLinks
- type SyncRequest
- type SyncResult
Constants ¶
const ( SyncRequestSchema = "stackkit.home-graph-sync-request/v1" SyncResultSchema = "stackkit.home-graph-sync-result/v1" // MaxSyncDocumentBytes bounds the request and the result document. MaxSyncDocumentBytes = 512 << 10 )
Wire schema ids of the node sync (StackKits operation application.home-graph.sync).
const ( StatusApplied = "applied" StatusObserved = "observed" )
Sync statuses.
const ( LeadingKombify = "kombify" LeadingHomeAssistant = "home_assistant" )
Leading systems.
const ( SystemHomeAssistant = "home_assistant" SystemLoxone = "loxone" )
External systems.
const ( ClassServer = "server" ClassDesktop = "desktop" ClassPhone = "phone" ClassTablet = "tablet" ClassWearable = "wearable" ClassVehicle = "vehicle" ClassVoice = "voice_device" ClassSpeaker = "speaker" ClassIntercom = "intercom" ClassDisplay = "display" ClassCamera = "camera" ClassLock = "lock" ClassAlarm = "alarm" ClassSensor = "sensor" ClassActuator = "actuator" ClassOther = "other" )
Device classes.
const ( RoleAudioIn = "audio_in" RoleAudioOut = "audio_out" RoleAnnounce = "announce" RoleDisplay = "display" RoleCompute = "compute" RoleControl = "control" )
Device roles.
const ( IdentityEdge = "edge_device" IdentityConnect = "connect_installation" IdentityNode = "techstack_node" IdentityExt = "external" StatusActive = "active" StatusRevoked = "revoked" AssignPerson = "person" AssignAnswersOn = "answers_on" AssignPartOf = "part_of" )
Identity kinds, device statuses and assignment kinds.
const LabelName = "kombify"
LabelName is the Home Assistant label kombify puts on the areas it creates.
const MappingSchema = "kombify.home_graph.mapping.v1"
MappingSchema identifies the stored mapping document.
const SchemaID = "kombify.home_graph.v1"
SchemaID is the const of the "schema" property of the home graph document.
Variables ¶
This section is empty.
Functions ¶
func ApplyPlan ¶
ApplyPlan executes the ops against Home Assistant and returns the updated mapping. It is idempotent: an object the mapping already links to an existing Home Assistant object is not created again, and an update repeats harmlessly. On an error it returns the mapping as far as it got, so the caller persists it and the next run resumes. An op whose parent floor, area or room is not in Home Assistant (it was skipped or its creation failed earlier) is not executed; it is returned in skipped as drift with the field "parent_skipped" and never aborts the remaining ops.
func FetchLoxoneStructure ¶
FetchLoxoneStructure reads /data/LoxAPP3.json from the Miniserver at baseURL (https only) with basic auth. It only reads. The credentials are never logged and never part of an error.
func FetchLoxoneStructureWith ¶
func FetchLoxoneStructureWith(ctx context.Context, client *http.Client, baseURL, user, pass string) ([]byte, error)
FetchLoxoneStructureWith is FetchLoxoneStructure with a caller-supplied HTTP client, for example one that pins the Miniserver's self-signed certificate. A nil client uses a default one with a 30 s timeout. Redirects are refused.
func Resolve ¶
Resolve applies the person's decisions (conflict id to side) and returns the merged graph together with the projection that writes the result to Home Assistant. A conflict without a decision follows plan.Leading.
Semantics: matched objects are linked in the mapping and get a Home Assistant external_ref; objects only in Home Assistant are imported into the graph (devices as external, class other, role control); objects only in kombify are projected. For room/device placement conflicts the chosen side's value wins. A name_collision resolved for Home Assistant links the kombify object to the first candidate; resolved for kombify it leaves both unlinked and the projection reports the collision as drift. A deletion in Home Assistant resolved for Home Assistant removes the kombify object, resolved for kombify it is recreated; a deletion in kombify resolved for Home Assistant re-imports the object, resolved for kombify leaves the Home Assistant object alone (the plan never deletes there). When Home Assistant leads, a matched object's name is taken from Home Assistant; when kombify leads, a differing Home Assistant name is renamed by the projection.
The merged graph is validated; a validation error is returned together with the graph and an empty plan, and nothing should be written.
func Sync ¶
func Sync(ctx context.Context, reg Registry, req SyncRequest, mapping Mapping, now time.Time) (SyncResult, Mapping, error)
Sync runs one synchronization. Leading kombify: plan (with the restore items), apply and return the updated mapping. Leading Home Assistant: read the registries only and return the mapping unchanged. Nothing is ever deleted in Home Assistant. On an apply error the returned mapping holds the progress made so the caller can persist it; the result is then zero.
Types ¶
type AdoptionPlan ¶
type AdoptionPlan struct {
Graph Graph
Snapshot HASnapshot
Mapping Mapping
FirstRun bool
// Leading is the leading system the person chose; it starts as the
// graph's setting (kombify by default) and decides every conflict without
// an explicit decision.
Leading string
Matched []Match
OnlyKombify []Only
OnlyHA []Only
Conflicts []Conflict
}
AdoptionPlan is the inventory, match and conflict list of an adoption run. Nothing is written until the person confirms the plan and Resolve produces the projection.
func PlanAdoption ¶
func PlanAdoption(g Graph, snap HASnapshot, m Mapping, firstRun bool) AdoptionPlan
PlanAdoption inventories and matches kombify's graph and a Home Assistant snapshot (HOME-GRAPH-TARGET.md section 8). Floors and rooms match by mapping, external reference, then normalized name and alias; devices match by the Home Assistant device id in their external_refs. It writes nothing.
type AreaUpdate ¶
type AreaUpdate struct {
Name *string
FloorID *string
ClearFloor bool
Labels []string
SetLabels bool
}
AreaUpdate lists the fields of an area update; nil fields stay untouched. ClearFloor sets floor_id to null.
type Assignment ¶
type Assignment struct {
ID string `json:"id"`
Kind string `json:"kind"`
DeviceID string `json:"device_id"`
Subject string `json:"subject,omitempty"`
TargetDeviceID string `json:"target_device_id,omitempty"`
}
Assignment is a typed relation of a device.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a Home Assistant WebSocket API client. It serializes calls; it is safe for concurrent use. The access token lives only inside the client.
func NewClient ¶
NewClient connects to the Home Assistant WebSocket API at origin (the http:// or https:// base URL of the instance) over httpClient and authenticates with the access token. The destination is decided by httpClient: on a node it is the confined Home Assistant transport, so no further address rule applies here. Only the scheme and host are checked.
func (*Client) Close ¶
Close closes the connection. It does not wait for an in-flight call, which fails instead.
func (*Client) CreateArea ¶
func (c *Client) CreateArea(ctx context.Context, name string, floorID *string, labels []string) (HAArea, error)
CreateArea creates an area.
func (*Client) CreateFloor ¶
CreateFloor creates a floor.
func (*Client) CreateLabel ¶
CreateLabel creates a label.
func (*Client) SetDeviceArea ¶
SetDeviceArea places a device in an area.
func (*Client) Snapshot ¶
func (c *Client) Snapshot(ctx context.Context) (HASnapshot, error)
Snapshot reads the floor, area, label and device registries.
func (*Client) UpdateArea ¶
UpdateArea updates an area.
type Conflict ¶
type Conflict struct {
ID string
Kind ConflictKind
Subject string // floor, room, device
KombifyID string
HAID string
Name string
// Kombify and HA are the contradicting values as the person reads them
// (floor name, room name, or the object's name for the other kinds).
Kombify string
HA string
// Candidates are the Home Assistant ids a name_collision is ambiguous
// between.
Candidates []string
// DeletedOn is the side that no longer has the object
// (deleted_on_one_side only).
DeletedOn Side
}
Conflict carries both sides' values so a UI can let the person choose.
type ConflictKind ¶
type ConflictKind string
ConflictKind names a contradiction between kombify and Home Assistant.
const ( ConflictRoomFloorMismatch ConflictKind = "room_floor_mismatch" ConflictDeviceRoomMismatch ConflictKind = "device_room_mismatch" ConflictNameCollision ConflictKind = "name_collision" ConflictDeletedOnOneSide ConflictKind = "deleted_on_one_side" )
Conflict kinds (HOME-GRAPH-TARGET.md section 8).
type Device ¶
type Device struct {
ID string `json:"id"`
Name string `json:"name"`
Class string `json:"class"`
Roles []string `json:"roles"`
RoomID *string `json:"room_id"`
Identity Identity `json:"identity"`
ExternalRefs []ExternalRef `json:"external_refs"`
Status string `json:"status"`
}
Device is one physical device.
type Drift ¶
type Drift struct {
Kind string // floor, area, device
Field string // name, level, floor, area, deleted, name_collision
KombifyID string
HAID string
Graph string
HA string
}
Drift is a difference between the graph and a mapped Home Assistant object that the plan does not overwrite: Home Assistant changed since the last sync, the object vanished, or a name collides with an unmapped object.
type ExternalRef ¶
ExternalRef is the identity of the same object in Home Assistant or Loxone.
type Floor ¶
type Floor struct {
ID string `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
ExternalRefs []ExternalRef `json:"external_refs"`
}
Floor is a level of the home.
type Graph ¶
type Graph struct {
Schema string `json:"schema"`
SpaceID string `json:"space_id"`
Revision int64 `json:"revision"`
LeadingSystem string `json:"leading_system"`
MirrorClasses []string `json:"mirror_classes"`
Floors []Floor `json:"floors"`
Rooms []Room `json:"rooms"`
Devices []Device `json:"devices"`
Assignments []Assignment `json:"assignments"`
}
Graph is the home graph document.
func MergeLoxone ¶
func MergeLoxone(g Graph, imp LoxoneImport) Graph
MergeLoxone adds the rooms and devices of imp that the graph does not hold yet (matched by the loxone external_ref) and returns the new graph. Floors stay as set in kombify; existing objects are left untouched.
type HAArea ¶
type HAArea struct {
AreaID string `json:"area_id"`
Name string `json:"name"`
FloorID *string `json:"floor_id"`
Labels []string `json:"labels"`
Aliases []string `json:"aliases"`
}
HAArea is a Home Assistant area registry entry.
type HADevice ¶
type HADevice struct {
ID string `json:"id"`
Name *string `json:"name"`
NameByUser *string `json:"name_by_user"`
AreaID *string `json:"area_id"`
Labels []string `json:"labels"`
Manufacturer *string `json:"manufacturer"`
Model *string `json:"model"`
}
HADevice is a Home Assistant device registry entry.
func (HADevice) DisplayName ¶
DisplayName is the name the person sees in Home Assistant.
type HAFloor ¶
type HAFloor struct {
FloorID string `json:"floor_id"`
Name string `json:"name"`
Level *int `json:"level"`
Aliases []string `json:"aliases"`
Icon *string `json:"icon"`
}
HAFloor is a Home Assistant floor registry entry.
type HASnapshot ¶
HASnapshot is one read of the four registries.
type InventoryArea ¶
type InventoryArea struct {
HAID string `json:"haId"`
Name string `json:"name"`
FloorHAID string `json:"floorHaId,omitempty"`
}
InventoryArea is one Home Assistant area.
type InventoryFloor ¶
type InventoryFloor struct {
HAID string `json:"haId"`
Name string `json:"name"`
Level *int `json:"level"`
}
InventoryFloor and InventoryArea are the bounded Home Assistant inventory.
type LoxoneDevice ¶
type LoxoneDevice struct {
Name string
Class string
Roles []string
Ref string // Loxone control uuid
RoomRef string // Loxone room uuid, empty when the control has none
Type string // Loxone control type
Category string // Loxone category name
}
LoxoneDevice is a control of the Miniserver structure file, mapped to an external device. Loxone control is not part of the first delivery, so the role is always control.
type LoxoneImport ¶
type LoxoneImport struct {
Rooms []LoxoneRoom
Devices []LoxoneDevice
}
LoxoneImport is the read-only result of parsing LoxAPP3.json. Loxone has no floors.
func ParseLoxoneStructure ¶
func ParseLoxoneStructure(data []byte) (LoxoneImport, error)
ParseLoxoneStructure reads the bytes of LoxAPP3.json. The result is sorted by name and uuid so repeated imports compare equal.
type LoxoneRoom ¶
LoxoneRoom is a room of the Miniserver structure file.
type Mapping ¶
type Mapping struct {
Schema string `json:"schema"`
// LabelID is the Home Assistant id of the label "kombify".
LabelID string `json:"label_id,omitempty"`
// Floors, Areas and Devices map a kombify floor, room and device id to the
// Home Assistant floor_id, area_id and device registry id.
Floors map[string]string `json:"floors"`
Areas map[string]string `json:"areas"`
Devices map[string]string `json:"devices"`
// Created lists the Home Assistant objects kombify created itself
// ("floor:<id>", "area:<id>"). Only these may ever be removed, and only with
// the person's confirmation.
Created map[string]bool `json:"created"`
// Synced holds the value each synchronized field had after the last
// successful write or comparison; it tells a change made in kombify from a
// change made in Home Assistant.
Synced map[string]string `json:"synced"`
}
Mapping ties kombify ids to Home Assistant ids. The node agent persists it; it holds no secret.
func ParseMapping ¶
ParseMapping decodes a stored mapping document.
type MappingStore ¶
type MappingStore struct{ Path string }
MappingStore persists a Mapping as a JSON file (mode 0600, atomic write).
func (MappingStore) Load ¶
func (s MappingStore) Load() (Mapping, error)
Load reads the mapping; a missing file yields an empty mapping.
func (MappingStore) Save ¶
func (s MappingStore) Save(m Mapping) error
Save writes the mapping atomically: a temporary file in the same directory, synced, then renamed over the target.
type Only ¶
Only is an object that exists on one side only; ID is a kombify id for OnlyKombify and a Home Assistant id for OnlyHA.
type Op ¶
type Op struct {
Kind OpKind
KombifyID string
// HAID is the Home Assistant object the op changes, empty for creates.
HAID string
Name string
// Level is the floor level of a floor op.
Level int
// ParentKombifyID is the floor of an area op ("" for none) or the room of
// a device op.
ParentKombifyID string
// Fields lists the changed fields of an update op: name, level, floor.
Fields []string
}
Op is one planned write. Home Assistant ids are resolved from the mapping at apply time, so an op may reference an object an earlier op creates.
type OpKind ¶
type OpKind string
OpKind names one write against Home Assistant. There is no delete kind: kombify removes nothing in Home Assistant on its own.
type Plan ¶
type Plan struct {
Ops []Op
Drift []Drift
// Mapping is the mapping the plan starts from, with links found while
// planning. ApplyPlan starts from it.
Mapping Mapping
}
Plan is the set of writes that projects the graph onto Home Assistant.
func PlanProjection ¶
func PlanProjection(g Graph, snap HASnapshot, m Mapping) Plan
PlanProjection plans the writes that project g onto Home Assistant when kombify leads. When Home Assistant leads, nothing is written from here and the plan is empty. It never plans a delete.
func PlanProjectionRestore ¶
func PlanProjectionRestore(g Graph, snap HASnapshot, m Mapping, restore []Restore) Plan
PlanProjectionRestore is PlanProjection where the fields in restore are overwritten in Home Assistant with the kombify value even when Home Assistant changed them since the last sync. Every other drift stays untouched and is reported.
type Registry ¶
type Registry interface {
Snapshot(ctx context.Context) (HASnapshot, error)
CreateFloor(ctx context.Context, name string, level int) (HAFloor, error)
UpdateFloor(ctx context.Context, id string, name *string, level *int) error
CreateArea(ctx context.Context, name string, floorID *string, labels []string) (HAArea, error)
UpdateArea(ctx context.Context, id string, set AreaUpdate) error
CreateLabel(ctx context.Context, name string) (HALabel, error)
SetDeviceArea(ctx context.Context, deviceID, areaID string) error
}
Registry is the Home Assistant registry surface the sync needs; *Client implements it.
type Restore ¶
type Restore struct {
Kind string `json:"kind"`
KombifyID string `json:"kombifyId"`
Field string `json:"field"`
}
Restore names one drifted field whose kombify value overwrites Home Assistant in this run instead of being reported as drift. Kind is floor, area or device; Field is name, level, floor or area. It never creates or deletes.
type Room ¶
type Room struct {
ID string `json:"id"`
Name string `json:"name"`
FloorID *string `json:"floor_id"`
Icon string `json:"icon,omitempty"`
Aliases []string `json:"aliases"`
ExternalRefs []ExternalRef `json:"external_refs"`
}
Room maps to a Home Assistant area and a Loxone room.
type SyncDrift ¶
type SyncDrift struct {
Kind string `json:"kind"`
Field string `json:"field"`
KombifyID string `json:"kombifyId"`
HAID string `json:"haId"`
Graph string `json:"graph"`
HA string `json:"ha"`
}
SyncDrift is one reported difference.
type SyncInventory ¶
type SyncInventory struct {
Floors []InventoryFloor `json:"floors"`
Areas []InventoryArea `json:"areas"`
UnmappedDevices int `json:"unmappedDevices"`
}
SyncInventory lists floors and areas; devices are only counted.
type SyncLinks ¶
type SyncLinks struct {
Floors map[string]string `json:"floors"`
Areas map[string]string `json:"areas"`
Devices map[string]string `json:"devices"`
}
SyncLinks maps kombify ids to Home Assistant ids.
type SyncRequest ¶
type SyncRequest struct {
SchemaVersion string `json:"schemaVersion"`
Graph Graph `json:"graph"`
Restore []Restore `json:"restore"`
}
SyncRequest is the document Cloud hands to a node through Techstack.
func (SyncRequest) Validate ¶
func (r SyncRequest) Validate() error
Validate checks the schema id, the graph and the restore list.
type SyncResult ¶
type SyncResult struct {
SchemaVersion string `json:"schemaVersion"`
Revision int64 `json:"revision"`
LeadingSystem string `json:"leadingSystem"`
Status string `json:"status"`
Operations int `json:"operations"`
Links SyncLinks `json:"links"`
Drift []SyncDrift `json:"drift"`
Inventory SyncInventory `json:"inventory"`
SyncedAt string `json:"syncedAt"`
}
SyncResult is the document the node returns.