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 }