Add Stream Deck+ support with encoders, touch strip, and LCD output

This commit is contained in:
Ian Gulliver
2026-02-11 22:07:17 -08:00
parent 366f1714a0
commit 12e02e680d
3 changed files with 319 additions and 94 deletions

View File

@@ -10,13 +10,6 @@ import (
"qrun/lib/streamdeck" "qrun/lib/streamdeck"
) )
var keyLabels = []string{
"1", "2", "3", "4", "5", "6", "7", "8",
"A", "B", "C", "D", "E", "F", "G", "H",
"I", "J", "K", "L", "M", "N", "O", "P",
"Q", "R", "S", "T", "U", "V", "W", "X",
}
var palette = []color.RGBA{ var palette = []color.RGBA{
{220, 50, 50, 255}, {220, 50, 50, 255},
{50, 180, 50, 255}, {50, 180, 50, 255},
@@ -28,15 +21,6 @@ var palette = []color.RGBA{
{100, 100, 200, 255}, {100, 100, 200, 255},
} }
func drawKey(dev *streamdeck.Device, key int, active bool) {
col := key % streamdeck.KeyCols()
bg := palette[col]
if !active {
bg = color.RGBA{bg.R / 3, bg.G / 3, bg.B / 3, 255}
}
dev.SetKeyText(key, bg, color.White, keyLabels[key])
}
func main() { func main() {
dev, err := streamdeck.Open() dev, err := streamdeck.Open()
if err != nil { if err != nil {
@@ -45,19 +29,43 @@ func main() {
} }
defer dev.Close() defer dev.Close()
fmt.Printf("Connected to: %s (serial: %s)\n", dev.Product(), dev.SerialNumber()) model := dev.Model()
fmt.Printf("Connected to: %s (%s, serial: %s)\n", dev.Product(), model.Name, dev.SerialNumber())
fmt.Printf("Keys: %d (%dx%d), Encoders: %d\n", model.Keys, model.KeyCols, model.KeyRows, model.Encoders)
dev.SetBrightness(80) dev.SetBrightness(80)
for i := 0; i < streamdeck.KeyCount(); i++ { keyLabels := make([]string, model.Keys)
drawKey(dev, i, false) for i := range keyLabels {
if i < 8 {
keyLabels[i] = string(rune('1' + i))
} else {
keyLabels[i] = string(rune('A' + i - 8))
}
} }
active := make([]bool, streamdeck.KeyCount()) drawKey := func(key int, active bool) {
col := key % model.KeyCols
bg := palette[col%len(palette)]
if !active {
bg = color.RGBA{bg.R / 3, bg.G / 3, bg.B / 3, 255}
}
dev.SetKeyText(key, bg, color.White, keyLabels[key])
}
keys := make(chan streamdeck.KeyEvent, 64) for i := 0; i < model.Keys; i++ {
drawKey(i, false)
}
if model.LCDWidth > 0 {
dev.SetLCDColor(0, 0, model.LCDWidth, model.LCDHeight, color.RGBA{30, 30, 30, 255})
}
active := make([]bool, model.Keys)
input := make(chan streamdeck.InputEvent, 64)
go func() { go func() {
if err := dev.ReadKeys(keys); err != nil { if err := dev.ReadInput(input); err != nil {
fmt.Fprintf(os.Stderr, "Read error: %v\n", err) fmt.Fprintf(os.Stderr, "Read error: %v\n", err)
} }
}() }()
@@ -67,13 +75,29 @@ func main() {
for { for {
select { select {
case ev := <-keys: case ev := <-input:
if !ev.Pressed { if ev.Key != nil && ev.Key.Pressed {
continue k := ev.Key.Key
active[k] = !active[k]
drawKey(k, active[k])
fmt.Printf("Key %s toggled %v\n", keyLabels[k], active[k])
}
if ev.Encoder != nil {
e := ev.Encoder
if e.Delta != 0 {
fmt.Printf("Encoder %d rotated %+d\n", e.Encoder, e.Delta)
} else {
fmt.Printf("Encoder %d pressed=%v\n", e.Encoder, e.Pressed)
}
}
if ev.Touch != nil {
t := ev.Touch
if t.Type == streamdeck.TouchSwipe {
fmt.Printf("Touch swipe (%d,%d) -> (%d,%d)\n", t.X, t.Y, t.X2, t.Y2)
} else {
fmt.Printf("Touch %v at (%d,%d)\n", t.Type, t.X, t.Y)
}
} }
active[ev.Key] = !active[ev.Key]
drawKey(dev, ev.Key, active[ev.Key])
fmt.Printf("Key %s toggled %v\n", keyLabels[ev.Key], active[ev.Key])
case <-sig: case <-sig:
fmt.Println() fmt.Println()
return return

View File

@@ -2,6 +2,7 @@ package streamdeck
import ( import (
"bytes" "bytes"
"encoding/binary"
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
@@ -13,33 +14,83 @@ import (
"rafaelmartins.com/p/usbhid" "rafaelmartins.com/p/usbhid"
) )
const ( const elgatoVendorID = 0x0fd9
elgatoVendorID = 0x0fd9
keyCount = 32
keyRows = 4
keyCols = 8
keySize = 96
keyStart = 3
)
var xlProductIDs = map[uint16]bool{ type Model struct {
0x006c: true, Name string
0x008f: true, Keys int
KeyRows int
KeyCols int
KeySize int
FlipKeys bool
Encoders int
LCDWidth int
LCDHeight int
}
var ModelXL = Model{
Name: "XL",
Keys: 32,
KeyRows: 4,
KeyCols: 8,
KeySize: 96,
FlipKeys: true,
}
var ModelPlus = Model{
Name: "Plus",
Keys: 8,
KeyRows: 2,
KeyCols: 4,
KeySize: 120,
Encoders: 4,
LCDWidth: 800,
LCDHeight: 100,
}
var productModels = map[uint16]*Model{
0x006c: &ModelXL,
0x008f: &ModelXL,
0x0084: &ModelPlus,
} }
type Device struct { type Device struct {
dev *usbhid.Device dev *usbhid.Device
model *Model
} }
func Open() (*Device, error) { func Open() (*Device, error) {
devices, err := usbhid.Enumerate(func(dev *usbhid.Device) bool { devices, err := usbhid.Enumerate(func(dev *usbhid.Device) bool {
return dev.VendorId() == elgatoVendorID && xlProductIDs[dev.ProductId()] return dev.VendorId() == elgatoVendorID && productModels[dev.ProductId()] != nil
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("streamdeck: enumerate: %w", err) return nil, fmt.Errorf("streamdeck: enumerate: %w", err)
} }
if len(devices) == 0 { if len(devices) == 0 {
return nil, fmt.Errorf("streamdeck: no XL device found") return nil, fmt.Errorf("streamdeck: no device found")
}
dev := devices[0]
model := productModels[dev.ProductId()]
if err := dev.Open(true); err != nil {
return nil, fmt.Errorf("streamdeck: open: %w", err)
}
return &Device{dev: dev, model: model}, nil
}
func OpenModel(m *Model) (*Device, error) {
devices, err := usbhid.Enumerate(func(dev *usbhid.Device) bool {
if dev.VendorId() != elgatoVendorID {
return false
}
return productModels[dev.ProductId()] == m
})
if err != nil {
return nil, fmt.Errorf("streamdeck: enumerate: %w", err)
}
if len(devices) == 0 {
return nil, fmt.Errorf("streamdeck: no %s device found", m.Name)
} }
dev := devices[0] dev := devices[0]
@@ -47,20 +98,13 @@ func Open() (*Device, error) {
return nil, fmt.Errorf("streamdeck: open: %w", err) return nil, fmt.Errorf("streamdeck: open: %w", err)
} }
return &Device{dev: dev}, nil return &Device{dev: dev, model: m}, nil
} }
func (d *Device) Close() error { func (d *Device) Model() *Model { return d.model }
return d.dev.Close() 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) SerialNumber() string {
return d.dev.SerialNumber()
}
func (d *Device) Product() string {
return d.dev.Product()
}
func (d *Device) FirmwareVersion() (string, error) { func (d *Device) FirmwareVersion() (string, error) {
buf, err := d.dev.GetFeatureReport(5) buf, err := d.dev.GetFeatureReport(5)
@@ -88,32 +132,38 @@ func (d *Device) Reset() error {
} }
func (d *Device) SetKeyColor(key int, c color.Color) error { func (d *Device) SetKeyColor(key int, c color.Color) error {
img := image.NewRGBA(image.Rect(0, 0, keySize, keySize)) sz := d.model.KeySize
img := image.NewRGBA(image.Rect(0, 0, sz, sz))
xdraw.Draw(img, img.Bounds(), &image.Uniform{c}, image.Point{}, xdraw.Src) xdraw.Draw(img, img.Bounds(), &image.Uniform{c}, image.Point{}, xdraw.Src)
return d.SetKeyImage(key, img) return d.SetKeyImage(key, img)
} }
func (d *Device) SetKeyImage(key int, img image.Image) error { func (d *Device) SetKeyImage(key int, img image.Image) error {
if key < 0 || key >= keyCount { if key < 0 || key >= d.model.Keys {
return fmt.Errorf("streamdeck: invalid key %d", key) return fmt.Errorf("streamdeck: invalid key %d", key)
} }
scaled := image.NewRGBA(image.Rect(0, 0, keySize, keySize)) sz := d.model.KeySize
scaled := image.NewRGBA(image.Rect(0, 0, sz, sz))
xdraw.BiLinear.Scale(scaled, scaled.Bounds(), img, img.Bounds(), xdraw.Over, nil) xdraw.BiLinear.Scale(scaled, scaled.Bounds(), img, img.Bounds(), xdraw.Over, nil)
flipped := image.NewRGBA(scaled.Bounds()) var src image.Image = scaled
for y := 0; y < keySize; y++ { if d.model.FlipKeys {
for x := 0; x < keySize; x++ { flipped := image.NewRGBA(scaled.Bounds())
flipped.Set(keySize-1-x, keySize-1-y, scaled.At(x, y)) for y := 0; y < sz; y++ {
for x := 0; x < sz; x++ {
flipped.Set(sz-1-x, sz-1-y, scaled.At(x, y))
}
} }
src = flipped
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := jpeg.Encode(&buf, flipped, &jpeg.Options{Quality: 100}); err != nil { if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: 100}); err != nil {
return err return err
} }
return d.sendImage(byte(key), buf.Bytes()) return d.sendKeyImage(byte(key), buf.Bytes())
} }
func (d *Device) ClearKey(key int) error { func (d *Device) ClearKey(key int) error {
@@ -121,7 +171,7 @@ func (d *Device) ClearKey(key int) error {
} }
func (d *Device) ClearAllKeys() error { func (d *Device) ClearAllKeys() error {
for i := 0; i < keyCount; i++ { for i := 0; i < d.model.Keys; i++ {
if err := d.ClearKey(i); err != nil { if err := d.ClearKey(i); err != nil {
return err return err
} }
@@ -129,12 +179,12 @@ func (d *Device) ClearAllKeys() error {
return nil return nil
} }
func (d *Device) sendImage(key byte, imgData []byte) error { func (d *Device) sendKeyImage(key byte, imgData []byte) error {
reportLen := d.dev.GetOutputReportLength() reportLen := d.dev.GetOutputReportLength()
hdrLen := uint16(7) hdrLen := uint16(8)
payloadLen := reportLen - hdrLen payloadLen := reportLen - hdrLen
var page byte var page uint16
for start := uint16(0); start < uint16(len(imgData)); page++ { for start := uint16(0); start < uint16(len(imgData)); page++ {
end := start + payloadLen end := start + payloadLen
last := byte(0) last := byte(0)
@@ -145,13 +195,14 @@ func (d *Device) sendImage(key byte, imgData []byte) error {
chunk := imgData[start:end] chunk := imgData[start:end]
hdr := []byte{ hdr := []byte{
0x02,
0x07, 0x07,
key, key,
last, last,
byte(len(chunk)), byte(len(chunk)),
byte(len(chunk) >> 8), byte(len(chunk) >> 8),
page, byte(page),
0, byte(page >> 8),
} }
payload := append(hdr, chunk...) payload := append(hdr, chunk...)
@@ -166,39 +217,193 @@ func (d *Device) sendImage(key byte, imgData []byte) error {
return nil return nil
} }
func (d *Device) SetLCDImage(x, y, w, h int, img image.Image) error {
if d.model.LCDWidth == 0 {
return fmt.Errorf("streamdeck: %s has no LCD", d.model.Name)
}
scaled := image.NewRGBA(image.Rect(0, 0, w, h))
xdraw.BiLinear.Scale(scaled, scaled.Bounds(), img, img.Bounds(), xdraw.Over, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 100}); err != nil {
return err
}
return d.sendLCDImage(uint16(x), uint16(y), uint16(w), uint16(h), buf.Bytes())
}
func (d *Device) SetLCDColor(x, y, w, h int, c color.Color) error {
img := image.NewRGBA(image.Rect(0, 0, w, h))
xdraw.Draw(img, img.Bounds(), &image.Uniform{c}, image.Point{}, xdraw.Src)
return d.SetLCDImage(x, y, w, h, img)
}
func (d *Device) sendLCDImage(x, y, w, h uint16, imgData []byte) error {
reportLen := d.dev.GetOutputReportLength()
hdrLen := uint16(16)
payloadLen := reportLen - hdrLen
var page uint16
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 := make([]byte, 16)
hdr[0] = 0x02
hdr[1] = 0x0C
binary.LittleEndian.PutUint16(hdr[2:], x)
binary.LittleEndian.PutUint16(hdr[4:], y)
binary.LittleEndian.PutUint16(hdr[6:], w)
binary.LittleEndian.PutUint16(hdr[8:], h)
hdr[10] = last
binary.LittleEndian.PutUint16(hdr[11:], page)
binary.LittleEndian.PutUint16(hdr[13:], uint16(len(chunk)))
hdr[15] = 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 { type KeyEvent struct {
Key int Key int
Pressed bool Pressed bool
Time time.Time Time time.Time
} }
func (d *Device) ReadKeys(ch chan<- KeyEvent) error { type EncoderEvent struct {
states := make([]byte, keyCount) Encoder int
Pressed bool
Delta int
Time time.Time
}
type TouchEvent struct {
X int
Y int
X2 int
Y2 int
Type TouchType
Time time.Time
}
type TouchType int
const (
TouchShort TouchType = 1
TouchLong TouchType = 2
TouchSwipe TouchType = 3
)
type InputEvent struct {
Key *KeyEvent
Encoder *EncoderEvent
Touch *TouchEvent
}
func (d *Device) ReadInput(ch chan<- InputEvent) error {
keyStates := make([]byte, d.model.Keys)
encoderStates := make([]byte, d.model.Encoders)
for { for {
_, buf, err := d.dev.GetInputReport() _, buf, err := d.dev.GetInputReport()
if err != nil { if err != nil {
return err return err
} }
if len(buf) < 4 {
if int(keyStart+keyCount) > len(buf) {
continue continue
} }
t := time.Now() t := time.Now()
for i := 0; i < keyCount; i++ { switch buf[0] {
st := buf[keyStart+i] case 0x00:
if st != states[i] { keyStart := 3
ch <- KeyEvent{ for i := 0; i < d.model.Keys; i++ {
Key: i, if keyStart+i >= len(buf) {
Pressed: st > 0, break
Time: t, }
st := buf[keyStart+i]
if st != keyStates[i] {
ch <- InputEvent{Key: &KeyEvent{
Key: i,
Pressed: st > 0,
Time: t,
}}
keyStates[i] = st
} }
states[i] = st
} }
case 0x03:
if d.model.Encoders == 0 || len(buf) < 8 {
continue
}
subType := buf[3]
switch subType {
case 0x00:
for i := 0; i < d.model.Encoders; i++ {
st := buf[4+i]
if st != encoderStates[i] {
ch <- InputEvent{Encoder: &EncoderEvent{
Encoder: i,
Pressed: st > 0,
Time: t,
}}
encoderStates[i] = st
}
}
case 0x01:
for i := 0; i < d.model.Encoders; i++ {
delta := int(int8(buf[4+i]))
if delta != 0 {
ch <- InputEvent{Encoder: &EncoderEvent{
Encoder: i,
Delta: delta,
Time: t,
}}
}
}
}
case 0x02:
if len(buf) < 14 {
continue
}
subType := TouchType(buf[3])
x := int(binary.LittleEndian.Uint16(buf[5:7]))
y := int(binary.LittleEndian.Uint16(buf[7:9]))
ev := TouchEvent{
X: x,
Y: y,
Type: subType,
Time: t,
}
if subType == TouchSwipe {
ev.X2 = int(binary.LittleEndian.Uint16(buf[9:11]))
ev.Y2 = int(binary.LittleEndian.Uint16(buf[11:13]))
}
ch <- InputEvent{Touch: &ev}
} }
} }
} }
func KeyCount() int { return keyCount } func (d *Device) ReadKeys(ch chan<- KeyEvent) error {
func KeyRows() int { return keyRows } input := make(chan InputEvent, 64)
func KeyCols() int { return keyCols } go func() {
for ev := range input {
if ev.Key != nil {
ch <- *ev.Key
}
}
}()
return d.ReadInput(input)
}

View File

@@ -77,16 +77,12 @@ func DrawText(img *image.RGBA, face font.Face, fg color.Color, lines ...string)
} }
} }
func TextImage(bg color.Color, fg color.Color, lines ...string) image.Image { func TextImageSized(size int, bg color.Color, fg color.Color, lines ...string) image.Image {
return TextImageWithFace(MonoMedium, bg, fg, lines...) return TextImageWithFaceSized(MonoMedium, size, bg, fg, lines...)
} }
func BoldTextImage(bg color.Color, fg color.Color, lines ...string) image.Image { func TextImageWithFaceSized(face font.Face, size int, bg color.Color, fg color.Color, lines ...string) image.Image {
return TextImageWithFace(MonoBold, bg, fg, lines...) img := image.NewRGBA(image.Rect(0, 0, size, size))
}
func TextImageWithFace(face font.Face, bg color.Color, fg color.Color, lines ...string) image.Image {
img := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
DrawText(img, face, fg, lines...) DrawText(img, face, fg, lines...)
return img return img
@@ -94,10 +90,10 @@ func TextImageWithFace(face font.Face, bg color.Color, fg color.Color, lines ...
func (d *Device) SetKeyText(key int, bg color.Color, fg color.Color, text string) error { func (d *Device) SetKeyText(key int, bg color.Color, fg color.Color, text string) error {
lines := strings.Split(text, "\n") lines := strings.Split(text, "\n")
return d.SetKeyImage(key, TextImage(bg, fg, lines...)) return d.SetKeyImage(key, TextImageSized(d.model.KeySize, bg, fg, lines...))
} }
func (d *Device) SetKeyBoldText(key int, bg color.Color, fg color.Color, text string) error { func (d *Device) SetKeyBoldText(key int, bg color.Color, fg color.Color, text string) error {
lines := strings.Split(text, "\n") lines := strings.Split(text, "\n")
return d.SetKeyImage(key, BoldTextImage(bg, fg, lines...)) return d.SetKeyImage(key, TextImageWithFaceSized(MonoBold, d.model.KeySize, bg, fg, lines...))
} }