| name | sonos-casting |
| description | Sonos UPnP discovery, multi-room casting, coordinator transfer, custom checkbox UI, and protocol quirks. Use when working on Sonos casting, UPnP control, multi-room audio, or group management. |
Sonos Integration
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
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
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 > Sonos submenu
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
Stop to Sonos but keeps upnpManager.activeSession, selected rooms, group membership, and LocalMediaServer registrations intact. The next compatible track reuses the same Sonos target without re-selecting rooms. This path is AudioEngine.stop() or castTrackDidFinish() → 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 metadata
Play - Start playback
Pause - Pause playback
Stop - Stop playback
Seek - Seek to position (REL_TIME format: HH:MM:SS)
GetTransportInfo - Get transport state
GetPositionInfo - 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 is handled in UPnPManager.setVolume(_:), getVolume(), and setMute(_:) by branching on session.device.type == .sonos.
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 of stopCasting(), 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.
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
AVAudioEngineConfigurationChange notifications even while cast playback is remote.
- Local
AudioEngine graph rebuilds must be deferred while CastManager.activeSession exists or AudioEngine.isAnyCastingActive is 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 and SetAVTransportURI/Play returns 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 of playbackFailed, so the status code is reliably available to callers (UPnPManager.sendSetAVTransportURI fails fast with no retry; sendSOAPAction still retries transient 5xx first).
CastManager.cast(...) .sonos branch: on a recoverable failure (soapError ≥ 500, networkError, or a raw URL-loading error) it disconnect()s, calls the awaitable UPnPManager.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 queries GetTransportInfo and GetPositionInfo every 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 by isAudioCastRoutingActive check) 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.
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:
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
selectedSonosRooms when not casting
- Joins/unjoins rooms when casting is active
@objc private func checkboxClicked(_ sender: NSButton) {
if isCastingToSonos {
if isNowChecked {
joinSonosToGroup(...)
} else {
unjoinSonos(...)
}
} else {
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).
Format Compatibility and Auto-Skip
Two-Tier Compatibility Check
CastManager.isSonosCompatible(_:allowUnknownSampleRate:) has two modes:
- Strict (default): nil sample rate on a lossless track → incompatible. Used as the final verdict in
castCurrentTrack and castNewTrack after the sample rate has been fetched.
- Permissive (
allowUnknownSampleRate: true): nil sample rate → pass through. Used in scan/positioning functions that advance the playlist index before casting begins.
Always-incompatible formats (regardless of sample rate): alac, aiff, aif, wv (WavPack), ape (Monkey's Audio).
Lossless formats requiring the sample-rate check: flac, wav — rejected above 48 kHz.
Format classification uses the URL extension first, then normalized Track.contentType when the URL is extensionless. MIME types are normalized case-insensitively and parameters are ignored, so Audio/X-FLAC; charset=binary is treated as FLAC. This matters for Plex, Subsonic/Navidrome, Jellyfin, and Emby stream URLs that may not end in .flac or .wav.
Scan Functions Use Permissive Mode
Functions that advance the playlist index use allowUnknownSampleRate: true because they run before the sample rate is known:
| Function | Location | Mode |
|---|
advanceToFirstSonosCompatibleTrack() | AudioEngine.swift | permissive |
Skip loops in castTrackDidFinish() — sequential, shuffle, shuffle+repeat | AudioEngine.swift | permissive |
Cast Functions Are the Final Authority
castCurrentTrack and castNewTrack in CastManager.swift fetch the actual sample rate from the Plex API for lossless tracks with nil SR, then call strict isSonosCompatible. The fetch decision must use the same URL-extension-or-content-type classification as isSonosCompatible; Plex stream URLs are often extensionless, so Track.contentType must identify FLAC/WAV for the fetch to happen. If a track fails there, advanceToFirstSonosCompatibleTrack() is called again to find the next candidate.
For non-Plex backends, preserve both Track.contentType and sample rate from server metadata. If an extensionless FLAC/WAV track reaches strict mode without sample rate, it is rejected conservatively rather than sent to Sonos.
Design Principle
Scan functions only reject definitively incompatible formats (ALAC, AIFF, WavPack, Monkey's Audio, known >48 kHz SR). They pass nil-SR lossless tracks through so the cast function can fetch and decide. Never use strict mode in a skip/scan loop — it causes Plex FLAC tracks with nil SR to be skipped silently without a fetch attempt.
Troubleshooting
Devices Not Found
- Check UPnP is enabled in Sonos app settings
- Ensure devices are on same network/VLAN
- Check firewall allows UDP 1900 (SSDP) and mDNS (5353)
- Click "Refresh" and wait 10 seconds
Devices Found But Won't Play
- Verify AVTransport URL is accessible:
curl http://{ip}:1400/xml/device_description.xml
- Check Sonos isn't in "TV" mode (some soundbars)
- Ensure media URL is accessible from Sonos (not localhost)
Authentication Errors (401/403)
If you see "Sonos rejected the command":
- Open Sonos app → Settings → Account → Privacy & Security → Connection Security
- Ensure UPnP is ON
- Ensure Authentication is OFF
- These settings were added in Sonos firmware 85.0 (July 2025)
Local Files Won't Cast
- Check your Mac has a local network IP address (not just 127.0.0.1)
- Ensure firewall allows incoming connections on port 8765
- Verify Sonos speakers are on same network
- Check Console.app for "LocalMediaServer" log messages
Seek Bar Stuck at 0 / Position Not Updating
- Root cause:
activeSession.position and activeSession.playbackStartDate not set at cast start.
- At Sonos cast start,
activeSession.position = startPosition and activeSession.playbackStartDate = Date() must both be set immediately (Sonos has no status updates to set them later).
- Each PLAYING poll must update both fields. Check
CastManager: Sonos poll — state=PLAYING logs.
Player Stop Keeps Session Alive
- This is expected for Sonos when using the player Stop button:
handleStopForActiveDevice() calls softStopForActiveDevice(), which stops Sonos playback but leaves the session active.
- Use the Sonos menu 🔴 Stop Casting action when a full disconnect is required.
- Chromecast and non-Sonos DLNA should still route through
stopCasting() from softStopForActiveDevice().
Casting Stops Unexpectedly
- Check if Sonos speaker went to sleep (idle timeout)
- Check if someone paused via Sonos app (NullPlayer detects this)
- Check if Mac went to sleep (NullPlayer recovers on wake)
- Check Console.app for "Sonos reported STOPPED" or "consecutive command failures"
Network Requirements
Ports
- UDP 1900: SSDP discovery
- UDP 5353: mDNS discovery
- TCP 1400: Sonos HTTP/SOAP control
- TCP 8765: Local media server (for casting local files)
- Media port: Whatever port your media is served on (Plex default: 32400)
Multicast
SSDP requires multicast. Some routers/switches block this:
- Enable IGMP snooping
- Allow multicast on WLAN
- Don't isolate wireless clients
Key Source Files
| File | Purpose |
|---|
Casting/CastManager.swift | Central coordinator, selectedSonosRooms state, polling timer, sleep/wake handling |
Casting/UPnPManager.swift | SSDP/mDNS discovery, SOAP control, group topology, pollSonosPlaybackState() |
App/ContextMenuBuilder.swift | Menu UI, SonosRoomCheckboxView, casting actions |
Casting/LocalMediaServer.swift | Embedded HTTP server, HEAD handlers, health checks, network monitoring |
References