diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 8289b2bf0539..b1d591190ff0 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -29,16 +29,7 @@ from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from . import api -from .const import ( - DATA_CAMERAS, - DATA_DEVICE_IDS, - DATA_EVENTS, - DATA_HOMES, - DATA_PERSONS, - DATA_SCHEDULES, - DOMAIN, - PLATFORMS, -) +from .const import DOMAIN, PLATFORMS from .data_handler import NetatmoConfigEntry, NetatmoDataHandler from .services import async_setup_services from .webhook import async_register_webhook, async_unregister_webhook @@ -52,17 +43,6 @@ MAX_WEBHOOK_RETRIES = 3 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Netatmo component.""" - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - hass.data[DOMAIN] = { - DATA_PERSONS: {}, - DATA_DEVICE_IDS: {}, - DATA_SCHEDULES: {}, - DATA_HOMES: {}, - DATA_EVENTS: {}, - DATA_CAMERAS: {}, - } - async_setup_services(hass) return True diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index b1dc17f4f5d9..51c14e0b69d3 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -1,5 +1,4 @@ """Support for the Netatmo cameras.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast, override @@ -24,8 +23,6 @@ from .const import ( CAMERA_LIGHT_MODES, CAMERA_TRIGGERS, CONF_URL_SECURITY, - DATA_CAMERAS, - DATA_EVENTS, DOMAIN, EVENT_TYPE_CONNECTION, EVENT_TYPE_DISCONNECTION, @@ -137,7 +134,7 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): ) ) - self.hass.data[DOMAIN][DATA_CAMERAS][self.device.entity_id] = self.device.name + self.data_handler.cameras[self.device.entity_id] = self.device.name @callback def handle_event(self, event: dict) -> None: @@ -281,8 +278,8 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): self._attr_is_streaming = self.device.monitoring self._attr_motion_detection_enabled = self.device.monitoring - self.hass.data[DOMAIN][DATA_EVENTS][self.device.entity_id] = ( - self.process_events(self.device.events) + self.data_handler.events[self.device.entity_id] = self.process_events( + self.device.events ) def process_events(self, event_list: list[NaEvent]) -> dict: diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 936be63126f2..7c60ea7f2f0f 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,5 +1,4 @@ """Support for Netatmo Smart thermostats.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast, override @@ -39,7 +38,6 @@ from .const import ( ATTR_SELECTED_SCHEDULE_ID, ATTR_TARGET_TEMPERATURE, ATTR_TIME_PERIOD, - DATA_SCHEDULES, DOMAIN, EVENT_TYPE_CANCEL_SET_POINT, EVENT_TYPE_SCHEDULE, @@ -254,7 +252,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if data["event_type"] == EVENT_TYPE_SCHEDULE: # handle schedule change if "schedule_id" in data: - selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ + selected_schedule = self.data_handler.schedules[ self.home.entity_id ].get(data["schedule_id"]) self._selected_schedule = getattr( @@ -461,9 +459,7 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None - for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].items(): + for sid, schedule in self.data_handler.schedules[self.home.entity_id].items(): if schedule.name == schedule_name: schedule_id = sid break diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 41bfc09dc905..96bdb16b9201 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -71,13 +71,6 @@ CONF_WEATHER_AREAS = "weather_areas" OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize" OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token" -DATA_CAMERAS = "cameras" -DATA_DEVICE_IDS = "netatmo_device_ids" -DATA_EVENTS = "netatmo_events" -DATA_HOMES = "netatmo_homes" -DATA_PERSONS = "netatmo_persons" -DATA_SCHEDULES = "netatmo_schedules" - NETATMO_EVENT = "netatmo_event" DEFAULT_DISCOVERY = True diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 8d63a2016f3d..db8523bd5960 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -1,5 +1,4 @@ """The Netatmo data handler.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections import deque from dataclasses import dataclass @@ -15,6 +14,7 @@ from pyatmo.modules.device_types import ( DeviceCategory as NetatmoDeviceCategory, DeviceType as NetatmoDeviceType, ) +from pyatmo.schedule import Schedule from homeassistant.components import cloud from homeassistant.config_entries import ConfigEntry @@ -27,8 +27,6 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( CAMERA_CONNECTION_WEBHOOKS, - DATA_PERSONS, - DATA_SCHEDULES, DOMAIN, MANUFACTURER, NETATMO_CREATE_BUTTON, @@ -91,6 +89,12 @@ SCAN_INTERVAL = 60 type NetatmoConfigEntry = ConfigEntry[NetatmoDataHandler] +def async_get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry | None: + """Return the single loaded Netatmo config entry, if any.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + return entries[0] if entries else None + + @dataclass class NetatmoDevice: """Netatmo device class.""" @@ -160,6 +164,11 @@ class NetatmoDataHandler: self._rate_limit = DEV_LIMIT self.poll_start = time() self.poll_count = 0 + self.persons: dict[str, dict[str, str | None]] = {} + self.schedules: dict[str, dict[str, Schedule]] = {} + self.device_ids: dict[str, str] = {} + self.cameras: dict[str, str] = {} + self.events: dict[str, dict] = {} async def async_setup(self) -> None: """Set up the Netatmo data handler.""" @@ -330,7 +339,7 @@ class NetatmoDataHandler: self.setup_rooms(home, signal_home) self.setup_modules(home, signal_home) - self.hass.data[DOMAIN][DATA_PERSONS][home.entity_id] = { + self.persons[home.entity_id] = { person.entity_id: person.pseudo for person in home.persons.values() } @@ -459,7 +468,7 @@ class NetatmoDataHandler: if NetatmoDeviceCategory.climate in [ next(iter(x)) for x in [room.features for room in home.rooms.values()] if x ]: - self.hass.data[DOMAIN][DATA_SCHEDULES][home.entity_id] = self.account.homes[ + self.schedules[home.entity_id] = self.account.homes[ home.entity_id ].schedules diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 2b2b5ea52b8c..d74658eb0727 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -16,7 +16,6 @@ from homeassistant.helpers.entity import Entity from .const import ( CONF_URL_ENERGY, CONF_URL_WEATHER, - DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, SIGNAL_NAME, @@ -141,9 +140,7 @@ class NetatmoRoomEntity(NetatmoDeviceEntity): if device := registry.async_get_device( identifiers={(DOMAIN, self.device.entity_id)} ): - # Uses legacy hass.data[DOMAIN] pattern - # pylint: disable-next=home-assistant-use-runtime-data - self.hass.data[DOMAIN][DATA_DEVICE_IDS][self.device.entity_id] = device.id + self.data_handler.device_ids[self.device.entity_id] = device.id @property @override diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 653b82e5acc2..5bfaf25f4739 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -1,5 +1,4 @@ """Netatmo Media Source Implementation.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import datetime as dt import logging @@ -15,9 +14,11 @@ from homeassistant.components.media_source import ( PlayMedia, Unresolvable, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback -from .const import DATA_CAMERAS, DATA_EVENTS, DOMAIN, MANUFACTURER +from .const import DOMAIN, MANUFACTURER +from .data_handler import NetatmoConfigEntry, NetatmoDataHandler, async_get_loaded_entry _LOGGER = logging.getLogger(__name__) MIME_TYPE = "application/x-mpegURL" @@ -29,7 +30,7 @@ class IncompatibleMediaSource(MediaSourceError): async def async_get_media_source(hass: HomeAssistant) -> NetatmoSource: """Set up Netatmo media source.""" - return NetatmoSource(hass) + return NetatmoSource(hass, async_get_loaded_entry(hass)) class NetatmoSource(MediaSource): @@ -37,11 +38,24 @@ class NetatmoSource(MediaSource): name: str = MANUFACTURER - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, entry: NetatmoConfigEntry | None) -> None: """Initialize Netatmo source.""" super().__init__(DOMAIN) self.hass = hass - self.events = self.hass.data[DOMAIN][DATA_EVENTS] + self.entry = entry + + @property + def _data_handler(self) -> NetatmoDataHandler | None: + """Return the data handler of the config entry, if it is loaded.""" + if self.entry is None or self.entry.state is not ConfigEntryState.LOADED: + return None + return self.entry.runtime_data + + @property + def events(self) -> dict[str, dict]: + """Return the camera events.""" + data_handler = self._data_handler + return data_handler.events if data_handler else {} @override async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: @@ -85,7 +99,12 @@ class NetatmoSource(MediaSource): ) title = f"{created} - {message}" else: - title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER) + data_handler = self._data_handler + title = ( + data_handler.cameras.get(camera_id, MANUFACTURER) + if data_handler + else MANUFACTURER + ) thumbnail = None if event_id: diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 7596146b4749..d4d746b62fbd 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -1,5 +1,4 @@ """Support for the Netatmo climate schedule selector.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import override @@ -12,7 +11,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( CONF_URL_ENERGY, - DATA_SCHEDULES, DOMAIN, EVENT_TYPE_SCHEDULE, MANUFACTURER, @@ -100,18 +98,16 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): return if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: - if schedule := self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].get(data["schedule_id"]): + if schedule := self.data_handler.schedules[self.home.entity_id].get( + data["schedule_id"] + ): self._attr_current_option = schedule.name self.async_write_ha_state() @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" - for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ - self.home.entity_id - ].items(): + for sid, schedule in self.data_handler.schedules[self.home.entity_id].items(): if schedule.name != option: continue _LOGGER.debug( @@ -130,9 +126,7 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): schedule = self.home.get_selected_schedule() assert schedule self._attr_current_option = schedule.name - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id] = ( - self.home.schedules - ) + self.data_handler.schedules[self.home.entity_id] = self.home.schedules self._attr_options = [ schedule.name for schedule in self.home.schedules.values() if schedule.name ] diff --git a/homeassistant/components/netatmo/services.py b/homeassistant/components/netatmo/services.py index 750e88baf308..b3ca63824a74 100644 --- a/homeassistant/components/netatmo/services.py +++ b/homeassistant/components/netatmo/services.py @@ -1,12 +1,11 @@ """Services for the Netatmo integration.""" -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .data_handler import NetatmoConfigEntry +from .data_handler import NetatmoConfigEntry, async_get_loaded_entry from .webhook import async_register_webhook, async_unregister_webhook SERVICE_REGISTER_WEBHOOK = "register_webhook" @@ -15,10 +14,7 @@ SERVICE_UNREGISTER_WEBHOOK = "unregister_webhook" def _get_loaded_entry(hass: HomeAssistant) -> NetatmoConfigEntry: """Return the loaded config entry or raise if unavailable.""" - entry: NetatmoConfigEntry | None = ( - hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, DOMAIN) - ) - if entry is None or entry.state is not ConfigEntryState.LOADED: + if (entry := async_get_loaded_entry(hass)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="entry_not_loaded", diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index c34abe3c76bf..1670f2c2657c 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -1,9 +1,8 @@ """The Netatmo integration.""" -# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern - import logging import secrets +from typing import Any from aiohttp.web import Request import pyatmo @@ -31,8 +30,6 @@ from .const import ( ATTR_HOME_ID, ATTR_IS_KNOWN, CONF_CLOUDHOOK_URL, - DATA_DEVICE_IDS, - DATA_PERSONS, DEFAULT_PERSON, DOMAIN, EVENT_ID_MAP, @@ -40,7 +37,7 @@ from .const import ( WEBHOOK_DEACTIVATION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import NetatmoConfigEntry +from .data_handler import NetatmoConfigEntry, NetatmoDataHandler _LOGGER = logging.getLogger(__name__) @@ -62,19 +59,31 @@ async def async_handle_webhook( _LOGGER.debug("Got webhook data: %s", data) + entry = next( + ( + entry + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + if entry.data.get(CONF_WEBHOOK_ID) == webhook_id + ), + None, + ) + if entry is None: + return + data_handler = entry.runtime_data + event_type = data.get(ATTR_EVENT_TYPE) if event_type in SUBEVENT_TYPE_MAP: - async_send_event(hass, event_type, data) + async_send_event(data_handler, event_type, data) for event_data in data.get(SUBEVENT_TYPE_MAP[event_type], []): - async_evaluate_event(hass, event_data) + async_evaluate_event(data_handler, event_data) else: - async_evaluate_event(hass, data) + async_evaluate_event(data_handler, data) -def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: +def async_evaluate_event(data_handler: NetatmoDataHandler, event_data: dict) -> None: """Evaluate events from webhook.""" event_type = event_data.get(ATTR_EVENT_TYPE, "None") @@ -82,20 +91,23 @@ def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: for person in event_data.get(ATTR_PERSONS, {}): person_event_data = dict(event_data) person_event_data[ATTR_ID] = person.get(ATTR_ID) - person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS][ + person_event_data[ATTR_NAME] = data_handler.persons[ event_data[ATTR_HOME_ID] ].get(person_event_data[ATTR_ID], DEFAULT_PERSON) person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN) person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL) - async_send_event(hass, event_type, person_event_data) + async_send_event(data_handler, event_type, person_event_data) else: - async_send_event(hass, event_type, event_data) + async_send_event(data_handler, event_type, event_data) -def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: +def async_send_event( + data_handler: NetatmoDataHandler, event_type: str, data: dict +) -> None: """Send events.""" + hass = data_handler.hass _LOGGER.debug("%s: %s", event_type, data) async_dispatcher_send( hass, @@ -103,16 +115,14 @@ def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: {"type": event_type, "data": data}, ) - event_data = { + event_data: dict[str, Any] = { "type": event_type, "data": data, } if event_type in EVENT_ID_MAP: data_device_id = data[EVENT_ID_MAP[event_type]] - event_data[ATTR_DEVICE_ID] = hass.data[DOMAIN][DATA_DEVICE_IDS].get( - data_device_id - ) + event_data[ATTR_DEVICE_ID] = data_handler.device_ids.get(data_device_id) hass.bus.async_fire( event_type=NETATMO_EVENT, diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index 76abd3269cd0..601d9afc8636 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -1,6 +1,7 @@ """Test Local Media Source.""" import ast +from unittest.mock import AsyncMock import pytest @@ -12,24 +13,30 @@ from homeassistant.components.media_source import ( async_browse_media, async_resolve_media, ) -from homeassistant.components.netatmo import DATA_CAMERAS, DATA_EVENTS, DOMAIN +from homeassistant.components.netatmo.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import async_load_fixture +from .common import selected_platforms + +from tests.common import MockConfigEntry, async_load_fixture -async def test_async_browse_media(hass: HomeAssistant) -> None: +async def test_async_browse_media( + hass: HomeAssistant, config_entry: MockConfigEntry, netatmo_auth: AsyncMock +) -> None: """Test browse media.""" - assert await async_setup_component(hass, DOMAIN, {}) + with selected_platforms([Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - # Prepare cached Netatmo event date - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_EVENTS] = ast.literal_eval( + # Prepare cached Netatmo event data + data_handler = config_entry.runtime_data + data_handler.events = ast.literal_eval( await async_load_fixture(hass, "events.txt", DOMAIN) ) - - hass.data[DOMAIN][DATA_CAMERAS] = { + data_handler.cameras = { "12:34:56:78:90:ab": "MyCamera", "12:34:56:78:90:ac": "MyOutdoorCamera", }