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:
Maksim Totmin
2026-08-19 12:03:54 +07:00
commit 4c34f0bedc
33 changed files with 2432 additions and 0 deletions
+21
View File
@@ -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")
+57
View File
@@ -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
}
+153
View File
@@ -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
}