Files
streamdeck-go/internal/device/streamdeck.go
Levi Woodard 8cff4f8418 Add streamdeck-ctl JSON control CLI
streamdeck-ctl reports device/daemon/key status as JSON and edits the
config (brightness, key set/clear/press, daemon control) so front-ends
don't have to reimplement YAML handling or device probing.

- internal/config/edit.go: comment-preserving, node-level YAML edits with
  atomic writes; key indices written as !!int so config.Load accepts them.
- internal/device: add model names, Lookup() and Present() for probing
  without opening the HID handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjuZBBfzXqZgxRhxzJvkkC
2026-09-27 18:21:33 -06:00

285 lines
8.2 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"
"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 {
Name string
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: {Name: "Stream Deck XL v2", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
0x006c: {Name: "Stream Deck XL v1", KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true},
0x006d: {Name: "Stream Deck MK.2", KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true},
}
// Lookup returns the hardware spec for a product ID without opening the device.
// Callers that only need geometry (key count, grid shape) can use this while the
// daemon holds the HID handle.
func Lookup(productID uint16) (ModelInfo, bool) {
m, ok := models[productID]
return m, ok
}
// Present reports whether a Stream Deck with the given USB IDs is currently
// enumerated. It does not open the device, so it is safe to call while the
// daemon has it open.
func Present(vendorID, productID uint16) (bool, error) {
if err := hid.Init(); err != nil {
return false, fmt.Errorf("hid init: %w", err)
}
found := false
err := hid.Enumerate(vendorID, productID, func(*hid.DeviceInfo) error {
found = true
return nil
})
if err != nil {
return false, fmt.Errorf("hid enumerate: %w", err)
}
return found, nil
}
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 (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 {
// 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
}