package main import ( "context" "fmt" "log" "strconv" "strings" "time" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/phraser" "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), } // No first propose here. This runs inside wireVoice, inside run, before the // IPC socket is serving, and on the locked path inside the passkey unlock // handler. A Home Assistant box that is powered off but still on a routed // subnet black-holes the connection rather than refusing it, so a // synchronous enumeration held the daemon's start for the per-call timeout. // run does the first propose off the ticker instead. 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() // The first enumeration, off the daemon's start path. wireSmartHome used to // do it synchronously and a dead house delayed the socket coming up. w.propose(ctx) 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 phraser.A(phraser.HomeUnreachable, nil), true } if len(ents) == 0 { return phraser.A(phraser.HomeEmpty, nil), true } var on []string var sensors []string dark := 0 for _, e := range ents { switch { case e.Domain == "sensor" || e.Domain == "binary_sensor": if e.State == "" || e.State == "unavailable" { dark++ continue } if len(sensors) < 3 { sensors = append(sensors, e.Name+" "+e.State+e.Unit) } case e.State == "unavailable" || e.State == "unknown" || e.State == "": // A lamp that is not reachable is not a lamp that is off. Counting // it as neither used to make "всё выключено" and "one device is // unreachable" read identically. dark++ case e.State == "on" || e.State == "open" || e.State == "unlocked": on = append(on, e.Name) } } var parts []string switch { case len(on) > 0: shown, rest := on, 0 if len(shown) > 5 { rest = len(shown) - 5 shown = shown[:5] } // Silent truncation on a status read is the same failure as the cap // one layer up: she has to say the list is not the whole list. line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")}) if rest > 0 { line += fmt.Sprintf(" и ещё %d", rest) } parts = append(parts, line) case dark > 0 && len(sensors) == 0: // Nothing is on and everything she can see is unreachable. "всё // выключено" would be a claim about the house she cannot make. return phraser.A(phraser.HomeDark, map[string]string{ "count": strconv.Itoa(dark), "word": phraser.Devices(dark), }), true default: parts = append(parts, "всё выключено") } if len(sensors) > 0 { parts = append(parts, strings.Join(sensors, ", ")) } if dark > 0 { parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark))) } 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 }