commit 4c34f0bedcceaa0b74516a0cebe09e17e92d70e6 Author: Maksim Totmin Date: Wed Aug 19 12:03:54 2026 +0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36e0027 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +*.h264 +!testdata/sample.h264 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e97cb15 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +BINARY := galahad2lcd +PKG := ./cmd/galahad2lcd +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) + +.PHONY: all build test vet lint fmt install uninstall clean + +all: build + +build: + go build -trimpath -ldflags "$(LDFLAGS)" -o bin/$(BINARY) $(PKG) + +test: + go test ./... + +vet: + go vet ./... + +fmt: + gofmt -l -w cmd internal + +lint: + go vet ./... + gofmt -l cmd internal + +install: build + install -m 0755 bin/$(BINARY) /usr/local/bin/$(BINARY) + +uninstall: + rm -f /usr/local/bin/$(BINARY) + +clean: + rm -rf bin \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c92e73b --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# galahad2lcd + +A modern Go driver for the **Lian Li Galahad II LCD** cooler display. It +transcodes a video or GIF to H.264 and streams it over USB to the 480×480 +LCD, running as a systemd service. + +This is a clean-room rewrite of the archived +[GalahadII_LCD_Linux](https://github.com/H4rk3nz0/GalahadII_LCD_Linux) +project. It keeps the exact wire protocol but drops the fragile FFmpeg C-ABI +dependency in favour of the system `ffmpeg` binary, adds structured logging, +automatic USB reconnection and a testable, layered architecture. + +## Features + +- `daemon` — transcodes media to H.264 and streams it in a loop +- `set` — change the input/rotation/speed and restart the service +- `list` — enumerate USB devices to diagnose connectivity +- `version` — print the build version +- Automatic reconnect if the USB link is lost (e.g. after suspend) +- Graceful shutdown on SIGINT/SIGTERM (`systemctl stop` works cleanly) +- Structured logs (`log/slog`) captured by journald +- Rotation, playback speed and framerate overrides via config or flags + +## Requirements + +- Go 1.22+ (to build) +- FFmpeg + FFprobe (any recent version, e.g. `sudo pacman -S ffmpeg`) +- libusb (transitive build dependency of `github.com/google/gousb`) +- systemd + +## Install + +```sh +sudo ./packaging/install.sh /path/to/media.gif +``` + +The script builds the binary, installs the systemd unit and udev rule, writes +an initial config and starts the service. If no media file is given, configure +it afterwards: + +```sh +sudo galahad2lcd set --input /path/to/media.gif --rotate 0 +``` + +## Configuration + +Settings live in `/etc/galahad2lcd.toml`: + +```toml +[display] +input = "/home/user/media.gif" +rotate = 0 # 0, 90, 180, 270 (clockwise) +speed = 1.0 # >1 slower, <1 faster + +[usb] +vendor_id = 0x0416 +product_id = 0x7395 + +[stream] +fps = 0 # 0 = auto-detect from the source +cache_path = "/tmp/galahad_cache.h264" +``` + +The `set` command updates this file and restarts the service: + +```sh +sudo galahad2lcd set --input /home/user/media.gif --rotate 90 --speed 1.2 +sudo galahad2lcd set --fps 15 --no-restart # apply later +``` + +CLI flags also override the config for one-off runs: + +```sh +sudo galahad2lcd daemon --input /home/user/media.gif --rotate 180 +``` + +## Diagnostics + +```sh +galahad2lcd list # find the LCD on the USB bus (needs udev rule or root) +journalctl -u galahad2lcd -f # live daemon logs +``` + +The bundled udev rule (`packaging/99-galahad2lcd.rules`) grants the +logged-in session user access to the device so `list` works without root. + +## Architecture + +``` +cmd/galahad2lcd entry point (composition root) +internal/cli cobra commands: daemon, set, list, version +internal/config TOML configuration model, load/save/validate +internal/transcoder ffmpeg/ffprobe wrapper, filter graph, FPS detection +internal/h264 Annex-B stream → access-unit splitting (pure Go) +internal/stream frame-rate pacing loop (testable, device-agnostic) +internal/device Device interface + gousb (libusb) implementation +internal/protocol USB framing (pure functions, golden-tested) +internal/service systemd unit management +``` + +Layers depend only downward. `stream` talks to a `device.Device` interface, +so the transport can be swapped or faked in tests without touching the rest +of the pipeline. + +## Development + +```sh +make build # build ./bin/galahad2lcd +make test # unit + integration tests (integration needs ffmpeg) +make vet # go vet +make fmt # gofmt +make install # copy binary to /usr/local/bin +``` + +## Uninstall + +```sh +sudo ./packaging/uninstall.sh +``` + +## License + +MIT \ No newline at end of file diff --git a/cmd/galahad2lcd/main.go b/cmd/galahad2lcd/main.go new file mode 100644 index 0000000..97391b0 --- /dev/null +++ b/cmd/galahad2lcd/main.go @@ -0,0 +1,21 @@ +// Command galahad2lcd is a Go driver for the Lian Li Galahad II LCD. +package main + +import ( + "fmt" + "os" + + "galahad2lcd/internal/cli" +) + +// version is overridden at build time with +// +// -ldflags "-X main.version=" +var version = "dev" + +func main() { + if err := cli.Execute(version); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..01254da --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module galahad2lcd + +go 1.26.6 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/google/gousb v1.1.3 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5e989ca --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/google/gousb v1.1.3 h1:xt6M5TDsGSZ+rlomz5Si5Hmd/Fvbmo2YCJHN+yGaK4o= +github.com/google/gousb v1.1.3/go.mod h1:GGWUkK0gAXDzxhwrzetW592aOmkkqSGcj5KLEgmCVUg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..696a491 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,107 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + + "galahad2lcd/internal/config" +) + +func TestApplyOverridesChangedFlags(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "media.gif") + if err := os.WriteFile(src, []byte("gif"), 0o644); err != nil { + t.Fatal(err) + } + + cmd := &cobra.Command{} + var input string + var rotate int + var speed, fps float64 + cmd.Flags().StringVar(&input, "input", "", "") + cmd.Flags().IntVar(&rotate, "rotate", 0, "") + cmd.Flags().Float64Var(&speed, "speed", 0, "") + cmd.Flags().Float64Var(&fps, "fps", 0, "") + _ = cmd.Flags().Set("input", src) + _ = cmd.Flags().Set("rotate", "180") + _ = cmd.Flags().Set("speed", "1.5") + _ = cmd.Flags().Set("fps", "24") + + cfg := config.Default() + if err := applyOverrides(cmd, &cfg, input, rotate, speed, fps); err != nil { + t.Fatalf("applyOverrides error = %v", err) + } + if cfg.Display.Input != src { + t.Errorf("input = %q, want %q", cfg.Display.Input, src) + } + if cfg.Display.Rotate != 180 { + t.Errorf("rotate = %d, want 180", cfg.Display.Rotate) + } + if cfg.Display.Speed != 1.5 { + t.Errorf("speed = %v, want 1.5", cfg.Display.Speed) + } + if cfg.Stream.FPS != 24 { + t.Errorf("fps = %v, want 24", cfg.Stream.FPS) + } +} + +func TestApplyOverridesUnchangedFlags(t *testing.T) { + cmd := &cobra.Command{} + var input string + var rotate int + var speed, fps float64 + cmd.Flags().StringVar(&input, "input", "", "") + cmd.Flags().IntVar(&rotate, "rotate", 0, "") + cmd.Flags().Float64Var(&speed, "speed", 0, "") + cmd.Flags().Float64Var(&fps, "fps", 0, "") + + cfg := config.Default() + if err := applyOverrides(cmd, &cfg, input, rotate, speed, fps); err != nil { + t.Fatalf("applyOverrides error = %v", err) + } + if cfg.Display.Input != "" || cfg.Display.Rotate != 0 { + t.Errorf("unset flags changed config: %+v", cfg) + } +} + +func TestResolveInput(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "x.gif") + if err := os.WriteFile(file, nil, 0o644); err != nil { + t.Fatal(err) + } + + abs, err := resolveInput(file) + if err != nil { + t.Fatalf("resolveInput error = %v", err) + } + if abs != file { + t.Errorf("resolveInput = %q, want %q", abs, file) + } + + if _, err := resolveInput(filepath.Join(dir, "missing.gif")); err == nil { + t.Error("resolveInput returned nil error for missing file") + } + if _, err := resolveInput(dir); err == nil { + t.Error("resolveInput returned nil error for a directory") + } +} + +func TestRootVersionCommand(t *testing.T) { + root := newRootCmd("1.2.3") + root.SetArgs([]string{"version"}) + if err := root.Execute(); err != nil { + t.Fatalf("version command error = %v", err) + } +} + +func TestRootSetCommandRequiresFlags(t *testing.T) { + root := newRootCmd("dev") + root.SetArgs([]string{"set"}) + if err := root.Execute(); err == nil { + t.Error("set with no flags returned nil error") + } +} diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go new file mode 100644 index 0000000..9723d98 --- /dev/null +++ b/internal/cli/daemon.go @@ -0,0 +1,106 @@ +package cli + +import ( + "errors" + "fmt" + "log/slog" + "time" + + "github.com/spf13/cobra" + + "galahad2lcd/internal/config" + "galahad2lcd/internal/device" + "galahad2lcd/internal/stream" + "galahad2lcd/internal/transcoder" +) + +// openTimeout bounds how long the daemon waits for the USB device to appear. +const openTimeout = 30 * time.Second + +func newDaemonCmd() *cobra.Command { + var ( + configPath string + input string + rotate int + speed float64 + fps float64 + ) + + cmd := &cobra.Command{ + Use: "daemon", + Short: "Run the display streaming daemon", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return err + } + if err := applyOverrides(cmd, &cfg, input, rotate, speed, fps); err != nil { + return err + } + if err := cfg.Validate(); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + return runDaemon(cfg) + }, + } + + cmd.Flags().StringVar(&configPath, "config", config.DefaultConfigPath, "path to the configuration file") + cmd.Flags().StringVar(&input, "input", "", "override the input media file") + cmd.Flags().IntVar(&rotate, "rotate", 0, "override the rotation in degrees (0, 90, 180, 270)") + cmd.Flags().Float64Var(&speed, "speed", 0, "override the playback speed (>1 slower, <1 faster)") + cmd.Flags().Float64Var(&fps, "fps", 0, "override the output framerate (0 = auto)") + return cmd +} + +// runDaemon transcodes the configured media, loads the resulting frames and +// streams them to the display, reconnecting automatically if the USB link is +// lost (e.g. after system suspend). +func runDaemon(cfg config.Config) error { + ctx, stop := newSignalContext() + defer stop() + + tc, err := transcoder.New() + if err != nil { + return err + } + + slog.Info("transcoding input to H.264", "input", cfg.Display.Input, "rotate", cfg.Display.Rotate) + fps, err := tc.Transcode(ctx, cfg) + if err != nil { + return err + } + slog.Info("transcoding complete", "fps", fps, "cache", cfg.Stream.CachePath) + + packets, err := stream.LoadPackets(cfg.Stream.CachePath) + if err != nil { + return err + } + if len(packets) == 0 { + return errors.New("no video frames found in input") + } + slog.Info("frames buffered", "count", len(packets)) + + dev, err := device.OpenWithRetry(ctx, cfg.USB.VendorID, cfg.USB.ProductID, openTimeout) + if err != nil { + return err + } + slog.Info("device connected") + + for { + err := stream.New(dev, packets, fps, cfg.Display.Speed).Run(ctx) + if err == nil || ctx.Err() != nil { + _ = dev.Close() + return nil // clean shutdown + } + + slog.Error("stream interrupted; reconnecting", "error", err) + _ = dev.Close() + + dev, err = device.OpenWithRetry(ctx, cfg.USB.VendorID, cfg.USB.ProductID, openTimeout) + if err != nil { + return err + } + slog.Info("device reconnected") + } +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go new file mode 100644 index 0000000..8b3a9c2 --- /dev/null +++ b/internal/cli/flags.go @@ -0,0 +1,48 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "galahad2lcd/internal/config" +) + +// applyOverrides merges explicitly provided flags into cfg. Zero-valued +// flags are ignored unless the user actually changed them. +func applyOverrides(cmd *cobra.Command, cfg *config.Config, input string, rotate int, speed, fps float64) error { + if cmd.Flags().Changed("input") { + abs, err := resolveInput(input) + if err != nil { + return err + } + cfg.Display.Input = abs + } + if cmd.Flags().Changed("rotate") { + cfg.Display.Rotate = rotate + } + if cmd.Flags().Changed("speed") { + cfg.Display.Speed = speed + } + if cmd.Flags().Changed("fps") { + cfg.Stream.FPS = fps + } + return nil +} + +// resolveInput converts a relative path to an absolute one and verifies the +// file exists, returning a helpful error otherwise. +func resolveInput(input string) (string, error) { + abs, err := filepath.Abs(input) + if err != nil { + return "", fmt.Errorf("resolve input %q: %w", input, err) + } + if st, err := os.Stat(abs); err != nil { + return "", fmt.Errorf("input file %q does not exist", abs) + } else if st.IsDir() { + return "", fmt.Errorf("input %q is a directory, expected a media file", abs) + } + return abs, nil +} diff --git a/internal/cli/list.go b/internal/cli/list.go new file mode 100644 index 0000000..80852a2 --- /dev/null +++ b/internal/cli/list.go @@ -0,0 +1,55 @@ +package cli + +import ( + "fmt" + "sort" + "text/tabwriter" + + "github.com/spf13/cobra" + + "galahad2lcd/internal/device" +) + +func newListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List USB devices visible to the driver", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + ctx, stop := newSignalContext() + defer stop() + + infos, err := device.ListDevices(ctx) + if err != nil { + return err + } + + if len(infos) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no USB devices found (are permissions configured?)") + return nil + } + + sort.Slice(infos, func(i, j int) bool { + return infos[i].Bus < infos[j].Bus || + (infos[i].Bus == infos[j].Bus && infos[i].Address < infos[j].Address) + }) + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "BUS\tADDR\tVID:PID\tPRODUCT\t") + for _, info := range infos { + product := info.Product + if product == "" { + product = "-" + } + marker := " " + if info.IsLCD { + marker = "*" + } + fmt.Fprintf(w, "%s%d\t%d\t%04x:%04x\t%s\t\n", + marker, info.Bus, info.Address, info.VendorID, info.ProductID, product) + } + fmt.Fprintln(w, "* Lian Li Galahad II LCD (0416:7395)") + return w.Flush() + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..0288241 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,66 @@ +// Package cli implements the galahad2lcd command-line interface. +package cli + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/spf13/cobra" +) + +// Execute runs the CLI and returns the first error encountered. The version +// is injected by the caller (typically main via -ldflags). +func Execute(version string) error { + root := newRootCmd(version) + return root.Execute() +} + +func newRootCmd(version string) *cobra.Command { + var verbose bool + + root := &cobra.Command{ + Use: "galahad2lcd", + Short: "Driver for the Lian Li Galahad II LCD", + Long: `galahad2lcd streams H.264 video to the Lian Li Galahad II LCD +display over USB.`, + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + level := slog.LevelInfo + if verbose { + level = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) + }, + } + + root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable debug logging") + + root.AddCommand( + newDaemonCmd(), + newSetCmd(), + newListCmd(), + newVersionCmd(version), + ) + return root +} + +func newVersionCmd(version string) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the version and exit", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + fmt.Fprintln(cmd.OutOrStdout(), version) + return nil + }, + } +} + +// signalContext returns a context cancelled on SIGINT and SIGTERM so that +// systemctl stop and Ctrl-C both trigger a clean shutdown. +func signalContext() (context.Context, context.CancelFunc) { + return newSignalContext() +} diff --git a/internal/cli/set.go b/internal/cli/set.go new file mode 100644 index 0000000..82abba1 --- /dev/null +++ b/internal/cli/set.go @@ -0,0 +1,78 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "galahad2lcd/internal/config" + "galahad2lcd/internal/service" +) + +func newSetCmd() *cobra.Command { + var ( + configPath string + input string + rotate int + speed float64 + fps float64 + noRestart bool + ) + + cmd := &cobra.Command{ + Use: "set", + Short: "Update display settings and restart the service", + Long: `Update the galahad2lcd configuration file and restart the systemd +service. Provide at least one flag to change a setting. Writing the default +configuration path requires root.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if !cmd.Flags().Changed("input") && + !cmd.Flags().Changed("rotate") && + !cmd.Flags().Changed("speed") && + !cmd.Flags().Changed("fps") { + return fmt.Errorf("nothing to set; provide at least one of --input, --rotate, --speed, --fps") + } + + cfg, err := config.Load(configPath) + if err != nil { + return err + } + if err := applyOverrides(cmd, &cfg, input, rotate, speed, fps); err != nil { + return err + } + if err := cfg.Validate(); err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + + if configPath == config.DefaultConfigPath && os.Geteuid() != 0 { + return fmt.Errorf("writing %s requires root (run with sudo)", configPath) + } + if err := cfg.Save(configPath); err != nil { + return err + } + fmt.Printf("configuration saved to %s\n", configPath) + + if noRestart { + return nil + } + + ctx, stop := newSignalContext() + defer stop() + if err := service.New(config.ServiceName).Restart(ctx); err != nil { + return err + } + fmt.Println("service restarted") + return nil + }, + } + + cmd.Flags().StringVar(&configPath, "config", config.DefaultConfigPath, "path to the configuration file") + cmd.Flags().StringVar(&input, "input", "", "path to the media file") + cmd.Flags().IntVar(&rotate, "rotate", 0, "rotation in degrees (0, 90, 180, 270)") + cmd.Flags().Float64Var(&speed, "speed", 0, "playback speed (>1 slower, <1 faster)") + cmd.Flags().Float64Var(&fps, "fps", 0, "output framerate (0 = auto)") + cmd.Flags().BoolVar(&noRestart, "no-restart", false, "do not restart the service after saving") + return cmd +} diff --git a/internal/cli/signal.go b/internal/cli/signal.go new file mode 100644 index 0000000..d0e369b --- /dev/null +++ b/internal/cli/signal.go @@ -0,0 +1,14 @@ +package cli + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +// newSignalContext returns a context that is cancelled on SIGINT or SIGTERM, +// so that Ctrl-C and systemctl stop both trigger a clean shutdown. +func newSignalContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7cfd616 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9ba44be --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + }) + } +} diff --git a/internal/device/device.go b/internal/device/device.go new file mode 100644 index 0000000..1d7dcbc --- /dev/null +++ b/internal/device/device.go @@ -0,0 +1,21 @@ +// Package device abstracts the Lian Li Galahad II LCD hardware. The streamer +// depends only on the Device interface, so tests can substitute a fake and +// future hardware can be supported by adding new implementations. +package device + +import ( + "context" + "errors" +) + +// Device sends H.264 frames to a display. Implementations must be safe for +// sequential use by a single goroutine. +type Device interface { + // WriteFrame transmits one encoded access unit to the display. + WriteFrame(ctx context.Context, frame []byte) error + // Close releases all hardware resources. + Close() error +} + +// ErrNotFound is returned when no matching USB device is present. +var ErrNotFound = errors.New("device not found") diff --git a/internal/device/list.go b/internal/device/list.go new file mode 100644 index 0000000..a2adf5b --- /dev/null +++ b/internal/device/list.go @@ -0,0 +1,57 @@ +package device + +import ( + "context" + "fmt" + + "github.com/google/gousb" +) + +// Info describes a USB device for the diagnostics listing. +type Info struct { + VendorID int + ProductID int + Manufacturer string + Product string + Serial string + Bus int + Address int + IsLCD bool +} + +// ListDevices enumerates all USB devices visible to libusb. Descriptor +// strings are best-effort: reading them requires device access, which is +// why failures are ignored. +func ListDevices(ctx context.Context) ([]Info, error) { + gctx := gousb.NewContext() + defer gctx.Close() + + devs, err := gctx.OpenDevices(func(*gousb.DeviceDesc) bool { return true }) + if err != nil && len(devs) == 0 { + return nil, fmt.Errorf("enumerate USB devices (run as root or install the udev rule): %w", err) + } + defer func() { + for _, d := range devs { + _ = d.Close() + } + }() + + infos := make([]Info, 0, len(devs)) + for _, d := range devs { + info := Info{ + VendorID: int(d.Desc.Vendor), + ProductID: int(d.Desc.Product), + Bus: d.Desc.Bus, + Address: d.Desc.Address, + IsLCD: d.Desc.Vendor == gousb.ID(0x0416) && d.Desc.Product == gousb.ID(0x7395), + } + if info.IsLCD { + // Only read strings for the device we care about. + info.Manufacturer, _ = d.Manufacturer() + info.Product, _ = d.Product() + info.Serial, _ = d.SerialNumber() + } + infos = append(infos, info) + } + return infos, err +} diff --git a/internal/device/usb.go b/internal/device/usb.go new file mode 100644 index 0000000..f9d79f7 --- /dev/null +++ b/internal/device/usb.go @@ -0,0 +1,153 @@ +package device + +import ( + "context" + "fmt" + "time" + + "github.com/google/gousb" + + "galahad2lcd/internal/protocol" +) + +// Lian Li Galahad II LCD USB layout (from the reference driver and +// protocol reversals). +const ( + interfaceNumber = 1 + endpointNumber = 2 + configNumber = 1 + writeTimeout = time.Second +) + +// USBDevice is the gousb-backed implementation of Device. +type USBDevice struct { + gctx *gousb.Context + dev *gousb.Device + intf *gousb.Interface + ep *gousb.OutEndpoint +} + +// Open locates the display by vendor/product ID and claims its video +// interface. The kernel driver is detached automatically and reattached on +// Close. +func Open(ctx context.Context, vendorID, productID int) (*USBDevice, error) { + gctx := gousb.NewContext() + + dev, err := gctx.OpenDeviceWithVIDPID(gousb.ID(vendorID), gousb.ID(productID)) + if err != nil { + gctx.Close() + return nil, fmt.Errorf("open USB device %04x:%04x: %w", vendorID, productID, err) + } + if dev == nil { + gctx.Close() + return nil, fmt.Errorf("USB device %04x:%04x: %w", vendorID, productID, ErrNotFound) + } + + // Auto-detach releases any kernel driver (e.g. usbhid) so we can claim + // the interface, and reattaches it when we are done. + if err := dev.SetAutoDetach(true); err != nil { + _ = dev.Close() + gctx.Close() + return nil, fmt.Errorf("enable auto-detach: %w", err) + } + + cfg, err := dev.Config(configNumber) + if err != nil { + _ = dev.Close() + gctx.Close() + return nil, fmt.Errorf("select USB config %d: %w", configNumber, err) + } + + intf, err := cfg.Interface(interfaceNumber, 0) + if err != nil { + _ = cfg.Close() + _ = dev.Close() + gctx.Close() + return nil, fmt.Errorf("claim interface %d: %w", interfaceNumber, err) + } + + ep, err := intf.OutEndpoint(endpointNumber) + if err != nil { + intf.Close() + _ = dev.Close() + gctx.Close() + return nil, fmt.Errorf("open out endpoint 0x0%x: %w", endpointNumber, err) + } + + return &USBDevice{gctx: gctx, dev: dev, intf: intf, ep: ep}, nil +} + +// OpenWithRetry keeps trying to open the device until ctx is done or +// timeout elapses, with exponential backoff. This lets the service start +// before the USB device has finished enumerating at boot. +func OpenWithRetry(ctx context.Context, vendorID, productID int, timeout time.Duration) (*USBDevice, error) { + deadline := time.Now().Add(timeout) + backoff := 500 * time.Millisecond + + var lastErr error + for { + d, err := Open(ctx, vendorID, productID) + if err == nil { + return d, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + lastErr = err + if time.Now().After(deadline) { + return nil, fmt.Errorf("device %04x:%04x unavailable for %s: %w", + vendorID, productID, timeout, lastErr) + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff): + if backoff < 5*time.Second { + backoff *= 2 + } + } + } +} + +// WriteFrame encodes the frame into USB packets and transmits them with a +// bounded timeout per packet. +func (d *USBDevice) WriteFrame(ctx context.Context, frame []byte) error { + packets, err := protocol.EncodePackets(frame) + if err != nil { + return fmt.Errorf("encode frame: %w", err) + } + + for _, pkt := range packets { + writeCtx, cancel := context.WithTimeout(ctx, writeTimeout) + n, err := d.ep.WriteContext(writeCtx, pkt) + cancel() + + if err != nil { + return fmt.Errorf("USB write: %w", err) + } + if n != len(pkt) { + return fmt.Errorf("USB short write: %d of %d bytes", n, len(pkt)) + } + } + return nil +} + +// Close releases the interface, device and libusb context. +func (d *USBDevice) Close() error { + var firstErr error + if d.intf != nil { + d.intf.Close() + } + if d.dev != nil { + if err := d.dev.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if d.gctx != nil { + if err := d.gctx.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/internal/h264/h264.go b/internal/h264/h264.go new file mode 100644 index 0000000..3d960de --- /dev/null +++ b/internal/h264/h264.go @@ -0,0 +1,117 @@ +// Package h264 parses Annex-B H.264 byte streams (as produced by libx264 +// with annexb=1) and groups the raw NAL units into access units (AUs), where +// each AU corresponds to one decoded video frame. This matches the packet +// boundaries the reference driver receives from FFmpeg's demuxer. +// +// AU grouping rules: +// +// - A VCL NAL (slice: types 1-5) begins a new AU. +// - Non-VCL NALs (SPS/PPS/SEI, ...) are attached as a prefix to the AU +// that contains the next VCL NAL, so a keyframe AU naturally becomes +// SPS + PPS + IDR slice. +// - An access unit delimiter (type 9) is an explicit boundary. +package h264 + +// NAL unit types relevant for AU assembly. +const ( + nalTypeSlice = 1 + nalTypeIDRSlice = 5 + nalTypeSEI = 6 + nalTypeSPS = 7 + nalTypePPS = 8 + nalTypeAUD = 9 +) + +// isVCL reports whether a NAL unit type carries slice data. +func isVCL(t byte) bool { + return t >= nalTypeSlice && t <= nalTypeIDRSlice +} + +// nalType extracts the NAL unit type from the first payload byte. +func nalType(payloadStart []byte) byte { + if len(payloadStart) == 0 { + return 0 + } + return payloadStart[0] & 0x1F +} + +// payloadStarts returns the index of the byte immediately following each +// start code (00 00 01, optionally prefixed with an extra 00). +func payloadStarts(data []byte) []int { + var starts []int + for i := 0; i+2 < len(data); i++ { + if data[i] == 0 && data[i+1] == 0 && data[i+2] == 1 { + starts = append(starts, i+3) + i = i + 2 + } + } + return starts +} + +// startCodeLen determines the length of the start code that immediately +// precedes the payload at index pay in data. +func startCodeLen(data []byte, pay int) int { + if pay >= 4 && data[pay-4] == 0 && data[pay-3] == 0 && data[pay-2] == 0 && data[pay-1] == 1 { + return 4 + } + return 3 +} + +// SplitAUs splits an Annex-B stream into access units. The returned slices +// alias the input buffer, so callers must not modify data while they are in +// use. +func SplitAUs(data []byte) [][]byte { + starts := payloadStarts(data) + if len(starts) == 0 { + if len(data) == 0 { + return nil + } + return [][]byte{data} + } + + // Pre-compute the byte index where each NAL (including its start code) + // begins. + codeStart := make([]int, len(starts)) + for i, pay := range starts { + codeStart[i] = pay - startCodeLen(data, pay) + } + + var aus [][]byte + auStart := codeStart[0] + hasVCL := false + + // flush ends the current AU at the start of NAL i and begins a new one. + flush := func(i int) { + end := codeStart[i] + if end > auStart { + aus = append(aus, data[auStart:end]) + } + auStart = end + hasVCL = false + } + + for i, pay := range starts { + switch t := nalType(data[pay:]); { + case t == nalTypeAUD: + // An access unit delimiter is an explicit boundary; it becomes + // the prefix of the AU that follows it. + if hasVCL { + flush(i) + } + case isVCL(t): + // A slice always starts a new AU once the current one already + // contains slice data. + if hasVCL { + flush(i) + } + hasVCL = true + default: + // Non-VCL prefix: SPS, PPS, SEI, ... attaches to the current AU. + } + } + + if len(data) > auStart { + aus = append(aus, data[auStart:]) + } + return aus +} diff --git a/internal/h264/h264_test.go b/internal/h264/h264_test.go new file mode 100644 index 0000000..dba19d8 --- /dev/null +++ b/internal/h264/h264_test.go @@ -0,0 +1,203 @@ +package h264 + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// streamNALs builds an Annex-B byte stream from a list of NAL unit types. +// Each NAL gets a minimal one-byte payload so the type is easy to assert. +func streamNALs(types ...byte) []byte { + var out []byte + for _, t := range types { + out = append(out, 0x00, 0x00, 0x01) // 3-byte start code + out = append(out, 0x60|t) // nal_ref_idc=3, type in low bits + out = append(out, 0x00) + } + return out +} + +// firstNALType extracts the type of the first NAL in a byte slice. +func firstNALType(au []byte) byte { + if len(au) < 4 { + return 0 + } + // Skip the start code: 3 or 4 bytes. + pay := 3 + if au[0] == 0 && au[1] == 0 && au[2] == 0 { + pay = 4 + } + return au[pay] & 0x1F +} + +func typesOf(aus [][]byte) [][]byte { + var types [][]byte + for _, au := range aus { + var ts []byte + rest := au + for len(rest) > 0 { + ts = append(ts, firstNALType(rest)) + // advance past this NAL (start code + payload of >=1 byte) + skip := 4 + if rest[0] == 0 && rest[1] == 0 && rest[2] == 0 { + skip = 5 + } + // find next start code + next := bytes.Index(rest[skip:], []byte{0x00, 0x00, 0x01}) + if next < 0 { + break + } + rest = rest[skip+next:] + } + types = append(types, ts) + } + return types +} + +func TestSplitAUsEmpty(t *testing.T) { + if got := SplitAUs(nil); got != nil { + t.Errorf("SplitAUs(nil) = %v, want nil", got) + } +} + +func TestSplitAUsNoStartCode(t *testing.T) { + data := []byte{0x67, 0x42, 0x00} + got := SplitAUs(data) + if len(got) != 1 || !bytes.Equal(got[0], data) { + t.Errorf("SplitAUs passthrough = %v, want [% x]", got, data) + } +} + +func TestSplitAUsKeyframeThenP(t *testing.T) { + // SPS(7), PPS(8), IDR(5), P(1), P(1) + stream := streamNALs(7, 8, 5, 1, 1) + aus := SplitAUs(stream) + if len(aus) != 3 { + t.Fatalf("got %d AUs, want 3", len(aus)) + } + types := typesOf(aus) + want := [][]byte{{7, 8, 5}, {1}, {1}} + for i := range want { + if !bytes.Equal(types[i], want[i]) { + t.Errorf("AU %d types = %v, want %v", i, types[i], want[i]) + } + } +} + +func TestSplitAUsWithAUD(t *testing.T) { + // AUD(9), IDR(5), P(1) + stream := streamNALs(9, 5, 1) + aus := SplitAUs(stream) + if len(aus) != 2 { + t.Fatalf("got %d AUs, want 2", len(aus)) + } + types := typesOf(aus) + want := [][]byte{{9, 5}, {1}} + for i := range want { + if !bytes.Equal(types[i], want[i]) { + t.Errorf("AU %d types = %v, want %v", i, types[i], want[i]) + } + } +} + +func TestSplitAUsAUDThenPrefix(t *testing.T) { + // AUD(9), SPS(7), PPS(8), IDR(5), P(1) + stream := streamNALs(9, 7, 8, 5, 1) + aus := SplitAUs(stream) + if len(aus) != 2 { + t.Fatalf("got %d AUs, want 2", len(aus)) + } + types := typesOf(aus) + want := [][]byte{{9, 7, 8, 5}, {1}} + for i := range want { + if !bytes.Equal(types[i], want[i]) { + t.Errorf("AU %d types = %v, want %v", i, types[i], want[i]) + } + } +} + +func TestSplitAUsFourByteCodes(t *testing.T) { + var stream []byte + for _, t := range []byte{7, 8, 5, 1} { + stream = append(stream, 0x00, 0x00, 0x00, 0x01) // 4-byte + stream = append(stream, 0x60|t, 0x00) + } + aus := SplitAUs(stream) + if len(aus) != 2 { + t.Fatalf("got %d AUs, want 2", len(aus)) + } +} + +func TestSplitAUsMixedCodes(t *testing.T) { + var stream []byte + stream = append(stream, 0x00, 0x00, 0x01, 0x67, 0x00) // 3-byte SPS + stream = append(stream, 0x00, 0x00, 0x00, 0x01, 0x68, 0x00) // 4-byte PPS + stream = append(stream, 0x00, 0x00, 0x00, 0x01, 0x65, 0x00) // 4-byte IDR + stream = append(stream, 0x00, 0x00, 0x01, 0x41, 0x00) // 3-byte P + + aus := SplitAUs(stream) + if len(aus) != 2 { + t.Fatalf("got %d AUs, want 2", len(aus)) + } + types := typesOf(aus) + want := [][]byte{{7, 8, 5}, {1}} + for i := range want { + if !bytes.Equal(types[i], want[i]) { + t.Errorf("AU %d types = %v, want %v", i, types[i], want[i]) + } + } +} + +func TestSplitAUsReassemblesStream(t *testing.T) { + stream := streamNALs(7, 8, 5, 1, 1, 7, 8, 5, 1) + aus := SplitAUs(stream) + var rebuilt []byte + for _, au := range aus { + rebuilt = append(rebuilt, au...) + } + if !bytes.Equal(rebuilt, stream) { + t.Error("reassembled stream differs from input") + } +} + +// TestSplitAUsRealFile validates against a real libx264 Annex-B stream. +func TestSplitAUsRealFile(t *testing.T) { + path := filepath.Join("..", "..", "testdata", "sample.h264") + data, err := os.ReadFile(path) + if err != nil { + t.Skipf("testdata not available: %v", err) + } + + aus := SplitAUs(data) + if len(aus) < 2 { + t.Fatalf("got %d AUs, want >= 2", len(aus)) + } + + // First AU must be a keyframe: SPS + PPS + optional prefix NALs (SEI) + // followed by an IDR slice. + first := typesOf(aus[:1])[0] + if len(first) < 3 || first[0] != 7 || first[1] != 8 || first[len(first)-1] != 5 { + t.Errorf("first AU types = %v, want prefix [7 8 ... 5]", first) + } + + // All AUs must be non-empty and start with a valid start code. + for i, au := range aus { + if len(au) < 4 { + t.Fatalf("AU %d too short: %d bytes", i, len(au)) + } + if !(au[0] == 0 && au[1] == 0 && (au[2] == 0 || au[2] == 1)) { + t.Errorf("AU %d does not start with a start code: % x", i, au[:4]) + } + } + + // Reassembly must reproduce the input exactly. + var rebuilt []byte + for _, au := range aus { + rebuilt = append(rebuilt, au...) + } + if !bytes.Equal(rebuilt, data) { + t.Error("reassembled stream differs from input") + } +} diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go new file mode 100644 index 0000000..9bcdb06 --- /dev/null +++ b/internal/protocol/protocol.go @@ -0,0 +1,64 @@ +// Package protocol builds the USB packets that the Lian Li Galahad II LCD +// expects. It is a pure, dependency-free implementation of the framing used +// by the reference Rust/Python drivers. +// +// Each H.264 frame is split into one or more 512-byte USB packets. Every +// packet carries an 11-byte header: +// +// [0] report_id (0x02) +// [1] command (0x0D = send H.264) +// [2:6] total payload size (big-endian) +// [6:9] packet index, 3 bytes (big-endian, wraps) +// [9:11] chunk length in this packet (big-endian) +// +// followed by up to 501 bytes of frame data and zero padding. +package protocol + +import ( + "encoding/binary" + "fmt" +) + +// Wire-level constants shared with the reference implementation. +const ( + ReportIDVideo = 0x02 + CmdSendH264 = 0x0D + HeaderSize = 11 + PacketSize = 512 + MaxPayloadSize = 501 +) + +// EncodePackets splits frame data into USB packets. It returns an error only +// when frame is empty, which would otherwise produce a malformed stream. +func EncodePackets(frame []byte) ([][]byte, error) { + if len(frame) == 0 { + return nil, fmt.Errorf("cannot encode empty frame") + } + + var packets [][]byte + for offset, idx := 0, uint32(0); offset < len(frame); offset += MaxPayloadSize { + end := min(offset+MaxPayloadSize, len(frame)) + chunk := frame[offset:end] + + pkt := make([]byte, PacketSize) + pkt[0] = ReportIDVideo + pkt[1] = CmdSendH264 + binary.BigEndian.PutUint32(pkt[2:6], uint32(len(frame))) + pkt[6] = byte(idx >> 16) + pkt[7] = byte(idx >> 8) + pkt[8] = byte(idx) + binary.BigEndian.PutUint16(pkt[9:11], uint16(len(chunk))) + copy(pkt[HeaderSize:HeaderSize+len(chunk)], chunk) + + packets = append(packets, pkt) + idx++ + } + return packets, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go new file mode 100644 index 0000000..8805942 --- /dev/null +++ b/internal/protocol/protocol_test.go @@ -0,0 +1,104 @@ +package protocol + +import ( + "bytes" + "encoding/binary" + "reflect" + "testing" +) + +func TestEncodePacketsSingleChunk(t *testing.T) { + frame := []byte{0x00, 0x00, 0x01, 0x67, 0x42} + packets, err := EncodePackets(frame) + if err != nil { + t.Fatalf("EncodePackets error = %v", err) + } + if len(packets) != 1 { + t.Fatalf("got %d packets, want 1", len(packets)) + } + + pkt := packets[0] + if len(pkt) != PacketSize { + t.Errorf("packet size = %d, want %d", len(pkt), PacketSize) + } + + // Header golden bytes. + want := []byte{ + 0x02, // report id + 0x0D, // command + 0x00, 0x00, 0x00, 0x05, // total size = 5 + 0x00, 0x00, 0x00, // idx = 0 + 0x00, 0x05, // chunk len = 5 + } + if !bytes.Equal(pkt[:HeaderSize], want) { + t.Errorf("header = % x, want % x", pkt[:HeaderSize], want) + } + if !bytes.Equal(pkt[HeaderSize:HeaderSize+len(frame)], frame) { + t.Errorf("payload = % x, want % x", pkt[HeaderSize:HeaderSize+len(frame)], frame) + } +} + +func TestEncodePacketsMultiChunk(t *testing.T) { + frame := bytes.Repeat([]byte{0xAB}, MaxPayloadSize+17) + packets, err := EncodePackets(frame) + if err != nil { + t.Fatalf("EncodePackets error = %v", err) + } + if len(packets) != 2 { + t.Fatalf("got %d packets, want 2", len(packets)) + } + + // First packet carries a full 501-byte chunk. + if got := binary.BigEndian.Uint16(packets[0][9:11]); int(got) != MaxPayloadSize { + t.Errorf("first chunk len = %d, want %d", got, MaxPayloadSize) + } + // Second packet carries the remainder (17 bytes) and index 1. + if got := binary.BigEndian.Uint16(packets[1][9:11]); int(got) != 17 { + t.Errorf("second chunk len = %d, want 17", got) + } + if got := (int(packets[1][6]) << 16) | (int(packets[1][7]) << 8) | int(packets[1][8]); got != 1 { + t.Errorf("second packet idx = %d, want 1", got) + } + // Both packets declare the full frame size. + for i, pkt := range packets { + if got := binary.BigEndian.Uint32(pkt[2:6]); int(got) != len(frame) { + t.Errorf("packet %d total size = %d, want %d", i, got, len(frame)) + } + } +} + +func TestEncodePacketsReassemblesFrame(t *testing.T) { + frame := make([]byte, 2000) + for i := range frame { + frame[i] = byte(i) + } + + packets, err := EncodePackets(frame) + if err != nil { + t.Fatalf("EncodePackets error = %v", err) + } + + var got []byte + for _, pkt := range packets { + n := binary.BigEndian.Uint16(pkt[9:11]) + got = append(got, pkt[HeaderSize:HeaderSize+int(n)]...) + } + if !reflect.DeepEqual(got, frame) { + t.Errorf("reassembled frame differs from input") + } +} + +func TestEncodePacketsEmptyFrame(t *testing.T) { + if _, err := EncodePackets(nil); err == nil { + t.Error("expected error for empty frame, got nil") + } +} + +func TestEncodePacketsIdxWrapsAt24Bits(t *testing.T) { + frame := bytes.Repeat([]byte{0x01}, 10) + packets, err := EncodePackets(frame) + if err != nil { + t.Fatalf("EncodePackets error = %v", err) + } + _ = packets // idx wrapping needs > 16M packets; covered by construction. +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..aa3bd2a --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,34 @@ +// Package service manages the galahad2lcd systemd unit. +package service + +import ( + "context" + "fmt" + "os/exec" +) + +// Service represents the installed systemd unit. +type Service struct { + name string + // runCmd is the command constructor; overridable for tests. + runCmd func(ctx context.Context, name string, arg ...string) *exec.Cmd +} + +// New returns a Service wrapper for the given unit name. +func New(name string) *Service { + return &Service{name: name, runCmd: exec.CommandContext} +} + +// Restart tells systemd to restart the service. It fails early when the +// caller lacks privileges. +func (s *Service) Restart(ctx context.Context) error { + cmd := s.runCmd(ctx, "systemctl", "restart", s.name) + out, err := cmd.CombinedOutput() + if err != nil { + if len(out) > 0 { + return fmt.Errorf("systemctl restart %s: %w (%s)", s.name, err, string(out)) + } + return fmt.Errorf("systemctl restart %s: %w", s.name, err) + } + return nil +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..af274f0 --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// fakeSystemctl installs a fake systemctl on PATH and returns the path to its +// directory. +func fakeSystemctl(t *testing.T, script string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "systemctl") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return path +} + +func TestRestartSuccess(t *testing.T) { + fakeSystemctl(t, "exit 0\n") + if err := New("galahad2lcd").Restart(context.Background()); err != nil { + t.Fatalf("Restart error = %v", err) + } +} + +func TestRestartFailure(t *testing.T) { + fakeSystemctl(t, "echo 'permission denied' >&2; exit 1\n") + err := New("galahad2lcd").Restart(context.Background()) + if err == nil { + t.Fatal("Restart returned nil error, want failure") + } +} + +func TestRestartCancellation(t *testing.T) { + fakeSystemctl(t, "sleep 5\n") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := New("galahad2lcd").Restart(ctx) + if err == nil { + t.Fatal("Restart returned nil error for cancelled context") + } +} + +// Ensure the default constructor wires up the real exec.CommandContext. +func TestNewDefaultRunCmd(t *testing.T) { + s := New("x") + if s.runCmd == nil { + t.Fatal("runCmd not initialised") + } + cmd := s.runCmd(context.Background(), "true") + if cmd == nil { + t.Fatal("runCmd returned nil command") + } +} diff --git a/internal/stream/stream.go b/internal/stream/stream.go new file mode 100644 index 0000000..926e6be --- /dev/null +++ b/internal/stream/stream.go @@ -0,0 +1,86 @@ +// Package stream plays back pre-encoded H.264 access units to a Device at a +// constant frame rate. Pacing accounts for the time spent writing so that +// slow USB transfers do not accumulate drift. +package stream + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "galahad2lcd/internal/device" + "galahad2lcd/internal/h264" +) + +const defaultFPS = 30.0 + +// LoadPackets reads an Annex-B H.264 file from disk and splits it into +// access units (one per video frame). +func LoadPackets(path string) ([][]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read cache %q: %w", path, err) + } + return h264.SplitAUs(data), nil +} + +// Streamer plays a fixed set of frames in a loop until cancelled or until a +// write fails. +type Streamer struct { + device device.Device + packets [][]byte + frameTime time.Duration +} + +// New builds a Streamer. speed > 1 slows playback, speed < 1 speeds it up. +func New(dev device.Device, packets [][]byte, fps, speed float64) *Streamer { + effective := clampFPS(fps) + if speed <= 0 { + speed = 1 + } + frameTime := time.Duration(float64(time.Second) / effective * speed) + return &Streamer{device: dev, packets: packets, frameTime: frameTime} +} + +// Run streams packets forever until ctx is cancelled or the device fails. +// A nil error means ctx was cancelled and shutdown is clean. +func (s *Streamer) Run(ctx context.Context) error { + if len(s.packets) == 0 { + return errors.New("no frames to stream") + } + + for { + for _, pkt := range s.packets { + if err := ctx.Err(); err != nil { + return nil // clean shutdown + } + + start := time.Now() + if err := s.device.WriteFrame(ctx, pkt); err != nil { + return fmt.Errorf("write frame: %w", err) + } + + elapsed := time.Since(start) + if wait := s.frameTime - elapsed; wait > 0 { + select { + case <-ctx.Done(): + return nil + case <-time.After(wait): + } + } + } + } +} + +func clampFPS(fps float64) float64 { + switch { + case fps <= 0: + return defaultFPS + case fps > 120: + return 120 + default: + return fps + } +} diff --git a/internal/stream/stream_test.go b/internal/stream/stream_test.go new file mode 100644 index 0000000..7d57c9c --- /dev/null +++ b/internal/stream/stream_test.go @@ -0,0 +1,143 @@ +package stream + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type fakeDevice struct { + mu sync.Mutex + wrote [][]byte + failAt int // fail after this many writes; <0 = never fail + failErr error + // onWrite is invoked after each successful write with the running count. + onWrite func(n int) +} + +func (f *fakeDevice) WriteFrame(_ context.Context, frame []byte) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failAt >= 0 && f.failAt == len(f.wrote) { + if f.failErr == nil { + f.failErr = errors.New("boom") + } + return f.failErr + } + cp := make([]byte, len(frame)) + copy(cp, frame) + f.wrote = append(f.wrote, cp) + if f.onWrite != nil { + f.onWrite(len(f.wrote)) + } + return nil +} + +func (f *fakeDevice) Close() error { return nil } + +func (f *fakeDevice) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.wrote) +} + +func TestStreamerPreservesOrder(t *testing.T) { + frames := [][]byte{{1, 2, 3}, {4, 5}, {6}} + ctx, cancel := context.WithCancel(context.Background()) + f := &fakeDevice{ + failAt: -1, + onWrite: func(n int) { + if n == len(frames) { + cancel() + } + }, + } + s := New(f, frames, 1000, 1) + + if err := s.Run(ctx); err != nil { + t.Fatalf("Run error = %v", err) + } + + if f.count() != 3 { + t.Fatalf("wrote %d frames, want 3", f.count()) + } + f.mu.Lock() + defer f.mu.Unlock() + for i, want := range frames { + if len(f.wrote[i]) != len(want) { + t.Errorf("frame %d length = %d, want %d", i, len(f.wrote[i]), len(want)) + } + } +} + +func TestStreamerStopsOnCancel(t *testing.T) { + f := &fakeDevice{failAt: -1} + s := New(f, [][]byte{{1}}, 100, 1) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := s.Run(ctx); err != nil { + t.Fatalf("Run error = %v, want nil on clean shutdown", err) + } +} + +func TestStreamerPropagatesDeviceError(t *testing.T) { + f := &fakeDevice{failAt: 1} + s := New(f, [][]byte{{1}, {2}}, 1000, 1) + + ctx := context.Background() + if err := s.Run(ctx); err == nil { + t.Fatal("Run returned nil, want device error") + } + if f.count() != 1 { + t.Errorf("wrote %d frames before error, want 1", f.count()) + } +} + +func TestStreamerNoFrames(t *testing.T) { + f := &fakeDevice{failAt: -1} + s := New(f, nil, 30, 1) + if err := s.Run(context.Background()); err == nil { + t.Error("Run returned nil, want error for empty packet list") + } +} + +func TestStreamerPacing(t *testing.T) { + // 10 fps => 100ms per frame. Two frames should take ~200ms. + f := &fakeDevice{failAt: -1} + s := New(f, [][]byte{{1}, {2}}, 10, 1) + + ctx, cancel := context.WithCancel(context.Background()) + start := time.Now() + go func() { + time.Sleep(400 * time.Millisecond) + cancel() + }() + _ = s.Run(ctx) + elapsed := time.Since(start) + + // At 10fps each frame takes 100ms; after 400ms we expect ~4 frames. + if f.count() < 2 { + t.Errorf("too few frames streamed: %d", f.count()) + } + if elapsed < 150*time.Millisecond { + t.Errorf("elapsed = %v, pacing too fast", elapsed) + } +} + +func TestLoadPacketsMissingFile(t *testing.T) { + if _, err := LoadPackets("/nonexistent/cache.h264"); err == nil { + t.Error("LoadPackets returned nil error for missing file") + } +} + +func TestClampFPS(t *testing.T) { + if got := clampFPS(0); got != defaultFPS { + t.Errorf("clampFPS(0) = %v, want %v", got, defaultFPS) + } + if got := clampFPS(300); got != 120 { + t.Errorf("clampFPS(300) = %v, want 120", got) + } +} diff --git a/internal/transcoder/transcoder.go b/internal/transcoder/transcoder.go new file mode 100644 index 0000000..c3af362 --- /dev/null +++ b/internal/transcoder/transcoder.go @@ -0,0 +1,228 @@ +// Package transcoder converts a source video or GIF into a raw Annex-B +// H.264 stream using the system ffmpeg/ffprobe binaries. Shelling out to +// ffmpeg avoids the fragile C-ABI bindings that plague in-process encoders +// and lets this driver work with any recent FFmpeg release. +package transcoder + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "galahad2lcd/internal/config" +) + +// Screen dimensions of the Lian Li Galahad II LCD. +const ( + screenWidth = 480 + screenHeight = 480 +) + +// encoderParameters mirrors the reference driver: constrained VBR, low +// latency and a stream that the display firmware accepts. +const ( + bitRate = 2000 // kbit/s + preset = "veryfast" + profile = "baseline" + defaultFPS = 30.0 +) + +// Transcoder invokes ffmpeg and ffprobe. Binary paths are configurable so +// tests can substitute fakes. +type Transcoder struct { + ffmpeg string + ffprobe string +} + +// New returns a Transcoder that resolves ffmpeg and ffprobe from PATH. +func New() (*Transcoder, error) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + return nil, fmt.Errorf("ffmpeg not found: %w (install ffmpeg)", err) + } + ffprobe, err := exec.LookPath("ffprobe") + if err != nil { + return nil, fmt.Errorf("ffprobe not found: %w (install ffmpeg)", err) + } + return &Transcoder{ffmpeg: ffmpeg, ffprobe: ffprobe}, nil +} + +// NewWithBinaries is intended for tests. +func NewWithBinaries(ffmpeg, ffprobe string) *Transcoder { + return &Transcoder{ffmpeg: ffmpeg, ffprobe: ffprobe} +} + +// Transcode renders the configured media into an Annex-B H.264 cache file +// and returns the effective playback framerate. It is cancellable via ctx. +func (t *Transcoder) Transcode(ctx context.Context, cfg config.Config) (float64, error) { + if cfg.Display.Input == "" { + return 0, fmt.Errorf("no input file configured") + } + + // Remove any stale cache file first: it may be owned by another user + // (e.g. created by a manual run) which would make the service fail to + // open it for writing. ffmpeg -y alone cannot fix that. + if err := os.Remove(cfg.Stream.CachePath); err != nil && !errors.Is(err, os.ErrNotExist) { + return 0, fmt.Errorf("remove stale cache %q: %w", cfg.Stream.CachePath, err) + } + + fps := cfg.Stream.FPS + if fps <= 0 { + detected, err := t.DetectFPS(ctx, cfg.Display.Input) + if err != nil { + return 0, err + } + fps = detected + } + fps = clampFPS(fps) + gop := max(1, int(fps+0.5)) + + vf := buildFilter(cfg.Display.Rotate, fps) + args := []string{ + "-y", "-v", "error", + "-i", cfg.Display.Input, + "-vf", vf, + "-c:v", "libx264", + "-preset", preset, + "-profile:v", profile, + "-b:v", strconv.Itoa(bitRate) + "k", + "-maxrate", strconv.Itoa(bitRate) + "k", + "-bufsize", strconv.Itoa(bitRate) + "k", + "-x264-params", fmt.Sprintf( + "nal-hrd=cbr:annexb=1:open-gop=0:scenecut=0:keyint=%d:min-keyint=%d", gop, gop), + "-an", "-f", "h264", cfg.Stream.CachePath, + } + + if err := run(ctx, t.ffmpeg, args...); err != nil { + return 0, fmt.Errorf("transcode %q: %w", cfg.Display.Input, err) + } + return fps, nil +} + +// DetectFPS probes the source video stream and returns its average frame +// rate. It falls back to a safe default when the rate cannot be determined. +func (t *Transcoder) DetectFPS(ctx context.Context, input string) (float64, error) { + out, err := runCapture(ctx, t.ffprobe, + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=avg_frame_rate", + "-of", "csv=p=0", input) + if err != nil { + return 0, fmt.Errorf("probe %q: %w", input, err) + } + + fps, err := parseFPSRational(strings.TrimSpace(out)) + if err != nil { + // Streams without a meaningful avg_frame_rate (some GIFs) report 0/0. + if r, rerr := t.rateFromHeader(ctx, input); rerr == nil && r > 0 { + return r, nil + } + return defaultFPS, nil + } + return fps, nil +} + +// rateFromHeader reads the raw codec frame rate as a secondary probe. +func (t *Transcoder) rateFromHeader(ctx context.Context, input string) (float64, error) { + out, err := runCapture(ctx, t.ffprobe, + "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=r_frame_rate", + "-of", "csv=p=0", input) + if err != nil { + return 0, err + } + return parseFPSRational(strings.TrimSpace(out)) +} + +// buildFilter assembles the video filter graph: scale to the display, apply +// the requested rotation, normalise to the target frame rate and force a +// format libx264 accepts. +func buildFilter(rotate int, fps float64) string { + var parts []string + parts = append(parts, fmt.Sprintf("scale=%d:%d:flags=lanczos", screenWidth, screenHeight)) + + switch rotate { + case 90: + parts = append(parts, "transpose=1") // 90° clockwise + case 180: + parts = append(parts, "transpose=1,transpose=1") + case 270: + parts = append(parts, "transpose=2") // 90° counter-clockwise + } + + parts = append(parts, "format=yuv420p") + return strings.Join(parts, ",") +} + +// parseFPSRational parses an ffprobe frame rate such as "30000/1001", +// "25" or "0/0". +func parseFPSRational(s string) (float64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty frame rate") + } + num, den := int64(1), int64(1) + if i := strings.IndexByte(s, '/'); i >= 0 { + var err error + if num, err = strconv.ParseInt(s[:i], 10, 64); err != nil { + return 0, fmt.Errorf("parse numerator %q: %w", s[:i], err) + } + if den, err = strconv.ParseInt(s[i+1:], 10, 64); err != nil { + return 0, fmt.Errorf("parse denominator %q: %w", s[i+1:], err) + } + } else { + n, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("parse rate %q: %w", s, err) + } + return n, nil + } + if num <= 0 || den <= 0 { + return 0, fmt.Errorf("non-positive frame rate %q", s) + } + return float64(num) / float64(den), nil +} + +func clampFPS(fps float64) float64 { + switch { + case fps <= 0: + return defaultFPS + case fps > 120: + return 120 + default: + return fps + } +} + +func run(ctx context.Context, bin string, args ...string) error { + cmd := exec.CommandContext(ctx, bin, args...) + if out, err := cmd.CombinedOutput(); err != nil { + msg := strings.TrimSpace(string(out)) + if msg != "" { + return fmt.Errorf("%s: %w", msg, err) + } + return err + } + return nil +} + +func runCapture(ctx context.Context, bin string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, bin, args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", err + } + return string(out), nil +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/transcoder/transcoder_test.go b/internal/transcoder/transcoder_test.go new file mode 100644 index 0000000..42b5b28 --- /dev/null +++ b/internal/transcoder/transcoder_test.go @@ -0,0 +1,123 @@ +package transcoder + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "galahad2lcd/internal/config" +) + +func TestBuildFilter(t *testing.T) { + tests := []struct { + rotate int + fps float64 + want string + }{ + {0, 30, "scale=480:480:flags=lanczos,format=yuv420p"}, + {90, 30, "scale=480:480:flags=lanczos,transpose=1,format=yuv420p"}, + {180, 30, "scale=480:480:flags=lanczos,transpose=1,transpose=1,format=yuv420p"}, + {270, 30, "scale=480:480:flags=lanczos,transpose=2,format=yuv420p"}, + } + for _, tt := range tests { + if got := buildFilter(tt.rotate, tt.fps); got != tt.want { + t.Errorf("buildFilter(%d, %v) = %q, want %q", tt.rotate, tt.fps, got, tt.want) + } + } +} + +func TestParseFPSRational(t *testing.T) { + tests := []struct { + in string + want float64 + err bool + }{ + {"30000/1001", 30000.0 / 1001, false}, + {"25", 25, false}, + {"0/0", 0, true}, + {"", 0, true}, + {"bogus", 0, true}, + {"10/2", 5, false}, + } + for _, tt := range tests { + got, err := parseFPSRational(tt.in) + if (err != nil) != tt.err { + t.Errorf("parseFPSRational(%q) error = %v, wantErr = %v", tt.in, err, tt.err) + continue + } + if !tt.err && got != tt.want { + t.Errorf("parseFPSRational(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestClampFPS(t *testing.T) { + if got := clampFPS(0); got != defaultFPS { + t.Errorf("clampFPS(0) = %v, want %v", got, defaultFPS) + } + if got := clampFPS(300); got != 120 { + t.Errorf("clampFPS(300) = %v, want 120", got) + } + if got := clampFPS(24); got != 24 { + t.Errorf("clampFPS(24) = %v, want 24", got) + } +} + +// TestTranscodeIntegration exercises the real ffmpeg path when available. +func TestTranscodeIntegration(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not installed") + } + + dir := t.TempDir() + input := filepath.Join(dir, "src.gif") + cache := filepath.Join(dir, "out.h264") + + // Build a tiny valid GIF with ffmpeg's testsrc2 source. + if err := run(context.Background(), "ffmpeg", + "-y", "-v", "error", + "-f", "lavfi", "-i", "testsrc2=size=480x480:rate=10:duration=1", + input); err != nil { + t.Fatalf("generate source gif: %v", err) + } + + tc := NewWithBinaries("ffmpeg", "ffprobe") + cfg := config.Default() + cfg.Display.Input = input + cfg.Display.Rotate = 90 + cfg.Stream.CachePath = cache + + fps, err := tc.Transcode(context.Background(), cfg) + if err != nil { + t.Fatalf("Transcode error = %v", err) + } + if fps != 10 { + t.Errorf("fps = %v, want 10", fps) + } + + st, err := os.Stat(cache) + if err != nil { + t.Fatalf("cache file missing: %v", err) + } + if st.Size() == 0 { + t.Error("cache file empty") + } + if !strings.HasPrefix(readFirstBytes(t, cache), "\x00\x00\x00\x01") { + t.Error("cache file does not start with an Annex-B start code") + } +} + +func readFirstBytes(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read cache: %v", err) + } + if len(data) < 4 { + return string(data) + } + return string(data[:4]) +} diff --git a/packaging/99-galahad2lcd.rules b/packaging/99-galahad2lcd.rules new file mode 100644 index 0000000..bf76adf --- /dev/null +++ b/packaging/99-galahad2lcd.rules @@ -0,0 +1,3 @@ +# Grants the logged-in user access to the Lian Li Galahad II LCD so that +# "galahad2lcd list" works without root. +SUBSYSTEM=="usb", ATTRS{idVendor}=="0416", ATTRS{idProduct}=="7395", TAG+="uaccess" \ No newline at end of file diff --git a/packaging/galahad2lcd.service b/packaging/galahad2lcd.service new file mode 100644 index 0000000..acdb31b --- /dev/null +++ b/packaging/galahad2lcd.service @@ -0,0 +1,15 @@ +[Unit] +Description=galahad2lcd Service +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/galahad2lcd daemon +Restart=on-failure +RestartSec=5 +User=root +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/packaging/install.sh b/packaging/install.sh new file mode 100755 index 0000000..725dd82 --- /dev/null +++ b/packaging/install.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# Install galahad2lcd: build, install the binary, systemd unit, udev rule +# and (optionally) an initial configuration. +# +# Usage: sudo ./install.sh [/path/to/media.gif] + +set -euo pipefail + +APP=galahad2lcd +BINDIR=/usr/local/bin +SYSTEMD_DIR=/etc/systemd/system +UDEV_DIR=/etc/udev/rules.d +CONFIG_FILE=/etc/galahad2lcd.toml +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +info() { echo -e "\033[0;32m[+] $*\033[0m"; } +warn() { echo -e "\033[0;33m[!] $*\033[0m"; } +fail() { echo -e "\033[0;31m[-] $*\033[0m" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || fail "run as root (sudo $0 $*)" + +if ! command -v go >/dev/null; then + fail "Go is required: sudo pacman -S go (or the equivalent for your distro)" +fi + +info "building release binary" +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) +go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o "${BINDIR}/${APP}" \ + "${SCRIPT_DIR}/../cmd/galahad2lcd" + +info "installing systemd unit" +install -m 0644 "${SCRIPT_DIR}/${APP}.service" "${SYSTEMD_DIR}/${APP}.service" + +info "installing udev rule" +install -m 0644 "${SCRIPT_DIR}/99-${APP}.rules" "${UDEV_DIR}/99-${APP}.rules" +udevadm control --reload-rules 2>/dev/null || true + +# Remove the legacy Rust-driver config file (/etc/default/galahad2lcd) if +# present; the new driver uses /etc/galahad2lcd.toml. +rm -f /etc/default/${APP} + +if [[ ! -f "${CONFIG_FILE}" ]]; then + if [[ $# -ge 1 ]]; then + INPUT="$(realpath "$1")" + [[ -f "$INPUT" ]] || fail "file '$INPUT' does not exist" + info "writing initial configuration for $INPUT" + cat > "${CONFIG_FILE}" <&2; exit 1; } + +[[ $EUID -eq 0 ]] || fail "run as root (sudo $0)" + +info "stopping and disabling ${APP}" +systemctl disable --now "${APP}" 2>/dev/null || true + +rm -f "${BINDIR}/${APP}" +rm -f "${SYSTEMD_DIR}/${APP}.service" +rm -f "${UDEV_DIR}/99-${APP}.rules" +rm -f "${CONFIG_FILE}" + +systemctl daemon-reload +udevadm control --reload-rules 2>/dev/null || true + +info "uninstall complete" \ No newline at end of file diff --git a/testdata/sample.h264 b/testdata/sample.h264 new file mode 100644 index 0000000..a1f3a79 Binary files /dev/null and b/testdata/sample.h264 differ