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