1
0

Compare commits

...

4 Commits

Author SHA1 Message Date
Maksim Totmin
f73fed6d84 add MIT license, AUR packaging files, and credits 2026-06-23 16:59:15 +07:00
Maksim Totmin
993756117c cli: add 'switch' command to change page on running daemon 2026-06-22 14:59:32 +07:00
Maksim Totmin
799ef45e1a cli: remove 'serve', fix --help to show commands 2026-06-22 14:52:16 +07:00
Maksim Totmin
feae481302 cli: add --page flag to start on a specific page 2026-06-22 14:44:16 +07:00
8 changed files with 200 additions and 15 deletions

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Wakatron
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -398,6 +398,16 @@ Dependencies:
--- ---
## Credits
This project builds on the work of the following open-source libraries:
- **[dh1tw/streamdeck](https://github.com/dh1tw/streamdeck)** (MIT) — Go protocol driver for Elgato Stream Deck devices
- **[bearsh/hid](https://github.com/bearsh/hid)** (MIT) — Go USB HID bindings via CGO / libusb
- **[disintegration/gift](https://github.com/disintegration/gift)** (MIT) — Go Image Filtering Toolkit
---
## License ## License
MIT MIT

View File

@ -15,6 +15,7 @@ type RunOptions struct {
HTTPAddr string HTTPAddr string
HTTPEnabled bool HTTPEnabled bool
NoDeck bool NoDeck bool
StartPage string
} }
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
@ -63,6 +64,16 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
primaryPM := pageMgrs[0] primaryPM := pageMgrs[0]
primaryDeck := decks[0] primaryDeck := decks[0]
startPage := opts.StartPage
if startPage == "" {
startPage = cfg.DefaultPage
}
if !pageExists(cfg.Pages, startPage) {
slog.Warn("start page not found in pages, falling back to default",
"requested", startPage, "default", cfg.DefaultPage)
startPage = cfg.DefaultPage
}
defer func() { defer func() {
for _, d := range decks { for _, d := range decks {
d.Close() d.Close()
@ -78,8 +89,8 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
web.SetExtraPageManagers(pageMgrs[1:]) web.SetExtraPageManagers(pageMgrs[1:])
for _, pm := range pageMgrs { for _, pm := range pageMgrs {
if err := pm.ActivatePage(cfg.DefaultPage); err != nil { if err := pm.ActivatePage(startPage); err != nil {
slog.Warn("activate default page", "error", err) slog.Warn("activate start page", "error", err)
} }
pm.startPeriodicKeys() pm.startPeriodicKeys()
} }
@ -98,7 +109,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
windowCh, _ = detector.Start(ctx) windowCh, _ = detector.Start(ctx)
} }
asm := NewAutoSwitchManager(cfg.AutoSwitch, cfg.DefaultPage) asm := NewAutoSwitchManager(cfg.AutoSwitch, startPage)
ssCtrl := NewScreensaver(&cfg.Screensaver) ssCtrl := NewScreensaver(&cfg.Screensaver)
@ -149,7 +160,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
asm.Pause() asm.Pause()
primaryDeck.Close() primaryDeck.Close()
newDeck := reconnectDeck(ctx, cfg, &primaryPM) newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
if newDeck == nil { if newDeck == nil {
return ctx.Err() return ctx.Err()
} }
@ -162,7 +173,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
web.SetPageManager(primaryPM) web.SetPageManager(primaryPM)
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial())) newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
asm.NotifyManualPage(cfg.DefaultPage) asm.NotifyManualPage(startPage)
asm.Resume() asm.Resume()
continue continue
} }
@ -288,7 +299,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
asm.Pause() asm.Pause()
primaryDeck.Close() primaryDeck.Close()
newDeck := reconnectDeck(ctx, cfg, &primaryPM) newDeck := reconnectDeck(ctx, cfg, &primaryPM, startPage)
if newDeck == nil { if newDeck == nil {
return ctx.Err() return ctx.Err()
} }
@ -301,7 +312,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
web.SetPageManager(primaryPM) web.SetPageManager(primaryPM)
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial())) newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
asm.NotifyManualPage(cfg.DefaultPage) asm.NotifyManualPage(startPage)
asm.Resume() asm.Resume()
} }
@ -353,7 +364,7 @@ func deviceBrightness(cfg *config.Config, serial string) int {
return 75 return 75
} }
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager) *Deck { func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, startPage string) *Deck {
for { for {
newDeck, err := OpenDeck("") newDeck, err := OpenDeck("")
if err == nil { if err == nil {
@ -361,7 +372,7 @@ func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager) *D
(*pm).defaultFont = cfg.Font (*pm).defaultFont = cfg.Font
(*pm).showLabelBackground = cfg.ShowLabelBackground (*pm).showLabelBackground = cfg.ShowLabelBackground
(*pm).LoadPages(cfg.Pages) (*pm).LoadPages(cfg.Pages)
(*pm).ActivatePage(cfg.DefaultPage) (*pm).ActivatePage(startPage)
(*pm).startPeriodicKeys() (*pm).startPeriodicKeys()
return newDeck return newDeck
} }
@ -372,3 +383,13 @@ func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager) *D
} }
} }
} }
// pageExists reports whether pages contains a page with the given name.
func pageExists(pages []config.PageConfig, name string) bool {
for _, p := range pages {
if p.Name == name {
return true
}
}
return false
}

