Skip to content

pyodio low-level client

OdioClient is a stateless, typed REST client with one method per odio API endpoint. It keeps nothing in memory: every call is an HTTP request, read methods return dataclasses from pyodio, and actions return None once the server has accepted them. Use it for one-shot scripts, CLIs, or when you want to manage state yourself. For a live mirror of the node, use OdioHub, which is built on this client.

from pyodio import OdioClient
async with OdioClient("http://odio.local:8018") as client:
info = await client.get_server_info()
players = await client.get_players()
if players:
await client.player_play_pause(players[0].bus_name)
OdioClient(base_url="http://localhost:8018", session=None, *, request_timeout=10.0)
ArgumentDescription
base_urlBase URL of the odio API. A trailing slash is ignored.
sessionAn existing aiohttp.ClientSession to reuse, for example Home Assistant’s shared session. Without it, the client creates its own on first use.
request_timeoutTimeout of each request in seconds. Service actions use 15 seconds.

close(), or leaving the async with block, closes the session only if the client created it. A session you passed in stays open and remains yours to close.

Inside an OdioHub, the client is available as hub.client.

Path parameters (bus names, sink names, unit names, track ids) are URL-encoded for you. Names follow the API reference for each backend.

MethodRequestReturns
get_server_info()GET /serverServerInfo
get_power_capabilities()GET /powerPowerCapabilities
reboot()POST /power/rebootNone
power_off()POST /power/power_offNone
MethodRequestReturns
get_players()GET /playerslist[PlayerState]
player_play(bus_name)POST /players/{player}/playNone
player_pause(bus_name)POST /players/{player}/pauseNone
player_play_pause(bus_name)POST /players/{player}/play_pauseNone
player_stop(bus_name)POST /players/{player}/stopNone
player_next(bus_name)POST /players/{player}/nextNone
player_previous(bus_name)POST /players/{player}/previousNone
player_seek(bus_name, offset)POST /players/{player}/seekNone
player_set_position(bus_name, position, track_id=None)POST /players/{player}/positionNone
player_set_volume(bus_name, volume)POST /players/{player}/volumeNone
player_set_loop(bus_name, loop)POST /players/{player}/loopNone
player_set_shuffle(bus_name, shuffle)POST /players/{player}/shuffleNone
player_cover_url(bus_name, *, art_url=None, track_id=None)none, builds the URL of GET /players/{player}/coverstr

offset and position are in microseconds. loop is "None", "Track" or "Playlist" (pyodio.LoopStatus). With track_id, player_set_position() is ignored server-side if that track is no longer playing. The art_url and track_id arguments of player_cover_url() only add cache-busting query parameters, so the URL changes with the artwork.

MethodRequestReturns
get_player_tracklist(bus_name)GET /players/{player}/tracklistTracklistState
player_tracklist_goto(bus_name, track_id)POST /players/{player}/tracklist/goto/{trackid}None
player_tracklist_add(bus_name, uri, *, after_track=None, set_as_current=False)POST /players/{player}/tracklist/addNone
player_tracklist_remove(bus_name, track_id)POST /players/{player}/tracklist/remove/{trackid}None

track_id is the full MPRIS track object path, or only its last segment. Without after_track, the track is appended at the end. These routes answer 404 on players without tracklist support.

MethodRequestReturns
get_audio()GET /audioAudioSnapshot
get_audio_server()GET /audio/serverAudioServerState
get_audio_clients()GET /audio/clientslist[AudioClientState]
get_audio_outputs()GET /audio/outputslist[AudioOutputState]
set_master_volume(volume)POST /audio/server/volumeNone
toggle_master_mute()POST /audio/server/muteNone
set_client_volume(name, volume)POST /audio/clients/{name}/volumeNone
toggle_client_mute(name)POST /audio/clients/{name}/muteNone
set_output_volume(name, volume)POST /audio/outputs/{name}/volumeNone
toggle_output_mute(name)POST /audio/outputs/{name}/muteNone
set_default_output(name)POST /audio/outputs/{name}/defaultNone

Volumes are between 0.0 and 1.0: the audio volume methods raise ValueError on anything else, before sending a request. Mute can only be toggled, the hub’s set_muted() builds on these toggles.

get_audio() returns clients and outputs in one call. On servers without GET /audio, it falls back to the individual endpoints, and an endpoint missing there yields an empty list.

