diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 36847c85515..65b321d25aa 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -8,6 +8,7 @@ import secrets import aiohttp import pyatmo +from pyatmo.const import ALL_SCOPES as NETATMO_SCOPES import voluptuous as vol from homeassistant.components import cloud @@ -51,7 +52,6 @@ from .const import ( DATA_PERSONS, DATA_SCHEDULES, DOMAIN, - NETATMO_SCOPES, PLATFORMS, WEBHOOK_DEACTIVATION, WEBHOOK_PUSH_TYPE, @@ -150,10 +150,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } data_handler = NetatmoDataHandler(hass, entry) - await data_handler.async_setup() hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] = data_handler - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await data_handler.async_setup() async def unregister_webhook( call_or_event_or_dt: ServiceCall | Event | datetime | None, @@ -208,10 +206,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.info("Register Netatmo webhook: %s", webhook_url) except pyatmo.ApiError as err: _LOGGER.error("Error during webhook registration - %s", err) - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) - ) + else: + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: if state is cloud.CloudConnectionState.CLOUD_CONNECTED: diff --git a/homeassistant/components/netatmo/api.py b/homeassistant/components/netatmo/api.py index e13032dc399..0b36745338e 100644 --- a/homeassistant/components/netatmo/api.py +++ b/homeassistant/components/netatmo/api.py @@ -7,7 +7,7 @@ import pyatmo from homeassistant.helpers import config_entry_oauth2_flow -class AsyncConfigEntryNetatmoAuth(pyatmo.auth.AbstractAsyncAuth): +class AsyncConfigEntryNetatmoAuth(pyatmo.AbstractAsyncAuth): """Provide Netatmo authentication tied to an OAuth2 based config entry.""" def __init__( diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 3235d16479c..9254ff6e284 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -5,13 +5,14 @@ import logging from typing import Any, cast import aiohttp -import pyatmo +from pyatmo import ApiError as NetatmoApiError, modules as NaModules +from pyatmo.event import Event as NaEvent import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,28 +21,24 @@ from .const import ( ATTR_CAMERA_LIGHT_MODE, ATTR_PERSON, ATTR_PERSONS, - ATTR_PSEUDO, CAMERA_LIGHT_MODES, + CONF_URL_SECURITY, DATA_CAMERAS, DATA_EVENTS, - DATA_HANDLER, - DATA_PERSONS, DOMAIN, EVENT_TYPE_LIGHT_MODE, EVENT_TYPE_OFF, EVENT_TYPE_ON, MANUFACTURER, - MODELS, + NETATMO_CREATE_CAMERA, SERVICE_SET_CAMERA_LIGHT, SERVICE_SET_PERSON_AWAY, SERVICE_SET_PERSONS_HOME, - SIGNAL_NAME, - TYPE_SECURITY, WEBHOOK_LIGHT_MODE, WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import CAMERA_DATA_CLASS_NAME, NetatmoDataHandler +from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoDevice from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -53,42 +50,15 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Netatmo camera platform.""" - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - data_class = data_handler.data.get(CAMERA_DATA_CLASS_NAME) + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoCamera(netatmo_device) + async_add_entities([entity]) - if not data_class or not data_class.raw_data: - raise PlatformNotReady - - all_cameras = [] - for home in data_class.cameras.values(): - for camera in home.values(): - all_cameras.append(camera) - - entities = [ - NetatmoCamera( - data_handler, - camera["id"], - camera["type"], - camera["home_id"], - DEFAULT_QUALITY, - ) - for camera in all_cameras - ] - - for home in data_class.homes.values(): - if home.get("id") is None: - continue - - hass.data[DOMAIN][DATA_PERSONS][home["id"]] = { - person_id: person_data.get(ATTR_PSEUDO) - for person_id, person_data in data_handler.data[CAMERA_DATA_CLASS_NAME] - .persons[home["id"]] - .items() - } - - _LOGGER.debug("Adding cameras %s", entities) - async_add_entities(entities, True) + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_CAMERA, _create_entity) + ) platform = entity_platform.async_get_current_platform() @@ -118,41 +88,44 @@ class NetatmoCamera(NetatmoBase, Camera): def __init__( self, - data_handler: NetatmoDataHandler, - camera_id: str, - camera_type: str, - home_id: str, - quality: str, + netatmo_device: NetatmoDevice, ) -> None: """Set up for access to the Netatmo camera images.""" Camera.__init__(self) - super().__init__(data_handler) + super().__init__(netatmo_device.data_handler) - self._publishers.append( - {"name": CAMERA_DATA_CLASS_NAME, SIGNAL_NAME: CAMERA_DATA_CLASS_NAME} - ) - - self._id = camera_id - self._home_id = home_id - self._device_name = self._data.get_camera(camera_id=camera_id)["name"] - self._model = camera_type - self._netatmo_type = TYPE_SECURITY + self._camera = cast(NaModules.Camera, netatmo_device.device) + self._id = self._camera.entity_id + self._home_id = self._camera.home.entity_id + self._device_name = self._camera.name + self._model = self._camera.device_type + self._config_url = CONF_URL_SECURITY self._attr_unique_id = f"{self._id}-{self._model}" - self._quality = quality - self._vpnurl: str | None = None - self._localurl: str | None = None - self._status: str | None = None - self._sd_status: str | None = None - self._alim_status: str | None = None - self._is_local: str | None = None + self._quality = DEFAULT_QUALITY + self._monitoring: bool | None = None self._light_state = None + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self._home_id, + SIGNAL_NAME: f"{HOME}-{self._home_id}", + }, + { + "name": EVENT, + "home_id": self._home_id, + SIGNAL_NAME: f"{EVENT}-{self._home_id}", + }, + ] + ) + async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() for event_type in (EVENT_TYPE_LIGHT_MODE, EVENT_TYPE_OFF, EVENT_TYPE_ON): - self.data_handler.config_entry.async_on_unload( + self.async_on_remove( async_dispatcher_connect( self.hass, f"signal-{DOMAIN}-webhook-{event_type}", @@ -173,13 +146,13 @@ class NetatmoCamera(NetatmoBase, Camera): if data["home_id"] == self._home_id and data["camera_id"] == self._id: if data[WEBHOOK_PUSH_TYPE] in ("NACamera-off", "NACamera-disconnection"): self._attr_is_streaming = False - self._status = "off" + self._monitoring = False elif data[WEBHOOK_PUSH_TYPE] in ( "NACamera-on", WEBHOOK_NACAMERA_CONNECTION, ): self._attr_is_streaming = True - self._status = "on" + self._monitoring = True elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE: self._light_state = data["sub_type"] self._attr_extra_state_attributes.update( @@ -189,128 +162,107 @@ class NetatmoCamera(NetatmoBase, Camera): self.async_write_ha_state() return - @property - def _data(self) -> pyatmo.AsyncCameraData: - """Return data for this entity.""" - return cast( - pyatmo.AsyncCameraData, - self.data_handler.data[self._publishers[0]["name"]], - ) - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" try: - return cast( - bytes, await self._data.async_get_live_snapshot(camera_id=self._id) - ) + return cast(bytes, await self._camera.async_get_live_snapshot()) except ( aiohttp.ClientPayloadError, aiohttp.ContentTypeError, aiohttp.ServerDisconnectedError, aiohttp.ClientConnectorError, - pyatmo.exceptions.ApiError, + NetatmoApiError, ) as err: _LOGGER.debug("Could not fetch live camera image (%s)", err) return None @property - def available(self) -> bool: - """Return True if entity is available.""" - return bool(self._alim_status == "on" or self._status == "disconnected") - - @property - def motion_detection_enabled(self) -> bool: - """Return the camera motion detection status.""" - return bool(self._status == "on") - - @property - def is_on(self) -> bool: - """Return true if on.""" - return self.is_streaming + def supported_features(self) -> int: + """Return supported features.""" + supported_features: int = CameraEntityFeature.ON_OFF + if self._model != "NDB": + supported_features |= CameraEntityFeature.STREAM + return supported_features async def async_turn_off(self) -> None: """Turn off camera.""" - await self._data.async_set_state( - home_id=self._home_id, camera_id=self._id, monitoring="off" - ) + await self._camera.async_monitoring_off() async def async_turn_on(self) -> None: """Turn on camera.""" - await self._data.async_set_state( - home_id=self._home_id, camera_id=self._id, monitoring="on" - ) + await self._camera.async_monitoring_on() async def stream_source(self) -> str: """Return the stream source.""" - url = "{0}/live/files/{1}/index.m3u8" - if self._localurl: - return url.format(self._localurl, self._quality) - return url.format(self._vpnurl, self._quality) + if self._camera.is_local: + await self._camera.async_update_camera_urls() - @property - def model(self) -> str: - """Return the camera model.""" - return MODELS[self._model] + if self._camera.local_url: + return "{}/live/files/{}/index.m3u8".format( + self._camera.local_url, self._quality + ) + return f"{self._camera.vpn_url}/live/files/{self._quality}/index.m3u8" @callback def async_update_callback(self) -> None: """Update the entity's state.""" - camera = self._data.get_camera(self._id) - self._vpnurl, self._localurl = self._data.camera_urls(self._id) - self._status = camera.get("status") - self._sd_status = camera.get("sd_status") - self._alim_status = camera.get("alim_status") - self._is_local = camera.get("is_local") - self._attr_is_streaming = bool(self._status == "on") + self._attr_is_on = self._camera.alim_status is not None + self._attr_available = self._camera.alim_status is not None - if self._model == "NACamera": # Smart Indoor Camera - self.hass.data[DOMAIN][DATA_EVENTS][self._id] = self.process_events( - self._data.events.get(self._id, {}) - ) - elif self._model == "NOC": # Smart Outdoor Camera - self.hass.data[DOMAIN][DATA_EVENTS][self._id] = self.process_events( - self._data.outdoor_events.get(self._id, {}) - ) + if self._camera.monitoring is not None: + self._attr_is_streaming = self._camera.monitoring + self._attr_motion_detection_enabled = self._camera.monitoring + + self.hass.data[DOMAIN][DATA_EVENTS][self._id] = self.process_events( + self._camera.events + ) self._attr_extra_state_attributes.update( { "id": self._id, - "status": self._status, - "sd_status": self._sd_status, - "alim_status": self._alim_status, - "is_local": self._is_local, - "vpn_url": self._vpnurl, - "local_url": self._localurl, + "monitoring": self._monitoring, + "sd_status": self._camera.sd_status, + "alim_status": self._camera.alim_status, + "is_local": self._camera.is_local, + "vpn_url": self._camera.vpn_url, + "local_url": self._camera.local_url, "light_state": self._light_state, } ) - def process_events(self, events: dict) -> dict: + def process_events(self, event_list: list[NaEvent]) -> dict: """Add meta data to events.""" - for event in events.values(): - if "video_id" not in event: + events = {} + for event in event_list: + if not (video_id := event.video_id): continue - if self._is_local: - event[ - "media_url" - ] = f"{self._localurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8" - else: - event[ - "media_url" - ] = f"{self._vpnurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8" + event_data = event.__dict__ + event_data["subevents"] = [ + event.__dict__ + for event in event_data.get("subevents", []) + if not isinstance(event, dict) + ] + event_data["media_url"] = self.get_video_url(video_id) + events[event.event_time] = event_data return events + def get_video_url(self, video_id: str) -> str: + """Get video url.""" + if self._camera.is_local: + return f"{self._camera.local_url}/vod/{video_id}/files/{self._quality}/index.m3u8" + return f"{self._camera.vpn_url}/vod/{video_id}/files/{self._quality}/index.m3u8" + def fetch_person_ids(self, persons: list[str | None]) -> list[str]: - """Fetch matching person ids for give list of persons.""" + """Fetch matching person ids for given list of persons.""" person_ids = [] person_id_errors = [] for person in persons: person_id = None - for pid, data in self._data.persons[self._home_id].items(): - if data.get("pseudo") == person: + for pid, data in self._camera.home.persons.items(): + if data.pseudo == person: person_ids.append(pid) person_id = pid break @@ -328,9 +280,7 @@ class NetatmoCamera(NetatmoBase, Camera): persons = kwargs.get(ATTR_PERSONS, []) person_ids = self.fetch_person_ids(persons) - await self._data.async_set_persons_home( - person_ids=person_ids, home_id=self._home_id - ) + await self._camera.home.async_set_persons_home(person_ids=person_ids) _LOGGER.debug("Set %s as at home", persons) async def _service_set_person_away(self, **kwargs: Any) -> None: @@ -339,9 +289,8 @@ class NetatmoCamera(NetatmoBase, Camera): person_ids = self.fetch_person_ids([person] if person else []) person_id = next(iter(person_ids), None) - await self._data.async_set_persons_away( + await self._camera.home.async_set_persons_away( person_id=person_id, - home_id=self._home_id, ) if person_id: @@ -351,10 +300,11 @@ class NetatmoCamera(NetatmoBase, Camera): async def _service_set_camera_light(self, **kwargs: Any) -> None: """Service to set light mode.""" + if not isinstance(self._camera, NaModules.netatmo.NOC): + raise HomeAssistantError( + f"{self._model} <{self._device_name}> does not have a floodlight" + ) + mode = str(kwargs.get(ATTR_CAMERA_LIGHT_MODE)) _LOGGER.debug("Turn %s camera light for '%s'", mode, self._attr_name) - await self._data.async_set_state( - home_id=self._home_id, - camera_id=self._id, - floodlight=mode, - ) + await self._camera.async_set_floodlight_state(mode) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 6b30989dd8f..400004ee4d1 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -2,15 +2,16 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast -import pyatmo +from pyatmo.modules import NATherm1 import voluptuous as vol from homeassistant.components.climate import ( DEFAULT_MIN_TEMP, PRESET_AWAY, PRESET_BOOST, + PRESET_HOME, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -25,12 +26,8 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform -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 @@ -38,25 +35,17 @@ from .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, - DATA_HANDLER, - DATA_HOMES, + CONF_URL_ENERGY, DATA_SCHEDULES, DOMAIN, EVENT_TYPE_CANCEL_SET_POINT, EVENT_TYPE_SCHEDULE, EVENT_TYPE_SET_POINT, EVENT_TYPE_THERM_MODE, - NETATMO_CREATE_BATTERY, + NETATMO_CREATE_CLIMATE, SERVICE_SET_SCHEDULE, - SIGNAL_NAME, - TYPE_ENERGY, -) -from .data_handler import ( - CLIMATE_STATE_CLASS_NAME, - CLIMATE_TOPOLOGY_CLASS_NAME, - NetatmoDataHandler, - NetatmoDevice, ) +from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -65,6 +54,9 @@ PRESET_FROST_GUARD = "Frost Guard" PRESET_SCHEDULE = "Schedule" PRESET_MANUAL = "Manual" +SUPPORT_FLAGS = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE +) SUPPORT_PRESET = [PRESET_AWAY, PRESET_BOOST, PRESET_FROST_GUARD, PRESET_SCHEDULE] STATE_NETATMO_SCHEDULE = "schedule" @@ -116,51 +108,22 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Netatmo energy platform.""" - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - climate_topology = data_handler.data.get(CLIMATE_TOPOLOGY_CLASS_NAME) + @callback + def _create_entity(netatmo_device: NetatmoRoom) -> None: + entity = NetatmoThermostat(netatmo_device) + async_add_entities([entity]) - if not climate_topology or climate_topology.raw_data == {}: - raise PlatformNotReady - - entities = [] - for home_id in climate_topology.home_ids: - signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}" - - await data_handler.subscribe( - CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id - ) - - if (climate_state := data_handler.data[signal_name]) is None: - continue - - climate_topology.register_handler(home_id, climate_state.process_topology) - - for room in climate_state.homes[home_id].rooms.values(): - if room.device_type is None or room.device_type.value not in [ - NA_THERM, - NA_VALVE, - ]: - continue - entities.append(NetatmoThermostat(data_handler, room)) - - hass.data[DOMAIN][DATA_SCHEDULES][home_id] = climate_state.homes[ - home_id - ].schedules - - hass.data[DOMAIN][DATA_HOMES][home_id] = climate_state.homes[home_id].name - - _LOGGER.debug("Adding climate devices %s", entities) - async_add_entities(entities, True) + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_CLIMATE, _create_entity) + ) platform = entity_platform.async_get_current_platform() - - if climate_topology is not None: - platform.async_register_entity_service( - SERVICE_SET_SCHEDULE, - {vol.Required(ATTR_SCHEDULE_NAME): cv.string}, - "_async_service_set_schedule", - ) + platform.async_register_entity_service( + SERVICE_SET_SCHEDULE, + {vol.Required(ATTR_SCHEDULE_NAME): cv.string}, + "_async_service_set_schedule", + ) class NetatmoThermostat(NetatmoBase, ClimateEntity): @@ -169,42 +132,33 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): _attr_hvac_mode = HVACMode.AUTO _attr_max_temp = DEFAULT_MAX_TEMP _attr_preset_modes = SUPPORT_PRESET - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) + _attr_supported_features = SUPPORT_FLAGS _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = TEMP_CELSIUS - def __init__( - self, data_handler: NetatmoDataHandler, room: pyatmo.climate.NetatmoRoom - ) -> None: + def __init__(self, netatmo_device: NetatmoRoom) -> None: """Initialize the sensor.""" ClimateEntity.__init__(self) - super().__init__(data_handler) + super().__init__(netatmo_device.data_handler) - self._room = room + self._room = netatmo_device.room self._id = self._room.entity_id + self._home_id = self._room.home.entity_id - self._signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{self._room.home.entity_id}" - self._climate_state: pyatmo.AsyncClimate = data_handler.data[self._signal_name] - + self._signal_name = f"{HOME}-{self._home_id}" self._publishers.extend( [ { - "name": CLIMATE_TOPOLOGY_CLASS_NAME, - SIGNAL_NAME: CLIMATE_TOPOLOGY_CLASS_NAME, - }, - { - "name": CLIMATE_STATE_CLASS_NAME, + "name": HOME, "home_id": self._room.home.entity_id, SIGNAL_NAME: self._signal_name, }, ] ) - self._model: str = getattr(room.device_type, "value") + self._model: str = f"{self._room.climate_type}" - self._netatmo_type = TYPE_ENERGY + self._config_url = CONF_URL_ENERGY self._attr_name = self._room.name self._away: bool | None = None @@ -231,7 +185,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): EVENT_TYPE_CANCEL_SET_POINT, EVENT_TYPE_SCHEDULE, ): - self.data_handler.config_entry.async_on_unload( + self.async_on_remove( async_dispatcher_connect( self.hass, f"signal-{DOMAIN}-webhook-{event_type}", @@ -239,21 +193,6 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) ) - for module in self._room.modules.values(): - if getattr(module.device_type, "value") not in [NA_THERM, NA_VALVE]: - continue - - async_dispatcher_send( - self.hass, - NETATMO_CREATE_BATTERY, - NetatmoDevice( - self.data_handler, - module, - self._id, - self._signal_name, - ), - ) - @callback def handle_event(self, event: dict) -> None: """Handle webhook events.""" @@ -289,7 +228,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self._attr_target_temperature = self._hg_temperature elif self._attr_preset_mode == PRESET_AWAY: self._attr_target_temperature = self._away_temperature - elif self._attr_preset_mode == PRESET_SCHEDULE: + elif self._attr_preset_mode in [PRESET_SCHEDULE, PRESET_HOME]: self.async_update_callback() self.data_handler.async_force_update(self._signal_name) self.async_write_ha_state() @@ -322,6 +261,10 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): data["event_type"] == EVENT_TYPE_CANCEL_SET_POINT and self._room.entity_id == room["id"] ): + if self._attr_hvac_mode == HVACMode.OFF: + self._attr_hvac_mode = HVACMode.AUTO + self._attr_preset_mode = PRESET_MAP_NETATMO[PRESET_SCHEDULE] + self.async_update_callback() self.async_write_ha_state() return @@ -329,7 +272,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported.""" - if self._model == NA_THERM and self._boilerstatus is not None: + if self._model != NA_VALVE and self._boilerstatus is not None: return CURRENT_HVAC_MAP_NETATMO[self._boilerstatus] # Maybe it is a valve if ( @@ -343,55 +286,36 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): if hvac_mode == HVACMode.OFF: await self.async_turn_off() elif hvac_mode == HVACMode.AUTO: - if self.hvac_mode == HVACMode.OFF: - await self.async_turn_on() await self.async_set_preset_mode(PRESET_SCHEDULE) elif hvac_mode == HVACMode.HEAT: await self.async_set_preset_mode(PRESET_BOOST) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if self.hvac_mode == HVACMode.OFF: - await self.async_turn_on() - - if self.target_temperature == 0: - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, - STATE_NETATMO_HOME, - ) - if ( preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE - and self.hvac_mode == HVACMode.HEAT + and self._attr_hvac_mode == HVACMode.HEAT ): - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, + await self._room.async_therm_set( STATE_NETATMO_HOME, ) elif ( preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE ): - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, + await self._room.async_therm_set( STATE_NETATMO_MANUAL, DEFAULT_MAX_TEMP, ) elif ( preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) - and self.hvac_mode == HVACMode.HEAT + and self._attr_hvac_mode == HVACMode.HEAT ): - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, STATE_NETATMO_HOME - ) + await self._room.async_therm_set(STATE_NETATMO_HOME) elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX): - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, PRESET_MAP_NETATMO[preset_mode] - ) + await self._room.async_therm_set(PRESET_MAP_NETATMO[preset_mode]) elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY): - await self._climate_state.async_set_thermmode( - PRESET_MAP_NETATMO[preset_mode] - ) + await self._room.async_therm_set(PRESET_MAP_NETATMO[preset_mode]) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) @@ -399,33 +323,25 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature for 2 hours.""" - if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: - return - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, STATE_NETATMO_MANUAL, min(temp, DEFAULT_MAX_TEMP) + await self._room.async_therm_set( + STATE_NETATMO_MANUAL, min(kwargs[ATTR_TEMPERATURE], DEFAULT_MAX_TEMP) ) - self.async_write_ha_state() async def async_turn_off(self) -> None: """Turn the entity off.""" if self._model == NA_VALVE: - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, + await self._room.async_therm_set( STATE_NETATMO_MANUAL, DEFAULT_MIN_TEMP, ) - elif self.hvac_mode != HVACMode.OFF: - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, STATE_NETATMO_OFF - ) + elif self._attr_hvac_mode != HVACMode.OFF: + await self._room.async_therm_set(STATE_NETATMO_OFF) self.async_write_ha_state() async def async_turn_on(self) -> None: """Turn the entity on.""" - await self._climate_state.async_set_room_thermpoint( - self._room.entity_id, STATE_NETATMO_HOME - ) + await self._room.async_therm_set(STATE_NETATMO_HOME) self.async_write_ha_state() @property @@ -466,8 +382,11 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ] = self._room.heating_power_request else: for module in self._room.modules.values(): - self._boilerstatus = module.boiler_status - break + if hasattr(module, "boiler_status"): + module = cast(NATherm1, module) + if module.boiler_status is not None: + self._boilerstatus = module.boiler_status + break async def _async_service_set_schedule(self, **kwargs: Any) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) @@ -483,7 +402,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): _LOGGER.error("%s is not a valid schedule", kwargs.get(ATTR_SCHEDULE_NAME)) return - await self._climate_state.async_switch_home_schedule(schedule_id=schedule_id) + await self._room.home.async_switch_schedule(schedule_id=schedule_id) _LOGGER.debug( "Setting %s schedule to %s (%s)", self._room.home.entity_id, diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index ba63c76ad66..99fa195b118 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -6,6 +6,7 @@ import logging from typing import Any import uuid +from pyatmo.const import ALL_SCOPES import voluptuous as vol from homeassistant import config_entries @@ -25,7 +26,6 @@ from .const import ( CONF_UUID, CONF_WEATHER_AREAS, DOMAIN, - NETATMO_SCOPES, ) _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,7 @@ class NetatmoFlowHandler( @property def extra_authorize_data(self) -> dict: """Extra data that needs to be appended to the authorize url.""" - return {"scope": " ".join(NETATMO_SCOPES)} + return {"scope": " ".join(ALL_SCOPES)} async def async_step_user(self, user_input: dict | None = None) -> FlowResult: """Handle a flow start.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 6bd66fa9644..e93d0c91a07 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -10,60 +10,17 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}" PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, + Platform.COVER, Platform.LIGHT, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] -NETATMO_SCOPES = [ - "access_camera", - "access_presence", - "read_camera", - "read_homecoach", - "read_presence", - "read_smokedetector", - "read_station", - "read_thermostat", - "write_camera", - "write_presence", - "write_thermostat", -] - -MODEL_NAPLUG = "Relay" -MODEL_NATHERM1 = "Smart Thermostat" -MODEL_NRV = "Smart Radiator Valves" -MODEL_NOC = "Smart Outdoor Camera" -MODEL_NACAMERA = "Smart Indoor Camera" -MODEL_NSD = "Smart Smoke Alarm" -MODEL_NACAMDOORTAG = "Smart Door and Window Sensors" -MODEL_NHC = "Smart Indoor Air Quality Monitor" -MODEL_NAMAIN = "Smart Home Weather station – indoor module" -MODEL_NAMODULE1 = "Smart Home Weather station – outdoor module" -MODEL_NAMODULE4 = "Smart Additional Indoor module" -MODEL_NAMODULE3 = "Smart Rain Gauge" -MODEL_NAMODULE2 = "Smart Anemometer" -MODEL_PUBLIC = "Public Weather stations" - -MODELS = { - "NAPlug": MODEL_NAPLUG, - "NATherm1": MODEL_NATHERM1, - "NRV": MODEL_NRV, - "NACamera": MODEL_NACAMERA, - "NOC": MODEL_NOC, - "NSD": MODEL_NSD, - "NACamDoorTag": MODEL_NACAMDOORTAG, - "NHC": MODEL_NHC, - "NAMain": MODEL_NAMAIN, - "NAModule1": MODEL_NAMODULE1, - "NAModule4": MODEL_NAMODULE4, - "NAModule3": MODEL_NAMODULE3, - "NAModule2": MODEL_NAMODULE2, - "public": MODEL_PUBLIC, -} - -TYPE_SECURITY = "security" -TYPE_ENERGY = "energy" -TYPE_WEATHER = "weather" +CONF_URL_SECURITY = "https://home.netatmo.com/security" +CONF_URL_ENERGY = "https://my.netatmo.com/app/energy" +CONF_URL_WEATHER = "https://my.netatmo.com/app/weather" +CONF_URL_CONTROL = "https://home.netatmo.com/control" AUTH = "netatmo_auth" CONF_PUBLIC = "public_sensor_config" @@ -71,7 +28,18 @@ CAMERA_DATA = "netatmo_camera" HOME_DATA = "netatmo_home_data" DATA_HANDLER = "netatmo_data_handler" SIGNAL_NAME = "signal_name" + NETATMO_CREATE_BATTERY = "netatmo_create_battery" +NETATMO_CREATE_CAMERA = "netatmo_create_camera" +NETATMO_CREATE_CAMERA_LIGHT = "netatmo_create_camera_light" +NETATMO_CREATE_CLIMATE = "netatmo_create_climate" +NETATMO_CREATE_COVER = "netatmo_create_cover" +NETATMO_CREATE_LIGHT = "netatmo_create_light" +NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor" +NETATMO_CREATE_SELECT = "netatmo_create_select" +NETATMO_CREATE_SENSOR = "netatmo_create_sensor" +NETATMO_CREATE_SWITCH = "netatmo_create_switch" +NETATMO_CREATE_WEATHER_SENSOR = "netatmo_create_weather_sensor" CONF_AREA_NAME = "area_name" CONF_CLOUDHOOK_URL = "cloudhook_url" diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py new file mode 100644 index 00000000000..6d755d828d3 --- /dev/null +++ b/homeassistant/components/netatmo/cover.py @@ -0,0 +1,110 @@ +"""Support for Netatmo/Bubendorff covers.""" +from __future__ import annotations + +import logging +from typing import Any, cast + +from pyatmo import modules as NaModules + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .netatmo_entity_base import NetatmoBase + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo cover platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoCover(netatmo_device) + _LOGGER.debug("Adding cover %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_COVER, _create_entity) + ) + + +class NetatmoCover(NetatmoBase, CoverEntity): + """Representation of a Netatmo cover device.""" + + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + def __init__(self, netatmo_device: NetatmoDevice) -> None: + """Initialize the Netatmo device.""" + super().__init__(netatmo_device.data_handler) + + self._cover = cast(NaModules.Shutter, netatmo_device.device) + + self._id = self._cover.entity_id + self._attr_name = self._device_name = self._cover.name + self._model = self._cover.device_type + self._config_url = CONF_URL_CONTROL + + self._home_id = self._cover.home.entity_id + self._attr_is_closed = self._cover.current_position == 0 + + self._signal_name = f"{HOME}-{self._home_id}" + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self._home_id, + SIGNAL_NAME: self._signal_name, + }, + ] + ) + self._attr_unique_id = f"{self._id}-{self._model}" + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self._cover.async_close() + self._attr_is_closed = True + self.async_write_ha_state() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._cover.async_open() + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._cover.async_stop() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover shutter to a specific position.""" + await self._cover.async_set_target_position(kwargs[ATTR_POSITION]) + + @property + def device_class(self) -> str: + """Return the device class.""" + return CoverDeviceClass.SHUTTER + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + self._attr_is_closed = self._cover.current_position == 0 + self._attr_current_cover_position = self._cover.current_position diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 50a3bed17ff..a376e6ee187 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -11,16 +11,34 @@ from time import time from typing import Any import pyatmo +from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.event import async_track_time_interval from .const import ( AUTH, + DATA_PERSONS, + DATA_SCHEDULES, DOMAIN, MANUFACTURER, + 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, WEBHOOK_ACTIVATION, WEBHOOK_DEACTIVATION, WEBHOOK_NACAMERA_CONNECTION, @@ -29,30 +47,31 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -CAMERA_DATA_CLASS_NAME = "AsyncCameraData" -WEATHERSTATION_DATA_CLASS_NAME = "AsyncWeatherStationData" -HOMECOACH_DATA_CLASS_NAME = "AsyncHomeCoachData" -CLIMATE_TOPOLOGY_CLASS_NAME = "AsyncClimateTopology" -CLIMATE_STATE_CLASS_NAME = "AsyncClimate" -PUBLICDATA_DATA_CLASS_NAME = "AsyncPublicData" +SIGNAL_NAME = "signal_name" +ACCOUNT = "account" +HOME = "home" +WEATHER = "weather" +AIR_CARE = "air_care" +PUBLIC = "public" +EVENT = "event" -DATA_CLASSES = { - WEATHERSTATION_DATA_CLASS_NAME: pyatmo.AsyncWeatherStationData, - HOMECOACH_DATA_CLASS_NAME: pyatmo.AsyncHomeCoachData, - CAMERA_DATA_CLASS_NAME: pyatmo.AsyncCameraData, - CLIMATE_TOPOLOGY_CLASS_NAME: pyatmo.AsyncClimateTopology, - CLIMATE_STATE_CLASS_NAME: pyatmo.AsyncClimate, - PUBLICDATA_DATA_CLASS_NAME: pyatmo.AsyncPublicData, +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", } BATCH_SIZE = 3 DEFAULT_INTERVALS = { - CLIMATE_TOPOLOGY_CLASS_NAME: 3600, - CLIMATE_STATE_CLASS_NAME: 300, - CAMERA_DATA_CLASS_NAME: 900, - WEATHERSTATION_DATA_CLASS_NAME: 600, - HOMECOACH_DATA_CLASS_NAME: 300, - PUBLICDATA_DATA_CLASS_NAME: 600, + ACCOUNT: 10800, + HOME: 300, + WEATHER: 600, + AIR_CARE: 300, + PUBLIC: 600, + EVENT: 600, } SCAN_INTERVAL = 60 @@ -62,7 +81,27 @@ class NetatmoDevice: """Netatmo device class.""" data_handler: NetatmoDataHandler - device: pyatmo.climate.NetatmoModule + 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 parent_id: str signal_name: str @@ -74,25 +113,27 @@ class NetatmoPublisher: name: str interval: int next_scan: float - subscriptions: list[CALLBACK_TYPE | None] + subscriptions: set[CALLBACK_TYPE | None] + method: str + kwargs: dict class NetatmoDataHandler: """Manages the Netatmo data handling.""" + account: pyatmo.AsyncAccount + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize self.""" self.hass = hass self.config_entry = config_entry self._auth = hass.data[DOMAIN][config_entry.entry_id][AUTH] self.publisher: dict[str, NetatmoPublisher] = {} - self.data: dict = {} self._queue: deque = deque() self._webhook: bool = False async def async_setup(self) -> None: """Set up the Netatmo data handler.""" - async_track_time_interval( self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) ) @@ -105,17 +146,14 @@ class NetatmoDataHandler: ) ) - await asyncio.gather( - *[ - self.subscribe(data_class, data_class, None) - for data_class in ( - CLIMATE_TOPOLOGY_CLASS_NAME, - CAMERA_DATA_CLASS_NAME, - WEATHERSTATION_DATA_CLASS_NAME, - HOMECOACH_DATA_CLASS_NAME, - ) - ] + 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 ) + await self.async_dispatch() async def async_update(self, event_time: datetime) -> None: """ @@ -153,19 +191,17 @@ class NetatmoDataHandler: elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_NACAMERA_CONNECTION: _LOGGER.debug("%s camera reconnected", MANUFACTURER) - self.async_force_update(CAMERA_DATA_CLASS_NAME) + self.async_force_update(ACCOUNT) async def async_fetch_data(self, signal_name: str) -> None: """Fetch data and notify.""" - if self.data[signal_name] is None: - return - try: - await self.data[signal_name].async_update() + await getattr(self.account, self.publisher[signal_name].method)( + **self.publisher[signal_name].kwargs + ) except pyatmo.NoDevice as err: _LOGGER.debug(err) - self.data[signal_name] = None except pyatmo.ApiError as err: _LOGGER.debug(err) @@ -188,18 +224,21 @@ class NetatmoDataHandler: """Subscribe to publisher.""" if signal_name in self.publisher: if update_callback not in self.publisher[signal_name].subscriptions: - self.publisher[signal_name].subscriptions.append(update_callback) + self.publisher[signal_name].subscriptions.add(update_callback) return + if publisher == "public": + kwargs = {"area_id": self.account.register_public_weather_area(**kwargs)} + self.publisher[signal_name] = NetatmoPublisher( name=signal_name, interval=DEFAULT_INTERVALS[publisher], next_scan=time() + DEFAULT_INTERVALS[publisher], - subscriptions=[update_callback], + subscriptions={update_callback}, + method=PUBLISHERS[publisher], + kwargs=kwargs, ) - self.data[signal_name] = DATA_CLASSES[publisher](self._auth, **kwargs) - try: await self.async_fetch_data(signal_name) except KeyError: @@ -213,15 +252,158 @@ class NetatmoDataHandler: self, signal_name: str, update_callback: CALLBACK_TYPE | None ) -> None: """Unsubscribe from publisher.""" + if update_callback in self.publisher[signal_name].subscriptions: + return + 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) - self.data.pop(signal_name) _LOGGER.debug("Publisher %s removed", signal_name) @property def webhook(self) -> bool: """Return the webhook state.""" return self._webhook + + 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, + ), + ) diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index 955671e3dc1..b037f45533f 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -31,10 +31,6 @@ from .const import ( DOMAIN, EVENT_TYPE_THERM_MODE, INDOOR_CAMERA_TRIGGERS, - MODEL_NACAMERA, - MODEL_NATHERM1, - MODEL_NOC, - MODEL_NRV, NETATMO_EVENT, OUTDOOR_CAMERA_TRIGGERS, ) @@ -42,10 +38,10 @@ from .const import ( CONF_SUBTYPE = "subtype" DEVICES = { - MODEL_NACAMERA: INDOOR_CAMERA_TRIGGERS, - MODEL_NOC: OUTDOOR_CAMERA_TRIGGERS, - MODEL_NATHERM1: CLIMATE_TRIGGERS, - MODEL_NRV: CLIMATE_TRIGGERS, + "NACamera": INDOOR_CAMERA_TRIGGERS, + "NOC": OUTDOOR_CAMERA_TRIGGERS, + "NATherm1": CLIMATE_TRIGGERS, + "NRV": CLIMATE_TRIGGERS, } SUBTYPES = { @@ -76,7 +72,7 @@ async def async_validate_trigger_config( device_registry = dr.async_get(hass) device = device_registry.async_get(config[CONF_DEVICE_ID]) - if not device: + if not device or device.model is None: raise InvalidDeviceAutomationConfig( f"Trigger invalid, device with ID {config[CONF_DEVICE_ID]} not found" ) diff --git a/homeassistant/components/netatmo/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py index 6c82c7f1db7..cac9c695f19 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import DATA_HANDLER, DOMAIN -from .data_handler import CLIMATE_TOPOLOGY_CLASS_NAME, NetatmoDataHandler +from .data_handler import ACCOUNT, NetatmoDataHandler TO_REDACT = { "access_token", @@ -45,8 +45,8 @@ async def async_get_config_entry_diagnostics( TO_REDACT, ), "data": { - CLIMATE_TOPOLOGY_CLASS_NAME: async_redact_data( - getattr(data_handler.data[CLIMATE_TOPOLOGY_CLASS_NAME], "raw_data"), + ACCOUNT: async_redact_data( + getattr(data_handler.account, "raw_data"), TO_REDACT, ) }, diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 6567ae770f2..b3e352eb7d8 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -4,25 +4,25 @@ from __future__ import annotations import logging from typing import Any, cast -import pyatmo +from pyatmo import modules as NaModules -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - DATA_HANDLER, + CONF_URL_CONTROL, + CONF_URL_SECURITY, DOMAIN, EVENT_TYPE_LIGHT_MODE, - SIGNAL_NAME, - TYPE_SECURITY, + NETATMO_CREATE_CAMERA_LIGHT, + NETATMO_CREATE_LIGHT, WEBHOOK_LIGHT_MODE, WEBHOOK_PUSH_TYPE, ) -from .data_handler import CAMERA_DATA_CLASS_NAME, NetatmoDataHandler +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -32,66 +32,73 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Netatmo camera light platform.""" - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - data_class = data_handler.data.get(CAMERA_DATA_CLASS_NAME) - if not data_class or data_class.raw_data == {}: - raise PlatformNotReady + @callback + def _create_camera_light_entity(netatmo_device: NetatmoDevice) -> None: + if not hasattr(netatmo_device.device, "floodlight"): + return - all_cameras = [] - for home in data_handler.data[CAMERA_DATA_CLASS_NAME].cameras.values(): - for camera in home.values(): - all_cameras.append(camera) + entity = NetatmoCameraLight(netatmo_device) + async_add_entities([entity]) - entities = [ - NetatmoLight( - data_handler, - camera["id"], - camera["type"], - camera["home_id"], + entry.async_on_unload( + async_dispatcher_connect( + hass, NETATMO_CREATE_CAMERA_LIGHT, _create_camera_light_entity ) - for camera in all_cameras - if camera["type"] == "NOC" - ] + ) - _LOGGER.debug("Adding camera lights %s", entities) - async_add_entities(entities, True) + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + if not hasattr(netatmo_device.device, "brightness"): + return + + entity = NetatmoLight(netatmo_device) + _LOGGER.debug("Adding light %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_LIGHT, _create_entity) + ) -class NetatmoLight(NetatmoBase, LightEntity): +class NetatmoCameraLight(NetatmoBase, LightEntity): """Representation of a Netatmo Presence camera light.""" - _attr_color_mode = ColorMode.ONOFF _attr_has_entity_name = True - _attr_supported_color_modes = {ColorMode.ONOFF} def __init__( self, - data_handler: NetatmoDataHandler, - camera_id: str, - camera_type: str, - home_id: str, + netatmo_device: NetatmoDevice, ) -> None: """Initialize a Netatmo Presence camera light.""" LightEntity.__init__(self) - super().__init__(data_handler) + super().__init__(netatmo_device.data_handler) - self._publishers.append( - {"name": CAMERA_DATA_CLASS_NAME, SIGNAL_NAME: CAMERA_DATA_CLASS_NAME} - ) - self._id = camera_id - self._home_id = home_id - self._model = camera_type - self._netatmo_type = TYPE_SECURITY - self._device_name: str = self._data.get_camera(camera_id)["name"] + self._camera = cast(NaModules.NOC, netatmo_device.device) + self._id = self._camera.entity_id + self._home_id = self._camera.home.entity_id + self._device_name = self._camera.name + self._model = self._camera.device_type + self._config_url = CONF_URL_SECURITY self._is_on = False self._attr_unique_id = f"{self._id}-light" + self._signal_name = f"{HOME}-{self._home_id}" + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self._camera.home.entity_id, + SIGNAL_NAME: self._signal_name, + }, + ] + ) + async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() - self.data_handler.config_entry.async_on_unload( + self.async_on_remove( async_dispatcher_connect( self.hass, f"signal-{DOMAIN}-webhook-{EVENT_TYPE_LIGHT_MODE}", @@ -117,14 +124,6 @@ class NetatmoLight(NetatmoBase, LightEntity): self.async_write_ha_state() return - @property - def _data(self) -> pyatmo.AsyncCameraData: - """Return data for this entity.""" - return cast( - pyatmo.AsyncCameraData, - self.data_handler.data[self._publishers[0]["name"]], - ) - @property def available(self) -> bool: """If the webhook is not established, mark as unavailable.""" @@ -138,22 +137,79 @@ class NetatmoLight(NetatmoBase, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn camera floodlight on.""" _LOGGER.debug("Turn camera '%s' on", self.name) - await self._data.async_set_state( - home_id=self._home_id, - camera_id=self._id, - floodlight="on", - ) + await self._camera.async_floodlight_on() async def async_turn_off(self, **kwargs: Any) -> None: """Turn camera floodlight into auto mode.""" _LOGGER.debug("Turn camera '%s' to auto mode", self.name) - await self._data.async_set_state( - home_id=self._home_id, - camera_id=self._id, - floodlight="auto", - ) + await self._camera.async_floodlight_auto() @callback def async_update_callback(self) -> None: """Update the entity's state.""" - self._is_on = bool(self._data.get_light_state(self._id) == "on") + self._is_on = bool(self._camera.floodlight == "on") + + +class NetatmoLight(NetatmoBase, LightEntity): + """Representation of a dimmable light by Legrand/BTicino.""" + + def __init__( + self, + netatmo_device: NetatmoDevice, + ) -> None: + """Initialize a Netatmo light.""" + super().__init__(netatmo_device.data_handler) + + self._dimmer = cast(NaModules.NLFN, netatmo_device.device) + self._id = self._dimmer.entity_id + self._home_id = self._dimmer.home.entity_id + self._device_name = self._dimmer.name + self._attr_name = f"{self._device_name}" + self._model = self._dimmer.device_type + self._config_url = CONF_URL_CONTROL + self._attr_brightness = 0 + self._attr_unique_id = f"{self._id}-light" + + self._attr_supported_color_modes: set[str] = set() + + if not self._attr_supported_color_modes and self._dimmer.brightness is not None: + self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + + self._signal_name = f"{HOME}-{self._home_id}" + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self._dimmer.home.entity_id, + SIGNAL_NAME: self._signal_name, + }, + ] + ) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self._dimmer.on is True + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn light on.""" + _LOGGER.debug("Turn light '%s' on", self.name) + if ATTR_BRIGHTNESS in kwargs: + await self._dimmer.async_set_brightness(kwargs[ATTR_BRIGHTNESS]) + + else: + await self._dimmer.async_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + _LOGGER.debug("Turn light '%s' off", self.name) + await self._dimmer.async_off() + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + if self._dimmer.brightness is not None: + # Netatmo uses a range of [0, 100] to control brightness + self._attr_brightness = round((self._dimmer.brightness / 100) * 255) + else: + self._attr_brightness = None diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 2081f9bd274..b198c43bb39 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -2,7 +2,7 @@ "domain": "netatmo", "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", - "requirements": ["pyatmo==6.2.4"], + "requirements": ["pyatmo==7.0.1"], "after_dependencies": ["cloud", "media_source"], "dependencies": ["application_credentials", "webhook"], "codeowners": ["@cgtobi"], diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 948d162a613..58bf2f93c96 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -72,21 +72,13 @@ class NetatmoSource(MediaSource): self, source: str, camera_id: str, event_id: int | None = None ) -> BrowseMediaSource: if event_id and event_id in self.events[camera_id]: - created = dt.datetime.fromtimestamp(event_id) - if self.events[camera_id][event_id]["type"] == "outdoor": - thumbnail = ( - self.events[camera_id][event_id]["event_list"][0] - .get("snapshot", {}) - .get("url") - ) - message = remove_html_tags( - self.events[camera_id][event_id]["event_list"][0]["message"] - ) - else: - thumbnail = ( - self.events[camera_id][event_id].get("snapshot", {}).get("url") - ) - message = remove_html_tags(self.events[camera_id][event_id]["message"]) + created = dt.datetime.fromtimestamp( + self.events[camera_id][event_id]["event_time"] + ) + thumbnail = self.events[camera_id][event_id].get("snapshot", {}).get("url") + message = remove_html_tags( + self.events[camera_id][event_id].get("message", "") + ) title = f"{created} - {message}" else: title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER) diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index e8a346ccd84..081d06f5d4f 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -3,20 +3,18 @@ from __future__ import annotations from typing import Any +from pyatmo.modules.device_types import ( + DEVICE_DESCRIPTION_MAP, + DeviceType as NetatmoDeviceType, +) + from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import ( - DATA_DEVICE_IDS, - DEFAULT_ATTRIBUTION, - DOMAIN, - MANUFACTURER, - MODELS, - SIGNAL_NAME, -) -from .data_handler import PUBLICDATA_DATA_CLASS_NAME, NetatmoDataHandler +from .const import DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, SIGNAL_NAME +from .data_handler import PUBLIC, NetatmoDataHandler class NetatmoBase(Entity): @@ -30,38 +28,38 @@ class NetatmoBase(Entity): self._device_name: str = "" self._id: str = "" self._model: str = "" - self._netatmo_type: str = "" + self._config_url: str = "" self._attr_name = None self._attr_unique_id = None self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} async def async_added_to_hass(self) -> None: """Entity created.""" - for data_class in self._publishers: - signal_name = data_class[SIGNAL_NAME] + for publisher in self._publishers: + signal_name = publisher[SIGNAL_NAME] - if "home_id" in data_class: + if "home_id" in publisher: await self.data_handler.subscribe( - data_class["name"], + publisher["name"], signal_name, self.async_update_callback, - home_id=data_class["home_id"], + home_id=publisher["home_id"], ) - elif data_class["name"] == PUBLICDATA_DATA_CLASS_NAME: + elif publisher["name"] == PUBLIC: await self.data_handler.subscribe( - data_class["name"], + publisher["name"], signal_name, self.async_update_callback, - lat_ne=data_class["lat_ne"], - lon_ne=data_class["lon_ne"], - lat_sw=data_class["lat_sw"], - lon_sw=data_class["lon_sw"], + lat_ne=publisher["lat_ne"], + lon_ne=publisher["lon_ne"], + lat_sw=publisher["lat_sw"], + lon_sw=publisher["lon_sw"], ) else: await self.data_handler.subscribe( - data_class["name"], signal_name, self.async_update_callback + publisher["name"], signal_name, self.async_update_callback ) for sub in self.data_handler.publisher[signal_name].subscriptions: @@ -78,9 +76,9 @@ class NetatmoBase(Entity): """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() - for data_class in self._publishers: + for publisher in self._publishers: await self.data_handler.unsubscribe( - data_class[SIGNAL_NAME], self.async_update_callback + publisher[SIGNAL_NAME], self.async_update_callback ) @callback @@ -91,10 +89,13 @@ class NetatmoBase(Entity): @property def device_info(self) -> DeviceInfo: """Return the device info for the sensor.""" + manufacturer, model = DEVICE_DESCRIPTION_MAP[ + getattr(NetatmoDeviceType, self._model) + ] return DeviceInfo( - configuration_url=f"https://my.netatmo.com/app/{self._netatmo_type}", + configuration_url=self._config_url, identifiers={(DOMAIN, self._id)}, name=self._device_name, - manufacturer=MANUFACTURER, - model=MODELS[self._model], + manufacturer=manufacturer, + model=model, ) diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 62e6ef25969..3651ae05e88 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -3,29 +3,20 @@ from __future__ import annotations import logging -import pyatmo - from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - DATA_HANDLER, + CONF_URL_ENERGY, DATA_SCHEDULES, DOMAIN, EVENT_TYPE_SCHEDULE, - MANUFACTURER, - SIGNAL_NAME, - TYPE_ENERGY, -) -from .data_handler import ( - CLIMATE_STATE_CLASS_NAME, - CLIMATE_TOPOLOGY_CLASS_NAME, - NetatmoDataHandler, + NETATMO_CREATE_SELECT, ) +from .data_handler import HOME, SIGNAL_NAME, NetatmoHome from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -35,100 +26,66 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Netatmo energy platform schedule selector.""" - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - climate_topology = data_handler.data.get(CLIMATE_TOPOLOGY_CLASS_NAME) + @callback + def _create_entity(netatmo_home: NetatmoHome) -> None: + entity = NetatmoScheduleSelect(netatmo_home) + async_add_entities([entity]) - if not climate_topology or climate_topology.raw_data == {}: - raise PlatformNotReady - - entities = [] - for home_id in climate_topology.home_ids: - signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}" - - await data_handler.subscribe( - CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id - ) - - if (climate_state := data_handler.data[signal_name]) is None: - continue - - climate_topology.register_handler(home_id, climate_state.process_topology) - - hass.data[DOMAIN][DATA_SCHEDULES][home_id] = climate_state.homes[ - home_id - ].schedules - - entities = [ - NetatmoScheduleSelect( - data_handler, - home_id, - [schedule.name for schedule in schedules.values()], - ) - for home_id, schedules in hass.data[DOMAIN][DATA_SCHEDULES].items() - if schedules - ] - - _LOGGER.debug("Adding climate schedule select entities %s", entities) - async_add_entities(entities, True) + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_SELECT, _create_entity) + ) class NetatmoScheduleSelect(NetatmoBase, SelectEntity): """Representation a Netatmo thermostat schedule selector.""" def __init__( - self, data_handler: NetatmoDataHandler, home_id: str, options: list + self, + netatmo_home: NetatmoHome, ) -> None: """Initialize the select entity.""" SelectEntity.__init__(self) - super().__init__(data_handler) + super().__init__(netatmo_home.data_handler) - self._home_id = home_id - - self._climate_state_class = f"{CLIMATE_STATE_CLASS_NAME}-{self._home_id}" - self._climate_state: pyatmo.AsyncClimate = data_handler.data[ - self._climate_state_class - ] - - self._home = self._climate_state.homes[self._home_id] + self._home = netatmo_home.home + self._home_id = self._home.entity_id + self._signal_name = netatmo_home.signal_name self._publishers.extend( [ { - "name": CLIMATE_TOPOLOGY_CLASS_NAME, - SIGNAL_NAME: CLIMATE_TOPOLOGY_CLASS_NAME, - }, - { - "name": CLIMATE_STATE_CLASS_NAME, - "home_id": self._home_id, - SIGNAL_NAME: self._climate_state_class, + "name": HOME, + "home_id": self._home.entity_id, + SIGNAL_NAME: self._signal_name, }, ] ) self._device_name = self._home.name - self._attr_name = f"{MANUFACTURER} {self._device_name}" + self._attr_name = f"{self._device_name}" self._model: str = "NATherm1" - self._netatmo_type = TYPE_ENERGY + self._config_url = CONF_URL_ENERGY self._attr_unique_id = f"{self._home_id}-schedule-select" self._attr_current_option = getattr(self._home.get_selected_schedule(), "name") - self._attr_options = options + self._attr_options = [ + schedule.name for schedule in self._home.schedules.values() + ] async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() - for event_type in (EVENT_TYPE_SCHEDULE,): - self.data_handler.config_entry.async_on_unload( - async_dispatcher_connect( - self.hass, - f"signal-{DOMAIN}-webhook-{event_type}", - self.handle_event, - ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"signal-{DOMAIN}-webhook-{EVENT_TYPE_SCHEDULE}", + self.handle_event, ) + ) @callback def handle_event(self, event: dict) -> None: @@ -160,7 +117,7 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): option, sid, ) - await self._climate_state.async_switch_home_schedule(schedule_id=sid) + await self._home.async_switch_schedule(schedule_id=sid) break @callback @@ -169,8 +126,5 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity): self._attr_current_option = getattr(self._home.get_selected_schedule(), "name") self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id] = self._home.schedules self._attr_options = [ - schedule.name - for schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][ - self._home_id - ].values() + schedule.name for schedule in self._home.schedules.values() ] diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index bec9af96442..ff555ecd472 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,9 +1,9 @@ -"""Support for the Netatmo Weather Service.""" +"""Support for the Netatmo sensors.""" from __future__ import annotations from dataclasses import dataclass import logging -from typing import NamedTuple, cast +from typing import cast import pyatmo @@ -21,15 +21,15 @@ from homeassistant.const import ( DEGREE, LENGTH_MILLIMETERS, PERCENTAGE, + POWER_WATT, PRESSURE_MBAR, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, SOUND_PRESSURE_DB, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import async_entries_for_config_entry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -38,20 +38,18 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + CONF_URL_ENERGY, + CONF_URL_WEATHER, CONF_WEATHER_AREAS, DATA_HANDLER, DOMAIN, NETATMO_CREATE_BATTERY, + NETATMO_CREATE_ROOM_SENSOR, + NETATMO_CREATE_SENSOR, + NETATMO_CREATE_WEATHER_SENSOR, SIGNAL_NAME, - TYPE_WEATHER, -) -from .data_handler import ( - HOMECOACH_DATA_CLASS_NAME, - PUBLICDATA_DATA_CLASS_NAME, - WEATHERSTATION_DATA_CLASS_NAME, - NetatmoDataHandler, - NetatmoDevice, ) +from .data_handler import HOME, PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom from .helper import NetatmoArea from .netatmo_entity_base import NetatmoBase @@ -62,10 +60,12 @@ SUPPORTED_PUBLIC_SENSOR_TYPES: tuple[str, ...] = ( "pressure", "humidity", "rain", - "windstrength", - "guststrength", + "wind_strength", + "gust_strength", "sum_rain_1", "sum_rain_24", + "wind_angle", + "gust_angle", ) @@ -85,7 +85,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="temperature", name="Temperature", - netatmo_name="Temperature", + netatmo_name="temperature", entity_registry_enabled_default=True, native_unit_of_measurement=TEMP_CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -101,7 +101,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="co2", name="CO2", - netatmo_name="CO2", + netatmo_name="co2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, @@ -110,7 +110,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="pressure", name="Pressure", - netatmo_name="Pressure", + netatmo_name="pressure", entity_registry_enabled_default=True, native_unit_of_measurement=PRESSURE_MBAR, state_class=SensorStateClass.MEASUREMENT, @@ -126,7 +126,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="noise", name="Noise", - netatmo_name="Noise", + netatmo_name="noise", entity_registry_enabled_default=True, native_unit_of_measurement=SOUND_PRESSURE_DB, icon="mdi:volume-high", @@ -135,7 +135,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="humidity", name="Humidity", - netatmo_name="Humidity", + netatmo_name="humidity", entity_registry_enabled_default=True, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -144,7 +144,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="rain", name="Rain", - netatmo_name="Rain", + netatmo_name="rain", entity_registry_enabled_default=True, native_unit_of_measurement=LENGTH_MILLIMETERS, state_class=SensorStateClass.MEASUREMENT, @@ -156,7 +156,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( netatmo_name="sum_rain_1", entity_registry_enabled_default=False, native_unit_of_measurement=LENGTH_MILLIMETERS, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:weather-rainy", ), NetatmoSensorEntityDescription( @@ -171,7 +171,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="battery_percent", name="Battery Percent", - netatmo_name="battery_percent", + netatmo_name="battery", entity_registry_enabled_default=True, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -181,14 +181,14 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="windangle", name="Direction", - netatmo_name="WindAngle", + netatmo_name="wind_direction", entity_registry_enabled_default=True, icon="mdi:compass-outline", ), NetatmoSensorEntityDescription( key="windangle_value", name="Angle", - netatmo_name="WindAngle", + netatmo_name="wind_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", @@ -197,7 +197,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="windstrength", name="Wind Strength", - netatmo_name="WindStrength", + netatmo_name="wind_strength", entity_registry_enabled_default=True, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", @@ -206,14 +206,14 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="gustangle", name="Gust Direction", - netatmo_name="GustAngle", + netatmo_name="gust_direction", entity_registry_enabled_default=False, icon="mdi:compass-outline", ), NetatmoSensorEntityDescription( key="gustangle_value", name="Gust Angle", - netatmo_name="GustAngle", + netatmo_name="gust_angle", entity_registry_enabled_default=False, native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", @@ -222,7 +222,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="guststrength", name="Gust Strength", - netatmo_name="GustStrength", + netatmo_name="gust_strength", entity_registry_enabled_default=False, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", @@ -239,39 +239,19 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( NetatmoSensorEntityDescription( key="rf_status", name="Radio", - netatmo_name="rf_status", + netatmo_name="rf_strength", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:signal", ), - NetatmoSensorEntityDescription( - key="rf_status_lvl", - name="Radio Level", - netatmo_name="rf_status", - entity_registry_enabled_default=False, - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - ), NetatmoSensorEntityDescription( key="wifi_status", name="Wifi", - netatmo_name="wifi_status", + netatmo_name="wifi_strength", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:wifi", ), - NetatmoSensorEntityDescription( - key="wifi_status_lvl", - name="Wifi Level", - netatmo_name="wifi_status", - entity_registry_enabled_default=False, - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - ), NetatmoSensorEntityDescription( key="health_idx", name="Health", @@ -279,136 +259,110 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( entity_registry_enabled_default=True, icon="mdi:cloud", ), + NetatmoSensorEntityDescription( + key="power", + name="Power", + netatmo_name="power", + entity_registry_enabled_default=True, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.POWER, + ), ) SENSOR_TYPES_KEYS = [desc.key for desc in SENSOR_TYPES] -MODULE_TYPE_OUTDOOR = "NAModule1" -MODULE_TYPE_WIND = "NAModule2" -MODULE_TYPE_RAIN = "NAModule3" -MODULE_TYPE_INDOOR = "NAModule4" - - -class BatteryData(NamedTuple): - """Metadata for a batter.""" - - full: int - high: int - medium: int - low: int - - -BATTERY_VALUES = { - MODULE_TYPE_WIND: BatteryData( - full=5590, - high=5180, - medium=4770, - low=4360, - ), - MODULE_TYPE_RAIN: BatteryData( - full=5500, - high=5000, - medium=4500, - low=4000, - ), - MODULE_TYPE_INDOOR: BatteryData( - full=5500, - high=5280, - medium=4920, - low=4560, - ), - MODULE_TYPE_OUTDOOR: BatteryData( - full=5500, - high=5000, - medium=4500, - low=4000, - ), -} - -PUBLIC = "public" +BATTERY_SENSOR_DESCRIPTION = NetatmoSensorEntityDescription( + key="battery", + name="Battery Percent", + netatmo_name="battery", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, +) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the Netatmo weather and homecoach platform.""" - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - platform_not_ready = True + """Set up the Netatmo sensor platform.""" - async def find_entities(data_class_name: str) -> list: - """Find all entities.""" - all_module_infos = {} - data = data_handler.data + @callback + def _create_battery_entity(netatmo_device: NetatmoDevice) -> None: + if not hasattr(netatmo_device.device, "battery"): + return + entity = NetatmoClimateBatterySensor(netatmo_device) + async_add_entities([entity]) - if data_class_name not in data: - return [] + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_BATTERY, _create_battery_entity) + ) - if data[data_class_name] is None: - return [] + @callback + def _create_weather_sensor_entity(netatmo_device: NetatmoDevice) -> None: + async_add_entities( + NetatmoWeatherSensor(netatmo_device, description) + for description in SENSOR_TYPES + if description.netatmo_name in netatmo_device.device.features + ) - data_class = data[data_class_name] + entry.async_on_unload( + async_dispatcher_connect( + hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_sensor_entity + ) + ) - for station_id in data_class.stations: - for module_id in data_class.get_modules(station_id): - all_module_infos[module_id] = data_class.get_module(module_id) - - all_module_infos[station_id] = data_class.get_station(station_id) - - entities = [] - for module in all_module_infos.values(): - if "_id" not in module: - _LOGGER.debug("Skipping module %s", module.get("module_name")) - continue - - conditions = [ - c.lower() - for c in data_class.get_monitored_conditions(module_id=module["_id"]) - if c.lower() in SENSOR_TYPES_KEYS + @callback + def _create_sensor_entity(netatmo_device: NetatmoDevice) -> None: + _LOGGER.debug( + "Adding %s sensor %s", + netatmo_device.device.device_category, + netatmo_device.device.name, + ) + async_add_entities( + [ + NetatmoSensor(netatmo_device, description) + for description in SENSOR_TYPES + if description.key in netatmo_device.device.features ] - for condition in conditions: - if f"{condition}_value" in SENSOR_TYPES_KEYS: - conditions.append(f"{condition}_value") - elif f"{condition}_lvl" in SENSOR_TYPES_KEYS: - conditions.append(f"{condition}_lvl") + ) - entities.extend( - [ - NetatmoSensor(data_handler, data_class_name, module, description) - for description in SENSOR_TYPES - if description.key in conditions - ] - ) + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_SENSOR, _create_sensor_entity) + ) - _LOGGER.debug("Adding weather sensors %s", entities) - return entities + @callback + def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: + async_add_entities( + NetatmoRoomSensor(netatmo_device, description) + for description in SENSOR_TYPES + if description.key in netatmo_device.room.features + ) - for data_class_name in ( - WEATHERSTATION_DATA_CLASS_NAME, - HOMECOACH_DATA_CLASS_NAME, - ): - data_class = data_handler.data.get(data_class_name) - - if data_class and data_class.raw_data: - platform_not_ready = False - - async_add_entities(await find_entities(data_class_name), True) + entry.async_on_unload( + async_dispatcher_connect( + hass, NETATMO_CREATE_ROOM_SENSOR, _create_room_sensor_entity + ) + ) device_registry = dr.async_get(hass) + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] async def add_public_entities(update: bool = True) -> None: """Retrieve Netatmo public weather entities.""" entities = { device.name: device.id - for device in dr.async_entries_for_config_entry( + for device in async_entries_for_config_entry( device_registry, entry.entry_id ) - if device.model == "Public Weather stations" + if device.model == "Public Weather station" } new_entities = [] for area in [ NetatmoArea(**i) for i in entry.options.get(CONF_WEATHER_AREAS, {}).values() ]: - signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" + signal_name = f"{PUBLIC}-{area.uuid}" if area.area_name in entities: entities.pop(area.area_name) @@ -422,25 +376,21 @@ async def async_setup_entry( continue await data_handler.subscribe( - PUBLICDATA_DATA_CLASS_NAME, + PUBLIC, signal_name, None, lat_ne=area.lat_ne, lon_ne=area.lon_ne, lat_sw=area.lat_sw, lon_sw=area.lon_sw, + area_id=str(area.uuid), ) - data_class = data_handler.data.get(signal_name) - - if data_class and data_class.raw_data: - nonlocal platform_not_ready - platform_not_ready = False new_entities.extend( [ NetatmoPublicSensor(data_handler, area, description) for description in SENSOR_TYPES - if description.key in SUPPORTED_PUBLIC_SENSOR_TYPES + if description.netatmo_name in SUPPORTED_PUBLIC_SENSOR_TYPES ] ) @@ -454,68 +404,56 @@ async def async_setup_entry( hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}", add_public_entities ) - @callback - def _create_entity(netatmo_device: NetatmoDevice) -> None: - entity = NetatmoClimateBatterySensor(netatmo_device) - _LOGGER.debug("Adding climate battery sensor %s", entity) - async_add_entities([entity]) - - entry.async_on_unload( - async_dispatcher_connect(hass, NETATMO_CREATE_BATTERY, _create_entity) - ) - await add_public_entities(False) - if platform_not_ready: - raise PlatformNotReady +class NetatmoWeatherSensor(NetatmoBase, SensorEntity): + """Implementation of a Netatmo weather/home coach sensor.""" -class NetatmoSensor(NetatmoBase, SensorEntity): - """Implementation of a Netatmo sensor.""" - + _attr_has_entity_name = True entity_description: NetatmoSensorEntityDescription def __init__( self, - data_handler: NetatmoDataHandler, - data_class_name: str, - module_info: dict, + netatmo_device: NetatmoDevice, description: NetatmoSensorEntityDescription, ) -> None: """Initialize the sensor.""" - super().__init__(data_handler) + super().__init__(netatmo_device.data_handler) self.entity_description = description - self._publishers.append({"name": data_class_name, SIGNAL_NAME: data_class_name}) + self._module = netatmo_device.device + self._id = self._module.entity_id + self._station_id = ( + self._module.bridge if self._module.bridge is not None else self._id + ) + self._device_name = self._module.name + category = getattr(self._module.device_category, "name") + self._publishers.extend( + [ + { + "name": category, + SIGNAL_NAME: category, + }, + ] + ) - self._id = module_info["_id"] - self._station_id = module_info.get("main_device", self._id) - - station = self._data.get_station(self._station_id) - if not (device := self._data.get_module(self._id)): - # Assume it's a station if module can't be found - device = station - - if device["type"] in ("NHC", "NAMain"): - self._device_name = module_info["station_name"] - else: - self._device_name = ( - f"{station['station_name']} " - f"{module_info.get('module_name', device['type'])}" - ) - - self._attr_name = f"{self._device_name} {description.name}" - self._model = device["type"] - self._netatmo_type = TYPE_WEATHER + self._attr_name = f"{description.name}" + self._model = self._module.device_type + self._config_url = CONF_URL_WEATHER self._attr_unique_id = f"{self._id}-{description.key}" - @property - def _data(self) -> pyatmo.AsyncWeatherStationData: - """Return data for this entity.""" - return cast( - pyatmo.AsyncWeatherStationData, - self.data_handler.data[self._publishers[0]["name"]], - ) + if hasattr(self._module, "place"): + place = cast( + pyatmo.modules.base_class.Place, getattr(self._module, "place") + ) + if hasattr(place, "location") and place.location is not None: + self._attr_extra_state_attributes.update( + { + ATTR_LATITUDE: place.location.latitude, + ATTR_LONGITUDE: place.location.longitude, + } + ) @property def available(self) -> bool: @@ -525,46 +463,25 @@ class NetatmoSensor(NetatmoBase, SensorEntity): @callback def async_update_callback(self) -> None: """Update the entity's state.""" - data = self._data.get_last_data(station_id=self._station_id, exclude=3600).get( - self._id - ) - - if data is None: - if self.state: - _LOGGER.debug( - "No data found for %s - %s (%s)", - self.name, - self._device_name, - self._id, - ) - self._attr_native_value = None + if ( + state := getattr(self._module, self.entity_description.netatmo_name) + ) is None: return - try: - state = data[self.entity_description.netatmo_name] - if self.entity_description.key in {"temperature", "pressure", "sum_rain_1"}: - self._attr_native_value = round(state, 1) - elif self.entity_description.key in {"windangle_value", "gustangle_value"}: - self._attr_native_value = fix_angle(state) - elif self.entity_description.key in {"windangle", "gustangle"}: - self._attr_native_value = process_angle(fix_angle(state)) - elif self.entity_description.key == "rf_status": - self._attr_native_value = process_rf(state) - elif self.entity_description.key == "wifi_status": - self._attr_native_value = process_wifi(state) - elif self.entity_description.key == "health_idx": - self._attr_native_value = process_health(state) - else: - self._attr_native_value = state - except KeyError: - if self.state: - _LOGGER.debug( - "No %s data found for %s", - self.entity_description.key, - self._device_name, - ) - self._attr_native_value = None - return + if self.entity_description.netatmo_name in { + "temperature", + "pressure", + "sum_rain_1", + }: + self._attr_native_value = round(state, 1) + elif self.entity_description.netatmo_name == "rf_strength": + self._attr_native_value = process_rf(state) + elif self.entity_description.netatmo_name == "wifi_strength": + self._attr_native_value = process_wifi(state) + elif self.entity_description.netatmo_name == "health_idx": + self._attr_native_value = process_health(state) + else: + self._attr_native_value = state self.async_write_ha_state() @@ -580,24 +497,25 @@ class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity): ) -> None: """Initialize the sensor.""" super().__init__(netatmo_device.data_handler) - self.entity_description = NetatmoSensorEntityDescription( - key="battery_percent", - name="Battery Percent", - netatmo_name="battery_percent", - entity_registry_enabled_default=True, - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.BATTERY, + self.entity_description = BATTERY_SENSOR_DESCRIPTION + + self._module = cast(pyatmo.modules.NRV, netatmo_device.device) + self._id = netatmo_device.parent_id + + self._publishers.extend( + [ + { + "name": HOME, + "home_id": netatmo_device.device.home.entity_id, + SIGNAL_NAME: netatmo_device.signal_name, + }, + ] ) - self._module = netatmo_device.device - self._id = netatmo_device.parent_id self._attr_name = f"{self._module.name} {self.entity_description.name}" - - self._signal_name = netatmo_device.signal_name self._room_id = self._module.room_id self._model = getattr(self._module.device_type, "value") + self._config_url = CONF_URL_ENERGY self._attr_unique_id = ( f"{self._id}-{self._module.entity_id}-{self.entity_description.key}" @@ -613,70 +531,54 @@ class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity): return self._attr_available = True - self._attr_native_value = self._process_battery_state() - - def _process_battery_state(self) -> int | None: - """Construct room status.""" - if battery_state := self._module.battery_state: - return process_battery_percentage(battery_state) - - return None + self._attr_native_value = self._module.battery -def process_battery_percentage(data: str) -> int: - """Process battery data and return percent (int) for display.""" - mapping = { - "max": 100, - "full": 90, - "high": 75, - "medium": 50, - "low": 25, - "very low": 10, - } - return mapping[data] +class NetatmoSensor(NetatmoBase, SensorEntity): + """Implementation of a Netatmo sensor.""" + entity_description: NetatmoSensorEntityDescription -def fix_angle(angle: int) -> int: - """Fix angle when value is negative.""" - if angle < 0: - return 360 + angle - return angle + def __init__( + self, + netatmo_device: NetatmoDevice, + description: NetatmoSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(netatmo_device.data_handler) + self.entity_description = description + self._module = netatmo_device.device + self._id = self._module.entity_id -def process_angle(angle: int) -> str: - """Process angle and return string for display.""" - if angle >= 330: - return "N" - if angle >= 300: - return "NW" - if angle >= 240: - return "W" - if angle >= 210: - return "SW" - if angle >= 150: - return "S" - if angle >= 120: - return "SE" - if angle >= 60: - return "E" - if angle >= 30: - return "NE" - return "N" + self._publishers.extend( + [ + { + "name": HOME, + "home_id": netatmo_device.device.home.entity_id, + SIGNAL_NAME: netatmo_device.signal_name, + }, + ] + ) + self._attr_name = f"{self._module.name} {self.entity_description.name}" + self._room_id = self._module.room_id + self._model = getattr(self._module.device_type, "value") + self._config_url = CONF_URL_ENERGY -def process_battery(data: int, model: str) -> str: - """Process battery data and return string for display.""" - battery_data = BATTERY_VALUES[model] + self._attr_unique_id = ( + f"{self._id}-{self._module.entity_id}-{self.entity_description.key}" + ) - if data >= battery_data.full: - return "Full" - if data >= battery_data.high: - return "High" - if data >= battery_data.medium: - return "Medium" - if data >= battery_data.low: - return "Low" - return "Very Low" + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + if (state := getattr(self._module, self.entity_description.key)) is None: + return + + self._attr_native_value = state + + self.async_write_ha_state() def process_health(health: int) -> str: @@ -714,9 +616,57 @@ def process_wifi(strength: int) -> str: return "Full" +class NetatmoRoomSensor(NetatmoBase, SensorEntity): + """Implementation of a Netatmo room sensor.""" + + entity_description: NetatmoSensorEntityDescription + + def __init__( + self, + netatmo_room: NetatmoRoom, + description: NetatmoSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(netatmo_room.data_handler) + self.entity_description = description + + self._room = netatmo_room.room + self._id = self._room.entity_id + + self._publishers.extend( + [ + { + "name": HOME, + "home_id": netatmo_room.room.home.entity_id, + SIGNAL_NAME: netatmo_room.signal_name, + }, + ] + ) + + self._attr_name = f"{self._room.name} {self.entity_description.name}" + self._room_id = self._room.entity_id + self._model = f"{self._room.climate_type}" + self._config_url = CONF_URL_ENERGY + + self._attr_unique_id = ( + f"{self._id}-{self._room.entity_id}-{self.entity_description.key}" + ) + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + if (state := getattr(self._room, self.entity_description.key)) is None: + return + + self._attr_native_value = state + + self.async_write_ha_state() + + class NetatmoPublicSensor(NetatmoBase, SensorEntity): """Represent a single sensor in a Netatmo.""" + _attr_has_entity_name = True entity_description: NetatmoSensorEntityDescription def __init__( @@ -729,11 +679,10 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): super().__init__(data_handler) self.entity_description = description - self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" - + self._signal_name = f"{PUBLIC}-{area.uuid}" self._publishers.append( { - "name": PUBLICDATA_DATA_CLASS_NAME, + "name": PUBLIC, "lat_ne": area.lat_ne, "lon_ne": area.lon_ne, "lat_sw": area.lat_sw, @@ -743,12 +692,14 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): } ) + self._station = data_handler.account.public_weather_areas[str(area.uuid)] + self.area = area self._mode = area.mode self._area_name = area.area_name self._id = self._area_name self._device_name = f"{self._area_name}" - self._attr_name = f"{self._device_name} {description.name}" + self._attr_name = f"{description.name}" self._show_on_map = area.show_on_map self._attr_unique_id = ( f"{self._device_name.replace(' ', '-')}-{description.key}" @@ -762,17 +713,12 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): } ) - @property - def _data(self) -> pyatmo.AsyncPublicData: - """Return data for this entity.""" - return cast(pyatmo.AsyncPublicData, self.data_handler.data[self._signal_name]) - async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() assert self.device_info and "name" in self.device_info - self.data_handler.config_entry.async_on_unload( + self.async_on_remove( async_dispatcher_connect( self.hass, f"netatmo-config-{self.device_info['name']}", @@ -790,22 +736,11 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): ) self.area = area - self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" - self._publishers = [ - { - "name": PUBLICDATA_DATA_CLASS_NAME, - "lat_ne": area.lat_ne, - "lon_ne": area.lon_ne, - "lat_sw": area.lat_sw, - "lon_sw": area.lon_sw, - "area_name": area.area_name, - SIGNAL_NAME: self._signal_name, - } - ] + self._signal_name = f"{PUBLIC}-{area.uuid}" self._mode = area.mode self._show_on_map = area.show_on_map await self.data_handler.subscribe( - PUBLICDATA_DATA_CLASS_NAME, + PUBLIC, self._signal_name, self.async_update_callback, lat_ne=area.lat_ne, @@ -819,22 +754,26 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): """Update the entity's state.""" data = None - if self.entity_description.key == "temperature": - data = self._data.get_latest_temperatures() - elif self.entity_description.key == "pressure": - data = self._data.get_latest_pressures() - elif self.entity_description.key == "humidity": - data = self._data.get_latest_humidities() - elif self.entity_description.key == "rain": - data = self._data.get_latest_rain() - elif self.entity_description.key == "sum_rain_1": - data = self._data.get_60_min_rain() - elif self.entity_description.key == "sum_rain_24": - data = self._data.get_24_h_rain() - elif self.entity_description.key == "windstrength": - data = self._data.get_latest_wind_strengths() - elif self.entity_description.key == "guststrength": - data = self._data.get_latest_gust_strengths() + if self.entity_description.netatmo_name == "temperature": + data = self._station.get_latest_temperatures() + elif self.entity_description.netatmo_name == "pressure": + data = self._station.get_latest_pressures() + elif self.entity_description.netatmo_name == "humidity": + data = self._station.get_latest_humidities() + elif self.entity_description.netatmo_name == "rain": + data = self._station.get_latest_rain() + elif self.entity_description.netatmo_name == "sum_rain_1": + data = self._station.get_60_min_rain() + elif self.entity_description.netatmo_name == "sum_rain_24": + data = self._station.get_24_h_rain() + elif self.entity_description.netatmo_name == "wind_strength": + data = self._station.get_latest_wind_strengths() + elif self.entity_description.netatmo_name == "gust_strength": + data = self._station.get_latest_gust_strengths() + elif self.entity_description.netatmo_name == "wind_angle": + data = self._station.get_latest_wind_angles() + elif self.entity_description.netatmo_name == "gust_angle": + data = self._station.get_latest_gust_angles() if not data: if self.available: diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py new file mode 100644 index 00000000000..338d073c205 --- /dev/null +++ b/homeassistant/components/netatmo/switch.py @@ -0,0 +1,83 @@ +"""Support for Netatmo/BTicino/Legrande switches.""" +from __future__ import annotations + +import logging +from typing import Any, cast + +from pyatmo import modules as NaModules + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH +from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .netatmo_entity_base import NetatmoBase + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Netatmo switch platform.""" + + @callback + def _create_entity(netatmo_device: NetatmoDevice) -> None: + entity = NetatmoSwitch(netatmo_device) + _LOGGER.debug("Adding switch %s", entity) + async_add_entities([entity]) + + entry.async_on_unload( + async_dispatcher_connect(hass, NETATMO_CREATE_SWITCH, _create_entity) + ) + + +class NetatmoSwitch(NetatmoBase, SwitchEntity): + """Representation of a Netatmo switch device.""" + + def __init__( + self, + netatmo_device: NetatmoDevice, + ) -> None: + """Initialize the Netatmo device.""" + super().__init__(netatmo_device.data_handler) + + self._switch = cast(NaModules.Switch, netatmo_device.device) + + self._id = self._switch.entity_id + self._attr_name = self._device_name = self._switch.name + self._model = self._switch.device_type + self._config_url = CONF_URL_CONTROL + + self._home_id = self._switch.home.entity_id + + self._signal_name = f"{HOME}-{self._home_id}" + self._publishers.extend( + [ + { + "name": HOME, + "home_id": self._home_id, + SIGNAL_NAME: self._signal_name, + }, + ] + ) + self._attr_unique_id = f"{self._id}-{self._model}" + self._attr_is_on = self._switch.on + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + self._attr_is_on = self._switch.on + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the zone on.""" + await self._switch.async_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the zone off.""" + await self._switch.async_off() diff --git a/requirements_all.txt b/requirements_all.txt index 8805209e6f4..2f811e4d286 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1436,7 +1436,7 @@ pyalmond==0.0.2 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.2.4 +pyatmo==7.0.1 # homeassistant.components.atome pyatome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ce12de4a86..6a2182faf87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1018,7 +1018,7 @@ pyalmond==0.0.2 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==6.2.4 +pyatmo==7.0.1 # homeassistant.components.apple_tv pyatv==0.10.3 diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index 784b428e8d0..375dce4e723 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -11,19 +11,6 @@ from tests.test_util.aiohttp import AiohttpClientMockResponse CLIENT_ID = "1234" CLIENT_SECRET = "5678" -ALL_SCOPES = [ - "read_station", - "read_camera", - "access_camera", - "write_camera", - "read_presence", - "access_presence", - "write_presence", - "read_homecoach", - "read_smokedetector", - "read_thermostat", - "write_thermostat", -] COMMON_RESPONSE = { "user_id": "91763b24c43d3e344f424e8d", @@ -43,10 +30,10 @@ DEFAULT_PLATFORMS = ["camera", "climate", "light", "sensor"] async def fake_post_request(*args, **kwargs): """Return fake data.""" - if "url" not in kwargs: + if "endpoint" not in kwargs: return "{}" - endpoint = kwargs["url"].split("/")[-1] + endpoint = kwargs["endpoint"].split("/")[-1] if endpoint in "snapshot_720.jpg": return b"test stream image bytes" @@ -59,7 +46,7 @@ async def fake_post_request(*args, **kwargs): "setthermmode", "switchhomeschedule", ]: - payload = f'{{"{endpoint}": true}}' + payload = {f"{endpoint}": True, "status": "ok"} elif endpoint == "homestatus": home_id = kwargs.get("params", {}).get("home_id") @@ -70,17 +57,17 @@ async def fake_post_request(*args, **kwargs): return AiohttpClientMockResponse( method="POST", - url=kwargs["url"], + url=kwargs["endpoint"], json=payload, ) async def fake_get_image(*args, **kwargs): """Return fake data.""" - if "url" not in kwargs: + if "endpoint" not in kwargs: return "{}" - endpoint = kwargs["url"].split("/")[-1] + endpoint = kwargs["endpoint"].split("/")[-1] if endpoint in "snapshot_720.jpg": return b"test stream image bytes" diff --git a/tests/components/netatmo/conftest.py b/tests/components/netatmo/conftest.py index 808e477e053..a10030fab08 100644 --- a/tests/components/netatmo/conftest.py +++ b/tests/components/netatmo/conftest.py @@ -2,9 +2,10 @@ from time import time from unittest.mock import AsyncMock, patch +from pyatmo.const import ALL_SCOPES import pytest -from .common import ALL_SCOPES, fake_get_image, fake_post_request +from .common import fake_get_image, fake_post_request from tests.common import MockConfigEntry @@ -60,6 +61,7 @@ def netatmo_auth(): "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth: mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_get_image.side_effect = fake_get_image mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() diff --git a/tests/components/netatmo/fixtures/events.txt b/tests/components/netatmo/fixtures/events.txt index f2bc29f782c..112abb7115a 100644 --- a/tests/components/netatmo/fixtures/events.txt +++ b/tests/components/netatmo/fixtures/events.txt @@ -1,61 +1,50 @@ { - "12:34:56:78:90:ab": { - 1599152672: { - "id": "12345", - "type": "person", - "time": 1599152672, - "camera_id": "12:34:56:78:90:ab", - "snapshot": { - "url": "https://netatmocameraimage", - }, - "video_id": "98765", - "video_status": "available", - "message": "Paulus seen", - "media_url": "http:///files/high/index.m3u8", - }, - 1599152673: { - "id": "12346", - "type": "person", - "time": 1599152673, - "camera_id": "12:34:56:78:90:ab", - "snapshot": { - "url": "https://netatmocameraimage", - }, - "message": "Tobias seen", - }, - 1599152674: { - "id": "12347", - "type": "outdoor", - "time": 1599152674, - "camera_id": "12:34:56:78:90:ac", - "snapshot": { - "url": "https://netatmocameraimage", - }, - "video_id": "98766", - "video_status": "available", - "event_list": [ - { - "type": "vehicle", - "time": 1599152674, - "id": "12347-0", - "offset": 0, - "message": "Vehicle detected", - "snapshot": { - "url": "https://netatmocameraimage", - }, - }, - { - "type": "human", - "time": 1599152674, - "id": "12347-1", - "offset": 8, - "message": "Person detected", - "snapshot": { - "url": "https://netatmocameraimage", - }, - }, - ], - "media_url": "http:///files/high/index.m3u8", - }, + "12:34:56:78:90:ab": { + 1654191519: { + "home_id": "91763b24c43d3e344f424e8b", + "entity_id": "000001", + "event_type": "human", + "event_time": 1654191519, + "module_id": "12:34:56:78:90:ab", + "snapshot": { + "url": "https://netatmocameraimage" + }, + "vignette": { + "url": "https://netatmocameraimage" + }, + "video_id": "0011", + "video_status": "available", + "message": "Bewegung erkannt", + "subevents": [], + "media_url": "http:///files/high/index.m3u8" + }, + 1654189491: { + "home_id": "91763b24c43d3e344f424e8b", + "entity_id": "000002", + "event_type": "person", + "event_time": 1654189491, + "module_id": "12:34:56:78:90:ab", + "snapshot": { + "url": "https://netatmocameraimage" + }, + "video_id": "0012", + "video_status": "available", + "message": "Jane gesehen", + "person_id": "1111", + "out_of_sight": False, + "subevents": [], + "media_url": "http:///files/high/index.m3u8" + }, + 1654289891: { + "home_id": "91763b24c43d3e344f424e8b", + "entity_id": "000002", + "event_type": "person", + "event_time": 1654289891, + "module_id": "12:34:56:78:90:ab", + "message": "Jane gesehen", + "person_id": "1111", + "out_of_sight": False, + "subevents": [] } -} \ No newline at end of file + } +} diff --git a/tests/components/netatmo/fixtures/getevents.json b/tests/components/netatmo/fixtures/getevents.json new file mode 100644 index 00000000000..7db4dbe5e9b --- /dev/null +++ b/tests/components/netatmo/fixtures/getevents.json @@ -0,0 +1,151 @@ +{ + "body": { + "home": { + "id": "91763b24c43d3e344f424e8b", + "events": [ + { + "id": "11111111111111111f7763a6d", + "type": "outdoor", + "time": 1645794709, + "module_id": "12:34:56:00:a5:a4", + "video_id": "11111111-2222-3333-4444-b42f0fc4cfad", + "video_status": "available", + "subevents": [ + { + "id": "11111111-2222-3333-4444-013560107fce", + "type": "human", + "time": 1645794709, + "verified": true, + "offset": 0, + "snapshot": { + "url": "https://netatmocameraimage.blob.core.windows.net/production/000000a722374" + }, + "vignette": { + "url": "https://netatmocameraimage.blob.core.windows.net/production/0000009625c0f" + }, + "message": "Person erfasst" + }, + { + "id": "11111111-2222-3333-4444-0b0bc962df43", + "type": "vehicle", + "time": 1645794716, + "verified": true, + "offset": 15, + "snapshot": { + "url": "https://netatmocameraimage.blob.core.windows.net/production/00000033f9f96" + }, + "vignette": { + "url": "https://netatmocameraimage.blob.core.windows.net/production/000000cba08af" + }, + "message": "Fahrzeug erfasst" + }, + { + "id": "11111111-2222-3333-4444-129e72195968", + "type": "human", + "time": 1645794716, + "verified": true, + "offset": 15, + "snapshot": { + "filename": "vod/11111/events/22222/snapshot_129e72195968.jpg" + }, + "vignette": { + "filename": "vod/11111/events/22222/vignette_129e72195968.jpg" + }, + "message": "Person erfasst" + }, + { + "id": "11111111-2222-3333-4444-dae4d7e4f24e", + "type": "human", + "time": 1645794718, + "verified": true, + "offset": 17, + "snapshot": { + "filename": "vod/11111/events/22222/snapshot_dae4d7e4f24e.jpg" + }, + "vignette": { + "filename": "vod/11111/events/22222/vignette_dae4d7e4f24e.jpg" + }, + "message": "Person erfasst" + } + ] + }, + { + "id": "1111111111111111e7e40c353", + "type": "connection", + "time": 1645784799, + "module_id": "12:34:56:00:a5:a4", + "message": "Front verbunden" + }, + { + "id": "11111111111111144e3115860", + "type": "boot", + "time": 1645784775, + "module_id": "12:34:56:00:a5:a4", + "message": "Front gestartet" + }, + { + "id": "11111111111111169804049ca", + "type": "disconnection", + "time": 1645773806, + "module_id": "12:34:56:00:a5:a4", + "message": "Front getrennt" + }, + { + "id": "1111111111111117cb8147ffd", + "type": "outdoor", + "time": 1645712826, + "module_id": "12:34:56:00:a5:a4", + "video_id": "11111111-2222-3333-4444-5091e1903f8d", + "video_status": "available", + "subevents": [ + { + "id": "11111111-2222-3333-4444-b7d28e3ccc38", + "type": "human", + "time": 1645712826, + "verified": true, + "offset": 0, + "snapshot": { + "url": "https://netatmocameraimage.blob.core.windows.net/production/000000a0ca642" + }, + "vignette": { + "url": "https://netatmocameraimage.blob.core.windows.net/production/00000031b0ed4" + }, + "message": "Person erfasst" + } + ] + }, + { + "id": "1111111111111119df3d2de6", + "type": "person_home", + "time": 1645902000, + "module_id": "12:34:56:00:f1:62", + "message": "Home Assistant Cloud definiert John Doe und Jane Doe als \"Zu Hause\"" + }, + { + "id": "1111111111111112c91b3628", + "type": "person", + "time": 1645901266, + "module_id": "12:34:56:00:f1:62", + "snapshot": { + "url": "https://netatmocameraimage.blob.core.windows.net/production/0000081d4f42875d9" + }, + "video_id": "11111111-2222-3333-4444-314d161525db", + "video_status": "available", + "message": "John Doe gesehen", + "person_id": "91827374-7e04-5298-83ad-a0cb8372dff1", + "out_of_sight": false + }, + { + "id": "1111111111111115166b1283", + "type": "tag_open", + "time": 1645897638, + "module_id": "12:34:56:00:86:99", + "message": "Window Hall: immer noch offen" + } + ] + } + }, + "status": "ok", + "time_exec": 0.24369096755981445, + "time_server": 1645897231 +} diff --git a/tests/components/netatmo/fixtures/homesdata.json b/tests/components/netatmo/fixtures/homesdata.json index a56ccb236b5..93c04388f4c 100644 --- a/tests/components/netatmo/fixtures/homesdata.json +++ b/tests/components/netatmo/fixtures/homesdata.json @@ -19,7 +19,13 @@ "id": "3688132631", "name": "Hall", "type": "custom", - "module_ids": ["12:34:56:00:f1:62"] + "module_ids": [ + "12:34:56:00:f1:62", + "12:34:56:10:f1:66", + "12:34:56:00:e3:9b", + "12:34:56:00:86:99", + "0009999992" + ] }, { "id": "2833524037", @@ -32,6 +38,44 @@ "name": "Cocina", "type": "kitchen", "module_ids": ["12:34:56:03:a0:ac"] + }, + { + "id": "2940411588", + "name": "Child", + "type": "custom", + "module_ids": ["12:34:56:26:cc:01"] + }, + { + "id": "222452125", + "name": "Bureau", + "type": "electrical_cabinet", + "module_ids": ["12:34:56:20:f5:44", "12:34:56:20:f5:8c"], + "modules": ["12:34:56:20:f5:44", "12:34:56:20:f5:8c"], + "therm_relay": "12:34:56:20:f5:44", + "true_temperature_available": true + }, + { + "id": "100007519", + "name": "Cabinet", + "type": "electrical_cabinet", + "module_ids": [ + "12:34:56:00:16:0e", + "12:34:56:00:16:0e#0", + "12:34:56:00:16:0e#1", + "12:34:56:00:16:0e#2", + "12:34:56:00:16:0e#3", + "12:34:56:00:16:0e#4", + "12:34:56:00:16:0e#5", + "12:34:56:00:16:0e#6", + "12:34:56:00:16:0e#7", + "12:34:56:00:16:0e#8" + ] + }, + { + "id": "1002003001", + "name": "Corridor", + "type": "corridor", + "module_ids": ["10:20:30:bd:b8:1e"] } ], "modules": [ @@ -75,7 +119,334 @@ "type": "NACamera", "name": "Hall", "setup_date": 1544828430, - "room_id": "3688132631" + "room_id": "3688132631", + "reachable": true, + "modules_bridged": ["12:34:56:00:86:99", "12:34:56:00:e3:9b"] + }, + { + "id": "12:34:56:00:a5:a4", + "type": "NOC", + "name": "Garden", + "setup_date": 1544828430, + "reachable": true + }, + { + "id": "12:34:56:20:f5:44", + "type": "OTH", + "name": "Modulating Relay", + "setup_date": 1607443936, + "room_id": "222452125", + "reachable": true, + "modules_bridged": ["12:34:56:20:f5:8c"], + "hk_device_id": "12:34:56:20:d0:c5", + "capabilities": [ + { + "name": "automatism", + "available": true + } + ], + "max_modules_nb": 21 + }, + { + "id": "12:34:56:20:f5:8c", + "type": "OTM", + "name": "Bureau Modulate", + "setup_date": 1607443939, + "room_id": "222452125", + "bridge": "12:34:56:20:f5:44" + }, + { + "id": "12:34:56:10:f1:66", + "type": "NDB", + "name": "Netatmo-Doorbell", + "setup_date": 1602691361, + "room_id": "3688132631", + "reachable": true, + "hk_device_id": "123456007df1", + "customer_id": "1000010", + "network_lock": false, + "quick_display_zone": 62 + }, + { + "id": "12:34:56:00:e3:9b", + "type": "NIS", + "setup_date": 1620479901, + "bridge": "12:34:56:00:f1:62", + "name": "Sirene in hall" + }, + { + "id": "12:34:56:00:86:99", + "type": "NACamDoorTag", + "name": "Window Hall", + "setup_date": 1581177375, + "bridge": "12:34:56:00:f1:62", + "category": "window" + }, + { + "id": "12:34:56:30:d5:d4", + "type": "NBG", + "name": "module iDiamant", + "setup_date": 1562262465, + "room_id": "222452125", + "modules_bridged": ["0009999992"] + }, + { + "id": "0009999992", + "type": "NBR", + "name": "Entrance Blinds", + "setup_date": 1578551339, + "room_id": "3688132631", + "bridge": "12:34:56:30:d5:d4" + }, + { + "id": "12:34:56:37:11:ca", + "type": "NAMain", + "name": "NetatmoIndoor", + "setup_date": 1419453350, + "reachable": true, + "modules_bridged": [ + "12:34:56:07:bb:3e", + "12:34:56:03:1b:e4", + "12:34:56:36:fc:de", + "12:34:56:05:51:20" + ], + "customer_id": "C00016", + "hardware_version": 251, + "public_ext_data": false, + "public_ext_counter": 0, + "alarm_config": { + "default_alarm": [ + { + "db_alarm_number": 0 + }, + { + "db_alarm_number": 1 + }, + { + "db_alarm_number": 2 + }, + { + "db_alarm_number": 6 + }, + { + "db_alarm_number": 4 + }, + { + "db_alarm_number": 5 + }, + { + "db_alarm_number": 7 + }, + { + "db_alarm_number": 22 + } + ], + "personnalized": [ + { + "threshold": 20, + "data_type": 1, + "direction": 0, + "db_alarm_number": 8 + }, + { + "threshold": 17, + "data_type": 1, + "direction": 1, + "db_alarm_number": 9 + }, + { + "threshold": 65, + "data_type": 4, + "direction": 0, + "db_alarm_number": 16 + }, + { + "threshold": 19, + "data_type": 8, + "direction": 0, + "db_alarm_number": 22 + } + ] + }, + "module_offset": { + "12:34:56:80:bb:26": { + "a": 0.1 + } + } + }, + { + "id": "12:34:56:36:fc:de", + "type": "NAModule1", + "name": "Outdoor", + "setup_date": 1448565785, + "bridge": "12:34:56:37:11:ca" + }, + { + "id": "12:34:56:03:1b:e4", + "type": "NAModule2", + "name": "Garden", + "setup_date": 1543579864, + "bridge": "12:34:56:37:11:ca" + }, + { + "id": "12:34:56:05:51:20", + "type": "NAModule3", + "name": "Rain", + "setup_date": 1591770206, + "bridge": "12:34:56:37:11:ca" + }, + { + "id": "12:34:56:07:bb:3e", + "type": "NAModule4", + "name": "Bedroom", + "setup_date": 1484997703, + "bridge": "12:34:56:37:11:ca" + }, + { + "id": "12:34:56:26:68:92", + "type": "NHC", + "name": "Indoor", + "setup_date": 1571342643 + }, + { + "id": "12:34:56:26:cc:01", + "type": "BNS", + "name": "Child", + "setup_date": 1571634243 + }, + { + "id": "12:34:56:80:60:40", + "type": "NLG", + "name": "Prise Control", + "setup_date": 1641841257, + "room_id": "1310352496", + "modules_bridged": [ + "12:34:56:80:00:12:ac:f2", + "12:34:56:80:00:c3:69:3c", + "12:34:56:00:00:a1:4c:da", + "12:34:56:00:01:01:01:a1" + ] + }, + { + "id": "12:34:56:80:00:12:ac:f2", + "type": "NLP", + "name": "Prise", + "setup_date": 1641841262, + "room_id": "1310352496", + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:80:00:c3:69:3c", + "type": "NLT", + "name": "Commande sans fil", + "setup_date": 1641841262, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:16:0e", + "type": "NLE", + "name": "Écocompteur", + "setup_date": 1644496884, + "room_id": "100007519", + "modules_bridged": [ + "12:34:56:00:16:0e#0", + "12:34:56:00:16:0e#1", + "12:34:56:00:16:0e#2", + "12:34:56:00:16:0e#3", + "12:34:56:00:16:0e#4", + "12:34:56:00:16:0e#5", + "12:34:56:00:16:0e#6", + "12:34:56:00:16:0e#7", + "12:34:56:00:16:0e#8" + ] + }, + { + "id": "12:34:56:00:16:0e#0", + "type": "NLE", + "name": "Line 1", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#1", + "type": "NLE", + "name": "Line 2", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#2", + "type": "NLE", + "name": "Line 3", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#3", + "type": "NLE", + "name": "Line 4", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#4", + "type": "NLE", + "name": "Line 5", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#5", + "type": "NLE", + "name": "Total", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#6", + "type": "NLE", + "name": "Gas", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#7", + "type": "NLE", + "name": "Hot water", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#8", + "type": "NLE", + "name": "Cold water", + "setup_date": 1644496886, + "room_id": "100007519", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:00:a1:4c:da", + "type": "NLPC", + "name": "Consumption meter", + "setup_date": 1638376602, + "room_id": "100008999", + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:a1", + "type": "NLFN", + "name": "Bathroom light", + "setup_date": 1598367404, + "room_id": "1002003001", + "bridge": "12:34:56:80:60:40" } ], "schedules": [ diff --git a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json index 12c7044aaaa..4cd5dceec3b 100644 --- a/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json +++ b/tests/components/netatmo/fixtures/homestatus_91763b24c43d3e344f424e8b.json @@ -14,6 +14,25 @@ "vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.123.45/609e27de5699fb18147ab47d06846631/MTRPn_BeWCav5RBq4U1OMDruTW4dkQ0NuMwNDAw11g,,", "is_local": true }, + { + "type": "NOC", + "firmware_revision": 3002000, + "monitoring": "on", + "sd_status": 4, + "connection": "wifi", + "homekit_status": "upgradable", + "floodlight": "auto", + "timelapse_available": true, + "id": "12:34:56:00:a5:a4", + "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,,", + "is_local": false, + "network_lock": false, + "firmware_name": "3.2.0", + "wifi_strength": 62, + "alim_status": 2, + "locked": false, + "wifi_state": "high" + }, { "id": "12:34:56:00:fa:d0", "type": "NAPlug", @@ -50,6 +69,805 @@ "rf_strength": 59, "bridge": "12:34:56:00:fa:d0", "battery_state": "full" + }, + { + "id": "12:34:56:26:cc:01", + "type": "BNS", + "firmware_revision": 32, + "wifi_strength": 50, + "boiler_valve_comfort_boost": false, + "boiler_status": true, + "cooler_status": false + }, + { + "type": "NDB", + "last_ftp_event": { + "type": 3, + "time": 1631444443, + "id": 3 + }, + "id": "12:34:56:10:f1:66", + "websocket_connected": true, + "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", + "is_local": false, + "alim_status": 2, + "connection": "wifi", + "firmware_name": "2.18.0", + "firmware_revision": 2018000, + "homekit_status": "configured", + "max_peers_reached": false, + "sd_status": 4, + "wifi_strength": 66, + "wifi_state": "medium" + }, + { + "boiler_control": "onoff", + "dhw_control": "none", + "firmware_revision": 22, + "hardware_version": 222, + "id": "12:34:56:20:f5:44", + "outdoor_temperature": 8.2, + "sequence_id": 19764, + "type": "OTH", + "wifi_strength": 57 + }, + { + "battery_level": 4176, + "boiler_status": false, + "boiler_valve_comfort_boost": false, + "firmware_revision": 6, + "id": "12:34:56:20:f5:8c", + "last_message": 1637684297, + "last_seen": 1637684297, + "radio_id": 2, + "reachable": true, + "rf_strength": 64, + "type": "OTM", + "bridge": "12:34:56:20:f5:44", + "battery_state": "full" + }, + { + "id": "12:34:56:30:d5:d4", + "type": "NBG", + "firmware_revision": 39, + "wifi_strength": 65, + "reachable": true + }, + { + "id": "0009999992", + "type": "NBR", + "current_position": 0, + "target_position": 0, + "target_position_step": 100, + "firmware_revision": 16, + "rf_strength": 0, + "last_seen": 1638353156, + "reachable": true, + "bridge": "12:34:56:30:d5:d4" + }, + { + "id": "12:34:56:00:86:99", + "type": "NACamDoorTag", + "battery_state": "high", + "battery_level": 5240, + "firmware_revision": 58, + "rf_state": "full", + "rf_strength": 58, + "last_seen": 1642698124, + "last_activity": 1627757310, + "reachable": false, + "bridge": "12:34:56:00:f1:62", + "status": "no_news" + }, + { + "id": "12:34:56:00:e3:9b", + "type": "NIS", + "battery_state": "low", + "battery_level": 5438, + "firmware_revision": 209, + "rf_state": "medium", + "rf_strength": 62, + "last_seen": 1644569790, + "reachable": true, + "bridge": "12:34:56:00:f1:62", + "status": "no_sound", + "monitoring": "off" + }, + { + "id": "12:34:56:80:60:40", + "type": "NLG", + "offload": false, + "firmware_revision": 211, + "last_seen": 1644567372, + "wifi_strength": 51, + "reachable": true + }, + { + "id": "12:34:56:80:00:12:ac:f2", + "type": "NLP", + "on": true, + "offload": false, + "firmware_revision": 62, + "last_seen": 1644569425, + "power": 0, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:80:00:c3:69:3c", + "type": "NLT", + "battery_state": "full", + "battery_level": 3300, + "firmware_revision": 42, + "last_seen": 0, + "reachable": false, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:16:0e", + "type": "NLE", + "firmware_revision": 14, + "wifi_strength": 38 + }, + { + "id": "12:34:56:00:16:0e#0", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#1", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#2", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#3", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#4", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#5", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#6", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#7", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#8", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:00:a1:4c:da", + "type": "NLPC", + "firmware_revision": 62, + "last_seen": 1646511241, + "power": 476, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:a1", + "brightness": 100, + "firmware_revision": 52, + "last_seen": 1604940167, + "on": false, + "power": 0, + "reachable": true, + "type": "NLFN", + "bridge": "12:34:56:80:60:40" + }, + { + "type": "NDB", + "last_ftp_event": { + "type": 3, + "time": 1631444443, + "id": 3 + }, + "id": "12:34:56:10:f1:66", + "websocket_connected": true, + "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", + "is_local": false, + "alim_status": 2, + "connection": "wifi", + "firmware_name": "2.18.0", + "firmware_revision": 2018000, + "homekit_status": "configured", + "max_peers_reached": false, + "sd_status": 4, + "wifi_strength": 66, + "wifi_state": "medium" + }, + { + "boiler_control": "onoff", + "dhw_control": "none", + "firmware_revision": 22, + "hardware_version": 222, + "id": "12:34:56:20:f5:44", + "outdoor_temperature": 8.2, + "sequence_id": 19764, + "type": "OTH", + "wifi_strength": 57 + }, + { + "battery_level": 4176, + "boiler_status": false, + "boiler_valve_comfort_boost": false, + "firmware_revision": 6, + "id": "12:34:56:20:f5:8c", + "last_message": 1637684297, + "last_seen": 1637684297, + "radio_id": 2, + "reachable": true, + "rf_strength": 64, + "type": "OTM", + "bridge": "12:34:56:20:f5:44", + "battery_state": "full" + }, + { + "id": "12:34:56:30:d5:d4", + "type": "NBG", + "firmware_revision": 39, + "wifi_strength": 65, + "reachable": true + }, + { + "id": "0009999992", + "type": "NBR", + "current_position": 0, + "target_position": 0, + "target_position_step": 100, + "firmware_revision": 16, + "rf_strength": 0, + "last_seen": 1638353156, + "reachable": true, + "therm_measured_temperature": 5, + "heating_power_request": 1, + "therm_setpoint_temperature": 7, + "therm_setpoint_mode": "away", + "therm_setpoint_start_time": 0, + "therm_setpoint_end_time": 0, + "anticipating": false, + "open_window": false + }, + { + "id": "12:34:56:00:86:99", + "type": "NACamDoorTag", + "battery_state": "high", + "battery_level": 5240, + "firmware_revision": 58, + "rf_state": "full", + "rf_strength": 58, + "last_seen": 1642698124, + "last_activity": 1627757310, + "reachable": false, + "bridge": "12:34:56:00:f1:62", + "status": "no_news" + }, + { + "id": "12:34:56:00:e3:9b", + "type": "NIS", + "battery_state": "low", + "battery_level": 5438, + "firmware_revision": 209, + "rf_state": "medium", + "rf_strength": 62, + "last_seen": 1644569790, + "reachable": true, + "bridge": "12:34:56:00:f1:62", + "status": "no_sound", + "monitoring": "off" + }, + { + "id": "12:34:56:80:60:40", + "type": "NLG", + "offload": false, + "firmware_revision": 211, + "last_seen": 1644567372, + "wifi_strength": 51, + "reachable": true + }, + { + "id": "12:34:56:80:00:12:ac:f2", + "type": "NLP", + "on": true, + "offload": false, + "firmware_revision": 62, + "last_seen": 1644569425, + "power": 0, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:80:00:c3:69:3c", + "type": "NLT", + "battery_state": "full", + "battery_level": 3300, + "firmware_revision": 42, + "last_seen": 0, + "reachable": false, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:16:0e", + "type": "NLE", + "firmware_revision": 14, + "wifi_strength": 38 + }, + { + "id": "12:34:56:00:16:0e#0", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#1", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#2", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#3", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#4", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#5", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#6", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#7", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#8", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:00:a1:4c:da", + "type": "NLPC", + "firmware_revision": 62, + "last_seen": 1646511241, + "power": 476, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:a1", + "brightness": 100, + "firmware_revision": 52, + "last_seen": 1604940167, + "on": false, + "power": 0, + "reachable": true, + "type": "NLFN", + "bridge": "12:34:56:80:60:40" + }, + { + "type": "NDB", + "last_ftp_event": { + "type": 3, + "time": 1631444443, + "id": 3 + }, + "id": "12:34:56:10:f1:66", + "websocket_connected": true, + "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", + "is_local": false, + "alim_status": 2, + "connection": "wifi", + "firmware_name": "2.18.0", + "firmware_revision": 2018000, + "homekit_status": "configured", + "max_peers_reached": false, + "sd_status": 4, + "wifi_strength": 66, + "wifi_state": "medium" + }, + { + "boiler_control": "onoff", + "dhw_control": "none", + "firmware_revision": 22, + "hardware_version": 222, + "id": "12:34:56:20:f5:44", + "outdoor_temperature": 8.2, + "sequence_id": 19764, + "type": "OTH", + "wifi_strength": 57 + }, + { + "battery_level": 4176, + "boiler_status": false, + "boiler_valve_comfort_boost": false, + "firmware_revision": 6, + "id": "12:34:56:20:f5:8c", + "last_message": 1637684297, + "last_seen": 1637684297, + "radio_id": 2, + "reachable": true, + "rf_strength": 64, + "type": "OTM", + "bridge": "12:34:56:20:f5:44", + "battery_state": "full" + }, + { + "id": "12:34:56:30:d5:d4", + "type": "NBG", + "firmware_revision": 39, + "wifi_strength": 65, + "reachable": true + }, + { + "id": "0009999992", + "type": "NBR", + "current_position": 0, + "target_position": 0, + "target_position_step": 100, + "firmware_revision": 16, + "rf_strength": 0, + "last_seen": 1638353156, + "reachable": true, + "therm_measured_temperature": 5, + "heating_power_request": 1, + "therm_setpoint_temperature": 7, + "therm_setpoint_mode": "away", + "therm_setpoint_start_time": 0, + "therm_setpoint_end_time": 0, + "anticipating": false, + "open_window": false + }, + { + "id": "12:34:56:00:86:99", + "type": "NACamDoorTag", + "battery_state": "high", + "battery_level": 5240, + "firmware_revision": 58, + "rf_state": "full", + "rf_strength": 58, + "last_seen": 1642698124, + "last_activity": 1627757310, + "reachable": false, + "bridge": "12:34:56:00:f1:62", + "status": "no_news" + }, + { + "id": "12:34:56:00:e3:9b", + "type": "NIS", + "battery_state": "low", + "battery_level": 5438, + "firmware_revision": 209, + "rf_state": "medium", + "rf_strength": 62, + "last_seen": 1644569790, + "reachable": true, + "bridge": "12:34:56:00:f1:62", + "status": "no_sound", + "monitoring": "off" + }, + { + "id": "12:34:56:80:60:40", + "type": "NLG", + "offload": false, + "firmware_revision": 211, + "last_seen": 1644567372, + "wifi_strength": 51, + "reachable": true + }, + { + "id": "12:34:56:80:00:12:ac:f2", + "type": "NLP", + "on": true, + "offload": false, + "firmware_revision": 62, + "last_seen": 1644569425, + "power": 0, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:80:00:c3:69:3c", + "type": "NLT", + "battery_state": "full", + "battery_level": 3300, + "firmware_revision": 42, + "last_seen": 0, + "reachable": false, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:16:0e", + "type": "NLE", + "firmware_revision": 14, + "wifi_strength": 38 + }, + { + "id": "12:34:56:00:16:0e#0", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#1", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#2", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#3", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#4", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#5", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#6", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#7", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#8", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:00:a1:4c:da", + "type": "NLPC", + "firmware_revision": 62, + "last_seen": 1646511241, + "power": 476, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:01:01:01:a1", + "brightness": 100, + "firmware_revision": 52, + "last_seen": 1604940167, + "on": false, + "power": 0, + "reachable": true, + "type": "NLFN", + "bridge": "12:34:56:80:60:40" + }, + { + "type": "NDB", + "last_ftp_event": { + "type": 3, + "time": 1631444443, + "id": 3 + }, + "id": "12:34:56:10:f1:66", + "websocket_connected": true, + "vpn_url": "https://prodvpn-eu-6.netatmo.net/10.20.30.40/1111111111111/2222222222222,,", + "is_local": false, + "alim_status": 2, + "connection": "wifi", + "firmware_name": "2.18.0", + "firmware_revision": 2018000, + "homekit_status": "configured", + "max_peers_reached": false, + "sd_status": 4, + "wifi_strength": 66, + "wifi_state": "medium" + }, + { + "boiler_control": "onoff", + "dhw_control": "none", + "firmware_revision": 22, + "hardware_version": 222, + "id": "12:34:56:20:f5:44", + "outdoor_temperature": 8.2, + "sequence_id": 19764, + "type": "OTH", + "wifi_strength": 57 + }, + { + "battery_level": 4176, + "boiler_status": false, + "boiler_valve_comfort_boost": false, + "firmware_revision": 6, + "id": "12:34:56:20:f5:8c", + "last_message": 1637684297, + "last_seen": 1637684297, + "radio_id": 2, + "reachable": true, + "rf_strength": 64, + "type": "OTM", + "bridge": "12:34:56:20:f5:44", + "battery_state": "full" + }, + { + "id": "12:34:56:30:d5:d4", + "type": "NBG", + "firmware_revision": 39, + "wifi_strength": 65, + "reachable": true + }, + { + "id": "0009999992", + "type": "NBR", + "current_position": 0, + "target_position": 0, + "target_position_step": 100, + "firmware_revision": 16, + "rf_strength": 0, + "last_seen": 1638353156, + "reachable": true, + "therm_measured_temperature": 5, + "heating_power_request": 1, + "therm_setpoint_temperature": 7, + "therm_setpoint_mode": "away", + "therm_setpoint_start_time": 0, + "therm_setpoint_end_time": 0, + "anticipating": false, + "open_window": false + }, + { + "id": "12:34:56:00:86:99", + "type": "NACamDoorTag", + "battery_state": "high", + "battery_level": 5240, + "firmware_revision": 58, + "rf_state": "full", + "rf_strength": 58, + "last_seen": 1642698124, + "last_activity": 1627757310, + "reachable": false, + "bridge": "12:34:56:00:f1:62", + "status": "no_news" + }, + { + "id": "12:34:56:00:e3:9b", + "type": "NIS", + "battery_state": "low", + "battery_level": 5438, + "firmware_revision": 209, + "rf_state": "medium", + "rf_strength": 62, + "last_seen": 1644569790, + "reachable": true, + "bridge": "12:34:56:00:f1:62", + "status": "no_sound", + "monitoring": "off" + }, + { + "id": "12:34:56:80:60:40", + "type": "NLG", + "offload": false, + "firmware_revision": 211, + "last_seen": 1644567372, + "wifi_strength": 51, + "reachable": true + }, + { + "id": "12:34:56:80:00:12:ac:f2", + "type": "NLP", + "on": true, + "offload": false, + "firmware_revision": 62, + "last_seen": 1644569425, + "power": 0, + "reachable": true, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:80:00:c3:69:3c", + "type": "NLT", + "battery_state": "full", + "battery_level": 3300, + "firmware_revision": 42, + "last_seen": 0, + "reachable": false, + "bridge": "12:34:56:80:60:40" + }, + { + "id": "12:34:56:00:16:0e", + "type": "NLE", + "firmware_revision": 14, + "wifi_strength": 38 + }, + { + "id": "12:34:56:00:16:0e#0", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#1", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#2", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#3", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#4", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#5", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#6", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#7", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:16:0e#8", + "type": "NLE", + "bridge": "12:34:56:00:16:0e" + }, + { + "id": "12:34:56:00:00:a1:4c:da", + "type": "NLPC", + "firmware_revision": 62, + "last_seen": 1646511241, + "power": 476, + "reachable": true, + "bridge": "12:34:56:80:60:40" } ], "rooms": [ @@ -85,6 +903,19 @@ "therm_setpoint_end_time": 0, "anticipating": false, "open_window": false + }, + { + "id": "2940411588", + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "humidity": 68, + "therm_measured_temperature": 19.9, + "therm_setpoint_temperature": 21.5, + "therm_setpoint_start_time": 1647793285, + "therm_setpoint_end_time": null, + "therm_setpoint_mode": "home" } ], "id": "91763b24c43d3e344f424e8b", diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 5b01668925f..ea39497ce58 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -93,26 +93,36 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth): assert hass.states.get(camera_entity_indoor).state == "streaming" assert hass.states.get(camera_entity_outdoor).attributes["light_state"] == "auto" - with patch("pyatmo.camera.AsyncCameraData.async_set_state") as mock_set_state: + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: await hass.services.async_call( "camera", "turn_off", service_data={"entity_id": "camera.hall"} ) await hass.async_block_till_done() mock_set_state.assert_called_once_with( - home_id="91763b24c43d3e344f424e8b", - camera_id="12:34:56:00:f1:62", - monitoring="off", + { + "modules": [ + { + "id": "12:34:56:00:f1:62", + "monitoring": "off", + } + ] + } ) - with patch("pyatmo.camera.AsyncCameraData.async_set_state") as mock_set_state: + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: await hass.services.async_call( "camera", "turn_on", service_data={"entity_id": "camera.hall"} ) await hass.async_block_till_done() mock_set_state.assert_called_once_with( - home_id="91763b24c43d3e344f424e8b", - camera_id="12:34:56:00:f1:62", - monitoring="on", + { + "modules": [ + { + "id": "12:34:56:00:f1:62", + "monitoring": "on", + } + ] + } ) @@ -135,15 +145,13 @@ async def test_camera_image_local(hass, config_entry, requests_mock, netatmo_aut assert cam is not None assert cam.state == STATE_STREAMING + assert cam.name == "Hall" stream_source = await camera.async_get_stream_source(hass, camera_entity_indoor) assert stream_source == stream_uri - requests_mock.get( - uri + "/live/snapshot_720.jpg", - content=IMAGE_BYTES_FROM_STREAM, - ) image = await camera.async_get_image(hass, camera_entity_indoor) + assert image.content == IMAGE_BYTES_FROM_STREAM @@ -156,10 +164,7 @@ async def test_camera_image_vpn(hass, config_entry, requests_mock, netatmo_auth) await hass.async_block_till_done() - uri = ( - "https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/" - "6d278460699e56180d47ab47169efb31/MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTTw,," - ) + uri = "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,," stream_uri = uri + "/live/files/high/index.m3u8" camera_entity_indoor = "camera.garden" cam = hass.states.get(camera_entity_indoor) @@ -170,10 +175,6 @@ async def test_camera_image_vpn(hass, config_entry, requests_mock, netatmo_auth) stream_source = await camera.async_get_stream_source(hass, camera_entity_indoor) assert stream_source == stream_uri - requests_mock.get( - uri + "/live/snapshot_720.jpg", - content=IMAGE_BYTES_FROM_STREAM, - ) image = await camera.async_get_image(hass, camera_entity_indoor) assert image.content == IMAGE_BYTES_FROM_STREAM @@ -192,32 +193,26 @@ async def test_service_set_person_away(hass, config_entry, netatmo_auth): "person": "Richard Doe", } - with patch( - "pyatmo.camera.AsyncCameraData.async_set_persons_away" - ) as mock_set_persons_away: + with patch("pyatmo.home.Home.async_set_persons_away") as mock_set_persons_away: await hass.services.async_call( "netatmo", SERVICE_SET_PERSON_AWAY, service_data=data ) await hass.async_block_till_done() mock_set_persons_away.assert_called_once_with( person_id="91827376-7e04-5298-83af-a0cb8372dff3", - home_id="91763b24c43d3e344f424e8b", ) data = { "entity_id": "camera.hall", } - with patch( - "pyatmo.camera.AsyncCameraData.async_set_persons_away" - ) as mock_set_persons_away: + with patch("pyatmo.home.Home.async_set_persons_away") as mock_set_persons_away: await hass.services.async_call( "netatmo", SERVICE_SET_PERSON_AWAY, service_data=data ) await hass.async_block_till_done() mock_set_persons_away.assert_called_once_with( person_id=None, - home_id="91763b24c43d3e344f424e8b", ) @@ -289,16 +284,13 @@ async def test_service_set_persons_home(hass, config_entry, netatmo_auth): "persons": "John Doe", } - with patch( - "pyatmo.camera.AsyncCameraData.async_set_persons_home" - ) as mock_set_persons_home: + with patch("pyatmo.home.Home.async_set_persons_home") as mock_set_persons_home: await hass.services.async_call( "netatmo", SERVICE_SET_PERSONS_HOME, service_data=data ) await hass.async_block_till_done() mock_set_persons_home.assert_called_once_with( person_ids=["91827374-7e04-5298-83ad-a0cb8372dff1"], - home_id="91763b24c43d3e344f424e8b", ) @@ -316,16 +308,49 @@ async def test_service_set_camera_light(hass, config_entry, netatmo_auth): "camera_light_mode": "on", } - with patch("pyatmo.camera.AsyncCameraData.async_set_state") as mock_set_state: + expected_data = { + "modules": [ + { + "id": "12:34:56:00:a5:a4", + "floodlight": "on", + }, + ], + } + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: await hass.services.async_call( "netatmo", SERVICE_SET_CAMERA_LIGHT, service_data=data ) await hass.async_block_till_done() - mock_set_state.assert_called_once_with( - home_id="91763b24c43d3e344f424e8b", - camera_id="12:34:56:00:a5:a4", - floodlight="on", + mock_set_state.assert_called_once_with(expected_data) + + +async def test_service_set_camera_light_invalid_type(hass, config_entry, netatmo_auth): + """Test service to set the indoor camera light mode.""" + with selected_platforms(["camera"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + await hass.async_block_till_done() + + data = { + "entity_id": "camera.hall", + "camera_light_mode": "on", + } + + with patch("pyatmo.home.Home.async_set_state") as mock_set_state, pytest.raises( + HomeAssistantError + ) as excinfo: + await hass.services.async_call( + "netatmo", + SERVICE_SET_CAMERA_LIGHT, + service_data=data, + blocking=True, ) + await hass.async_block_till_done() + + mock_set_state.assert_not_called() + assert excinfo.value.args == ("NACamera does not have a floodlight",) @pytest.mark.skip @@ -342,13 +367,13 @@ async def test_camera_reconnect_webhook(hass, config_entry): with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["camera"] + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook: - mock_auth.return_value.async_post_request.side_effect = fake_post + mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_webhook.return_value = "https://example.com" @@ -429,44 +454,6 @@ async def test_setup_component_no_devices(hass, config_entry): """Fake error during requesting backend data.""" nonlocal fake_post_hits fake_post_hits += 1 - return "{}" - - with patch( - "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" - ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["camera"] - ), patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", - ), patch( - "homeassistant.components.netatmo.webhook_generate_url" - ): - mock_auth.return_value.async_post_request.side_effect = fake_post_no_data - mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() - mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert fake_post_hits == 4 - - -async def test_camera_image_raises_exception(hass, config_entry, requests_mock): - """Test setup with no devices.""" - fake_post_hits = 0 - - async def fake_post(*args, **kwargs): - """Return fake data.""" - nonlocal fake_post_hits - fake_post_hits += 1 - - if "url" not in kwargs: - return "{}" - - endpoint = kwargs["url"].split("/")[-1] - - if "snapshot_720.jpg" in endpoint: - raise pyatmo.exceptions.ApiError() - return await fake_post_request(*args, **kwargs) with patch( @@ -478,7 +465,45 @@ async def test_camera_image_raises_exception(hass, config_entry, requests_mock): ), patch( "homeassistant.components.netatmo.webhook_generate_url" ): - mock_auth.return_value.async_post_request.side_effect = fake_post + mock_auth.return_value.async_post_api_request.side_effect = fake_post_no_data + mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() + mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert fake_post_hits == 9 + + +async def test_camera_image_raises_exception(hass, config_entry, requests_mock): + """Test setup with no devices.""" + fake_post_hits = 0 + + async def fake_post(*args, **kwargs): + """Return fake data.""" + nonlocal fake_post_hits + fake_post_hits += 1 + + if "endpoint" not in kwargs: + return "{}" + + endpoint = kwargs["endpoint"].split("/")[-1] + + if "snapshot_720.jpg" in endpoint: + raise pyatmo.ApiError() + + return await fake_post_request(*args, **kwargs) + + with patch( + "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" + ) as mock_auth, patch( + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"] + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.netatmo.webhook_generate_url" + ): + mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_get_image.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 0d51a53ec71..cc23dc887bd 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -413,9 +413,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_ climate_entity_livingroom = "climate.livingroom" # Test setting a valid schedule - with patch( - "pyatmo.climate.AsyncClimate.async_switch_home_schedule" - ) as mock_switch_home_schedule: + with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_schedule: await hass.services.async_call( "netatmo", SERVICE_SET_SCHEDULE, @@ -423,7 +421,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_ blocking=True, ) await hass.async_block_till_done() - mock_switch_home_schedule.assert_called_once_with( + mock_switch_schedule.assert_called_once_with( schedule_id="b1b54a2f45795764f59d50d8" ) @@ -442,9 +440,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_ ) # Test setting an invalid schedule - with patch( - "pyatmo.climate.AsyncClimate.async_switch_home_schedule" - ) as mock_switch_home_schedule: + with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_home_schedule: await hass.services.async_call( "netatmo", SERVICE_SET_SCHEDULE, diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index ae13268eda3..964c7696e64 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Netatmo config flow.""" from unittest.mock import patch +from pyatmo.const import ALL_SCOPES + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components import zeroconf from homeassistant.components.netatmo import config_flow @@ -14,8 +16,6 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.helpers import config_entry_oauth2_flow -from .common import ALL_SCOPES - from tests.common import MockConfigEntry CLIENT_ID = "1234" diff --git a/tests/components/netatmo/test_cover.py b/tests/components/netatmo/test_cover.py new file mode 100644 index 00000000000..c0f34f25b24 --- /dev/null +++ b/tests/components/netatmo/test_cover.py @@ -0,0 +1,110 @@ +"""The tests for Netatmo cover.""" +from unittest.mock import patch + +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, +) +from homeassistant.const import ATTR_ENTITY_ID + +from .common import selected_platforms + + +async def test_cover_setup_and_services(hass, config_entry, netatmo_auth): + """Test setup and services.""" + with selected_platforms(["cover"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + switch_entity = "cover.entrance_blinds" + + assert hass.states.get(switch_entity).state == "closed" + + # Test cover open + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: switch_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": 100, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) + + # Test cover close + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: switch_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": 0, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) + + # Test stop cover + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: switch_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": -1, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) + + # Test set cover position + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: switch_entity, ATTR_POSITION: 50}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "0009999992", + "target_position": 50, + "bridge": "12:34:56:30:d5:d4", + } + ] + } + ) diff --git a/tests/components/netatmo/test_device_trigger.py b/tests/components/netatmo/test_device_trigger.py index 88e455b9f67..25b86f8410e 100644 --- a/tests/components/netatmo/test_device_trigger.py +++ b/tests/components/netatmo/test_device_trigger.py @@ -7,11 +7,6 @@ from homeassistant.components.netatmo import DOMAIN as NETATMO_DOMAIN from homeassistant.components.netatmo.const import ( CLIMATE_TRIGGERS, INDOOR_CAMERA_TRIGGERS, - MODEL_NACAMERA, - MODEL_NAPLUG, - MODEL_NATHERM1, - MODEL_NOC, - MODEL_NRV, NETATMO_EVENT, OUTDOOR_CAMERA_TRIGGERS, ) @@ -52,10 +47,10 @@ def calls(hass): @pytest.mark.parametrize( "platform,device_type,event_types", [ - ("camera", MODEL_NOC, OUTDOOR_CAMERA_TRIGGERS), - ("camera", MODEL_NACAMERA, INDOOR_CAMERA_TRIGGERS), - ("climate", MODEL_NRV, CLIMATE_TRIGGERS), - ("climate", MODEL_NATHERM1, CLIMATE_TRIGGERS), + ("camera", "NOC", OUTDOOR_CAMERA_TRIGGERS), + ("camera", "NACamera", INDOOR_CAMERA_TRIGGERS), + ("climate", "NRV", CLIMATE_TRIGGERS), + ("climate", "NATherm1", CLIMATE_TRIGGERS), ], ) async def test_get_triggers( @@ -110,15 +105,15 @@ async def test_get_triggers( @pytest.mark.parametrize( "platform,camera_type,event_type", - [("camera", MODEL_NOC, trigger) for trigger in OUTDOOR_CAMERA_TRIGGERS] - + [("camera", MODEL_NACAMERA, trigger) for trigger in INDOOR_CAMERA_TRIGGERS] + [("camera", "NOC", trigger) for trigger in OUTDOOR_CAMERA_TRIGGERS] + + [("camera", "NACamera", trigger) for trigger in INDOOR_CAMERA_TRIGGERS] + [ - ("climate", MODEL_NRV, trigger) + ("climate", "NRV", trigger) for trigger in CLIMATE_TRIGGERS if trigger not in SUBTYPES ] + [ - ("climate", MODEL_NATHERM1, trigger) + ("climate", "NATherm1", trigger) for trigger in CLIMATE_TRIGGERS if trigger not in SUBTYPES ], @@ -188,12 +183,12 @@ async def test_if_fires_on_event( @pytest.mark.parametrize( "platform,camera_type,event_type,sub_type", [ - ("climate", MODEL_NRV, trigger, subtype) + ("climate", "NRV", trigger, subtype) for trigger in SUBTYPES for subtype in SUBTYPES[trigger] ] + [ - ("climate", MODEL_NATHERM1, trigger, subtype) + ("climate", "NATherm1", trigger, subtype) for trigger in SUBTYPES for subtype in SUBTYPES[trigger] ], @@ -267,7 +262,7 @@ async def test_if_fires_on_event_with_subtype( @pytest.mark.parametrize( "platform,device_type,event_type", - [("climate", MODEL_NAPLUG, trigger) for trigger in CLIMATE_TRIGGERS], + [("climate", "NAPLUG", trigger) for trigger in CLIMATE_TRIGGERS], ) async def test_if_invalid_device( hass, device_reg, entity_reg, platform, device_type, event_type diff --git a/tests/components/netatmo/test_diagnostics.py b/tests/components/netatmo/test_diagnostics.py index 3c4a2090f1b..cf9a76e38a3 100644 --- a/tests/components/netatmo/test_diagnostics.py +++ b/tests/components/netatmo/test_diagnostics.py @@ -18,7 +18,7 @@ async def test_entry_diagnostics(hass, hass_client, config_entry): ), patch( "homeassistant.components.netatmo.webhook_generate_url" ): - mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component(hass, "netatmo", {}) @@ -39,16 +39,27 @@ async def test_entry_diagnostics(hass, hass_client, config_entry): "expires_in": 60, "refresh_token": REDACTED, "scope": [ - "read_station", - "read_camera", "access_camera", - "write_camera", - "read_presence", + "access_doorbell", "access_presence", - "write_presence", + "read_bubendorff", + "read_camera", + "read_carbonmonoxidedetector", + "read_doorbell", "read_homecoach", + "read_magellan", + "read_mx", + "read_presence", + "read_smarther", "read_smokedetector", + "read_station", "read_thermostat", + "write_bubendorff", + "write_camera", + "write_magellan", + "write_mx", + "write_presence", + "write_smarther", "write_thermostat", ], "type": "Bearer", @@ -88,5 +99,5 @@ async def test_entry_diagnostics(hass, hass_client, config_entry): "webhook_registered": False, } - for home in result["data"]["AsyncClimateTopology"]["homes"]: + for home in result["data"]["account"]["homes"]: assert home["coordinates"] == REDACTED diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 911fd6c309a..373d7e19765 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -1,11 +1,10 @@ """The tests for Netatmo component.""" -import asyncio from datetime import timedelta from time import time from unittest.mock import AsyncMock, patch import aiohttp -import pyatmo +from pyatmo.const import ALL_SCOPES from homeassistant import config_entries from homeassistant.components.netatmo import DOMAIN @@ -15,7 +14,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt from .common import ( - ALL_SCOPES, FAKE_WEBHOOK_ACTIVATION, fake_post_request, selected_platforms, @@ -60,7 +58,7 @@ async def test_setup_component(hass, config_entry): ) as mock_impl, patch( "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook: - mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component(hass, "netatmo", {}) @@ -100,9 +98,9 @@ async def test_setup_component_with_config(hass, config_entry): ) as mock_webhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["sensor"] + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["sensor"] ): - mock_auth.return_value.async_post_request.side_effect = fake_post + mock_auth.return_value.async_post_api_request.side_effect = fake_post mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() @@ -112,7 +110,7 @@ async def test_setup_component_with_config(hass, config_entry): await hass.async_block_till_done() - assert fake_post_hits == 9 + assert fake_post_hits == 8 mock_impl.assert_called_once() mock_webhook.assert_called_once() @@ -162,7 +160,7 @@ async def test_setup_without_https(hass, config_entry, caplog): ), patch( "homeassistant.components.netatmo.webhook_generate_url" ) as mock_async_generate_url: - mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_async_generate_url.return_value = "http://example.com" assert await async_setup_component( hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} @@ -200,7 +198,7 @@ async def test_setup_with_cloud(hass, config_entry): ), patch( "homeassistant.components.netatmo.webhook_generate_url" ): - mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_post_api_request.side_effect = fake_post_request assert await async_setup_component( hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} ) @@ -266,7 +264,7 @@ async def test_setup_with_cloudhook(hass): ), patch( "homeassistant.components.netatmo.webhook_generate_url" ): - mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component(hass, "netatmo", {}) @@ -289,52 +287,6 @@ async def test_setup_with_cloudhook(hass): assert not hass.config_entries.async_entries(DOMAIN) -async def test_setup_component_api_error(hass, config_entry): - """Test error on setup of the netatmo component.""" - with patch( - "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", - ) as mock_auth, patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", - ) as mock_impl, patch( - "homeassistant.components.netatmo.webhook_generate_url" - ): - mock_auth.return_value.async_post_request.side_effect = ( - pyatmo.exceptions.ApiError() - ) - - mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() - mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() - assert await async_setup_component(hass, "netatmo", {}) - - await hass.async_block_till_done() - - mock_auth.assert_called_once() - mock_impl.assert_called_once() - - -async def test_setup_component_api_timeout(hass, config_entry): - """Test timeout on setup of the netatmo component.""" - with patch( - "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", - ) as mock_auth, patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", - ) as mock_impl, patch( - "homeassistant.components.netatmo.webhook_generate_url" - ): - mock_auth.return_value.async_post_request.side_effect = ( - asyncio.exceptions.TimeoutError() - ) - - mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() - mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() - assert await async_setup_component(hass, "netatmo", {}) - - await hass.async_block_till_done() - - mock_auth.assert_called_once() - mock_impl.assert_called_once() - - async def test_setup_component_with_delay(hass, config_entry): """Test setup of the netatmo component with delayed startup.""" hass.state = CoreState.not_running @@ -348,9 +300,9 @@ async def test_setup_component_with_delay(hass, config_entry): ) as mock_impl, patch( "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook, patch( - "pyatmo.AbstractAsyncAuth.async_post_request", side_effect=fake_post_request - ) as mock_post_request, patch( - "homeassistant.components.netatmo.PLATFORMS", ["light"] + "pyatmo.AbstractAsyncAuth.async_post_api_request", side_effect=fake_post_request + ) as mock_post_api_request, patch( + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"] ): assert await async_setup_component( @@ -359,7 +311,7 @@ async def test_setup_component_with_delay(hass, config_entry): await hass.async_block_till_done() - assert mock_post_request.call_count == 8 + assert mock_post_api_request.call_count == 7 mock_impl.assert_called_once() mock_webhook.assert_not_called() @@ -422,7 +374,7 @@ async def test_setup_component_invalid_token_scope(hass): ) as mock_impl, patch( "homeassistant.components.netatmo.webhook_generate_url" ) as mock_webhook: - mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() assert await async_setup_component(hass, "netatmo", {}) @@ -465,7 +417,7 @@ async def test_setup_component_invalid_token(hass, config_entry): ) as mock_webhook, patch( "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" ) as mock_session: - mock_auth.return_value.async_post_request.side_effect = fake_post_request + mock_auth.return_value.async_post_api_request.side_effect = fake_post_request mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_session.return_value.async_ensure_token_valid.side_effect = ( diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 433841f3878..ced24c738e3 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -14,8 +14,8 @@ from .common import FAKE_WEBHOOK_ACTIVATION, selected_platforms, simulate_webhoo from tests.test_util.aiohttp import AiohttpClientMockResponse -async def test_light_setup_and_services(hass, config_entry, netatmo_auth): - """Test setup and services.""" +async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth): + """Test camera ligiht setup and services.""" with selected_platforms(["light"]): await hass.config_entries.async_setup(config_entry.entry_id) @@ -53,7 +53,7 @@ async def test_light_setup_and_services(hass, config_entry, netatmo_auth): assert hass.states.get(light_entity).state == "on" # Test turning light off - with patch("pyatmo.camera.AsyncCameraData.async_set_state") as mock_set_state: + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -62,13 +62,11 @@ async def test_light_setup_and_services(hass, config_entry, netatmo_auth): ) await hass.async_block_till_done() mock_set_state.assert_called_once_with( - home_id="91763b24c43d3e344f424e8b", - camera_id="12:34:56:00:a5:a4", - floodlight="auto", + {"modules": [{"id": "12:34:56:00:a5:a4", "floodlight": "auto"}]} ) # Test turning light on - with patch("pyatmo.camera.AsyncCameraData.async_set_state") as mock_set_state: + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -77,9 +75,7 @@ async def test_light_setup_and_services(hass, config_entry, netatmo_auth): ) await hass.async_block_till_done() mock_set_state.assert_called_once_with( - home_id="91763b24c43d3e344f424e8b", - camera_id="12:34:56:00:a5:a4", - floodlight="on", + {"modules": [{"id": "12:34:56:00:a5:a4", "floodlight": "on"}]} ) @@ -93,7 +89,7 @@ async def test_setup_component_no_devices(hass, config_entry): fake_post_hits += 1 return AiohttpClientMockResponse( method="POST", - url=kwargs["url"], + url=kwargs["endpoint"], json={}, ) @@ -106,7 +102,7 @@ async def test_setup_component_no_devices(hass, config_entry): ), patch( "homeassistant.components.netatmo.webhook_generate_url" ): - mock_auth.return_value.async_post_request.side_effect = ( + mock_auth.return_value.async_post_api_request.side_effect = ( fake_post_request_no_data ) mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() @@ -125,3 +121,57 @@ async def test_setup_component_no_devices(hass, config_entry): assert hass.config_entries.async_entries(DOMAIN) assert len(hass.states.async_all()) == 0 + + +async def test_light_setup_and_services(hass, config_entry, netatmo_auth): + """Test setup and services.""" + with selected_platforms(["light"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + light_entity = "light.bathroom_light" + + assert hass.states.get(light_entity).state == "off" + + # Test turning light off + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: light_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "12:34:56:00:01:01:01:a1", + "on": False, + "bridge": "12:34:56:80:60:40", + } + ] + } + ) + + # Test turning light on + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: light_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "12:34:56:00:01:01:01:a1", + "on": True, + "bridge": "12:34:56:80:60:40", + } + ] + } + ) diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index 01422dfd118..96566af6832 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -70,13 +70,13 @@ async def test_async_browse_media(hass): # Test successful event listing media = await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672" + hass, f"{URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1654191519" ) assert media # Test successful event resolve media = await async_resolve_media( - hass, f"{URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672", None + hass, f"{URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1654191519", None ) assert media == PlayMedia( url="http:///files/high/index.m3u8", mime_type="application/x-mpegURL" diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py index 12168a03ad8..ea8e88ce8de 100644 --- a/tests/components/netatmo/test_select.py +++ b/tests/components/netatmo/test_select.py @@ -19,7 +19,7 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a await hass.async_block_till_done() webhook_id = config_entry.data[CONF_WEBHOOK_ID] - select_entity = "select.netatmo_myhome" + select_entity = "select.myhome" assert hass.states.get(select_entity).state == "Default" @@ -40,9 +40,7 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a ] # Test setting a different schedule - with patch( - "pyatmo.climate.AsyncClimate.async_switch_home_schedule" - ) as mock_switch_home_schedule: + with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_home_schedule: await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 9adc7423bd6..99e76389c13 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -4,7 +4,6 @@ from unittest.mock import patch import pytest from homeassistant.components.netatmo import sensor -from homeassistant.components.netatmo.sensor import MODULE_TYPE_WIND from homeassistant.helpers import entity_registry as er from .common import TEST_TIME, selected_platforms @@ -17,7 +16,7 @@ async def test_weather_sensor(hass, config_entry, netatmo_auth): await hass.async_block_till_done() - prefix = "sensor.mystation_" + prefix = "sensor.netatmoindoor_" assert hass.states.get(f"{prefix}temperature").state == "24.6" assert hass.states.get(f"{prefix}humidity").state == "36" @@ -102,77 +101,19 @@ async def test_process_health(health, expected): assert sensor.process_health(health) == expected -@pytest.mark.parametrize( - "model, data, expected", - [ - (MODULE_TYPE_WIND, 5591, "Full"), - (MODULE_TYPE_WIND, 5181, "High"), - (MODULE_TYPE_WIND, 4771, "Medium"), - (MODULE_TYPE_WIND, 4361, "Low"), - (MODULE_TYPE_WIND, 4300, "Very Low"), - ], -) -async def test_process_battery(model, data, expected): - """Test battery level translation.""" - assert sensor.process_battery(data, model) == expected - - -@pytest.mark.parametrize( - "angle, expected", - [ - (0, "N"), - (40, "NE"), - (70, "E"), - (130, "SE"), - (160, "S"), - (220, "SW"), - (250, "W"), - (310, "NW"), - (340, "N"), - ], -) -async def test_process_angle(angle, expected): - """Test wind direction translation.""" - assert sensor.process_angle(angle) == expected - - -@pytest.mark.parametrize( - "angle, expected", - [(-1, 359), (-40, 320)], -) -async def test_fix_angle(angle, expected): - """Test wind angle fix.""" - assert sensor.fix_angle(angle) == expected - - @pytest.mark.parametrize( "uid, name, expected", [ - ("12:34:56:37:11:ca-reachable", "netatmo_mystation_reachable", "True"), - ("12:34:56:03:1b:e4-rf_status", "netatmo_mystation_yard_radio", "Full"), - ( - "12:34:56:05:25:6e-rf_status", - "netatmo_valley_road_rain_gauge_radio", - "Medium", - ), - ( - "12:34:56:36:fc:de-rf_status_lvl", - "netatmo_mystation_netatmooutdoor_radio_level", - "65", - ), - ( - "12:34:56:37:11:ca-wifi_status_lvl", - "netatmo_mystation_wifi_level", - "45", - ), + ("12:34:56:37:11:ca-reachable", "mystation_reachable", "True"), + ("12:34:56:03:1b:e4-rf_status", "mystation_yard_radio", "Full"), ( "12:34:56:37:11:ca-wifi_status", - "netatmo_mystation_wifi_status", + "mystation_wifi_strength", "Full", ), ( "12:34:56:37:11:ca-temp_trend", - "netatmo_mystation_temperature_trend", + "mystation_temperature_trend", "stable", ), ( @@ -182,33 +123,47 @@ async def test_fix_angle(angle, expected): ), ("12:34:56:05:51:20-sum_rain_1", "netatmo_mystation_yard_rain_last_hour", "0"), ("12:34:56:05:51:20-sum_rain_24", "netatmo_mystation_yard_rain_today", "0"), - ("12:34:56:03:1b:e4-windangle", "netatmo_mystation_garden_direction", "SW"), + ("12:34:56:03:1b:e4-windangle", "netatmoindoor_garden_direction", "SW"), ( "12:34:56:03:1b:e4-windangle_value", - "netatmo_mystation_garden_angle", + "netatmoindoor_garden_angle", "217", ), ("12:34:56:03:1b:e4-gustangle", "mystation_garden_gust_direction", "S"), ( "12:34:56:03:1b:e4-gustangle", - "netatmo_mystation_garden_gust_direction", + "netatmoindoor_garden_gust_direction", "S", ), ( "12:34:56:03:1b:e4-gustangle_value", - "netatmo_mystation_garden_gust_angle_value", + "netatmoindoor_garden_gust_angle", "206", ), ( "12:34:56:03:1b:e4-guststrength", - "netatmo_mystation_garden_gust_strength", + "netatmoindoor_garden_gust_strength", "9", ), + ( + "12:34:56:03:1b:e4-rf_status", + "netatmoindoor_garden_rf_strength", + "Full", + ), ( "12:34:56:26:68:92-health_idx", - "netatmo_baby_bedroom_health", + "baby_bedroom_health", "Fine", ), + ( + "12:34:56:26:68:92-wifi_status", + "baby_bedroom_wifi", + "High", + ), + ("Home-max-windangle_value", "home_max_wind_angle", "17"), + ("Home-max-gustangle_value", "home_max_gust_angle", "217"), + ("Home-max-guststrength", "home_max_gust_strength", "31"), + ("Home-max-sum_rain_1", "home_max_sum_rain_1", "0.2"), ], ) async def test_weather_sensor_enabling( diff --git a/tests/components/netatmo/test_switch.py b/tests/components/netatmo/test_switch.py new file mode 100644 index 00000000000..dc11ac22746 --- /dev/null +++ b/tests/components/netatmo/test_switch.py @@ -0,0 +1,65 @@ +"""The tests for Netatmo switch.""" +from unittest.mock import patch + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID + +from .common import selected_platforms + + +async def test_switch_setup_and_services(hass, config_entry, netatmo_auth): + """Test setup and services.""" + with selected_platforms(["switch"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + switch_entity = "switch.prise" + + assert hass.states.get(switch_entity).state == "on" + + # Test turning switch off + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: switch_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "12:34:56:80:00:12:ac:f2", + "on": False, + "bridge": "12:34:56:80:60:40", + } + ] + } + ) + + # Test turning switch on + with patch("pyatmo.home.Home.async_set_state") as mock_set_state: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch_entity}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once_with( + { + "modules": [ + { + "id": "12:34:56:80:00:12:ac:f2", + "on": True, + "bridge": "12:34:56:80:60:40", + } + ] + } + )