Bose shut down the SoundTouch cloud, and the preset buttons on every SoundTouch speaker stopped working. SoundHack brings them back, using a Raspberry Pi on your own network.
It is about 1500 lines of plain Node.js with no dependencies at all — nothing
to npm install, nothing to compile, no Docker. An old Raspberry Pi that has been
in a drawer since 2014 is enough, because the audio never passes through it.
Buttons 1–6 play whatever config.json says they play.
A Bose SoundTouch speaker. Developed against a SoundTouch 10 on firmware 27.0.6. The rest of the family — SoundTouch 20, 30, Wave, Portable — speaks the same protocol, though the pairing step may behave differently.
A Raspberry Pi, or any always-on Linux machine on the same network as the speaker. A Pi 1 Model B+ from 2014 is plenty: the audio never passes through it, so it only ever answers a handful of small HTTP requests. It needs to keep the same IP address, so give it a static address or a DHCP reservation in your router.
Node 18 or newer, and git:
node --version # v18.0.0 or higher
git --versionIf Node is missing or too old on an armv6 Pi — the Pi 1, Pi Zero, or B+ — read the Node note before installing anything, as that case has a trap that wastes an afternoon.
Network access to the speaker, including its telnet port. Check both:
curl -s http://192.168.1.20:8090/info | head -3 # the speaker's HTTP API
telnet 192.168.1.20 17000 # the diagnostic shellNo password is needed; that shell is open by design. If telnet is refused, you cannot repoint the speaker, and nothing else here will work.
Ports 8000, 8090, 8080 and 17000 reachable between the Pi and the speaker. Anything isolating them — guest VLANs, client isolation on the access point — will break this silently.
You do not need a Bose account, the SoundTouch phone app, or any internet connection beyond whatever your radio stations need.
A SoundTouch speaker is not a standalone internet radio. When you press a preset, the speaker does not hold a stream URL and open it. It asks a server what to play, and the server answers. That server was Bose's, and it is gone.
The speaker talks to four addresses, all of which used to belong to Bose:
| What it is | Bose's original address | What it does |
|---|---|---|
| marge | streaming.bose.com |
your account: which speakers, which services, which presets |
| BMX registry | content.api.bose.io |
catalogue of streaming services and where to reach them |
| stats | events.api.bosecm.com |
telemetry |
| software update | worldwide.bose.com/updates/soundtouch |
firmware checks |
Those four addresses are stored inside the speaker, and they can be changed. You point them at your Pi, and SoundHack answers on the other end.
Six steps. Budget half an hour the first time, mostly waiting for the speaker to reboot.
git clone https://github.com/stefda/soundhack.git
cd soundhack
./install.shThat checks your Node version and creates config.json from the template. It
never overwrites an existing config.json, so it is safe to re-run after a
git pull.
You need the speaker's IP address (your router's client list will show it) and its device ID:
curl -s http://192.168.1.20:8090/infoThe deviceID attribute near the top is twelve hex characters — the MAC address
without separators. Note it down along with the Pi's own address (hostname -I).
Two rules, both of which the service checks at startup rather than letting you discover them later:
serviceUrlmust be the Pi's LAN address, neverlocalhost. It gets baked into the URLs handed to the speaker, and the speaker has to be able to reach it.- Stream URLs must be
http://, nothttps://. The speaker cannot play HTTPS streams.
A slot you leave out simply stays as it is. See choosing stations for where to find stream URLs.
This is the step that makes the speaker talk to you instead of to Bose, and it is done once. Full detail and the reasoning behind the ordering is in Pointing the speaker at the Pi — worth reading before you type, because the order of these lines matters.
telnet 192.168.1.20 17000sys configuration bmxRegistryUrl http://192.168.1.10:8000/bmx/registry/v1/services
sys configuration statsServerUrl http://192.168.1.10:8000
sys configuration margeServerUrl http://192.168.1.10:8000/marge
sys configuration swUpdateUrl http://192.168.1.10:8000/updates/soundtouch
envswitch boseurls set http://192.168.1.10:8000/marge http://192.168.1.10:8000/updates/soundtouch
Then power-cycle the speaker — pull the plug, wait, plug it back in.
sudo ./install.sh # installs and enables the systemd service
soundhack-logs # leave this running in another window
node --no-warnings pair-speaker.jsThen power-cycle the speaker again and give it a minute. This is the step where it learns it has an account, and therefore that it is allowed to play radio at all.
node --no-warnings set-presets.js
node --no-warnings set-presets.js --play 1 # test without leaving your chairPress a button. You should have radio.
Worth ten minutes, because almost every problem you can have is explained by this section.
marge is the account service, and it is the part that matters most. Its job is to tell the speaker "you belong to an account, and that account may use a source called LOCAL_INTERNET_RADIO."
This is the non-obvious bit:
A speaker will not play a source it has not been told it has.
If the speaker has no
LOCAL_INTERNET_RADIOsource, pressing a radio preset does nothing whatsoever — the press is dropped inside the speaker. Nothing reaches the Pi, nothing appears in the log, and the button looks broken. It is not. The speaker simply does not believe it is allowed to play that kind of thing.
The speaker learns its sources by syncing its account, and it only syncs an
account if it believes it belongs to one. That belief is a single field in the
speaker's own storage called margeAccountUUID. Setting it is all
pair-speaker.js does, and it is why pairing comes before everything else.
BMX is the content half. Its registry tells the speaker "there is a service called LOCAL_INTERNET_RADIO, and it lives at this address on the Pi". When a radio preset is pressed, the speaker fetches that address and we reply with the real stream URL. The speaker then opens the stream directly — the audio never passes through the Pi, which is why such modest hardware is enough.
Pressing preset 1 on a paired, working speaker:
- The speaker looks up preset 1 in its own storage. It finds a ContentItem whose
source is
LOCAL_INTERNET_RADIOand whose location is a URL on the Pi:http://<pi>:8000/core02/svc-bmx-adapter-orion/prod/orion/station?data=<blob> - It checks that it has a
LOCAL_INTERNET_RADIOsource. This is the check that fails silently when pairing has not been done. - It fetches that URL. This is the first moment anything reaches the Pi, and you will see it in the log.
- We decode
<blob>— base64 of{streamUrl, imageUrl, name}— and reply with the stream address. - The speaker opens the stream and plays it.
That indirection is deliberate. Because the preset stores our URL rather than
the station's, you can change which station a button plays by editing
config.json and restarting, without touching the speaker at all.
The speaker reads its service URLs and its account state once, at startup. Any change to either needs a power-cycle — pulling the plug, not pausing playback. After a cold boot, give it about a minute before it answers properly.
This single fact explains most confusing behaviour, including the most common
one: you add a station to a button that was previously empty, set-presets.js
reports success, and the button still does nothing. Storing a preset changes what
a button holds; only an account sync changes which buttons the speaker
considers live. Power-cycle and it works.
Find the plain http:// stream URL for a station, add it to presets in
config.json, then:
sudo systemctl restart soundhack
node --no-warnings set-presets.jsGood places to look: https://streamurl.link, https://www.radio-browser.info.
Two formats work, and knowing which you have matters when something will not play:
- A direct audio stream — a URL ending in
.mp3or.aacthat returns the audio itself. Check withcurl -sI <url>: you want200and aContent-Typeofaudio/mpegor similar. - A playlist —
.m3u8(HLS),.m3u, or.pls. The URL returns a short text file that points at the audio. Bose lists HLS among the supported formats, and some broadcasters (the BBC among them) now publish nothing else.
We tell the speaker which kind it is by looking at the file extension
(lib/bmx.js). Getting that wrong makes the speaker try to play playlist text as
audio, which fails quietly.
BBC URLs carry a pool_NNNNNNNN path segment that has changed several times over
the years. If a BBC station dies while others keep working, a stale pool number is
the first thing to suspect. Their ww variant is worldwide; a uk variant exists
and is geo-restricted.
Start here. Watch the log while you press the button:
soundhack-logsThat is a wrapper around journalctl -u soundhack -n 50 -f, installed to
/usr/local/bin by install.sh. Pass it any argument and it hands that straight
to journalctl instead:
soundhack-logs -n 200 --no-pager # last 200 lines, then exit
soundhack-logs --since "10 min ago"Then find the first row that matches what you see.
| What you see | What it means | What to do |
|---|---|---|
| Nothing at all in the log when a button is pressed | The press was dropped inside the speaker: it has no matching source. | pair-speaker.js --check. If unpaired, pair and power-cycle. |
| Nothing in the log, but the speaker is paired and other buttons work | The account sync predates this preset. | Power-cycle the speaker. See the speaker caches at boot. |
No power_on line after a power-cycle |
The speaker is not pointed at the Pi, or cannot reach it. | Re-check the URLs, and that the Pi is reachable at serviceUrl. |
power_on refused, 4012 in the reply |
speaker.deviceId does not match the hardware. |
The log prints the real one. Correct config.json, restart. |
A station line, but no audio |
We answered; the speaker could not play the stream. | Test it: curl -sI <url>. Must be http://, return 200, and be audio. |
Lines beginning with ? |
The speaker asked for something not implemented. | Usually harmless. The path says what it wanted. |
LOCAL_INTERNET_RADIO missing from :8090/sources |
The account sync has not happened, or carried no source. | Power-cycle, wait a minute, re-check. |
Useful direct checks:
curl -s http://192.168.1.20:8090/info # margeAccountUUID and the service URLs
curl -s http://192.168.1.20:8090/sources # LOCAL_INTERNET_RADIO should be here
curl -s http://192.168.1.20:8090/presets # what the buttons actually hold
curl -s http://192.168.1.10:8000/ # our own status pageSet "logLevel": "debug" in config.json for full request bodies.
The speaker has a diagnostic shell on port 17000, with no password.
telnet 192.168.1.20 17000Read the current values:
getpdo CurrentSystemConfiguration
Set them, one line at a time, waiting for each reply:
sys configuration bmxRegistryUrl http://192.168.1.10:8000/bmx/registry/v1/services
sys configuration statsServerUrl http://192.168.1.10:8000
sys configuration margeServerUrl http://192.168.1.10:8000/marge
sys configuration swUpdateUrl http://192.168.1.10:8000/updates/soundtouch
envswitch boseurls set http://192.168.1.10:8000/marge http://192.168.1.10:8000/updates/soundtouch
Then power-cycle the speaker.
Three things about that sequence, all learned the hard way by the community:
- Order matters.
envswitch boseurls setis not merely a two-field setter: it commits whatever is in the runtime layer at the moment it runs. The foursys configurationlines must come first. Reversed, everything still answers normally and the settings vanish silently at the next reboot. envswitchdoes not replyOK. It answers with something likeSetting Bose Server URLs to ... ->. That is success.- Commands are sometimes ignored on first entry. If a line gets no reply, type it again.
Running getpdo before a reboot only proves the runtime layer accepted the
values. It does not prove they will survive; only the envswitch line, in the
right order, decides that.
node --no-warnings pair-speaker.js --check # report state, change nothing
node --no-warnings pair-speaker.js # do itThe script tries three routes in order and stops at the first that works:
- WebSocket
setMargeAccount. The official app only ever sent this wrapped in a setup handshake, but on firmware 27.0.6 the bare message is accepted on its own and survives a reboot. This is the one that normally works. - HTTP
POST :8090/setMargeAccount. Usually answersSET_MARGE_ACCOUNT_ERROR (1038)on this firmware. Cheap to try. - Telnet
envswitch accountid set. Writes the layer the speaker reads at boot, so it only takes effect after a power-cycle and cannot be verified before then.
If the speaker is already paired to an old Bose account — --check shows a
non-empty margeAccountUUID — the easiest thing is not to re-pair at all. Put
that same number in accountId and restart the service. Any number works; the
speaker and the Pi only have to agree.
To return the speaker to factory behaviour, set the URLs back to Bose's originals. They no longer answer, but this leaves a clean state before a factory reset or a sale.
sys configuration bmxRegistryUrl https://content.api.bose.io/bmx/registry/v1/services
sys configuration statsServerUrl https://events.api.bosecm.com
sys configuration margeServerUrl https://streaming.bose.com
sys configuration swUpdateUrl https://worldwide.bose.com/updates/soundtouch
envswitch boseurls set https://streaming.bose.com https://worldwide.bose.com/updates/soundtouch
A factory reset — hold button 1 and volume-down — clears the account, the presets and the URLs, but leaves telnet open, so it is always recoverable with the steps above.
Any Node 18 or newer works. On armv6 Raspberry Pis — the Pi 1, the Pi Zero, and the B+ — there is a trap worth knowing before you upgrade anything:
Node 20 and later, in their armv6l builds, need
GLIBCXX_3.4.29or3.4.30. Raspbian bullseye tops out atGLIBCXX_3.4.28. A newer Node will install happily and then fail to start with aGLIBCXX_... not founderror.
On those machines, use the armv6l build of Node 18. Nothing here needs anything
newer. Unpack it to /opt/node and symlink it:
sudo ln -s /opt/node/bin/node /usr/local/bin/nodeNode 18 prints an experimental-feature warning when it uses fetch, which is why
every command here passes --no-warnings.
server.js HTTP server, routing, request logging
lib/marge.js the account service -- the load-bearing part
lib/bmx.js the registry and the station lookup
lib/content.js config.json presets -> ContentItems, sources, XML
lib/speaker.js talking TO the speaker (:8090 API and the telnet shell)
lib/ws.js a minimal WebSocket client, used only for pairing
lib/xml.js small XML builder and reader
lib/config.js load and validate config.json
lib/router.js path matching
lib/respond.js response builders
lib/log.js logging
pair-speaker.js set the speaker's account (run once)
set-presets.js write the preset buttons from config.json
install.sh create config.json, install the systemd service
logs.sh follow the log; installed as `soundhack-logs`
test/units.js XML, WebSocket frames, content model, routing
test/smoke.js drives the real server through a full boot and preset press
test/scripts.js runs the scripts against a mock speaker
Run everything:
npm testThe tests need no speaker and no network. test/smoke.js replays a real captured
power-on payload from a SoundTouch 10 on firmware 27.0.6 and follows a preset
through to the stream URL, which is the same path a button press takes.
None of the protocol knowledge here is original. It comes from people who reverse-engineered a closed system and wrote down what they found. If you want to go further than SoundHack does — other music services, other speaker models, the parts of the API nothing here touches — start with these:
- AfterTouch protocol docs —
the best reference that exists. The cloud API, the telnet command reference,
the device pairing flow, and the experiment establishing that a bare
setMargeAccountis accepted on firmware 27.0.6. - soundcork — a fuller Python implementation of the same marge and BMX surfaces, including the music services SoundHack deliberately leaves out. The reference this was ported from.
- soundploy — a smaller, sharper take on custom stations, worth reading if this codebase feels like too much.
MIT — see LICENSE.
SoundHack is not affiliated with, authorised by, or endorsed by Bose.
{ "serviceUrl": "http://192.168.1.10:8000", // how the SPEAKER reaches the Pi "port": 8000, "accountId": "1234567", // any number; speaker and Pi must agree "speaker": { "ip": "192.168.1.20", "deviceId": "AABBCCDDEEFF", // from /info above "name": "Kitchen Speaker" }, "presets": [ { "slot": 1, "type": "radio", "name": "BBC Radio 4", "url": "http://..." }, { "slot": 2, "type": "radio", "name": "ČRo Plus", "url": "http://..." } ], "logLevel": "info" }