homegraph

package
v0.57.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 10, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

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

View Source
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).

View Source
const (
	StatusApplied  = "applied"
	StatusObserved = "observed"
)

Sync statuses.

View Source
const (
	LeadingKombify       = "kombify"
	LeadingHomeAssistant = "home_assistant"
)

Leading systems.

View Source
const (
	SystemHomeAssistant = "home_assistant"
	SystemLoxone        = "loxone"
)

External systems.

View Source
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.

View Source
const (
	RoleAudioIn  = "audio_in"
	RoleAudioOut = "audio_out"
	RoleAnnounce = "announce"
	RoleDisplay  = "display"
	RoleCompute  = "compute"
	RoleControl  = "control"
)

Device roles.

View Source
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.

View Source
const LabelName = "kombify"

LabelName is the Home Assistant label kombify puts on the areas it creates.

View Source
const MappingSchema = "kombify.home_graph.mapping.v1"

MappingSchema identifies the stored mapping document.

View Source
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

func ApplyPlan(ctx context.Context, c Registry, plan Plan) (m Mapping, skipped []Drift, err error)

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

func FetchLoxoneStructure(ctx context.Context, baseURL, user, pass string) ([]byte, error)

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 NewID

func NewID(prefix string) string

NewID returns a fresh id with the given prefix ("flr_", "rom_", "hdv_", "asg_").

func Resolve

func Resolve(plan AdoptionPlan, decisions map[string]Side) (Graph, Plan, error)

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

func NewClient(ctx context.Context, httpClient *http.Client, origin, token string) (*Client, error)

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) Call

func (c *Client) Call(ctx context.Context, typ string, params map[string]any, out any) error

Call sends one command and decodes its result into out (nil to discard).

func (*Client) Close

func (c *Client) Close() error

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

func (c *Client) CreateFloor(ctx context.Context, name string, level int) (HAFloor, error)

CreateFloor creates a floor.

func (*Client) CreateLabel

func (c *Client) CreateLabel(ctx context.Context, name string) (HALabel, error)

CreateLabel creates a label.

func (*Client) SetDeviceArea

func (c *Client) SetDeviceArea(ctx context.Context, deviceID, areaID string) error

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

func (c *Client) UpdateArea(ctx context.Context, id string, u AreaUpdate) error

UpdateArea updates an area.

func (*Client) UpdateFloor

func (c *Client) UpdateFloor(ctx context.Context, id string, name *string, level *int) error

UpdateFloor renames a floor or changes its level; nil fields stay untouched.

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.

func (Conflict) Subject2

func (c Conflict) Subject2() string

Subject2 is the key prefix the planner uses for the subject's synced state.

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

type ExternalRef struct {
	System string `json:"system"`
	Ref    string `json:"ref"`
}

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.

func (Graph) Validate

func (g Graph) Validate() error

Validate enforces the enums, id patterns and referential integrity of the home-graph.v1 schema. It reports every problem it finds.

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

func (d HADevice) DisplayName() string

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 HALabel

type HALabel struct {
	LabelID string `json:"label_id"`
	Name    string `json:"name"`
}

HALabel is a Home Assistant label registry entry.

type HASnapshot

type HASnapshot struct {
	Floors  []HAFloor
	Areas   []HAArea
	Labels  []HALabel
	Devices []HADevice
}

HASnapshot is one read of the four registries.

type Identity

type Identity struct {
	Kind string `json:"kind"`
	Ref  string `json:"ref"`
}

Identity references the registry that owns a device's keys.

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

type LoxoneRoom struct {
	Name string
	Ref  string // Loxone room uuid
}

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

func ParseMapping(b []byte) (Mapping, error)

ParseMapping decodes a stored mapping document.

func (Mapping) Clone

func (m Mapping) Clone() Mapping

Clone returns a deep copy.

func (Mapping) Marshal

func (m Mapping) Marshal() ([]byte, error)

Marshal encodes the 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 Match

type Match struct {
	Kind      string // floor, room, device
	KombifyID string
	HAID      string
	Name      string
}

Match pairs one kombify object with the Home Assistant object it equals.

type Only

type Only struct {
	Kind string
	ID   string
	Name string
}

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.

const (
	OpEnsureLabel   OpKind = "ensure_label"
	OpCreateFloor   OpKind = "create_floor"
	OpUpdateFloor   OpKind = "update_floor"
	OpCreateArea    OpKind = "create_area"
	OpUpdateArea    OpKind = "update_area"
	OpSetDeviceArea OpKind = "set_device_area"
)

Operation kinds, in the order a plan lists them.

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.

func (Plan) Empty

func (p Plan) Empty() bool

Empty reports whether the plan writes nothing.

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 Side

type Side string

Side names the system whose value a decision keeps.

const (
	SideKombify Side = "kombify"
	SideHA      Side = "home_assistant"
)

Sides.

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 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL