package config import ( "fmt" "time" "github.com/kami/maven/internal/netscan" "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) } // NetScanConfig — the LAN scanner block (Vikunja #257). Dark until // `"enabled": true`. // // The important field is Subnets, and it is the ONLY source of a scan target. // Nothing an utterance, a router or a scanned host says can widen or move the // range: internal/netscan.Scanner.Scan takes no target argument at all. Each // subnet must be private and no larger than netscan.MaxPrefixHosts addresses // (a /22), enforced at config load rather than at the first spoken scan. type NetScanConfig struct { // Subnets — CIDRs to scan, "192.168.1.0/24". Subnets []string `json:"subnets,omitempty"` // Ports — TCP ports to try per host. Empty ⇒ netscan.DefaultPorts // (22, 80, 443, 8080). Ports []int `json:"ports,omitempty"` // Timeout — per-connection budget. 0 ⇒ netscan.DefaultTimeout (400ms). Timeout Duration `json:"timeout,omitempty"` // Rate — connections per second across the whole scan. 0 ⇒ // netscan.DefaultRate (100). Low on purpose: a scan should look like // background traffic, not a portscan. Rate int `json:"rate,omitempty"` // MaxHosts — cap on addresses probed per scan. 0 ⇒ netscan.DefaultMaxHosts // (256). MaxHosts int `json:"max_hosts,omitempty"` // Enabled — false (the default) keeps a written block dark. Enabled bool `json:"enabled,omitempty"` } // NetScanner maps the config block onto the netscan package's own type. // ok=false when absent or disabled, so validation and daemon wiring cannot // drift on the mapping. func (c *Config) NetScanner() (netscan.Config, bool) { if c.NetScan == nil || !c.NetScan.Enabled { return netscan.Config{}, false } return netscan.Config{ Subnets: c.NetScan.Subnets, Ports: c.NetScan.Ports, Timeout: time.Duration(c.NetScan.Timeout), Rate: c.NetScan.Rate, MaxHosts: c.NetScan.MaxHosts, }, true } // normaliseNetScan applies the block's defaults. Same rule as the house: not // enabled is the same as no block at all. func (c *Config) normaliseNetScan() { if c.NetScan != nil && !c.NetScan.Enabled { c.NetScan = nil } } // validateNetScan fails a scanner pointed at the public internet, or at a /8, // here rather than after the packets have already left. func (c *Config) validateNetScan() error { nc, ok := c.NetScanner() if !ok { return nil } return netscan.Validate(nc) }