23
dist/arch/.SRCINFO vendored Normal file
View File

@ -0,0 +1,23 @@
pkgbase = streamdeck-lets-go-git
pkgdesc = Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI
pkgver = r0.unknown
pkgrel = 1
url = https://github.com/Wakatron/streamdeck-lets-go
arch = x86_64
arch = aarch64
license = MIT
makedepends = go>=1.26
makedepends = git
depends = libusb-1.0
optdepends = librsvg: SVG icon rendering
optdepends = fontconfig: system font detection
provides = streamdeck-lets-go
conflicts = streamdeck-lets-go
source = streamdeck-lets-go-git::git+https://github.com/Wakatron/streamdeck-lets-go.git
source = streamdeck-lets-go.service
source = 90-streamdeck.rules
b2sums = SKIP
b2sums = SKIP
b2sums = SKIP
pkgname = streamdeck-lets-go-git

7
dist/arch/90-streamdeck.rules vendored Normal file
View File

@ -0,0 +1,7 @@
# Elgato Stream Deck — allow access for locally logged-in users
# Vendor 0fd9
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", TAG+="uaccess"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", TAG+="uaccess"

48
dist/arch/PKGBUILD vendored Normal file
View File

@ -0,0 +1,48 @@
# Maintainer: Wakatron <wakatron@example.com>
pkgname=streamdeck-lets-go-git
_giturl=https://github.com/Wakatron/streamdeck-lets-go.git
pkgver=r0.unknown
pkgrel=1
pkgdesc="Lightweight daemon for controlling Elgato Stream Deck devices with a built-in web UI"
arch=('x86_64' 'aarch64')
url="https://github.com/Wakatron/streamdeck-lets-go"
license=('MIT')
depends=('libusb-1.0')
optdepends=('librsvg: SVG icon rendering'
'fontconfig: system font detection')
makedepends=('go>=1.26' 'git')
provides=('streamdeck-lets-go')
conflicts=('streamdeck-lets-go')
source=("${pkgname}::git+${_giturl}"
"streamdeck-lets-go.service"
"90-streamdeck.rules")
b2sums=('SKIP'
'SKIP'
'SKIP')
pkgver() {
cd "${srcdir}/${pkgname}"
git describe --always --tags 2>/dev/null || echo "r0.$(git rev-parse --short HEAD)"
}
build() {
cd "${srcdir}/${pkgname}"
export CGO_ENABLED=1
go build \
-ldflags "-s -w" \
-o streamdeck-lets-go .
}
package() {
cd "${srcdir}/${pkgname}"
install -Dm755 streamdeck-lets-go "${pkgdir}/usr/bin/streamdeck-lets-go"
install -Dm644 "${srcdir}/streamdeck-lets-go.service" \
"${pkgdir}/usr/lib/systemd/user/streamdeck-lets-go.service"
install -Dm644 "${srcdir}/90-streamdeck.rules" \
"${pkgdir}/usr/lib/udev/rules.d/90-streamdeck.rules"
}

