Skip to content
Snippets Groups Projects
Unverified Commit f5c6a6be authored by Alex Henry's avatar Alex Henry Committed by GitHub
Browse files

Add config flow to AnthemAV integration (#53268)

* Add config flow to AnthemAV integration

* Add importing of existing configuration

* Change setting to optional and add default value

* Use entity attribute

* Reduce changes by removing additional media player properties

* Remove title from translation

* Refactor config flow and fix PR comments

* Fix a failing test because of wrong renaming

* Add typing and use existing class and enum

* Bump dependency to v1.3.1

* Remove unecessary async_reload_entry

* Fix requirements_test_all after rebase

* Add const for timeout and remove async_block in test

* Reapply CodeOwner and configflow after rebase

* Remove name from configflow

* Fix manifest prettier failure

* Simplify code and avoid catching broad exception

* Removed unused strings and translations

* Avoid asserting hass.data
parent 57fd84e2
No related branches found
No related tags found
No related merge requests found
Showing
with 504 additions and 51 deletions
......@@ -74,6 +74,8 @@ build.json @home-assistant/supervisor
/tests/components/analytics/ @home-assistant/core @ludeeus
/homeassistant/components/androidtv/ @JeffLIrion @ollo69
/tests/components/androidtv/ @JeffLIrion @ollo69
/homeassistant/components/anthemav/ @hyralex
/tests/components/anthemav/ @hyralex
/homeassistant/components/apache_kafka/ @bachya
/tests/components/apache_kafka/ @bachya
/homeassistant/components/api/ @home-assistant/core
......
"""The anthemav component."""
"""The Anthem A/V Receivers integration."""
from __future__ import annotations
import logging
import anthemav
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import ANTHEMAV_UDATE_SIGNAL, DOMAIN
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Anthem A/V Receivers from a config entry."""
@callback
def async_anthemav_update_callback(message):
"""Receive notification from transport that new data exists."""
_LOGGER.debug("Received update callback from AVR: %s", message)
async_dispatcher_send(hass, f"{ANTHEMAV_UDATE_SIGNAL}_{entry.data[CONF_NAME]}")
try:
avr = await anthemav.Connection.create(
host=entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
update_callback=async_anthemav_update_callback,
)
except OSError as err:
raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = avr
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
avr = hass.data[DOMAIN][entry.entry_id]
if avr is not None:
_LOGGER.debug("Close avr connection")
avr.close()
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
"""Config flow for Anthem A/V Receivers integration."""
from __future__ import annotations
import logging
from typing import Any
import anthemav
from anthemav.connection import Connection
from anthemav.device_error import DeviceError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import CONF_MODEL, DEFAULT_NAME, DEFAULT_PORT, DOMAIN
DEVICE_TIMEOUT_SECONDS = 4.0
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
)
async def connect_device(user_input: dict[str, Any]) -> Connection:
"""Connect to the AVR device."""
avr = await anthemav.Connection.create(
host=user_input[CONF_HOST], port=user_input[CONF_PORT], auto_reconnect=False
)
await avr.reconnect()
await avr.protocol.wait_for_device_initialised(DEVICE_TIMEOUT_SECONDS)
return avr
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthem A/V Receivers."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
if CONF_NAME not in user_input:
user_input[CONF_NAME] = DEFAULT_NAME
errors = {}
avr: Connection | None = None
try:
avr = await connect_device(user_input)
except OSError:
_LOGGER.error(
"Couldn't establish connection to %s:%s",
user_input[CONF_HOST],
user_input[CONF_PORT],
)
errors["base"] = "cannot_connect"
except DeviceError:
_LOGGER.error(
"Couldn't receive device information from %s:%s",
user_input[CONF_HOST],
user_input[CONF_PORT],
)
errors["base"] = "cannot_receive_deviceinfo"
else:
user_input[CONF_MAC] = format_mac(avr.protocol.macaddress)
user_input[CONF_MODEL] = avr.protocol.model
await self.async_set_unique_id(user_input[CONF_MAC])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
finally:
if avr is not None:
avr.close()
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_import(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Import a config entry from configuration.yaml."""
return await self.async_step_user(user_input)
"""Constants for the Anthem A/V Receivers integration."""
ANTHEMAV_UDATE_SIGNAL = "anthemav_update"
CONF_MODEL = "model"
DEFAULT_NAME = "Anthem AV"
DEFAULT_PORT = 14999
DOMAIN = "anthemav"
MANUFACTURER = "Anthem"
......@@ -2,8 +2,9 @@
"domain": "anthemav",
"name": "Anthem A/V Receivers",
"documentation": "https://www.home-assistant.io/integrations/anthemav",
"requirements": ["anthemav==1.2.0"],
"codeowners": [],
"requirements": ["anthemav==1.3.2"],
"codeowners": ["@hyralex"],
"config_flow": true,
"iot_class": "local_push",
"loggers": ["anthemav"]
}
......@@ -2,8 +2,9 @@
from __future__ import annotations
import logging
from typing import Any
import anthemav
from anthemav.connection import Connection
import voluptuous as vol
from homeassistant.components.media_player import (
......@@ -11,33 +12,37 @@ from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_NAME,
CONF_PORT,
EVENT_HOMEASSISTANT_STOP,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "anthemav"
from .const import (
ANTHEMAV_UDATE_SIGNAL,
CONF_MODEL,
DEFAULT_NAME,
DEFAULT_PORT,
DOMAIN,
MANUFACTURER,
)
DEFAULT_PORT = 14999
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
)
......@@ -50,30 +55,33 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up our socket to the AVR."""
_LOGGER.warning(
"AnthemAV configuration is deprecated and has been automatically imported. Please remove the integration from your configuration file"
)
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
host = config[CONF_HOST]
port = config[CONF_PORT]
name = config.get(CONF_NAME)
device = None
_LOGGER.info("Provisioning Anthem AVR device at %s:%d", host, port)
@callback
def async_anthemav_update_callback(message):
"""Receive notification from transport that new data exists."""
_LOGGER.debug("Received update callback from AVR: %s", message)
async_dispatcher_send(hass, DOMAIN)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up entry."""
name = config_entry.data[CONF_NAME]
macaddress = config_entry.data[CONF_MAC]
model = config_entry.data[CONF_MODEL]
avr = await anthemav.Connection.create(
host=host, port=port, update_callback=async_anthemav_update_callback
)
avr = hass.data[DOMAIN][config_entry.entry_id]
device = AnthemAVR(avr, name)
device = AnthemAVR(avr, name, macaddress, model)
_LOGGER.debug("dump_devicedata: %s", device.dump_avrdata)
_LOGGER.debug("dump_conndata: %s", avr.dump_conndata)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.avr.close)
async_add_entities([device])
......@@ -89,23 +97,34 @@ class AnthemAVR(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def __init__(self, avr, name):
def __init__(self, avr: Connection, name: str, macaddress: str, model: str) -> None:
"""Initialize entity with transport."""
super().__init__()
self.avr = avr
self._attr_name = name or self._lookup("model")
self._attr_name = name
self._attr_unique_id = macaddress
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, macaddress)},
name=name,
manufacturer=MANUFACTURER,
model=model,
)
def _lookup(self, propname, dval=None):
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):
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
self.async_on_remove(
async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state)
async_dispatcher_connect(
self.hass,
f"{ANTHEMAV_UDATE_SIGNAL}_{self._attr_name}",
self.async_write_ha_state,
)
)
@property
def state(self):
def state(self) -> str | None:
"""Return state of power on/off."""
pwrstate = self._lookup("power")
......@@ -116,22 +135,22 @@ class AnthemAVR(MediaPlayerEntity):
return None
@property
def is_volume_muted(self):
def is_volume_muted(self) -> bool | None:
"""Return boolean reflecting mute state on device."""
return self._lookup("mute", False)
@property
def volume_level(self):
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):
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):
def app_name(self) -> str | None:
"""Return details about current video and audio stream."""
return (
f"{self._lookup('video_input_resolution_text', '')} "
......@@ -139,38 +158,38 @@ class AnthemAVR(MediaPlayerEntity):
)
@property
def source(self):
def source(self) -> str | None:
"""Return currently selected input."""
return self._lookup("input_name", "Unknown")
@property
def source_list(self):
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):
async def async_select_source(self, source: str) -> None:
"""Change AVR to the designated source (by name)."""
self._update_avr("input_name", source)
async def async_turn_off(self):
async def async_turn_off(self) -> None:
"""Turn AVR power off."""
self._update_avr("power", False)
async def async_turn_on(self):
async def async_turn_on(self) -> None:
"""Turn AVR power on."""
self._update_avr("power", True)
async def async_set_volume_level(self, volume):
async def async_set_volume_level(self, volume: float) -> None:
"""Set AVR volume (0 to 1)."""
self._update_avr("volume_as_percentage", volume)
async def async_mute_volume(self, mute):
async def async_mute_volume(self, mute: bool) -> None:
"""Engage AVR mute."""
self._update_avr("mute", mute)
def _update_avr(self, propname, value):
def _update_avr(self, propname: str, value: Any | None) -> None:
"""Update a property in the AVR."""
_LOGGER.info("Sending command to AVR: set %s to %s", propname, str(value))
_LOGGER.debug("Sending command to AVR: set %s to %s", propname, str(value))
setattr(self.avr.protocol, propname, value)
@property
......
{
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"cannot_receive_deviceinfo": "Failed to retreive MAC Address. Make sure the device is turned on"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
{
"config": {
"abort": {
"already_configured": "Device is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"cannot_receive_deviceinfo": "Failed to retreive MAC Address. Make sure the device is turned on"
},
"step": {
"user": {
"data": {
"host": "Host",
"port": "Port"
}
}
}
}
}
\ No newline at end of file
{
"config": {
"abort": {
"already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9"
},
"error": {
"cannot_connect": "\u00c9chec de connexion",
"cannot_receive_deviceinfo": "Erreur lors de la d\u00e9couverte de l'addresse MAC. V\u00e9rifiez que l'appareil est allum\u00e9."
},
"step": {
"user": {
"data": {
"host": "H\u00f4te",
"port": "Port"
}
}
}
}
}
\ No newline at end of file
......@@ -29,6 +29,7 @@ FLOWS = {
"ambiclimate",
"ambient_station",
"androidtv",
"anthemav",
"apple_tv",
"arcam_fmj",
"aseko_pool_live",
......
......@@ -310,7 +310,7 @@ androidtv[async]==0.0.67
anel_pwrctrl-homeassistant==0.0.1.dev2
# homeassistant.components.anthemav
anthemav==1.2.0
anthemav==1.3.2
# homeassistant.components.apcupsd
apcaccess==0.0.13
......
......@@ -269,6 +269,9 @@ ambiclimate==0.2.1
# homeassistant.components.androidtv
androidtv[async]==0.0.67
# homeassistant.components.anthemav
anthemav==1.3.2
# homeassistant.components.apprise
apprise==0.9.9
......
"""Tests for the Anthem A/V Receivers integration."""
"""Fixtures for anthemav integration tests."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def mock_anthemav() -> AsyncMock:
"""Return the default mocked anthemav."""
avr = AsyncMock()
avr.protocol.macaddress = "000000000001"
avr.protocol.model = "MRX 520"
avr.reconnect = AsyncMock()
avr.close = MagicMock()
avr.protocol.input_list = []
avr.protocol.audio_listening_mode_list = []
return avr
@pytest.fixture
def mock_connection_create(mock_anthemav: AsyncMock) -> AsyncMock:
"""Return the default mocked connection.create."""
with patch(
"anthemav.Connection.create",
return_value=mock_anthemav,
) as mock:
yield mock
"""Test the Anthem A/V Receivers config flow."""
from unittest.mock import AsyncMock, patch
from anthemav.device_error import DeviceError
from homeassistant import config_entries
from homeassistant.components.anthemav.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
async def test_form_with_valid_connection(
hass: HomeAssistant, mock_connection_create: AsyncMock, mock_anthemav: AsyncMock
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] is None
with patch(
"homeassistant.components.anthemav.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"port": 14999,
},
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
assert result2["data"] == {
"host": "1.1.1.1",
"port": 14999,
"name": "Anthem AV",
"mac": "00:00:00:00:00:01",
"model": "MRX 520",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_device_info_error(hass: HomeAssistant) -> None:
"""Test we handle DeviceError from library."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"anthemav.Connection.create",
side_effect=DeviceError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"port": 14999,
},
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_receive_deviceinfo"}
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"anthemav.Connection.create",
side_effect=OSError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": "1.1.1.1",
"port": 14999,
},
)
await hass.async_block_till_done()
assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_import_configuration(
hass: HomeAssistant, mock_connection_create: AsyncMock, mock_anthemav: AsyncMock
) -> None:
"""Test we import existing configuration."""
config = {
"host": "1.1.1.1",
"port": 14999,
"name": "Anthem Av Import",
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
"host": "1.1.1.1",
"port": 14999,
"name": "Anthem Av Import",
"mac": "00:00:00:00:00:01",
"model": "MRX 520",
}
"""Test the Anthem A/V Receivers config flow."""
from unittest.mock import ANY, AsyncMock, patch
from homeassistant import config_entries
from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant, mock_connection_create: AsyncMock, mock_anthemav: AsyncMock
) -> None:
"""Test load and unload AnthemAv component."""
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "1.1.1.1",
CONF_PORT: 14999,
CONF_NAME: "Anthem AV",
CONF_MAC: "aabbccddeeff",
CONF_MODEL: "MRX 520",
},
)
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
mock_connection_create.assert_called_with(
host="1.1.1.1", port=14999, update_callback=ANY
)
assert mock_config_entry.state == config_entries.ConfigEntryState.LOADED
# unload
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# assert unload and avr is closed
assert mock_config_entry.state == config_entries.ConfigEntryState.NOT_LOADED
mock_anthemav.close.assert_called_once()
async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
"""Test AnthemAV configuration entry not ready."""
mock_config_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "1.1.1.1",
CONF_PORT: 14999,
CONF_NAME: "Anthem AV",
CONF_MAC: "aabbccddeeff",
CONF_MODEL: "MRX 520",
},
)
with patch(
"anthemav.Connection.create",
side_effect=OSError,
):
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 mock_config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment