382 lines
8.0 KiB
Go
382 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/bearsh/hid"
|
|
"github.com/dh1tw/streamdeck"
|
|
"github.com/disintegration/gift"
|
|
)
|
|
|
|
type DeviceInfo struct {
|
|
Serial string
|
|
Model string
|
|
PID uint16
|
|
}
|
|
|
|
type DeckConfig struct {
|
|
PID uint16
|
|
Name string
|
|
KeysX int
|
|
KeysY int
|
|
KeySize int
|
|
ImageFmt string
|
|
Rotate bool
|
|
Convert bool
|
|
HasDials bool
|
|
HasTouch bool
|
|
}
|
|
|
|
var knownDecks = []DeckConfig{
|
|
{PID: 0x60, Name: "Mini", KeysX: 3, KeysY: 2, KeySize: 80, ImageFmt: "bmp", Convert: true},
|
|
{PID: 0x6d, Name: "Original", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
|
|
{PID: 0x63, Name: "OriginalV2",KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "bmp"},
|
|
{PID: 0x80, Name: "MK2", KeysX: 5, KeysY: 3, KeySize: 72, ImageFmt: "jpg", Rotate: true},
|
|
{PID: 0x6c, Name: "XL", KeysX: 8, KeysY: 4, KeySize: 96, ImageFmt: "jpg"},
|
|
}
|
|
|
|
func findConfig(pid uint16) (DeckConfig, bool) {
|
|
for _, d := range knownDecks {
|
|
if d.PID == pid {
|
|
return d, true
|
|
}
|
|
}
|
|
return DeckConfig{}, false
|
|
}
|
|
|
|
func EnumerateDevices() ([]DeviceInfo, error) {
|
|
devices := hid.Enumerate(streamdeck.VendorID, 0)
|
|
if len(devices) == 0 {
|
|
return nil, nil
|
|
}
|
|
var infos []DeviceInfo
|
|
for _, d := range devices {
|
|
info := DeviceInfo{Serial: d.Serial, PID: d.ProductID}
|
|
if cfg, ok := findConfig(d.ProductID); ok {
|
|
info.Model = cfg.Name
|
|
} else {
|
|
info.Model = fmt.Sprintf("Unknown (0x%04x)", d.ProductID)
|
|
}
|
|
infos = append(infos, info)
|
|
}
|
|
return infos, nil
|
|
}
|
|
|
|
func toStreamDeckConfig(dc DeckConfig) *streamdeck.Config {
|
|
return &streamdeck.Config{
|
|
ProductID: dc.PID,
|
|
NumButtonColumns: dc.KeysX,
|
|
NumButtonRows: dc.KeysY,
|
|
Spacer: 19,
|
|
ButtonSize: dc.KeySize,
|
|
ImageFormat: dc.ImageFmt,
|
|
ImageRotate: dc.Rotate,
|
|
ConvertKey: dc.Convert,
|
|
}
|
|
}
|
|
|
|
type Event struct {
|
|
Kind int
|
|
Index int
|
|
At time.Time
|
|
}
|
|
|
|
const (
|
|
EventKeyPressed = 1
|
|
EventKeyReleased = 2
|
|
)
|
|
|
|
type Deck struct {
|
|
sd *streamdeck.StreamDeck
|
|
cfg DeckConfig
|
|
serial string
|
|
|
|
mu sync.Mutex
|
|
events chan Event
|
|
closed bool
|
|
brightness int
|
|
}
|
|
|
|
func OpenDeck(serial string) (*Deck, error) {
|
|
desiredSerial := serial
|
|
if serial == "first" || serial == "" {
|
|
desiredSerial = ""
|
|
}
|
|
|
|
sd, err := streamdeck.NewStreamDeck(desiredSerial)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening streamdeck: %w", err)
|
|
}
|
|
|
|
cfg, ok := findConfig(sd.Config.ProductID)
|
|
if !ok {
|
|
sd.Close()
|
|
return nil, fmt.Errorf("unsupported device PID: 0x%04x", sd.Config.ProductID)
|
|
}
|
|
|
|
d := &Deck{
|
|
sd: sd,
|
|
cfg: cfg,
|
|
serial: sd.Serial(),
|
|
events: make(chan Event, 64),
|
|
brightness: 75,
|
|
}
|
|
|
|
sd.SetBtnEventCb(func(s streamdeck.State, e streamdeck.Event) {
|
|
kind := 0
|
|
switch e.Kind {
|
|
case streamdeck.EventKeyPressed:
|
|
kind = EventKeyPressed
|
|
case streamdeck.EventKeyReleased:
|
|
kind = EventKeyReleased
|
|
default:
|
|
return
|
|
}
|
|
select {
|
|
case d.events <- Event{Kind: kind, Index: e.Which, At: time.Now()}:
|
|
default:
|
|
slog.Warn("event channel full, dropping event", "kind", e.Kind, "index", e.Which)
|
|
}
|
|
})
|
|
|
|
slog.Info("deck opened", "serial", d.serial, "model", cfg.Name, "keys", cfg.KeysX*cfg.KeysY)
|
|
return d, nil
|
|
}
|
|
|
|
func (d *Deck) Close() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return
|
|
}
|
|
d.closed = true
|
|
close(d.events)
|
|
d.sd.Close()
|
|
}
|
|
|
|
func (d *Deck) Serial() string { return d.serial }
|
|
func (d *Deck) Events() <-chan Event { return d.events }
|
|
func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
|
|
func (d *Deck) KeySize() int { return d.cfg.KeySize }
|
|
func (d *Deck) Config() DeckConfig { return d.cfg }
|
|
func (d *Deck) Model() string { return d.cfg.Name }
|
|
|
|
type DeckInfo struct {
|
|
Serial string `json:"serial"`
|
|
Model string `json:"model"`
|
|
KeysX int `json:"keys_x"`
|
|
KeysY int `json:"keys_y"`
|
|
NumKeys int `json:"num_keys"`
|
|
KeySize int `json:"key_size"`
|
|
}
|
|
|
|
func (d *Deck) DeckInfo() DeckInfo {
|
|
return DeckInfo{
|
|
Serial: d.serial,
|
|
Model: d.cfg.Name,
|
|
KeysX: d.cfg.KeysX,
|
|
KeysY: d.cfg.KeysY,
|
|
NumKeys: d.cfg.KeysX * d.cfg.KeysY,
|
|
KeySize: d.cfg.KeySize,
|
|
}
|
|
}
|
|
|
|
func OpenAllDecks() ([]*Deck, error) {
|
|
devices := hid.Enumerate(streamdeck.VendorID, 0)
|
|
if len(devices) == 0 {
|
|
return nil, fmt.Errorf("no stream deck devices found")
|
|
}
|
|
var decks []*Deck
|
|
for _, d := range devices {
|
|
if _, ok := findConfig(d.ProductID); !ok {
|
|
slog.Warn("skipping unsupported device", "pid", fmt.Sprintf("0x%04x", d.ProductID))
|
|
continue
|
|
}
|
|
deck, err := OpenDeck(d.Serial)
|
|
if err != nil {
|
|
slog.Warn("failed to open deck", "serial", d.Serial, "error", err)
|
|
continue
|
|
}
|
|
decks = append(decks, deck)
|
|
}
|
|
if len(decks) == 0 {
|
|
return nil, fmt.Errorf("no supported stream deck devices could be opened")
|
|
}
|
|
return decks, nil
|
|
}
|
|
|
|
func (d *Deck) SetBrightness(val int) error {
|
|
if val < 0 {
|
|
val = 0
|
|
}
|
|
if val > 100 {
|
|
val = 100
|
|
}
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return fmt.Errorf("deck closed")
|
|
}
|
|
d.brightness = val
|
|
return d.sd.SetBrightness(uint16(val))
|
|
}
|
|
|
|
func (d *Deck) Brightness() int {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
return d.brightness
|
|
}
|
|
|
|
func (d *Deck) FillColor(keyIndex int, r, g, b uint8) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return fmt.Errorf("deck closed")
|
|
}
|
|
return d.sd.FillColor(keyIndex, int(r), int(g), int(b))
|
|
}
|
|
|
|
func (d *Deck) FillImage(keyIndex int, img image.Image) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return fmt.Errorf("deck closed")
|
|
}
|
|
return d.sd.FillImage(keyIndex, img)
|
|
}
|
|
|
|
func (d *Deck) FillPanel(img image.Image) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return fmt.Errorf("deck closed")
|
|
}
|
|
return d.sd.FillPanel(img)
|
|
}
|
|
|
|
func (d *Deck) ClearAll() error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return fmt.Errorf("deck closed")
|
|
}
|
|
return d.sd.ClearAllBtns()
|
|
}
|
|
|
|
func (d *Deck) ClearKey(idx int) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return fmt.Errorf("deck closed")
|
|
}
|
|
return d.sd.ClearBtn(idx)
|
|
}
|
|
|
|
func (d *Deck) WriteText(keyIndex int, text string, bg color.Color, fontName string, fontSize float64) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return fmt.Errorf("deck closed")
|
|
}
|
|
if fontSize <= 0 {
|
|
fontSize = 18
|
|
}
|
|
font := streamdeck.MonoMedium
|
|
if fontName == "regular" {
|
|
font = streamdeck.MonoRegular
|
|
}
|
|
|
|
ks := d.cfg.KeySize
|
|
charW := fontSize * 0.55
|
|
textW := int(float64(len(text)) * charW)
|
|
posX := (ks - textW) / 2
|
|
if posX < 2 {
|
|
posX = 2
|
|
}
|
|
posY := ks/2 - 24 + int(fontSize/3)
|
|
if posY < 2 {
|
|
posY = 2
|
|
}
|
|
|
|
return d.sd.WriteText(keyIndex, streamdeck.TextButton{
|
|
Lines: []streamdeck.TextLine{
|
|
{
|
|
Text: text,
|
|
PosX: posX,
|
|
PosY: posY,
|
|
Font: font,
|
|
FontSize: fontSize,
|
|
FontColor: color.White,
|
|
},
|
|
},
|
|
BgColor: bg,
|
|
})
|
|
}
|
|
|
|
func (d *Deck) WriteTextOnImage(keyIndex int, img image.Image, text, fontName string, fontSize float64, showLabelBackground bool) error {
|
|
ks := d.cfg.KeySize
|
|
|
|
g := gift.New(
|
|
gift.Resize(ks, ks, gift.LanczosResampling),
|
|
)
|
|
rgba := image.NewRGBA(image.Rect(0, 0, ks, ks))
|
|
g.Draw(rgba, img)
|
|
|
|
lines := []streamdeck.TextLine{}
|
|
if text != "" {
|
|
if fontSize <= 0 {
|
|
fontSize = 16
|
|
}
|
|
font := streamdeck.MonoRegular
|
|
if fontName == "medium" {
|
|
font = streamdeck.MonoMedium
|
|
}
|
|
|
|
if showLabelBackground {
|
|
barHeight := 20
|
|
if ks < 72 {
|
|
barHeight = 18
|
|
}
|
|
barRect := image.Rect(0, ks-barHeight, ks, ks)
|
|
draw.Draw(rgba, barRect, &image.Uniform{color.RGBA{0, 0, 0, 180}}, image.Point{}, draw.Over)
|
|
}
|
|
|
|
offsetY := 24
|
|
baselineY := ks - 6
|
|
posY := baselineY - offsetY
|
|
if posY < 2 {
|
|
posY = 2
|
|
}
|
|
|
|
charW := fontSize * 0.55
|
|
textW := int(float64(utf8.RuneCountInString(text)) * charW)
|
|
posX := (ks - textW) / 2
|
|
if posX < 2 {
|
|
posX = 2
|
|
}
|
|
|
|
lines = append(lines, streamdeck.TextLine{
|
|
Text: text,
|
|
PosX: posX,
|
|
PosY: posY,
|
|
Font: font,
|
|
FontSize: fontSize,
|
|
FontColor: color.White,
|
|
})
|
|
}
|
|
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.closed {
|
|
return fmt.Errorf("deck closed")
|
|
}
|
|
return d.sd.WriteTextOnImage(keyIndex, rgba, lines)
|
|
}
|