Read and control the house through Home Assistant (#256)
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
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package smarthome talks to a Home Assistant instance so Maven can read what
|
||||
// the house is doing and change it (Vikunja #256,
|
||||
// docs/plans/11-smarthome-integration.md).
|
||||
//
|
||||
// The shape of this package is copied deliberately from internal/mcp: a
|
||||
// controllable entity becomes a PROPOSED row in the existing act allowlist,
|
||||
// encoded in the columns that already exist — cmd
|
||||
// ["smarthome", "<entity_id>", "<service>"], scope "smarthome:<domain>". So
|
||||
// ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn need no
|
||||
// change, and turning a light off in his flat goes through exactly the same
|
||||
// gate as `restart nginx`.
|
||||
//
|
||||
// Two rules that are not negotiable here:
|
||||
//
|
||||
// - Discovery only ever PROPOSES. Finding a switch on the network is not the
|
||||
// same as being allowed to flip it; Kami enables it on /tools, behind
|
||||
// step-up.
|
||||
// - Every control row is destructive=true. There is no read-only way to turn
|
||||
// the heating off. That means a spoken act always gets the confirm turn,
|
||||
// which is the point.
|
||||
//
|
||||
// MQTT / Zigbee2MQTT (steps 2 and 5 of the plan) are NOT here: they need a
|
||||
// broker client dependency and the module cache in this repo is vendored, and
|
||||
// there is no broker on this network to test one against. Home Assistant's REST
|
||||
// API is stdlib-only and already fronts Zigbee2MQTT when it is present.
|
||||
package smarthome
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured — no smarthome block, or it is disabled.
|
||||
ErrNotConfigured = errors.New("smarthome: not configured")
|
||||
// ErrUnknownEntity — the entity vanished between discovery and the call.
|
||||
ErrUnknownEntity = errors.New("smarthome: unknown entity")
|
||||
// ErrNotControllable — the entity's domain has no service Maven will call.
|
||||
ErrNotControllable = errors.New("smarthome: entity is not controllable")
|
||||
)
|
||||
|
||||
// cmdPrefix marks an allowlist row as a Home Assistant service call rather than
|
||||
// a process. It is never run as a binary — tool.Executor branches on it before
|
||||
// it ever reaches exec.
|
||||
const cmdPrefix = "smarthome"
|
||||
|
||||
// Entity is one thing in the house, as Home Assistant sees it.
|
||||
type Entity struct {
|
||||
// ID — the Home Assistant entity_id, "light.living_room".
|
||||
ID string
|
||||
// Domain — the part before the dot. Decides which services apply.
|
||||
Domain string
|
||||
// Name — friendly_name when the instance has one, else ID.
|
||||
Name string
|
||||
// State — "on", "off", "22.5", …
|
||||
State string
|
||||
// Unit — unit_of_measurement, for sensors.
|
||||
Unit string
|
||||
}
|
||||
|
||||
// Service is one thing Maven can do to an entity.
|
||||
type Service struct {
|
||||
// Name — the Home Assistant service, "turn_on".
|
||||
Name string
|
||||
// Verb — the local suffix used to build the allowlist row name.
|
||||
Verb string
|
||||
}
|
||||
|
||||
// controllable maps a domain to the services Maven will expose for it. A domain
|
||||
// that is not in this table gets no control row at all — the list is an
|
||||
// allowlist, not a default, so a new HA integration cannot quietly hand her a
|
||||
// verb nobody reviewed. set_temperature and set_brightness take a value and are
|
||||
// deliberately absent: a spoken number that the router got wrong is a wrong act
|
||||
// on real hardware, and on/off is the whole of what a voice turn can defend.
|
||||
var controllable = map[string][]Service{
|
||||
"light": {{Name: "turn_on", Verb: "on"}, {Name: "turn_off", Verb: "off"}},
|
||||
"switch": {{Name: "turn_on", Verb: "on"}, {Name: "turn_off", Verb: "off"}},
|
||||
"fan": {{Name: "turn_on", Verb: "on"}, {Name: "turn_off", Verb: "off"}},
|
||||
"cover": {{Name: "open_cover", Verb: "open"}, {Name: "close_cover", Verb: "close"}},
|
||||
"lock": {{Name: "lock", Verb: "lock"}, {Name: "unlock", Verb: "unlock"}},
|
||||
}
|
||||
|
||||
// Services returns the services exposed for an entity, nil when its domain is
|
||||
// not controllable (a sensor, a person, a weather entity: readable, not
|
||||
// flippable).
|
||||
func Services(domain string) []Service { return controllable[domain] }
|
||||
|
||||
// DomainOf splits "light.living_room" into "light". Empty when the id has no
|
||||
// dot, which Home Assistant guarantees it does.
|
||||
func DomainOf(entityID string) string {
|
||||
i := strings.IndexByte(entityID, '.')
|
||||
if i <= 0 {
|
||||
return ""
|
||||
}
|
||||
return entityID[:i]
|
||||
}
|
||||
|
||||
// LocalName is the allowlist row name for one entity+service. Prefixed so a
|
||||
// house row is recognisable on /tools without opening the config, and so it
|
||||
// cannot collide with a shell tool Kami named himself.
|
||||
func LocalName(entityID, verb string) string {
|
||||
return "home_" + strings.ReplaceAll(entityID, ".", "_") + "_" + verb
|
||||
}
|
||||
|
||||
// Scope is the store scope for an entity's domain.
|
||||
func Scope(domain string) string { return cmdPrefix + ":" + domain }
|
||||
|
||||
// Cmd is the allowlist cmd column for an entity+service.
|
||||
func Cmd(entityID, service string) []string { return []string{cmdPrefix, entityID, service} }
|
||||
|
||||
// ParseCmd recognises a Home Assistant row. ok=false ⇒ an ordinary process row,
|
||||
// and the caller execs it as it always did.
|
||||
func ParseCmd(cmd []string) (entityID, service string, ok bool) {
|
||||
if len(cmd) != 3 || cmd[0] != cmdPrefix {
|
||||
return "", "", false
|
||||
}
|
||||
if cmd[1] == "" || cmd[2] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return cmd[1], cmd[2], true
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package smarthome
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// statesFixture is a trimmed /api/states response from a Home Assistant with
|
||||
// one light, one switch, one sensor and two entities Maven must ignore.
|
||||
const statesFixture = `[
|
||||
{"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}},
|
||||
{"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}},
|
||||
{"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"unit_of_measurement":"°C"}},
|
||||
{"entity_id":"person.kami","state":"home","attributes":{}},
|
||||
{"entity_id":"automation.wake","state":"on","attributes":[]}
|
||||
]`
|
||||
|
||||
func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
c := NewClient(Config{URL: srv.URL, Token: "tok"})
|
||||
return c, srv
|
||||
}
|
||||
|
||||
func TestStatesFiltersAndNames(t *testing.T) {
|
||||
var auth string
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
auth = r.Header.Get("Authorization")
|
||||
if r.URL.Path != "/api/states" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(statesFixture))
|
||||
})
|
||||
got, err := c.States(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("States: %v", err)
|
||||
}
|
||||
if auth != "Bearer tok" {
|
||||
t.Errorf("Authorization = %q", auth)
|
||||
}
|
||||
// person and automation are neither controllable nor sensors.
|
||||
want := []string{"light.living_room", "sensor.bedroom_temp", "switch.kettle"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d entities, want %d: %+v", len(got), len(want), got)
|
||||
}
|
||||
for i, id := range want {
|
||||
if got[i].ID != id {
|
||||
t.Errorf("entity %d = %q, want %q (sorted by id)", i, got[i].ID, id)
|
||||
}
|
||||
}
|
||||
if got[0].Name != "Гостиная" || got[0].Domain != "light" || got[0].State != "on" {
|
||||
t.Errorf("light = %+v", got[0])
|
||||
}
|
||||
if got[1].Unit != "°C" {
|
||||
t.Errorf("sensor unit = %q", got[1].Unit)
|
||||
}
|
||||
// An attributes value of the wrong shape must not lose the entity.
|
||||
if got[2].Name != "Чайник" {
|
||||
t.Errorf("switch name = %q", got[2].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatesRespectsConfiguredDomainsAndCap(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(statesFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(Config{URL: srv.URL, Token: "t", Domains: []string{"switch"}})
|
||||
got, err := c.States(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("States: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "switch.kettle" {
|
||||
t.Fatalf("domain filter: %+v", got)
|
||||
}
|
||||
|
||||
c = NewClient(Config{URL: srv.URL, Token: "t", MaxEntities: 2})
|
||||
got, err = c.States(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("States: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("cap: got %d entities, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallServicePostsEntityID(t *testing.T) {
|
||||
var path, body string
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
path = r.URL.Path
|
||||
b := make([]byte, 256)
|
||||
n, _ := r.Body.Read(b)
|
||||
body = string(b[:n])
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
})
|
||||
out, err := c.CallService(context.Background(), "light.living_room", "turn_off")
|
||||
if err != nil {
|
||||
t.Fatalf("CallService: %v", err)
|
||||
}
|
||||
if out != "готово" {
|
||||
t.Errorf("out = %q", out)
|
||||
}
|
||||
if path != "/api/services/light/turn_off" {
|
||||
t.Errorf("path = %q", path)
|
||||
}
|
||||
if !strings.Contains(body, `"entity_id":"light.living_room"`) {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A service that is not in the domain's table never leaves the box. The
|
||||
// allowlist is the gate, and it is enforced on the way out too, so a corrupted
|
||||
// or hand-edited cmd column cannot reach an arbitrary Home Assistant service.
|
||||
func TestCallServiceRefusesUnknownServiceAndDomain(t *testing.T) {
|
||||
called := false
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
entity, service string
|
||||
want error
|
||||
}{
|
||||
{"light.living_room", "delete_everything", ErrNotControllable},
|
||||
{"sensor.bedroom_temp", "turn_on", ErrNotControllable},
|
||||
{"nodot", "turn_on", ErrUnknownEntity},
|
||||
} {
|
||||
if _, err := c.CallService(context.Background(), tc.entity, tc.service); !errors.Is(err, tc.want) {
|
||||
t.Errorf("CallService(%q,%q) err = %v, want %v", tc.entity, tc.service, err, tc.want)
|
||||
}
|
||||
}
|
||||
if called {
|
||||
t.Error("a refused call still reached the network")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPErrorIsAnError(t *testing.T) {
|
||||
c, _ := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
if _, err := c.States(context.Background()); err == nil {
|
||||
t.Fatal("want error on 401")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconfiguredClientRefuses(t *testing.T) {
|
||||
c := NewClient(Config{})
|
||||
if _, err := c.States(context.Background()); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("err = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
ok := Config{URL: "http://ha.lan:8123", Token: "t"}
|
||||
if err := Validate(ok); err != nil {
|
||||
t.Fatalf("Validate(ok): %v", err)
|
||||
}
|
||||
for name, c := range map[string]Config{
|
||||
"no url": {Token: "t"},
|
||||
"no token": {URL: "http://ha.lan:8123"},
|
||||
"bad scheme": {URL: "ftp://ha.lan", Token: "t"},
|
||||
"no host": {URL: "http://", Token: "t"},
|
||||
"not a url": {URL: "://x", Token: "t"},
|
||||
"bare string": {URL: "ha.lan:8123", Token: "t"},
|
||||
} {
|
||||
if err := Validate(c); err == nil {
|
||||
t.Errorf("Validate(%s) = nil, want error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistEncoding(t *testing.T) {
|
||||
cmd := Cmd("light.living_room", "turn_off")
|
||||
id, svc, ok := ParseCmd(cmd)
|
||||
if !ok || id != "light.living_room" || svc != "turn_off" {
|
||||
t.Fatalf("ParseCmd(%v) = %q,%q,%v", cmd, id, svc, ok)
|
||||
}
|
||||
// Anything that is not exactly a three-element smarthome row stays a
|
||||
// process row, or the executor would swallow a real shell tool.
|
||||
for _, bad := range [][]string{
|
||||
nil,
|
||||
{"smarthome"},
|
||||
{"smarthome", "light.x"},
|
||||
{"smarthome", "light.x", "turn_on", "extra"},
|
||||
{"smarthome", "", "turn_on"},
|
||||
{"smarthome", "light.x", ""},
|
||||
{"systemctl", "restart", "nginx"},
|
||||
} {
|
||||
if _, _, ok := ParseCmd(bad); ok {
|
||||
t.Errorf("ParseCmd(%v) = ok, want not a smarthome row", bad)
|
||||
}
|
||||
}
|
||||
if got := LocalName("light.living_room", "off"); got != "home_light_living_room_off" {
|
||||
t.Errorf("LocalName = %q", got)
|
||||
}
|
||||
if got := Scope("light"); got != "smarthome:light" {
|
||||
t.Errorf("Scope = %q", got)
|
||||
}
|
||||
if Services("light") == nil || Services("sensor") != nil {
|
||||
t.Error("Services: light must be controllable and sensor must not")
|
||||
}
|
||||
if DomainOf("light.x") != "light" || DomainOf("nodot") != "" || DomainOf(".x") != "" {
|
||||
t.Error("DomainOf")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user