A local volume preset system for three Google Home speakers, controlled via a modded Spotify Car Thing.
The Car Thing runs a custom webapp talking to a Python Flask server on your Mac. The server sends absolute volume commands to each speaker through a local Home Assistant instance (running in Docker), which talks to the speakers over the LAN via the Google Cast integration. Home Assistant handles all Google auth internally with a long-lived token that never expires — no cloud round-trip and no 24h token churn. The Mac server auto-starts when a specific external monitor is connected, and shuts down when it disconnects.
Networking: the Car Thing has no WiFi of its own — it reaches the Mac only
over USB. monitor_watch.sh runs adb reverse tcp:5005 tcp:5005, which tunnels
the device's localhost:5005 to the Mac server. Because the device also has no
internet, the Mac proxies all Spotify calls (now-playing + album art); no
Spotify tokens ever touch the device.
[Car Thing] ──USB (adb reverse :5005)──> [Mac Flask server] ──REST──> [Home Assistant] ──> speakers
webapp @ localhost:5005 │ (Docker, :8123)
└── proxies ──> [Spotify Web API] (now-playing, art)
All config lives in presets.json, which holds secrets (Home Assistant
long-lived token, Spotify client secret + refresh token) and is therefore
git-ignored. The repo ships presets.example.json as a template — copy
it to presets.json and fill in your own values (see Setup). Never commit
presets.json.
Home Assistant discovers the speakers over the LAN, so they should have stable IP addresses, or Home Assistant may lose them when DHCP renews. Use DHCP reservations on your router:
- Find each speaker's MAC address in the Google Home app:
- Open the Google Home app → tap the device → gear icon → Device information.
- Note the MAC address (looks like
AA:BB:CC:11:22:33).
- Log into your router admin page (usually
http://192.168.1.1orhttp://10.0.0.1— check the sticker on the router). - Find the section called DHCP Reservations, Address Reservations, or Static Leases.
- Add a reservation for each speaker's MAC, assigning it a fixed IP on your
LAN (e.g.
10.0.0.50,10.0.0.51,10.0.0.52). - Reboot the speakers so they pick up the new lease.
Copy the template to create your local config:
cp presets.example.json presets.jsonpresets.json holds your secrets (Home Assistant token, Spotify client secret,
refresh token) and is git-ignored — never commit it. You'll fill in the
ha_token and per-speaker entity_id values during Step 3, and the Spotify
fields during Step 8. For now, just tune the volume levels.
Volumes are floats from 0.0 (muted) to 1.0 (max). Tune the per-speaker
levels to taste — the three presets DESK, BED, and AMBIENT are starting
points.
Speaker commands are routed through a local Home Assistant instance running in Docker. Home Assistant manages the Google Cast connection and auth for you.
-
Install Docker Desktop for Mac if you don't already have it: https://www.docker.com/products/docker-desktop/ — install and launch it.
-
Run the Home Assistant container:
docker run -d \ --name homeassistant \ --restart unless-stopped \ -p 8123:8123 \ -v "$HOME/homeassistant:/config" \ ghcr.io/home-assistant/home-assistant:stable -
Create an account: open
http://localhost:8123in a browser and follow the onboarding to create your Home Assistant account. -
Add the Google Cast integration: go to Settings → Devices & Services, click Add Integration, search for Google Cast, and add it.
-
Discover the speakers: your three speakers should be discovered automatically. Add all three when prompted.
-
Find each entity ID: go to Settings → Devices & Services → Google Cast and click each device. Each speaker has an entity ID in the form
media_player.device_name(e.g.media_player.nest_audio). Note all three. -
Create a long-lived access token: click your profile (bottom-left corner) → scroll to the bottom → Long-Lived Access Tokens → Create Token → name it
volumepresets→ copy it immediately (it is shown only once). -
Fill in
presets.json: replace the placeholders with the values you just collected:REPLACE_WITH_HA_LONG_LIVED_TOKEN→ your long-lived tokenREPLACE_WITH_NEST_AUDIO_ENTITY_ID→ the Nest Audio entity IDREPLACE_WITH_GOOGLE_HOME_ENTITY_ID→ the Google Home entity IDREPLACE_WITH_CHROMECAST_ENTITY_ID→ the Chromecast entity ID
Note: Home Assistant is intentionally not exposed to the internet. It only listens on
localhost(-p 8123:8123binds the local port) and communicates with the speakers over your local LAN. The long-lived token never leaves your Mac.
macOS's system Python is externally-managed, so install Flask into a local
virtualenv inside the project folder. monitor_watch.sh looks for the venv
at ./venv/bin/python3.
cd /path/to/SmartRoomThing
python3 -m venv venv
./venv/bin/pip install --upgrade pip flaskCA certificates (important): the server makes outbound HTTPS calls to Spotify,
so its Python needs a working CA bundle. The python.org macOS installer ships
without one. If you used it, run its certificate installer once (adjust the
version), or the server's Spotify proxy will fail with
CERTIFICATE_VERIFY_FAILED:
/Applications/Python\ 3.11/Install\ Certificates.command(Homebrew Python already has certs and needs nothing here.)
./venv/bin/python3 server.pyYou should see a startup banner with the URL, the Home Assistant URL, the Spotify
status, and each speaker's entity ID. Open http://127.0.0.1:5005 in a browser on
the Mac — you should see the Car Thing webapp. Try
curl -X POST http://127.0.0.1:5005/preset/1 and verify the speakers respond.
Stop the server with Ctrl-C.
brew install android-platform-toolsPlug the Car Thing into your Mac over USB, then:
adb devices # verify Car Thing is connected
adb shell mount -o remount,rw /
# Push the webapp file directly (pushing the folder nests a stale copy).
adb push car-thing-webapp/index.html /usr/share/qt-superbird-app/webapp/index.html
# Stop the kiosk Chromium from serving a stale cached page after a relaunch
# (a dock power-glitch can reboot the device). Disable its disk cache once:
adb shell 'grep -q "disk-cache-size=1" /etc/supervisord.conf || \
sed -i "s#--user-data-dir=/var/cache/chrome_storage #&--disk-cache-size=1 --aggressive-cache-discard #" /etc/supervisord.conf'
adb shell 'rm -rf /var/cache/chrome_storage/Default/Cache /var/cache/chrome_storage/Default/"Code Cache" /var/cache/chrome_storage/Default/GPUCache 2>/dev/null'
adb shell 'supervisorctl reread; supervisorctl update' # applies the chromium flag
# IMPORTANT: commit the writes to disk. The dock power-cycles the Car Thing on
# dock/undock (an *unclean* reboot), which drops any rootfs writes still sitting
# in cache — so without this, your changes silently revert to the stock image on
# the next dock. `sync` + remount-ro forces them to durable storage.
adb shell 'sync; sync; mount -o remount,ro /'
adb shell 'supervisorctl restart chromium' # load the freshly-pushed webappRe-run the
mount -o remount,rw /…sync; … remount,ro /wrapper any time you push new files to the device, or the change won't survive the next dock.
The webapp reaches the server at http://localhost:5005 on the device,
tunneled to your Mac over USB. In normal operation monitor_watch.sh keeps
adb reverse tcp:5005 tcp:5005 running for you (Step 9). To test by hand before
that's installed, set it yourself once the device is up:
adb reverse tcp:5005 tcp:5005
adb shell 'curl -s http://localhost:5005/presets' # should return JSONThe Car Thing has no keyboard, so Spotify is not authorized on the device.
You run a one-time OAuth flow on your Mac at http://127.0.0.1:5005/auth, which
produces a refresh token. All Spotify secrets — client ID, client secret,
and refresh token — live server-side in presets.json; the webapp never sees
them. It asks the server for a short-lived access token at /spotify/token
whenever it needs one. Nothing secret is ever pushed to the Car Thing.
-
Register the app. Go to https://developer.spotify.com/dashboard, log in, and either create an app or open the existing one. Under Edit Settings → Redirect URIs, make sure
http://127.0.0.1:5005/is listed (exactly — with the trailing slash), then Save. Spotify rejects plain-http://redirects to a LAN IP like10.0.0.144but grants a loopback exception tohttp://127.0.0.1, which is why auth must be done from the Mac. -
Fill in
presets.json. Copy the Client ID and Client Secret from the dashboard into:spotify_client_idspotify_client_secret
-
Start the server (Step 5) and, in a browser on the Mac, open
http://127.0.0.1:5005/auth. -
Click "Authorize with Spotify" and log in / approve the prompt. You'll be redirected back and the page will exchange the code for tokens automatically.
-
Copy the refresh token shown on the page (use the Copy button).
-
Paste it into
presets.jsonasspotify_refresh_token, then restart the server so it picks up the new value:launchctl kickstart -k gui/$UID/com.volumepresets(Or just unplug/replug the monitor.)
This only needs to be done once. The refresh token does not expire unless you
revoke it in your Spotify dashboard. Because the token lives only on the Mac, you
do not need to re-push the webapp to the Car Thing when you (re)authorize —
adb push is only for changes to index.html itself. The 127.0.0.1 address is
used both for this one-time auth (in your Mac browser) and by the Car Thing at
runtime (its USB tunnel maps the device's localhost:5005 to the Mac).
Until
spotify_refresh_tokenis set inpresets.json, the Car Thing shows "Open http://127.0.0.1:5005/auth on your Mac to complete setup."
This makes the server start whenever the LG HDR WFHD monitor is connected, and shut down when it disconnects.
# Replace the username placeholder in the plist with your actual username
sed -i '' 's/REPLACE_WITH_YOUR_USERNAME/'"$USER"'/g' com.volumepresets.plist
# Install
cp com.volumepresets.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.volumepresets.plistVerify it's running:
launchctl list | grep volumepresets
tail -f ~/Library/Logs/volumepresets.logEdit presets.json whenever you want to adjust the levels. After saving,
restart the server (launchctl kickstart -k gui/$UID/com.volumepresets, or
just unplug/replug the monitor). The Car Thing reflects the new values on the
next preset press.
server.py— Flask, binds0.0.0.0:5005. Loadspresets.jsonon startup.POST /preset/<id>fans out to all three speakers concurrently viaThreadPoolExecutor, calling the Home Assistant REST endpointPOST {ha_url}/api/services/media_player/volume_set(with aBearer {ha_token}header) and a body of{"entity_id": "<entity_id>", "volume_level": <0.0-1.0>}. Holds all Spotify secrets server-side and fully proxies Spotify (the device has no internet):GET /spotify/now-playingreturns the current track andGET /spotify/artstreams album art from the Spotify CDN. Access tokens are minted and cached on the server from the stored refresh token + client secret and never leave the Mac. Also serves the one-time auth page at/auth(completing the OAuth exchange when the redirect lands on/with?code=).GET /presetsreturns only preset/speaker names plus aspotify_configuredflag — never the HA token or Spotify secrets.car-thing-webapp/index.html— vanilla HTML/JS, no build step. Renders text as 5×7 dot matrix on canvas (scaled to fit the 800×480 screen). Reaches the server athttp://localhost:5005(USB tunnel). Two views: Mixer (live vertical faders, one per speaker — the home screen) and Now Playing (auto-switches when Spotify is playing). Turning the dial updates the faders live; tapping a fader focuses that speaker for individual control (auto-returns to ALL after a few seconds). Holds no credentials and makes no internet calls — it polls/spotify/now-playingand loads art via/spotify/art.init()retries until the server is reachable, then either starts or (if/presetsreports Spotify unconfigured) shows a message pointing to the Mac auth page.monitor_watch.sh— pollssystem_profiler SPDisplaysDataTypeevery 5 seconds for the stringLG HDR WFHD. On connect it starts thehomeassistantDocker container (waiting up to 30s for its API to come up), starts the Flask server, and establishesadb reverse tcp:5005 tcp:5005so the Car Thing can reach the server over USB (re-asserted each poll, so it survives a Car Thing reboot/replug). On disconnect it stops the Flask server, removes the reverse, and stops the container.com.volumepresets.plist— launchd agent that keepsmonitor_watch.shalive across logins and crashes.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /presets |
— | {presets, speakers, spotify_configured} (no secrets) |
| POST | /preset/<id> |
— | {ok, preset, volumes} |
| POST | /volume/adjust |
{"delta": ±N} or {"delta": ±N, "speaker": "<key>"} |
{ok, volumes} — ALL (proportional) or one speaker |
| GET | /status |
— | {active_preset, volumes} |
| GET | /spotify/now-playing |
— | Current track JSON, or {item:null, is_playing:false} |
| GET | /spotify/art?u= |
— | Proxied album art image (Spotify CDN hosts only) |
| GET | /spotify/token |
— | {ok, have_token} (debug: confirms server can mint a token) |
| GET | /auth |
— | One-time Spotify OAuth page (run on the Mac) |
| GET | / |
— | Car Thing webapp (or auth completion if ?code=) |
| Input | Action |
|---|---|
| Button 1 | Activate preset 1 (DESK) |
| Button 2 | Activate preset 2 (BED) |
| Button 3 | Activate preset 3 (AMBIENT) |
| Button 4 | Toggle Mixer ↔ Now Playing view |
| Dial scroll | Volume up/down (live faders) |
| Tap a fader | Control just that speaker (~4s, then back to ALL) |
By default the dial scales all speakers together by a fixed ratio — the loudest moves by the step and the rest scale to match, preserving the per-speaker balance and capping at 1.0 so nothing clips. Tap a fader to focus one speaker; the dial then adjusts only that one until it auto-returns to ALL a few seconds later. The Mixer view shows all three live levels and updates as you turn the dial.
The dial is a rotary encoder that the webview delivers as DOM wheel events
(it has no keyboard handler). Desktop/browser fallback for testing: keys
1 2 3 4, arrow up / arrow down, mouse wheel, and clicking a fader.
- Server logs:
tail -f ~/Library/Logs/volumepresets.log - Check Home Assistant is running:
docker ps | grep homeassistant - Restart Home Assistant:
docker restart homeassistant - Home Assistant logs:
docker logs -f homeassistant - Test the HA API directly:
Should return
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8123/api/{"message": "API running."}. - Test a volume set directly:
curl -X POST http://localhost:8123/api/services/media_player/volume_set \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"entity_id": "media_player.YOUR_ENTITY", "volume_level": 0.5}'
- Speaker not responding: confirm the speaker shows up in Home Assistant (Settings → Devices & Services → Google Cast), confirm its DHCP reservation is still active, and verify the speaker is on the same Wi-Fi network as the Mac.
- Car Thing shows "Connecting to Mac server…" or the auth message: this is
almost always the USB tunnel. The device has no WiFi — it reaches the Mac only
via
adb reverse. Check:adb devices # Car Thing listed as "device"? adb reverse --list # should show 5005 adb reverse tcp:5005 tcp:5005 # (re)establish it adb shell 'curl -s http://localhost:5005/presets' # device -> server works?
monitor_watch.shre-asserts the reverse every 5s, so it self-heals after a reboot/replug once adb reconnects. Ifadbisn't found by the agent, install it (brew install android-platform-tools) — the log will warn about this. - Car Thing shows an old version of the webapp: the kiosk Chromium cached it.
Confirm the cache-disable flag is applied (
adb shell 'grep disk-cache-size /etc/supervisord.conf'); if missing, re-run the Chromium steps in Step 7. Force a clean reload:adb shell 'rm -rf /var/cache/chrome_storage/Default/Cache; supervisorctl restart chromium'. Also make sure you pushedcar-thing-webapp/index.html(the file), not the folder. - Now Playing blank / no album art, or
CERTIFICATE_VERIFY_FAILEDin the log: the server's Python can't verify Spotify's TLS cert. Run the cert installer for your Python (see Step 4):/Applications/Python\ 3.11/Install\ Certificates.command. Verify withcurl http://127.0.0.1:5005/spotify/token→{"ok":true,...}. - Reload launchd after editing the plist:
launchctl unload ~/Library/LaunchAgents/com.volumepresets.plist launchctl load ~/Library/LaunchAgents/com.volumepresets.plist
- Re-authorize Spotify: redo Step 8 — run the
/authflow on the Mac again to mint a fresh refresh token, paste it intoindex.htmlasSTORED_REFRESH_TOKEN, and re-push to the Car Thing. (Revoke the old token at developer.spotify.com if needed.) - Monitor not detected: the script greps for the exact string
LG HDR WFHD— if your monitor reports a different name, runsystem_profiler SPDisplaysDataType | grep -i <hint>to find the right substring and updateMONITOR_NAMEinmonitor_watch.sh.