Add Stream Deck XL library and test command

This commit is contained in:
Ian Gulliver
2026-02-11 19:16:22 -08:00
parent e4bfa80c64
commit b547334dbe
4 changed files with 277 additions and 1 deletions

60
cmd/decktest/main.go Normal file
View File

@@ -0,0 +1,60 @@
package main
import (
"fmt"
"image/color"
"math/rand/v2"
"os"
"os/signal"
"syscall"
"qrun/streamdeck"
)
func main() {
dev, err := streamdeck.Open()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer dev.Close()
fmt.Printf("Connected to: %s (serial: %s)\n", dev.Product(), dev.SerialNumber())
dev.SetBrightness(80)
for i := 0; i < streamdeck.KeyCount(); i++ {
r := uint8(rand.IntN(256))
g := uint8(rand.IntN(256))
b := uint8(rand.IntN(256))
dev.SetKeyColor(i, color.RGBA{r, g, b, 255})
}
keys := make(chan streamdeck.KeyEvent, 64)
go func() {
if err := dev.ReadKeys(keys); err != nil {
fmt.Fprintf(os.Stderr, "Read error: %v\n", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
for {
select {
case ev := <-keys:
if ev.Pressed {
fmt.Printf("Key %d pressed\n", ev.Key)
r := uint8(rand.IntN(256))
g := uint8(rand.IntN(256))
b := uint8(rand.IntN(256))
dev.SetKeyColor(ev.Key, color.RGBA{r, g, b, 255})
} else {
fmt.Printf("Key %d released\n", ev.Key)
}
case <-sig:
fmt.Println()
return
}
}
}

8
go.mod
View File

@@ -2,4 +2,10 @@ module qrun
go 1.25.6 go 1.25.6
require gitlab.com/gomidi/midi/v2 v2.3.22 require (
gitlab.com/gomidi/midi/v2 v2.3.22
golang.org/x/image v0.30.0
rafaelmartins.com/p/usbhid v0.0.0-20250616003425-c818f1cb579e
)
require github.com/ebitengine/purego v0.8.4 // indirect

6
go.sum
View File

@@ -1,2 +1,8 @@
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
gitlab.com/gomidi/midi/v2 v2.3.22 h1:4Q20o6q4BDo7i/KGvnwASeytOlrPI7MwsS7F2hA7fOM= gitlab.com/gomidi/midi/v2 v2.3.22 h1:4Q20o6q4BDo7i/KGvnwASeytOlrPI7MwsS7F2hA7fOM=
gitlab.com/gomidi/midi/v2 v2.3.22/go.mod h1:jDpP4O4skYi+7iVwt6Zyp18bd2M4hkjtMuw2cmgKgfw= gitlab.com/gomidi/midi/v2 v2.3.22/go.mod h1:jDpP4O4skYi+7iVwt6Zyp18bd2M4hkjtMuw2cmgKgfw=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
rafaelmartins.com/p/usbhid v0.0.0-20250616003425-c818f1cb579e h1:Xlg01Rbs6PVG1yOvNEmMjI+edsmua23REsPO+tyhOyU=
rafaelmartins.com/p/usbhid v0.0.0-20250616003425-c818f1cb579e/go.mod h1:focKssvBxJwZE6GrEZipSBZsUwsFkcc0ECSq/In1Kww=

204
streamdeck/streamdeck.go Normal file
View File

@@ -0,0 +1,204 @@
package streamdeck
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
"time"
xdraw "golang.org/x/image/draw"
"rafaelmartins.com/p/usbhid"
)
const (
elgatoVendorID = 0x0fd9
keyCount = 32
keyRows = 4
keyCols = 8
keySize = 96
keyStart = 3
)
var xlProductIDs = map[uint16]bool{
0x006c: true,
0x008f: true,
}
type Device struct {
dev *usbhid.Device
}
func Open() (*Device, error) {
devices, err := usbhid.Enumerate(func(dev *usbhid.Device) bool {
return dev.VendorId() == elgatoVendorID && xlProductIDs[dev.ProductId()]
})
if err != nil {
return nil, fmt.Errorf("streamdeck: enumerate: %w", err)
}
if len(devices) == 0 {
return nil, fmt.Errorf("streamdeck: no XL device found")
}
dev := devices[0]
if err := dev.Open(true); err != nil {
return nil, fmt.Errorf("streamdeck: open: %w", err)
}
return &Device{dev: dev}, nil
}
func (d *Device) Close() error {
return d.dev.Close()
}
func (d *Device) SerialNumber() string {
return d.dev.SerialNumber()
}
func (d *Device) Product() string {
return d.dev.Product()
}
func (d *Device) FirmwareVersion() (string, error) {
buf, err := d.dev.GetFeatureReport(5)
if err != nil {
return "", err
}
b, _, _ := bytes.Cut(buf[5:], []byte{0})
return string(b), nil
}
func (d *Device) SetBrightness(perc byte) error {
if perc > 100 {
perc = 100
}
pl := make([]byte, d.dev.GetFeatureReportLength())
pl[0] = 0x08
pl[1] = perc
return d.dev.SetFeatureReport(3, pl)
}
func (d *Device) Reset() error {
pl := make([]byte, d.dev.GetFeatureReportLength())
pl[0] = 0x02
return d.dev.SetFeatureReport(3, pl)
}
func (d *Device) SetKeyColor(key int, c color.Color) error {
img := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
xdraw.Draw(img, img.Bounds(), &image.Uniform{c}, image.Point{}, xdraw.Src)
return d.SetKeyImage(key, img)
}
func (d *Device) SetKeyImage(key int, img image.Image) error {
if key < 0 || key >= keyCount {
return fmt.Errorf("streamdeck: invalid key %d", key)
}
scaled := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
xdraw.BiLinear.Scale(scaled, scaled.Bounds(), img, img.Bounds(), xdraw.Over, nil)
flipped := image.NewRGBA(scaled.Bounds())
for y := 0; y < keySize; y++ {
for x := 0; x < keySize; x++ {
flipped.Set(keySize-1-x, keySize-1-y, scaled.At(x, y))
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, flipped, &jpeg.Options{Quality: 100}); err != nil {
return err
}
return d.sendImage(byte(key), buf.Bytes())
}
func (d *Device) ClearKey(key int) error {
return d.SetKeyColor(key, color.Black)
}
func (d *Device) ClearAllKeys() error {
for i := 0; i < keyCount; i++ {
if err := d.ClearKey(i); err != nil {
return err
}
}
return nil
}
func (d *Device) sendImage(key byte, imgData []byte) error {
reportLen := d.dev.GetOutputReportLength()
hdrLen := uint16(7)
payloadLen := reportLen - hdrLen
var page byte
for start := uint16(0); start < uint16(len(imgData)); page++ {
end := start + payloadLen
last := byte(0)
if end >= uint16(len(imgData)) {
end = uint16(len(imgData))
last = 1
}
chunk := imgData[start:end]
hdr := []byte{
0x07,
key,
last,
byte(len(chunk)),
byte(len(chunk) >> 8),
page,
0,
}
payload := append(hdr, chunk...)
padding := make([]byte, reportLen-uint16(len(payload)))
payload = append(payload, padding...)
if err := d.dev.SetOutputReport(2, payload); err != nil {
return err
}
start = end
}
return nil
}
type KeyEvent struct {
Key int
Pressed bool
Time time.Time
}
func (d *Device) ReadKeys(ch chan<- KeyEvent) error {
states := make([]byte, keyCount)
for {
_, buf, err := d.dev.GetInputReport()
if err != nil {
return err
}
if int(keyStart+keyCount) > len(buf) {
continue
}
t := time.Now()
for i := 0; i < keyCount; i++ {
st := buf[keyStart+i]
if st != states[i] {
ch <- KeyEvent{
Key: i,
Pressed: st > 0,
Time: t,
}
states[i] = st
}
}
}
}
func KeyCount() int { return keyCount }
func KeyRows() int { return keyRows }
func KeyCols() int { return keyCols }