Portable Sonos + network health diagnostics — bring it to any network, get a verdict. Python 3, stdlib only, read-only against the network.
Born from a real incident: a Sonos Boost silently won a LAN's spanning-tree root election and degraded the whole network. Off-the-shelf Sonos tools don't check for that. This one does — from the speakers' own point of view, no controller required.
| Source | What it yields |
|---|---|
| SSDP discovery | every ZonePlayer on the segment |
/xml/device_description.xml |
room, model, serial, ethernet MAC |
/support/review (ONE fetch, any speaker) |
whole-household dump: SonosNet channel, noise floor, OFDM ANI, PHY errors, and the neighbour signal table behind Sonos's classic network matrix |
/status/showstp |
each speaker's own STP bridge view — root bridge identity, per-port states, forwarding vs blocking SonosNet tunnels |
/status/ifconfig |
radio MAC (the matrix keys on radio MACs, identity XML on ethernet MACs — this closes the gap) |
| ZoneGroupTopology SOAP | groups, bonded pairs/satellites, BootSeq (reboot counter) |
| ICMP | loss / latency / jitter per speaker |
| UniFi controller (optional) | RF stats, switch/port per speaker, switch STP priorities. Degrades gracefully to nothing. |
- STP root watch — CRIT if a Sonos player is the root bridge (speaker-side
showstp, works with zero infrastructure access; controller-side too when available). WARN if switches sit at priority ≥ 32768 (losable election) or players disagree on the root. - Weak mesh path in use — matrix signal < 15 dB on a forwarding STP tunnel (audio actually rides it).
- Reachability — port 1400 closed; in-household but SSDP-silent (multicast broken); controller says up but player dead (stale state).
- Link quality — packet loss, jitter > 30 ms, noise floor ≥ −87 dBm, SonosNet channel mismatch.
- Tail-only latency — the burst reached wire speed and lost nothing, but
its peak was ≥ 25× the floor. The radio path is healthy; packets are waiting
for airtime. Another AP on the same channel cannot fix this, and a
rising floor is the opposite failure needing the opposite fix. Read
minagainstavg: that pair, notavgalone, tells a bad link from a bad tail. - High ANI, corroborated — ANI rises against any traffic a radio must arbitrate around, so a hot ANI on a well-connected node with a pristine noise floor is a busy radio, not a victim. Reported only when an elevated noise floor agrees, or the node is not busy yet still pinned.
- Labels lie — controller alias doesn't match the Sonos identity (verify by OUI/causality, never by name).
- History — a device that disappears is reported every snapshot it stays
missing, with duration, not only the one it vanished in. BootSeq advances
are reported as
bootseq-advancedunless uptime went backwards or the player went unreachable;reboot-detectedmeans a restart was actually corroborated.
Checks that are duplicates by construction collapse to the more specific
message, because that is the one carrying an action: tail-only-latency
supersedes high-jitter, link-up-but-dead supersedes port-1400-closed.
Each still fires alone when its partner does not apply. This is
de-duplication, never suppression — a real problem stays visible the instant it
is seen and the page states its confidence.
Build the single-file artifact and copy it to any laptop with Python ≥ 3.8:
./build.sh # → dist/sonos-doctor.pyz (~25 KB)
python3 sonos-doctor.pyz snapshot --no-unifiRequirements on site: be on the same VLAN/segment as the speakers, nothing
else. If SSDP multicast is filtered (a classic "the app can't find my
speakers" network), the snapshot automatically falls back to a TCP sweep of
the local /24 — and reports the broken multicast as a finding, because that
IS a diagnosis. Force or widen the sweep with --sweep [CIDR].
Run python3 -m sonosdoctor selftest from the source tree to verify a
checkout (117 tests pinned against real captured fleet data — including
synthetic recreations of the incidents each check exists for, and the live
readings behind the 2026-08-13 alerting-integrity pass).
Packaged builds run a built-in smoke (selftest → 4 checks: parsers,
S1 detection, battery format, root-bridge detector) since the full suite
stays in the source tree.
Device-generation notes: S1 firmware (≤ 57.x) predates the SWGen tag, so
generation is derived from the firmware major version. Era-class speakers
(Era 100/300, Move 2) expose the :1400/status endpoints but have no
SonosNet radio and don't populate the matrix — they get discovery,
identity, ping, topology and checks; mesh sections auto-hide. Battery
parsing matches the format SoCo verifies against real Move/Roam hardware
(<Data name="Level">…).
Note: tests/fixtures/ contains real (scrubbed) captures from the home
fleet — keep the repo private, or re-scrub before publishing.
python3 -m sonosdoctor snapshot # collect + check + store; exit 0/1/2 = ok/warn/crit
python3 -m sonosdoctor snapshot --no-unifi # pure network probe, no controller
python3 -m sonosdoctor snapshot --sweep # force a TCP sweep (multicast-broken sites)
python3 -m sonosdoctor watch --interval 60 # on-site mode: live finding diffs each pass
python3 -m sonosdoctor report # re-print latest stored snapshot
python3 -m sonosdoctor report --html > report.html # self-contained leave-behind page
python3 -m sonosdoctor history # list stored snapshots
python3 -m sonosdoctor import-legacy sonos-*.json # ingest old sonosdiag.py output
python3 -m sonosdoctor serve --port 8090 # web UI: matrix, tree, findings, trends, guide
python3 -m sonosdoctor prune --keep-days 180 # retention
python3 -m sonosdoctor selftest # run the bundled test suite (source tree only)History lives in SQLite (~/.sonos-doctor/history.db, override with --db).
Raw snapshots are zlib-compressed (12.8× measured: 247 KB → 19 KB each), and
reads fall back to plain JSON so pre-compression rows stay readable. Schema
changes are applied by store.migrate() on open; the device INSERT names its
columns so appending can never shift them.
Each probe sends 30 pings (DEFAULT_PING_COUNT). At the old 10, two lost
packets were 20% — the crit threshold — so a critical rested on two ICMP
packets. Raising the sample count rather than the threshold keeps real loss
visible while making 20% mean six packets. Costs wall clock: a 30-player
snapshot takes ~35 s (max_workers=12, so three ping batches) against a
10-minute cron.
The snapshot exit code makes cron alerting trivial.
Field-visit extras: multiple households on one LAN are each collected
(S1/S2 splits detected), WiFi-extender-fed players are flagged, Roam/Move
battery is read, and foreign radios competing for airtime are identified by
OUI. The web UI ships a "How to read this" tab — triage order, findings
reference with actions, healthy baselines — which auto-hides UniFi- and
mesh-specific content when the viewed snapshot has neither. The same guide
is embedded in report --html exports.
UniFi enrichment reads UNIFI_API_KEY from the environment (or a path in
UNIFI_KEYFILE), UNIFI_HOST defaults to 192.168.1.1. No credentials are
ever written by this tool.
serve renders the recreated network matrix (row hears column at N dB,
sequential colour ramp, forwarding tunnels outlined), the findings list,
per-player fleet health, and jitter sparklines over stored history. Read-only
over the DB — it never probes the network itself.
The fleet table carries min ms beside avg (the floor is the diagnostic) and
hops / cost from mesh_tree — depth to a wired bridge and the STP path cost
of that route. Bad vitals at 1 hop with a low cost is not a coverage problem.
Each finding is tagged with how much of the last 24 h it was present: chronic (≥ 50%) is a standing condition worth fixing, intermittent is weather, and a first sighting reads provisional. Sort by how long something has been true, not by severity alone.
sonosdoctor/
discovery.py SSDP
collect.py per-speaker probes (1400, identity, ping, radio MAC)
review.py /support/review parser → fleet + matrix edges
stp.py /status/showstp parser → root bridge + tunnel states
topology.py ZoneGroupTopology SOAP → groups/bonded/BootSeq
unifi.py optional controller enrichment
snapshot.py orchestrates one full collection
checks.py findings engine (thresholds in one dict)
store.py SQLite history + legacy import
web.py stdlib web UI