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
191 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|