dc4c5b7841
A `smarthome` block points Maven at a Home Assistant instance. She reads its entity states to answer "что включено дома?", and every controllable device becomes a PROPOSED row in the existing act allowlist — cmd ["smarthome",<entity_id>,<service>], scope smarthome:<domain> — so nothing new had to be invented for the mutating half. ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn are untouched; one branch in Executor.Exec routes such a row to the client instead of exec, and "smarthome" is never run as a binary. This is the same trick overnight/mcp-tools used for #251, on purpose. Discovery only ever PROPOSES, and every control row is destructive=true: there is no read-only way to turn the heating off, so flipping something in his flat always costs a confirm turn and always had to be enabled by hand on /tools, behind step-up. The entity and the service come from the row he enabled, never from the utterance — Exec drops the spoken tail for a house row. A router that misheard can pick the wrong lamp; it cannot compose a target of its own. The service is checked against the domain's table on the way out too, so a hand-edited cmd column cannot reach an arbitrary Home Assistant service. set_brightness and set_temperature are deliberately absent: a spoken number the router got wrong is a wrong act on real hardware, and on/off is the whole of what a voice turn can defend. The read side is a query source ("home", before calendar and the recall passes) so "что нового дома?" is not answered from an old note. Its matcher needs a house marker plus an ask plus a device word and bails out on weather wording, because "какая температура на улице?" belongs to the weather source. Off unless configured: the block is dark without "enabled": true, and applyDefaults normalises a disabled block to nil so "off" stays in one place. deploy/mavend.json carries it disabled, with the token as ${HA_TOKEN}. NOT shipped, and not faked: MQTT / Zigbee2MQTT (plan steps 2 and 5) and the sensor-to-fact and presence-probe pipelines. There is no broker and no Home Assistant anywhere on this network — 8123 and 1883 are closed on every host in 192.168.1.0/24 — the module tree is vendored so a paho dependency cannot be added offline, and Home Assistant already fronts Zigbee2MQTT where it exists. Writing a sensor pipeline with no sensor to test it against would be a guess. Vikunja #256
122 lines
5.0 KiB
Go
122 lines
5.0 KiB
Go
// 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", "<entity_id>", "<service>"], scope "smarthome:<domain>". 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
|
|
}
|