2020-08-04 18:46:46 +00:00
|
|
|
"""The Netatmo data handler."""
|
2021-03-18 12:21:46 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2021-05-20 12:59:19 +00:00
|
|
|
import asyncio
|
2020-08-04 18:46:46 +00:00
|
|
|
from collections import deque
|
2021-07-05 11:05:18 +00:00
|
|
|
from dataclasses import dataclass
|
2022-01-07 07:01:27 +00:00
|
|
|
from datetime import datetime, timedelta
|
2020-08-04 18:46:46 +00:00
|
|
|
from itertools import islice
|
|
|
|
import logging
|
|
|
|
from time import time
|
2021-07-21 21:36:57 +00:00
|
|
|
from typing import Any
|
2020-08-04 18:46:46 +00:00
|
|
|
|
|
|
|
import pyatmo
|
2022-09-26 01:55:58 +00:00
|
|
|
from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory
|
2020-08-04 18:46:46 +00:00
|
|
|
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
2021-03-25 18:07:45 +00:00
|
|
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
2022-09-26 01:55:58 +00:00
|
|
|
from homeassistant.helpers.dispatcher import (
|
|
|
|
async_dispatcher_connect,
|
|
|
|
async_dispatcher_send,
|
|
|
|
)
|
2020-08-04 18:46:46 +00:00
|
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
|
|
|
2021-05-20 17:28:21 +00:00
|
|
|
from .const import (
|
|
|
|
AUTH,
|
2022-09-26 01:55:58 +00:00
|
|
|
DATA_PERSONS,
|
|
|
|
DATA_SCHEDULES,
|
2021-05-20 17:28:21 +00:00
|
|
|
DOMAIN,
|
|
|
|
MANUFACTURER,
|
2022-09-26 01:55:58 +00:00
|
|
|
NETATMO_CREATE_BATTERY,
|
|
|
|
NETATMO_CREATE_CAMERA,
|
|
|
|
NETATMO_CREATE_CAMERA_LIGHT,
|
|
|
|
NETATMO_CREATE_CLIMATE,
|
|
|
|
NETATMO_CREATE_COVER,
|
|
|
|
NETATMO_CREATE_LIGHT,
|
|
|
|
NETATMO_CREATE_ROOM_SENSOR,
|
|
|
|
NETATMO_CREATE_SELECT,
|
|
|
|
NETATMO_CREATE_SENSOR,
|
|
|
|
NETATMO_CREATE_SWITCH,
|
|
|
|
NETATMO_CREATE_WEATHER_SENSOR,
|
|
|
|
PLATFORMS,
|
2021-05-20 17:28:21 +00:00
|
|
|
WEBHOOK_ACTIVATION,
|
|
|
|
WEBHOOK_DEACTIVATION,
|
|
|
|
WEBHOOK_NACAMERA_CONNECTION,
|
|
|
|
WEBHOOK_PUSH_TYPE,
|
|
|
|
)
|
2020-08-04 18:46:46 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2022-09-26 01:55:58 +00:00
|
|
|
SIGNAL_NAME = "signal_name"
|
|
|
|
ACCOUNT = "account"
|
|
|
|
HOME = "home"
|
|
|
|
WEATHER = "weather"
|
|
|
|
AIR_CARE = "air_care"
|
|
|
|
PUBLIC = "public"
|
|
|
|
EVENT = "event"
|
|
|
|
|
|
|
|
PUBLISHERS = {
|
|
|
|
ACCOUNT: "async_update_topology",
|
|
|
|
HOME: "async_update_status",
|
|
|
|
WEATHER: "async_update_weather_stations",
|
|
|
|
AIR_CARE: "async_update_air_care",
|
|
|
|
PUBLIC: "async_update_public_weather",
|
|
|
|
EVENT: "async_update_events",
|
2020-08-04 18:46:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
BATCH_SIZE = 3
|
|
|
|
DEFAULT_INTERVALS = {
|
2022-09-26 01:55:58 +00:00
|
|
|
ACCOUNT: 10800,
|
|
|
|
HOME: 300,
|
|
|
|
WEATHER: 600,
|
|
|
|
AIR_CARE: 300,
|
|
|
|
PUBLIC: 600,
|
|
|
|
EVENT: 600,
|
2020-08-04 18:46:46 +00:00
|
|
|
}
|
|
|
|
SCAN_INTERVAL = 60
|
|
|
|
|
|
|
|
|
2021-12-03 17:33:24 +00:00
|
|
|
@dataclass
|
|
|
|
class NetatmoDevice:
|
|
|
|
"""Netatmo device class."""
|
|
|
|
|
|
|
|
data_handler: NetatmoDataHandler
|
2022-09-26 01:55:58 +00:00
|
|
|
device: pyatmo.modules.Module
|
|
|
|
parent_id: str
|
|
|
|
signal_name: str
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class NetatmoHome:
|
|
|
|
"""Netatmo home class."""
|
|
|
|
|
|
|
|
data_handler: NetatmoDataHandler
|
|
|
|
home: pyatmo.Home
|
|
|
|
parent_id: str
|
|
|
|
signal_name: str
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class NetatmoRoom:
|
|
|
|
"""Netatmo room class."""
|
|
|
|
|
|
|
|
data_handler: NetatmoDataHandler
|
|
|
|
room: pyatmo.Room
|
2021-12-03 17:33:24 +00:00
|
|
|
parent_id: str
|
2022-07-27 12:17:38 +00:00
|
|
|
signal_name: str
|
2021-12-03 17:33:24 +00:00
|
|
|
|
|
|
|
|
2021-07-05 11:05:18 +00:00
|
|
|
@dataclass
|
2022-07-27 12:17:38 +00:00
|
|
|
class NetatmoPublisher:
|
2021-07-05 11:05:18 +00:00
|
|
|
"""Class for keeping track of Netatmo data class metadata."""
|
|
|
|
|
|
|
|
name: str
|
|
|
|
interval: int
|
|
|
|
next_scan: float
|
2022-09-26 01:55:58 +00:00
|
|
|
subscriptions: set[CALLBACK_TYPE | None]
|
|
|
|
method: str
|
|
|
|
kwargs: dict
|
2021-07-05 11:05:18 +00:00
|
|
|
|
|
|
|
|
2020-08-04 18:46:46 +00:00
|
|
|
class NetatmoDataHandler:
|
|
|
|
"""Manages the Netatmo data handling."""
|
|
|
|
|
2022-09-26 01:55:58 +00:00
|
|
|
account: pyatmo.AsyncAccount
|
|
|
|
|
2021-10-26 11:43:54 +00:00
|
|
|
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
2020-08-04 18:46:46 +00:00
|
|
|
"""Initialize self."""
|
|
|
|
self.hass = hass
|
2021-10-26 11:43:54 +00:00
|
|
|
self.config_entry = config_entry
|
|
|
|
self._auth = hass.data[DOMAIN][config_entry.entry_id][AUTH]
|
2022-07-27 12:17:38 +00:00
|
|
|
self.publisher: dict[str, NetatmoPublisher] = {}
|
2021-07-21 21:36:57 +00:00
|
|
|
self._queue: deque = deque()
|
2020-08-04 18:46:46 +00:00
|
|
|
self._webhook: bool = False
|
|
|
|
|
2021-07-21 21:36:57 +00:00
|
|
|
async def async_setup(self) -> None:
|
2020-08-04 18:46:46 +00:00
|
|
|
"""Set up the Netatmo data handler."""
|
|
|
|
async_track_time_interval(
|
|
|
|
self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL)
|
|
|
|
)
|
|
|
|
|
2021-10-26 11:43:54 +00:00
|
|
|
self.config_entry.async_on_unload(
|
2020-08-07 07:25:59 +00:00
|
|
|
async_dispatcher_connect(
|
2020-08-27 11:56:20 +00:00
|
|
|
self.hass,
|
|
|
|
f"signal-{DOMAIN}-webhook-None",
|
|
|
|
self.handle_event,
|
2020-08-07 07:25:59 +00:00
|
|
|
)
|
2020-08-04 18:46:46 +00:00
|
|
|
)
|
|
|
|
|
2022-09-26 01:55:58 +00:00
|
|
|
self.account = pyatmo.AsyncAccount(self._auth)
|
|
|
|
|
|
|
|
await self.subscribe(ACCOUNT, ACCOUNT, None)
|
|
|
|
|
|
|
|
await self.hass.config_entries.async_forward_entry_setups(
|
|
|
|
self.config_entry, PLATFORMS
|
2022-01-28 09:51:32 +00:00
|
|
|
)
|
2022-09-26 01:55:58 +00:00
|
|
|
await self.async_dispatch()
|
2022-01-28 09:51:32 +00:00
|
|
|
|
2022-01-07 07:01:27 +00:00
|
|
|
async def async_update(self, event_time: datetime) -> None:
|
2020-08-04 18:46:46 +00:00
|
|
|
"""
|
|
|
|
Update device.
|
|
|
|
|
|
|
|
We do up to BATCH_SIZE calls in one update in order
|
|
|
|
to minimize the calls on the api service.
|
|
|
|
"""
|
|
|
|
for data_class in islice(self._queue, 0, BATCH_SIZE):
|
2021-07-05 11:05:18 +00:00
|
|
|
if data_class.next_scan > time():
|
2020-08-04 18:46:46 +00:00
|
|
|
continue
|
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
if publisher := data_class.name:
|
|
|
|
self.publisher[publisher].next_scan = time() + data_class.interval
|
2021-05-28 11:36:22 +00:00
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
await self.async_fetch_data(publisher)
|
2020-08-04 18:46:46 +00:00
|
|
|
|
|
|
|
self._queue.rotate(BATCH_SIZE)
|
|
|
|
|
2021-03-25 18:07:45 +00:00
|
|
|
@callback
|
2022-07-27 12:17:38 +00:00
|
|
|
def async_force_update(self, signal_name: str) -> None:
|
2021-03-25 18:07:45 +00:00
|
|
|
"""Prioritize data retrieval for given data class entry."""
|
2022-09-22 02:31:14 +00:00
|
|
|
# self.publisher[signal_name].next_scan = time()
|
|
|
|
# self._queue.rotate(-(self._queue.index(self.publisher[signal_name])))
|
2021-03-25 18:07:45 +00:00
|
|
|
|
2021-07-21 21:36:57 +00:00
|
|
|
async def handle_event(self, event: dict) -> None:
|
2020-08-04 18:46:46 +00:00
|
|
|
"""Handle webhook events."""
|
2021-05-20 17:28:21 +00:00
|
|
|
if event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_ACTIVATION:
|
2020-08-04 18:46:46 +00:00
|
|
|
_LOGGER.info("%s webhook successfully registered", MANUFACTURER)
|
|
|
|
self._webhook = True
|
|
|
|
|
2021-05-20 17:28:21 +00:00
|
|
|
elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_DEACTIVATION:
|
2021-01-28 14:30:10 +00:00
|
|
|
_LOGGER.info("%s webhook unregistered", MANUFACTURER)
|
|
|
|
self._webhook = False
|
|
|
|
|
2021-05-20 17:28:21 +00:00
|
|
|
elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_NACAMERA_CONNECTION:
|
2020-08-04 18:46:46 +00:00
|
|
|
_LOGGER.debug("%s camera reconnected", MANUFACTURER)
|
2022-09-26 01:55:58 +00:00
|
|
|
self.async_force_update(ACCOUNT)
|
2020-08-04 18:46:46 +00:00
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
async def async_fetch_data(self, signal_name: str) -> None:
|
2020-08-04 18:46:46 +00:00
|
|
|
"""Fetch data and notify."""
|
|
|
|
try:
|
2022-09-26 01:55:58 +00:00
|
|
|
await getattr(self.account, self.publisher[signal_name].method)(
|
|
|
|
**self.publisher[signal_name].kwargs
|
|
|
|
)
|
2020-08-04 18:46:46 +00:00
|
|
|
|
2021-03-05 20:41:55 +00:00
|
|
|
except pyatmo.NoDevice as err:
|
|
|
|
_LOGGER.debug(err)
|
|
|
|
|
|
|
|
except pyatmo.ApiError as err:
|
2020-08-04 18:46:46 +00:00
|
|
|
_LOGGER.debug(err)
|
|
|
|
|
2021-05-20 12:59:19 +00:00
|
|
|
except asyncio.TimeoutError as err:
|
|
|
|
_LOGGER.debug(err)
|
|
|
|
return
|
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
for update_callback in self.publisher[signal_name].subscriptions:
|
2021-05-20 12:59:19 +00:00
|
|
|
if update_callback:
|
|
|
|
update_callback()
|
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
async def subscribe(
|
2021-07-21 21:36:57 +00:00
|
|
|
self,
|
2022-07-27 12:17:38 +00:00
|
|
|
publisher: str,
|
|
|
|
signal_name: str,
|
2022-01-28 09:51:32 +00:00
|
|
|
update_callback: CALLBACK_TYPE | None,
|
2021-07-21 21:36:57 +00:00
|
|
|
**kwargs: Any,
|
|
|
|
) -> None:
|
2022-07-27 12:17:38 +00:00
|
|
|
"""Subscribe to publisher."""
|
|
|
|
if signal_name in self.publisher:
|
|
|
|
if update_callback not in self.publisher[signal_name].subscriptions:
|
2022-09-26 01:55:58 +00:00
|
|
|
self.publisher[signal_name].subscriptions.add(update_callback)
|
2020-08-07 07:25:59 +00:00
|
|
|
return
|
|
|
|
|
2022-09-26 01:55:58 +00:00
|
|
|
if publisher == "public":
|
|
|
|
kwargs = {"area_id": self.account.register_public_weather_area(**kwargs)}
|
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
self.publisher[signal_name] = NetatmoPublisher(
|
|
|
|
name=signal_name,
|
|
|
|
interval=DEFAULT_INTERVALS[publisher],
|
|
|
|
next_scan=time() + DEFAULT_INTERVALS[publisher],
|
2022-09-26 01:55:58 +00:00
|
|
|
subscriptions={update_callback},
|
|
|
|
method=PUBLISHERS[publisher],
|
|
|
|
kwargs=kwargs,
|
2021-07-05 11:05:18 +00:00
|
|
|
)
|
2020-08-07 07:25:59 +00:00
|
|
|
|
2021-12-07 12:56:31 +00:00
|
|
|
try:
|
2022-07-27 12:17:38 +00:00
|
|
|
await self.async_fetch_data(signal_name)
|
2021-12-07 12:56:31 +00:00
|
|
|
except KeyError:
|
2022-07-27 12:17:38 +00:00
|
|
|
self.publisher.pop(signal_name)
|
2021-12-07 12:56:31 +00:00
|
|
|
raise
|
2021-05-20 12:59:19 +00:00
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
self._queue.append(self.publisher[signal_name])
|
|
|
|
_LOGGER.debug("Publisher %s added", signal_name)
|
2020-08-04 18:46:46 +00:00
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
async def unsubscribe(
|
|
|
|
self, signal_name: str, update_callback: CALLBACK_TYPE | None
|
2021-07-21 21:36:57 +00:00
|
|
|
) -> None:
|
2022-07-27 12:17:38 +00:00
|
|
|
"""Unsubscribe from publisher."""
|
2022-09-26 01:55:58 +00:00
|
|
|
if update_callback in self.publisher[signal_name].subscriptions:
|
|
|
|
return
|
|
|
|
|
2022-07-27 12:17:38 +00:00
|
|
|
self.publisher[signal_name].subscriptions.remove(update_callback)
|
|
|
|
|
|
|
|
if not self.publisher[signal_name].subscriptions:
|
|
|
|
self._queue.remove(self.publisher[signal_name])
|
|
|
|
self.publisher.pop(signal_name)
|
|
|
|
_LOGGER.debug("Publisher %s removed", signal_name)
|
2020-08-04 18:46:46 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def webhook(self) -> bool:
|
|
|
|
"""Return the webhook state."""
|
|
|
|
return self._webhook
|
2022-09-26 01:55:58 +00:00
|
|
|
|
|
|
|
async def async_dispatch(self) -> None:
|
|
|
|
"""Dispatch the creation of entities."""
|
|
|
|
await self.subscribe(WEATHER, WEATHER, None)
|
|
|
|
await self.subscribe(AIR_CARE, AIR_CARE, None)
|
|
|
|
|
|
|
|
self.setup_air_care()
|
|
|
|
|
|
|
|
for home in self.account.homes.values():
|
|
|
|
signal_home = f"{HOME}-{home.entity_id}"
|
|
|
|
|
|
|
|
await self.subscribe(HOME, signal_home, None, home_id=home.entity_id)
|
|
|
|
await self.subscribe(EVENT, signal_home, None, home_id=home.entity_id)
|
|
|
|
|
|
|
|
self.setup_climate_schedule_select(home, signal_home)
|
|
|
|
self.setup_rooms(home, signal_home)
|
|
|
|
self.setup_modules(home, signal_home)
|
|
|
|
|
|
|
|
self.hass.data[DOMAIN][DATA_PERSONS][home.entity_id] = {
|
|
|
|
person.entity_id: person.pseudo for person in home.persons.values()
|
|
|
|
}
|
|
|
|
|
|
|
|
def setup_air_care(self) -> None:
|
|
|
|
"""Set up home coach/air care modules."""
|
|
|
|
for module in self.account.modules.values():
|
|
|
|
if module.device_category is NetatmoDeviceCategory.air_care:
|
|
|
|
async_dispatcher_send(
|
|
|
|
self.hass,
|
|
|
|
NETATMO_CREATE_WEATHER_SENSOR,
|
|
|
|
NetatmoDevice(
|
|
|
|
self,
|
|
|
|
module,
|
|
|
|
AIR_CARE,
|
|
|
|
AIR_CARE,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
def setup_modules(self, home: pyatmo.Home, signal_home: str) -> None:
|
|
|
|
"""Set up modules."""
|
|
|
|
netatmo_type_signal_map = {
|
|
|
|
NetatmoDeviceCategory.camera: [
|
|
|
|
NETATMO_CREATE_CAMERA,
|
|
|
|
NETATMO_CREATE_CAMERA_LIGHT,
|
|
|
|
],
|
|
|
|
NetatmoDeviceCategory.dimmer: [NETATMO_CREATE_LIGHT],
|
|
|
|
NetatmoDeviceCategory.shutter: [NETATMO_CREATE_COVER],
|
|
|
|
NetatmoDeviceCategory.switch: [
|
|
|
|
NETATMO_CREATE_LIGHT,
|
|
|
|
NETATMO_CREATE_SWITCH,
|
|
|
|
NETATMO_CREATE_SENSOR,
|
|
|
|
],
|
|
|
|
NetatmoDeviceCategory.meter: [NETATMO_CREATE_SENSOR],
|
|
|
|
}
|
|
|
|
for module in home.modules.values():
|
|
|
|
if not module.device_category:
|
|
|
|
continue
|
|
|
|
|
|
|
|
for signal in netatmo_type_signal_map.get(module.device_category, []):
|
|
|
|
async_dispatcher_send(
|
|
|
|
self.hass,
|
|
|
|
signal,
|
|
|
|
NetatmoDevice(
|
|
|
|
self,
|
|
|
|
module,
|
|
|
|
home.entity_id,
|
|
|
|
signal_home,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
if module.device_category is NetatmoDeviceCategory.weather:
|
|
|
|
async_dispatcher_send(
|
|
|
|
self.hass,
|
|
|
|
NETATMO_CREATE_WEATHER_SENSOR,
|
|
|
|
NetatmoDevice(
|
|
|
|
self,
|
|
|
|
module,
|
|
|
|
home.entity_id,
|
|
|
|
WEATHER,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
def setup_rooms(self, home: pyatmo.Home, signal_home: str) -> None:
|
|
|
|
"""Set up rooms."""
|
|
|
|
for room in home.rooms.values():
|
|
|
|
if NetatmoDeviceCategory.climate in room.features:
|
|
|
|
async_dispatcher_send(
|
|
|
|
self.hass,
|
|
|
|
NETATMO_CREATE_CLIMATE,
|
|
|
|
NetatmoRoom(
|
|
|
|
self,
|
|
|
|
room,
|
|
|
|
home.entity_id,
|
|
|
|
signal_home,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
for module in room.modules.values():
|
|
|
|
if module.device_category is NetatmoDeviceCategory.climate:
|
|
|
|
async_dispatcher_send(
|
|
|
|
self.hass,
|
|
|
|
NETATMO_CREATE_BATTERY,
|
|
|
|
NetatmoDevice(
|
|
|
|
self,
|
|
|
|
module,
|
|
|
|
room.entity_id,
|
|
|
|
signal_home,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
if "humidity" in room.features:
|
|
|
|
async_dispatcher_send(
|
|
|
|
self.hass,
|
|
|
|
NETATMO_CREATE_ROOM_SENSOR,
|
|
|
|
NetatmoRoom(
|
|
|
|
self,
|
|
|
|
room,
|
|
|
|
room.entity_id,
|
|
|
|
signal_home,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
def setup_climate_schedule_select(
|
|
|
|
self, home: pyatmo.Home, signal_home: str
|
|
|
|
) -> None:
|
|
|
|
"""Set up climate schedule per home."""
|
|
|
|
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[
|
|
|
|
home.entity_id
|
|
|
|
].schedules
|
|
|
|
|
|
|
|
async_dispatcher_send(
|
|
|
|
self.hass,
|
|
|
|
NETATMO_CREATE_SELECT,
|
|
|
|
NetatmoHome(
|
|
|
|
self,
|
|
|
|
home,
|
|
|
|
home.entity_id,
|
|
|
|
signal_home,
|
|
|
|
),
|
|
|
|
)
|