14
dist/arch/streamdeck-lets-go.service vendored Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=Stream Deck Lets Go daemon
Documentation=https://github.com/Wakatron/streamdeck-lets-go
After=graphical-session.target
Wants=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/streamdeck-lets-go daemon
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target

53
main.go
View File

@ -1,12 +1,16 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
_ "image/gif" _ "image/gif"
@ -22,15 +26,16 @@ func main() {
fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError) fs := flag.NewFlagSet("streamdeck-lets-go", flag.ExitOnError)
configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)") configPath := fs.String("config", "", "path to config.json (default: ~/.config/streamdeck-lets-go/config.json)")
httpAddr := fs.String("addr", ":9090", "web UI listen address") httpAddr := fs.String("addr", ":9090", "web UI listen address")
startPage := fs.String("page", "", "page to activate on startup (default: config's default_page)")
noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)") noDeck := fs.Bool("no-deck", false, "run without Stream Deck hardware (config editing only)")
verbose := fs.Bool("v", false, "verbose output") verbose := fs.Bool("v", false, "verbose output")
if len(os.Args) < 2 { if len(os.Args) < 2 || os.Args[1] == "--help" || os.Args[1] == "-help" || os.Args[1] == "-h" {
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n") fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go [flags] [command]\n\n")
fmt.Fprintf(os.Stderr, "Commands:\n") fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n") fmt.Fprintf(os.Stderr, " daemon run the Stream Deck daemon with web UI (default)\n")
fmt.Fprintf(os.Stderr, " serve run web UI only (no deck hardware)\n") fmt.Fprintf(os.Stderr, " switch switch running daemon to a page (e.g. 'switch games')\n")
fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n") fmt.Fprintf(os.Stderr, " discover list connected Stream Decks\n")
fmt.Fprintf(os.Stderr, "\nFlags:\n") fmt.Fprintf(os.Stderr, "\nFlags:\n")
fs.PrintDefaults() fs.PrintDefaults()
os.Exit(0) os.Exit(0)
@ -42,13 +47,26 @@ func main() {
switch cmd { switch cmd {
case "daemon": case "daemon":
fs.Parse(args) fs.Parse(args)
case "serve": case "switch":
fs.Parse(args) fs.Parse(args)
*noDeck = true if fs.NArg() == 0 {
fmt.Fprintf(os.Stderr, "Usage: streamdeck-lets-go switch <page>\n")
os.Exit(1)
}
if err := switchToPage(*httpAddr, fs.Arg(0)); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("switched to page %q\n", fs.Arg(0))
return
case "discover": case "discover":
discover() discover()
return return
default: default:
if cmd == "serve" {
fmt.Fprintf(os.Stderr, "streamdeck-lets-go: 'serve' removed; use 'daemon --no-deck' instead\n")
os.Exit(1)
}
fs.Parse(os.Args[1:]) fs.Parse(os.Args[1:])
} }
@ -71,6 +89,7 @@ func main() {
HTTPAddr: *httpAddr, HTTPAddr: *httpAddr,
HTTPEnabled: httpEnabled, HTTPEnabled: httpEnabled,
NoDeck: *noDeck, NoDeck: *noDeck,
StartPage: *startPage,
}); err != nil { }); err != nil {
slog.Error("run", "error", err) slog.Error("run", "error", err)
os.Exit(1) os.Exit(1)
@ -92,3 +111,25 @@ func discover() {
fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID) fmt.Printf(" Serial: %s Model: %s PID: 0x%04x\n", d.Serial, d.Model, d.PID)
} }
} }
// switchToPage tells the running daemon (at httpAddr) to activate the given page
// by POSTing to its /api/activate-page endpoint.
func switchToPage(httpAddr, page string) error {
host := httpAddr
if strings.HasPrefix(host, ":") {
host = "127.0.0.1" + host
}
url := "http://" + host + "/api/activate-page"
body, _ := json.Marshal(map[string]string{"page": page})
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("daemon not running? %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("switch failed (HTTP %d)", resp.StatusCode)
}
return nil
}