From dc4c5b7841a14e241ee18e55779036ca8b42844e Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 06:27:39 +0400 Subject: [PATCH] Read and control the house through Home Assistant (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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",,], scope smarthome: — 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 --- cmd/mavend/actions_query.go | 24 +++ cmd/mavend/main.go | 12 ++ cmd/mavend/smarthome.go | 215 +++++++++++++++++++++++++ cmd/mavend/smarthome_test.go | 190 ++++++++++++++++++++++ cmd/mavend/voice.go | 6 + cmd/mavend/voicewire.go | 12 ++ deploy/mavend.json | 11 ++ internal/config/config.go | 85 ++++++++++ internal/smarthome/ha.go | 231 +++++++++++++++++++++++++++ internal/smarthome/smarthome.go | 121 ++++++++++++++ internal/smarthome/smarthome_test.go | 211 ++++++++++++++++++++++++ internal/store/tools.go | 13 ++ internal/tool/tool.go | 37 +++++ internal/tool/tool_test.go | 77 +++++++++ 14 files changed, 1245 insertions(+) create mode 100644 cmd/mavend/smarthome.go create mode 100644 cmd/mavend/smarthome_test.go create mode 100644 internal/smarthome/ha.go create mode 100644 internal/smarthome/smarthome.go create mode 100644 internal/smarthome/smarthome_test.go diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 56f9ede..fa506bf 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -74,6 +74,13 @@ var querySources = []querySource{ // it by inventing news. Its matcher needs a feed noun plus an ask, so // "у меня новая лента в инстаграме" is untouched. {"feeds", (*reactiveHandler).queryFeeds}, + // Before "calendar" and before the recall sources: "что включено дома?" is + // a question about the house, and the notes pass would otherwise answer it + // from whatever he once said about the lights. Its matcher needs a house + // marker plus an ask plus a device word, and it bails out on weather + // wording, so "какая температура на улице?" still reaches the weather + // source. + {"home", (*reactiveHandler).queryHome}, {"calendar", (*reactiveHandler).queryCalendar}, {"weather", (*reactiveHandler).queryWeather}, {"embed", (*reactiveHandler).queryEmbed}, @@ -275,6 +282,23 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri return f.FormatEntries(entries, date), true } +// queryHome answers a question about the house. Read-only by construction: it +// calls States and nothing else, so there is no confirm turn here — the only +// way to CHANGE something is an enabled allowlist row through tool.Executor. +func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) { + if !isHomeQuery(t.dec.Utterance) { + return "", false + } + if h.home == nil { + // Claim the turn rather than fall through: "дом не подключён" is true, + // and letting general knowledge answer would be an invented house. + return "дом не подключён — я его не вижу.", true + } + ctxH, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + return h.home.homeSummary(ctxH) +} + func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) { if !isWeatherQuery(t.dec.Utterance) { return "", false diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 4913d91..d6df63a 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -611,6 +611,11 @@ func run(args []string) error { go voiceW.mcp.run(ctx) } + // Re-enumerate the house for new devices (nil unless configured). + if voiceW != nil && voiceW.home != nil { + go voiceW.home.run(ctx) + } + dl.unlock(st) log.Printf("mavend: unlocked via passkey assertion") return nil @@ -677,6 +682,13 @@ func run(args []string) error { voiceW.mcp.run(ctx) }() } + if voiceW != nil && voiceW.home != nil { + wg.Add(1) + go func() { + defer wg.Done() + voiceW.home.run(ctx) + }() + } } <-ctx.Done() diff --git a/cmd/mavend/smarthome.go b/cmd/mavend/smarthome.go new file mode 100644 index 0000000..f4f848c --- /dev/null +++ b/cmd/mavend/smarthome.go @@ -0,0 +1,215 @@ +package main + +import ( + "context" + "log" + "strings" + "time" + + "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/smarthome" + "github.com/kami/maven/internal/store" +) + +// homeWiring — the Home Assistant client, when the `smarthome` block is present +// AND enabled. nil ⇒ the house is not wired, nothing was proposed, and an +// allowlist row that happens to look like a house row refuses to run. +// +// It lives on the voice wiring for the same reason MCP does: a house control IS +// an act. It goes through tool.Executor, the enabled allowlist and the confirm +// turn, all of which only exist on the voice/chat path. +type homeWiring struct { + client *smarthome.Client + st *store.Store + refresh time.Duration +} + +// wireSmartHome builds the client and proposes what it found. It never fails +// the daemon: an instance that is down at boot is logged and retried, because +// Maven starting is not contingent on someone else's process. +func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring { + hc, ok := cfg.SmartHomeClient() + if !ok || st == nil { + return nil + } + if err := smarthome.Validate(hc); err != nil { + // config.validate already ran this, so reaching here is a programming + // error rather than a config one. Still not fatal: the house off is a + // working Maven. + log.Printf("smarthome: not wired: %v", err) + return nil + } + w := &homeWiring{ + client: smarthome.NewClient(hc), + st: st, + refresh: time.Duration(cfg.SmartHome.Refresh), + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + w.propose(ctx) + return w +} + +// caller is the tool.HomeCaller seam. +func (w *homeWiring) caller() *smarthome.Client { + if w == nil { + return nil + } + return w.client +} + +// propose writes a 'proposed' allowlist row for every controllable device. It +// does NOT enable anything: a reachable house is a place Maven may look, not a +// set of switches she may flip. Kami enables what he wants on /tools, behind +// step-up, which is the same gate a shell tool goes through. +// +// Sensors are read but never proposed — there is nothing to call on them. +func (w *homeWiring) propose(ctx context.Context) { + if w == nil { + return + } + ents, err := w.client.States(ctx) + if err != nil { + log.Printf("smarthome: read states: %v", err) + return + } + now := time.Now() + fresh, devices := 0, 0 + for _, e := range ents { + svcs := smarthome.Services(e.Domain) + if len(svcs) == 0 { + continue + } + devices++ + for _, s := range svcs { + name := smarthome.LocalName(e.ID, s.Verb) + provenance := "дом: " + s.Name + " → " + e.Name + " (" + e.ID + ")" + ok, err := w.st.ProposeSmartHomeTool(ctx, name, smarthome.Scope(e.Domain), + smarthome.Cmd(e.ID, s.Name), provenance, now) + if err != nil { + log.Printf("smarthome: propose %s: %v", name, err) + continue + } + if ok { + fresh++ + } + } + } + log.Printf("smarthome: %d entities, %d controllable", len(ents), devices) + if fresh > 0 { + log.Printf("smarthome: %d new device proposal(s) waiting on /tools", fresh) + } +} + +// run re-enumerates the house and picks up devices that appeared, until ctx is +// canceled. +func (w *homeWiring) run(ctx context.Context) { + if w == nil { + return + } + iv := w.refresh + if iv <= 0 { + iv = config.DefaultSmartHomeRefresh + } + t := time.NewTicker(iv) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w.propose(ctx) + } + } +} + +// homeSummary answers "что дома?" — a read of the current entity states, one +// short line. Read-only: it can never call a service, so it needs no confirm +// and no allowlist row. +func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) { + if w == nil { + return "", false + } + ents, err := w.client.States(ctx) + if err != nil { + log.Printf("smarthome: summary: %v", err) + return "не смогла достучаться до дома.", true + } + if len(ents) == 0 { + return "дом ничего не отдаёт.", true + } + var on []string + var sensors []string + for _, e := range ents { + switch { + case e.Domain == "sensor" || e.Domain == "binary_sensor": + if len(sensors) < 3 && e.State != "" && e.State != "unavailable" { + sensors = append(sensors, e.Name+" "+e.State+e.Unit) + } + case e.State == "on" || e.State == "open" || e.State == "unlocked": + on = append(on, e.Name) + } + } + var parts []string + if len(on) > 0 { + if len(on) > 5 { + on = on[:5] + } + parts = append(parts, "включено: "+strings.Join(on, ", ")) + } else { + parts = append(parts, "всё выключено") + } + if len(sensors) > 0 { + parts = append(parts, strings.Join(sensors, ", ")) + } + return strings.Join(parts, "; ") + ".", true +} + +// isHomeQuery recognises a question about the house, narrowly. "дома" on its +// own is not enough — "я дома" is a fact, not a question — so it takes a house +// marker AND an ask AND either a device word or the word "включ…". Weather +// wording bails out first: "какая температура на улице?" belongs to the weather +// source, and both questions contain "температура". +func isHomeQuery(u string) bool { + s := strings.ToLower(strings.TrimSpace(u)) + if s == "" { + return false + } + for _, w := range []string{"погод", "на улице", "прогноз"} { + if strings.Contains(s, w) { + return false + } + } + for _, phrase := range []string{"что включено", "что выключено", "умный дом", "что в доме включено"} { + if strings.Contains(s, phrase) { + return true + } + } + house := homeWord(s, "дома") || strings.Contains(s, "в доме") || strings.Contains(s, "в квартире") + if !house { + return false + } + ask := strings.Contains(s, "?") || homeWord(s, "что") || homeWord(s, "какая") || + homeWord(s, "какой") || homeWord(s, "сколько") + if !ask { + return false + } + for _, w := range []string{"свет", "лампа", "лампы", "розетк", "датчик", "температур", "включ", "выключ"} { + if strings.Contains(s, w) { + return true + } + } + return false +} + +// homeWord — whole-token membership, so "дома" does not fire on "домашний". +// Punctuation is trimmed off each token because a spoken question arrives with +// a question mark glued to the last word. +func homeWord(s, w string) bool { + for _, tok := range strings.Fields(s) { + if strings.Trim(tok, ".,!?;:") == w { + return true + } + } + return false +} diff --git a/cmd/mavend/smarthome_test.go b/cmd/mavend/smarthome_test.go new file mode 100644 index 0000000..41f07c1 --- /dev/null +++ b/cmd/mavend/smarthome_test.go @@ -0,0 +1,190 @@ +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) + } + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index c9ecbcd..1850ae9 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -92,6 +92,12 @@ type reactiveHandler struct { // instead of "ничего нового", which are different truths. feedsOn bool + // home — the Home Assistant client (Vikunja #256). nil ⇒ the house is not + // configured, which is the default: no `smarthome` block, no reads, no + // switches. Control does not go through this field — it goes through the + // act allowlist and tool.Executor, like every other mutating act. + home *homeWiring + weatherProvider weather.Provider weatherLocation string // default location for weather queries diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 5b4a60a..1ca9d8b 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -49,6 +49,10 @@ type voiceWiring struct { // server (Vikunja #251). Its tools land in the same allowlist as every // other act, so nothing else here has to know about it. mcp *mcpWiring + // home — the Home Assistant client, nil unless the `smarthome` block is + // enabled (Vikunja #256). Its devices land in the same allowlist as every + // other act, so nothing else here has to know about it. + home *homeWiring } // close releases the listener + worker conns. Safe to call on nil (when @@ -150,6 +154,13 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem if w.mcp != nil { exec = exec.WithMCP(w.mcp.caller()) } + // The house (Vikunja #256): same story as MCP. Discovery PROPOSES a row per + // controllable device, always destructive, and Kami enables the ones he + // wants on /tools. Off unless the `smarthome` block is enabled. + w.home = wireSmartHome(cfg, dataStore) + if w.home != nil { + exec = exec.WithHome(w.home.caller()) + } matcher := tool.NewMatcher(coreAPI) // ----- weather provider (Open-Meteo when configured, Stub otherwise) ----- @@ -233,6 +244,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem phraser: phr, now: time.Now, feedsOn: cfg.Feeds != nil, + home: w.home, // nil unless `crawl.on_demand` is on: reading a page he names is a // capability, and capabilities are off unless configured. crawler: onDemandCrawler(cfg), diff --git a/deploy/mavend.json b/deploy/mavend.json index 4551e9f..7f1449f 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -45,6 +45,17 @@ ] }, + "smarthome": { + "provider": "homeassistant", + "url": "http://192.168.1.50:8123", + "token": "${HA_TOKEN}", + "domains": ["light", "switch", "sensor"], + "max_entities": 40, + "timeout": "10s", + "refresh": "15m", + "enabled": false + }, + "nexus": { "url": "http://nexus:9740" }, "praxis": { "url": "http://praxis:8989" }, "hexis": { "url": "http://hexis:9741" }, diff --git a/internal/config/config.go b/internal/config/config.go index 8865711..0191662 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ import ( "github.com/kami/maven/internal/delivery/telegramsink" "github.com/kami/maven/internal/mcp" "github.com/kami/maven/internal/morning" + "github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/update" "github.com/robfig/cron/v3" ) @@ -237,6 +238,11 @@ type Config struct { // box. She is a client here, never a server: nothing exposes her own // capabilities to an outside caller. See MCPConfig. MCP *MCPConfig `json:"mcp,omitempty"` + + // SmartHome — the Home Assistant instance (Vikunja #256). nil / absent / + // disabled ⇒ Maven neither reads the house nor touches it, and no house row + // exists in the act allowlist. See SmartHomeConfig. + SmartHome *SmartHomeConfig `json:"smarthome,omitempty"` } // MCPConfig — the MCP client block. Servers are dark until one has @@ -263,6 +269,61 @@ type MCPConfig struct { MaxBytes int64 `json:"max_bytes,omitempty"` } +// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until +// `"enabled": true`, and even then a discovered device is only ever PROPOSED +// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as +// he would a shell tool. Finding a switch on the network is not the same as +// being allowed to flip it. +type SmartHomeConfig struct { + // Provider — only "homeassistant" is implemented. MQTT / Zigbee2MQTT are + // not: Home Assistant already fronts them, and a broker client is a + // dependency this vendored module tree cannot take on tonight. + Provider string `json:"provider,omitempty"` + + // URL — the instance base, "http://192.168.1.50:8123". + URL string `json:"url,omitempty"` + + // Token — a long-lived access token. Use ${HA_TOKEN} and keep the value in + // the gitignored env file, like the telegram credentials. + Token string `json:"token,omitempty"` + + // Domains — entity domains to take. Empty ⇒ the controllable domains + // (light, switch, fan, cover, lock) plus sensor and binary_sensor for + // reads. Narrow it when the instance is large: a tool name the 1.7B + // half-remembers is a wrong act. + Domains []string `json:"domains,omitempty"` + + // MaxEntities — cap on the proposal catalogue. 0 ⇒ 40. + MaxEntities int `json:"max_entities,omitempty"` + + // Timeout — per-call budget. 0 ⇒ 10s. + Timeout Duration `json:"timeout,omitempty"` + + // Refresh — how often the entity list is re-read and new devices proposed. + // 0 ⇒ 15m. Discovery is idempotent, so this only ever adds rows. + Refresh Duration `json:"refresh,omitempty"` + + // Enabled — false (the default) keeps a written block dark, so it can be + // reviewed before the house is wired to a voice. + Enabled bool `json:"enabled,omitempty"` +} + +// SmartHomeClient maps the config block onto the smarthome package's own type. +// Returns ok=false when nothing is configured or it is disabled, so validation +// and daemon wiring cannot drift on the mapping. +func (c *Config) SmartHomeClient() (smarthome.Config, bool) { + if c.SmartHome == nil || !c.SmartHome.Enabled { + return smarthome.Config{}, false + } + return smarthome.Config{ + URL: c.SmartHome.URL, + Token: c.SmartHome.Token, + Domains: c.SmartHome.Domains, + MaxEntities: c.SmartHome.MaxEntities, + Timeout: time.Duration(c.SmartHome.Timeout), + }, true +} + // MCPServerConfig — one MCP server. type MCPServerConfig struct { // Name — the local handle. It prefixes every tool this server contributes @@ -884,6 +945,11 @@ type EmailConfig struct { // DefaultEmailTimeout — extraction budget per message. const DefaultEmailTimeout = 2 * time.Minute +// DefaultSmartHomeRefresh — how often the house is re-enumerated for new +// devices. Slow on purpose: discovery only adds proposals, and a flat does not +// grow a new lamp every minute. +const DefaultSmartHomeRefresh = 15 * time.Minute + // PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server // as a managed subprocess and sends chat-completion requests to phrase nudge // and reminder messages. nil ⇒ the template-based Stub is used instead. @@ -1113,6 +1179,15 @@ func (c *Config) applyDefaults() { c.MCP = nil } + // Same rule for the house: a block that is not enabled is the same as no + // block at all, so "off" stays in one place. + if c.SmartHome != nil && !c.SmartHome.Enabled { + c.SmartHome = nil + } + if c.SmartHome != nil && c.SmartHome.Refresh <= 0 { + c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh) + } + // Same rule for the crawler: a block that neither answers on demand nor // watches anything has nothing to do, so it is normalised to "off". if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 { @@ -1223,6 +1298,16 @@ func (c *Config) validate() error { if err := mcp.Validate(c.MCPServers()); err != nil { return err } + // Same for the house: a missing token or a bare hostname fails at startup, + // not at the first "выключи свет". + if hc, ok := c.SmartHomeClient(); ok { + if p := c.SmartHome.Provider; p != "" && p != "homeassistant" { + return fmt.Errorf("smarthome: provider %q: only \"homeassistant\" is implemented", p) + } + if err := smarthome.Validate(hc); err != nil { + return err + } + } if len(c.MorningRoutines) > 0 { if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil { return err diff --git a/internal/smarthome/ha.go b/internal/smarthome/ha.go new file mode 100644 index 0000000..096b5a2 --- /dev/null +++ b/internal/smarthome/ha.go @@ -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 +} diff --git a/internal/smarthome/smarthome.go b/internal/smarthome/smarthome.go new file mode 100644 index 0000000..d156c2e --- /dev/null +++ b/internal/smarthome/smarthome.go @@ -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", "", ""], scope "smarthome:". 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 +} diff --git a/internal/smarthome/smarthome_test.go b/internal/smarthome/smarthome_test.go new file mode 100644 index 0000000..48347ea --- /dev/null +++ b/internal/smarthome/smarthome_test.go @@ -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") + } +} diff --git a/internal/store/tools.go b/internal/store/tools.go index 6455fcd..10f9c37 100644 --- a/internal/store/tools.go +++ b/internal/store/tools.go @@ -95,6 +95,19 @@ func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []st return n > 0, nil } +// ProposeSmartHomeTool is ProposeTool for a controllable device discovered on +// the Home Assistant instance (Vikunja #256). Like ProposeMCPTool the proposal +// already knows what it would run, so cmd is written with it and Kami only has +// to press enable. +// +// It is still a PROPOSAL, and destructive is not a parameter: there is no +// read-only way to turn a lamp off, so every house row carries the confirm +// turn. Re-discovery on every refresh is idempotent — an existing row is never +// touched, so a device he disabled stays disabled. +func (s *Store) ProposeSmartHomeTool(ctx context.Context, name, scope string, cmd []string, utterance string, ts time.Time) (bool, error) { + return s.ProposeMCPTool(ctx, name, scope, cmd, true, utterance, ts) +} + // EnableTool fills cmd + destructive and flips status to 'enabled'. This is the // human "enable" act (the authed surface calls it); it upserts so enabling a // name that was never proposed still works. An empty cmd is refused — an diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 4d16c6f..4c0165d 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -13,6 +13,11 @@ // - Args are passed as argv, NEVER through a shell. STT text lands as // positional arguments to Cmd; there is no `sh -c`, so "restart nginx; // rm -rf" can't inject — the tail is one argv element to the named binary. +// - An enabled row whose cmd is ["smarthome", "", ""] is +// a Home Assistant service call instead of a process (Vikunja #256), by +// exactly the same trick and under exactly the same rules. Control rows are +// always destructive, so flipping something in his flat always costs a +// confirm turn. // - An enabled row whose cmd is ["mcp", "", ""] is a call to a // configured MCP server instead of a process (Vikunja #251). It goes // through every rule above unchanged — enabled, and confirmed if it @@ -38,6 +43,7 @@ import ( "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/mcp" "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/smarthome" ) // API — the narrow slice of ipc.CoreAPI the executor and matcher need. Backed @@ -64,6 +70,14 @@ type MCPCaller interface { CallPositional(ctx context.Context, server, tool string, args []string) (string, error) } +// HomeCaller is the seam for an act that is a Home Assistant service call +// rather than a process (Vikunja #256). internal/smarthome.Client satisfies it. +// nil ⇒ the house is not configured, and a house row refuses to run rather than +// silently doing nothing. +type HomeCaller interface { + CallService(ctx context.Context, entityID, service string) (string, error) +} + // Executor runs enabled tools. run is the exec seam (default: real process); // tests swap it. timeout bounds each invocation. type Executor struct { @@ -71,6 +85,7 @@ type Executor struct { timeout time.Duration run func(ctx context.Context, argv []string) (string, error) mcp MCPCaller + home HomeCaller } // NewExecutor builds the executor. timeout<=0 defaults to 30s. @@ -88,6 +103,14 @@ func (e *Executor) WithMCP(m MCPCaller) *Executor { return e } +// WithHome attaches the Home Assistant caller. Called once at wiring time when +// the smarthome block is enabled; without it, a row whose cmd is +// ["smarthome", …] refuses. +func (e *Executor) WithHome(h HomeCaller) *Executor { + e.home = h + return e +} + // Exec looks up name in the store and runs Cmd+args as argv (no shell). // confirmed=true is the second turn of a destructive act (the user said "да"); // it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a @@ -117,6 +140,20 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm defer cancel() return e.mcp.CallPositional(ctx, server, remote, args) } + // A house row is a Home Assistant service call, not a process (Vikunja + // #256). Same story: enabled, and confirmed — every control row is + // destructive, because there is no read-only way to turn the heating off. + // The spoken args are dropped on purpose: the entity and the service come + // from the row Kami enabled, so a router that misheard can pick the wrong + // row but can never compose a target of its own. + if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok { + if e.home == nil { + return "", ErrNotEnabled + } + ctx, cancel := context.WithTimeout(ctx, e.timeout) + defer cancel() + return e.home.CallService(ctx, entityID, service) + } argv := append(append([]string(nil), t.Cmd...), args...) if len(argv) == 0 { return "", ErrNotEnabled diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go index f0c5dcd..cfc4c35 100644 --- a/internal/tool/tool_test.go +++ b/internal/tool/tool_test.go @@ -185,3 +185,80 @@ func TestExecMCPRowWithoutCallerRefuses(t *testing.T) { t.Fatal(`"mcp" must never be run as a binary`) } } + +// fakeHome records what the executor asked the house to do. +type fakeHome struct { + entity, service string + calls int +} + +func (f *fakeHome) CallService(_ context.Context, entityID, service string) (string, error) { + f.calls++ + f.entity, f.service = entityID, service + return "готово", nil +} + +// A house row goes through the same allowlist and the same confirm turn as any +// other act, and it is never exec'd as a binary (Vikunja #256). +func TestExecSmartHomeRow(t *testing.T) { + api := fakeAPI{tools: map[string]ipc.Tool{ + "home_light_x_off": { + Name: "home_light_x_off", Scope: "smarthome:light", + Cmd: []string{"smarthome", "light.x", "turn_off"}, Destructive: true, Status: "enabled", + }, + "home_draft": { + Name: "home_draft", Scope: "smarthome:light", + Cmd: []string{"smarthome", "light.y", "turn_on"}, Destructive: true, Status: "proposed", + }, + }} + ran := false + newExec := func(h HomeCaller) *Executor { + e := NewExecutor(api, time.Second) + e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil } + if h != nil { + e = e.WithHome(h) + } + return e + } + + // No house configured ⇒ the row refuses rather than being exec'd. + if _, err := newExec(nil).Exec(context.Background(), "home_light_x_off", nil, true); !errors.Is(err, ErrNotEnabled) { + t.Fatalf("unconfigured house: err = %v, want ErrNotEnabled", err) + } + if ran { + t.Fatal(`"smarthome" was run as a binary`) + } + + // Configured, but not confirmed ⇒ the confirm turn, before any call. + fh := &fakeHome{} + if _, err := newExec(fh).Exec(context.Background(), "home_light_x_off", nil, false); !errors.Is(err, ErrNeedsConfirm) { + t.Fatalf("err = %v, want ErrNeedsConfirm", err) + } + if fh.calls != 0 { + t.Fatal("an unconfirmed house act reached the house") + } + + // A merely proposed row never runs, confirmed or not. + if _, err := newExec(fh).Exec(context.Background(), "home_draft", nil, true); !errors.Is(err, ErrNotEnabled) { + t.Fatalf("proposed row: err = %v, want ErrNotEnabled", err) + } + if fh.calls != 0 { + t.Fatal("a proposed house row reached the house") + } + + // Confirmed ⇒ the service call, with the entity from the ROW and the + // spoken tail dropped. + out, err := newExec(fh).Exec(context.Background(), "home_light_x_off", []string{"light.somewhere_else"}, true) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if out != "готово" { + t.Errorf("out = %q", out) + } + if fh.entity != "light.x" || fh.service != "turn_off" { + t.Errorf("called %s/%s: the target must come from the enabled row, never from the utterance", fh.entity, fh.service) + } + if ran { + t.Fatal(`"smarthome" was run as a binary`) + } +}