MethodRequestReturns
get_services()GET /serviceslist[ServiceState]
service_action(scope, unit, action)POST /services/{scope}/{unit}/{action}None
service_start(scope, unit)POST /services/{scope}/{unit}/startNone
service_stop(scope, unit)POST /services/{scope}/{unit}/stopNone
service_restart(scope, unit)POST /services/{scope}/{unit}/restartNone
service_enable(scope, unit)POST /services/{scope}/{unit}/enableNone
service_disable(scope, unit)POST /services/{scope}/{unit}/disableNone

action is one of start, stop, restart, enable, disable, anything else raises ValueError. The server answers 403 for system units, which are read-only, and GET /services answers 404 when no unit is configured.

MethodRequestReturns
get_bluetooth()GET /bluetoothBluetoothState
get_bluetooth_devices()GET /bluetooth/deviceslist[BluetoothDeviceState]
bluetooth_power_up()POST /bluetooth/power_upNone
bluetooth_power_down()POST /bluetooth/power_downNone
bluetooth_pairing_mode()POST /bluetooth/pairing_modeNone
bluetooth_scan()POST /bluetooth/scanNone
bluetooth_scan_stop()POST /bluetooth/scan/stopNone
bluetooth_connect(address)POST /bluetooth/connectNone
bluetooth_disconnect(address)POST /bluetooth/disconnectNone
MethodRequestReturns
get_upgrade()GET /upgradeUpgradeStatus, or None if the server returns no status
upgrade_check()POST /upgrade/checkNone
upgrade_start()POST /upgrade/startNone, raises OdioApiError with status 409 if a run is in progress

pyodio reads the API’s SSE stream at two levels below the hub.

pyodio.stream_events() is an async generator yielding OdioEvent objects (type, and data decoded from JSON) until the connection drops. It does not reconnect.

async with OdioClient("http://odio.local:8018") as client:
async for event in pyodio.stream_events(client, backends=["mpris"], exclude=["player.position"]):
print(event.type, event.data)
ArgumentDefaultDescription
clientThe OdioClient whose URL and session are used
typesNoneOnly receive these event types
backendsNoneOnly receive events from these backends
excludeNoneDrop these event types. server.info can’t be excluded.
keepalive30Keepalive interval requested from the server, in seconds, between 10 and 120

Keepalive server.info events are yielded too, so you can track liveness: the server always sends them, even when types or backends filter everything else. An invalid filter or keepalive makes the server refuse the stream with a 400, raised as OdioConnectionError with the server’s reason in the message. When keepalive + 15 seconds pass without any data, the generator raises OdioTimeoutError. Any other failure, including the server refusing the stream, raises OdioConnectionError.

pyodio.EventStream supervises stream_events(): it runs it in a background task, reconnects with exponential backoff (1 second up to 5 minutes, reset after a successful connection), and dispatches events to listeners. It has the same filtering arguments, plus on_connected, a coroutine function awaited each time the stream connects or reconnects, before any event is dispatched. It is the place to fetch a fresh snapshot, which is what OdioHub does.

async def resync() -> None:
players = await client.get_players()
...
stream = pyodio.EventStream(client, backends=["mpris"], on_connected=resync)
stream.add_event_listener(lambda event: print(event.type))
stream.add_connection_listener(lambda up: print("connected" if up else "disconnected"))
await stream.start()
...
await stream.stop()

add_event_listener() and add_connection_listener() return an unsubscribe callable. Listeners are plain functions called in the event loop: they must not block, and an exception they raise is logged without stopping the stream. stream.connected tells whether the stream is currently up. A stream the server refuses is retried like a dropped one, the reason is logged at DEBUG on the pyodio.sse logger (see logging). start() does nothing if the stream is already running.

Every exception pyodio raises for a server or network problem derives from pyodio.OdioError:

OdioError
├── OdioConnectionError server unreachable, connection or stream lost
│ └── OdioTimeoutError request timed out, or no keepalive on the stream
└── OdioApiError the server answered with an HTTP error

OdioApiError carries the HTTP status and the server’s plain-text message:

try:
await odio.upgrade.start()
except pyodio.OdioApiError as err:
if err.status == 409:
print("An upgrade is already running")
else:
raise
except pyodio.OdioConnectionError:
print("Node unreachable")

Since OdioTimeoutError is a subclass of OdioConnectionError, catching the latter covers both. Invalid arguments caught client-side, a volume out of range or an unknown service action, raise the standard ValueError instead.

Reading OdioHub.server before the hub has synced raises a plain OdioError.