Files
galahad2lcd/internal/config/config.go
T
Maksim Totmin 4c34f0bedc Initial commit: Go driver for the Lian Li Galahad II LCD
Streams H.264 to the Galahad II LCD via USB (gousb) with:
- ffmpeg CLI transcoding (no fragile FFmpeg C-ABI bindings)
- pure-Go Annex-B access-unit splitting
- framerate pacing with graceful shutdown and USB reconnect
- TOML config, cobra CLI, structured slog logging, unit tests
2026-08-19 12:03:54 +07:00

146 lines
3.7 KiB
Go

// 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
}