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 }