Initial Push
This commit is contained in:
230
internal/device/streamdeck.go
Normal file
230
internal/device/streamdeck.go
Normal 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 (0–100).
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user