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,3 @@
|
|||||||
|
bin/
|
||||||
|
*.h264
|
||||||
|
!testdata/sample.h264
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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=<version>"
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := cli.Execute(version); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
Executable
+80
@@ -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}" <<EOF
|
||||||
|
[display]
|
||||||
|
input = "${INPUT}"
|
||||||
|
rotate = 0
|
||||||
|
speed = 1.0
|
||||||
|
|
||||||
|
[usb]
|
||||||
|
vendor_id = 0x0416
|
||||||
|
product_id = 0x7395
|
||||||
|
|
||||||
|
[stream]
|
||||||
|
fps = 0
|
||||||
|
cache_path = "/tmp/galahad_cache.h264"
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
warn "no media file given; run 'sudo ${APP} set --input /path/to/media.gif' after install"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
chmod 0644 "${CONFIG_FILE}"
|
||||||
|
|
||||||
|
info "reloading systemd and starting ${APP}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${APP}"
|
||||||
|
systemctl restart "${APP}"
|
||||||
|
|
||||||
|
info "installation complete"
|
||||||
|
echo
|
||||||
|
echo " Change the media later with:"
|
||||||
|
echo " sudo ${APP} set --input /path/to/media.gif --rotate 0"
|
||||||
|
echo " Inspect USB devices with:"
|
||||||
|
echo " ${APP} list"
|
||||||
|
echo " View logs with:"
|
||||||
|
echo " journalctl -u ${APP} -f"
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Remove galahad2lcd: stop the service and delete all installed files.
|
||||||
|
#
|
||||||
|
# Usage: sudo ./uninstall.sh
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
info() { echo -e "\033[0;32m[+] $*\033[0m"; }
|
||||||
|
fail() { echo -e "\033[0;31m[-] $*\033[0m" >&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"
|
||||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user