// Package config defines the runtime configuration for the galahad2lcd // daemon and handles loading and saving it as a TOML file. package config import ( "errors" "fmt" "os" "path/filepath" "strings" "github.com/BurntSushi/toml" ) // Default paths used across the project. They mirror the historical Rust // driver so that migration is painless. const ( DefaultConfigPath = "/etc/galahad2lcd.toml" DefaultCachePath = "/tmp/galahad_cache.h264" ServiceName = "galahad2lcd" ) // Default hardware identifiers for the Lian Li Galahad II LCD. const ( DefaultVendorID = 0x0416 DefaultProductID = 0x7395 ) // Display holds the media-related settings. type Display struct { // Input is the path to the source video or GIF file. Input string `toml:"input"` // Rotate is the clockwise rotation in degrees: 0, 90, 180 or 270. Rotate int `toml:"rotate"` // Speed scales playback: >1 plays slower, <1 plays faster. 1.0 is normal. Speed float64 `toml:"speed"` } // USB holds the device identifiers used to locate the LCD. type USB struct { VendorID int `toml:"vendor_id"` ProductID int `toml:"product_id"` } // Stream holds playback behaviour. type Stream struct { // FPS forces a fixed output framerate. 0 (the default) means auto-detect // from the source file. FPS float64 `toml:"fps"` // CachePath is where the transcoded H.264 stream is written. CachePath string `toml:"cache_path"` } // Config is the top-level configuration model. type Config struct { Display Display `toml:"display"` USB USB `toml:"usb"` Stream Stream `toml:"stream"` } // Default returns a configuration populated with sensible defaults. func Default() Config { return Config{ Display: Display{ Input: "", Rotate: 0, Speed: 1.0, }, USB: USB{ VendorID: DefaultVendorID, ProductID: DefaultProductID, }, Stream: Stream{ FPS: 0, CachePath: DefaultCachePath, }, } } // Load reads the configuration from path. A missing file results in a // configuration with defaults; any other read or parse error is returned. func Load(path string) (Config, error) { cfg := Default() if path == "" { path = DefaultConfigPath } data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return cfg, nil } return cfg, fmt.Errorf("read config %q: %w", path, err) } if err := toml.Unmarshal(data, &cfg); err != nil { return cfg, fmt.Errorf("parse config %q: %w", path, err) } return cfg, nil } // Save writes the configuration to path, creating parent directories. func (c Config) Save(path string) error { if path == "" { path = DefaultConfigPath } if dir := filepath.Dir(path); dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("create config dir %q: %w", dir, err) } } var b strings.Builder enc := toml.NewEncoder(&b) if err := enc.Encode(c); err != nil { return fmt.Errorf("encode config: %w", err) } if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { return fmt.Errorf("write config %q: %w", path, err) } return nil } // Validate checks the configuration for correctness and returns a // human-readable description of the first problem found, if any. func (c Config) Validate() error { if c.Display.Input == "" { return errors.New("display.input must not be empty") } switch c.Display.Rotate { case 0, 90, 180, 270: default: return fmt.Errorf("display.rotate must be 0, 90, 180 or 270, got %d", c.Display.Rotate) } if c.Display.Speed <= 0 { return fmt.Errorf("display.speed must be > 0, got %v", c.Display.Speed) } if c.Stream.FPS < 0 { return fmt.Errorf("stream.fps must be >= 0, got %v", c.Stream.FPS) } if c.Stream.CachePath == "" { return errors.New("stream.cache_path must not be empty") } return nil }