d62ba093f5
States sorted every entity by id and cut at MaxEntities. Entity ids sort by domain prefix, so binary_sensor came first and forty slots went to connectivity and update-available rows: propose found nothing controllable, and homeSummary, reading the same list, said everything was off with the lights on. The cap stays, because a tool name the 1.7B half-remembers is a wrong act. What changes is which forty. Controllable domains are taken first and round-robin, so every switch and light is in before any sensor. CallService reported done for a call that changed nothing. Home Assistant answers a service call with the states it changed, and a removed entity or an offline integration gets 200 and an empty array. That is the one place Maven asserts something about the physical world, so an empty array is now ErrUnknownEntity. The confirm turn on a house row was a column, not an invariant. The proposal is destructive, but /tools writes the checkbox through on enable, so unticking it once made an unlock row that ran on first hearing. Exec now demands the second turn for any smarthome row whatever the column says, and lock is out of the default domain set so a bare block does not propose an unlock for every door. wireSmartHome enumerated the house synchronously, inside wireVoice, before the socket was serving and inside the unlock handler. A box that black-holes the connection held the daemon's start for the per-call timeout. The first propose moved onto the ticker goroutine. Smaller: an unreachable lamp is counted apart from an off one, a truncated on-list says how many it left out, refresh has a floor of a minute, and the http url is documented as a deliberate wg-only choice. Found in review of #80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
309 lines
9.9 KiB
Go
309 lines
9.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 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()
|
|
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
|
|
}
|