1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-13 01:27:57 +01:00

Migrate legacy data stores in Netatmo (#175620)

This commit is contained in:
Tobias Sauerwein
2026-07-07 16:08:17 +02:00
committed by GitHub
parent da68bf36a4
commit b4b11fa383
11 changed files with 96 additions and 98 deletions
+1 -21
View File
@@ -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
+3 -6
View File
@@ -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:
+2 -6
View File
@@ -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
@@ -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
@@ -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
+1 -4
View File
@@ -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
@@ -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:
+5 -11
View File
@@ -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
]
+2 -6
View File
@@ -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",
+27 -17
View File
@@ -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,
+16 -9
View File
@@ -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",
}