Compare commits
4 Commits
cdbfec8bb7
...
f73fed6d84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73fed6d84 | ||
|
|
993756117c | ||
|
|
799ef45e1a | ||
|
|
feae481302 |
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||||
10
README.md
10
README.md
@ -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
|
||||||
|
|||||||
39
daemon.go
39
daemon.go
@ -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
23
dist/arch/.SRCINFO
vendored
Normal 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
7
dist/arch/90-streamdeck.rules
vendored
Normal 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
48
dist/arch/PKGBUILD
vendored
Normal 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
14
dist/arch/streamdeck-lets-go.service
vendored
Normal 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
|
||||||
49
main.go
49
main.go
@ -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,14 +26,15 @@ 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()
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user