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
232 lines
6.9 KiB
Go
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
|
|
}
|