d79b30a1a6
The weather line spelled "градусов" out in the template, which is the wrong
form for 1-4 and for every number ending in 1-4. Russian inflects the noun
after a numeral, so the count splits into the number and {word}.
hostWord in cmd/mavend/netscan.go already knew the rule for устройство and was
the only place that did. It moves to internal/phraser as CountWord, with
Degrees and Devices over it, and the three call sites that counted devices now
read the same helper the weather line does. Degrees rounds before it counts, so
the noun agrees with the number she is about to say rather than the reading
behind it, and a negative reading counts by its magnitude.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
250 lines
7.9 KiB
Go
250 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"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 "не смогла достучаться до дома.", true
|
|
}
|
|
if len(ents) == 0 {
|
|
return "дом ничего не отдаёт.", 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 := "включено: " + 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 fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, 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
|
|
}
|