Skip to main content

soundtouch-zonemaster

CI CodeQL License: MIT Open in Codespaces PyPI PyPI - Downloads Python Code Style: Ruff Types: pyright strict codecov security: bandit

A software zone master for Bose SoundTouch speakers, and the measured protocol description behind it

Bose shut down the SoundTouch cloud, and with it the app that grouped speakers into a zone. The speakers themselves still work: a master feeds synchronised audio to its slaves over a protocol Bose never documented. This repository is that protocol, written down from captures of real speakers, plus a Python program that plays the master's part.

Table of Contents

Why soundtouch-zonemaster?

A SoundTouch speaker will still play a stream you point it at, but grouping several of them was the cloud app's job, and the zone protocol underneath it was never published. Without it, hardware that works perfectly well plays one room at a time.

A Python process on an ordinary Linux box takes the place of the master speaker. It serves the SoundTouch HTTP API on port 8090, answers the clock protocol on UDP 40005, drives the transport channel on TCP 40002 and the data channel on TCP 40003, and streams an internet radio station to one or more real speakers. A speaker joining a stream that is already playing is placed on the same timeline as the others, so the room does not echo.

Every one of those claims was measured on real speakers, and the measurements are written up in a protocol research report, research/REPORT.md. The report and the raw measurements are kept on the development machine while development continues and are published in anonymized form when it is finished; docstrings cite the report by section (REPORT.md S3) in the meantime. Nothing in it comes from reading names in a binary: where a capture and a plausible reading of the firmware disagreed, the capture won.

Scope and boundaries

This is a working prototype, not a product.

Proven Untested
Two speakers playing one stream in sync Three or more speakers at once
A late joiner placed on the zone timeline, no echo Bose's Lifestyle console as a slave
MP3 (constant rate) and AAC in ADTS (variable) stations A station whose server sends no initial burst
Clock, transport, data and HTTP against real firmware What the speakers do when the master process dies

Two programs are built on it. The prototype master is started from the command line and runs for a given duration. The service holds a zone until it is stopped, and reads its settings from layered configuration files.

Installation

The master needs Python 3.12 or newer and runs on Linux.

# Install uv
pip install --upgrade uv

# the two commands, soundtouch-zonemaster and soundtouch-zonemaster-service, on the PATH
uv tool install soundtouch-zonemaster

# or run one without installing it
uvx --from soundtouch-zonemaster soundtouch-zonemaster-service --help

Alternative: pipx or pip

pipx install soundtouch-zonemaster
# or, inside a virtual environment:
python -m pip install soundtouch-zonemaster

From a clone (development)

git clone https://github.com/bitranox/soundtouch-zonemaster.git
cd soundtouch-zonemaster
uv venv
uv pip install -e ".[dev]"
make test

Nothing has to be installed on the speakers, and no firmware is modified. The master needs to be on the same LAN as them.

Configuration

The service reads its settings through lib_layered_config. Six layers are merged in a fixed order, and a command-line option overrides all of them:

defaults -> app -> host -> user -> dotenv -> env -> command line

The settings are split by scope, one section per file, so a change to one concern is a change to one file:

config.toml                  the header: which layers exist and in what order
config.d/10-zone.toml        [zone]        what this master is on the network
config.d/20-files.toml       [files]       the channel list, the switch, the state
config.d/30-registry.toml    [registry]    where the speaker list comes from
config.d/40-membership.toml  [membership]  who may join, and when a box stops counting
config.d/50-dialling.toml    [dialling]    how a channel number is typed on the preset keys
config.d/60-switch.toml      [switch]      how quickly the on/off file is noticed
config.d/70-observer.toml    [observer]    each speaker's notification channel
config.d/80-prototype.toml   [prototype]   the prototype master, and the boxes it must never touch
config.d/90-mpd.toml         [mpd]         the Music Player Daemon beside the service

Write yourself a copy of that whole tree, then read back what the merged answer is and which file produced each value:

soundtouch-zonemaster-service config-deploy --target user   # ~/.config/soundtouch-zonemaster/: the header and every scope file
soundtouch-zonemaster-service config                        # every value, with its source
soundtouch-zonemaster-service config --section registry     # one scope
soundtouch-zonemaster-service --json config --redact        # the same, parseable, secrets masked

