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
This commit is contained in:
kami
2026-08-01 06:27:39 +04:00
parent 33e53ee897
commit dc4c5b7841
14 changed files with 1245 additions and 0 deletions
+24
View File
@@ -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
+12
View File
@@ -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()
+215
View File
@@ -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
}
+190
View File
@@ -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)
}
}
}
+6
View File
@@ -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
+12
View File
@@ -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),
+11
View File
@@ -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" },
+85
View File
@@ -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
+231
View File
@@ -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
}
+121
View File
@@ -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", "<entity_id>", "<service>"], scope "smarthome:<domain>". 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
}
+211
View File
@@ -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")
}
}
+13
View File
@@ -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
+37
View File
@@ -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", "<entity_id>", "<service>"] 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", "<server>", "<tool>"] 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
+77
View File
@@ -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`)
}
}