This started as a tile on my dashboard. I wanted my homepage to tell me what was playing in the kitchen, which is the sort of thing that should take an afternoon. It is now a small Node app with a JSON API, three widgets, a full player page, and a second life as the thing my light switches talk to.
Sonos has no local HTTP API. It has UPnP, which is a 2008-era XML-over-SOAP protocol that every speaker on your network speaks perfectly well and nothing else in your house wants to. There is an excellent Node library, node-sonos, that wraps most of it. So sonos-status is a small Express app that sits in the middle: it speaks UPnP to the speakers and JSON to everything else.

Discovery is the slow part
Sonos speakers are found with SSDP, a multicast broadcast that asks “is anyone there”. It works, but when a reply gets missed the library waits the full timeout, and five seconds is a long time for a dashboard tile.
So nothing goes looking on demand. The app finds the speakers once and holds on to them, and only searches again if one stops answering.
The two things node-sonos gets wrong for my use
Favourites came back unplayable. getFavorites() returns the list, but drops the DIDL metadata embedded in each entry. For a plain track that does not matter. For a radio stream or a music service container, Sonos rejects SetAVTransportURI with a UPnP 402 when that metadata is missing - so every favourite I actually use failed. The fix was to stop using the helper and browse the speaker’s ContentDirectory service directly, keeping the real <r:resMD> payload to hand back on playback.
Albums and playlists then failed differently, with a 714, because they are containers rather than streams. They cannot be set as a transport URI at all; they have to be added to the queue and played from there, which is what the Sonos app is doing when you tap an album.
Volume moved one speaker. Plain RenderingControl on the coordinator changes only that speaker and leaves the rest of the group where it was. The Sonos app moves the whole group and keeps everyone’s relative levels, which is obviously right - if the kitchen and the snug are playing together, “turn it down” means both. That is GroupRenderingControl, and for an ungrouped room the group is just that one speaker, so it is safe to use everywhere.
Radio metadata is a mess
Some stations put structured data in the title field as pipe-delimited text:
BR P|TYPE=SNG|TITLE Song Name|ARTIST Artist Name|ALBUM Album
That is not a title. It gets parsed out into real fields, along with detecting line-in, TV audio and AirPlay from the transport URI scheme, so a widget can say “TV” instead of showing a URI nobody wants to read.
Night mode is soundbar-only, and speakers that do not support it answer GetEQ with an error. Rather than pay for a failing round trip on every poll, the app remembers which speakers said no and stops asking them.
The album art proxy needed a guard
Album art lives on the speaker itself, on port 1400, on a private IP that a browser out on the WAN cannot reach. So the app proxies it.
An endpoint that fetches a URL supplied by the caller is an open forward proxy into anything the host can reach, which on a homelab is everything. Art only ever comes from a speaker on :1400 or a public CDN, so private address space is refused outright - including IPv4-mapped addresses like ::ffff:10.0.0.1, which have to be judged on the address they embed rather than the string they look like.
The name is also resolved up front and the connection made to that exact address, so a hostname cannot resolve to something public when it is checked and something private when it is fetched.
Then it became an API for something else
The player page was the point for a while.

Then I built a Lightwave to Sonos bridge, and this app turned out to be the right shape for it already. Two endpoints exist purely because of that: a server-side play/pause toggle, and relative volume nudges. A button press has no idea what state the system is in and should not have to make two round trips to find out - it just wants to say “flip it” or “five louder” in one call.
That is the bit I did not plan. A thing I built to look at became a thing I build against.
What it is
A Node app in Docker, about a thousand lines, speaking UPnP to the speakers and JSON to everything else:
- A JSON API. Groups, rooms, favourites, queue and per-zone playback state, plus transport, volume, mute, shuffle, repeat, night mode and grouping.
- Widgets. One compact now-playing tile per zone, embedded as iframes on my dashboard.
- A player page. Room tabs, queue, favourites, grouping and transport, tinted from the album art.
I built all of it with Claude Code, across sixteen commits between February and September - each one me noticing something else that was subtly wrong.
Nothing here is clever. It is mostly the accumulated result of finding out what Sonos actually wants, one 402 at a time.