Skip to content

About

Portable Sonos + network health diagnostics — matrix, mesh tree, STP root watch, findings engine, web UI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

21 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sonos-doctor

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.

What it collects

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.

Checks (each earned from a real incident)

  • 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 min against avg: that pair, not avg alone, 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-advanced unless uptime went backwards or the player went unreachable; reboot-detected means a restart was actually corroborated.

One condition, one finding

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.

Taking it to another network

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

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

Use

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.

Web UI

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.

Layout

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

About

Portable Sonos + network health diagnostics — matrix, mesh tree, STP root watch, findings engine, web UI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages