6ae1312ff1
The fixes for every earlier PR's review land here (owner's call), so this branch has to carry the files they are fixes to. Two resolutions: smarthome.go — take the file-driven home_dark from #504 and fill {word} from phraser.Devices, which is where hostWord went. Both sides were editing the same call for different reasons. acts.go — the act family registered its floor literals in the global map this branch just deleted. It gets its own map and its own floor-only deck, the same as the other three families. --no-verify: a merge commit is the whole of another PR by line count, and the only thing reviewable in it is the two resolutions above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
254 lines
7.9 KiB
Go
254 lines
7.9 KiB
Go
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
|
|
}
|