- Python 96.7%
- Shell 3.3%
| .gitignore | ||
| actions.yaml | ||
| config.yaml.example | ||
| README.md | ||
| requirements.txt | ||
| run.sh | ||
| server.py | ||
esp32-streamdeck-server
Companion HTTP server for
vipul-sharma20/esp32-streamdeck —
the ESP32-S3 CrowPanel firmware posts {"key": "<id>"} here and we run the
mapped shell command. Optional diary endpoints expose a daily markdown file as
a structured AST for the firmware's Notes layer.
FastAPI + YAML. No database, no state beyond toggle flip-flops held in memory.
Endpoints
GET / # service info + enabled features
POST /tap # body: {"key": "<id>"} → runs actions.yaml[id]
GET /state # toggle states
GET /diary/today # plain text, simplified markdown (opt-in)
GET /diary/today.json # block AST for rich rendering (opt-in)
GET /diary/today/mtime # cheap poll target (opt-in)
actions.yaml is reloaded on every /tap, so edits apply without a restart.
Setup
git clone https://github.com/vipul-sharma20/esp32-streamdeck-server
cd esp32-streamdeck-server
cp config.yaml.example config.yaml
$EDITOR config.yaml # toggle diary, set its path
./run.sh # creates .venv, installs deps, runs on :8787
Server listens on 0.0.0.0:8787. Make sure your Mac's firewall lets the
ESP32 reach it on port 8787 on the local subnet.
Point the firmware at the Mac's LAN IP via its include/secrets.h:
#define SERVER_HOST "192.168.1.9"
#define SERVER_PORT 8787
Default actions (actions.yaml)
The firmware ships with nine keys: mute_mic, play_pause, next_track,
vol_down, vol_up, dnd_toggle, open_slack, open_terminal,
lock_screen. Default mappings in actions.yaml use osascript, open -a,
shortcuts run, and pmset — standard macOS userland, no extra tools
required beyond Shortcuts for the DND toggle.
dnd_toggle expects a user-created Shortcut named "Toggle Focus" that
toggles Do Not Disturb. Set that up once in the Shortcuts app.
Custom actions
Two shapes:
# Fire-and-forget
open_slack:
run: "open -a Slack"
# Toggle (server tracks state in memory)
mute_mic:
toggle: true
on: "osascript -e 'set volume input volume 0'"
off: "osascript -e 'set volume input volume 75'"
Reloads per-request. Return payload includes rc and captured stdout+stderr
(truncated to 10s of runtime via subprocess.run(timeout=10)); the firmware
ignores the body and just shows HTTP status in the header.
Keys must match what the firmware sends. If you add a new key in the firmware
TILES[] array, add the same key here.
Diary layer (opt-in)
Off by default in config.yaml.example. Turn on:
diary:
enabled: true
path: "~/obsidian/vault/diary" # YYYY-MM-DD.md files live here
The firmware polls /diary/today/mtime every ~3s while its Notes tab is
active and refetches /diary/today.json only when the mtime changes, so a
:w in vim shows up on the device in ~3s without busy polling. The parser
recognizes # headings, - [ ] / - [x] todos, and -/* bullets; anything
else becomes a paragraph block.
Path presets for other daily-notes systems:
# Obsidian daily notes
path: "~/Documents/Obsidian/daily"
# Logseq journals (filename format differs — edit server.py if needed)
path: "~/logseq/journals"
When diary.enabled: false, all three /diary/* routes return 404. The
firmware side has its own compile-time flag (-DENABLE_NOTES_LAYER=0) to
drop the Notes tab entirely.
Running as a background service
./run.sh is fine for quick starts; launchd for always-on. Minimal plist
example (~/Library/LaunchAgents/com.local.streamdeck.plist):
<plist version="1.0">
<dict>
<key>Label</key><string>com.local.streamdeck</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/you/projects/esp32-streamdeck-server/run.sh</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/tmp/streamdeck.log</string>
<key>StandardErrorPath</key><string>/tmp/streamdeck.err</string>
</dict>
</plist>
launchctl load ~/Library/LaunchAgents/com.local.streamdeck.plist.
Layout on disk
esp32-streamdeck-server/
├── server.py
├── actions.yaml # your key → command map
├── config.yaml # gitignored (feature flags + paths)
├── config.yaml.example
├── requirements.txt
├── run.sh # venv + uvicorn
└── .gitignore # ignores .venv, config.yaml, __pycache__
Security
server.py runs whatever's in actions.yaml via subprocess.run(shell=True).
Bind to 0.0.0.0 only if you trust everyone on your LAN — there's no auth on
/tap. For a shared network, bind to 127.0.0.1 and reverse-proxy, or put
the ESP32 on a VLAN that can only see the Mac.