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