diff --git a/.strict-typing b/.strict-typing index be25c50ae38..74a255a7f96 100644 --- a/.strict-typing +++ b/.strict-typing @@ -58,6 +58,11 @@ homeassistant.components.canary.* homeassistant.components.cover.* homeassistant.components.crownstone.* homeassistant.components.cpuspeed.* +homeassistant.components.deconz +homeassistant.components.deconz.config_flow +homeassistant.components.deconz.diagnostics +homeassistant.components.deconz.gateway +homeassistant.components.deconz.services homeassistant.components.device_automation.* homeassistant.components.device_tracker.* homeassistant.components.devolo_home_control.* diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index f069605d438..112f29db333 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -12,11 +12,14 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady import homeassistant.helpers.entity_registry as er from .config_flow import get_master_gateway -from .const import CONF_GROUP_ID_BASE, CONF_MASTER_GATEWAY, DOMAIN -from .gateway import DeconzGateway +from .const import CONF_GROUP_ID_BASE, CONF_MASTER_GATEWAY, DOMAIN, PLATFORMS +from .deconz_event import async_setup_events, async_unload_events +from .errors import AuthenticationRequired, CannotConnect +from .gateway import DeconzGateway, get_deconz_session from .services import async_setup_services, async_unload_services @@ -33,16 +36,27 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if not config_entry.options: await async_update_master_gateway(hass, config_entry) - gateway = DeconzGateway(hass, config_entry) - if not await gateway.async_setup(): - return False + try: + api = await get_deconz_session(hass, config_entry.data) + except CannotConnect as err: + raise ConfigEntryNotReady from err + except AuthenticationRequired as err: + raise ConfigEntryAuthFailed from err - if not hass.data[DOMAIN]: + gateway = hass.data[DOMAIN][config_entry.entry_id] = DeconzGateway( + hass, config_entry, api + ) + + config_entry.add_update_listener(gateway.async_config_entry_updated) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + + await async_setup_events(gateway) + await gateway.async_update_device_registry() + + if len(hass.data[DOMAIN]) == 1: async_setup_services(hass) - hass.data[DOMAIN][config_entry.entry_id] = gateway - - await gateway.async_update_device_registry() + api.start() config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gateway.shutdown) @@ -53,7 +67,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload deCONZ config entry.""" - gateway = hass.data[DOMAIN].pop(config_entry.entry_id) + gateway: DeconzGateway = hass.data[DOMAIN].pop(config_entry.entry_id) + async_unload_events(gateway) if not hass.data[DOMAIN]: async_unload_services(hass) @@ -89,9 +104,10 @@ async def async_update_group_unique_id( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: """Update unique ID entities based on deCONZ groups.""" - if not isinstance(old_unique_id := config_entry.data.get(CONF_GROUP_ID_BASE), str): + if not (group_id_base := config_entry.data.get(CONF_GROUP_ID_BASE)): return + old_unique_id = cast(str, group_id_base) new_unique_id = cast(str, config_entry.unique_id) @callback diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index d197f27910b..3bd0278a27a 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -1,13 +1,21 @@ """Representation of a deCONZ gateway.""" + +from __future__ import annotations + import asyncio +from types import MappingProxyType +from typing import Any, cast import async_timeout from pydeconz import DeconzSession, errors, group, light, sensor +from pydeconz.alarm_system import AlarmSystem as DeconzAlarmSystem +from pydeconz.group import Group as DeconzGroup +from pydeconz.light import DeconzLight +from pydeconz.sensor import DeconzSensor from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( aiohttp_client, device_registry as dr, @@ -29,25 +37,23 @@ from .const import ( LOGGER, PLATFORMS, ) -from .deconz_event import async_setup_events, async_unload_events +from .deconz_event import DeconzAlarmEvent, DeconzEvent from .errors import AuthenticationRequired, CannotConnect -@callback -def get_gateway_from_config_entry(hass, config_entry): - """Return gateway with a matching config entry ID.""" - return hass.data[DECONZ_DOMAIN][config_entry.entry_id] - - class DeconzGateway: """Manages a single deCONZ gateway.""" - def __init__(self, hass, config_entry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: DeconzSession + ) -> None: """Initialize the system.""" self.hass = hass self.config_entry = config_entry + self.api = api - self.api = None + api.add_device_callback = self.async_add_device_callback + api.connection_status_callback = self.async_connection_status_callback self.available = True self.ignore_state_updates = False @@ -66,24 +72,24 @@ class DeconzGateway: sensor.RESOURCE_TYPE: self.signal_new_sensor, } - self.deconz_ids = {} - self.entities = {} - self.events = [] + self.deconz_ids: dict[str, str] = {} + self.entities: dict[str, set[str]] = {} + self.events: list[DeconzAlarmEvent | DeconzEvent] = [] @property def bridgeid(self) -> str: """Return the unique identifier of the gateway.""" - return self.config_entry.unique_id + return cast(str, self.config_entry.unique_id) @property def host(self) -> str: """Return the host of the gateway.""" - return self.config_entry.data[CONF_HOST] + return cast(str, self.config_entry.data[CONF_HOST]) @property def master(self) -> bool: """Gateway which is used with deCONZ services without defining id.""" - return self.config_entry.options[CONF_MASTER_GATEWAY] + return cast(bool, self.config_entry.options[CONF_MASTER_GATEWAY]) # Options @@ -111,7 +117,7 @@ class DeconzGateway: # Callbacks @callback - def async_connection_status_callback(self, available) -> None: + def async_connection_status_callback(self, available: bool) -> None: """Handle signals of gateway connection status.""" self.available = available self.ignore_state_updates = False @@ -119,7 +125,15 @@ class DeconzGateway: @callback def async_add_device_callback( - self, resource_type, device=None, force: bool = False + self, + resource_type: str, + device: DeconzAlarmSystem + | DeconzGroup + | DeconzLight + | DeconzSensor + | list[DeconzAlarmSystem | DeconzGroup | DeconzLight | DeconzSensor] + | None = None, + force: bool = False, ) -> None: """Handle event of new device creation in deCONZ.""" if ( @@ -166,32 +180,6 @@ class DeconzGateway: via_device=(CONNECTION_NETWORK_MAC, self.api.config.mac), ) - async def async_setup(self) -> bool: - """Set up a deCONZ gateway.""" - try: - self.api = await get_gateway( - self.hass, - self.config_entry.data, - self.async_add_device_callback, - self.async_connection_status_callback, - ) - - except CannotConnect as err: - raise ConfigEntryNotReady from err - - except AuthenticationRequired as err: - raise ConfigEntryAuthFailed from err - - self.hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) - - await async_setup_events(self) - - self.api.start() - - self.config_entry.add_update_listener(self.async_config_entry_updated) - - return True - @staticmethod async def async_config_entry_updated( hass: HomeAssistant, entry: ConfigEntry @@ -211,7 +199,7 @@ class DeconzGateway: await gateway.options_updated() - async def options_updated(self): + async def options_updated(self) -> None: """Manage entities affected by config entry options.""" deconz_ids = [] @@ -242,14 +230,14 @@ class DeconzGateway: entity_registry.async_remove(entity_id) @callback - def shutdown(self, event) -> None: + def shutdown(self, event: Event) -> None: """Wrap the call to deconz.close. Used as an argument to EventBus.async_listen_once. """ self.api.close() - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this gateway to default state.""" self.api.async_connection_status_callback = None self.api.close() @@ -258,30 +246,35 @@ class DeconzGateway: self.config_entry, PLATFORMS ) - async_unload_events(self) - self.deconz_ids = {} return True -async def get_gateway( - hass, config, async_add_device_callback, async_connection_status_callback +@callback +def get_gateway_from_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> DeconzGateway: + """Return gateway with a matching config entry ID.""" + return cast(DeconzGateway, hass.data[DECONZ_DOMAIN][config_entry.entry_id]) + + +async def get_deconz_session( + hass: HomeAssistant, + config: MappingProxyType[str, Any], ) -> DeconzSession: """Create a gateway object and verify configuration.""" session = aiohttp_client.async_get_clientsession(hass) - deconz = DeconzSession( + deconz_session = DeconzSession( session, config[CONF_HOST], config[CONF_PORT], config[CONF_API_KEY], - add_device=async_add_device_callback, - connection_status=async_connection_status_callback, ) try: async with async_timeout.timeout(10): - await deconz.refresh_state() - return deconz + await deconz_session.refresh_state() + return deconz_session except errors.Unauthorized as err: LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index aeb528c0ac9..4b840532fa6 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,7 +1,5 @@ """deCONZ services.""" -from types import MappingProxyType - from pydeconz.utils import normalize_bridge_id import voluptuous as vol @@ -16,6 +14,7 @@ from homeassistant.helpers.entity_registry import ( async_entries_for_config_entry, async_entries_for_device, ) +from homeassistant.util.read_only_dict import ReadOnlyDict from .config_flow import get_master_gateway from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER @@ -111,9 +110,7 @@ def async_unload_services(hass: HomeAssistant) -> None: hass.services.async_remove(DOMAIN, service) -async def async_configure_service( - gateway: DeconzGateway, data: MappingProxyType -) -> None: +async def async_configure_service(gateway: DeconzGateway, data: ReadOnlyDict) -> None: """Set attribute of device in deCONZ. Entity is used to resolve to a device path (e.g. '/lights/1'). diff --git a/mypy.ini b/mypy.ini index b508045d623..adb0cb25297 100644 --- a/mypy.ini +++ b/mypy.ini @@ -447,6 +447,61 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.deconz] +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.deconz.config_flow] +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.deconz.diagnostics] +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.deconz.gateway] +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.deconz.services] +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.device_automation.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2209,9 +2264,6 @@ ignore_errors = true [mypy-homeassistant.components.conversation.default_agent] ignore_errors = true -[mypy-homeassistant.components.deconz] -ignore_errors = true - [mypy-homeassistant.components.deconz.alarm_control_panel] ignore_errors = true @@ -2227,9 +2279,6 @@ ignore_errors = true [mypy-homeassistant.components.deconz.fan] ignore_errors = true -[mypy-homeassistant.components.deconz.gateway] -ignore_errors = true - [mypy-homeassistant.components.deconz.light] ignore_errors = true @@ -2245,9 +2294,6 @@ ignore_errors = true [mypy-homeassistant.components.deconz.sensor] ignore_errors = true -[mypy-homeassistant.components.deconz.services] -ignore_errors = true - [mypy-homeassistant.components.deconz.siren] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 8842b42fbd3..8d542290458 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -23,19 +23,16 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.cloud.http_api", "homeassistant.components.conversation", "homeassistant.components.conversation.default_agent", - "homeassistant.components.deconz", "homeassistant.components.deconz.alarm_control_panel", "homeassistant.components.deconz.binary_sensor", "homeassistant.components.deconz.climate", "homeassistant.components.deconz.cover", "homeassistant.components.deconz.fan", - "homeassistant.components.deconz.gateway", "homeassistant.components.deconz.light", "homeassistant.components.deconz.lock", "homeassistant.components.deconz.logbook", "homeassistant.components.deconz.number", "homeassistant.components.deconz.sensor", - "homeassistant.components.deconz.services", "homeassistant.components.deconz.siren", "homeassistant.components.deconz.switch", "homeassistant.components.denonavr.config_flow", diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 8a449456fde..3a4f6b907af 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -1,7 +1,8 @@ """Test deCONZ gateway.""" +import asyncio from copy import deepcopy -from unittest.mock import Mock, patch +from unittest.mock import patch import pydeconz from pydeconz.websocket import STATE_RETRYING, STATE_RUNNING @@ -19,7 +20,7 @@ from homeassistant.components.deconz.config_flow import DECONZ_MANUFACTURERURL from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN from homeassistant.components.deconz.errors import AuthenticationRequired, CannotConnect from homeassistant.components.deconz.gateway import ( - get_gateway, + get_deconz_session, get_gateway_from_config_entry, ) from homeassistant.components.fan import DOMAIN as FAN_DOMAIN @@ -202,25 +203,6 @@ async def test_gateway_device_configuration_url_when_addon(hass, aioclient_mock) ) -async def test_gateway_retry(hass): - """Retry setup.""" - with patch( - "homeassistant.components.deconz.gateway.get_gateway", - side_effect=CannotConnect, - ): - await setup_deconz_integration(hass) - assert not hass.data[DECONZ_DOMAIN] - - -async def test_gateway_setup_fails(hass): - """Retry setup.""" - with patch( - "homeassistant.components.deconz.gateway.get_gateway", side_effect=Exception - ): - await setup_deconz_integration(hass) - assert not hass.data[DECONZ_DOMAIN] - - async def test_connection_status_signalling( hass, aioclient_mock, mock_deconz_websocket ): @@ -282,18 +264,6 @@ async def test_update_address(hass, aioclient_mock): assert len(mock_setup_entry.mock_calls) == 1 -async def test_gateway_trigger_reauth_flow(hass): - """Failed authentication trigger a reauthentication flow.""" - with patch( - "homeassistant.components.deconz.gateway.get_gateway", - side_effect=AuthenticationRequired, - ), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: - await setup_deconz_integration(hass) - mock_flow_init.assert_called_once() - - assert hass.data[DECONZ_DOMAIN] == {} - - async def test_reset_after_successful_setup(hass, aioclient_mock): """Make sure that connection status triggers a dispatcher send.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -305,25 +275,24 @@ async def test_reset_after_successful_setup(hass, aioclient_mock): assert result is True -async def test_get_gateway(hass): +async def test_get_deconz_session(hass): """Successful call.""" with patch("pydeconz.DeconzSession.refresh_state", return_value=True): - assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) + assert await get_deconz_session(hass, ENTRY_CONFIG) -async def test_get_gateway_fails_unauthorized(hass): +@pytest.mark.parametrize( + "side_effect, raised_exception", + [ + (asyncio.TimeoutError, CannotConnect), + (pydeconz.RequestError, CannotConnect), + (pydeconz.Unauthorized, AuthenticationRequired), + ], +) +async def test_get_deconz_session_fails(hass, side_effect, raised_exception): """Failed call.""" with patch( "pydeconz.DeconzSession.refresh_state", - side_effect=pydeconz.errors.Unauthorized, - ), pytest.raises(AuthenticationRequired): - assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) is False - - -async def test_get_gateway_fails_cannot_connect(hass): - """Failed call.""" - with patch( - "pydeconz.DeconzSession.refresh_state", - side_effect=pydeconz.errors.RequestError, - ), pytest.raises(CannotConnect): - assert await get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) is False + side_effect=side_effect, + ), pytest.raises(raised_exception): + assert await get_deconz_session(hass, ENTRY_CONFIG) diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index e50ac41d63d..05fb708b75f 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -1,6 +1,5 @@ """Test deCONZ component setup process.""" -import asyncio from unittest.mock import patch from homeassistant.components.deconz import ( @@ -13,6 +12,7 @@ from homeassistant.components.deconz.const import ( CONF_GROUP_ID_BASE, DOMAIN as DECONZ_DOMAIN, ) +from homeassistant.components.deconz.errors import AuthenticationRequired, CannotConnect from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.helpers import entity_registry as er @@ -42,22 +42,6 @@ async def setup_entry(hass, entry): assert await async_setup_entry(hass, entry) is True -async def test_setup_entry_fails(hass): - """Test setup entry fails if deCONZ is not available.""" - with patch("pydeconz.DeconzSession.refresh_state", side_effect=Exception): - await setup_deconz_integration(hass) - assert not hass.data[DECONZ_DOMAIN] - - -async def test_setup_entry_no_available_bridge(hass): - """Test setup entry fails if deCONZ is not available.""" - with patch( - "pydeconz.DeconzSession.refresh_state", side_effect=asyncio.TimeoutError - ): - await setup_deconz_integration(hass) - assert not hass.data[DECONZ_DOMAIN] - - async def test_setup_entry_successful(hass, aioclient_mock): """Test setup entry is successful.""" config_entry = await setup_deconz_integration(hass, aioclient_mock) @@ -67,6 +51,29 @@ async def test_setup_entry_successful(hass, aioclient_mock): assert hass.data[DECONZ_DOMAIN][config_entry.entry_id].master +async def test_setup_entry_fails_config_entry_not_ready(hass): + """Failed authentication trigger a reauthentication flow.""" + with patch( + "homeassistant.components.deconz.get_deconz_session", + side_effect=CannotConnect, + ): + await setup_deconz_integration(hass) + + assert hass.data[DECONZ_DOMAIN] == {} + + +async def test_setup_entry_fails_trigger_reauth_flow(hass): + """Failed authentication trigger a reauthentication flow.""" + with patch( + "homeassistant.components.deconz.get_deconz_session", + side_effect=AuthenticationRequired, + ), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: + await setup_deconz_integration(hass) + mock_flow_init.assert_called_once() + + assert hass.data[DECONZ_DOMAIN] == {} + + async def test_setup_entry_multiple_gateways(hass, aioclient_mock): """Test setup entry is successful with multiple gateways.""" config_entry = await setup_deconz_integration(hass, aioclient_mock)