diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d57b26d..78405d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,9 @@ jobs: release: name: Build and publish Python package to PyPI and TestPyPI runs-on: ubuntu-latest - + permissions: + contents: write + id-token: write steps: - uses: actions/checkout@master - name: 🏗 Set up Poetry diff --git a/aiorussound/connection.py b/aiorussound/connection.py new file mode 100644 index 0000000..22b1e69 --- /dev/null +++ b/aiorussound/connection.py @@ -0,0 +1,189 @@ +import asyncio +import logging +from abc import abstractmethod +from asyncio import AbstractEventLoop, Queue, StreamReader, StreamWriter, Future +from typing import Any, Optional + +from aiorussound import CommandError +from aiorussound.const import DEFAULT_PORT, RECONNECT_DELAY, RESPONSE_REGEX +from aiorussound.models import RussoundMessage + +_LOGGER = logging.getLogger(__package__) + +# Maintain compat with various 3.x async changes +if hasattr(asyncio, "ensure_future"): + ensure_future = asyncio.ensure_future +else: + ensure_future = getattr(asyncio, "async") + + +def _process_response(res: bytes) -> Optional[RussoundMessage]: + """Process an incoming string of bytes into a RussoundMessage""" + try: + # Attempt to decode in Latin and re-encode in UTF-8 to support international characters + str_res = res.decode(encoding="iso-8859-1").encode(encoding="utf-8").decode(encoding="utf-8").strip() + except UnicodeDecodeError as e: + _LOGGER.warning("Failed to decode Russound response %s", res, e) + return None + if not str_res: + return None + tag, payload = str_res[0], str_res[2:] + if tag == "E": + _LOGGER.debug("Device responded with error: %s", payload) + raise CommandError(payload) + m = RESPONSE_REGEX.match(payload.strip()) + if not m: + return RussoundMessage(tag, None, None, None, None, None) + p = m.groupdict() + value = p["value"] or p["value_only"] + return RussoundMessage(tag, p["variable"], value, p["zone"], p["controller"], p["source"]) + + +class RussoundConnectionHandler: + def __init__(self, loop: AbstractEventLoop) -> None: + self._loop = loop + self._connection_started: bool = False + self.connected: bool = False + self._message_callback: list[Any] = [] + self._connection_callbacks: list[Any] = [] + self._cmd_queue: Queue = Queue() + + @abstractmethod + async def close(self): + raise NotImplementedError + + async def send(self, cmd: str) -> str: + """Send a command to the Russound client.""" + _LOGGER.debug("Sending command '%s' to Russound client", cmd) + future: Future = Future() + await self._cmd_queue.put((cmd, future)) + return await future + + @abstractmethod + async def connect(self, reconnect=True) -> None: + raise NotImplementedError + + async def _keep_alive(self) -> None: + while True: + await asyncio.sleep(900) # 15 minutes + _LOGGER.debug("Sending keep alive to device") + await self.send("VERSION") + + def _set_connected(self, connected: bool): + self.connected = connected + for callback in self._connection_callbacks: + callback(connected) + + def add_connection_callback(self, callback) -> None: + """Register a callback to be called whenever the instance is connected/disconnected. + The callback will be passed one argument: connected: bool. + """ + self._connection_callbacks.append(callback) + + def remove_connection_callback(self, callback) -> None: + """Removes a previously registered callback.""" + self._connection_callbacks.remove(callback) + + def add_message_callback(self, callback) -> None: + """Register a callback to be called whenever the controller sends a message. + The callback will be passed one argument: msg: str. + """ + self._message_callback.append(callback) + + def remove_message_callback(self, callback) -> None: + """Removes a previously registered callback.""" + self._message_callback.remove(callback) + + def _on_msg_recv(self, msg: RussoundMessage) -> None: + for callback in self._message_callback: + callback(msg) + + +class RussoundTcpConnectionHandler(RussoundConnectionHandler): + + def __init__(self, loop: AbstractEventLoop, host: str, port: int = DEFAULT_PORT) -> None: + """Initialize the Russound object using the event loop, host and port + provided. + """ + super().__init__(loop) + self.host = host + self.port = port + self._ioloop_future = None + + async def connect(self, reconnect=True) -> None: + self._connection_started = True + _LOGGER.info("Connecting to %s:%s", self.host, self.port) + reader, writer = await asyncio.open_connection(self.host, self.port) + self._ioloop_future = ensure_future(self._ioloop(reader, writer, reconnect)) + self._set_connected(True) + + async def close(self): + """Disconnect from the controller.""" + self._connection_started = False + _LOGGER.info("Closing connection to %s:%s", self.host, self.port) + self._ioloop_future.cancel() + try: + await self._ioloop_future + except asyncio.CancelledError: + pass + self._set_connected(False) + + async def _ioloop( + self, reader: StreamReader, writer: StreamWriter, reconnect: bool + ) -> None: + queue_future = ensure_future(self._cmd_queue.get()) + net_future = ensure_future(reader.readline()) + keep_alive_task = asyncio.create_task(self._keep_alive()) + last_command_future = None + + try: + _LOGGER.debug("Starting IO loop") + while True: + done, _ = await asyncio.wait( + [queue_future, net_future], return_when=asyncio.FIRST_COMPLETED + ) + + if net_future in done: + response = net_future.result() + try: + msg = _process_response(response) + if msg: + self._on_msg_recv(msg) + if msg.tag == "S" and last_command_future: + last_command_future.set_result(msg.value) + last_command_future = None + except CommandError as e: + if last_command_future: + last_command_future.set_exception(e) + last_command_future = None + net_future = ensure_future(reader.readline()) + + if queue_future in done and not last_command_future: + cmd, future = queue_future.result() + writer.write(bytearray(f"{cmd}\r", "utf-8")) + await writer.drain() + last_command_future = future + queue_future = ensure_future(self._cmd_queue.get()) + except asyncio.CancelledError: + _LOGGER.debug("IO loop cancelled") + self._set_connected(False) + raise + except asyncio.TimeoutError: + _LOGGER.warning("Connection to Russound client timed out") + except ConnectionResetError: + _LOGGER.warning("Connection to Russound client reset") + except Exception: + _LOGGER.exception("Unhandled exception in IO loop") + self._set_connected(False) + raise + finally: + _LOGGER.debug("Cancelling all tasks...") + writer.close() + queue_future.cancel() + net_future.cancel() + keep_alive_task.cancel() + self._set_connected(False) + if reconnect and self._connection_started: + _LOGGER.info("Retrying connection to Russound client in 5s") + await asyncio.sleep(RECONNECT_DELAY) + await self.connect(reconnect) diff --git a/aiorussound/models.py b/aiorussound/models.py new file mode 100644 index 0000000..c83ba6a --- /dev/null +++ b/aiorussound/models.py @@ -0,0 +1,66 @@ +"""Models for aiorussound.""" +from dataclasses import dataclass, field +from typing import Optional + +from mashumaro import field_options +from mashumaro.mixins.orjson import DataClassORJSONMixin + + +@dataclass +class RussoundMessage: + """Incoming russound message.""" + tag: str + variable: Optional[str] = None + value: Optional[str] = None + zone: Optional[str] = None + controller: Optional[str] = None + source: Optional[str] = None + + +@dataclass +class ZoneProperties(DataClassORJSONMixin): + """Data class representing Russound state.""" + + volume: str = field(metadata=field_options(alias="volume"), default="0") + bass: str = field(metadata=field_options(alias="bass"), default="0") + treble: str = field(metadata=field_options(alias="treble"), default="0") + balance: str = field(metadata=field_options(alias="balance"), default="0") + loudness: str = field(metadata=field_options(alias="loudness"), default="OFF") + turn_on_volume: str = field(metadata=field_options(alias="turnOnVolume"), default="20") + do_not_disturb: str = field(metadata=field_options(alias="doNotDisturb"), default="OFF") + party_mode: str = field(metadata=field_options(alias="partyMode"), default="OFF") + status: str = field(metadata=field_options(alias="status"), default="OFF") + is_mute: str = field(metadata=field_options(alias="mute"), default="OFF") + shared_source: str = field(metadata=field_options(alias="sharedSource"), default="OFF") + last_error: Optional[str] = field(metadata=field_options(alias="lastError"), default=None) + page: Optional[str] = field(metadata=field_options(alias="page"), default=None) + sleep_time_default: Optional[str] = field(metadata=field_options(alias="sleepTimeDefault"), default=None) + sleep_time_remaining: Optional[str] = field(metadata=field_options(alias="sleepTimeRemaining"), default=None) + enabled: str = field(metadata=field_options(alias="enabled"), default="False") + current_source: str = field(metadata=field_options(alias="currentSource"), default="1") + + +@dataclass +class SourceProperties(DataClassORJSONMixin): + """Data class representing Russound source.""" + + type: str = field(metadata=field_options(alias="type"), default=None) + channel: str = field(metadata=field_options(alias="channel"), default=None) + cover_art_url: str = field(metadata=field_options(alias="coverArtURL"), default=None) + channel_name: str = field(metadata=field_options(alias="channelName"), default=None) + genre: str = field(metadata=field_options(alias="genre"), default=None) + artist_name: str = field(metadata=field_options(alias="artistName"), default=None) + album_name: str = field(metadata=field_options(alias="albumName"), default=None) + playlist_name: str = field(metadata=field_options(alias="playlistName"), default=None) + song_name: str = field(metadata=field_options(alias="songName"), default=None) + program_service_name: str = field(metadata=field_options(alias="programServiceName"), default=None) + radio_text: str = field(metadata=field_options(alias="radioText"), default=None) + shuffle_mode: str = field(metadata=field_options(alias="shuffleMode"), default=None) + repeat_mode: str = field(metadata=field_options(alias="repeatMode"), default=None) + mode: str = field(metadata=field_options(alias="mode"), default=None) + play_status: str = field(metadata=field_options(alias="playStatus"), default=None) + sample_rate: str = field(metadata=field_options(alias="sampleRate"), default=None) + bit_rate: str = field(metadata=field_options(alias="bitRate"), default=None) + bit_depth: str = field(metadata=field_options(alias="bitDepth"), default=None) + play_time: str = field(metadata=field_options(alias="playTime"), default=None) + track_time: str = field(metadata=field_options(alias="trackTime"), default=None) \ No newline at end of file diff --git a/aiorussound/rio.py b/aiorussound/rio.py index cf4db7d..648777a 100644 --- a/aiorussound/rio.py +++ b/aiorussound/rio.py @@ -2,18 +2,14 @@ from __future__ import annotations -import asyncio -from asyncio import AbstractEventLoop, Future, Queue, StreamReader, StreamWriter import logging from typing import Any, Coroutine +from aiorussound.connection import RussoundConnectionHandler from aiorussound.const import ( - DEFAULT_PORT, FLAGS_BY_VERSION, MAX_SOURCE, MINIMUM_API_SUPPORT, - RECONNECT_DELAY, - RESPONSE_REGEX, SOURCE_PROPERTIES, ZONE_PROPERTIES, FeatureFlag, @@ -23,6 +19,7 @@ UncachedVariableError, UnsupportedFeatureError, ) +from aiorussound.models import RussoundMessage, ZoneProperties, SourceProperties from aiorussound.util import ( controller_device_str, get_max_zones, @@ -32,12 +29,6 @@ zone_device_str, ) -# Maintain compat with various 3.x async changes -if hasattr(asyncio, "ensure_future"): - ensure_future = asyncio.ensure_future -else: - ensure_future = getattr(asyncio, "async") - _LOGGER = logging.getLogger(__package__) @@ -45,24 +36,19 @@ class Russound: """Manages the RIO connection to a Russound device.""" def __init__( - self, loop: AbstractEventLoop, host: str, port: int = DEFAULT_PORT + self, connection_handler: RussoundConnectionHandler ) -> None: """Initialize the Russound object using the event loop, host and port provided. """ - self._loop = loop - self.host = host - self.port = port - self._ioloop_future = None - self._cmd_queue: Queue = Queue() + self.connection_handler = connection_handler + self.connection_handler.add_message_callback(self._on_msg_recv) self._state: dict[str, dict[str, str]] = {} self._callbacks: dict[str, list[Any]] = {} - self._connection_callbacks: list[Any] = [] - self._connection_started: bool = False self._watched_devices: dict[str, bool] = {} self._controllers: dict[int, Controller] = {} + self.sources: dict[int, Zone] = {} self.rio_version: str | None = None - self.connected: bool = False def _retrieve_cached_variable(self, device_str: str, key: str) -> str: """Retrieve the cache state of the named variable for a particular @@ -96,112 +82,19 @@ def _store_cached_variable(self, device_str: str, key: str, value: str) -> None: for callback in self._callbacks.get(zone.device_str(), []): callback(device_str, key, value) - def _process_response(self, res: bytes) -> [str, str]: - s = str(res, "utf-8").strip() - if not s: - return None, None - ty, payload = s[0], s[2:] - if ty == "E": - _LOGGER.debug("Device responded with error: %s", payload) - raise CommandError(payload) - - m = RESPONSE_REGEX.match(payload) - if not m: - return ty, None - - p = m.groupdict() - if p["source"]: - source_id = int(p["source"]) + def _on_msg_recv(self, msg: RussoundMessage) -> None: + if msg.source: + source_id = int(msg.source) self._store_cached_variable( - source_device_str(source_id), p["variable"], p["value"] + source_device_str(source_id), msg.variable, msg.value ) - elif p["zone"]: - controller_id = int(p["controller"]) - zone_id = int(p["zone"]) + elif msg.zone: + controller_id = int(msg.controller) + zone_id = int(msg.zone) self._store_cached_variable( - zone_device_str(controller_id, zone_id), p["variable"], p["value"] + zone_device_str(controller_id, zone_id), msg.variable, msg.value ) - return ty, p["value"] or p["value_only"] - - async def _keep_alive(self) -> None: - while True: - await asyncio.sleep(900) # 15 minutes - _LOGGER.debug("Sending keep alive to device") - await self.send_cmd("VERSION") - - async def _ioloop( - self, reader: StreamReader, writer: StreamWriter, reconnect: bool - ) -> None: - queue_future = ensure_future(self._cmd_queue.get()) - net_future = ensure_future(reader.readline()) - keep_alive_task = asyncio.create_task(self._keep_alive()) - - try: - _LOGGER.debug("Starting IO loop") - while True: - done, _ = await asyncio.wait( - [queue_future, net_future], return_when=asyncio.FIRST_COMPLETED - ) - - if net_future in done: - response = net_future.result() - try: - self._process_response(response) - except CommandError: - pass - net_future = ensure_future(reader.readline()) - - if queue_future in done: - cmd, future = queue_future.result() - cmd += "\r" - writer.write(bytearray(cmd, "utf-8")) - await writer.drain() - - queue_future = ensure_future(self._cmd_queue.get()) - - while True: - response = await net_future - net_future = ensure_future(reader.readline()) - try: - ty, value = self._process_response(response) - if ty == "S": - future.set_result(value) - break - except CommandError as e: - future.set_exception(e) - break - except asyncio.CancelledError: - _LOGGER.debug("IO loop cancelled") - self._set_connected(False) - raise - except asyncio.TimeoutError: - _LOGGER.warning("Connection to Russound client timed out") - except ConnectionResetError: - _LOGGER.warning("Connection to Russound client reset") - except Exception: - _LOGGER.exception("Unhandled exception in IO loop") - self._set_connected(False) - raise - finally: - _LOGGER.debug("Cancelling all tasks...") - writer.close() - queue_future.cancel() - net_future.cancel() - keep_alive_task.cancel() - self._set_connected(False) - if reconnect and self._connection_started: - _LOGGER.info("Retrying connection to Russound client in 5s") - await asyncio.sleep(RECONNECT_DELAY) - await self.connect(reconnect) - - async def send_cmd(self, cmd: str) -> str: - """Send a command to the Russound client.""" - _LOGGER.debug("Sending command '%s' to Russound client", cmd) - future: Future = Future() - await self._cmd_queue.put((cmd, future)) - return await future - def add_callback(self, device_str: str, callback) -> None: """Register a callback to be called whenever a device variable changes. The callback will be passed three arguments: the device_str, the variable @@ -215,53 +108,32 @@ def remove_callback(self, callback) -> None: for callbacks in self._callbacks.values(): callbacks.remove(callback) - def add_connection_callback(self, callback) -> None: - """Register a callback to be called whenever the instance is connected/disconnected. - The callback will be passed one argument: connected: bool. - """ - self._connection_callbacks.append(callback) - - def remove_connection_callback(self, callback) -> None: - """Removes a previously registered callback.""" - self._connection_callbacks.remove(callback) - - def _set_connected(self, connected: bool): - self.connected = connected - for callback in self._connection_callbacks: - callback(connected) - async def connect(self, reconnect=True) -> None: """Connect to the controller and start processing responses.""" - self._connection_started = True - _LOGGER.info("Connecting to %s:%s", self.host, self.port) - reader, writer = await asyncio.open_connection(self.host, self.port) - self._ioloop_future = ensure_future(self._ioloop(reader, writer, reconnect)) - self.rio_version = await self.send_cmd("VERSION") + await self.connection_handler.connect(reconnect=reconnect) + self.rio_version = await self.connection_handler.send("VERSION") if not is_fw_version_higher(self.rio_version, MINIMUM_API_SUPPORT): + await self.connection_handler.close() raise UnsupportedFeatureError( f"Russound RIO API v{self.rio_version} is not supported. The minimum " f"supported version is v{MINIMUM_API_SUPPORT}" ) _LOGGER.info("Connected (Russound RIO v%s})", self.rio_version) await self._watch_cached_devices() - self._set_connected(True) async def close(self) -> None: """Disconnect from the controller.""" - self._connection_started = False - _LOGGER.info("Closing connection to %s:%s", self.host, self.port) - self._ioloop_future.cancel() - try: - await self._ioloop_future - except asyncio.CancelledError: - pass - self._set_connected(False) + await self.connection_handler.close() async def set_variable( - self, device_str: str, key: str, value: str + self, device_str: str, key: str, value: str ) -> Coroutine[Any, Any, str]: """Set a zone variable to a new value.""" - return self.send_cmd(f'SET {device_str}.{key}="{value}"') + return self.connection_handler.send(f'SET {device_str}.{key}="{value}"') + + def get_cache(self, device_str: str) -> dict: + """Retrieve the cache for a given device by its device string.""" + return self._state.get(device_str, {}) async def get_variable(self, device_str: str, key: str) -> str: """Retrieve the current value of a zone variable. If the variable is @@ -271,7 +143,7 @@ async def get_variable(self, device_str: str, key: str) -> str: try: return self._retrieve_cached_variable(device_str, key) except UncachedVariableError: - return await self.send_cmd(f"GET {device_str}.{key}") + return await self.connection_handler.send(f"GET {device_str}.{key}") def get_cached_variable(self, device_str: str, key: str, default=None) -> str: """Retrieve the current value of a zone variable from the cache or @@ -301,7 +173,7 @@ async def enumerate_controllers(self) -> dict[int, Controller]: pass firmware_version = None if is_feature_supported( - self.rio_version, FeatureFlag.PROPERTY_FIRMWARE_VERSION + self.rio_version, FeatureFlag.PROPERTY_FIRMWARE_VERSION ): firmware_version = await self.get_variable( device_str, "firmwareVersion" @@ -334,30 +206,44 @@ def supported_features(self) -> list[FeatureFlag]: async def watch(self, device_str: str) -> str: """Watch a device.""" self._watched_devices[device_str] = True - return await self.send_cmd(f"WATCH {device_str} ON") + return await self.connection_handler.send(f"WATCH {device_str} ON") async def unwatch(self, device_str: str) -> str: """Unwatch a device.""" del self._watched_devices[device_str] - return await self.send_cmd(f"WATCH {device_str} OFF") + return await self.connection_handler.send(f"WATCH {device_str} OFF") async def _watch_cached_devices(self) -> None: _LOGGER.debug("Watching cached devices") for device in self._watched_devices.keys(): await self.watch(device) + async def init_sources(self) -> None: + """Return a list of (zone_id, zone) tuples.""" + self.sources = {} + for source_id in range(1, MAX_SOURCE): + try: + device_str = source_device_str(source_id) + name = await self.get_variable(device_str, "name") + if name: + source = Source(self, source_id, name) + await source.fetch_configuration() + self.sources[source_id] = source + except CommandError: + break + class Controller: """Uniquely identifies a controller.""" def __init__( - self, - instance: Russound, - parent_controller: Controller, - controller_id: int, - mac_address: str, - controller_type: str, - firmware_version: str, + self, + instance: Russound, + parent_controller: Controller, + controller_id: int, + mac_address: str, + controller_type: str, + firmware_version: str, ) -> None: """Initialize the controller.""" self.instance = instance @@ -367,12 +253,10 @@ def __init__( self.controller_type = controller_type self.firmware_version = firmware_version self.zones: dict[int, Zone] = {} - self.sources: dict[int, Zone] = {} self.max_zones = get_max_zones(controller_type) async def fetch_configuration(self) -> None: """Fetches source and zone configuration from controller.""" - await self._init_sources() await self._init_zones() def __str__(self) -> str: @@ -382,8 +266,8 @@ def __str__(self) -> str: def __eq__(self, other: object) -> bool: """Equality check.""" return ( - hasattr(other, "controller_id") - and other.controller_id == self.controller_id + hasattr(other, "controller_id") + and other.controller_id == self.controller_id ) def __hash__(self) -> int: @@ -405,20 +289,6 @@ async def _init_zones(self) -> None: except CommandError: break - async def _init_sources(self) -> None: - """Return a list of (zone_id, zone) tuples.""" - self.sources = {} - for source_id in range(1, MAX_SOURCE): - try: - device_str = source_device_str(source_id) - name = await self.instance.get_variable(device_str, "name") - if name: - source = Source(self.instance, self, source_id, name) - await source.fetch_configuration() - self.sources[source_id] = source - except CommandError: - break - def add_callback(self, callback) -> None: """Add a callback function to be called when a zone is changed.""" self.instance.add_callback(controller_device_str(self.controller_id), callback) @@ -437,7 +307,7 @@ class Zone: """ def __init__( - self, instance: Russound, controller: Controller, zone_id: int, name: str + self, instance: Russound, controller: Controller, zone_id: int, name: str ) -> None: """Initialize a zone object.""" self.instance = instance @@ -460,10 +330,10 @@ def __str__(self) -> str: def __eq__(self, other: object) -> bool: """Equality check.""" return ( - hasattr(other, "zone_id") - and hasattr(other, "controller") - and other.zone_id == self.zone_id - and other.controller == self.controller + hasattr(other, "zone_id") + and hasattr(other, "controller") + and other.zone_id == self.zone_id + and other.controller == self.controller ) def __hash__(self) -> int: @@ -499,101 +369,19 @@ def remove_callback(self, callback) -> None: async def send_event(self, event_name, *args) -> str: """Send an event to a zone.""" cmd = f"EVENT {self.device_str()}!{event_name} {" ".join(str(x) for x in args)}" - return await self.instance.send_cmd(cmd) + return await self.instance.connection_handler.send(cmd) def _get(self, variable, default=None) -> str: return self.instance.get_cached_variable(self.device_str(), variable, default) - @property - def current_source(self) -> str: - """Return the current source.""" - # Default to one if not available at the present time - return self._get("currentSource", "1") - def fetch_current_source(self) -> Zone: """Return the current source as a source object.""" - current_source = int(self.current_source) - return self.controller.sources[current_source] - - @property - def volume(self) -> str: - """Return the current volume.""" - return self._get("volume", "0") - - @property - def bass(self) -> str: - """Return the current bass.""" - return self._get("bass") - - @property - def treble(self) -> str: - """Return the current treble.""" - return self._get("treble") - - @property - def balance(self) -> str: - """Return the current balance.""" - return self._get("balance") - - @property - def loudness(self) -> str: - """Return the current loudness.""" - return self._get("loudness") - - @property - def turn_on_volume(self) -> str: - """Return the current turn on the volume.""" - return self._get("turnOnVolume") - - @property - def do_not_disturb(self) -> str: - """Return the current do-not-disturb.""" - return self._get("doNotDisturb") - - @property - def party_mode(self) -> str: - """Return the current party mode.""" - return self._get("partyMode") - - @property - def status(self) -> str: - """Return the current status of the zone.""" - return self._get("status", "OFF") - - @property - def is_mute(self) -> str: - """Return whether the zone is muted or not.""" - return self._get("mute") - - @property - def shared_source(self) -> str: - """Return the current shared source.""" - return self._get("sharedSource") - - @property - def last_error(self) -> str: - """Return the last error.""" - return self._get("lastError") - - @property - def page(self) -> str: - """Return the current page.""" - return self._get("page") - - @property - def sleep_time_default(self) -> str: - """Return the current sleep time in seconds.""" - return self._get("sleepTimeDefault") - - @property - def sleep_time_remaining(self) -> str: - """Return the current sleep time remaining in seconds.""" - return self._get("sleepTimeRemaining") + current_source = int(self.properties.current_source) + return self.instance.sources[current_source] @property - def enabled(self) -> str: - """Return whether the zone is enabled.""" - return self._get("enabled") + def properties(self) -> ZoneProperties: + return ZoneProperties.from_dict(self.instance.get_cache(self.device_str())) async def mute(self) -> str: """Mute the zone.""" @@ -652,11 +440,10 @@ class Source: """Uniquely identifies a Source.""" def __init__( - self, instance: Russound, controller: Controller, source_id: int, name: str + self, instance: Russound, source_id: int, name: str ) -> None: """Initialize a Source.""" self.instance = instance - self.controller = controller self.source_id = int(source_id) self.name = name @@ -670,15 +457,13 @@ async def fetch_configuration(self) -> None: def __str__(self) -> str: """Return the current configuration of the source.""" - return f"{self.controller.mac_address} > S{self.source_id}" + return f"S{self.source_id}" def __eq__(self, other: object) -> bool: """Equality check.""" return ( - hasattr(other, "source_id") - and hasattr(other, "controller") - and other.source_id == self.source_id - and other.controller == self.controller + hasattr(other, "source_id") + and other.source_id == self.source_id ) def __hash__(self) -> int: @@ -714,109 +499,14 @@ async def unwatch(self) -> str: async def send_event(self, event_name: str, *args: tuple[str, ...]) -> str: """Send an event to a source.""" cmd = ( - f"EVENT {self.device_str()}!{event_name} %{ " ".join(str(x) for x in args)}" + f"EVENT {self.device_str()}!{event_name} %{" ".join(str(x) for x in args)}" ) - return await self.instance.send_cmd(cmd) + return await self.instance.connection_handler.send(cmd) def _get(self, variable: str) -> str: return self.instance.get_cached_variable(self.device_str(), variable) @property - def type(self) -> str: - """Get the type of the source.""" - return self._get("type") - - @property - def channel(self) -> str: - """Get the channel of the source.""" - return self._get("channel") - - @property - def cover_art_url(self) -> str: - """Get the cover art url of the source.""" - return self._get("coverArtURL") - - @property - def channel_name(self) -> str: - """Get the current channel name of the source.""" - return self._get("channelName") - - @property - def genre(self) -> str: - """Get the current genre of the source.""" - return self._get("genre") - - @property - def artist_name(self) -> str: - """Get the current artist of the source.""" - return self._get("artistName") + def properties(self) -> SourceProperties: + return SourceProperties.from_dict(self.instance.get_cache(self.device_str())) - @property - def album_name(self) -> str: - """Get the current album of the source.""" - return self._get("albumName") - - @property - def playlist_name(self) -> str: - """Get the current playlist of the source.""" - return self._get("playlistName") - - @property - def song_name(self) -> str: - """Get the current song of the source.""" - return self._get("songName") - - @property - def program_service_name(self) -> str: - """Get the current program service name.""" - return self._get("programServiceName") - - @property - def radio_text(self) -> str: - """Get the current radio text of the source.""" - return self._get("radioText") - - @property - def shuffle_mode(self) -> str: - """Get the current shuffle mode of the source.""" - return self._get("shuffleMode") - - @property - def repeat_mode(self) -> str: - """Get the current repeat mode of the source.""" - return self._get("repeatMode") - - @property - def mode(self) -> str: - """Get the current mode of the source.""" - return self._get("mode") - - @property - def play_status(self) -> str: - """Get the current play status of the source.""" - return self._get("playStatus") - - @property - def sample_rate(self) -> str: - """Get the current sample rate of the source.""" - return self._get("sampleRate") - - @property - def bit_rate(self) -> str: - """Get the current bit rate of the source.""" - return self._get("bitRate") - - @property - def bit_depth(self) -> str: - """Get the current bit depth of the source.""" - return self._get("bitDepth") - - @property - def play_time(self) -> str: - """Get the current play time of the source.""" - return self._get("playTime") - - @property - def track_time(self) -> str: - """Get the current track time of the source.""" - return self._get("trackTime") diff --git a/examples/basic.py b/examples/basic.py index 4005e75..171d8b2 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -9,6 +9,8 @@ # is used for tests. import sys +from aiorussound.connection import RussoundTcpConnectionHandler + sys.path.insert(1, os.path.join(os.path.dirname(__file__), "..")) from aiorussound import Russound, Zone @@ -17,11 +19,19 @@ async def demo(loop: AbstractEventLoop, host: str) -> None: - rus = Russound(loop, host) + conn_handler = RussoundTcpConnectionHandler(loop, host, 4999) + rus = Russound(conn_handler) await rus.connect() _LOGGER.info("Supported Features:") for flag in rus.supported_features: _LOGGER.info(flag) + + _LOGGER.info("Finding sources") + await rus.init_sources() + for source_id, source in rus.sources.items(): + await source.watch() + _LOGGER.info("%s: %s", source_id, source.name) + _LOGGER.info("Finding controllers") controllers = await rus.enumerate_controllers() @@ -35,14 +45,10 @@ async def demo(loop: AbstractEventLoop, host: str) -> None: await zone.watch() _LOGGER.info("%s: %s", zone_id, zone.name) - for source_id, source in c.sources.items(): - await source.watch() - _LOGGER.info("%s: %s", source_id, source.name) + await asyncio.sleep(3.0) + for source_id, source in rus.sources.items(): + print(source.properties) - for _ in range(5): - con: Zone = c.zones.get(1) - await con.volume_up() - await asyncio.sleep(1.0) while True: await asyncio.sleep(1) diff --git a/poetry.lock b/poetry.lock index 95f94a6..75df3ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -198,6 +198,26 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] +[[package]] +name = "mashumaro" +version = "3.13.1" +description = "Fast and well tested serialization library" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mashumaro-3.13.1-py3-none-any.whl", hash = "sha256:ad0a162b8f4ea232dadd2891d77ff20165b855b9d84610f36ac84462d4576aa0"}, + {file = "mashumaro-3.13.1.tar.gz", hash = "sha256:169f0290253b3e6077bcb39c14a9dd0791a3fdedd9e286e536ae561d4ff1975b"}, +] + +[package.dependencies] +typing-extensions = ">=4.1.0" + +[package.extras] +msgpack = ["msgpack (>=0.5.6)"] +orjson = ["orjson"] +toml = ["tomli (>=1.1.0)", "tomli-w (>=1.0)"] +yaml = ["pyyaml (>=3.13)"] + [[package]] name = "mccabe" version = "0.7.0" @@ -277,6 +297,72 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "orjson" +version = "3.10.7" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = false +python-versions = ">=3.8" +files = [ + {file = "orjson-3.10.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:74f4544f5a6405b90da8ea724d15ac9c36da4d72a738c64685003337401f5c12"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34a566f22c28222b08875b18b0dfbf8a947e69df21a9ed5c51a6bf91cfb944ac"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf6ba8ebc8ef5792e2337fb0419f8009729335bb400ece005606336b7fd7bab7"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac7cf6222b29fbda9e3a472b41e6a5538b48f2c8f99261eecd60aafbdb60690c"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de817e2f5fc75a9e7dd350c4b0f54617b280e26d1631811a43e7e968fa71e3e9"}, + {file = "orjson-3.10.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:348bdd16b32556cf8d7257b17cf2bdb7ab7976af4af41ebe79f9796c218f7e91"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:479fd0844ddc3ca77e0fd99644c7fe2de8e8be1efcd57705b5c92e5186e8a250"}, + {file = "orjson-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fdf5197a21dd660cf19dfd2a3ce79574588f8f5e2dbf21bda9ee2d2b46924d84"}, + {file = "orjson-3.10.7-cp310-none-win32.whl", hash = "sha256:d374d36726746c81a49f3ff8daa2898dccab6596864ebe43d50733275c629175"}, + {file = "orjson-3.10.7-cp310-none-win_amd64.whl", hash = "sha256:cb61938aec8b0ffb6eef484d480188a1777e67b05d58e41b435c74b9d84e0b9c"}, + {file = "orjson-3.10.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7db8539039698ddfb9a524b4dd19508256107568cdad24f3682d5773e60504a2"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:480f455222cb7a1dea35c57a67578848537d2602b46c464472c995297117fa09"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a9c9b168b3a19e37fe2778c0003359f07822c90fdff8f98d9d2a91b3144d8e0"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8de062de550f63185e4c1c54151bdddfc5625e37daf0aa1e75d2a1293e3b7d9a"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b0dd04483499d1de9c8f6203f8975caf17a6000b9c0c54630cef02e44ee624e"}, + {file = "orjson-3.10.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b58d3795dafa334fc8fd46f7c5dc013e6ad06fd5b9a4cc98cb1456e7d3558bd6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:33cfb96c24034a878d83d1a9415799a73dc77480e6c40417e5dda0710d559ee6"}, + {file = "orjson-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e724cebe1fadc2b23c6f7415bad5ee6239e00a69f30ee423f319c6af70e2a5c0"}, + {file = "orjson-3.10.7-cp311-none-win32.whl", hash = "sha256:82763b46053727a7168d29c772ed5c870fdae2f61aa8a25994c7984a19b1021f"}, + {file = "orjson-3.10.7-cp311-none-win_amd64.whl", hash = "sha256:eb8d384a24778abf29afb8e41d68fdd9a156cf6e5390c04cc07bbc24b89e98b5"}, + {file = "orjson-3.10.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44a96f2d4c3af51bfac6bc4ef7b182aa33f2f054fd7f34cc0ee9a320d051d41f"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76ac14cd57df0572453543f8f2575e2d01ae9e790c21f57627803f5e79b0d3c3"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bdbb61dcc365dd9be94e8f7df91975edc9364d6a78c8f7adb69c1cdff318ec93"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b48b3db6bb6e0a08fa8c83b47bc169623f801e5cc4f24442ab2b6617da3b5313"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23820a1563a1d386414fef15c249040042b8e5d07b40ab3fe3efbfbbcbcb8864"}, + {file = "orjson-3.10.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0c6a008e91d10a2564edbb6ee5069a9e66df3fbe11c9a005cb411f441fd2c09"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d352ee8ac1926d6193f602cbe36b1643bbd1bbcb25e3c1a657a4390f3000c9a5"}, + {file = "orjson-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2d9f990623f15c0ae7ac608103c33dfe1486d2ed974ac3f40b693bad1a22a7b"}, + {file = "orjson-3.10.7-cp312-none-win32.whl", hash = "sha256:7c4c17f8157bd520cdb7195f75ddbd31671997cbe10aee559c2d613592e7d7eb"}, + {file = "orjson-3.10.7-cp312-none-win_amd64.whl", hash = "sha256:1d9c0e733e02ada3ed6098a10a8ee0052dd55774de3d9110d29868d24b17faa1"}, + {file = "orjson-3.10.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:77d325ed866876c0fa6492598ec01fe30e803272a6e8b10e992288b009cbe149"}, + {file = "orjson-3.10.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea2c232deedcb605e853ae1db2cc94f7390ac776743b699b50b071b02bea6fe"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3dcfbede6737fdbef3ce9c37af3fb6142e8e1ebc10336daa05872bfb1d87839c"}, + {file = "orjson-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:11748c135f281203f4ee695b7f80bb1358a82a63905f9f0b794769483ea854ad"}, + {file = "orjson-3.10.7-cp313-none-win32.whl", hash = "sha256:a7e19150d215c7a13f39eb787d84db274298d3f83d85463e61d277bbd7f401d2"}, + {file = "orjson-3.10.7-cp313-none-win_amd64.whl", hash = "sha256:eef44224729e9525d5261cc8d28d6b11cafc90e6bd0be2157bde69a52ec83024"}, + {file = "orjson-3.10.7-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6ea2b2258eff652c82652d5e0f02bd5e0463a6a52abb78e49ac288827aaa1469"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:430ee4d85841e1483d487e7b81401785a5dfd69db5de01314538f31f8fbf7ee1"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b6146e439af4c2472c56f8540d799a67a81226e11992008cb47e1267a9b3225"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:084e537806b458911137f76097e53ce7bf5806dda33ddf6aaa66a028f8d43a23"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829cf2195838e3f93b70fd3b4292156fc5e097aac3739859ac0dcc722b27ac0"}, + {file = "orjson-3.10.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1193b2416cbad1a769f868b1749535d5da47626ac29445803dae7cc64b3f5c98"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:4e6c3da13e5a57e4b3dca2de059f243ebec705857522f188f0180ae88badd354"}, + {file = "orjson-3.10.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c31008598424dfbe52ce8c5b47e0752dca918a4fdc4a2a32004efd9fab41d866"}, + {file = "orjson-3.10.7-cp38-none-win32.whl", hash = "sha256:7122a99831f9e7fe977dc45784d3b2edc821c172d545e6420c375e5a935f5a1c"}, + {file = "orjson-3.10.7-cp38-none-win_amd64.whl", hash = "sha256:a763bc0e58504cc803739e7df040685816145a6f3c8a589787084b54ebc9f16e"}, + {file = "orjson-3.10.7-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e76be12658a6fa376fcd331b1ea4e58f5a06fd0220653450f0d415b8fd0fbe20"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed350d6978d28b92939bfeb1a0570c523f6170efc3f0a0ef1f1df287cd4f4960"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:144888c76f8520e39bfa121b31fd637e18d4cc2f115727865fdf9fa325b10412"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09b2d92fd95ad2402188cf51573acde57eb269eddabaa60f69ea0d733e789fe9"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b24a579123fa884f3a3caadaed7b75eb5715ee2b17ab5c66ac97d29b18fe57f"}, + {file = "orjson-3.10.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591bcfe7512353bd609875ab38050efe3d55e18934e2f18950c108334b4ff"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f4db56635b58cd1a200b0a23744ff44206ee6aa428185e2b6c4a65b3197abdcd"}, + {file = "orjson-3.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0fa5886854673222618638c6df7718ea7fe2f3f2384c452c9ccedc70b4a510a5"}, + {file = "orjson-3.10.7-cp39-none-win32.whl", hash = "sha256:8272527d08450ab16eb405f47e0f4ef0e5ff5981c3d82afe0efd25dcbef2bcd2"}, + {file = "orjson-3.10.7-cp39-none-win_amd64.whl", hash = "sha256:974683d4618c0c7dbf4f69c95a979734bf183d0658611760017f6e70a145af58"}, + {file = "orjson-3.10.7.tar.gz", hash = "sha256:75ef0640403f945f3a1f9f6400686560dbfb0fb5b16589ad62cd477043c4eee3"}, +] + [[package]] name = "packaging" version = "24.1" @@ -645,4 +731,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "7d49b260a958426cece907bd9d0c13628f62d22b43f8c72c504e761e493b59d6" +content-hash = "5e8a47d7cd1c4371b066e43ba888f01404af7e91b64bb5f021931f42f41980ed" diff --git a/pyproject.toml b/pyproject.toml index 481a6b8..5b748ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiorussound" -version = "2.3.2" +version = "3.0.0" description = "Asyncio client for Russound RIO devices." authors = ["Noah Husby "] maintainers = ["Noah Husby "] @@ -9,9 +9,12 @@ readme = "README.md" homepage = "https://github.com/noahhusby/aiorussound" repository = "https://github.com/noahhusby/aiorussound" documentation = "https://github.com/noahhusby/aiorussound" +requires-python = "^3.11" [tool.poetry.dependencies] python = "^3.11" +mashumaro = "^3.11" +orjson = ">=3.9.0" [tool.poetry.group.dev.dependencies] coverage = {version = "7.6.1"}