dc4c5b7841
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
212 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|