Initila commit
Build Docker image on push / docker (push) Successful in 22s

This commit is contained in:
2026-08-18 00:46:59 +02:00
commit 9db7aa2560
28 changed files with 2823 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
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
}