From 12e02e680d5af5ce9b0bb52416cd268e2c480d7d Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 11 Feb 2026 22:07:17 -0800 Subject: [PATCH] Add Stream Deck+ support with encoders, touch strip, and LCD output --- cmd/decktest/main.go | 80 +++++---- lib/streamdeck/streamdeck.go | 317 ++++++++++++++++++++++++++++------- lib/streamdeck/text.go | 16 +- 3 files changed, 319 insertions(+), 94 deletions(-) diff --git a/cmd/decktest/main.go b/cmd/decktest/main.go index 523442e..b79e0c9 100644 --- a/cmd/decktest/main.go +++ b/cmd/decktest/main.go @@ -10,13 +10,6 @@ import ( "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{ {220, 50, 50, 255}, {50, 180, 50, 255}, @@ -28,15 +21,6 @@ var palette = []color.RGBA{ {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() { dev, err := streamdeck.Open() if err != nil { @@ -45,19 +29,43 @@ func main() { } 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) - for i := 0; i < streamdeck.KeyCount(); i++ { - drawKey(dev, i, false) + keyLabels := make([]string, model.Keys) + 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() { - if err := dev.ReadKeys(keys); err != nil { + if err := dev.ReadInput(input); err != nil { fmt.Fprintf(os.Stderr, "Read error: %v\n", err) } }() @@ -67,13 +75,29 @@ func main() { for { select { - case ev := <-keys: - if !ev.Pressed { - continue + case ev := <-input: + if ev.Key != nil && ev.Key.Pressed { + 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: fmt.Println() return diff --git a/lib/streamdeck/streamdeck.go b/lib/streamdeck/streamdeck.go index 79a6510..0dc9bf4 100644 --- a/lib/streamdeck/streamdeck.go +++ b/lib/streamdeck/streamdeck.go @@ -2,6 +2,7 @@ package streamdeck import ( "bytes" + "encoding/binary" "fmt" "image" "image/color" @@ -13,33 +14,83 @@ import ( "rafaelmartins.com/p/usbhid" ) -const ( - elgatoVendorID = 0x0fd9 - keyCount = 32 - keyRows = 4 - keyCols = 8 - keySize = 96 - keyStart = 3 -) +const elgatoVendorID = 0x0fd9 -var xlProductIDs = map[uint16]bool{ - 0x006c: true, - 0x008f: true, +type Model struct { + Name string + 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 { - dev *usbhid.Device + dev *usbhid.Device + model *Model } func Open() (*Device, error) { 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 { return nil, fmt.Errorf("streamdeck: enumerate: %w", err) } 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] @@ -47,20 +98,13 @@ func Open() (*Device, error) { 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 { - 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) Model() *Model { return d.model } +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) @@ -88,32 +132,38 @@ func (d *Device) Reset() 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) return d.SetKeyImage(key, img) } 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) } - 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) - 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 src image.Image = scaled + if d.model.FlipKeys { + flipped := image.NewRGBA(scaled.Bounds()) + 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 - 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 d.sendImage(byte(key), buf.Bytes()) + return d.sendKeyImage(byte(key), buf.Bytes()) } func (d *Device) ClearKey(key int) error { @@ -121,7 +171,7 @@ func (d *Device) ClearKey(key int) 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 { return err } @@ -129,12 +179,12 @@ func (d *Device) ClearAllKeys() error { return nil } -func (d *Device) sendImage(key byte, imgData []byte) error { +func (d *Device) sendKeyImage(key byte, imgData []byte) error { reportLen := d.dev.GetOutputReportLength() - hdrLen := uint16(7) + hdrLen := uint16(8) payloadLen := reportLen - hdrLen - var page byte + var page uint16 for start := uint16(0); start < uint16(len(imgData)); page++ { end := start + payloadLen last := byte(0) @@ -145,13 +195,14 @@ func (d *Device) sendImage(key byte, imgData []byte) error { chunk := imgData[start:end] hdr := []byte{ + 0x02, 0x07, key, last, byte(len(chunk)), byte(len(chunk) >> 8), - page, - 0, + byte(page), + byte(page >> 8), } payload := append(hdr, chunk...) @@ -166,39 +217,193 @@ func (d *Device) sendImage(key byte, imgData []byte) error { 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 { Key int Pressed bool Time time.Time } -func (d *Device) ReadKeys(ch chan<- KeyEvent) error { - states := make([]byte, keyCount) +type EncoderEvent struct { + 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 { _, buf, err := d.dev.GetInputReport() if err != nil { return err } - - if int(keyStart+keyCount) > len(buf) { + if len(buf) < 4 { 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, + switch buf[0] { + case 0x00: + keyStart := 3 + for i := 0; i < d.model.Keys; i++ { + if keyStart+i >= len(buf) { + break + } + 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 KeyRows() int { return keyRows } -func KeyCols() int { return keyCols } +func (d *Device) ReadKeys(ch chan<- KeyEvent) error { + input := make(chan InputEvent, 64) + go func() { + for ev := range input { + if ev.Key != nil { + ch <- *ev.Key + } + } + }() + return d.ReadInput(input) +} diff --git a/lib/streamdeck/text.go b/lib/streamdeck/text.go index b1424a7..cd6a298 100644 --- a/lib/streamdeck/text.go +++ b/lib/streamdeck/text.go @@ -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 { - return TextImageWithFace(MonoMedium, bg, fg, lines...) +func TextImageSized(size int, bg color.Color, fg color.Color, lines ...string) image.Image { + return TextImageWithFaceSized(MonoMedium, size, bg, fg, lines...) } -func BoldTextImage(bg color.Color, fg color.Color, lines ...string) image.Image { - return TextImageWithFace(MonoBold, bg, fg, lines...) -} - -func TextImageWithFace(face font.Face, bg color.Color, fg color.Color, lines ...string) image.Image { - img := image.NewRGBA(image.Rect(0, 0, keySize, keySize)) +func TextImageWithFaceSized(face font.Face, size int, bg color.Color, fg color.Color, lines ...string) image.Image { + img := image.NewRGBA(image.Rect(0, 0, size, size)) draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src) DrawText(img, face, fg, lines...) 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 { 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 { 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...)) }