Files
claude f4a021d3da a tool row with no cmd no longer execs the utterance (V-581)
An enabled row whose Cmd is empty built argv from the spoken args alone. So
args[0] became the program name, and free text picked the binary. A proposal is
drafted with no cmd. /tools can enable one before anybody fills it in, so
reaching this took no compromise. Exec now refuses such a row with ErrNotEnabled
before it builds argv. TestExecEmptyCmdRefuses pins it.

Three smaller reads in the same sweep. CapabilityOf parsed a Home Assistant
entity id by hand where smarthome.DomainOf already does it. The fallback for an
id with no dot is unchanged. GroupByDomain built its map key twice per row. The
zenmoney and Home Assistant HTTP clients read an error body before checking the
status that discards it. The status check moved ahead of the read in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:11:53 +04:00

310 lines
10 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 EXCEPT lock, plus sensor/binary_sensor for reads.
// A lock is only enumerated when it is named here.
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 {
// A deadbolt is a different class of object from a lamp, so "lock" is
// not in the implicit set: a bare url+token block must not auto-propose
// an unlock row for every door in the flat. Naming it in domains is the
// operator saying he meant it.
return domain != "lock"
}
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"`
}
// capEntities cuts a state list to at most max entries, taking a controllable
// entity before any sensor and then round-robin across domains.
//
// The cap used to be applied to a globally id-sorted list, and entity ids sort
// by domain prefix: binary_sensor < cover < fan < light < lock < sensor <
// switch. A stock Home Assistant carries dozens of binary_sensor rows before it
// carries anything else, so forty slots went entirely to connectivity and
// update-available sensors. propose then found zero controllable entities, and
// homeSummary, reading the same list, said "всё выключено" with the lights on.
//
// The cap itself stays. The resident model is a 1.7B with a 4096-token context
// and a tool name it half-remembers is a wrong act, so a bounded deliberate
// catalogue still beats a complete one. What changes is which forty: every
// switch and light before any sensor, and an even spread inside each group so
// one crowded domain cannot starve the others. The result is sorted by id, so
// /tools reads the same across restarts.
func capEntities(all []Entity, max int) []Entity {
if max <= 0 || len(all) <= max {
sort.Slice(all, func(i, j int) bool { return all[i].ID < all[j].ID })
return all
}
byDomain := map[string][]Entity{}
for _, e := range all {
byDomain[e.Domain] = append(byDomain[e.Domain], e)
}
var control, read []string
for d := range byDomain {
sort.Slice(byDomain[d], func(i, j int) bool { return byDomain[d][i].ID < byDomain[d][j].ID })
if _, ok := controllable[d]; ok {
control = append(control, d)
} else {
read = append(read, d)
}
}
sort.Strings(control)
sort.Strings(read)
out := make([]Entity, 0, max)
take := func(domains []string) {
for i := 0; len(out) < max; i++ {
took := false
for _, d := range domains {
l := byDomain[d]
if i >= len(l) || len(out) >= max {
continue
}
out = append(out, l[i])
took = true
}
if !took {
return
}
}
}
take(control)
take(read)
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// 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. See capEntities for
// what the cap keeps.
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)
}
return capEntities(out, c.cfg.MaxEntities), 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)
body, err := c.do(ctx, http.MethodPost, path, payload)
if err != nil {
return "", err
}
// Home Assistant answers a service call with the states it changed. An
// entity that was removed since discovery, or one whose integration is
// offline, gets 200 and an empty array. Reporting "готово" for that is
// Maven asserting something false about the physical world: he says
// "выключи свет", she says done, the light stays on.
var changed []haState
if err := json.Unmarshal(body, &changed); err != nil {
// A shape we cannot read is not evidence of failure. HA has answered
// 2xx, so report the call as made rather than inventing a fault.
return "готово", nil
}
if len(changed) == 0 {
return "", fmt.Errorf("%w: %s did not change anything", ErrUnknownEntity, entityID)
}
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()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// The status only, and read before the body: an error page can carry the
// instance's own detail, and there is no reason to pull it into memory to
// discard it.
return nil, fmt.Errorf("smarthome: %s %s: http %d", method, path, resp.StatusCode)
}
out, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, fmt.Errorf("smarthome: read %s: %w", path, err)
}
return out, nil
}