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,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