Programmatic Python API for discovering and controlling Sonos speakers on a local network.
An async Python API for discovery, playback, volume, queue, groups, favorites, audio clips, and live UPnP event subscriptions. Everything runs against the speakers on your LAN — no cloud account, no API keys.
Requires Python 3.13 or 3.14. Sonos speakers must be reachable on the same local network (SSDP discovery uses UDP multicast).
uv add sonosify
# or: pip install sonosifyFrom a local checkout of this repository:
uv pip install -e .Setting up a development environment (dependency groups, tests, linting, pre-commit) is covered in CONTRIBUTING.md.
import asyncio
from sonosify import SonosController
async def main():
sonos = SonosController()
system = await sonos.discover()
print([speaker.room_name for speaker in system.speakers])
async with await sonos.client("Kitchen") as kitchen:
await kitchen.pause()
await kitchen.set_volume(25)
print(await kitchen.now_playing())
asyncio.run(main())Lower-level direct device access is also available, bypassing discovery entirely:
import asyncio
from sonosify import SonosClient
async def main():
async with SonosClient("192.168.1.42") as speaker:
await speaker.play_uri("https://example.com/live.mp3", radio=True, title="Example Radio")
asyncio.run(main())The public surface is fully async and built from three layers:
| Layer | Type | Responsibility |
|---|---|---|
| Household | SonosController |
Discovery, caching the topology, convenience shortcuts, watching |
| Topology | SonosSystem |
Snapshot of speakers and groups; resolves a room name to a speaker |
| Device | SonosClient |
Every command against one speaker (UPnP/SOAP + local WebSocket) |
SonosController is the normal entry point: it runs SSDP discovery once, keeps
the resulting SonosSystem, and hands out SonosClient instances. Use
SonosClient directly when you already know a speaker's IP address and want to
skip discovery.
Both SonosClient and EventSubscription are async context managers and should
be closed — async with handles that for you.
Household-wide entry point. Import from sonosify.
SonosController(
*,
timeout: float = 15.0, # per-request HTTP timeout
discovery_timeout: float = 2.0, # SSDP listen window
include_invisible: bool = False, # include satellites/bonded players
)| Method | Returns | Description |
|---|---|---|
await discover() |
SonosSystem |
Runs SSDP discovery, reads the zone group topology, caches the result on the controller |
await client(room=None, *, ip=None, coordinator=True) |
SonosClient |
Resolves a room name to a speaker and returns a client. Discovers on first use unless ip is given, which bypasses discovery entirely. coordinator=True redirects to the group coordinator, which is what transport commands must target |
watch(room=None, *, ip=None, coordinator=True, services=..., callback_host=None, callback_port=0, timeout_seconds=300) |
async context manager → EventSubscription |
Subscribes to live UPnP events for one speaker (see Live events) |
await play(room=None, *, ip=None) |
None |
One-shot shortcut: open a client, send the command, close it |
await pause(room=None, *, ip=None) |
None |
As above |
await stop(room=None, *, ip=None) |
None |
As above |
await set_volume(volume, room=None, *, ip=None) |
None |
As above, but targets the individual speaker (coordinator=False) |
Read-only properties: timeout, discovery_timeout, include_invisible, and
system (the cached SonosSystem, or None before the first discovery).
The module-level discover() function is exported too, if you want a
SonosSystem without holding on to a controller:
from sonosify import discover
system = await discover(timeout=15.0, discovery_timeout=2.0, include_invisible=False)An immutable snapshot of the household returned by discover(). Re-run
discovery to pick up grouping changes.
| Member | Returns | Description |
|---|---|---|
speakers |
tuple[Speaker, ...] |
All discovered speakers |
groups |
tuple[Group, ...] |
Zone groups with their members |
find(query=None, *, ip=None, include_invisible=False) |
Speaker |
Resolves a room name: exact case-insensitive match first, then substring match. Raises AmbiguousSpeakerError on multiple substring hits, SpeakerNotFoundError on none. With no query it returns the only speaker, if there is exactly one |
coordinator_for(speaker) |
Speaker |
The group coordinator for a speaker, or the speaker itself when standalone |
client(query=None, *, ip=None, coordinator=True, include_invisible=False) |
SonosClient |
find() plus SonosClient.from_speaker() |
All commands for a single speaker. Speaks UPnP/SOAP over HTTP on port 1400, plus the local Control API WebSocket for audio clips.
SonosClient(
ip: str,
*,
port: int = 1400,
uid: str = "", # RINCON uid; auto-fetched when needed
timeout: float = 15.0,
http_client: httpx.AsyncClient | None = None, # inject to share a connection pool
)
SonosClient.from_speaker(speaker, *, timeout=15.0) # classmethodProperties: ip, port, uid, base_url. Use it as an async context manager,
or call await close() yourself. A client constructed with an injected
http_client does not close that client.
| Method | Description |
|---|---|
await play() / await pause() / await stop() |
Basic transport |
await next() / await previous() |
Skip within the queue |
await seek(position) |
Seek inside the current track; position is an "H:MM:SS" string |
await seek_queue(position) |
Jump to a 1-based queue position |
await play_uri(uri, *, title="", radio=False) |
Set the transport URI and start playing. radio=True rewrites the URI to x-rincon-mp3radio:// and generates stream metadata |
await open(value, *, title="", radio=False) |
Accepts a URI string or a Favorite and dispatches to play_uri() / open_favorite() |
Speaker-level and group-level volume are separate controls. Group volume scales every member of the group at once.
| Method | Returns | Description |
|---|---|---|
await get_volume() |
int |
0–100 |
await set_volume(volume) |
None |
Clamped to 0–100 |
await adjust_volume(delta) |
int |
Relative change; returns the new volume |
await get_group_volume() / await set_group_volume(volume) / await adjust_group_volume(delta) |
Same three operations for the whole group | |
await get_mute() |
bool |
|
await set_mute(muted) |
None |
|
await toggle_mute() |
bool |
Returns the new mute state |
| Method | Returns | Description |
|---|---|---|
await now_playing() |
PlaybackState |
Combines transport state, the parsed current Track, elapsed time, and duration |
await get_transport_info() |
TransportInfo |
Raw GetTransportInfo result: state (TransportState), status, speed |
await get_position_info() |
PositionInfo |
Raw GetPositionInfo result: track number, URI, duration, metadata, relative/absolute time |
await get_room_name() |
str |
The speaker's zone name |
await get_zone_group_state() |
str |
Raw zone group topology XML (used internally by discovery) |
| Method | Returns | Description |
|---|---|---|
await queue(*, start=0, count=100) |
list[Track] |
Paginated queue listing with 1-based position on each track |
await enqueue_uri(uri, *, metadata="", next_=False, play=False) |
int | None |
Adds a URI to the queue; returns the assigned position. next_=True inserts after the current track, play=True jumps to it and starts playback |
await remove_queue_item(position) |
None |
Removes one item by queue position |
await clear_queue() |
None |
Empties the queue |
Sonos encodes shuffle and repeat in a single PlayMode string. The helpers
below read the current mode, flip one dimension, and write it back, so setting
shuffle preserves repeat and vice versa.
| Method | Returns | Description |
|---|---|---|
await get_play_mode() |
str |
Raw mode, e.g. NORMAL, SHUFFLE, REPEAT_ONE |
await set_play_mode(mode) |
None |
Sets the raw mode |
await set_shuffle(enabled) |
str |
Toggles shuffle, keeps repeat; returns the resulting mode |
await set_repeat(repeat) |
str |
Takes "off" / "one" / "all", keeps shuffle; returns the resulting mode |
await get_crossfade() / await set_crossfade(enabled) |
bool / None |
Crossfade between tracks |
await configure_sleep_timer(duration) |
None |
"H:MM:SS" string, or None to cancel |
| Method | Returns | Description |
|---|---|---|
await favorites() |
list[Favorite] |
Sonos Favorites (FV:2), with the DIDL metadata needed to play them |
await open_favorite(favorite) |
None |
Sets the favorite as the transport URI and plays |
| Method | Description |
|---|---|
await join(coordinator) |
Joins the group of a Speaker or RINCON uid — this speaker becomes a follower |
await unjoin() |
Leaves the group and becomes a standalone coordinator |
| Method | Description |
|---|---|
await line_in(source=None) |
Plays the analog line-in of source (a Speaker, a RINCON uid, or this speaker when omitted) |
await tv() |
Switches a soundbar to its TV/S-PDIF input; requires a client that knows its uid |
Audio clips play a short sound over whatever is currently playing and then
restore it — ideal for TTS and notification sounds. They use the player's local
Control API WebSocket, which targets the speaker's immutable player ID, so
discovery (or a uid) is required.
import asyncio
from sonosify import ClipPriority, ClipType, SonosController
async def main():
sonos = SonosController()
async with await sonos.client("Kitchen") as kitchen:
clip = await kitchen.play_audio_clip(
"http://192.168.1.50:8000/tts/response.mp3",
app_id="com.example.voice-agent",
name="Agent Voice",
volume=30,
priority=ClipPriority.HIGH,
clip_type=ClipType.VOICE_ASSISTANT,
)
print(clip.id)
asyncio.run(main())| Method | Returns | Description |
|---|---|---|
await play_audio_clip(stream_url=None, *, app_id, name="sonosify", volume=None, priority=ClipPriority.LOW, clip_type=None, http_authorization=None, led_behavior=ClipLEDBehavior.NONE) |
AudioClip |
Schedules a clip. Omitting stream_url plays the built-in chime |
await play_audio_clip_data(audio, *, content_type="audio/wav", local_host=None, app_id, name="sonosify", volume=None, priority=ClipPriority.LOW, clip_type=None, led_behavior=ClipLEDBehavior.NONE) |
HostedAudioClip |
Hosts complete WAV or MP3 data temporarily and schedules it as a clip |
await cancel_audio_clip(clip_id) |
None |
Cancels a scheduled or active clip |
Arguments are validated before the request: name 1–64 characters, app_id
1–127 characters, volume 0–100, stream_url an absolute HTTP(S) URL,
http_authorization at most 512 bytes.
Enums: ClipPriority (LOW, HIGH), ClipType (CHIME, CUSTOM,
VOICE_ASSISTANT), ClipLEDBehavior (NONE, WHITE_LED_QUICK_BREATHING).
The player must expose the AUDIO_CLIP capability, and it must be able to fetch
the supplied HTTP(S) URL itself. A client keeps its local command WebSocket open
so a subsequent cancel_audio_clip does not require another connection
handshake. If the player closes the socket, the next command reconnects.
For audio already available in memory, play_audio_clip_data lazily starts a
small HTTP server on the local machine and chooses the LAN address that routes
to the player. The returned handle distinguishes a successful HTTP fetch from
the terminal playback status reported by Sonos:
handle = await kitchen.play_audio_clip_data(
response.audio,
content_type="audio/wav",
app_id="com.example.voice-agent",
name="Agent Voice",
priority=ClipPriority.HIGH,
clip_type=ClipType.VOICE_ASSISTANT,
)
await handle.wait_until_fetched(timeout=10)
clip = await handle.wait_until_finished(timeout=60)
print(clip.status) # DONE, INTERRUPTED, DISMISSED, or ERRORlocal_host can override the advertised address on machines with unusual
network routing. The temporary server and all remaining clip data are released
when the client closes. HostedAudioClip.close() can release one clip earlier.
Live updates use Sonos UPnP event subscriptions. EventSubscription starts a
local HTTP callback server, subscribes the speaker to it, and yields parsed
events — your OS firewall may ask whether Python can accept incoming
connections.
Register handlers with on() and let run() do the dispatching. Handlers are
selected by event class, may be sync or async, and run in registration order:
import asyncio
from sonosify import AVTransportEvent, RenderingControlEvent, SonosController
async def main():
sonos = SonosController()
async with sonos.watch("Kitchen") as watcher:
@watcher.on(AVTransportEvent)
def transport(event):
print(event.transport_state, event.track.title if event.track else "")
@watcher.on(RenderingControlEvent)
async def rendering(event):
print(event.volume, event.muted)
await watcher.run()
asyncio.run(main())@watcher.on(A, B) registers one handler for several event classes, and
@watcher.on() receives everything. The iterator form still works if you prefer
match over handlers:
async for event in watcher:
match event:
case AVTransportEvent(transport_state=state):
print(state)SonosController.watch() is the convenient form; SonosClient.watch() returns
the same EventSubscription for a client you already hold.
| Member | Description |
|---|---|
async with subscription |
Starts the callback server and subscribes; unsubscribes and shuts down on exit |
@subscription.on(*event_types) |
Registers a handler; no arguments means every event |
await run() |
Starts if needed, then dispatches every incoming event to the handlers, indefinitely |
async for event in subscription |
Yields events as they arrive, indefinitely |
await next_event(timeout=None) |
Single event, optionally bounded by a timeout (raises TimeoutError) |
events(*, timeout=None) |
Async iterator form of next_event() |
await start() / await close() |
Manual lifecycle, if you are not using async with |
services / subscribed_services |
The services you asked for, and the ones the player actually accepted |
Subscriptions are renewed automatically at half their timeout_seconds, so a
watcher keeps running past the player's subscription lifetime.
No player implements every service — a speaker answers SUBSCRIBE on the
soundbar-only HTControl with 503. Those services are skipped and listed in
subscribed_services; only if the player accepts nothing at all does start()
raise SubscriptionError. That makes services=ALL_SERVICES safe on any model.
Every service Sonos documents with evented state variables has its own frozen
model. All of them carry service, the raw values dict, sequence, and sid;
the typed fields below are the documented state variables of that service, and
anything not modelled stays available in values. Every field is optional
because a NOTIFY only carries the variables that actually changed.
EventService |
Type | Typed fields include |
|---|---|---|
AV_TRANSPORT |
AVTransportEvent |
transport_state, play_mode, crossfade, track, next_track, enqueued_track, number_of_tracks, transport_actions, alarm_running |
RENDERING_CONTROL |
RenderingControlEvent |
volume, muted, bass, treble, loudness, night_mode, dialog_level, sub_enabled, surround_enabled, trueplay_enabled |
GROUP_RENDERING_CONTROL |
GroupRenderingControlEvent |
group_volume, group_muted, group_volume_changeable |
QUEUE |
QueueEvent |
update_id, queue_owner_id, curated |
CONTENT_DIRECTORY |
ContentDirectoryEvent |
system_update_id, container_update_ids, favorites_update_id, share_index_in_progress |
ZONE_GROUP_TOPOLOGY |
ZoneGroupTopologyEvent |
zone_group_state, zone_group_id, zone_player_uuids_in_group, available_software_update |
DEVICE_PROPERTIES |
DevicePropertiesEvent |
zone_name, icon, invisible, orientation, mic_enabled, wifi_enabled, supports_audio_clip |
ALARM_CLOCK |
AlarmClockEvent |
alarm_list_version, time_zone, time_server, time_format |
AUDIO_IN |
AudioInEvent |
audio_input_name, line_in_connected, playing, left_line_in_level |
HT_CONTROL |
HTControlEvent |
ir_repeater_state, remote_configured, tos_link_connected |
GROUP_MANAGEMENT |
GroupManagementEvent |
group_coordinator_is_local, local_group_uuid, reset_volume_after |
MUSIC_SERVICES |
MusicServicesEvent |
service_list_version |
SYSTEM_PROPERTIES |
SystemPropertiesEvent |
customer_id, update_id, voice_update_id |
VIRTUAL_LINE_IN |
VirtualLineInEvent |
transport_state, current_track_uri |
RENDERER_CONNECTION_MANAGER |
RendererConnectionManagerEvent |
source_protocol_info, sink_protocol_info, current_connection_ids |
SERVER_CONNECTION_MANAGER |
ServerConnectionManagerEvent |
same three, for the MediaServer side |
| — | UnknownSonosEvent |
Fallback for a service this library does not model |
SonosEvent is the base class of all of them, so @watcher.on(SonosEvent)
matches everything and event.values is always available. DEFAULT_SERVICES
subscribes to AV_TRANSPORT and RENDERING_CONTROL; pass ALL_SERVICES to
watch(services=...) for the full set. TransportState enumerates PLAYING,
PAUSED_PLAYBACK, STOPPED, TRANSITIONING, and the remaining UPnP states;
PlayMode enumerates NORMAL, SHUFFLE, REPEAT_ALL, and the rest.
Values are coerced leniently: unknown enum members, non-numeric numbers, and
malformed flags become None rather than raising. For stereo pairs, the typed
field holds the Master channel and the other channels stay in values under
Volume:LF-style keys.
All models are frozen Pydantic models.
| Model | Fields |
|---|---|
Speaker |
ip, room_name, uid, zone_name, coordinator_uid, is_coordinator, invisible, port |
Group |
id, coordinator_uid, members, plus a coordinator property |
Track |
title, creator, album, uri, album_art_uri, duration, position |
PlaybackState |
state, track, relative_time, absolute_time, track_duration |
Favorite |
title, uri, metadata, album_art_uri |
AudioClip |
id, name, app_id, priority, clip_type, status, error_code |
TransportInfo |
state (TransportState), status, speed |
PositionInfo |
track, track_uri, track_duration, track_metadata, relative_time, absolute_time |
Every error derives from SonosifyError and exposes error_details(), a
dictionary with a stable machine-readable code.
| Error | Raised when | Extra |
|---|---|---|
DiscoveryError |
SSDP found nothing, or no device metadata could be parsed | |
SpeakerNotFoundError |
No speaker matches the room name or IP | query |
AmbiguousSpeakerError |
A room name matches several speakers | query, matches |
NetworkError |
A speaker was unreachable or the HTTP request failed | |
UPnPError |
The speaker rejected a SOAP action | code, description |
SubscriptionError |
The speaker accepted none of the requested event subscriptions | services |
LocalAPIError |
The local WebSocket API returned an error (importable from sonosify.errors) |
response |
UnsupportedFeatureError |
The player does not support the requested feature |
Runnable scripts for every area of the API are in examples/:
uv run python examples/discover.py
uv run python examples/now_playing.py Kitchen
uv run python examples/watch.py Kitchen
uv run python examples/play_radio.py Kitchen https://example.com/live.mp3 "Example Radio"
uv run python examples/local_audio_clip.py --volume 30 # uses SONOS_IP_ADDRESS
uv run python examples/resume_playback.py Kitchen
uv run python examples/volume.py Kitchen 25
uv run python examples/favorites.py Kitchen
uv run python examples/favorites.py Kitchen "Jazz FM"
uv run python examples/track_queue.py Kitchen
uv run python examples/track_queue.py Kitchen add "x-rincon-mp3radio://example.com/live.mp3"
uv run python examples/groups.py
uv run python examples/group.py join Kitchen "Living Room"
uv run python examples/group.py leave Kitchen
uv run python examples/playback_modes.py Kitchen shuffle on
uv run python examples/playback_modes.py Kitchen sleep 0:30:00Bug reports and pull requests are welcome. See CONTRIBUTING.md for the development setup, test/lint commands, and what CI checks on every push.
MIT — see LICENSE.