--json and --json-bare belong to the group and go BEFORE the subcommand; --section and --redact are config's own and go after it. A scope that no layer has set - zone and files on a machine nobody has configured yet - is reported as empty rather than refused; only a name this program does not read at all is an error.

Each file documents its own settings: what the default is, what the setting does, when to change it, and what changing it costs. It also names the environment variable that overrides each one. The prefix is SOUNDTOUCH_ZONEMASTER___, and the segments of a key are joined by double underscores, so registry.poll_s is set by SOUNDTOUCH_ZONEMASTER___REGISTRY__POLL_S.

You are not obliged to keep that layout. The loader merges every file key by key, so a deployed config.d/ file may be deleted, or a section moved into config.toml, without changing what the service reads.

Private settings in a checkout

The tracked files hold public defaults only. Values that belong to one house - its addresses, its file paths, the boxes the prototype must never touch - go into an override file in the SAME directory, named 9N-<scope>-rnhome.toml (for example 91-zone-rnhome.toml beside 10-zone.toml). Every *.toml in a .d directory is merged in sorted order, so the higher number wins over the default; the -rnhome suffix is gitignored, so the file never leaves the machine. Each one has a tracked *-rnhome.toml.example beside it that shows its shape. The research scripts read research/defaultconfig.toml and research/defaultconfig.d/ the same way.

The same convention holds the private name list, tools/public_redactions-rnhome.txt: every name of the house, its machines and its device ids. A test in the gate fails if any tracked file holds one of them, and skips where the list is absent (see tools/public_redactions.example.txt).

Four settings have no default at all, because they describe one deployment: the address to bind, and the three files that hold the channel list, the switch and the state. Give them in a config file, in the environment, or on the command line. A setting missing from every layer is refused at startup and names itself. A fifth, the device id, falls back to the host's own MAC.

Usage

Running the master

You need a speaker on the same LAN and a station URL it can reach. --bind-ip is the address of the machine running the master, --slave a speaker that should play (repeatable), and --preset-from a speaker whose stored presets supply the station.

uv run python -m soundtouch_zonemaster \
    --bind-ip 192.168.1.10 \
    --slave 192.168.1.21 \
    --preset-from 192.168.1.21 \
    --preset 1

Add --late-slave with --join-after to have a second speaker join a stream that is already running, which is the case the timeline work exists for:

uv run python -m soundtouch_zonemaster \
    --bind-ip 192.168.1.10 \
    --slave 192.168.1.21 \
    --late-slave 192.168.1.22 --join-after 30 \
    --preset-from 192.168.1.21 --preset 1 --duration 120

The sync lines it logs are the instrument: they report how far each speaker is from the zone, in milliseconds, and they agreed with the ear on every run the ear judged.

Anything that makes a speaker play is audible in the room it stands in. Check the speakers are in standby before a run, and note that the master refuses to target a Lifestyle console, whose input switches when it is sent a power command.

Running the tests

The tests need no hardware. Framing is proven against captured bytes, and a loopback end-to-end run drives a fake slave through all four channels.

uv run python -m pytest -q tests/

Key mappings

While the service holds the house, the remote's keys are its controls: the preset keys dial channel numbers, next and previous step through channels (or through the files of an MPD channel), and the thumbs manage the rotation and, held, take a room in or out of multiroom. A key's meaning depends on the state of the box and the house, so the full mapping, state by state, is in keymappings.md.

The recovered schemas

research/proto/ holds 330 .proto files recovered from a speaker's own firmware. protoc embeds every compiled schema in the binary it generates as a serialized FileDescriptorProto, so these are Bose's definitions, byte for byte, not a reconstruction. research/extract_protos.py is the recovery tool, and it works on any binary that links libprotobuf. The handful the master needs are compiled into src/soundtouch_zonemaster/adapters/soundtouch/pb/.

Repository layout

