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),