parent
db9fc27a5c
commit
f30c6e01f9
8 changed files with 57 additions and 75 deletions
|
@ -6,7 +6,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiorussound import Russound
|
from aiorussound import Controller, Russound
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
@ -31,13 +31,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def find_primary_controller_metadata(
|
def find_primary_controller_metadata(
|
||||||
controllers: list[tuple[int, str, str]],
|
controllers: dict[int, Controller],
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Find the mac address of the primary Russound controller."""
|
"""Find the mac address of the primary Russound controller."""
|
||||||
for controller_id, mac_address, controller_type in controllers:
|
if 1 in controllers:
|
||||||
# The integration only cares about the primary controller linked by IP and not any downstream controllers
|
c = controllers[1]
|
||||||
if controller_id == 1:
|
return c.mac_address, c.controller_type
|
||||||
return (mac_address, controller_type)
|
|
||||||
raise NoPrimaryControllerException
|
raise NoPrimaryControllerException
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -6,5 +6,5 @@
|
||||||
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
|
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["aiorussound"],
|
"loggers": ["aiorussound"],
|
||||||
"requirements": ["aiorussound==1.1.2"]
|
"requirements": ["aiorussound==2.0.6"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from aiorussound import Source, Zone
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
MediaPlayerEntity,
|
MediaPlayerEntity,
|
||||||
MediaPlayerEntityFeature,
|
MediaPlayerEntityFeature,
|
||||||
|
@ -80,21 +82,18 @@ async def async_setup_entry(
|
||||||
"""Set up the Russound RIO platform."""
|
"""Set up the Russound RIO platform."""
|
||||||
russ = entry.runtime_data
|
russ = entry.runtime_data
|
||||||
|
|
||||||
# Discover sources and zones
|
# Discover controllers
|
||||||
sources = await russ.enumerate_sources()
|
controllers = await russ.enumerate_controllers()
|
||||||
valid_zones = await russ.enumerate_zones()
|
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
for zone_id, name in valid_zones:
|
for controller in controllers:
|
||||||
if zone_id.controller > 6:
|
sources = controller.sources
|
||||||
_LOGGER.debug(
|
for source in sources.values():
|
||||||
"Zone ID %s exceeds RIO controller maximum, skipping",
|
await source.watch()
|
||||||
zone_id.device_str(),
|
for zone in controller.zones.values():
|
||||||
)
|
await zone.watch()
|
||||||
continue
|
mp = RussoundZoneDevice(zone, sources)
|
||||||
await russ.watch_zone(zone_id)
|
entities.append(mp)
|
||||||
zone = RussoundZoneDevice(russ, zone_id, name, sources)
|
|
||||||
entities.append(zone)
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def on_stop(event):
|
def on_stop(event):
|
||||||
|
@ -119,56 +118,35 @@ class RussoundZoneDevice(MediaPlayerEntity):
|
||||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, russ, zone_id, name, sources) -> None:
|
def __init__(self, zone: Zone, sources: dict[int, Source]) -> None:
|
||||||
"""Initialize the zone device."""
|
"""Initialize the zone device."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._name = name
|
self._zone = zone
|
||||||
self._russ = russ
|
|
||||||
self._zone_id = zone_id
|
|
||||||
self._sources = sources
|
self._sources = sources
|
||||||
|
|
||||||
def _zone_var(self, name, default=None):
|
def _callback_handler(self, device_str, *args):
|
||||||
return self._russ.get_cached_zone_variable(self._zone_id, name, default)
|
if (
|
||||||
|
device_str == self._zone.device_str()
|
||||||
def _source_var(self, name, default=None):
|
or device_str == self._current_source().device_str()
|
||||||
current = int(self._zone_var("currentsource", 0))
|
):
|
||||||
if current:
|
|
||||||
return self._russ.get_cached_source_variable(current, name, default)
|
|
||||||
return default
|
|
||||||
|
|
||||||
def _source_na_var(self, name):
|
|
||||||
"""Will replace invalid values with None."""
|
|
||||||
current = int(self._zone_var("currentsource", 0))
|
|
||||||
if current:
|
|
||||||
value = self._russ.get_cached_source_variable(current, name, None)
|
|
||||||
if value in (None, "", "------"):
|
|
||||||
return None
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _zone_callback_handler(self, zone_id, *args):
|
|
||||||
if zone_id == self._zone_id:
|
|
||||||
self.schedule_update_ha_state()
|
|
||||||
|
|
||||||
def _source_callback_handler(self, source_id, *args):
|
|
||||||
current = int(self._zone_var("currentsource", 0))
|
|
||||||
if source_id == current:
|
|
||||||
self.schedule_update_ha_state()
|
self.schedule_update_ha_state()
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callback handlers."""
|
"""Register callback handlers."""
|
||||||
self._russ.add_zone_callback(self._zone_callback_handler)
|
self._zone.add_callback(self._callback_handler)
|
||||||
self._russ.add_source_callback(self._source_callback_handler)
|
|
||||||
|
def _current_source(self) -> Source:
|
||||||
|
return self._zone.fetch_current_source()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of the zone."""
|
"""Return the name of the zone."""
|
||||||
return self._zone_var("name", self._name)
|
return self._zone.name
|
||||||
|
|
||||||
@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_var("status", "OFF")
|
status = self._zone.status
|
||||||
if status == "ON":
|
if status == "ON":
|
||||||
return MediaPlayerState.ON
|
return MediaPlayerState.ON
|
||||||
if status == "OFF":
|
if status == "OFF":
|
||||||
|
@ -178,32 +156,32 @@ class RussoundZoneDevice(MediaPlayerEntity):
|
||||||
@property
|
@property
|
||||||
def source(self):
|
def source(self):
|
||||||
"""Get the currently selected source."""
|
"""Get the currently selected source."""
|
||||||
return self._source_na_var("name")
|
return self._current_source().name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_list(self):
|
def source_list(self):
|
||||||
"""Return a list of available input sources."""
|
"""Return a list of available input sources."""
|
||||||
return [x[1] for x in self._sources]
|
return [x.name for x in self._sources.values()]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def media_title(self):
|
def media_title(self):
|
||||||
"""Title of current playing media."""
|
"""Title of current playing media."""
|
||||||
return self._source_na_var("songname")
|
return self._current_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._source_na_var("artistname")
|
return self._current_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._source_na_var("albumname")
|
return self._current_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._source_na_var("coverarturl")
|
return self._current_source().cover_art_url
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def volume_level(self):
|
def volume_level(self):
|
||||||
|
@ -212,25 +190,25 @@ class RussoundZoneDevice(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_var("volume", 0)) / 50.0
|
return float(self._zone.volume or "0") / 50.0
|
||||||
|
|
||||||
async def async_turn_off(self) -> None:
|
async def async_turn_off(self) -> None:
|
||||||
"""Turn off the zone."""
|
"""Turn off the zone."""
|
||||||
await self._russ.send_zone_event(self._zone_id, "ZoneOff")
|
await self._zone.send_event("ZoneOff")
|
||||||
|
|
||||||
async def async_turn_on(self) -> None:
|
async def async_turn_on(self) -> None:
|
||||||
"""Turn on the zone."""
|
"""Turn on the zone."""
|
||||||
await self._russ.send_zone_event(self._zone_id, "ZoneOn")
|
await self._zone.send_event("ZoneOn")
|
||||||
|
|
||||||
async def async_set_volume_level(self, volume: float) -> None:
|
async def async_set_volume_level(self, volume: float) -> None:
|
||||||
"""Set the volume level."""
|
"""Set the volume level."""
|
||||||
rvol = int(volume * 50.0)
|
rvol = int(volume * 50.0)
|
||||||
await self._russ.send_zone_event(self._zone_id, "KeyPress", "Volume", rvol)
|
await self._zone.send_event("KeyPress", "Volume", rvol)
|
||||||
|
|
||||||
async def async_select_source(self, source: str) -> None:
|
async def async_select_source(self, source: str) -> None:
|
||||||
"""Select the source input for this zone."""
|
"""Select the source input for this zone."""
|
||||||
for source_id, name in self._sources:
|
for source_id, src in self._sources.items():
|
||||||
if name.lower() != source.lower():
|
if src.name.lower() != source.lower():
|
||||||
continue
|
continue
|
||||||
await self._russ.send_zone_event(self._zone_id, "SelectSource", source_id)
|
await self._zone.send_event("SelectSource", source_id)
|
||||||
break
|
break
|
||||||
|
|
|
@ -350,7 +350,7 @@ aioridwell==2024.01.0
|
||||||
aioruckus==0.34
|
aioruckus==0.34
|
||||||
|
|
||||||
# homeassistant.components.russound_rio
|
# homeassistant.components.russound_rio
|
||||||
aiorussound==1.1.2
|
aiorussound==2.0.6
|
||||||
|
|
||||||
# homeassistant.components.ruuvi_gateway
|
# homeassistant.components.ruuvi_gateway
|
||||||
aioruuvigateway==0.1.0
|
aioruuvigateway==0.1.0
|
||||||
|
|
|
@ -329,7 +329,7 @@ aioridwell==2024.01.0
|
||||||
aioruckus==0.34
|
aioruckus==0.34
|
||||||
|
|
||||||
# homeassistant.components.russound_rio
|
# homeassistant.components.russound_rio
|
||||||
aiorussound==1.1.2
|
aiorussound==2.0.6
|
||||||
|
|
||||||
# homeassistant.components.ruuvi_gateway
|
# homeassistant.components.ruuvi_gateway
|
||||||
aioruuvigateway==0.1.0
|
aioruuvigateway==0.1.0
|
||||||
|
|
|
@ -8,7 +8,7 @@ import pytest
|
||||||
from homeassistant.components.russound_rio.const import DOMAIN
|
from homeassistant.components.russound_rio.const import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import HARDWARE_MAC, MOCK_CONFIG, MODEL
|
from .const import HARDWARE_MAC, MOCK_CONFIG, MOCK_CONTROLLERS, MODEL
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
@ -44,5 +44,5 @@ def mock_russound() -> Generator[AsyncMock]:
|
||||||
return_value=mock_client,
|
return_value=mock_client,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
mock_client.enumerate_controllers.return_value = [(1, HARDWARE_MAC, MODEL)]
|
mock_client.enumerate_controllers.return_value = MOCK_CONTROLLERS
|
||||||
yield mock_client
|
yield mock_client
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
"""Constants for russound_rio tests."""
|
"""Constants for russound_rio tests."""
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
HOST = "127.0.0.1"
|
HOST = "127.0.0.1"
|
||||||
PORT = 9621
|
PORT = 9621
|
||||||
MODEL = "MCA-C5"
|
MODEL = "MCA-C5"
|
||||||
|
@ -9,3 +11,6 @@ MOCK_CONFIG = {
|
||||||
"host": HOST,
|
"host": HOST,
|
||||||
"port": PORT,
|
"port": PORT,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_CONTROLLER = namedtuple("Controller", ["mac_address", "controller_type"])
|
||||||
|
MOCK_CONTROLLERS = {1: _CONTROLLER(mac_address=HARDWARE_MAC, controller_type=MODEL)}
|
||||||
|
|
|
@ -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 HARDWARE_MAC, MOCK_CONFIG, MODEL
|
from .const import MOCK_CONFIG, MOCK_CONTROLLERS, MODEL
|
||||||
|
|
||||||
|
|
||||||
async def test_form(
|
async def test_form(
|
||||||
|
@ -64,7 +64,7 @@ async def test_no_primary_controller(
|
||||||
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
|
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test we handle no primary controller error."""
|
"""Test we handle no primary controller error."""
|
||||||
mock_russound.enumerate_controllers.return_value = []
|
mock_russound.enumerate_controllers.return_value = {}
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_USER}
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
@ -79,7 +79,7 @@ async def test_no_primary_controller(
|
||||||
assert result["errors"] == {"base": "no_primary_controller"}
|
assert result["errors"] == {"base": "no_primary_controller"}
|
||||||
|
|
||||||
# Recover with correct information
|
# Recover with correct information
|
||||||
mock_russound.enumerate_controllers.return_value = [(1, HARDWARE_MAC, MODEL)]
|
mock_russound.enumerate_controllers.return_value = MOCK_CONTROLLERS
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
MOCK_CONFIG,
|
MOCK_CONFIG,
|
||||||
|
@ -125,7 +125,7 @@ async def test_import_no_primary_controller(
|
||||||
hass: HomeAssistant, mock_russound: AsyncMock
|
hass: HomeAssistant, mock_russound: AsyncMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test import with no primary controller error."""
|
"""Test import with no primary controller error."""
|
||||||
mock_russound.enumerate_controllers.return_value = []
|
mock_russound.enumerate_controllers.return_value = {}
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG
|
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue