Bump aiorussound to 4.0.5 (#126774)

* Bump aiorussound to 4.0.4

* Remove unnecessary exception

* Bump aiorussound to 4.0.5

* Fixes

* Update homeassistant/components/russound_rio/media_player.py

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Noah Husby 2024-09-26 08:38:36 -04:00 committed by GitHub
parent b766d91f49
commit 7afad1dde9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 90 additions and 192 deletions

View file

@ -4,10 +4,11 @@ import asyncio
import logging import logging
from aiorussound import RussoundClient, RussoundTcpConnectionHandler from aiorussound import RussoundClient, RussoundTcpConnectionHandler
from aiorussound.models import CallbackType
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONNECT_TIMEOUT, RUSSOUND_RIO_EXCEPTIONS from .const import CONNECT_TIMEOUT, RUSSOUND_RIO_EXCEPTIONS
@ -24,26 +25,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT] port = entry.data[CONF_PORT]
russ = RussoundClient(RussoundTcpConnectionHandler(hass.loop, host, port)) client = RussoundClient(RussoundTcpConnectionHandler(host, port))
@callback async def _connection_update_callback(
def is_connected_updated(connected: bool) -> None: _client: RussoundClient, _callback_type: CallbackType
if connected: ) -> None:
_LOGGER.warning("Reconnected to controller at %s:%s", host, port) """Call when the device is notified of changes."""
if _callback_type == CallbackType.CONNECTION:
if _client.is_connected():
_LOGGER.warning("Reconnected to device at %s", entry.data[CONF_HOST])
else: else:
_LOGGER.warning( _LOGGER.warning("Disconnected from device at %s", entry.data[CONF_HOST])
"Disconnected from controller at %s:%s",
host, await client.register_state_update_callbacks(_connection_update_callback)
port,
)
russ.connection_handler.add_connection_callback(is_connected_updated)
try: try:
async with asyncio.timeout(CONNECT_TIMEOUT): async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect() await client.connect()
except RUSSOUND_RIO_EXCEPTIONS as err: except RUSSOUND_RIO_EXCEPTIONS as err:
raise ConfigEntryNotReady(f"Error while connecting to {host}:{port}") from err raise ConfigEntryNotReady(f"Error while connecting to {host}:{port}") from err
entry.runtime_data = russ entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@ -53,6 +54,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.close() await entry.runtime_data.disconnect()
return unload_ok return unload_ok

View file

@ -6,19 +6,14 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler from aiorussound import RussoundClient, RussoundTcpConnectionHandler
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from .const import ( from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS
CONNECT_TIMEOUT,
DOMAIN,
RUSSOUND_RIO_EXCEPTIONS,
NoPrimaryControllerException,
)
DATA_SCHEMA = vol.Schema( DATA_SCHEMA = vol.Schema(
{ {
@ -30,16 +25,6 @@ DATA_SCHEMA = vol.Schema(
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def find_primary_controller_metadata(
controllers: dict[int, Controller],
) -> tuple[str, str]:
"""Find the mac address of the primary Russound controller."""
if 1 in controllers:
c = controllers[1]
return c.mac_address, c.controller_type
raise NoPrimaryControllerException
class FlowHandler(ConfigFlow, domain=DOMAIN): class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Russound RIO configuration flow.""" """Russound RIO configuration flow."""
@ -54,28 +39,22 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
host = user_input[CONF_HOST] host = user_input[CONF_HOST]
port = user_input[CONF_PORT] port = user_input[CONF_PORT]
russ = RussoundClient( client = RussoundClient(RussoundTcpConnectionHandler(host, port))
RussoundTcpConnectionHandler(self.hass.loop, host, port)
)
try: try:
async with asyncio.timeout(CONNECT_TIMEOUT): async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect() await client.connect()
controllers = await russ.enumerate_controllers() controller = client.controllers[1]
metadata = find_primary_controller_metadata(controllers) await client.disconnect()
await russ.close()
except RUSSOUND_RIO_EXCEPTIONS: except RUSSOUND_RIO_EXCEPTIONS:
_LOGGER.exception("Could not connect to Russound RIO") _LOGGER.exception("Could not connect to Russound RIO")
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except NoPrimaryControllerException:
_LOGGER.exception(
"Russound RIO device doesn't have a primary controller",
)
errors["base"] = "no_primary_controller"
else: else:
await self.async_set_unique_id(metadata[0]) await self.async_set_unique_id(controller.mac_address)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
data = {CONF_HOST: host, CONF_PORT: port} data = {CONF_HOST: host, CONF_PORT: port}
return self.async_create_entry(title=metadata[1], data=data) return self.async_create_entry(
title=controller.controller_type, data=data
)
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors step_id="user", data_schema=DATA_SCHEMA, errors=errors
@ -88,25 +67,19 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
port = import_data.get(CONF_PORT, 9621) port = import_data.get(CONF_PORT, 9621)
# Connection logic is repeated here since this method will be removed in future releases # Connection logic is repeated here since this method will be removed in future releases
russ = RussoundClient(RussoundTcpConnectionHandler(self.hass.loop, host, port)) client = RussoundClient(RussoundTcpConnectionHandler(host, port))
try: try:
async with asyncio.timeout(CONNECT_TIMEOUT): async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect() await client.connect()
controllers = await russ.enumerate_controllers() controller = client.controllers[1]
metadata = find_primary_controller_metadata(controllers) await client.disconnect()
await russ.close()
except RUSSOUND_RIO_EXCEPTIONS: except RUSSOUND_RIO_EXCEPTIONS:
_LOGGER.exception("Could not connect to Russound RIO") _LOGGER.exception("Could not connect to Russound RIO")
return self.async_abort( return self.async_abort(
reason="cannot_connect", description_placeholders={} reason="cannot_connect", description_placeholders={}
) )
except NoPrimaryControllerException:
_LOGGER.exception("Russound RIO device doesn't have a primary controller")
return self.async_abort(
reason="no_primary_controller", description_placeholders={}
)
else: else:
await self.async_set_unique_id(metadata[0]) await self.async_set_unique_id(controller.mac_address)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
data = {CONF_HOST: host, CONF_PORT: port} data = {CONF_HOST: host, CONF_PORT: port}
return self.async_create_entry(title=metadata[1], data=data) return self.async_create_entry(title=controller.controller_type, data=data)

View file

@ -17,10 +17,6 @@ RUSSOUND_RIO_EXCEPTIONS = (
) )
class NoPrimaryControllerException(Exception):
"""Thrown when the Russound device is not the primary unit in the RNET stack."""
CONNECT_TIMEOUT = 5 CONNECT_TIMEOUT = 5
MP_FEATURES_BY_FLAG = { MP_FEATURES_BY_FLAG = {

View file

@ -4,9 +4,9 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps from functools import wraps
from typing import Any, Concatenate from typing import Any, Concatenate
from aiorussound import Controller, RussoundTcpConnectionHandler from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler
from aiorussound.models import CallbackType
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
@ -46,7 +46,7 @@ class RussoundBaseEntity(Entity):
self._client = controller.client self._client = controller.client
self._controller = controller self._controller = controller
self._primary_mac_address = ( self._primary_mac_address = (
controller.mac_address or controller.parent_controller.mac_address controller.mac_address or self._client.controllers[1].mac_address
) )
self._device_identifier = ( self._device_identifier = (
self._controller.mac_address self._controller.mac_address
@ -64,30 +64,33 @@ class RussoundBaseEntity(Entity):
self._attr_device_info["configuration_url"] = ( self._attr_device_info["configuration_url"] = (
f"http://{self._client.connection_handler.host}" f"http://{self._client.connection_handler.host}"
) )
if controller.parent_controller: if controller.controller_id != 1:
assert self._client.controllers[1].mac_address
self._attr_device_info["via_device"] = ( self._attr_device_info["via_device"] = (
DOMAIN, DOMAIN,
controller.parent_controller.mac_address, self._client.controllers[1].mac_address,
) )
else: else:
assert controller.mac_address
self._attr_device_info["connections"] = { self._attr_device_info["connections"] = {
(CONNECTION_NETWORK_MAC, controller.mac_address) (CONNECTION_NETWORK_MAC, controller.mac_address)
} }
@callback async def _state_update_callback(
def _is_connected_updated(self, connected: bool) -> None: self, _client: RussoundClient, _callback_type: CallbackType
"""Update the state when the device is ready to receive commands or is unavailable.""" ) -> None:
self._attr_available = connected """Call when the device is notified of changes."""
if _callback_type == CallbackType.CONNECTION:
self._attr_available = _client.is_connected()
self._controller = _client.controllers[self._controller.controller_id]
self.async_write_ha_state() self.async_write_ha_state()
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callback handlers."""
self._client.connection_handler.add_connection_callback( await self._client.register_state_update_callbacks(self._state_update_callback)
self._is_connected_updated
)
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks.""" """Remove callbacks."""
self._client.connection_handler.remove_connection_callback( await self._client.unregister_state_update_callbacks(
self._is_connected_updated self._state_update_callback
) )

View file

@ -7,5 +7,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiorussound"], "loggers": ["aiorussound"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["aiorussound==3.1.5"] "requirements": ["aiorussound==4.0.5"]
} }

View file

@ -4,8 +4,9 @@ from __future__ import annotations
import logging import logging
from aiorussound import RussoundClient, Source, Zone from aiorussound import Controller
from aiorussound.models import CallbackType from aiorussound.models import Source
from aiorussound.rio import ZoneControlSurface
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerDeviceClass, MediaPlayerDeviceClass,
@ -15,8 +16,7 @@ from homeassistant.components.media_player import (
MediaType, MediaType,
) )
from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@ -83,31 +83,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Russound RIO platform.""" """Set up the Russound RIO platform."""
russ = entry.runtime_data client = entry.runtime_data
sources = client.sources
await russ.init_sources() async_add_entities(
sources = russ.sources RussoundZoneDevice(controller, zone_id, sources)
for source in sources.values(): for controller in client.controllers.values()
await source.watch() for zone_id in controller.zones
)
# Discover controllers
controllers = await russ.enumerate_controllers()
entities = []
for controller in controllers.values():
for zone in controller.zones.values():
await zone.watch()
mp = RussoundZoneDevice(zone, sources)
entities.append(mp)
@callback
def on_stop(event):
"""Shutdown cleanly when hass stops."""
hass.loop.create_task(russ.close())
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop)
async_add_entities(entities)
class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@ -123,42 +106,32 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SELECT_SOURCE
) )
def __init__(self, zone: Zone, sources: dict[int, Source]) -> None: def __init__(
self, controller: Controller, zone_id: int, sources: dict[int, Source]
) -> None:
"""Initialize the zone device.""" """Initialize the zone device."""
super().__init__(zone.controller) super().__init__(controller)
self._zone = zone self._zone_id = zone_id
_zone = self._zone
self._sources = sources self._sources = sources
self._attr_name = zone.name self._attr_name = _zone.name
self._attr_unique_id = f"{self._primary_mac_address}-{zone.device_str()}" self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}"
for flag, feature in MP_FEATURES_BY_FLAG.items(): for flag, feature in MP_FEATURES_BY_FLAG.items():
if flag in zone.client.supported_features: if flag in self._client.supported_features:
self._attr_supported_features |= feature self._attr_supported_features |= feature
async def _state_update_callback( @property
self, _client: RussoundClient, _callback_type: CallbackType def _zone(self) -> ZoneControlSurface:
) -> None: return self._controller.zones[self._zone_id]
"""Call when the device is notified of changes."""
self.async_write_ha_state()
async def async_added_to_hass(self) -> None: @property
"""Register callback handlers.""" def _source(self) -> Source:
await super().async_added_to_hass()
await self._client.register_state_update_callbacks(self._state_update_callback)
async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
await super().async_will_remove_from_hass()
await self._client.unregister_state_update_callbacks(
self._state_update_callback
)
def _current_source(self) -> Source:
return self._zone.fetch_current_source() return self._zone.fetch_current_source()
@property @property
def state(self) -> MediaPlayerState | None: def state(self) -> MediaPlayerState | None:
"""Return the state of the device.""" """Return the state of the device."""
status = self._zone.properties.status status = self._zone.status
if status == "ON": if status == "ON":
return MediaPlayerState.ON return MediaPlayerState.ON
if status == "OFF": if status == "OFF":
@ -168,7 +141,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@property @property
def source(self): def source(self):
"""Get the currently selected source.""" """Get the currently selected source."""
return self._current_source().name return self._source.name
@property @property
def source_list(self): def source_list(self):
@ -178,22 +151,22 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@property @property
def media_title(self): def media_title(self):
"""Title of current playing media.""" """Title of current playing media."""
return self._current_source().properties.song_name return self._source.song_name
@property @property
def media_artist(self): def media_artist(self):
"""Artist of current playing media, music track only.""" """Artist of current playing media, music track only."""
return self._current_source().properties.artist_name return self._source.artist_name
@property @property
def media_album_name(self): def media_album_name(self):
"""Album name of current playing media, music track only.""" """Album name of current playing media, music track only."""
return self._current_source().properties.album_name return self._source.album_name
@property @property
def media_image_url(self): def media_image_url(self):
"""Image url of current playing media.""" """Image url of current playing media."""
return self._current_source().properties.cover_art_url return self._source.cover_art_url
@property @property
def volume_level(self): def volume_level(self):
@ -202,7 +175,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
Value is returned based on a range (0..50). Value is returned based on a range (0..50).
Therefore float divide by 50 to get to the required range. Therefore float divide by 50 to get to the required range.
""" """
return float(self._zone.properties.volume or "0") / 50.0 return float(self._zone.volume or "0") / 50.0
@command @command
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:

View file

@ -1,7 +1,6 @@
{ {
"common": { "common": {
"error_cannot_connect": "Failed to connect to Russound device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect.", "error_cannot_connect": "Failed to connect to Russound device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect."
"error_no_primary_controller": "No primary controller was detected for the Russound device. Please make sure that the target Russound device has it's controller ID set to 1 (using the selector on the back of the unit)."
}, },
"config": { "config": {
"step": { "step": {
@ -14,12 +13,10 @@
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]", "cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]"
"no_primary_controller": "[%key:component::russound_rio::common::error_no_primary_controller%]"
}, },
"abort": { "abort": {
"cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]", "cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]",
"no_primary_controller": "[%key:component::russound_rio::common::error_no_primary_controller%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
} }
}, },

View file

@ -356,7 +356,7 @@ aioridwell==2024.01.0
aioruckus==0.41 aioruckus==0.41
# homeassistant.components.russound_rio # homeassistant.components.russound_rio
aiorussound==3.1.5 aiorussound==4.0.5
# homeassistant.components.ruuvi_gateway # homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0 aioruuvigateway==0.1.0

View file

@ -338,7 +338,7 @@ aioridwell==2024.01.0
aioruckus==0.41 aioruckus==0.41
# homeassistant.components.russound_rio # homeassistant.components.russound_rio
aiorussound==3.1.5 aiorussound==4.0.5
# homeassistant.components.ruuvi_gateway # homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0 aioruuvigateway==0.1.0

View file

@ -44,5 +44,5 @@ def mock_russound() -> Generator[AsyncMock]:
return_value=mock_client, return_value=mock_client,
), ),
): ):
mock_client.enumerate_controllers.return_value = MOCK_CONTROLLERS mock_client.controllers = MOCK_CONTROLLERS
yield mock_client yield mock_client

View file

@ -7,7 +7,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from .const import MOCK_CONFIG, MOCK_CONTROLLERS, MODEL from .const import MOCK_CONFIG, MODEL
async def test_form( async def test_form(
@ -60,37 +60,6 @@ async def test_form_cannot_connect(
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_no_primary_controller(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
) -> None:
"""Test we handle no primary controller error."""
mock_russound.enumerate_controllers.return_value = {}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
user_input = MOCK_CONFIG
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_primary_controller"}
# Recover with correct information
mock_russound.enumerate_controllers.return_value = MOCK_CONTROLLERS
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MODEL
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
async def test_import( async def test_import(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
) -> None: ) -> None:
@ -119,17 +88,3 @@ async def test_import_cannot_connect(
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect" assert result["reason"] == "cannot_connect"
async def test_import_no_primary_controller(
hass: HomeAssistant, mock_russound: AsyncMock
) -> None:
"""Test import with no primary controller error."""
mock_russound.enumerate_controllers.return_value = {}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_primary_controller"