Files
Maven/internal/smarthome/smarthome_test.go
T
kami dc4c5b7841 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
2026-08-01 06:27:39 +04:00

212 lines
6.5 KiB
Go

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")
}
}