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
154 lines
3.7 KiB
Go
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
|
|
}
|