Add multi-zone support to Anthem AV receiver and distribution solution (#74779)

* Add multi-zone support to Anthem AV receiver and distribution amplifier

* Fix typo in comment

* Convert properties to attribute and add test

* Migrate entity name

* Fix after rebase add strict typing and bump version

* fix typing

* Simplify test

* Small improvement

* remove dispatcher send and use callback
This commit is contained in:
Alex Henry 2022-07-31 00:04:24 +12:00 committed by GitHub
parent 8181da7090
commit ace359b1bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 200 additions and 104 deletions

View File

@ -56,6 +56,7 @@ homeassistant.components.ambee.*
homeassistant.components.ambient_station.* homeassistant.components.ambient_station.*
homeassistant.components.amcrest.* homeassistant.components.amcrest.*
homeassistant.components.ampio.* homeassistant.components.ampio.*
homeassistant.components.anthemav.*
homeassistant.components.aseko_pool_live.* homeassistant.components.aseko_pool_live.*
homeassistant.components.asuswrt.* homeassistant.components.asuswrt.*
homeassistant.components.automation.* homeassistant.components.automation.*

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import logging import logging
import anthemav import anthemav
from anthemav.device_error import DeviceError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
@ -11,7 +12,7 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import ANTHEMAV_UDATE_SIGNAL, DOMAIN from .const import ANTHEMAV_UDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS, DOMAIN
PLATFORMS = [Platform.MEDIA_PLAYER] PLATFORMS = [Platform.MEDIA_PLAYER]
@ -22,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Anthem A/V Receivers from a config entry.""" """Set up Anthem A/V Receivers from a config entry."""
@callback @callback
def async_anthemav_update_callback(message): def async_anthemav_update_callback(message: str) -> None:
"""Receive notification from transport that new data exists.""" """Receive notification from transport that new data exists."""
_LOGGER.debug("Received update callback from AVR: %s", message) _LOGGER.debug("Received update callback from AVR: %s", message)
async_dispatcher_send(hass, f"{ANTHEMAV_UDATE_SIGNAL}_{entry.entry_id}") async_dispatcher_send(hass, f"{ANTHEMAV_UDATE_SIGNAL}_{entry.entry_id}")
@ -34,7 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
update_callback=async_anthemav_update_callback, update_callback=async_anthemav_update_callback,
) )
except OSError as err: # Wait for the zones to be initialised based on the model
await avr.protocol.wait_for_device_initialised(DEVICE_TIMEOUT_SECONDS)
except (OSError, DeviceError) as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = avr hass.data.setdefault(DOMAIN, {})[entry.entry_id] = avr

View File

@ -15,9 +15,13 @@ from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from .const import CONF_MODEL, DEFAULT_NAME, DEFAULT_PORT, DOMAIN from .const import (
CONF_MODEL,
DEVICE_TIMEOUT_SECONDS = 4.0 DEFAULT_NAME,
DEFAULT_PORT,
DEVICE_TIMEOUT_SECONDS,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@ -5,3 +5,4 @@ DEFAULT_NAME = "Anthem AV"
DEFAULT_PORT = 14999 DEFAULT_PORT = 14999
DOMAIN = "anthemav" DOMAIN = "anthemav"
MANUFACTURER = "Anthem" MANUFACTURER = "Anthem"
DEVICE_TIMEOUT_SECONDS = 4.0

View File

@ -2,7 +2,7 @@
"domain": "anthemav", "domain": "anthemav",
"name": "Anthem A/V Receivers", "name": "Anthem A/V Receivers",
"documentation": "https://www.home-assistant.io/integrations/anthemav", "documentation": "https://www.home-assistant.io/integrations/anthemav",
"requirements": ["anthemav==1.3.2"], "requirements": ["anthemav==1.4.1"],
"dependencies": ["repairs"], "dependencies": ["repairs"],
"codeowners": ["@hyralex"], "codeowners": ["@hyralex"],
"config_flow": true, "config_flow": true,

View File

@ -2,13 +2,14 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
from anthemav.connection import Connection from anthemav.connection import Connection
from anthemav.protocol import AVR
import voluptuous as vol import voluptuous as vol
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
PLATFORM_SCHEMA, PLATFORM_SCHEMA,
MediaPlayerDeviceClass,
MediaPlayerEntity, MediaPlayerEntity,
MediaPlayerEntityFeature, MediaPlayerEntityFeature,
) )
@ -22,7 +23,7 @@ from homeassistant.const import (
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
@ -88,20 +89,28 @@ async def async_setup_entry(
mac_address = config_entry.data[CONF_MAC] mac_address = config_entry.data[CONF_MAC]
model = config_entry.data[CONF_MODEL] model = config_entry.data[CONF_MODEL]
avr = hass.data[DOMAIN][config_entry.entry_id] avr: Connection = hass.data[DOMAIN][config_entry.entry_id]
entity = AnthemAVR(avr, name, mac_address, model, config_entry.entry_id) entities = []
for zone_number in avr.protocol.zones:
_LOGGER.debug("Initializing Zone %s", zone_number)
entity = AnthemAVR(
avr.protocol, name, mac_address, model, zone_number, config_entry.entry_id
)
entities.append(entity)
_LOGGER.debug("Device data dump: %s", entity.dump_avrdata)
_LOGGER.debug("Connection data dump: %s", avr.dump_conndata) _LOGGER.debug("Connection data dump: %s", avr.dump_conndata)
async_add_entities([entity]) async_add_entities(entities)
class AnthemAVR(MediaPlayerEntity): class AnthemAVR(MediaPlayerEntity):
"""Entity reading values from Anthem AVR protocol.""" """Entity reading values from Anthem AVR protocol."""
_attr_has_entity_name = True
_attr_should_poll = False _attr_should_poll = False
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_icon = "mdi:audio-video"
_attr_supported_features = ( _attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_SET MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_MUTE
@ -111,23 +120,33 @@ class AnthemAVR(MediaPlayerEntity):
) )
def __init__( def __init__(
self, avr: Connection, name: str, mac_address: str, model: str, entry_id: str self,
avr: AVR,
name: str,
mac_address: str,
model: str,
zone_number: int,
entry_id: str,
) -> None: ) -> None:
"""Initialize entity with transport.""" """Initialize entity with transport."""
super().__init__() super().__init__()
self.avr = avr self.avr = avr
self._entry_id = entry_id self._entry_id = entry_id
self._attr_name = name self._zone_number = zone_number
self._attr_unique_id = mac_address self._zone = avr.zones[zone_number]
if zone_number > 1:
self._attr_name = f"zone {zone_number}"
self._attr_unique_id = f"{mac_address}_{zone_number}"
else:
self._attr_unique_id = mac_address
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac_address)}, identifiers={(DOMAIN, mac_address)},
name=name, name=name,
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=model, model=model,
) )
self.set_states()
def _lookup(self, propname: str, dval: Any | None = None) -> Any | None:
return getattr(self.avr.protocol, propname, dval)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""When entity is added to hass.""" """When entity is added to hass."""
@ -135,82 +154,42 @@ class AnthemAVR(MediaPlayerEntity):
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
f"{ANTHEMAV_UDATE_SIGNAL}_{self._entry_id}", f"{ANTHEMAV_UDATE_SIGNAL}_{self._entry_id}",
self.async_write_ha_state, self.update_states,
) )
) )
@property @callback
def state(self) -> str | None: def update_states(self) -> None:
"""Return state of power on/off.""" """Update states for the current zone."""
pwrstate = self._lookup("power") self.set_states()
self.async_write_ha_state()
if pwrstate is True: def set_states(self) -> None:
return STATE_ON """Set all the states from the device to the entity."""
if pwrstate is False: self._attr_state = STATE_ON if self._zone.power is True else STATE_OFF
return STATE_OFF self._attr_is_volume_muted = self._zone.mute
return None self._attr_volume_level = self._zone.volume_as_percentage
self._attr_media_title = self._zone.input_name
@property self._attr_app_name = self._zone.input_format
def is_volume_muted(self) -> bool | None: self._attr_source = self._zone.input_name
"""Return boolean reflecting mute state on device.""" self._attr_source_list = self.avr.input_list
return self._lookup("mute", False)
@property
def volume_level(self) -> float | None:
"""Return volume level from 0 to 1."""
return self._lookup("volume_as_percentage", 0.0)
@property
def media_title(self) -> str | None:
"""Return current input name (closest we have to media title)."""
return self._lookup("input_name", "No Source")
@property
def app_name(self) -> str | None:
"""Return details about current video and audio stream."""
return (
f"{self._lookup('video_input_resolution_text', '')} "
f"{self._lookup('audio_input_name', '')}"
)
@property
def source(self) -> str | None:
"""Return currently selected input."""
return self._lookup("input_name", "Unknown")
@property
def source_list(self) -> list[str] | None:
"""Return all active, configured inputs."""
return self._lookup("input_list", ["Unknown"])
async def async_select_source(self, source: str) -> None: async def async_select_source(self, source: str) -> None:
"""Change AVR to the designated source (by name).""" """Change AVR to the designated source (by name)."""
self._update_avr("input_name", source) self._zone.input_name = source
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Turn AVR power off.""" """Turn AVR power off."""
self._update_avr("power", False) self._zone.power = False
async def async_turn_on(self) -> None: async def async_turn_on(self) -> None:
"""Turn AVR power on.""" """Turn AVR power on."""
self._update_avr("power", True) self._zone.power = True
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Set AVR volume (0 to 1).""" """Set AVR volume (0 to 1)."""
self._update_avr("volume_as_percentage", volume) self._zone.volume_as_percentage = volume
async def async_mute_volume(self, mute: bool) -> None: async def async_mute_volume(self, mute: bool) -> None:
"""Engage AVR mute.""" """Engage AVR mute."""
self._update_avr("mute", mute) self._zone.mute = mute
def _update_avr(self, propname: str, value: Any | None) -> None:
"""Update a property in the AVR."""
_LOGGER.debug("Sending command to AVR: set %s to %s", propname, str(value))
setattr(self.avr.protocol, propname, value)
@property
def dump_avrdata(self):
"""Return state of avr object for debugging forensics."""
attrs = vars(self)
items_string = ", ".join(f"{item}: {item}" for item in attrs.items())
return f"dump_avrdata: {items_string}"

