Initial commit: Stream Deck daemon with web UI
- Web-based key editor (Alpine.js SPA) - Actions: shell commands, scripts, built-in media/volume/brightness, page switching - Keyboard shortcut action (wtype/xdotool) - On-device display with periodic command output - Auto page switching (Hyprland, Sway, Niri, GNOME, KDE, X11) - Screensaver with idle detection - Config hot-reload - Multi-device support
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
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}:
|
||||
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) 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) 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
|
||||
}
|
||||
|
||||
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(len(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)
|
||||
}
|
||||
Reference in New Issue
Block a user