diff --git a/cmd/decktest/main.go b/cmd/decktest/main.go new file mode 100644 index 0000000..e058fdf --- /dev/null +++ b/cmd/decktest/main.go @@ -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 + } + } +} diff --git a/go.mod b/go.mod index c3bdcec..faa1f9f 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,10 @@ module qrun 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 diff --git a/go.sum b/go.sum index 7b614e6..b7f3ea7 100644 --- a/go.sum +++ b/go.sum @@ -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/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= diff --git a/streamdeck/streamdeck.go b/streamdeck/streamdeck.go new file mode 100644 index 0000000..79a6510 --- /dev/null +++ b/streamdeck/streamdeck.go @@ -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 }