Files
galahad2lcd/internal/device/usb.go
T
Maksim Totmin 4c34f0bedc 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
2026-08-19 12:03:54 +07:00

154 lines
3.7 KiB
Go

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
}