Files
streamdeck-go/internal/device/streamdeck.go
2026-04-18 11:55:18 -06:00

258 lines
7.3 KiB
Go
Raw Permalink 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"
"runtime"
"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 {
var hint string
switch runtime.GOOS {
case "linux":
hint = "try: sudo chmod a+rw /dev/hidraw*"
case "darwin":
hint = "try: brew install hidapi; check System Settings → Privacy & Security → Input Monitoring"
default:
hint = "check device permissions"
}
return nil, fmt.Errorf("open device 0x%04x:0x%04x: %w (%s)", vendorID, productID, err, hint)
}
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 }
// ImageWidth returns the pixel width of key images for this device.
func (sd *StreamDeck) ImageWidth() int { return sd.model.ImageWidth }
// ImageHeight returns the pixel height of key images for this device.
func (sd *StreamDeck) ImageHeight() int { return sd.model.ImageHeight }
// 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 {
// Linux hidraw returns errors (not (0,nil)) for non-fatal conditions:
// timeout waiting for data, or EINTR (signal interrupted).
// macOS IOHIDManager usually returns (0, nil) on timeout, but may also
// return an error with a different message — catch both spellings.
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "timeout") ||
strings.Contains(msg, "timed out") ||
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
}