Files
Maven/cmd/mavend/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

191 lines
5.9 KiB
Go

package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/config"
)
const haStatesFixture = `[
{"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":{"friendly_name":"Спальня","unit_of_measurement":"°C"}}
]`
func TestWireSmartHomeOffUnlessEnabled(t *testing.T) {
st := newTestStore(t)
for name, cfg := range map[string]*config.Config{
"no block": {},
"written but dark": {SmartHome: &config.SmartHomeConfig{
URL: "http://ha.lan:8123", Token: "t",
}},
} {
t.Run(name, func(t *testing.T) {
if w := wireSmartHome(cfg, st); w != nil {
t.Fatal("the house must be off unless the block is enabled")
}
})
}
// nil wiring must be safe everywhere it is reachable.
var w *homeWiring
w.propose(context.Background())
w.run(context.Background())
if w.caller() != nil {
t.Fatal("a nil wiring must have no caller")
}
if _, ok := w.homeSummary(context.Background()); ok {
t.Fatal("a nil wiring must not claim a query")
}
}
// An unreachable instance must not stop the daemon and must propose nothing.
func TestWireSmartHomeUnreachableIsNotFatal(t *testing.T) {
st := newTestStore(t)
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
// Port 1 on loopback: nothing listens, and it fails fast.
URL: "http://127.0.0.1:1", Token: "t", Enabled: true,
}}, st)
if w == nil {
t.Fatal("a configured house should still wire")
}
tools, err := st.ListTools(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if len(tools) != 0 {
t.Fatalf("an instance that never answered must propose nothing, got %+v", tools)
}
}
// Discovery proposes one row per controllable service, always destructive,
// always 'proposed'. A sensor gets no row: there is nothing to call on it.
func TestProposeOnlyProposesControllableDevices(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(haStatesFixture))
}))
defer srv.Close()
st := newTestStore(t)
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
URL: srv.URL, Token: "t", Enabled: true,
}}, st)
if w == nil {
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
}
tools, err := st.ListTools(context.Background(), "")
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, tl := range tools {
got[tl.Name] = true
if tl.Status != "proposed" {
t.Errorf("%s status = %q: discovery must never enable", tl.Name, tl.Status)
}
if !tl.Destructive {
t.Errorf("%s is not destructive: every house control needs the confirm turn", tl.Name)
}
if len(tl.Cmd) == 0 || tl.Cmd[0] != "smarthome" {
t.Errorf("%s cmd = %v", tl.Name, tl.Cmd)
}
}
for _, want := range []string{
"home_light_living_room_on", "home_light_living_room_off",
"home_switch_kettle_on", "home_switch_kettle_off",
} {
if !got[want] {
t.Errorf("missing proposal %q (have %v)", want, got)
}
}
if len(tools) != 4 {
t.Fatalf("got %d rows, want 4 — the sensor must not be proposed: %+v", len(tools), tools)
}
// A second pass must be idempotent: re-discovery duplicates nothing and
// never rewrites a row Kami already enabled.
if err := st.EnableTool(context.Background(), "home_switch_kettle_on",
[]string{"smarthome", "switch.kettle", "turn_on"}, true, "smarthome:switch", time.Now()); err != nil {
t.Fatal(err)
}
w.propose(context.Background())
again, err := st.ListTools(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if len(again) != 4 {
t.Fatalf("re-discovery duplicated rows: %d", len(again))
}
for _, tl := range again {
if tl.Name == "home_switch_kettle_on" && tl.Status != "enabled" {
t.Errorf("re-discovery un-enabled a device he had enabled: %q", tl.Status)
}
}
}
func TestHomeSummaryReadsState(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(haStatesFixture))
}))
defer srv.Close()
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
URL: srv.URL, Token: "t", Enabled: true,
}}, newTestStore(t))
out, ok := w.homeSummary(context.Background())
if !ok {
t.Fatal("summary did not claim the turn")
}
if !strings.Contains(out, "Гостиная") {
t.Errorf("the lamp that is on should be named: %q", out)
}
if strings.Contains(out, "Чайник") {
t.Errorf("a device that is off should not be listed as on: %q", out)
}
if !strings.Contains(out, "22.5") {
t.Errorf("the sensor reading should be there: %q", out)
}
// Persona: no masculine self-reference, no "вы", no pet names.
for _, bad := range []string{"рад ", "готов ", "вы ", "ваш", "милый", "дорогой"} {
if strings.Contains(strings.ToLower(out), bad) {
t.Errorf("persona violation %q in %q", bad, out)
}
}
}
func TestIsHomeQuery(t *testing.T) {
yes := []string{
"что включено дома?",
"что выключено",
"какой свет горит дома",
"свет в доме включен?",
"какая температура в квартире?",
"покажи умный дом",
}
no := []string{
"",
"я дома",
"буду дома в семь",
"какая погода дома", // weather wording wins
"какая температура на улице?",
"домашние дела", // "дома" must not fire on "домашние"
"что мне нужно сделать?",
"напомни выключить чайник в семь", // a reminder, not a house read
}
for _, u := range yes {
if !isHomeQuery(u) {
t.Errorf("isHomeQuery(%q) = false, want true", u)
}
}
for _, u := range no {
if isHomeQuery(u) {
t.Errorf("isHomeQuery(%q) = true, want false", u)
}
}
}