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
+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`)
}
}