diff --git a/internal/config/config.go b/internal/config/config.go index b766515..92ded93 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,6 @@ import ( "github.com/kami/maven/internal/delivery/telegramsink" "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/netscan" - "github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/update" "github.com/kami/maven/internal/vision" "github.com/robfig/cron/v3" @@ -286,70 +285,6 @@ type Config struct { NetScan *NetScanConfig `json:"netscan,omitempty"` } -// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until -// `"enabled": true`, and even then a discovered device is only ever PROPOSED -// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as -// he would a shell tool. Finding a switch on the network is not the same as -// being allowed to flip it. -type SmartHomeConfig struct { - // Provider — only "homeassistant" is implemented. MQTT / Zigbee2MQTT are - // not: Home Assistant already fronts them, and a broker client is a - // dependency this vendored module tree cannot take on tonight. - Provider string `json:"provider,omitempty"` - - // URL — the instance base, "http://192.168.1.50:8123". - // - // Plain http is accepted and is what the deploy block uses. That is a - // deliberate choice, not an oversight: the instance is on the LAN behind - // wireguard, and a self-signed cert on a home box buys a warning rather - // than a guarantee. It does mean the long-lived token crosses the LAN in - // cleartext on every refresh, so the LAN is part of the trust boundary. - URL string `json:"url,omitempty"` - - // Token — a long-lived access token. Use ${HA_TOKEN} and keep the value in - // the gitignored env file, like the telegram credentials. - Token string `json:"token,omitempty"` - - // Domains — entity domains to take. Empty ⇒ the controllable domains - // EXCEPT lock (light, switch, fan, cover) plus sensor and binary_sensor - // for reads. A lock is only enumerated when it is named here, because a - // front door is not a lamp. Narrow it when the instance is large: a tool name the 1.7B - // half-remembers is a wrong act. - Domains []string `json:"domains,omitempty"` - - // MaxEntities — cap on the proposal catalogue. 0 ⇒ 40. - MaxEntities int `json:"max_entities,omitempty"` - - // Timeout — per-call budget. 0 ⇒ 10s. - Timeout Duration `json:"timeout,omitempty"` - - // Refresh — how often the entity list is re-read and new devices proposed. - // 0 ⇒ 15m, and anything under MinSmartHomeRefresh is raised to it: - // "refresh": "1s" used to pass validation and enumerate the whole instance - // every second. Discovery is idempotent, so this only ever adds rows. - Refresh Duration `json:"refresh,omitempty"` - - // Enabled — false (the default) keeps a written block dark, so it can be - // reviewed before the house is wired to a voice. - Enabled bool `json:"enabled,omitempty"` -} - -// SmartHomeClient maps the config block onto the smarthome package's own type. -// Returns ok=false when nothing is configured or it is disabled, so validation -// and daemon wiring cannot drift on the mapping. -func (c *Config) SmartHomeClient() (smarthome.Config, bool) { - if c.SmartHome == nil || !c.SmartHome.Enabled { - return smarthome.Config{}, false - } - return smarthome.Config{ - URL: c.SmartHome.URL, - Token: c.SmartHome.Token, - Domains: c.SmartHome.Domains, - MaxEntities: c.SmartHome.MaxEntities, - Timeout: time.Duration(c.SmartHome.Timeout), - }, true -} - // NetScanConfig — the LAN scanner block (Vikunja #257). Dark until // `"enabled": true`. // @@ -1118,16 +1053,6 @@ type EmailConfig struct { // DefaultEmailTimeout — extraction budget per message. const DefaultEmailTimeout = 2 * time.Minute -// DefaultSmartHomeRefresh — how often the house is re-enumerated for new -// devices. Slow on purpose: discovery only adds proposals, and a flat does not -// grow a new lamp every minute. -const DefaultSmartHomeRefresh = 15 * time.Minute - -// MinSmartHomeRefresh — the floor under SmartHomeConfig.Refresh. Enumerating -// every entity in the house is a full /api/states read; a misconfigured second -// would hammer the instance for proposals that are idempotent anyway. -const MinSmartHomeRefresh = time.Minute - // PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server // as a managed subprocess and sends chat-completion requests to phrase nudge // and reminder messages. nil ⇒ the template-based Stub is used instead. @@ -1359,17 +1284,7 @@ func (c *Config) applyDefaults() { c.normaliseMCP() - // Same rule for the house: a block that is not enabled is the same as no - // block at all, so "off" stays in one place. - if c.SmartHome != nil && !c.SmartHome.Enabled { - c.SmartHome = nil - } - if c.SmartHome != nil && c.SmartHome.Refresh <= 0 { - c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh) - } - if c.SmartHome != nil && c.SmartHome.Refresh < Duration(MinSmartHomeRefresh) { - c.SmartHome.Refresh = Duration(MinSmartHomeRefresh) - } + c.normaliseSmartHome() // Same rule for the scanner. if c.NetScan != nil && !c.NetScan.Enabled { @@ -1527,15 +1442,8 @@ func (c *Config) validate() error { if err := c.validateMCP(); err != nil { return err } - // Same for the house: a missing token or a bare hostname fails at startup, - // not at the first "выключи свет". - if hc, ok := c.SmartHomeClient(); ok { - if p := c.SmartHome.Provider; p != "" && p != "homeassistant" { - return fmt.Errorf("smarthome: provider %q: only \"homeassistant\" is implemented", p) - } - if err := smarthome.Validate(hc); err != nil { - return err - } + if err := c.validateSmartHome(); err != nil { + return err } // A scanner pointed at the public internet, or at a /8, fails here rather // than after the packets have already left. diff --git a/internal/config/house.go b/internal/config/house.go new file mode 100644 index 0000000..97ee0a6 --- /dev/null +++ b/internal/config/house.go @@ -0,0 +1,109 @@ +package config + +import ( + "fmt" + "time" + + "github.com/kami/maven/internal/smarthome" +) + +// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until +// `"enabled": true`, and even then a discovered device is only ever PROPOSED +// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as +// he would a shell tool. Finding a switch on the network is not the same as +// being allowed to flip it. +type SmartHomeConfig struct { + // Provider — only "homeassistant" is implemented. MQTT / Zigbee2MQTT are + // not: Home Assistant already fronts them, and a broker client is a + // dependency this vendored module tree cannot take on tonight. + Provider string `json:"provider,omitempty"` + + // URL — the instance base, "http://192.168.1.50:8123". + // + // Plain http is accepted and is what the deploy block uses. That is a + // deliberate choice, not an oversight: the instance is on the LAN behind + // wireguard, and a self-signed cert on a home box buys a warning rather + // than a guarantee. It does mean the long-lived token crosses the LAN in + // cleartext on every refresh, so the LAN is part of the trust boundary. + URL string `json:"url,omitempty"` + + // Token — a long-lived access token. Use ${HA_TOKEN} and keep the value in + // the gitignored env file, like the telegram credentials. + Token string `json:"token,omitempty"` + + // Domains — entity domains to take. Empty ⇒ the controllable domains + // EXCEPT lock (light, switch, fan, cover) plus sensor and binary_sensor + // for reads. A lock is only enumerated when it is named here, because a + // front door is not a lamp. Narrow it when the instance is large: a tool name the 1.7B + // half-remembers is a wrong act. + Domains []string `json:"domains,omitempty"` + + // MaxEntities — cap on the proposal catalogue. 0 ⇒ 40. + MaxEntities int `json:"max_entities,omitempty"` + + // Timeout — per-call budget. 0 ⇒ 10s. + Timeout Duration `json:"timeout,omitempty"` + + // Refresh — how often the entity list is re-read and new devices proposed. + // 0 ⇒ 15m, and anything under MinSmartHomeRefresh is raised to it: + // "refresh": "1s" used to pass validation and enumerate the whole instance + // every second. Discovery is idempotent, so this only ever adds rows. + Refresh Duration `json:"refresh,omitempty"` + + // Enabled — false (the default) keeps a written block dark, so it can be + // reviewed before the house is wired to a voice. + Enabled bool `json:"enabled,omitempty"` +} + +// DefaultSmartHomeRefresh — how often the house is re-enumerated for new +// devices. Slow on purpose: discovery only adds proposals, and a flat does not +// grow a new lamp every minute. +const DefaultSmartHomeRefresh = 15 * time.Minute + +// MinSmartHomeRefresh — the floor under SmartHomeConfig.Refresh. Enumerating +// every entity in the house is a full /api/states read; a misconfigured second +// would hammer the instance for proposals that are idempotent anyway. +const MinSmartHomeRefresh = time.Minute + +// SmartHomeClient maps the config block onto the smarthome package's own type. +// Returns ok=false when nothing is configured or it is disabled, so validation +// and daemon wiring cannot drift on the mapping. +func (c *Config) SmartHomeClient() (smarthome.Config, bool) { + if c.SmartHome == nil || !c.SmartHome.Enabled { + return smarthome.Config{}, false + } + return smarthome.Config{ + URL: c.SmartHome.URL, + Token: c.SmartHome.Token, + Domains: c.SmartHome.Domains, + MaxEntities: c.SmartHome.MaxEntities, + Timeout: time.Duration(c.SmartHome.Timeout), + }, true +} + +// normaliseSmartHome applies the block's defaults. A block that is not enabled +// is the same as no block at all, so "off" stays in one place. +func (c *Config) normaliseSmartHome() { + if c.SmartHome != nil && !c.SmartHome.Enabled { + c.SmartHome = nil + } + if c.SmartHome != nil && c.SmartHome.Refresh <= 0 { + c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh) + } + if c.SmartHome != nil && c.SmartHome.Refresh < Duration(MinSmartHomeRefresh) { + c.SmartHome.Refresh = Duration(MinSmartHomeRefresh) + } +} + +// validateSmartHome fails a missing token or a bare hostname at startup, not at +// the first "выключи свет". +func (c *Config) validateSmartHome() error { + hc, ok := c.SmartHomeClient() + if !ok { + return nil + } + if p := c.SmartHome.Provider; p != "" && p != "homeassistant" { + return fmt.Errorf("smarthome: provider %q: only \"homeassistant\" is implemented", p) + } + return smarthome.Validate(hc) +}