Files
streamdeck-go/internal/device/streamdeck.go
Levi Woodard b428f4861a Fix HID timeout/EINTR errors and relative icon paths
- Treat "timeout" and "interrupted system call" from ReadWithTimeout as
  non-fatal — hidraw on Linux returns errors instead of (0, nil) for
  both, causing false device-dead detection and reconnect loops
- Resolve icons_dir relative to the config file when the path is not
  absolute, so the service finds icons regardless of working directory
- Installer now copies bundled icons to the config dir on first install
2026-03-15 10:36:44 -06:00

238 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package device
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/jpeg"
_ "image/png"
"strings"
"sync"
"github.com/sstallion/go-hid"
"golang.org/x/image/draw"
)
const VendorID = 0x0fd9
// ModelInfo describes hardware-specific constants for a Stream Deck model.
type ModelInfo struct {
KeyCount int
Cols int
Rows int
ImageWidth int
ImageHeight int
// FlipX/FlipY: the XL renders images mirrored; we pre-flip before sending.
FlipX bool
FlipY bool
}
// models maps USB product IDs to their hardware specs.
var models = map[uint16]ModelInfo{
0x00ba: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v2
0x006c: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v1
0x006d: {KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, // MK.2
}
const (
reportSize = 1024
imageHeaderSize = 8
imagePayloadSize = reportSize - imageHeaderSize
readReportSize = 512
)
// StreamDeck represents an open Stream Deck device.
type StreamDeck struct {
mu sync.Mutex
dev *hid.Device
model ModelInfo
}
// Open finds and opens the first Stream Deck with the given product ID.
func Open(vendorID, productID uint16) (*StreamDeck, error) {
if err := hid.Init(); err != nil {
return nil, fmt.Errorf("hid init: %w", err)
}
m, ok := models[productID]
if !ok {
return nil, fmt.Errorf("unsupported product ID: 0x%04x (add it to internal/device/streamdeck.go)", productID)
}
dev, err := hid.OpenFirst(vendorID, productID)
if err != nil {
return nil, fmt.Errorf("open device 0x%04x:0x%04x: %w (try: sudo chmod a+rw /dev/hidraw*)", vendorID, productID, err)
}
return &StreamDeck{dev: dev, model: m}, nil
}
// Close releases the device.
func (sd *StreamDeck) Close() error {
_ = hid.Exit()
return sd.dev.Close()
}
// KeyCount returns the number of keys on this device.
func (sd *StreamDeck) KeyCount() int { return sd.model.KeyCount }
// Reset clears all key images and returns the device to its default state.
func (sd *StreamDeck) Reset() error {
report := make([]byte, 32)
report[0] = 0x03
report[1] = 0x02
_, err := sd.dev.SendFeatureReport(report)
return err
}
// SetBrightness sets the display brightness (0100).
func (sd *StreamDeck) SetBrightness(pct int) error {
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
report := make([]byte, 32)
report[0] = 0x03
report[1] = 0x08
report[2] = byte(pct)
_, err := sd.dev.SendFeatureReport(report)
return err
}
// EncodeFrame scales img to key size and returns the JPEG bytes ready to send.
// Use this to pre-encode animation frames once rather than on every tick.
func (sd *StreamDeck) EncodeFrame(img image.Image) ([]byte, error) {
dst := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight))
draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
if sd.model.FlipX || sd.model.FlipY {
dst = flipImage(dst, sd.model.FlipX, sd.model.FlipY)
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 95}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// SetKeyFrame sends pre-encoded JPEG bytes (from EncodeFrame) to a key.
func (sd *StreamDeck) SetKeyFrame(keyIndex int, jpegData []byte) error {
return sd.sendKeyImageData(keyIndex, jpegData)
}
// SetKeyImage scales img to the key size and sends it to the given key (0-indexed).
func (sd *StreamDeck) SetKeyImage(keyIndex int, img image.Image) error {
// Scale to key pixel dimensions.
dst := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight))
draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
// The XL renders images flipped; pre-flip so they appear correct.
if sd.model.FlipX || sd.model.FlipY {
dst = flipImage(dst, sd.model.FlipX, sd.model.FlipY)
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 95}); err != nil {
return fmt.Errorf("jpeg encode: %w", err)
}
return sd.sendKeyImageData(keyIndex, buf.Bytes())
}
// ClearKey fills the given key with solid black.
func (sd *StreamDeck) ClearKey(keyIndex int) error {
blank := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight))
return sd.SetKeyImage(keyIndex, blank)
}
// ReadButtons waits up to 250 ms for a button state report.
// Returns (nil, nil) on timeout — callers should check context and retry.
func (sd *StreamDeck) ReadButtons() ([]bool, error) {
data := make([]byte, readReportSize)
n, err := sd.dev.ReadWithTimeout(data, 250)
if err != nil {
// hidraw on Linux returns errors rather than (0, nil) for non-fatal
// conditions: timeout waiting for data, or EINTR (signal interrupted).
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "timeout") || strings.Contains(msg, "interrupted") {
return nil, nil
}
return nil, err
}
if n == 0 {
return nil, nil // timeout, no data
}
// Input report layout (XL v2): [report_id, 0x00, 0x00, 0x00, key0, key1, ...]
const offset = 4
if n < offset+sd.model.KeyCount {
return nil, fmt.Errorf("short read: got %d bytes, expected at least %d", n, offset+sd.model.KeyCount)
}
buttons := make([]bool, sd.model.KeyCount)
for i := 0; i < sd.model.KeyCount; i++ {
buttons[i] = data[offset+i] == 0x01
}
return buttons, nil
}
// sendKeyImageData sends raw JPEG bytes to a key, split across 1024-byte HID reports.
func (sd *StreamDeck) sendKeyImageData(keyIndex int, data []byte) error {
if keyIndex < 0 || keyIndex >= sd.model.KeyCount {
return fmt.Errorf("key index %d out of range (device has %d keys, 0%d)",
keyIndex, sd.model.KeyCount, sd.model.KeyCount-1)
}
sd.mu.Lock()
defer sd.mu.Unlock()
pageIndex := 0
for len(data) > 0 {
chunk := data
if len(chunk) > imagePayloadSize {
chunk = chunk[:imagePayloadSize]
}
isLast := byte(0)
if len(data) == len(chunk) {
isLast = 1
}
report := make([]byte, reportSize)
report[0] = 0x02
report[1] = 0x07
report[2] = byte(keyIndex)
report[3] = isLast
binary.LittleEndian.PutUint16(report[4:6], uint16(len(chunk)))
binary.LittleEndian.PutUint16(report[6:8], uint16(pageIndex))
copy(report[8:], chunk)
if _, err := sd.dev.Write(report); err != nil {
return fmt.Errorf("write report page %d: %w", pageIndex, err)
}
data = data[len(chunk):]
pageIndex++
}
return nil
}
func flipImage(src *image.RGBA, flipX, flipY bool) *image.RGBA {
b := src.Bounds()
w, h := b.Max.X, b.Max.Y
dst := image.NewRGBA(b)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
dx, dy := x, y
if flipX {
dx = w - 1 - x
}
if flipY {
dy = h - 1 - y
}
dst.SetRGBA(dx, dy, src.RGBAAt(x, y))
}
}
return dst
}