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:
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user