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,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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user