7414ef4c39
Pure move, with one comment corrected on the way. NetScanConfig.Rate said "0 ⇒ 50"; netscan.DefaultRate is 100 and has been since the package was written, and deploy/mavend.json sets 100 explicitly. The three sibling defaults now name the constant they come from rather than restating a number that can drift again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
6.7 KiB
Go
177 lines
6.7 KiB
Go
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)
|
|
}
|