dc4c5b7841
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
216 lines
6.3 KiB
Go
216 lines
6.3 KiB
Go
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
|
|
}
|