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>
pull/126825/head
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
from aiorussound import RussoundClient, RussoundTcpConnectionHandler
from aiorussound.models import CallbackType
from homeassistant.config_entries import ConfigEntry
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 .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]
port = entry.data[CONF_PORT]
russ = RussoundClient(RussoundTcpConnectionHandler(hass.loop, host, port))
client = RussoundClient(RussoundTcpConnectionHandler(host, port))
@callback
def is_connected_updated(connected: bool) -> None:
if connected:
_LOGGER.warning("Reconnected to controller at %s:%s", host, port)
else:
_LOGGER.warning(
"Disconnected from controller at %s:%s",
host,
port,
)
async def _connection_update_callback(
_client: RussoundClient, _callback_type: CallbackType
) -> None:
"""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:
_LOGGER.warning("Disconnected from device at %s", entry.data[CONF_HOST])
await client.register_state_update_callbacks(_connection_update_callback)
russ.connection_handler.add_connection_callback(is_connected_updated)
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
await client.connect()
except RUSSOUND_RIO_EXCEPTIONS as 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)
@ -53,6 +54,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
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

View File

@ -6,19 +6,14 @@ import asyncio
import logging
from typing import Any
from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler
from aiorussound import RussoundClient, RussoundTcpConnectionHandler
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers import config_validation as cv
from .const import (
CONNECT_TIMEOUT,
DOMAIN,
RUSSOUND_RIO_EXCEPTIONS,
NoPrimaryControllerException,
)
from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS
DATA_SCHEMA = vol.Schema(
{
@ -30,16 +25,6 @@ DATA_SCHEMA = vol.Schema(
_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):
"""Russound RIO configuration flow."""
@ -54,28 +39,22 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
russ = RussoundClient(
RussoundTcpConnectionHandler(self.hass.loop, host, port)
)
client = RussoundClient(RussoundTcpConnectionHandler(host, port))
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
controllers = await russ.enumerate_controllers()
metadata = find_primary_controller_metadata(controllers)
await russ.close()
await client.connect()
controller = client.controllers[1]
await client.disconnect()
except RUSSOUND_RIO_EXCEPTIONS:
_LOGGER.exception("Could not connect to Russound RIO")
errors["base"] = "cannot_connect"
except NoPrimaryControllerException:
_LOGGER.exception(
"Russound RIO device doesn't have a primary controller",
)
errors["base"] = "no_primary_controller"
else:
await self.async_set_unique_id(metadata[0])
await self.async_set_unique_id(controller.mac_address)
self._abort_if_unique_id_configured()
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(
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)
# 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:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
controllers = await russ.enumerate_controllers()
metadata = find_primary_controller_metadata(controllers)
await russ.close()
await client.connect()
controller = client.controllers[1]
await client.disconnect()
except RUSSOUND_RIO_EXCEPTIONS:
_LOGGER.exception("Could not connect to Russound RIO")
return self.async_abort(
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:
await self.async_set_unique_id(metadata[0])
await self.async_set_unique_id(controller.mac_address)
self._abort_if_unique_id_configured()
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
MP_FEATURES_BY_FLAG = {

View File

@ -4,9 +4,9 @@ from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
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.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity
@ -46,7 +46,7 @@ class RussoundBaseEntity(Entity):
self._client = controller.client
self._controller = controller
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._controller.mac_address
@ -64,30 +64,33 @@ class RussoundBaseEntity(Entity):
self._attr_device_info["configuration_url"] = (
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"] = (
DOMAIN,
controller.parent_controller.mac_address,
self._client.controllers[1].mac_address,
)
else:
assert controller.mac_address
self._attr_device_info["connections"] = {
(CONNECTION_NETWORK_MAC, controller.mac_address)
}
@callback
def _is_connected_updated(self, connected: bool) -> None:
"""Update the state when the device is ready to receive commands or is unavailable."""
self._attr_available = connected
async def _state_update_callback(
self, _client: RussoundClient, _callback_type: CallbackType
) -> None:
"""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()
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._client.connection_handler.add_connection_callback(
self._is_connected_updated
)
"""Register callback handlers."""
await self._client.register_state_update_callbacks(self._state_update_callback)
async def async_will_remove_from_hass(self) -> None:
"""Remove callbacks."""
self._client.connection_handler.remove_connection_callback(
self._is_connected_updated
await self._client.unregister_state_update_callbacks(
self._state_update_callback
)

View File

@ -7,5 +7,5 @@
"iot_class": "local_push",
"loggers": ["aiorussound"],
"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
from aiorussound import RussoundClient, Source, Zone
from aiorussound.models import CallbackType
from aiorussound import Controller
from aiorussound.models import Source
from aiorussound.rio import ZoneControlSurface
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
@ -15,8 +16,7 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@ -83,31 +83,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Russound RIO platform."""
russ = entry.runtime_data
client = entry.runtime_data
sources = client.sources
await russ.init_sources()
sources = russ.sources
for source in sources.values():
await source.watch()
# 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)
async_add_entities(
RussoundZoneDevice(controller, zone_id, sources)
for controller in client.controllers.values()
for zone_id in controller.zones
)
class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@ -123,42 +106,32 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
| 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."""
super().__init__(zone.controller)
self._zone = zone
super().__init__(controller)
self._zone_id = zone_id
_zone = self._zone
self._sources = sources
self._attr_name = zone.name
self._attr_unique_id = f"{self._primary_mac_address}-{zone.device_str()}"
self._attr_name = _zone.name
self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}"
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
async def _state_update_callback(
self, _client: RussoundClient, _callback_type: CallbackType
) -> None:
"""Call when the device is notified of changes."""
self.async_write_ha_state()
@property
def _zone(self) -> ZoneControlSurface:
return self._controller.zones[self._zone_id]
async def async_added_to_hass(self) -> None:
"""Register callback handlers."""
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:
@property
def _source(self) -> Source:
return self._zone.fetch_current_source()
@property
def state(self) -> MediaPlayerState | None:
"""Return the state of the device."""
status = self._zone.properties.status
status = self._zone.status
if status == "ON":
return MediaPlayerState.ON
if status == "OFF":
@ -168,7 +141,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@property
def source(self):
"""Get the currently selected source."""
return self._current_source().name
return self._source.name
@property
def source_list(self):
@ -178,22 +151,22 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
@property
def media_title(self):
"""Title of current playing media."""
return self._current_source().properties.song_name
return self._source.song_name
@property
def media_artist(self):
"""Artist of current playing media, music track only."""
return self._current_source().properties.artist_name
return self._source.artist_name
@property
def media_album_name(self):
"""Album name of current playing media, music track only."""
return self._current_source().properties.album_name
return self._source.album_name
@property
def media_image_url(self):
"""Image url of current playing media."""
return self._current_source().properties.cover_art_url
return self._source.cover_art_url
@property
def volume_level(self):
@ -202,7 +175,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity):
Value is returned based on a range (0..50).
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
async def async_turn_off(self) -> None:

View File

@ -1,7 +1,6 @@
{
"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_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)."
"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."
},
"config": {
"step": {
@ -14,12 +13,10 @@
}
},
"error": {
"cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]",
"no_primary_controller": "[%key:component::russound_rio::common::error_no_primary_controller%]"
"cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]"
},
"abort": {
"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%]"
}
},

View File

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

View File

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

View File

@ -44,5 +44,5 @@ def mock_russound() -> Generator[AsyncMock]:
return_value=mock_client,
),
):
mock_client.enumerate_controllers.return_value = MOCK_CONTROLLERS
mock_client.controllers = MOCK_CONTROLLERS
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.data_entry_flow import FlowResultType
from .const import MOCK_CONFIG, MOCK_CONTROLLERS, MODEL
from .const import MOCK_CONFIG, MODEL
async def test_form(
@ -60,37 +60,6 @@ async def test_form_cannot_connect(
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(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
) -> None:
@ -119,17 +88,3 @@ async def test_import_cannot_connect(
assert result["type"] is FlowResultType.ABORT
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"