View File

@ -339,6 +339,17 @@ no_implicit_optional = true
warn_return_any = true warn_return_any = true
warn_unreachable = true warn_unreachable = true
[mypy-homeassistant.components.anthemav.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.aseko_pool_live.*] [mypy-homeassistant.components.aseko_pool_live.*]
check_untyped_defs = true check_untyped_defs = true
disallow_incomplete_defs = true disallow_incomplete_defs = true

View File

@ -313,7 +313,7 @@ androidtv[async]==0.0.67
anel_pwrctrl-homeassistant==0.0.1.dev2 anel_pwrctrl-homeassistant==0.0.1.dev2
# homeassistant.components.anthemav # homeassistant.components.anthemav
anthemav==1.3.2 anthemav==1.4.1
# homeassistant.components.apcupsd # homeassistant.components.apcupsd
apcaccess==0.0.13 apcaccess==0.0.13

View File

@ -279,7 +279,7 @@ ambiclimate==0.2.1
androidtv[async]==0.0.67 androidtv[async]==0.0.67
# homeassistant.components.anthemav # homeassistant.components.anthemav
anthemav==1.3.2 anthemav==1.4.1
# homeassistant.components.apprise # homeassistant.components.apprise
apprise==0.9.9 apprise==0.9.9

View File

@ -1,10 +1,12 @@
"""Fixtures for anthemav integration tests.""" """Fixtures for anthemav integration tests."""
from typing import Callable
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -16,17 +18,25 @@ def mock_anthemav() -> AsyncMock:
avr.protocol.macaddress = "000000000001" avr.protocol.macaddress = "000000000001"
avr.protocol.model = "MRX 520" avr.protocol.model = "MRX 520"
avr.reconnect = AsyncMock() avr.reconnect = AsyncMock()
avr.protocol.wait_for_device_initialised = AsyncMock()
avr.close = MagicMock() avr.close = MagicMock()
avr.protocol.input_list = [] avr.protocol.input_list = []
avr.protocol.audio_listening_mode_list = [] avr.protocol.audio_listening_mode_list = []
avr.protocol.power = False avr.protocol.zones = {1: get_zone(), 2: get_zone()}
return avr return avr
def get_zone() -> MagicMock:
"""Return a mocked zone."""
zone = MagicMock()
zone.power = False
return zone
@pytest.fixture @pytest.fixture
def mock_connection_create(mock_anthemav: AsyncMock) -> AsyncMock: def mock_connection_create(mock_anthemav: AsyncMock) -> AsyncMock:
"""Return the default mocked connection.create.""" """Return the default mocked connection.create."""
with patch( with patch(
"anthemav.Connection.create", "anthemav.Connection.create",
return_value=mock_anthemav, return_value=mock_anthemav,
@ -34,6 +44,12 @@ def mock_connection_create(mock_anthemav: AsyncMock) -> AsyncMock:
yield mock yield mock
@pytest.fixture
def update_callback(mock_connection_create: AsyncMock) -> Callable[[str], None]:
"""Return the update_callback used when creating the connection."""
return mock_connection_create.call_args[1]["update_callback"]
@pytest.fixture @pytest.fixture
def mock_config_entry() -> MockConfigEntry: def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry.""" """Return the default mocked config entry."""
@ -48,3 +64,18 @@ def mock_config_entry() -> MockConfigEntry:
}, },
unique_id="00:00:00:00:00:01", unique_id="00:00:00:00:00:01",
) )
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connection_create: AsyncMock,
) -> MockConfigEntry:
"""Set up the AnthemAv integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@ -1,6 +1,10 @@
"""Test the Anthem A/V Receivers config flow.""" """Test the Anthem A/V Receivers config flow."""
from typing import Callable
from unittest.mock import ANY, AsyncMock, patch from unittest.mock import ANY, AsyncMock, patch
from anthemav.device_error import DeviceError
import pytest
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -12,35 +16,31 @@ async def test_load_unload_config_entry(
hass: HomeAssistant, hass: HomeAssistant,
mock_connection_create: AsyncMock, mock_connection_create: AsyncMock,
mock_anthemav: AsyncMock, mock_anthemav: AsyncMock,
mock_config_entry: MockConfigEntry, init_integration: MockConfigEntry,
) -> None: ) -> None:
"""Test load and unload AnthemAv component.""" """Test load and unload AnthemAv component."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# assert avr is created # assert avr is created
mock_connection_create.assert_called_with( mock_connection_create.assert_called_with(
host="1.1.1.1", port=14999, update_callback=ANY host="1.1.1.1", port=14999, update_callback=ANY
) )
assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED assert init_integration.state == config_entries.ConfigEntryState.LOADED
# unload # unload
await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.config_entries.async_unload(init_integration.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
# assert unload and avr is closed # assert unload and avr is closed
assert mock_config_entry.state == config_entries.ConfigEntryState.NOT_LOADED assert init_integration.state == config_entries.ConfigEntryState.NOT_LOADED
mock_anthemav.close.assert_called_once() mock_anthemav.close.assert_called_once()
async def test_config_entry_not_ready( @pytest.mark.parametrize("error", [OSError, DeviceError])
hass: HomeAssistant, mock_config_entry: MockConfigEntry async def test_config_entry_not_ready_when_oserror(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, error: Exception
) -> None: ) -> None:
"""Test AnthemAV configuration entry not ready.""" """Test AnthemAV configuration entry not ready."""
with patch( with patch(
"anthemav.Connection.create", "anthemav.Connection.create",
side_effect=OSError, side_effect=error,
): ):
mock_config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(mock_config_entry.entry_id)
@ -52,23 +52,18 @@ async def test_anthemav_dispatcher_signal(
hass: HomeAssistant, hass: HomeAssistant,
mock_connection_create: AsyncMock, mock_connection_create: AsyncMock,
mock_anthemav: AsyncMock, mock_anthemav: AsyncMock,
mock_config_entry: MockConfigEntry, init_integration: MockConfigEntry,
update_callback: Callable[[str], None],
) -> None: ) -> None:
"""Test send update signal to dispatcher.""" """Test send update signal to dispatcher."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
states = hass.states.get("media_player.anthem_av") states = hass.states.get("media_player.anthem_av")
assert states assert states
assert states.state == STATE_OFF assert states.state == STATE_OFF
# change state of the AVR # change state of the AVR
mock_anthemav.protocol.power = True mock_anthemav.protocol.zones[1].power = True
# get the callback function that trigger the signal to update the state update_callback("power")
avr_update_callback = mock_connection_create.call_args[1]["update_callback"]
avr_update_callback("power")
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -0,0 +1,71 @@
"""Test the Anthem A/V Receivers config flow."""
from typing import Callable
from unittest.mock import AsyncMock
import pytest
from homeassistant.components.media_player.const import (
ATTR_APP_NAME,
ATTR_INPUT_SOURCE,
ATTR_INPUT_SOURCE_LIST,
ATTR_MEDIA_TITLE,
ATTR_MEDIA_VOLUME_MUTED,
)
from homeassistant.components.siren.const import ATTR_VOLUME_LEVEL
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
"entity_id,entity_name",
[
("media_player.anthem_av", "Anthem AV"),
("media_player.anthem_av_zone_2", "Anthem AV zone 2"),
],
)
async def test_zones_loaded(
hass: HomeAssistant,
init_integration: MockConfigEntry,
entity_id: str,
entity_name: str,
) -> None:
"""Test zones are loaded."""
states = hass.states.get(entity_id)
assert states
assert states.state == STATE_OFF
assert states.name == entity_name
async def test_update_states_zone1(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_anthemav: AsyncMock,
update_callback: Callable[[str], None],
) -> None:
"""Test zone states are updated."""
mock_zone = mock_anthemav.protocol.zones[1]
mock_zone.power = True
mock_zone.mute = True
mock_zone.volume_as_percentage = 42
mock_zone.input_name = "TEST INPUT"
mock_zone.input_format = "2.0 PCM"
mock_anthemav.protocol.input_list = ["TEST INPUT", "INPUT 2"]
update_callback("command")
await hass.async_block_till_done()
states = hass.states.get("media_player.anthem_av")
assert states
assert states.state == STATE_ON
assert states.attributes[ATTR_VOLUME_LEVEL] == 42
assert states.attributes[ATTR_MEDIA_VOLUME_MUTED] is True
assert states.attributes[ATTR_INPUT_SOURCE] == "TEST INPUT"
assert states.attributes[ATTR_MEDIA_TITLE] == "TEST INPUT"
assert states.attributes[ATTR_APP_NAME] == "2.0 PCM"
assert states.attributes[ATTR_INPUT_SOURCE_LIST] == ["TEST INPUT", "INPUT 2"]