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 }