Initial Push

This commit is contained in:
2026-03-15 10:10:13 -06:00
commit 663d452ca3
14 changed files with 1413 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
package device
import (
"bytes"
"encoding/binary"
"fmt"
"image"
"image/jpeg"
_ "image/png"
"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 {
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
}