package configuration import ( "errors" "fmt" "net" "net/url" "os" "strings" "gopkg.in/yaml.v2" ) const defaultTimeoutSeconds = 10 // Configuration contains the HTTP server and Shelly device configuration. type Configuration struct { Host string `yaml:"host"` Port uint16 `yaml:"port"` LogLevel int `yaml:"logLevel"` Shelly ShellyConfiguration `yaml:"shelly"` } // ShellyConfiguration controls communication with all configured devices. type ShellyConfiguration struct { TimeoutSeconds int `yaml:"timeoutSeconds"` Devices []DeviceConfiguration `yaml:"devices"` } // DeviceConfiguration identifies a single Shelly device and its product implementation. type DeviceConfiguration struct { Name string `yaml:"name"` Product string `yaml:"product"` URL string `yaml:"url"` Username string `yaml:"username"` Password string `yaml:"password"` } // Load reads and strictly validates a YAML configuration file. func Load(path string) (*Configuration, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read configuration %q: %w", path, err) } var config Configuration if err := yaml.UnmarshalStrict(data, &config); err != nil { return nil, fmt.Errorf("parse configuration %q: %w", path, err) } config.applyDefaults() if err := config.Validate(); err != nil { return nil, err } return &config, nil } func (c *Configuration) applyDefaults() { if c.Shelly.TimeoutSeconds == 0 { c.Shelly.TimeoutSeconds = defaultTimeoutSeconds } } // Validate checks fields that are independent of a concrete product implementation. func (c *Configuration) Validate() error { if net.ParseIP(c.Host) == nil { return fmt.Errorf("host is not a valid IP address: %q", c.Host) } if c.Port == 0 { return errors.New("port must be greater than zero") } if c.LogLevel < 0 || c.LogLevel > 6 { return errors.New("logLevel must be between 0 and 6") } if c.Shelly.TimeoutSeconds <= 0 { return errors.New("shelly.timeoutSeconds must be greater than zero") } if len(c.Shelly.Devices) == 0 { return errors.New("shelly.devices cannot be empty") } names := make(map[string]struct{}, len(c.Shelly.Devices)) for index, device := range c.Shelly.Devices { prefix := fmt.Sprintf("shelly.devices[%d]", index) if strings.TrimSpace(device.Name) == "" { return fmt.Errorf("%s.name cannot be empty", prefix) } if _, exists := names[device.Name]; exists { return fmt.Errorf("device name %q is configured more than once", device.Name) } names[device.Name] = struct{}{} if strings.TrimSpace(device.Product) == "" { return fmt.Errorf("%s.product cannot be empty", prefix) } parsedURL, err := url.ParseRequestURI(device.URL) if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { return fmt.Errorf("%s.url must be an absolute HTTP or HTTPS URL", prefix) } if parsedURL.RawQuery != "" || parsedURL.Fragment != "" { return fmt.Errorf("%s.url cannot contain a query or fragment", prefix) } } return nil }