research/               the spike that produced the protocol description
  REPORT.md             the protocol report (published in anonymized form when development finishes)
  extract_protos.py     recovers the .proto schemas protoc embedded in the firmware binaries
  generate_pb.py        regenerates adapters/soundtouch/pb (runtime modules and .pyi stubs) from proto/
  capture_zone.py       drives two real speakers through a zone while recording everything
  analyze_capture.py    decodes a capture frame by frame with the recovered schemas
  analyze_late_join.py  places a joining speaker's first byte, clock and buffer on the timeline
  capture_model.py      the vocabulary a capture is read in: endpoints, frames, payload types
  golden_check.py       re-runs the instruments and requires their recorded output byte for byte
  proto/                the 330 recovered schemas
src/soundtouch_zonemaster/    the master, in the layered package: clock (UDP 40005), transport (40002),
                              data (40003), HTTP (8090); domain, application, adapters, composition
  domain/               the pure rules as frozen dataclasses: wire enums, frames, timeline, membership,
                        dialling, channellist, presses, state - no I/O, no framework
  application/          ports and the option records; the service loop as a chain of eight classes,
                        one file each; the prototype's run loop
  adapters/             everything that speaks or serves: the zone protocol over the wire (ZoneMaster,
                        placement, channels, source, clock, ipc, pb), the file boundaries, the speaker
                        registry, the six-layer config, the rich-click CLI
  composition/          the one module naming both sides: build_production() wires one adapter per port
tests/                  framing against captured bytes; a loopback run with a fake slave; the golden
                        corpus that pins every converted boundary to the old code's bytes
tools/                  install_service.py (installs the service on the machine that runs it),
                        export_public.py (the anonymized export) and the private name list's example

Development

The gate is make test (bmk): ruff, pyright strict, import-linter, bandit, pip-audit, and pytest with branch coverage. It must be green before a push, and it rewrites the Makefile and raises dependency floors as it runs.

git clone https://github.com/bitranox/soundtouch-zonemaster.git
cd soundtouch-zonemaster

make test                      # the whole gate
make test-human                # the same, with the tool output shown

Two things worth knowing before changing protocol code:

  • Protocol claims come from a capture or a live run, never from reading names in a binary. Where the two disagree, the capture wins and the protocol report is corrected.
  • A response frame carries the sequence number of the request it answers; events and requests count on their own, per connection. Sharing one counter stalls a slave silently.

Never hand-edit src/soundtouch_zonemaster/adapters/soundtouch/pb/. Regenerate it:

uv run --with grpcio-tools python research/generate_pb.py

Further Documentation

  • Protocol report - the measured description of the zone protocol and how each claim was measured; published in anonymized form when development is finished
  • Key mappings - what every remote key does, in every state of the box and the house
  • Changelog - what changed, and why
  • License - MIT

Acknowledgements

The speakers, and the people who kept using them after the service they were sold with was switched off.

License

This software is licensed under the MIT License. The recovered schemas are Bose's, included here as the interface description a second implementation needs.

Release files for soundtouch-zonemaster 0.4.8

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for soundtouch-zonemaster 0.4.8
File Size Uploaded
soundtouch_zonemaster-0.4.8.tar.gz 788.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for soundtouch-zonemaster 0.4.8
File Interpreter ABI Platform
soundtouch_zonemaster-0.4.8-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / soundtouch_zonemaster-0.4.8.tar.gz

Download URL soundtouch_zonemaster-0.4.8.tar.gz
Size 788.2 kB
Tags Source
SHA-256 checksum
How to use checksums
1471b9ad9d6322cb436a721a5ee9e67aae8b1e041747ba060f35348cbe844dd1
BLAKE2b-256 checksum
How to use checksums
b5cfe13f76c8ed9bf01ffb7cc942a78a200c8f604850ede987fc345148d44437
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / soundtouch_zonemaster-0.4.8-py3-none-any.whl

Download URL soundtouch_zonemaster-0.4.8-py3-none-any.whl
Size 301.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4972dc5a69d549b07cf372753a356eb9511bf5b7fe95aba5ee611241a932838d
BLAKE2b-256 checksum
How to use checksums
c57f0feb3b27c1d9fe4a80d7cf52d1b9f8a40dd6e7f612a0d6cbeb6901546b45
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

0.4.8 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page