Companion HTTP dispatcher for esp32-streamdeck
  • Python 96.7%
  • Shell 3.3%
Find a file
2026-04-21 02:22:33 +05:30
.gitignore add: streamdeck local server 2026-04-21 02:22:33 +05:30
actions.yaml add: streamdeck local server 2026-04-21 02:22:33 +05:30
config.yaml.example add: streamdeck local server 2026-04-21 02:22:33 +05:30
README.md add: streamdeck local server 2026-04-21 02:22:33 +05:30
requirements.txt add: streamdeck local server 2026-04-21 02:22:33 +05:30
run.sh add: streamdeck local server 2026-04-21 02:22:33 +05:30
server.py add: streamdeck local server 2026-04-21 02:22:33 +05:30

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.