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
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Display.Speed != 1.0 {
|
||||
t.Errorf("default speed = %v, want 1.0", cfg.Display.Speed)
|
||||
}
|
||||
if cfg.USB.VendorID != DefaultVendorID || cfg.USB.ProductID != DefaultProductID {
|
||||
t.Errorf("default usb ids = %x:%x, want %x:%x",
|
||||
cfg.USB.VendorID, cfg.USB.ProductID, DefaultVendorID, DefaultProductID)
|
||||
}
|
||||
if cfg.Stream.CachePath == "" {
|
||||
t.Error("default cache_path must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFileReturnsDefaults(t *testing.T) {
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "nope.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load(missing) error = %v", err)
|
||||
}
|
||||
if cfg.Display.Input != "" {
|
||||
t.Errorf("input = %q, want empty", cfg.Display.Input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoadRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub", "galahad2lcd.toml")
|
||||
orig := Default()
|
||||
orig.Display.Input = "/tmp/demo.gif"
|
||||
orig.Display.Rotate = 180
|
||||
orig.Display.Speed = 1.5
|
||||
orig.USB.VendorID = 0x1234
|
||||
orig.Stream.FPS = 15
|
||||
|
||||
if err := orig.Save(path); err != nil {
|
||||
t.Fatalf("Save error = %v", err)
|
||||
}
|
||||
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load error = %v", err)
|
||||
}
|
||||
if got.Display != orig.Display {
|
||||
t.Errorf("display = %+v, want %+v", got.Display, orig.Display)
|
||||
}
|
||||
if got.USB != orig.USB {
|
||||
t.Errorf("usb = %+v, want %+v", got.USB, orig.USB)
|
||||
}
|
||||
if got.Stream != orig.Stream {
|
||||
t.Errorf("stream = %+v, want %+v", got.Stream, orig.Stream)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", func(c *Config) { c.Display.Input = "/tmp/x.gif" }, false},
|
||||
{"empty input", func(c *Config) {}, true},
|
||||
{"bad rotate", func(c *Config) { c.Display.Input = "/tmp/x.gif"; c.Display.Rotate = 45 }, true},
|
||||
{"negative speed", func(c *Config) { c.Display.Input = "/tmp/x.gif"; c.Display.Speed = -1 }, true},
|
||||
{"negative fps", func(c *Config) { c.Display.Input = "/tmp/x.gif"; c.Stream.FPS = -2 }, true},
|
||||
{"empty cache", func(c *Config) { c.Display.Input = "/tmp/x.gif"; c.Stream.CachePath = "" }, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.mutate(&cfg)
|
||||
err := cfg.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr = %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user