Skip to content

Repository files navigation

SoundHack

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.


Prerequisites

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 --version

If 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 shell

No 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.

The problem, briefly

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.


Quick start

Six steps. Budget half an hour the first time, mostly waiting for the speaker to reboot.

1. Install on the Pi

git clone https://github.com/stefda/soundhack.git
cd soundhack
./install.sh

That 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.

2. Find your speaker

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/info

The 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).

3. Edit config.json

{
  "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"
}

Two rules, both of which the service checks at startup rather than letting you discover them later:

  • serviceUrl must be the Pi's LAN address, never localhost. 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://, not https://. 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.

4. Point the speaker at the Pi

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 17000
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 — pull the plug, wait, plug it back in.

5. Start the service and pair

sudo ./install.sh                     # installs and enables the systemd service
soundhack-logs                        # leave this running in another window
node --no-warnings pair-speaker.js

Then 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.

6. Write the buttons

node --no-warnings set-presets.js
node --no-warnings set-presets.js --play 1    # test without leaving your chair

Press a button. You should have radio.


How it works

Worth ten minutes, because almost every problem you can have is explained by this section.

The two halves

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_RADIO source, 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.

What a button press actually does

Pressing preset 1 on a paired, working speaker:

  1. The speaker looks up preset 1 in its own storage. It finds a ContentItem whose source is LOCAL_INTERNET_RADIO and whose location is a URL on the Pi: http://<pi>:8000/core02/svc-bmx-adapter-orion/prod/orion/station?data=<blob>
  2. It checks that it has a LOCAL_INTERNET_RADIO source. This is the check that fails silently when pairing has not been done.
  3. It fetches that URL. This is the first moment anything reaches the Pi, and you will see it in the log.
  4. We decode <blob> — base64 of {streamUrl, imageUrl, name} — and reply with the stream address.
  5. 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 caches at boot

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.


Choosing stations

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.js

Good 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 .mp3 or .aac that returns the audio itself. Check with curl -sI <url>: you want 200 and a Content-Type of audio/mpeg or 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.


Troubleshooting

Start here. Watch the log while you press the button:

soundhack-logs

That 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 page

Set "logLevel": "debug" in config.json for full request bodies.


Pointing the speaker at the Pi

The speaker has a diagnostic shell on port 17000, with no password.

telnet 192.168.1.20 17000

Read 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 set is not merely a two-field setter: it commits whatever is in the runtime layer at the moment it runs. The four sys configuration lines must come first. Reversed, everything still answers normally and the settings vanish silently at the next reboot.
  • envswitch does not reply OK. It answers with something like Setting 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.

Pairing in detail

node --no-warnings pair-speaker.js --check   # report state, change nothing
node --no-warnings pair-speaker.js           # do it

The script tries three routes in order and stops at the first that works:

  1. 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.
  2. HTTP POST :8090/setMargeAccount. Usually answers SET_MARGE_ACCOUNT_ERROR (1038) on this firmware. Cheap to try.
  3. 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.

Handing the speaker back

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.


A note on Node versions

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.29 or 3.4.30. Raspbian bullseye tops out at GLIBCXX_3.4.28. A newer Node will install happily and then fail to start with a GLIBCXX_... not found error.

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/node

Node 18 prints an experimental-feature warning when it uses fetch, which is why every command here passes --no-warnings.


The code

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 test

The 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.


Sources and further reading

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 setMargeAccount is 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.

License

MIT — see LICENSE.

SoundHack is not affiliated with, authorised by, or endorsed by Bose.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages