Adventures with the Elgato Streamdeck

29 June 2023

The Elgato Streamdeck is a programmable console with illuminated buttons which supports both Windows and MacOS, with third-party open source software is also available for linux support. It has taken the internet by storm and has been particularly well received by streamers/video content producers, as a quick search on youtube for ‘Elgato Streamdeck’ will confirm.

This is what the Elgato Streamdeck Mk. II looks like:

Elgato Streamdeck Mk. II

On MacOS, the buttons can be made to e.g. open websites. To run arbitrary commands, shell scripts can be executed. Alternatively, it can issue a HTTP(S) GET request when a button is pressed. For more involved automation, a simple locally-running server can be used to do whatever we want when a given HTTP endpoint/route is hit. Go and the Gin HTTP server framework are ideal for this task.

Using the Streamdeck on linux

The software you want here is streamdeck-ui.

The instructions at https://timothycrosley.github.io/streamdeck-ui/docs/installation/ubuntu/ worked well for me on Ubuntu/Pop!_OS 22.04 LTS, though I did run into an error with the QT library when launching:

Could not load the Qt platform plugin "xcb" in "" even though it was found.

which was solved with:

sudo apt-get install '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev

Using the Streamdeck to control Squeezebox Touch and Squeezebox Radio

As a specific example, I wanted to be able to control my Squeezebox Touch and Squeezebox Radio from my Streamdeck. To do this, a locally running server is used to send JSON commands to the Logitech Media Server when a HTTP endpoint is hit as a result of a button being pressed on the Streamdeck.

In the below, Gin is used to set up the following routes:

  • /lms/play
  • /lms/pause
  • /touch/volup
  • /touch/voldown
  • /radio/volup
  • /radio/voldown

which are then hit with a GET request by the Streamdeck when button is pressed, and send JSON to the Logitech Media Service with a POST to effect the action.

go mod init github.com/algrt-hm/steamdeck-intermediary
go get github.com/gin-gonic/gin
// server.go

package main

import (
	"github.com/algrt-hm/steamdeck-intermediary/service"
	"github.com/gin-gonic/gin"
)

func main() {
	// playerMacAddressConst is the MAC address of the Squeezebox player we want to control
	const touchMacAddress = "00:04:20:23:a1:b5"
	const radioMacAddress = "00:04:20:2b:76:f6"

	gin.SetMode(gin.ReleaseMode)
	// instantiate server, includes logger and recovery middleware
	server := gin.Default()

	// Test / status endpoint
	server.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Hello World!",
		})
	})

	// Simple group: lms
	lms := server.Group("/lms")
	{
		lms.GET("/play", func(c *gin.Context) {
			code, body := service.LmsPost(touchMacAddress, service.LmsPlay)
			c.String(code, body)
		})
		lms.GET("/pause", func(c *gin.Context) {
			code, body := service.LmsPost(touchMacAddress, service.LmsPause)
			c.String(code, body)
		})

		touch := lms.Group("/touch")
		{
			touch.GET("/voldown", func(c *gin.Context) {
				code, body := service.LmsPost(touchMacAddress, service.LmsVolumeDown)
				c.String(code, body)
			})
			touch.GET("/volup", func(c *gin.Context) {
				code, body := service.LmsPost(touchMacAddress, service.LmsVolumeUp)
				c.String(code, body)
			})
		}

		radio := lms.Group("/radio")
		{
			radio.GET("/voldown", func(c *gin.Context) {
				code, body := service.LmsPost(radioMacAddress, service.LmsVolumeDown)
				c.String(code, body)
			})
			radio.GET("/volup", func(c *gin.Context) {
				code, body := service.LmsPost(radioMacAddress, service.LmsVolumeUp)
				c.String(code, body)
			})
		}
	}

	server.Run(":8080")
}
// service/lms-service.go

package service

import (
	"bytes"
	"encoding/json"
	"io"
	"log"
	"net/http"
)

/*
Example of what we're doing here:
We send a post request to e.g: http://lms-host:9000/jsonrpc.js

The body looks like e.g. (for pause):
   {"id":1,"method":"slim.request","params":["00:04:20:2b:76:f6",["pause"]]}
e.g. (for play):
   {"id":1,"method":"slim.request","params":["00:04:20:2b:76:f6",["play"]]}
*/

// lmsHostnameConst is the hostname of the LMS server; here I am using a local DNS entry
// that is in my /etc/hosts file
const lmsHostnameConst = "lms-host"

// form of the JSON we send to the LMS server
type LmsService struct {
	Id     string `json:"id"`
	Method string `json:"method"`
	Params []any  `json:"params"`
}

// checkErr will call log.Fatal if the error is not nil i.e. there is an error
func checkErr(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

// LmsSimple is a helper function to create a LmsService struct for a simple operation
// with a given player MAC address and verb (e.g. "play" or "pause")
func LmsSimple(playerMacAddress string, verb string) LmsService {
	/*
		Looks like e.g:

		{
			"id": "1",
			"method": "slim.request",
			"params": [
				"00:04:20:2b:76:f6",
				[
					"pause"
				]
			]
		}
	*/

	return LmsService{
		Id:     "1",
		Method: "slim.request",
		Params: []any{playerMacAddress, []string{verb}},
	}
}

// LmsPlay is a helper function to create a LmsService struct for a "play" operation
func LmsPlay(playerMacAddress string) LmsService {
	return LmsSimple(playerMacAddress, "play")
}

// LmsPause is a helper function to create a LmsService struct for a "pause" operation
func LmsPause(playerMacAddress string) LmsService {
	return LmsSimple(playerMacAddress, "pause")
}

// LmsVolumeDown is a helper function to create a LmsService struct for a "volume down" operation
func LmsVolumeDown(playerMacAddress string) LmsService {
	return LmsService{
		Id:     "1",
		Method: "slim.request",
		Params: []any{playerMacAddress, []string{"mixer", "volume", "-10"}},
	}
}

// LmsVolumeUp is a helper function to create a LmsService struct for a "volume up" operation
func LmsVolumeUp(playerMacAddress string) LmsService {
	return LmsService{
		Id:     "1",
		Method: "slim.request",
		Params: []any{playerMacAddress, []string{"mixer", "volume", "+10"}},
	}
}

// LmsPost is a helper function to send a POST request to the LMS server
// with the given LmsService struct
// returning the HTTP status code and body text
func LmsPost(playerMacAddress string, fn func(string) LmsService) (int, string) {
	postUrl := "http://" + lmsHostnameConst + ":9000/jsonrpc.js"
	jsondata, err := json.Marshal(fn(playerMacAddress))
	checkErr(err)

	r, err := http.NewRequest("POST", postUrl, bytes.NewBuffer(jsondata))
	checkErr(err)
	r.Header.Set("Content-Type", "application/json")

	client := &http.Client{}
	res, err := client.Do(r)
	checkErr(err)
	defer res.Body.Close()

	bodyText, err := io.ReadAll(res.Body)
	checkErr(err)
	return res.StatusCode, string(bodyText)
}
go build
./steamdeck-intermediary

steamdeck-intermediary output in shell

We can then hit those endpoints from the streamdeck (example below):

play api endpoint in streamdeck

These screenshots show MacOS. Using streamdeck-ui on linux, the HTTP GET option is not baked-in, so e.g. curl -X GET http://hansolo:8080/lms/pause is the command to have run on the button press.

That takes care of volume control: for a re-scan we can hit http://lms-host:9000/settings/index.html?p0=rescan in the browser:

rescan in streamdeck

Reference