Sonos Integration
SkillMediaSonos UPnP discovery, multi-room casting, coordinator transfer, dockable room mixer, individual room volume, custom checkbox UI, and protocol quirks. Use when working on Sonos casting, room controls, UPnP control, multi-room audio, or group management.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Sonos Integration skill
What this skill tells your AI
The instructions your AI receives, as published by ad-repo/nullplayer in skills/sonos-casting/SKILL.md and read by ahel’s review.
This guide covers Sonos speaker discovery, casting, and multi-room grouping in NullPlayer.
Quick Start
- Open Sonos from either:
- Right-click anywhere in NullPlayer → Output Devices → Sonos
- Top menu bar → Output → Sonos
- Check the rooms you want to cast to (checkboxes stay open for multi-select)
- Click 🟢 Start Casting to begin playback
- Click 🔴 Stop Casting from the Sonos menu to fully end the cast session
For a persistent room list and individual volume controls, open Windows → Sonos Rooms or Output → Sonos → Sonos Rooms…. Resize the window or scroll to reach additional rooms; Refresh remains in the footer even when no rooms are discovered.
Discovery Methods
NullPlayer uses two methods to discover Sonos devices:
1. SSDP (Simple Service Discovery Protocol)
- UDP multicast to
239.255.255.250:1900 - Search target:
urn:schemas-upnp-org:device:ZonePlayer:1 - Works on most networks but can be blocked by firewalls/routers
2. mDNS/Bonjour (Fallback)
- Service type:
_sonos._tcp.local. - Uses Apple's NWBrowser API
- More reliable on networks that block UDP multicast
- Added as fallback due to Sonos app changes in 2024-2025
Requirements
UPnP Must Be Enabled
Sonos added a UPnP toggle in their app settings. Discovery will fail if disabled.
To enable:
- Open Sonos app (iOS/Android)
- Go to Account → Privacy & Security → Connection Security
- Ensure UPnP is ON (default)
If UPnP is disabled, SSDP discovery won't find devices and SOAP control won't work.
Connection Security (Firmware 85.0+, July 2025)
Sonos firmware 85.0-66270 added optional security settings:
| Setting | Default | Effect if Changed |
|---|---|---|
| Authentication | OFF | Blocks SOAP commands from NullPlayer |
| UPnP | ON | Disables ALL local SOAP control |
| Guest Access | ON | Prevents same-network playback control |
NullPlayer detects 401/403 SOAP errors and shows a specific message directing users to the Connection Security settings.
Architecture
Zone vs Group vs Room
- Zone: Individual Sonos speaker hardware (e.g., a single Sonos One)
- Room: A named location that may contain one or more zones (e.g., "Living Room" with stereo pair)
- Group: Multiple rooms playing in sync (e.g., "Living Room + Kitchen")
When casting, NullPlayer targets the group coordinator - the speaker that controls playback for the group.
Discovery Flow
- SSDP/mDNS finds Sonos devices on network
- Fetch device description XML from each device (port 1400)
- Extract room name, UDN (unique device name), and AVTransport URL
- After 3 seconds, fetch group topology from any zone
- Create cast devices based on groups (showing coordinator only)
Group Topology
Fetched via SOAP request to /ZoneGroupTopology/Control:
<u:GetZoneGroupState xmlns:u="urn:schemas-upnp-org:service:ZoneGroupTopology:1"/>
Response contains all groups and their member zones.
User Interface
Sonos Rooms window
Open Windows → Sonos Rooms or Output → Sonos → Sonos Rooms…. The context-menu Output Devices → Sonos submenu exposes the same entry. The existing Sonos submenu remains available even before discovery finds a room, including its Refresh command.
The window is a resizable center-stack room mixer. Its scrollable list has no fixed room limit; Refresh and Start/Stop Casting stay in a fixed footer. Each room has a selection checkbox, an independent 0–100 volume slider, a numeric level, and selection/error status. Volume is available before casting as well as during a cast. An unreadable volume is shown as unavailable, not as zero. The empty state retains Refresh. Native controls use a light/dark appearance matched to the skin; sliders use the skin's text color.
Casting/SonosRoomMixer.swiftowns shared room selection/start actions and per-room volume state. Both the existing menu and the window call these actions, including coordinator transfer when removing the current coordinator. Starting a cast resolves an actual selected room and makes it standalone before starting, so an unselected existing group is not used as a fallback target.- Room volume uses RenderingControl,
Channel=Master, on the room representative's own renderer.UPnPManager.getSonosRoomVolume/setSonosRoomVolumedo not require an active session and never sendSetGroupVolume. The player's existing group-volume path is unchanged. - Writes are single-flight and latest-value-wins per room, with SOAP retries disabled for superseded writes. Polling uses at most four concurrent reads; per-room revision checks prevent a read begun before a slider edit from replacing the new value. One failed room does not disable other rooms. Refresh retries discovery as well as topology and levels.
Windows/Sonos/owns the controller and shared controls;Windows/ModernSonos/supplies Original and Metal chrome..waluses the.sonoshosted-window registry entry and a chromelessWinampModernHostedSurface, with the standard hosted drag helper and palette.- WindowManager registers
.sonosas a center-stack sizing policy with a double-height baseline. User-expanded height is preserved on restore; the window participates in snapping, scaling, stack collapse, Compact Mode, and live UI rebuilding. AppState stores visibility and frame with backward-compatible decoding. Speaker volume is always read from the speaker, never restored from saved window state. - Polling belongs to the visible view and is suspended on hide, minimize, occlusion, and teardown. Volume writes belong to the mixer so an accepted slider edit can finish across a UI rebuild.
SonosRoomMixerTests covers independent room writes, stale-read protection, bounded polling with
32 rooms, scrolling to the last room, footer visibility with no rooms, restored sizing, and the
Sonos menu. Set SONOS_RENDER_DUMP to an existing directory when running the tests to render
empty and 32-room fixtures for Classic, Original, and Metal. These are rendering fixtures, not
proof of playback or hardware volume control.
Menu Structure
Sonos ▸
├── ☐ Dining Room (checkbox - selectable room)
├── ☐ Living Room (checkbox - selectable room)
├── ☐ Kitchen (checkbox - selectable room)
├── ─────────────────
├── 🟢 Start Casting (when NOT casting)
│ OR
├── 🔴 Stop Casting (when casting)
└── Refresh
Checkbox Behavior
When NOT casting:
| State | Meaning |
|---|---|
| ☐ Unchecked | Room is not selected for casting |
| ☑ Checked | Room is selected for future casting |
When casting:
| State | Meaning |
|---|---|
| ☐ Unchecked | Room is NOT receiving audio from the app |
| ☑ Checked | Room IS receiving audio from the app |
Multi-Select Feature
The room checkboxes use SonosRoomCheckboxView which keeps the menu open when clicked, allowing you to select multiple rooms without the menu closing.
This behavior is intentionally the same in both:
- Context menu Sonos submenu
- Top menu bar
Output > Sonossubmenu
Casting Workflow
Starting a Cast
- Load music - Play or load a track from Plex, Subsonic, local files, or internet radio
- Open Sonos menu - Right-click → Output Devices → Sonos
- Select rooms - Check one or more room checkboxes
- Start casting - Click "🟢 Start Casting"
The app will:
- Cast to the first selected room
- Join additional rooms to that group
- Update checkboxes to show which rooms are receiving audio
Internet Radio Note: Radio streams are live and don't support seeking. When you cast a radio station, time resets to 0:00.
Managing Rooms While Casting
While casting is active:
- Check a room → Room joins the cast group and starts playing
- Uncheck a non-coordinator room → Room leaves the group and stops playing
- Uncheck the coordinator room (with other rooms still checked) → Playback transfers to the next remaining room, which becomes the new coordinator. Brief (~1-2s) playback interruption during transfer. Menu closes to refresh state.
- Uncheck the coordinator room (only room in group) → Casting stops entirely
Coordinator transfer implementation: CastManager.transferSonosCast() saves session state, stops the old coordinator, casts to the new coordinator, and re-joins other rooms. Uses UPnPManager.disconnectSession() to clear the session without sending Stop (old coordinator is already standalone after leaving). stopCasting() ungroups all member rooms before stopping the coordinator to prevent stale group topology on subsequent casts. Polling is also stopped at the top of stopCasting() before any ungrouping SOAP calls.
Stopping a Cast
There are two intentional stop paths:
- Player Stop button / end-of-playlist: sends
Stopto Sonos but keepsupnpManager.activeSession, selected rooms, group membership, and LocalMediaServer registrations intact. The next compatible track reuses the same Sonos target without re-selecting rooms. This path isAudioEngine.stop()orcastTrackDidFinish()→CastManager.softStopForActiveDevice()→stopPlayback(). - Sonos menu 🔴 Stop Casting: fully tears down the cast session via
CastManager.stopCasting().
Click 🔴 Stop Casting to fully disconnect:
- Ungroup all member rooms (each becomes standalone)
- Stop playback on the coordinator
- Clear all room selections
- Return to local control (stopped; use the play button to resume)
Casting Protocol
AVTransport Control
- Control URL:
http://{ip}:1400/MediaRenderer/AVTransport/Control - Service type:
urn:schemas-upnp-org:service:AVTransport:1
Key actions:
SetAVTransportURI- Set media URL with DIDL-Lite metadataPlay- Start playbackPause- Pause playbackStop- Stop playbackSeek- Seek to position (REL_TIME format: HH:MM:SS)GetTransportInfo- Get transport stateGetPositionInfo- Get current position and duration
Fire-and-Forget Commands
For Sonos audio casting, playback control commands use a fire-and-forget pattern:
| Command | Behavior |
|---|---|
Pause | Sends SOAP request, returns immediately |
Resume | Sends SOAP request, returns immediately |
Seek | Sends SOAP request, returns immediately |
Why fire-and-forget?
- Sonos SOAP requests can take 5-10 seconds
- Blocking makes the UI unresponsive
- Commands succeed even without waiting for acknowledgment
Error detection: Consecutive failures are tracked. After 3 failures, a user-facing error notification is posted.
Volume Control
Sonos uses GroupRenderingControl (not RenderingControl) so that volume/mute apply to the whole group, not just the coordinator zone. Non-Sonos DLNA devices still use RenderingControl.
| Sonos | Other DLNA | |
|---|---|---|
| Control URL | /MediaRenderer/GroupRenderingControl/Control | /MediaRenderer/RenderingControl/Control |
| Set volume | SetGroupVolume (no Channel arg) | SetVolume (Channel=Master) |
| Get volume | GetGroupVolume | GetVolume |
| Set mute | SetGroupMute (no Channel arg) | SetMute (Channel=Master) |
| Service type | urn:schemas-upnp-org:service:GroupRenderingControl:1 | urn:schemas-upnp-org:service:RenderingControl:1 |
This group-level behavior is handled in UPnPManager.setVolume(_:), getVolume(), and setMute(_:)
by branching on session.device.type == .sonos. The room mixer's independent RenderingControl
path is described above; do not route a room slider through these group methods.
Playback State Monitoring
NullPlayer polls Sonos every 5 seconds during casting:
GetTransportInfo— Returns transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.)GetPositionInfo— Returns current position and duration
What polling detects:
- Sonos stopped externally (paused via Sonos app, speaker went to sleep)
- Track position drift (syncs local timer)
- Device unreachable (SOAP timeout) — tears down the session after 3 consecutive poll failures (~15s)
Polling lifecycle:
- Started (
startSonosPolling) when Sonos casting begins - Stopped (
stopSonosPolling) at the top ofstopCasting(), before ungrouping rooms - Also runs post-wake check after Mac sleep
Position sync: Each PLAYING poll updates activeSession.position and sets activeSession.playbackStartDate = Date(). On PAUSED_PLAYBACK, playbackStartDate is set to nil so the timer freezes. CastManager.currentTime interpolates session.position + elapsed(since: playbackStartDate) — the same pattern used for Chromecast.
Seek clock sync: Sonos Seek is fire-and-forget, so an audio seek must optimistically re-anchor activeSession.position to the requested target and reset playbackStartDate when the session is playing. Do this for audio as well as video. Otherwise Sonos can reach EOF before the next 5-second poll while NullPlayer still evaluates completion from the pre-seek position.
Poll failure tracking and teardown: pollSonosState() counts consecutive pollSonosPlaybackState() failures (each returns nil). When the counter reaches 3 consecutive failures (~15s of poll timeouts), the cast session is torn down via _stopCastingCore(), returning audio playback to local. The counter is reset to 0 on every successful poll or when a new cast starts. Early post-stop race failures (detected by checking isAudioCastRoutingActive) are ignored so they don't trigger false teardowns.
Resilience and Recovery
CoreAudio route churn:
- Sonos grouping, coordinator transfer, room switching, Wi-Fi changes, Zoom routes, and AirPlay-style output changes can trigger local
AVAudioEngineConfigurationChangenotifications even while cast playback is remote. - Local
AudioEnginegraph rebuilds must be deferred whileCastManager.activeSessionexists orAudioEngine.isAnyCastingActiveis true. - See
skills/audio-system/audio-pipelines.md— Cast Route-Change Safety.
Network change detection:
- LocalMediaServer monitors network changes via NWPathMonitor and refreshes its own bound IP when Wi-Fi changes.
- UPnPManager also monitors network changes. When the active local IP changes, or the network returns after being unavailable, it stops SSDP/mDNS discovery, clears cached Sonos/DLNA device URLs and Sonos topology, then starts discovery on the current interface.
- Active Sonos casts are ended only when the local IP actually changes. A pure reconnect refreshes discovery without tearing down a working speaker session.
Mac sleep/wake handling:
- CastManager observes sleep/wake notifications
- On wake: waits 2s for network, refreshes local/UPnP network state, then polls Sonos state if casting and updates UI
Server health checks:
- LocalMediaServer pings itself every 30 seconds
- Auto-restarts if the ping fails
Group topology refresh:
- During casting, group topology refreshed every 60 seconds
- Detects external group changes
Cast-failure auto-recovery (stale coordinator after reboot):
- Casts target the group coordinator via the control URL cached in
sonosZones+lastFetchedGroups. Topology is only re-fetched on a local-IP change or every 60s while a cast is already active — never before initiating a new cast. A speaker reboot is not an IP change, so the cached coordinator can go stale andSetAVTransportURI/Playreturns HTTP 500. Manually refreshing the device list used to be the only fix. - Both AVTransport SOAP error sites now throw
CastError.soapError(statusCode:detail:)instead ofplaybackFailed, so the status code is reliably available to callers (UPnPManager.sendSetAVTransportURIfails fast with no retry;sendSOAPActionstill retries transient 5xx first). CastManager.cast(...).sonosbranch: on a recoverable failure (soapError≥ 500,networkError, or a raw URL-loading error) itdisconnect()s, calls the awaitableUPnPManager.refreshSonosGroupTopologyAwait(). The refresh validates the HTTP response, tries each cached zone until one returns a valid topology, and blocks until the device/coordinator list is rebuilt. It then maps the old zone UDN through fresh group membership to the current coordinator (falling back to room-name matching) and retries the cast once. If no zone returns valid topology, it preserves the cached topology and does not retry the stale endpoint.- Recovery is Sonos-only and retries at most once; a genuinely fatal error (401/403 Connection Security, unsupported format) is not retried and surfaces its existing message.
Mid-cast unreachability teardown (speaker reboot/power loss):
- While a cast is active,
pollSonosState()continuously queriesGetTransportInfoandGetPositionInfoevery 5 seconds. If the poll fails 3 consecutive times (indicating the speaker is unreachable, e.g., due to a reboot or power loss), the session is automatically torn down via_stopCastingCore(), cleanly returning audio playback to local and clearing the UI. The counter is reset to 0 on every successful poll or when a new cast begins. Early post-stop poll failures (detected byisAudioCastRoutingActivecheck) are ignored to avoid false positives during normal teardown races. - This is distinct from the stale-coordinator auto-recovery above: stale-coordinator recovery fires during
cast()before playback starts; mid-cast unreachability detection fires during active polling if the speaker becomes unreachable while casting.
Premature STOPPED after a near-end Plex FLAC seek:
- Reproduced deterministically on a Living Room Sonos Arc (S19, S2 firmware 96.0-79160, home-theater/bonded renderer) while a Dining Room renderer handled repeated tests correctly.
- NullPlayer sent a valid
REL_TIMEseek to a direct Plex FLAC URL. The Arc acknowledged the SOAP request in about 15 ms and reportedPLAYINGat the requested position, then changed toSTOPPEDat position 0 after only 3–4 seconds while still advertising 12–18 seconds of remaining duration. This points to receiver-specific FLAC time-to-byte/range seeking behavior, not a rejected SOAP command. - The ordinary natural-finish classifier intentionally has a tight 6-second tolerance so an external stop near the end does not advance. The Arc's premature EOF falls outside that tolerance and would therefore be misclassified as an external stop, leaving playback paused.
CastManagerbriefly records a Sonos seek made within the final 30 seconds (and no earlier than halfway through a short track). If the same session was playing, its local clock reached the target, and Sonos reportsSTOPPEDwithin 12 seconds, classify that report as EOF and use the normal cast-track advance path. Clear the recent-seek state on a new cast/track, teardown, local Stop, and after the first STOPPED classification. Rooms that play through normally remain on the ordinary completion path.- Regression status: this is not introduced by the #419 changes. Fire-and-forget Sonos seeking existed from the initial January 2026 casting implementation, and STOPPED polling paused playback from the February 2026 resilience work. #419 added the 6-second natural-finish classifier for #415; the Arc/Plex case is a pre-existing renderer edge case that exceeded that classifier's intended tolerance.
Group Management
Join a group - SetAVTransportURI with x-rincon:{coordinator_uid}:
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<CurrentURI>x-rincon:RINCON_xxxx</CurrentURI>
<CurrentURIMetaData></CurrentURIMetaData>
</u:SetAVTransportURI>
Leave a group - BecomeCoordinatorOfStandaloneGroup:
<u:BecomeCoordinatorOfStandaloneGroup xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
</u:BecomeCoordinatorOfStandaloneGroup>
Implementation Details
State Management
CastManager.swift maintains:
/// Rooms selected for Sonos casting (UDNs) - used before casting starts
var selectedSonosRooms: Set<String> = []
Custom Checkbox View
SonosRoomCheckboxView is an NSView subclass that:
- Renders a checkbox with the room name
- Handles clicks without closing the menu
- Updates
selectedSonosRoomswhen not casting - Joins/unjoins rooms when casting is active
@objc private func checkboxClicked(_ sender: NSButton) {
if isCastingToSonos {
// Toggle actual Sonos group membership
if isNowChecked {
joinSonosToGroup(...)
} else {
unjoinSonos(...)
}
} else {
// Just update local selection state
if isNowChecked {
selectedSonosRooms.insert(roomUDN)
} else {
selectedSonosRooms.remove(roomUDN)
}
}
}
Device Matching
Challenge: sonosRooms returns room UDNs, but sonosDevices only contains group coordinators.
Solution in castToSonosRoom:
- Try direct ID match (room is a coordinator)
- Fall back to matching by room name
- Use first available device as last resort
Local File Casting
Local files are supported via an embedded HTTP server (LocalMediaServer):
- Port: 8765
- Seeking: Supports HTTP Range requests
- Network binding: Binds to local network interface (en0/en1), not localhost
- HEAD requests: Handles HEAD requests (Sonos may send HEAD before GET)
Supported content:
- ✅ Plex streaming (with token in URL)
- ✅ Subsonic/Navidrome streaming (via proxy)
- ✅ Jellyfin streaming (via proxy)
- ✅ Emby streaming (via proxy)
- ✅ Local files (via embedded HTTP server)
- ✅ Internet radio (Shoutcast/Icecast streams)
Subsonic/Navidrome/Jellyfin/Emby Casting: Streams are proxied through LocalMediaServer because:
- Sonos has issues with URLs containing query parameters
- The media server may be localhost-bound, unreachable by Sonos
Artwork Display
NullPlayer sends artwork URLs via DIDL-Lite metadata:
| Source | Artwork URL |
|---|---|
| Plex | PlexManager.artworkURL(thumb:) - Plex transcode endpoint |
| Subsonic | SubsonicManager.coverArtURL(coverArtId:) - Subsonic getCoverArt |
| Jellyfin | JellyfinManager.imageURL(itemId:imageTag:size:) - Jellyfin /Items/{id}/Images/Primary |
| Emby | EmbyManager.imageURL(itemId:imageTag:size:) - Emby /Items/{id}/Images/Primary |
| Local files | LocalMediaServer extracts embedded artwork and serves as JPEG |
See artwork-debugging-history.md for historical artwork troubleshooting attempts.
Sonos Protocol Quirks
Content-Type matching: The content type in DIDL-Lite protocolInfo must match the actual HTTP Content-Type header. Use track.contentType when a backend provides it; otherwise use CastManager.detectAudioContentType(for:) to detect from file extension. Extensionless server streams must not fall back to audio/mpeg when API metadata says the codec/container is FLAC, WAV, ALAC, etc.
Content-Length for MP3/OGG: Sonos closes the connection if Content-Length is missing for MP3 and OGG. Chunked transfer encoding only works for WAV/FLAC.
HEAD requests: Sonos sends HTTP HEAD before GET to check file size. LocalMediaServer handles both methods.
Radio streams: MP3 radio streams use x-rincon-mp3radio:// URI scheme for better Sonos buffering.
Error 701: "Transition Not Available" - occurs when the speaker is busy. NullPlayer waits for transport ready state before retrying.
Redirect limitation: Sonos doesn't follow HTTP 30x redirects with relative URLs - only absolute URLs work.
Supported formats: MP3 (320kbps), AAC/HE-AAC (320kbps), FLAC (24-bit, 48kHz), WAV (16-bit), OGG Vorbis (320kbps).
Not supported for Sonos casting: ALAC, AIFF/AIF, WavPack (wv), Monkey's Audio (ape).
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 118
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
sonos-casting- Source
- github.com/ad-repo/nullplayer