Netatmo refactor to use pyatmo 7.0.1 (#73482) (#78523)

Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>
This commit is contained in:
Tobias Sauerwein 2022-09-26 03:55:58 +02:00 committed by GitHub
parent 1b17c83095
commit 81abeac83e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2915 additions and 1279 deletions

View file

@ -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,7 +206,7 @@ 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)
else:
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
)

View file

@ -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__(

View file

@ -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,
entry.async_on_unload(
async_dispatcher_connect(hass, NETATMO_CREATE_CAMERA, _create_entity)
)
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)
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._camera.monitoring is not None:
self._attr_is_streaming = self._camera.monitoring
self._attr_motion_detection_enabled = self._camera.monitoring
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, {})
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)

View file

@ -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,46 +108,17 @@ 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
entry.async_on_unload(
async_dispatcher_connect(hass, NETATMO_CREATE_CLIMATE, _create_entity)
)
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)
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},
@ -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,6 +382,9 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
] = self._room.heating_power_request
else:
for module in self._room.modules.values():
if hasattr(module, "boiler_status"):
module = cast(NATherm1, module)
if module.boiler_status is not None:
self._boilerstatus = module.boiler_status
break
@ -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,

View file

@ -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."""

View file

@ -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"

View file

@ -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

View file

@ -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,
),
)

View file

@ -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"
)

View file

@ -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,
)
},

View file

@ -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

View file

@ -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"],

View file

@ -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")
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]["event_list"][0]["message"]
self.events[camera_id][event_id].get("message", "")
)
else:
thumbnail = (
self.events[camera_id][event_id].get("snapshot", {}).get("url")
)
message = remove_html_tags(self.events[camera_id][event_id]["message"])
title = f"{created} - {message}"
else:
title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER)

View file

@ -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,
)

View file

@ -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,97 +26,63 @@ 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
entry.async_on_unload(
async_dispatcher_connect(hass, NETATMO_CREATE_SELECT, _create_entity)
)
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)
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(
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"signal-{DOMAIN}-webhook-{event_type}",
f"signal-{DOMAIN}-webhook-{EVENT_TYPE_SCHEDULE}",
self.handle_event,
)
)
@ -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()
]

View file

@ -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 []
data_class = data[data_class_name]
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
]
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)
@callback
def _create_weather_sensor_entity(netatmo_device: NetatmoDevice) -> None:
async_add_entities(
NetatmoWeatherSensor(netatmo_device, description)
for description in SENSOR_TYPES
if description.key in conditions
if description.netatmo_name in netatmo_device.device.features
)
entry.async_on_unload(
async_dispatcher_connect(
hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_sensor_entity
)
)
@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
]
)
_LOGGER.debug("Adding weather sensors %s", entities)
return entities
entry.async_on_unload(
async_dispatcher_connect(hass, NETATMO_CREATE_SENSOR, _create_sensor_entity)
)
for data_class_name in (
WEATHERSTATION_DATA_CLASS_NAME,
HOMECOACH_DATA_CLASS_NAME,
):
data_class = data_handler.data.get(data_class_name)
@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
)
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,67 +404,55 @@ 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._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._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._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
@ -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"}:
if self.entity_description.netatmo_name 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":
elif self.entity_description.netatmo_name == "rf_strength":
self._attr_native_value = process_rf(state)
elif self.entity_description.key == "wifi_status":
elif self.entity_description.netatmo_name == "wifi_strength":
self._attr_native_value = process_wifi(state)
elif self.entity_description.key == "health_idx":
elif self.entity_description.netatmo_name == "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
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:

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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()

View file

@ -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",
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",
"url": "https://netatmocameraimage"
},
"video_id": "98765",
"vignette": {
"url": "https://netatmocameraimage"
},
"video_id": "0011",
"video_status": "available",
"message": "<b>Paulus</b> seen",
"media_url": "http:///files/high/index.m3u8",
"message": "<b>Bewegung</b> erkannt",
"subevents": [],
"media_url": "http:///files/high/index.m3u8"
},
1599152673: {
"id": "12346",
"type": "person",
"time": 1599152673,
"camera_id": "12:34:56:78:90:ab",
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",
"url": "https://netatmocameraimage"
},
"message": "<b>Tobias</b> seen",
},
1599152674: {
"id": "12347",
"type": "outdoor",
"time": 1599152674,
"camera_id": "12:34:56:78:90:ac",
"snapshot": {
"url": "https://netatmocameraimage",
},
"video_id": "98766",
"video_id": "0012",
"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",
"message": "<b>Jane</b> 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": "<b>Jane</b> gesehen",
"person_id": "1111",
"out_of_sight": False,
"subevents": []
}
}
}

View file

@ -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": "<b>John Doe</b> 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
}

View file

@ -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": [

View file

@ -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",

View file

@ -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 <Hall> 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()

View file

@ -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,

View file

@ -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"

View file

@ -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",
}
]
}
)

View file

@ -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

View file

@ -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

View file

@ -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 = (

View file

@ -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",
}
]
}
)

View file

@ -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"

View file

@ -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,

View file

@ -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(

View file

@ -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",
}
]
}
)