// Package smarthome talks to a Home Assistant instance so Maven can read what // the house is doing and change it (Vikunja #256, // docs/plans/11-smarthome-integration.md). // // The shape of this package is copied deliberately from internal/mcp: a // controllable entity becomes a PROPOSED row in the existing act allowlist, // encoded in the columns that already exist — cmd // ["smarthome", "", ""], scope "smarthome:". So // ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn need no // change, and turning a light off in his flat goes through exactly the same // gate as `restart nginx`. // // Two rules that are not negotiable here: // // - Discovery only ever PROPOSES. Finding a switch on the network is not the // same as being allowed to flip it; Kami enables it on /tools, behind // step-up. // - Every control row is destructive=true. There is no read-only way to turn // the heating off. That means a spoken act always gets the confirm turn, // which is the point. // // MQTT / Zigbee2MQTT (steps 2 and 5 of the plan) are NOT here: they need a // broker client dependency and the module cache in this repo is vendored, and // there is no broker on this network to test one against. Home Assistant's REST // API is stdlib-only and already fronts Zigbee2MQTT when it is present. package smarthome import ( "errors" "strings" ) var ( // ErrNotConfigured — no smarthome block, or it is disabled. ErrNotConfigured = errors.New("smarthome: not configured") // ErrUnknownEntity — the entity vanished between discovery and the call. ErrUnknownEntity = errors.New("smarthome: unknown entity") // ErrNotControllable — the entity's domain has no service Maven will call. ErrNotControllable = errors.New("smarthome: entity is not controllable") ) // cmdPrefix marks an allowlist row as a Home Assistant service call rather than // a process. It is never run as a binary — tool.Executor branches on it before // it ever reaches exec. const cmdPrefix = "smarthome" // Entity is one thing in the house, as Home Assistant sees it. type Entity struct { // ID — the Home Assistant entity_id, "light.living_room". ID string // Domain — the part before the dot. Decides which services apply. Domain string // Name — friendly_name when the instance has one, else ID. Name string // State — "on", "off", "22.5", … State string // Unit — unit_of_measurement, for sensors. Unit string } // Service is one thing Maven can do to an entity. type Service struct { // Name — the Home Assistant service, "turn_on". Name string // Verb — the local suffix used to build the allowlist row name. Verb string } // controllable maps a domain to the services Maven will expose for it. A domain // that is not in this table gets no control row at all — the list is an // allowlist, not a default, so a new HA integration cannot quietly hand her a // verb nobody reviewed. set_temperature and set_brightness take a value and are // deliberately absent: a spoken number that the router got wrong is a wrong act // on real hardware, and on/off is the whole of what a voice turn can defend. var controllable = map[string][]Service{ "light": {{Name: "turn_on", Verb: "on"}, {Name: "turn_off", Verb: "off"}}, "switch": {{Name: "turn_on", Verb: "on"}, {Name: "turn_off", Verb: "off"}}, "fan": {{Name: "turn_on", Verb: "on"}, {Name: "turn_off", Verb: "off"}}, "cover": {{Name: "open_cover", Verb: "open"}, {Name: "close_cover", Verb: "close"}}, "lock": {{Name: "lock", Verb: "lock"}, {Name: "unlock", Verb: "unlock"}}, } // Services returns the services exposed for an entity, nil when its domain is // not controllable (a sensor, a person, a weather entity: readable, not // flippable). func Services(domain string) []Service { return controllable[domain] } // DomainOf splits "light.living_room" into "light". Empty when the id has no // dot, which Home Assistant guarantees it does. func DomainOf(entityID string) string { i := strings.IndexByte(entityID, '.') if i <= 0 { return "" } return entityID[:i] } // LocalName is the allowlist row name for one entity+service. Prefixed so a // house row is recognisable on /tools without opening the config, and so it // cannot collide with a shell tool Kami named himself. func LocalName(entityID, verb string) string { return "home_" + strings.ReplaceAll(entityID, ".", "_") + "_" + verb } // Scope is the store scope for an entity's domain. func Scope(domain string) string { return cmdPrefix + ":" + domain } // Cmd is the allowlist cmd column for an entity+service. func Cmd(entityID, service string) []string { return []string{cmdPrefix, entityID, service} } // ParseCmd recognises a Home Assistant row. ok=false ⇒ an ordinary process row, // and the caller execs it as it always did. func ParseCmd(cmd []string) (entityID, service string, ok bool) { if len(cmd) != 3 || cmd[0] != cmdPrefix { return "", "", false } if cmd[1] == "" || cmd[2] == "" { return "", "", false } return cmd[1], cmd[2], true }