Files
Maven/internal/smarthome/ha.go
T
kami dc4c5b7841 Read and control the house through Home Assistant (#256)
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
2026-08-01 06:27:39 +04:00

232 lines
6.9 KiB
Go

package smarthome
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
// DefaultTimeout — per-call budget. A house that takes longer than this to
// answer is not usable in a spoken turn.
const DefaultTimeout = 10 * time.Second
// DefaultMaxEntities — cap on how many entities become allowlist proposals.
// The resident model is a 1.7B with a 4096-token context: a tool name it
// half-remembers is a wrong act, so a bounded, deliberate catalogue beats a
// complete one.
const DefaultMaxEntities = 40
// maxBody — cap on one /api/states response. A Home Assistant with hundreds of
// entities would otherwise stream megabytes into a daemon that wants forty
// names.
const maxBody = 4 << 20
// Config — what a Home Assistant instance needs to be reachable.
type Config struct {
// URL — the base, "http://homeassistant.local:8123". No trailing path.
URL string
// Token — a long-lived access token. Sent as a bearer header and never
// logged.
Token string
// Domains — the entity domains to take. Empty ⇒ every domain in the
// controllable table plus sensor/binary_sensor for reads.
Domains []string
// MaxEntities — 0 ⇒ DefaultMaxEntities.
MaxEntities int
// Timeout — 0 ⇒ DefaultTimeout.
Timeout time.Duration
}
// Validate rejects a block that cannot work, at config-load time rather than at
// the first spoken act.
func Validate(c Config) error {
if c.URL == "" {
return errors.New("smarthome: url is required")
}
u, err := url.Parse(c.URL)
if err != nil {
return fmt.Errorf("smarthome: url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("smarthome: url scheme %q: want http or https", u.Scheme)
}
if u.Host == "" {
return errors.New("smarthome: url has no host")
}
if c.Token == "" {
return errors.New("smarthome: token is required")
}
return nil
}
// Client is a Home Assistant REST client. Read (States) and one write
// (CallService); no WebSocket, because a spoken turn is request/response and an
// event stream is a second failure mode for no gain yet.
type Client struct {
cfg Config
http *http.Client
}
// NewClient builds a client. Validate first — this does not.
func NewClient(cfg Config) *Client {
if cfg.Timeout <= 0 {
cfg.Timeout = DefaultTimeout
}
if cfg.MaxEntities <= 0 {
cfg.MaxEntities = DefaultMaxEntities
}
return &Client{cfg: cfg, http: &http.Client{Timeout: cfg.Timeout}}
}
// SetHTTPClient swaps the transport. Tests use it; nothing else should.
func (c *Client) SetHTTPClient(h *http.Client) { c.http = h }
// wanted reports whether an entity's domain is one Maven takes. The config list
// wins when set; otherwise every controllable domain plus the two read-only
// sensor domains.
func (c *Client) wanted(domain string) bool {
if len(c.cfg.Domains) > 0 {
for _, d := range c.cfg.Domains {
if d == domain {
return true
}
}
return false
}
if _, ok := controllable[domain]; ok {
return true
}
return domain == "sensor" || domain == "binary_sensor"
}
type haState struct {
EntityID string `json:"entity_id"`
State string `json:"state"`
Attributes json.RawMessage `json:"attributes"`
}
type haAttrs struct {
FriendlyName string `json:"friendly_name"`
Unit string `json:"unit_of_measurement"`
}
// States reads every entity Maven cares about, sorted by id and capped at
// MaxEntities so the catalogue is deterministic across restarts — a proposal
// list that reshuffles itself would make /tools unreadable.
func (c *Client) States(ctx context.Context) ([]Entity, error) {
body, err := c.do(ctx, http.MethodGet, "/api/states", nil)
if err != nil {
return nil, err
}
var raw []haState
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("smarthome: decode states: %w", err)
}
out := make([]Entity, 0, len(raw))
for _, s := range raw {
domain := DomainOf(s.EntityID)
if domain == "" || !c.wanted(domain) {
continue
}
e := Entity{ID: s.EntityID, Domain: domain, Name: s.EntityID, State: s.State}
if len(s.Attributes) > 0 {
var a haAttrs
// Attributes are free-form per integration; a shape we cannot read
// costs the friendly name, not the entity.
if err := json.Unmarshal(s.Attributes, &a); err == nil {
if a.FriendlyName != "" {
e.Name = a.FriendlyName
}
e.Unit = a.Unit
}
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
if len(out) > c.cfg.MaxEntities {
out = out[:c.cfg.MaxEntities]
}
return out, nil
}
// CallService performs one service call against one entity and returns a short
// Russian confirmation.
//
// The entity id and service are NOT taken from the utterance: they come from
// the allowlist row that Kami enabled, so the router can only pick a row, never
// compose a target. That is the whole reason control is encoded in the cmd
// column instead of parsed out of speech.
func (c *Client) CallService(ctx context.Context, entityID, service string) (string, error) {
domain := DomainOf(entityID)
if domain == "" {
return "", ErrUnknownEntity
}
svcs := Services(domain)
if len(svcs) == 0 {
return "", ErrNotControllable
}
known := false
for _, s := range svcs {
if s.Name == service {
known = true
break
}
}
if !known {
return "", fmt.Errorf("%w: %s has no service %q", ErrNotControllable, domain, service)
}
payload, err := json.Marshal(map[string]string{"entity_id": entityID})
if err != nil {
return "", fmt.Errorf("smarthome: encode call: %w", err)
}
path := "/api/services/" + url.PathEscape(domain) + "/" + url.PathEscape(service)
if _, err := c.do(ctx, http.MethodPost, path, payload); err != nil {
return "", err
}
return "готово", nil
}
// do issues one authenticated request and returns the (capped) body.
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
if c.cfg.URL == "" || c.cfg.Token == "" {
return nil, ErrNotConfigured
}
target := strings.TrimRight(c.cfg.URL, "/") + path
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, target, rdr)
if err != nil {
return nil, fmt.Errorf("smarthome: request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("smarthome: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
out, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, fmt.Errorf("smarthome: read %s: %w", path, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The body of an error can contain the instance's own detail; the token
// never appears in it, but keep it to one line anyway.
return nil, fmt.Errorf("smarthome: %s %s: http %d", method, path, resp.StatusCode)
}
return out, nil
}