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 aiohttp
import pyatmo import pyatmo
from pyatmo.const import ALL_SCOPES as NETATMO_SCOPES
import voluptuous as vol import voluptuous as vol
from homeassistant.components import cloud from homeassistant.components import cloud
@ -51,7 +52,6 @@ from .const import (
DATA_PERSONS, DATA_PERSONS,
DATA_SCHEDULES, DATA_SCHEDULES,
DOMAIN, DOMAIN,
NETATMO_SCOPES,
PLATFORMS, PLATFORMS,
WEBHOOK_DEACTIVATION, WEBHOOK_DEACTIVATION,
WEBHOOK_PUSH_TYPE, WEBHOOK_PUSH_TYPE,
@ -150,10 +150,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
} }
data_handler = NetatmoDataHandler(hass, entry) data_handler = NetatmoDataHandler(hass, entry)
await data_handler.async_setup()
hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] = data_handler hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] = data_handler
await data_handler.async_setup()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def unregister_webhook( async def unregister_webhook(
call_or_event_or_dt: ServiceCall | Event | datetime | None, call_or_event_or_dt: ServiceCall | Event | datetime | None,
@ -208,10 +206,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.info("Register Netatmo webhook: %s", webhook_url) _LOGGER.info("Register Netatmo webhook: %s", webhook_url)
except pyatmo.ApiError as err: except pyatmo.ApiError as err:
_LOGGER.error("Error during webhook registration - %s", err) _LOGGER.error("Error during webhook registration - %s", err)
else:
entry.async_on_unload( entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
) )
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
if state is cloud.CloudConnectionState.CLOUD_CONNECTED: if state is cloud.CloudConnectionState.CLOUD_CONNECTED:

View file

@ -7,7 +7,7 @@ import pyatmo
from homeassistant.helpers import config_entry_oauth2_flow 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.""" """Provide Netatmo authentication tied to an OAuth2 based config entry."""
def __init__( def __init__(

View file

@ -5,13 +5,14 @@ import logging
from typing import Any, cast from typing import Any, cast
import aiohttp import aiohttp
import pyatmo from pyatmo import ApiError as NetatmoApiError, modules as NaModules
from pyatmo.event import Event as NaEvent
import voluptuous as vol import voluptuous as vol
from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback 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 import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -20,28 +21,24 @@ from .const import (
ATTR_CAMERA_LIGHT_MODE, ATTR_CAMERA_LIGHT_MODE,
ATTR_PERSON, ATTR_PERSON,
ATTR_PERSONS, ATTR_PERSONS,
ATTR_PSEUDO,
CAMERA_LIGHT_MODES, CAMERA_LIGHT_MODES,
CONF_URL_SECURITY,
DATA_CAMERAS, DATA_CAMERAS,
DATA_EVENTS, DATA_EVENTS,
DATA_HANDLER,
DATA_PERSONS,
DOMAIN, DOMAIN,
EVENT_TYPE_LIGHT_MODE, EVENT_TYPE_LIGHT_MODE,
EVENT_TYPE_OFF, EVENT_TYPE_OFF,
EVENT_TYPE_ON, EVENT_TYPE_ON,
MANUFACTURER, MANUFACTURER,
MODELS, NETATMO_CREATE_CAMERA,
SERVICE_SET_CAMERA_LIGHT, SERVICE_SET_CAMERA_LIGHT,
SERVICE_SET_PERSON_AWAY, SERVICE_SET_PERSON_AWAY,
SERVICE_SET_PERSONS_HOME, SERVICE_SET_PERSONS_HOME,
SIGNAL_NAME,
TYPE_SECURITY,
WEBHOOK_LIGHT_MODE, WEBHOOK_LIGHT_MODE,
WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NACAMERA_CONNECTION,
WEBHOOK_PUSH_TYPE, 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 from .netatmo_entity_base import NetatmoBase
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -53,42 +50,15 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the Netatmo camera platform.""" """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: entry.async_on_unload(
raise PlatformNotReady async_dispatcher_connect(hass, NETATMO_CREATE_CAMERA, _create_entity)
)
all_cameras = []
for home in data_class.cameras.values():
for camera in home.values():
all_cameras.append(camera)
entities = [
NetatmoCamera(
data_handler,
camera["id"],
camera["type"],
camera["home_id"],
DEFAULT_QUALITY,
)
for camera in all_cameras
]
for home in data_class.homes.values():
if home.get("id") is None:
continue
hass.data[DOMAIN][DATA_PERSONS][home["id"]] = {
person_id: person_data.get(ATTR_PSEUDO)
for person_id, person_data in data_handler.data[CAMERA_DATA_CLASS_NAME]
.persons[home["id"]]
.items()
}
_LOGGER.debug("Adding cameras %s", entities)
async_add_entities(entities, True)
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
@ -118,41 +88,44 @@ class NetatmoCamera(NetatmoBase, Camera):
def __init__( def __init__(
self, self,
data_handler: NetatmoDataHandler, netatmo_device: NetatmoDevice,
camera_id: str,
camera_type: str,
home_id: str,
quality: str,
) -> None: ) -> None:
"""Set up for access to the Netatmo camera images.""" """Set up for access to the Netatmo camera images."""
Camera.__init__(self) Camera.__init__(self)
super().__init__(data_handler) super().__init__(netatmo_device.data_handler)
self._publishers.append( self._camera = cast(NaModules.Camera, netatmo_device.device)
{"name": CAMERA_DATA_CLASS_NAME, SIGNAL_NAME: CAMERA_DATA_CLASS_NAME} self._id = self._camera.entity_id
) self._home_id = self._camera.home.entity_id
self._device_name = self._camera.name
self._id = camera_id self._model = self._camera.device_type
self._home_id = home_id self._config_url = CONF_URL_SECURITY
self._device_name = self._data.get_camera(camera_id=camera_id)["name"]
self._model = camera_type
self._netatmo_type = TYPE_SECURITY
self._attr_unique_id = f"{self._id}-{self._model}" self._attr_unique_id = f"{self._id}-{self._model}"
self._quality = quality self._quality = DEFAULT_QUALITY
self._vpnurl: str | None = None self._monitoring: bool | 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._light_state = 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: async def async_added_to_hass(self) -> None:
"""Entity created.""" """Entity created."""
await super().async_added_to_hass() await super().async_added_to_hass()
for event_type in (EVENT_TYPE_LIGHT_MODE, EVENT_TYPE_OFF, EVENT_TYPE_ON): 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( async_dispatcher_connect(
self.hass, self.hass,
f"signal-{DOMAIN}-webhook-{event_type}", 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["home_id"] == self._home_id and data["camera_id"] == self._id:
if data[WEBHOOK_PUSH_TYPE] in ("NACamera-off", "NACamera-disconnection"): if data[WEBHOOK_PUSH_TYPE] in ("NACamera-off", "NACamera-disconnection"):
self._attr_is_streaming = False self._attr_is_streaming = False
self._status = "off" self._monitoring = False
elif data[WEBHOOK_PUSH_TYPE] in ( elif data[WEBHOOK_PUSH_TYPE] in (
"NACamera-on", "NACamera-on",
WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NACAMERA_CONNECTION,
): ):
self._attr_is_streaming = True self._attr_is_streaming = True
self._status = "on" self._monitoring = True
elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE: elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE:
self._light_state = data["sub_type"] self._light_state = data["sub_type"]
self._attr_extra_state_attributes.update( self._attr_extra_state_attributes.update(
@ -189,128 +162,107 @@ class NetatmoCamera(NetatmoBase, Camera):
self.async_write_ha_state() self.async_write_ha_state()
return 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( async def async_camera_image(
self, width: int | None = None, height: int | None = None self, width: int | None = None, height: int | None = None
) -> bytes | None: ) -> bytes | None:
"""Return a still image response from the camera.""" """Return a still image response from the camera."""
try: try:
return cast( return cast(bytes, await self._camera.async_get_live_snapshot())
bytes, await self._data.async_get_live_snapshot(camera_id=self._id)
)
except ( except (
aiohttp.ClientPayloadError, aiohttp.ClientPayloadError,
aiohttp.ContentTypeError, aiohttp.ContentTypeError,
aiohttp.ServerDisconnectedError, aiohttp.ServerDisconnectedError,
aiohttp.ClientConnectorError, aiohttp.ClientConnectorError,
pyatmo.exceptions.ApiError, NetatmoApiError,
) as err: ) as err:
_LOGGER.debug("Could not fetch live camera image (%s)", err) _LOGGER.debug("Could not fetch live camera image (%s)", err)
return None return None
@property @property
def available(self) -> bool: def supported_features(self) -> int:
"""Return True if entity is available.""" """Return supported features."""
return bool(self._alim_status == "on" or self._status == "disconnected") supported_features: int = CameraEntityFeature.ON_OFF
if self._model != "NDB":
@property supported_features |= CameraEntityFeature.STREAM
def motion_detection_enabled(self) -> bool: return supported_features
"""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
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Turn off camera.""" """Turn off camera."""
await self._data.async_set_state( await self._camera.async_monitoring_off()
home_id=self._home_id, camera_id=self._id, monitoring="off"
)
async def async_turn_on(self) -> None: async def async_turn_on(self) -> None:
"""Turn on camera.""" """Turn on camera."""
await self._data.async_set_state( await self._camera.async_monitoring_on()
home_id=self._home_id, camera_id=self._id, monitoring="on"
)
async def stream_source(self) -> str: async def stream_source(self) -> str:
"""Return the stream source.""" """Return the stream source."""
url = "{0}/live/files/{1}/index.m3u8" if self._camera.is_local:
if self._localurl: await self._camera.async_update_camera_urls()
return url.format(self._localurl, self._quality)
return url.format(self._vpnurl, self._quality)
@property if self._camera.local_url:
def model(self) -> str: return "{}/live/files/{}/index.m3u8".format(
"""Return the camera model.""" self._camera.local_url, self._quality
return MODELS[self._model] )
return f"{self._camera.vpn_url}/live/files/{self._quality}/index.m3u8"
@callback @callback
def async_update_callback(self) -> None: def async_update_callback(self) -> None:
"""Update the entity's state.""" """Update the entity's state."""
camera = self._data.get_camera(self._id) self._attr_is_on = self._camera.alim_status is not None
self._vpnurl, self._localurl = self._data.camera_urls(self._id) self._attr_available = self._camera.alim_status is not None
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")
if self._model == "NACamera": # Smart Indoor Camera if self._camera.monitoring is not None:
self.hass.data[DOMAIN][DATA_EVENTS][self._id] = self.process_events( self._attr_is_streaming = self._camera.monitoring
self._data.events.get(self._id, {}) self._attr_motion_detection_enabled = self._camera.monitoring
)
elif self._model == "NOC": # Smart Outdoor Camera self.hass.data[DOMAIN][DATA_EVENTS][self._id] = self.process_events(
self.hass.data[DOMAIN][DATA_EVENTS][self._id] = self.process_events( self._camera.events
self._data.outdoor_events.get(self._id, {}) )
)
self._attr_extra_state_attributes.update( self._attr_extra_state_attributes.update(
{ {
"id": self._id, "id": self._id,
"status": self._status, "monitoring": self._monitoring,
"sd_status": self._sd_status, "sd_status": self._camera.sd_status,
"alim_status": self._alim_status, "alim_status": self._camera.alim_status,
"is_local": self._is_local, "is_local": self._camera.is_local,
"vpn_url": self._vpnurl, "vpn_url": self._camera.vpn_url,
"local_url": self._localurl, "local_url": self._camera.local_url,
"light_state": self._light_state, "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.""" """Add meta data to events."""
for event in events.values(): events = {}
if "video_id" not in event: for event in event_list:
if not (video_id := event.video_id):
continue continue
if self._is_local: event_data = event.__dict__
event[ event_data["subevents"] = [
"media_url" event.__dict__
] = f"{self._localurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8" for event in event_data.get("subevents", [])
else: if not isinstance(event, dict)
event[ ]
"media_url" event_data["media_url"] = self.get_video_url(video_id)
] = f"{self._vpnurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8" events[event.event_time] = event_data
return events 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]: 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_ids = []
person_id_errors = [] person_id_errors = []
for person in persons: for person in persons:
person_id = None person_id = None
for pid, data in self._data.persons[self._home_id].items(): for pid, data in self._camera.home.persons.items():
if data.get("pseudo") == person: if data.pseudo == person:
person_ids.append(pid) person_ids.append(pid)
person_id = pid person_id = pid
break break
@ -328,9 +280,7 @@ class NetatmoCamera(NetatmoBase, Camera):
persons = kwargs.get(ATTR_PERSONS, []) persons = kwargs.get(ATTR_PERSONS, [])
person_ids = self.fetch_person_ids(persons) person_ids = self.fetch_person_ids(persons)
await self._data.async_set_persons_home( await self._camera.home.async_set_persons_home(person_ids=person_ids)
person_ids=person_ids, home_id=self._home_id
)
_LOGGER.debug("Set %s as at home", persons) _LOGGER.debug("Set %s as at home", persons)
async def _service_set_person_away(self, **kwargs: Any) -> None: 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_ids = self.fetch_person_ids([person] if person else [])
person_id = next(iter(person_ids), None) 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, person_id=person_id,
home_id=self._home_id,
) )
if person_id: if person_id:
@ -351,10 +300,11 @@ class NetatmoCamera(NetatmoBase, Camera):
async def _service_set_camera_light(self, **kwargs: Any) -> None: async def _service_set_camera_light(self, **kwargs: Any) -> None:
"""Service to set light mode.""" """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)) mode = str(kwargs.get(ATTR_CAMERA_LIGHT_MODE))
_LOGGER.debug("Turn %s camera light for '%s'", mode, self._attr_name) _LOGGER.debug("Turn %s camera light for '%s'", mode, self._attr_name)
await self._data.async_set_state( await self._camera.async_set_floodlight_state(mode)
home_id=self._home_id,
camera_id=self._id,
floodlight=mode,
)

View file

@ -2,15 +2,16 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any, cast
import pyatmo from pyatmo.modules import NATherm1
import voluptuous as vol import voluptuous as vol
from homeassistant.components.climate import ( from homeassistant.components.climate import (
DEFAULT_MIN_TEMP, DEFAULT_MIN_TEMP,
PRESET_AWAY, PRESET_AWAY,
PRESET_BOOST, PRESET_BOOST,
PRESET_HOME,
ClimateEntity, ClimateEntity,
ClimateEntityFeature, ClimateEntityFeature,
HVACAction, HVACAction,
@ -25,12 +26,8 @@ from homeassistant.const import (
TEMP_CELSIUS, TEMP_CELSIUS,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import async_dispatcher_connect
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -38,25 +35,17 @@ from .const import (
ATTR_HEATING_POWER_REQUEST, ATTR_HEATING_POWER_REQUEST,
ATTR_SCHEDULE_NAME, ATTR_SCHEDULE_NAME,
ATTR_SELECTED_SCHEDULE, ATTR_SELECTED_SCHEDULE,
DATA_HANDLER, CONF_URL_ENERGY,
DATA_HOMES,
DATA_SCHEDULES, DATA_SCHEDULES,
DOMAIN, DOMAIN,
EVENT_TYPE_CANCEL_SET_POINT, EVENT_TYPE_CANCEL_SET_POINT,
EVENT_TYPE_SCHEDULE, EVENT_TYPE_SCHEDULE,
EVENT_TYPE_SET_POINT, EVENT_TYPE_SET_POINT,
EVENT_TYPE_THERM_MODE, EVENT_TYPE_THERM_MODE,
NETATMO_CREATE_BATTERY, NETATMO_CREATE_CLIMATE,
SERVICE_SET_SCHEDULE, 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 from .netatmo_entity_base import NetatmoBase
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -65,6 +54,9 @@ PRESET_FROST_GUARD = "Frost Guard"
PRESET_SCHEDULE = "Schedule" PRESET_SCHEDULE = "Schedule"
PRESET_MANUAL = "Manual" PRESET_MANUAL = "Manual"
SUPPORT_FLAGS = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
SUPPORT_PRESET = [PRESET_AWAY, PRESET_BOOST, PRESET_FROST_GUARD, PRESET_SCHEDULE] SUPPORT_PRESET = [PRESET_AWAY, PRESET_BOOST, PRESET_FROST_GUARD, PRESET_SCHEDULE]
STATE_NETATMO_SCHEDULE = "schedule" STATE_NETATMO_SCHEDULE = "schedule"
@ -116,51 +108,22 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the Netatmo energy platform.""" """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 == {}: entry.async_on_unload(
raise PlatformNotReady async_dispatcher_connect(hass, NETATMO_CREATE_CLIMATE, _create_entity)
)
entities = []
for home_id in climate_topology.home_ids:
signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}"
await data_handler.subscribe(
CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id
)
if (climate_state := data_handler.data[signal_name]) is None:
continue
climate_topology.register_handler(home_id, climate_state.process_topology)
for room in climate_state.homes[home_id].rooms.values():
if room.device_type is None or room.device_type.value not in [
NA_THERM,
NA_VALVE,
]:
continue
entities.append(NetatmoThermostat(data_handler, room))
hass.data[DOMAIN][DATA_SCHEDULES][home_id] = climate_state.homes[
home_id
].schedules
hass.data[DOMAIN][DATA_HOMES][home_id] = climate_state.homes[home_id].name
_LOGGER.debug("Adding climate devices %s", entities)
async_add_entities(entities, True)
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
if climate_topology is not None: SERVICE_SET_SCHEDULE,
platform.async_register_entity_service( {vol.Required(ATTR_SCHEDULE_NAME): cv.string},
SERVICE_SET_SCHEDULE, "_async_service_set_schedule",
{vol.Required(ATTR_SCHEDULE_NAME): cv.string}, )
"_async_service_set_schedule",
)
class NetatmoThermostat(NetatmoBase, ClimateEntity): class NetatmoThermostat(NetatmoBase, ClimateEntity):
@ -169,42 +132,33 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
_attr_hvac_mode = HVACMode.AUTO _attr_hvac_mode = HVACMode.AUTO
_attr_max_temp = DEFAULT_MAX_TEMP _attr_max_temp = DEFAULT_MAX_TEMP
_attr_preset_modes = SUPPORT_PRESET _attr_preset_modes = SUPPORT_PRESET
_attr_supported_features = ( _attr_supported_features = SUPPORT_FLAGS
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
)
_attr_target_temperature_step = PRECISION_HALVES _attr_target_temperature_step = PRECISION_HALVES
_attr_temperature_unit = TEMP_CELSIUS _attr_temperature_unit = TEMP_CELSIUS
def __init__( def __init__(self, netatmo_device: NetatmoRoom) -> None:
self, data_handler: NetatmoDataHandler, room: pyatmo.climate.NetatmoRoom
) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
ClimateEntity.__init__(self) 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._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._signal_name = f"{HOME}-{self._home_id}"
self._climate_state: pyatmo.AsyncClimate = data_handler.data[self._signal_name]
self._publishers.extend( self._publishers.extend(
[ [
{ {
"name": CLIMATE_TOPOLOGY_CLASS_NAME, "name": HOME,
SIGNAL_NAME: CLIMATE_TOPOLOGY_CLASS_NAME,
},
{
"name": CLIMATE_STATE_CLASS_NAME,
"home_id": self._room.home.entity_id, "home_id": self._room.home.entity_id,
SIGNAL_NAME: self._signal_name, 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._attr_name = self._room.name
self._away: bool | None = None self._away: bool | None = None
@ -231,7 +185,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
EVENT_TYPE_CANCEL_SET_POINT, EVENT_TYPE_CANCEL_SET_POINT,
EVENT_TYPE_SCHEDULE, EVENT_TYPE_SCHEDULE,
): ):
self.data_handler.config_entry.async_on_unload( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
f"signal-{DOMAIN}-webhook-{event_type}", 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 @callback
def handle_event(self, event: dict) -> None: def handle_event(self, event: dict) -> None:
"""Handle webhook events.""" """Handle webhook events."""
@ -289,7 +228,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
self._attr_target_temperature = self._hg_temperature self._attr_target_temperature = self._hg_temperature
elif self._attr_preset_mode == PRESET_AWAY: elif self._attr_preset_mode == PRESET_AWAY:
self._attr_target_temperature = self._away_temperature 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.async_update_callback()
self.data_handler.async_force_update(self._signal_name) self.data_handler.async_force_update(self._signal_name)
self.async_write_ha_state() self.async_write_ha_state()
@ -322,6 +261,10 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
data["event_type"] == EVENT_TYPE_CANCEL_SET_POINT data["event_type"] == EVENT_TYPE_CANCEL_SET_POINT
and self._room.entity_id == room["id"] 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_update_callback()
self.async_write_ha_state() self.async_write_ha_state()
return return
@ -329,7 +272,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
@property @property
def hvac_action(self) -> HVACAction: def hvac_action(self) -> HVACAction:
"""Return the current running hvac operation if supported.""" """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] return CURRENT_HVAC_MAP_NETATMO[self._boilerstatus]
# Maybe it is a valve # Maybe it is a valve
if ( if (
@ -343,55 +286,36 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
if hvac_mode == HVACMode.OFF: if hvac_mode == HVACMode.OFF:
await self.async_turn_off() await self.async_turn_off()
elif hvac_mode == HVACMode.AUTO: elif hvac_mode == HVACMode.AUTO:
if self.hvac_mode == HVACMode.OFF:
await self.async_turn_on()
await self.async_set_preset_mode(PRESET_SCHEDULE) await self.async_set_preset_mode(PRESET_SCHEDULE)
elif hvac_mode == HVACMode.HEAT: elif hvac_mode == HVACMode.HEAT:
await self.async_set_preset_mode(PRESET_BOOST) await self.async_set_preset_mode(PRESET_BOOST)
async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode.""" """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 ( if (
preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX)
and self._model == NA_VALVE 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( await self._room.async_therm_set(
self._room.entity_id,
STATE_NETATMO_HOME, STATE_NETATMO_HOME,
) )
elif ( elif (
preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE
): ):
await self._climate_state.async_set_room_thermpoint( await self._room.async_therm_set(
self._room.entity_id,
STATE_NETATMO_MANUAL, STATE_NETATMO_MANUAL,
DEFAULT_MAX_TEMP, DEFAULT_MAX_TEMP,
) )
elif ( elif (
preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) 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( await self._room.async_therm_set(STATE_NETATMO_HOME)
self._room.entity_id, STATE_NETATMO_HOME
)
elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX): elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX):
await self._climate_state.async_set_room_thermpoint( await self._room.async_therm_set(PRESET_MAP_NETATMO[preset_mode])
self._room.entity_id, PRESET_MAP_NETATMO[preset_mode]
)
elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY): elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY):
await self._climate_state.async_set_thermmode( await self._room.async_therm_set(PRESET_MAP_NETATMO[preset_mode])
PRESET_MAP_NETATMO[preset_mode]
)
else: else:
_LOGGER.error("Preset mode '%s' not available", preset_mode) _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: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature for 2 hours.""" """Set new target temperature for 2 hours."""
if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: await self._room.async_therm_set(
return STATE_NETATMO_MANUAL, min(kwargs[ATTR_TEMPERATURE], DEFAULT_MAX_TEMP)
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id, STATE_NETATMO_MANUAL, min(temp, DEFAULT_MAX_TEMP)
) )
self.async_write_ha_state() self.async_write_ha_state()
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Turn the entity off.""" """Turn the entity off."""
if self._model == NA_VALVE: if self._model == NA_VALVE:
await self._climate_state.async_set_room_thermpoint( await self._room.async_therm_set(
self._room.entity_id,
STATE_NETATMO_MANUAL, STATE_NETATMO_MANUAL,
DEFAULT_MIN_TEMP, DEFAULT_MIN_TEMP,
) )
elif self.hvac_mode != HVACMode.OFF: elif self._attr_hvac_mode != HVACMode.OFF:
await self._climate_state.async_set_room_thermpoint( await self._room.async_therm_set(STATE_NETATMO_OFF)
self._room.entity_id, STATE_NETATMO_OFF
)
self.async_write_ha_state() self.async_write_ha_state()
async def async_turn_on(self) -> None: async def async_turn_on(self) -> None:
"""Turn the entity on.""" """Turn the entity on."""
await self._climate_state.async_set_room_thermpoint( await self._room.async_therm_set(STATE_NETATMO_HOME)
self._room.entity_id, STATE_NETATMO_HOME
)
self.async_write_ha_state() self.async_write_ha_state()
@property @property
@ -466,8 +382,11 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
] = self._room.heating_power_request ] = self._room.heating_power_request
else: else:
for module in self._room.modules.values(): for module in self._room.modules.values():
self._boilerstatus = module.boiler_status if hasattr(module, "boiler_status"):
break module = cast(NATherm1, module)
if module.boiler_status is not None:
self._boilerstatus = module.boiler_status
break
async def _async_service_set_schedule(self, **kwargs: Any) -> None: async def _async_service_set_schedule(self, **kwargs: Any) -> None:
schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_name = kwargs.get(ATTR_SCHEDULE_NAME)
@ -483,7 +402,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
_LOGGER.error("%s is not a valid schedule", kwargs.get(ATTR_SCHEDULE_NAME)) _LOGGER.error("%s is not a valid schedule", kwargs.get(ATTR_SCHEDULE_NAME))
return 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( _LOGGER.debug(
"Setting %s schedule to %s (%s)", "Setting %s schedule to %s (%s)",
self._room.home.entity_id, self._room.home.entity_id,

View file

@ -6,6 +6,7 @@ import logging
from typing import Any from typing import Any
import uuid import uuid
from pyatmo.const import ALL_SCOPES
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@ -25,7 +26,6 @@ from .const import (
CONF_UUID, CONF_UUID,
CONF_WEATHER_AREAS, CONF_WEATHER_AREAS,
DOMAIN, DOMAIN,
NETATMO_SCOPES,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -54,7 +54,7 @@ class NetatmoFlowHandler(
@property @property
def extra_authorize_data(self) -> dict: def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url.""" """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: async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
"""Handle a flow start.""" """Handle a flow start."""

View file

@ -10,60 +10,17 @@ DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}"
PLATFORMS = [ PLATFORMS = [
Platform.CAMERA, Platform.CAMERA,
Platform.CLIMATE, Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT, Platform.LIGHT,
Platform.SELECT, Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH,
] ]
NETATMO_SCOPES = [ CONF_URL_SECURITY = "https://home.netatmo.com/security"
"access_camera", CONF_URL_ENERGY = "https://my.netatmo.com/app/energy"
"access_presence", CONF_URL_WEATHER = "https://my.netatmo.com/app/weather"
"read_camera", CONF_URL_CONTROL = "https://home.netatmo.com/control"
"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"
AUTH = "netatmo_auth" AUTH = "netatmo_auth"
CONF_PUBLIC = "public_sensor_config" CONF_PUBLIC = "public_sensor_config"
@ -71,7 +28,18 @@ CAMERA_DATA = "netatmo_camera"
HOME_DATA = "netatmo_home_data" HOME_DATA = "netatmo_home_data"
DATA_HANDLER = "netatmo_data_handler" DATA_HANDLER = "netatmo_data_handler"
SIGNAL_NAME = "signal_name" SIGNAL_NAME = "signal_name"
NETATMO_CREATE_BATTERY = "netatmo_create_battery" 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_AREA_NAME = "area_name"
CONF_CLOUDHOOK_URL = "cloudhook_url" 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 from typing import Any
import pyatmo import pyatmo
from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback 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 homeassistant.helpers.event import async_track_time_interval
from .const import ( from .const import (
AUTH, AUTH,
DATA_PERSONS,
DATA_SCHEDULES,
DOMAIN, DOMAIN,
MANUFACTURER, 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_ACTIVATION,
WEBHOOK_DEACTIVATION, WEBHOOK_DEACTIVATION,
WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NACAMERA_CONNECTION,
@ -29,30 +47,31 @@ from .const import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CAMERA_DATA_CLASS_NAME = "AsyncCameraData" SIGNAL_NAME = "signal_name"
WEATHERSTATION_DATA_CLASS_NAME = "AsyncWeatherStationData" ACCOUNT = "account"
HOMECOACH_DATA_CLASS_NAME = "AsyncHomeCoachData" HOME = "home"
CLIMATE_TOPOLOGY_CLASS_NAME = "AsyncClimateTopology" WEATHER = "weather"
CLIMATE_STATE_CLASS_NAME = "AsyncClimate" AIR_CARE = "air_care"
PUBLICDATA_DATA_CLASS_NAME = "AsyncPublicData" PUBLIC = "public"
EVENT = "event"
DATA_CLASSES = { PUBLISHERS = {
WEATHERSTATION_DATA_CLASS_NAME: pyatmo.AsyncWeatherStationData, ACCOUNT: "async_update_topology",
HOMECOACH_DATA_CLASS_NAME: pyatmo.AsyncHomeCoachData, HOME: "async_update_status",
CAMERA_DATA_CLASS_NAME: pyatmo.AsyncCameraData, WEATHER: "async_update_weather_stations",
CLIMATE_TOPOLOGY_CLASS_NAME: pyatmo.AsyncClimateTopology, AIR_CARE: "async_update_air_care",
CLIMATE_STATE_CLASS_NAME: pyatmo.AsyncClimate, PUBLIC: "async_update_public_weather",
PUBLICDATA_DATA_CLASS_NAME: pyatmo.AsyncPublicData, EVENT: "async_update_events",
} }
BATCH_SIZE = 3 BATCH_SIZE = 3
DEFAULT_INTERVALS = { DEFAULT_INTERVALS = {
CLIMATE_TOPOLOGY_CLASS_NAME: 3600, ACCOUNT: 10800,
CLIMATE_STATE_CLASS_NAME: 300, HOME: 300,
CAMERA_DATA_CLASS_NAME: 900, WEATHER: 600,
WEATHERSTATION_DATA_CLASS_NAME: 600, AIR_CARE: 300,
HOMECOACH_DATA_CLASS_NAME: 300, PUBLIC: 600,
PUBLICDATA_DATA_CLASS_NAME: 600, EVENT: 600,
} }
SCAN_INTERVAL = 60 SCAN_INTERVAL = 60
@ -62,7 +81,27 @@ class NetatmoDevice:
"""Netatmo device class.""" """Netatmo device class."""
data_handler: NetatmoDataHandler 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 parent_id: str
signal_name: str signal_name: str
@ -74,25 +113,27 @@ class NetatmoPublisher:
name: str name: str
interval: int interval: int
next_scan: float next_scan: float
subscriptions: list[CALLBACK_TYPE | None] subscriptions: set[CALLBACK_TYPE | None]
method: str
kwargs: dict
class NetatmoDataHandler: class NetatmoDataHandler:
"""Manages the Netatmo data handling.""" """Manages the Netatmo data handling."""
account: pyatmo.AsyncAccount
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize self.""" """Initialize self."""
self.hass = hass self.hass = hass
self.config_entry = config_entry self.config_entry = config_entry
self._auth = hass.data[DOMAIN][config_entry.entry_id][AUTH] self._auth = hass.data[DOMAIN][config_entry.entry_id][AUTH]
self.publisher: dict[str, NetatmoPublisher] = {} self.publisher: dict[str, NetatmoPublisher] = {}
self.data: dict = {}
self._queue: deque = deque() self._queue: deque = deque()
self._webhook: bool = False self._webhook: bool = False
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up the Netatmo data handler.""" """Set up the Netatmo data handler."""
async_track_time_interval( async_track_time_interval(
self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL)
) )
@ -105,17 +146,14 @@ class NetatmoDataHandler:
) )
) )
await asyncio.gather( self.account = pyatmo.AsyncAccount(self._auth)
*[
self.subscribe(data_class, data_class, None) await self.subscribe(ACCOUNT, ACCOUNT, None)
for data_class in (
CLIMATE_TOPOLOGY_CLASS_NAME, await self.hass.config_entries.async_forward_entry_setups(
CAMERA_DATA_CLASS_NAME, self.config_entry, PLATFORMS
WEATHERSTATION_DATA_CLASS_NAME,
HOMECOACH_DATA_CLASS_NAME,
)
]
) )
await self.async_dispatch()
async def async_update(self, event_time: datetime) -> None: async def async_update(self, event_time: datetime) -> None:
""" """
@ -153,19 +191,17 @@ class NetatmoDataHandler:
elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_NACAMERA_CONNECTION: elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_NACAMERA_CONNECTION:
_LOGGER.debug("%s camera reconnected", MANUFACTURER) _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: async def async_fetch_data(self, signal_name: str) -> None:
"""Fetch data and notify.""" """Fetch data and notify."""
if self.data[signal_name] is None:
return
try: 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: except pyatmo.NoDevice as err:
_LOGGER.debug(err) _LOGGER.debug(err)
self.data[signal_name] = None
except pyatmo.ApiError as err: except pyatmo.ApiError as err:
_LOGGER.debug(err) _LOGGER.debug(err)
@ -188,18 +224,21 @@ class NetatmoDataHandler:
"""Subscribe to publisher.""" """Subscribe to publisher."""
if signal_name in self.publisher: if signal_name in self.publisher:
if update_callback not in self.publisher[signal_name].subscriptions: 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 return
if publisher == "public":
kwargs = {"area_id": self.account.register_public_weather_area(**kwargs)}
self.publisher[signal_name] = NetatmoPublisher( self.publisher[signal_name] = NetatmoPublisher(
name=signal_name, name=signal_name,
interval=DEFAULT_INTERVALS[publisher], interval=DEFAULT_INTERVALS[publisher],
next_scan=time() + 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: try:
await self.async_fetch_data(signal_name) await self.async_fetch_data(signal_name)
except KeyError: except KeyError:
@ -213,15 +252,158 @@ class NetatmoDataHandler:
self, signal_name: str, update_callback: CALLBACK_TYPE | None self, signal_name: str, update_callback: CALLBACK_TYPE | None
) -> None: ) -> None:
"""Unsubscribe from publisher.""" """Unsubscribe from publisher."""
if update_callback in self.publisher[signal_name].subscriptions:
return
self.publisher[signal_name].subscriptions.remove(update_callback) self.publisher[signal_name].subscriptions.remove(update_callback)
if not self.publisher[signal_name].subscriptions: if not self.publisher[signal_name].subscriptions:
self._queue.remove(self.publisher[signal_name]) self._queue.remove(self.publisher[signal_name])
self.publisher.pop(signal_name) self.publisher.pop(signal_name)
self.data.pop(signal_name)
_LOGGER.debug("Publisher %s removed", signal_name) _LOGGER.debug("Publisher %s removed", signal_name)
@property @property
def webhook(self) -> bool: def webhook(self) -> bool:
"""Return the webhook state.""" """Return the webhook state."""
return self._webhook 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, DOMAIN,
EVENT_TYPE_THERM_MODE, EVENT_TYPE_THERM_MODE,
INDOOR_CAMERA_TRIGGERS, INDOOR_CAMERA_TRIGGERS,
MODEL_NACAMERA,
MODEL_NATHERM1,
MODEL_NOC,
MODEL_NRV,
NETATMO_EVENT, NETATMO_EVENT,
OUTDOOR_CAMERA_TRIGGERS, OUTDOOR_CAMERA_TRIGGERS,
) )
@ -42,10 +38,10 @@ from .const import (
CONF_SUBTYPE = "subtype" CONF_SUBTYPE = "subtype"
DEVICES = { DEVICES = {
MODEL_NACAMERA: INDOOR_CAMERA_TRIGGERS, "NACamera": INDOOR_CAMERA_TRIGGERS,
MODEL_NOC: OUTDOOR_CAMERA_TRIGGERS, "NOC": OUTDOOR_CAMERA_TRIGGERS,
MODEL_NATHERM1: CLIMATE_TRIGGERS, "NATherm1": CLIMATE_TRIGGERS,
MODEL_NRV: CLIMATE_TRIGGERS, "NRV": CLIMATE_TRIGGERS,
} }
SUBTYPES = { SUBTYPES = {
@ -76,7 +72,7 @@ async def async_validate_trigger_config(
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
device = device_registry.async_get(config[CONF_DEVICE_ID]) device = device_registry.async_get(config[CONF_DEVICE_ID])
if not device: if not device or device.model is None:
raise InvalidDeviceAutomationConfig( raise InvalidDeviceAutomationConfig(
f"Trigger invalid, device with ID {config[CONF_DEVICE_ID]} not found" 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 homeassistant.core import HomeAssistant
from .const import DATA_HANDLER, DOMAIN from .const import DATA_HANDLER, DOMAIN
from .data_handler import CLIMATE_TOPOLOGY_CLASS_NAME, NetatmoDataHandler from .data_handler import ACCOUNT, NetatmoDataHandler
TO_REDACT = { TO_REDACT = {
"access_token", "access_token",
@ -45,8 +45,8 @@ async def async_get_config_entry_diagnostics(
TO_REDACT, TO_REDACT,
), ),
"data": { "data": {
CLIMATE_TOPOLOGY_CLASS_NAME: async_redact_data( ACCOUNT: async_redact_data(
getattr(data_handler.data[CLIMATE_TOPOLOGY_CLASS_NAME], "raw_data"), getattr(data_handler.account, "raw_data"),
TO_REDACT, TO_REDACT,
) )
}, },

View file

@ -4,25 +4,25 @@ from __future__ import annotations
import logging import logging
from typing import Any, cast 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.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
DATA_HANDLER, CONF_URL_CONTROL,
CONF_URL_SECURITY,
DOMAIN, DOMAIN,
EVENT_TYPE_LIGHT_MODE, EVENT_TYPE_LIGHT_MODE,
SIGNAL_NAME, NETATMO_CREATE_CAMERA_LIGHT,
TYPE_SECURITY, NETATMO_CREATE_LIGHT,
WEBHOOK_LIGHT_MODE, WEBHOOK_LIGHT_MODE,
WEBHOOK_PUSH_TYPE, 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 from .netatmo_entity_base import NetatmoBase
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -32,66 +32,73 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the Netatmo camera light platform.""" """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 == {}: @callback
raise PlatformNotReady def _create_camera_light_entity(netatmo_device: NetatmoDevice) -> None:
if not hasattr(netatmo_device.device, "floodlight"):
return
all_cameras = [] entity = NetatmoCameraLight(netatmo_device)
for home in data_handler.data[CAMERA_DATA_CLASS_NAME].cameras.values(): async_add_entities([entity])
for camera in home.values():
all_cameras.append(camera)
entities = [ entry.async_on_unload(
NetatmoLight( async_dispatcher_connect(
data_handler, hass, NETATMO_CREATE_CAMERA_LIGHT, _create_camera_light_entity
camera["id"],
camera["type"],
camera["home_id"],
) )
for camera in all_cameras )
if camera["type"] == "NOC"
]
_LOGGER.debug("Adding camera lights %s", entities) @callback
async_add_entities(entities, True) 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.""" """Representation of a Netatmo Presence camera light."""
_attr_color_mode = ColorMode.ONOFF
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_supported_color_modes = {ColorMode.ONOFF}
def __init__( def __init__(
self, self,
data_handler: NetatmoDataHandler, netatmo_device: NetatmoDevice,
camera_id: str,
camera_type: str,
home_id: str,
) -> None: ) -> None:
"""Initialize a Netatmo Presence camera light.""" """Initialize a Netatmo Presence camera light."""
LightEntity.__init__(self) LightEntity.__init__(self)
super().__init__(data_handler) super().__init__(netatmo_device.data_handler)
self._publishers.append( self._camera = cast(NaModules.NOC, netatmo_device.device)
{"name": CAMERA_DATA_CLASS_NAME, SIGNAL_NAME: CAMERA_DATA_CLASS_NAME} self._id = self._camera.entity_id
) self._home_id = self._camera.home.entity_id
self._id = camera_id self._device_name = self._camera.name
self._home_id = home_id self._model = self._camera.device_type
self._model = camera_type self._config_url = CONF_URL_SECURITY
self._netatmo_type = TYPE_SECURITY
self._device_name: str = self._data.get_camera(camera_id)["name"]
self._is_on = False self._is_on = False
self._attr_unique_id = f"{self._id}-light" 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: async def async_added_to_hass(self) -> None:
"""Entity created.""" """Entity created."""
await super().async_added_to_hass() await super().async_added_to_hass()
self.data_handler.config_entry.async_on_unload( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self.hass,
f"signal-{DOMAIN}-webhook-{EVENT_TYPE_LIGHT_MODE}", f"signal-{DOMAIN}-webhook-{EVENT_TYPE_LIGHT_MODE}",
@ -117,14 +124,6 @@ class NetatmoLight(NetatmoBase, LightEntity):
self.async_write_ha_state() self.async_write_ha_state()
return 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 @property
def available(self) -> bool: def available(self) -> bool:
"""If the webhook is not established, mark as unavailable.""" """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: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn camera floodlight on.""" """Turn camera floodlight on."""
_LOGGER.debug("Turn camera '%s' on", self.name) _LOGGER.debug("Turn camera '%s' on", self.name)
await self._data.async_set_state( await self._camera.async_floodlight_on()
home_id=self._home_id,
camera_id=self._id,
floodlight="on",
)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn camera floodlight into auto mode.""" """Turn camera floodlight into auto mode."""
_LOGGER.debug("Turn camera '%s' to auto mode", self.name) _LOGGER.debug("Turn camera '%s' to auto mode", self.name)
await self._data.async_set_state( await self._camera.async_floodlight_auto()
home_id=self._home_id,
camera_id=self._id,
floodlight="auto",
)
@callback @callback
def async_update_callback(self) -> None: def async_update_callback(self) -> None:
"""Update the entity's state.""" """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", "domain": "netatmo",
"name": "Netatmo", "name": "Netatmo",
"documentation": "https://www.home-assistant.io/integrations/netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo",
"requirements": ["pyatmo==6.2.4"], "requirements": ["pyatmo==7.0.1"],
"after_dependencies": ["cloud", "media_source"], "after_dependencies": ["cloud", "media_source"],
"dependencies": ["application_credentials", "webhook"], "dependencies": ["application_credentials", "webhook"],
"codeowners": ["@cgtobi"], "codeowners": ["@cgtobi"],

View file

@ -72,21 +72,13 @@ class NetatmoSource(MediaSource):
self, source: str, camera_id: str, event_id: int | None = None self, source: str, camera_id: str, event_id: int | None = None
) -> BrowseMediaSource: ) -> BrowseMediaSource:
if event_id and event_id in self.events[camera_id]: if event_id and event_id in self.events[camera_id]:
created = dt.datetime.fromtimestamp(event_id) created = dt.datetime.fromtimestamp(
if self.events[camera_id][event_id]["type"] == "outdoor": self.events[camera_id][event_id]["event_time"]
thumbnail = ( )
self.events[camera_id][event_id]["event_list"][0] thumbnail = self.events[camera_id][event_id].get("snapshot", {}).get("url")
.get("snapshot", {}) message = remove_html_tags(
.get("url") self.events[camera_id][event_id].get("message", "")
) )
message = remove_html_tags(
self.events[camera_id][event_id]["event_list"][0]["message"]
)
else:
thumbnail = (
self.events[camera_id][event_id].get("snapshot", {}).get("url")
)
message = remove_html_tags(self.events[camera_id][event_id]["message"])
title = f"{created} - {message}" title = f"{created} - {message}"
else: else:
title = self.hass.data[DOMAIN][DATA_CAMERAS].get(camera_id, MANUFACTURER) 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 typing import Any
from pyatmo.modules.device_types import (
DEVICE_DESCRIPTION_MAP,
DeviceType as NetatmoDeviceType,
)
from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import ( from .const import DATA_DEVICE_IDS, DEFAULT_ATTRIBUTION, DOMAIN, SIGNAL_NAME
DATA_DEVICE_IDS, from .data_handler import PUBLIC, NetatmoDataHandler
DEFAULT_ATTRIBUTION,
DOMAIN,
MANUFACTURER,
MODELS,
SIGNAL_NAME,
)
from .data_handler import PUBLICDATA_DATA_CLASS_NAME, NetatmoDataHandler
class NetatmoBase(Entity): class NetatmoBase(Entity):
@ -30,38 +28,38 @@ class NetatmoBase(Entity):
self._device_name: str = "" self._device_name: str = ""
self._id: str = "" self._id: str = ""
self._model: str = "" self._model: str = ""
self._netatmo_type: str = "" self._config_url: str = ""
self._attr_name = None self._attr_name = None
self._attr_unique_id = None self._attr_unique_id = None
self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Entity created.""" """Entity created."""
for data_class in self._publishers: for publisher in self._publishers:
signal_name = data_class[SIGNAL_NAME] signal_name = publisher[SIGNAL_NAME]
if "home_id" in data_class: if "home_id" in publisher:
await self.data_handler.subscribe( await self.data_handler.subscribe(
data_class["name"], publisher["name"],
signal_name, signal_name,
self.async_update_callback, 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( await self.data_handler.subscribe(
data_class["name"], publisher["name"],
signal_name, signal_name,
self.async_update_callback, self.async_update_callback,
lat_ne=data_class["lat_ne"], lat_ne=publisher["lat_ne"],
lon_ne=data_class["lon_ne"], lon_ne=publisher["lon_ne"],
lat_sw=data_class["lat_sw"], lat_sw=publisher["lat_sw"],
lon_sw=data_class["lon_sw"], lon_sw=publisher["lon_sw"],
) )
else: else:
await self.data_handler.subscribe( 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: 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.""" """Run when entity will be removed from hass."""
await super().async_will_remove_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( await self.data_handler.unsubscribe(
data_class[SIGNAL_NAME], self.async_update_callback publisher[SIGNAL_NAME], self.async_update_callback
) )
@callback @callback
@ -91,10 +89,13 @@ class NetatmoBase(Entity):
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return the device info for the sensor.""" """Return the device info for the sensor."""
manufacturer, model = DEVICE_DESCRIPTION_MAP[
getattr(NetatmoDeviceType, self._model)
]
return DeviceInfo( return DeviceInfo(
configuration_url=f"https://my.netatmo.com/app/{self._netatmo_type}", configuration_url=self._config_url,
identifiers={(DOMAIN, self._id)}, identifiers={(DOMAIN, self._id)},
name=self._device_name, name=self._device_name,
manufacturer=MANUFACTURER, manufacturer=manufacturer,
model=MODELS[self._model], model=model,
) )

View file

@ -3,29 +3,20 @@ from __future__ import annotations
import logging import logging
import pyatmo
from homeassistant.components.select import SelectEntity from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
DATA_HANDLER, CONF_URL_ENERGY,
DATA_SCHEDULES, DATA_SCHEDULES,
DOMAIN, DOMAIN,
EVENT_TYPE_SCHEDULE, EVENT_TYPE_SCHEDULE,
MANUFACTURER, NETATMO_CREATE_SELECT,
SIGNAL_NAME,
TYPE_ENERGY,
)
from .data_handler import (
CLIMATE_STATE_CLASS_NAME,
CLIMATE_TOPOLOGY_CLASS_NAME,
NetatmoDataHandler,
) )
from .data_handler import HOME, SIGNAL_NAME, NetatmoHome
from .netatmo_entity_base import NetatmoBase from .netatmo_entity_base import NetatmoBase
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -35,100 +26,66 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the Netatmo energy platform schedule selector.""" """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 == {}: entry.async_on_unload(
raise PlatformNotReady async_dispatcher_connect(hass, NETATMO_CREATE_SELECT, _create_entity)
)
entities = []
for home_id in climate_topology.home_ids:
signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}"
await data_handler.subscribe(
CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id
)
if (climate_state := data_handler.data[signal_name]) is None:
continue
climate_topology.register_handler(home_id, climate_state.process_topology)
hass.data[DOMAIN][DATA_SCHEDULES][home_id] = climate_state.homes[
home_id
].schedules
entities = [
NetatmoScheduleSelect(
data_handler,
home_id,
[schedule.name for schedule in schedules.values()],
)
for home_id, schedules in hass.data[DOMAIN][DATA_SCHEDULES].items()
if schedules
]
_LOGGER.debug("Adding climate schedule select entities %s", entities)
async_add_entities(entities, True)
class NetatmoScheduleSelect(NetatmoBase, SelectEntity): class NetatmoScheduleSelect(NetatmoBase, SelectEntity):
"""Representation a Netatmo thermostat schedule selector.""" """Representation a Netatmo thermostat schedule selector."""
def __init__( def __init__(
self, data_handler: NetatmoDataHandler, home_id: str, options: list self,
netatmo_home: NetatmoHome,
) -> None: ) -> None:
"""Initialize the select entity.""" """Initialize the select entity."""
SelectEntity.__init__(self) SelectEntity.__init__(self)
super().__init__(data_handler) super().__init__(netatmo_home.data_handler)
self._home_id = home_id self._home = netatmo_home.home
self._home_id = self._home.entity_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._signal_name = netatmo_home.signal_name
self._publishers.extend( self._publishers.extend(
[ [
{ {
"name": CLIMATE_TOPOLOGY_CLASS_NAME, "name": HOME,
SIGNAL_NAME: CLIMATE_TOPOLOGY_CLASS_NAME, "home_id": self._home.entity_id,
}, SIGNAL_NAME: self._signal_name,
{
"name": CLIMATE_STATE_CLASS_NAME,
"home_id": self._home_id,
SIGNAL_NAME: self._climate_state_class,
}, },
] ]
) )
self._device_name = self._home.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._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_unique_id = f"{self._home_id}-schedule-select"
self._attr_current_option = getattr(self._home.get_selected_schedule(), "name") 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: async def async_added_to_hass(self) -> None:
"""Entity created.""" """Entity created."""
await super().async_added_to_hass() await super().async_added_to_hass()
for event_type in (EVENT_TYPE_SCHEDULE,): self.async_on_remove(
self.data_handler.config_entry.async_on_unload( async_dispatcher_connect(
async_dispatcher_connect( self.hass,
self.hass, f"signal-{DOMAIN}-webhook-{EVENT_TYPE_SCHEDULE}",
f"signal-{DOMAIN}-webhook-{event_type}", self.handle_event,
self.handle_event,
)
) )
)
@callback @callback
def handle_event(self, event: dict) -> None: def handle_event(self, event: dict) -> None:
@ -160,7 +117,7 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity):
option, option,
sid, sid,
) )
await self._climate_state.async_switch_home_schedule(schedule_id=sid) await self._home.async_switch_schedule(schedule_id=sid)
break break
@callback @callback
@ -169,8 +126,5 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity):
self._attr_current_option = getattr(self._home.get_selected_schedule(), "name") self._attr_current_option = getattr(self._home.get_selected_schedule(), "name")
self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id] = self._home.schedules self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id] = self._home.schedules
self._attr_options = [ self._attr_options = [
schedule.name schedule.name for schedule in self._home.schedules.values()
for schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][
self._home_id
].values()
] ]

View file

@ -1,9 +1,9 @@
"""Support for the Netatmo Weather Service.""" """Support for the Netatmo sensors."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import NamedTuple, cast from typing import cast
import pyatmo import pyatmo
@ -21,15 +21,15 @@ from homeassistant.const import (
DEGREE, DEGREE,
LENGTH_MILLIMETERS, LENGTH_MILLIMETERS,
PERCENTAGE, PERCENTAGE,
POWER_WATT,
PRESSURE_MBAR, PRESSURE_MBAR,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
SOUND_PRESSURE_DB, SOUND_PRESSURE_DB,
SPEED_KILOMETERS_PER_HOUR, SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS, TEMP_CELSIUS,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import async_entries_for_config_entry
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_connect,
async_dispatcher_send, async_dispatcher_send,
@ -38,20 +38,18 @@ from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
CONF_URL_ENERGY,
CONF_URL_WEATHER,
CONF_WEATHER_AREAS, CONF_WEATHER_AREAS,
DATA_HANDLER, DATA_HANDLER,
DOMAIN, DOMAIN,
NETATMO_CREATE_BATTERY, NETATMO_CREATE_BATTERY,
NETATMO_CREATE_ROOM_SENSOR,
NETATMO_CREATE_SENSOR,
NETATMO_CREATE_WEATHER_SENSOR,
SIGNAL_NAME, 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 .helper import NetatmoArea
from .netatmo_entity_base import NetatmoBase from .netatmo_entity_base import NetatmoBase
@ -62,10 +60,12 @@ SUPPORTED_PUBLIC_SENSOR_TYPES: tuple[str, ...] = (
"pressure", "pressure",
"humidity", "humidity",
"rain", "rain",
"windstrength", "wind_strength",
"guststrength", "gust_strength",
"sum_rain_1", "sum_rain_1",
"sum_rain_24", "sum_rain_24",
"wind_angle",
"gust_angle",
) )
@ -85,7 +85,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="temperature", key="temperature",
name="Temperature", name="Temperature",
netatmo_name="Temperature", netatmo_name="temperature",
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
native_unit_of_measurement=TEMP_CELSIUS, native_unit_of_measurement=TEMP_CELSIUS,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@ -101,7 +101,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="co2", key="co2",
name="CO2", name="CO2",
netatmo_name="CO2", netatmo_name="co2",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@ -110,7 +110,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="pressure", key="pressure",
name="Pressure", name="Pressure",
netatmo_name="Pressure", netatmo_name="pressure",
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
native_unit_of_measurement=PRESSURE_MBAR, native_unit_of_measurement=PRESSURE_MBAR,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@ -126,7 +126,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="noise", key="noise",
name="Noise", name="Noise",
netatmo_name="Noise", netatmo_name="noise",
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
native_unit_of_measurement=SOUND_PRESSURE_DB, native_unit_of_measurement=SOUND_PRESSURE_DB,
icon="mdi:volume-high", icon="mdi:volume-high",
@ -135,7 +135,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="humidity", key="humidity",
name="Humidity", name="Humidity",
netatmo_name="Humidity", netatmo_name="humidity",
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@ -144,7 +144,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="rain", key="rain",
name="Rain", name="Rain",
netatmo_name="Rain", netatmo_name="rain",
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
native_unit_of_measurement=LENGTH_MILLIMETERS, native_unit_of_measurement=LENGTH_MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@ -156,7 +156,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
netatmo_name="sum_rain_1", netatmo_name="sum_rain_1",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=LENGTH_MILLIMETERS, native_unit_of_measurement=LENGTH_MILLIMETERS,
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL,
icon="mdi:weather-rainy", icon="mdi:weather-rainy",
), ),
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
@ -171,7 +171,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="battery_percent", key="battery_percent",
name="Battery Percent", name="Battery Percent",
netatmo_name="battery_percent", netatmo_name="battery",
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
@ -181,14 +181,14 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="windangle", key="windangle",
name="Direction", name="Direction",
netatmo_name="WindAngle", netatmo_name="wind_direction",
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
icon="mdi:compass-outline", icon="mdi:compass-outline",
), ),
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="windangle_value", key="windangle_value",
name="Angle", name="Angle",
netatmo_name="WindAngle", netatmo_name="wind_angle",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=DEGREE, native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline", icon="mdi:compass-outline",
@ -197,7 +197,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="windstrength", key="windstrength",
name="Wind Strength", name="Wind Strength",
netatmo_name="WindStrength", netatmo_name="wind_strength",
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR,
icon="mdi:weather-windy", icon="mdi:weather-windy",
@ -206,14 +206,14 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="gustangle", key="gustangle",
name="Gust Direction", name="Gust Direction",
netatmo_name="GustAngle", netatmo_name="gust_direction",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
icon="mdi:compass-outline", icon="mdi:compass-outline",
), ),
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="gustangle_value", key="gustangle_value",
name="Gust Angle", name="Gust Angle",
netatmo_name="GustAngle", netatmo_name="gust_angle",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=DEGREE, native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline", icon="mdi:compass-outline",
@ -222,7 +222,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="guststrength", key="guststrength",
name="Gust Strength", name="Gust Strength",
netatmo_name="GustStrength", netatmo_name="gust_strength",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR,
icon="mdi:weather-windy", icon="mdi:weather-windy",
@ -239,39 +239,19 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
NetatmoSensorEntityDescription( NetatmoSensorEntityDescription(
key="rf_status", key="rf_status",
name="Radio", name="Radio",
netatmo_name="rf_status", netatmo_name="rf_strength",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:signal", 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( NetatmoSensorEntityDescription(
key="wifi_status", key="wifi_status",
name="Wifi", name="Wifi",
netatmo_name="wifi_status", netatmo_name="wifi_strength",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:wifi", 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( NetatmoSensorEntityDescription(
key="health_idx", key="health_idx",
name="Health", name="Health",
@ -279,136 +259,110 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
entity_registry_enabled_default=True, entity_registry_enabled_default=True,
icon="mdi:cloud", 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] SENSOR_TYPES_KEYS = [desc.key for desc in SENSOR_TYPES]
MODULE_TYPE_OUTDOOR = "NAModule1" BATTERY_SENSOR_DESCRIPTION = NetatmoSensorEntityDescription(
MODULE_TYPE_WIND = "NAModule2" key="battery",
MODULE_TYPE_RAIN = "NAModule3" name="Battery Percent",
MODULE_TYPE_INDOOR = "NAModule4" netatmo_name="battery",
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
class BatteryData(NamedTuple): state_class=SensorStateClass.MEASUREMENT,
"""Metadata for a batter.""" device_class=SensorDeviceClass.BATTERY,
)
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"
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None: ) -> None:
"""Set up the Netatmo weather and homecoach platform.""" """Set up the Netatmo sensor platform."""
data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER]
platform_not_ready = True
async def find_entities(data_class_name: str) -> list: @callback
"""Find all entities.""" def _create_battery_entity(netatmo_device: NetatmoDevice) -> None:
all_module_infos = {} if not hasattr(netatmo_device.device, "battery"):
data = data_handler.data return
entity = NetatmoClimateBatterySensor(netatmo_device)
async_add_entities([entity])
if data_class_name not in data: entry.async_on_unload(
return [] async_dispatcher_connect(hass, NETATMO_CREATE_BATTERY, _create_battery_entity)
)
if data[data_class_name] is None: @callback
return [] def _create_weather_sensor_entity(netatmo_device: NetatmoDevice) -> None:
async_add_entities(
NetatmoWeatherSensor(netatmo_device, description)
for description in SENSOR_TYPES
if description.netatmo_name in netatmo_device.device.features
)
data_class = data[data_class_name] entry.async_on_unload(
async_dispatcher_connect(
hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_sensor_entity
)
)
for station_id in data_class.stations: @callback
for module_id in data_class.get_modules(station_id): def _create_sensor_entity(netatmo_device: NetatmoDevice) -> None:
all_module_infos[module_id] = data_class.get_module(module_id) _LOGGER.debug(
"Adding %s sensor %s",
all_module_infos[station_id] = data_class.get_station(station_id) netatmo_device.device.device_category,
netatmo_device.device.name,
entities = [] )
for module in all_module_infos.values(): async_add_entities(
if "_id" not in module: [
_LOGGER.debug("Skipping module %s", module.get("module_name")) NetatmoSensor(netatmo_device, description)
continue for description in SENSOR_TYPES
if description.key in netatmo_device.device.features
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( entry.async_on_unload(
[ async_dispatcher_connect(hass, NETATMO_CREATE_SENSOR, _create_sensor_entity)
NetatmoSensor(data_handler, data_class_name, module, description) )
for description in SENSOR_TYPES
if description.key in conditions
]
)
_LOGGER.debug("Adding weather sensors %s", entities) @callback
return entities def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None:
async_add_entities(
NetatmoRoomSensor(netatmo_device, description)
for description in SENSOR_TYPES
if description.key in netatmo_device.room.features
)
for data_class_name in ( entry.async_on_unload(
WEATHERSTATION_DATA_CLASS_NAME, async_dispatcher_connect(
HOMECOACH_DATA_CLASS_NAME, hass, NETATMO_CREATE_ROOM_SENSOR, _create_room_sensor_entity
): )
data_class = data_handler.data.get(data_class_name) )
if data_class and data_class.raw_data:
platform_not_ready = False
async_add_entities(await find_entities(data_class_name), True)
device_registry = dr.async_get(hass) 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: async def add_public_entities(update: bool = True) -> None:
"""Retrieve Netatmo public weather entities.""" """Retrieve Netatmo public weather entities."""
entities = { entities = {
device.name: device.id 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 device_registry, entry.entry_id
) )
if device.model == "Public Weather stations" if device.model == "Public Weather station"
} }
new_entities = [] new_entities = []
for area in [ for area in [
NetatmoArea(**i) for i in entry.options.get(CONF_WEATHER_AREAS, {}).values() 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: if area.area_name in entities:
entities.pop(area.area_name) entities.pop(area.area_name)
@ -422,25 +376,21 @@ async def async_setup_entry(
continue continue
await data_handler.subscribe( await data_handler.subscribe(
PUBLICDATA_DATA_CLASS_NAME, PUBLIC,
signal_name, signal_name,
None, None,
lat_ne=area.lat_ne, lat_ne=area.lat_ne,
lon_ne=area.lon_ne, lon_ne=area.lon_ne,
lat_sw=area.lat_sw, lat_sw=area.lat_sw,
lon_sw=area.lon_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( new_entities.extend(
[ [
NetatmoPublicSensor(data_handler, area, description) NetatmoPublicSensor(data_handler, area, description)
for description in SENSOR_TYPES for description in SENSOR_TYPES
if description.key in SUPPORTED_PUBLIC_SENSOR_TYPES if description.netatmo_name in SUPPORTED_PUBLIC_SENSOR_TYPES
] ]
) )
@ -454,68 +404,56 @@ async def async_setup_entry(
hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}", add_public_entities 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) 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): _attr_has_entity_name = True
"""Implementation of a Netatmo sensor."""
entity_description: NetatmoSensorEntityDescription entity_description: NetatmoSensorEntityDescription
def __init__( def __init__(
self, self,
data_handler: NetatmoDataHandler, netatmo_device: NetatmoDevice,
data_class_name: str,
module_info: dict,
description: NetatmoSensorEntityDescription, description: NetatmoSensorEntityDescription,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(data_handler) super().__init__(netatmo_device.data_handler)
self.entity_description = description self.entity_description = description
self._publishers.append({"name": data_class_name, SIGNAL_NAME: data_class_name}) self._module = netatmo_device.device
self._id = self._module.entity_id
self._station_id = (
self._module.bridge if self._module.bridge is not None else self._id
)
self._device_name = self._module.name
category = getattr(self._module.device_category, "name")
self._publishers.extend(
[
{
"name": category,
SIGNAL_NAME: category,
},
]
)
self._id = module_info["_id"] self._attr_name = f"{description.name}"
self._station_id = module_info.get("main_device", self._id) self._model = self._module.device_type
self._config_url = CONF_URL_WEATHER
station = self._data.get_station(self._station_id)
if not (device := self._data.get_module(self._id)):
# Assume it's a station if module can't be found
device = station
if device["type"] in ("NHC", "NAMain"):
self._device_name = module_info["station_name"]
else:
self._device_name = (
f"{station['station_name']} "
f"{module_info.get('module_name', device['type'])}"
)
self._attr_name = f"{self._device_name} {description.name}"
self._model = device["type"]
self._netatmo_type = TYPE_WEATHER
self._attr_unique_id = f"{self._id}-{description.key}" self._attr_unique_id = f"{self._id}-{description.key}"
@property if hasattr(self._module, "place"):
def _data(self) -> pyatmo.AsyncWeatherStationData: place = cast(
"""Return data for this entity.""" pyatmo.modules.base_class.Place, getattr(self._module, "place")
return cast( )
pyatmo.AsyncWeatherStationData, if hasattr(place, "location") and place.location is not None:
self.data_handler.data[self._publishers[0]["name"]], self._attr_extra_state_attributes.update(
) {
ATTR_LATITUDE: place.location.latitude,
ATTR_LONGITUDE: place.location.longitude,
}
)
@property @property
def available(self) -> bool: def available(self) -> bool:
@ -525,46 +463,25 @@ class NetatmoSensor(NetatmoBase, SensorEntity):
@callback @callback
def async_update_callback(self) -> None: def async_update_callback(self) -> None:
"""Update the entity's state.""" """Update the entity's state."""
data = self._data.get_last_data(station_id=self._station_id, exclude=3600).get( if (
self._id state := getattr(self._module, self.entity_description.netatmo_name)
) ) is None:
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
return return
try: if self.entity_description.netatmo_name in {
state = data[self.entity_description.netatmo_name] "temperature",
if self.entity_description.key in {"temperature", "pressure", "sum_rain_1"}: "pressure",
self._attr_native_value = round(state, 1) "sum_rain_1",
elif self.entity_description.key in {"windangle_value", "gustangle_value"}: }:
self._attr_native_value = fix_angle(state) self._attr_native_value = round(state, 1)
elif self.entity_description.key in {"windangle", "gustangle"}: elif self.entity_description.netatmo_name == "rf_strength":
self._attr_native_value = process_angle(fix_angle(state)) self._attr_native_value = process_rf(state)
elif self.entity_description.key == "rf_status": elif self.entity_description.netatmo_name == "wifi_strength":
self._attr_native_value = process_rf(state) self._attr_native_value = process_wifi(state)
elif self.entity_description.key == "wifi_status": elif self.entity_description.netatmo_name == "health_idx":
self._attr_native_value = process_wifi(state) self._attr_native_value = process_health(state)
elif self.entity_description.key == "health_idx": else:
self._attr_native_value = process_health(state) self._attr_native_value = 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() self.async_write_ha_state()
@ -580,24 +497,25 @@ class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity):
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(netatmo_device.data_handler) super().__init__(netatmo_device.data_handler)
self.entity_description = NetatmoSensorEntityDescription( self.entity_description = BATTERY_SENSOR_DESCRIPTION
key="battery_percent",
name="Battery Percent", self._module = cast(pyatmo.modules.NRV, netatmo_device.device)
netatmo_name="battery_percent", self._id = netatmo_device.parent_id
entity_registry_enabled_default=True,
entity_category=EntityCategory.DIAGNOSTIC, self._publishers.extend(
native_unit_of_measurement=PERCENTAGE, [
state_class=SensorStateClass.MEASUREMENT, {
device_class=SensorDeviceClass.BATTERY, "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._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._room_id = self._module.room_id
self._model = getattr(self._module.device_type, "value") self._model = getattr(self._module.device_type, "value")
self._config_url = CONF_URL_ENERGY
self._attr_unique_id = ( self._attr_unique_id = (
f"{self._id}-{self._module.entity_id}-{self.entity_description.key}" f"{self._id}-{self._module.entity_id}-{self.entity_description.key}"
@ -613,70 +531,54 @@ class NetatmoClimateBatterySensor(NetatmoBase, SensorEntity):
return return
self._attr_available = True self._attr_available = True
self._attr_native_value = self._process_battery_state() self._attr_native_value = self._module.battery
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
def process_battery_percentage(data: str) -> int: class NetatmoSensor(NetatmoBase, SensorEntity):
"""Process battery data and return percent (int) for display.""" """Implementation of a Netatmo sensor."""
mapping = {
"max": 100,
"full": 90,
"high": 75,
"medium": 50,
"low": 25,
"very low": 10,
}
return mapping[data]
entity_description: NetatmoSensorEntityDescription
def fix_angle(angle: int) -> int: def __init__(
"""Fix angle when value is negative.""" self,
if angle < 0: netatmo_device: NetatmoDevice,
return 360 + angle description: NetatmoSensorEntityDescription,
return angle ) -> 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: self._publishers.extend(
"""Process angle and return string for display.""" [
if angle >= 330: {
return "N" "name": HOME,
if angle >= 300: "home_id": netatmo_device.device.home.entity_id,
return "NW" SIGNAL_NAME: netatmo_device.signal_name,
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._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: self._attr_unique_id = (
"""Process battery data and return string for display.""" f"{self._id}-{self._module.entity_id}-{self.entity_description.key}"
battery_data = BATTERY_VALUES[model] )
if data >= battery_data.full: @callback
return "Full" def async_update_callback(self) -> None:
if data >= battery_data.high: """Update the entity's state."""
return "High" if (state := getattr(self._module, self.entity_description.key)) is None:
if data >= battery_data.medium: return
return "Medium"
if data >= battery_data.low: self._attr_native_value = state
return "Low"
return "Very Low" self.async_write_ha_state()
def process_health(health: int) -> str: def process_health(health: int) -> str:
@ -714,9 +616,57 @@ def process_wifi(strength: int) -> str:
return "Full" 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): class NetatmoPublicSensor(NetatmoBase, SensorEntity):
"""Represent a single sensor in a Netatmo.""" """Represent a single sensor in a Netatmo."""
_attr_has_entity_name = True
entity_description: NetatmoSensorEntityDescription entity_description: NetatmoSensorEntityDescription
def __init__( def __init__(
@ -729,11 +679,10 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity):
super().__init__(data_handler) super().__init__(data_handler)
self.entity_description = description self.entity_description = description
self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" self._signal_name = f"{PUBLIC}-{area.uuid}"
self._publishers.append( self._publishers.append(
{ {
"name": PUBLICDATA_DATA_CLASS_NAME, "name": PUBLIC,
"lat_ne": area.lat_ne, "lat_ne": area.lat_ne,
"lon_ne": area.lon_ne, "lon_ne": area.lon_ne,
"lat_sw": area.lat_sw, "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.area = area
self._mode = area.mode self._mode = area.mode
self._area_name = area.area_name self._area_name = area.area_name
self._id = self._area_name self._id = self._area_name
self._device_name = f"{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._show_on_map = area.show_on_map
self._attr_unique_id = ( self._attr_unique_id = (
f"{self._device_name.replace(' ', '-')}-{description.key}" 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: async def async_added_to_hass(self) -> None:
"""Entity created.""" """Entity created."""
await super().async_added_to_hass() await super().async_added_to_hass()
assert self.device_info and "name" in self.device_info 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( async_dispatcher_connect(
self.hass, self.hass,
f"netatmo-config-{self.device_info['name']}", f"netatmo-config-{self.device_info['name']}",
@ -790,22 +736,11 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity):
) )
self.area = area self.area = area
self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" self._signal_name = f"{PUBLIC}-{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._mode = area.mode self._mode = area.mode
self._show_on_map = area.show_on_map self._show_on_map = area.show_on_map
await self.data_handler.subscribe( await self.data_handler.subscribe(
PUBLICDATA_DATA_CLASS_NAME, PUBLIC,
self._signal_name, self._signal_name,
self.async_update_callback, self.async_update_callback,
lat_ne=area.lat_ne, lat_ne=area.lat_ne,
@ -819,22 +754,26 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity):
"""Update the entity's state.""" """Update the entity's state."""
data = None data = None
if self.entity_description.key == "temperature": if self.entity_description.netatmo_name == "temperature":
data = self._data.get_latest_temperatures() data = self._station.get_latest_temperatures()
elif self.entity_description.key == "pressure": elif self.entity_description.netatmo_name == "pressure":
data = self._data.get_latest_pressures() data = self._station.get_latest_pressures()
elif self.entity_description.key == "humidity": elif self.entity_description.netatmo_name == "humidity":
data = self._data.get_latest_humidities() data = self._station.get_latest_humidities()
elif self.entity_description.key == "rain": elif self.entity_description.netatmo_name == "rain":
data = self._data.get_latest_rain() data = self._station.get_latest_rain()
elif self.entity_description.key == "sum_rain_1": elif self.entity_description.netatmo_name == "sum_rain_1":
data = self._data.get_60_min_rain() data = self._station.get_60_min_rain()
elif self.entity_description.key == "sum_rain_24": elif self.entity_description.netatmo_name == "sum_rain_24":
data = self._data.get_24_h_rain() data = self._station.get_24_h_rain()
elif self.entity_description.key == "windstrength": elif self.entity_description.netatmo_name == "wind_strength":
data = self._data.get_latest_wind_strengths() data = self._station.get_latest_wind_strengths()
elif self.entity_description.key == "guststrength": elif self.entity_description.netatmo_name == "gust_strength":
data = self._data.get_latest_gust_strengths() 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 not data:
if self.available: 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 pyatag==0.3.5.3
# homeassistant.components.netatmo # homeassistant.components.netatmo
pyatmo==6.2.4 pyatmo==7.0.1
# homeassistant.components.atome # homeassistant.components.atome
pyatome==0.1.1 pyatome==0.1.1

View file

@ -1018,7 +1018,7 @@ pyalmond==0.0.2
pyatag==0.3.5.3 pyatag==0.3.5.3
# homeassistant.components.netatmo # homeassistant.components.netatmo
pyatmo==6.2.4 pyatmo==7.0.1
# homeassistant.components.apple_tv # homeassistant.components.apple_tv
pyatv==0.10.3 pyatv==0.10.3

View file

@ -11,19 +11,6 @@ from tests.test_util.aiohttp import AiohttpClientMockResponse
CLIENT_ID = "1234" CLIENT_ID = "1234"
CLIENT_SECRET = "5678" 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 = { COMMON_RESPONSE = {
"user_id": "91763b24c43d3e344f424e8d", "user_id": "91763b24c43d3e344f424e8d",
@ -43,10 +30,10 @@ DEFAULT_PLATFORMS = ["camera", "climate", "light", "sensor"]
async def fake_post_request(*args, **kwargs): async def fake_post_request(*args, **kwargs):
"""Return fake data.""" """Return fake data."""
if "url" not in kwargs: if "endpoint" not in kwargs:
return "{}" return "{}"
endpoint = kwargs["url"].split("/")[-1] endpoint = kwargs["endpoint"].split("/")[-1]
if endpoint in "snapshot_720.jpg": if endpoint in "snapshot_720.jpg":
return b"test stream image bytes" return b"test stream image bytes"
@ -59,7 +46,7 @@ async def fake_post_request(*args, **kwargs):
"setthermmode", "setthermmode",
"switchhomeschedule", "switchhomeschedule",
]: ]:
payload = f'{{"{endpoint}": true}}' payload = {f"{endpoint}": True, "status": "ok"}
elif endpoint == "homestatus": elif endpoint == "homestatus":
home_id = kwargs.get("params", {}).get("home_id") home_id = kwargs.get("params", {}).get("home_id")
@ -70,17 +57,17 @@ async def fake_post_request(*args, **kwargs):
return AiohttpClientMockResponse( return AiohttpClientMockResponse(
method="POST", method="POST",
url=kwargs["url"], url=kwargs["endpoint"],
json=payload, json=payload,
) )
async def fake_get_image(*args, **kwargs): async def fake_get_image(*args, **kwargs):
"""Return fake data.""" """Return fake data."""
if "url" not in kwargs: if "endpoint" not in kwargs:
return "{}" return "{}"
endpoint = kwargs["url"].split("/")[-1] endpoint = kwargs["endpoint"].split("/")[-1]
if endpoint in "snapshot_720.jpg": if endpoint in "snapshot_720.jpg":
return b"test stream image bytes" return b"test stream image bytes"

View file

@ -2,9 +2,10 @@
from time import time from time import time
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from pyatmo.const import ALL_SCOPES
import pytest 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 from tests.common import MockConfigEntry
@ -60,6 +61,7 @@ def netatmo_auth():
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth"
) as mock_auth: ) as mock_auth:
mock_auth.return_value.async_post_request.side_effect = fake_post_request 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_get_image.side_effect = fake_get_image
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()

View file

@ -1,61 +1,50 @@
{ {
"12:34:56:78:90:ab": { "12:34:56:78:90:ab": {
1599152672: { 1654191519: {
"id": "12345", "home_id": "91763b24c43d3e344f424e8b",
"type": "person", "entity_id": "000001",
"time": 1599152672, "event_type": "human",
"camera_id": "12:34:56:78:90:ab", "event_time": 1654191519,
"snapshot": { "module_id": "12:34:56:78:90:ab",
"url": "https://netatmocameraimage", "snapshot": {
}, "url": "https://netatmocameraimage"
"video_id": "98765", },
"video_status": "available", "vignette": {
"message": "<b>Paulus</b> seen", "url": "https://netatmocameraimage"
"media_url": "http:///files/high/index.m3u8", },
}, "video_id": "0011",
1599152673: { "video_status": "available",
"id": "12346", "message": "<b>Bewegung</b> erkannt",
"type": "person", "subevents": [],
"time": 1599152673, "media_url": "http:///files/high/index.m3u8"
"camera_id": "12:34:56:78:90:ab", },
"snapshot": { 1654189491: {
"url": "https://netatmocameraimage", "home_id": "91763b24c43d3e344f424e8b",
}, "entity_id": "000002",
"message": "<b>Tobias</b> seen", "event_type": "person",
}, "event_time": 1654189491,
1599152674: { "module_id": "12:34:56:78:90:ab",
"id": "12347", "snapshot": {
"type": "outdoor", "url": "https://netatmocameraimage"
"time": 1599152674, },
"camera_id": "12:34:56:78:90:ac", "video_id": "0012",
"snapshot": { "video_status": "available",
"url": "https://netatmocameraimage", "message": "<b>Jane</b> gesehen",
}, "person_id": "1111",
"video_id": "98766", "out_of_sight": False,
"video_status": "available", "subevents": [],
"event_list": [ "media_url": "http:///files/high/index.m3u8"
{ },
"type": "vehicle", 1654289891: {
"time": 1599152674, "home_id": "91763b24c43d3e344f424e8b",
"id": "12347-0", "entity_id": "000002",
"offset": 0, "event_type": "person",
"message": "Vehicle detected", "event_time": 1654289891,
"snapshot": { "module_id": "12:34:56:78:90:ab",
"url": "https://netatmocameraimage", "message": "<b>Jane</b> gesehen",
}, "person_id": "1111",
}, "out_of_sight": False,
{ "subevents": []
"type": "human",
"time": 1599152674,
"id": "12347-1",
"offset": 8,
"message": "Person detected",
"snapshot": {
"url": "https://netatmocameraimage",
},
},
],
"media_url": "http:///files/high/index.m3u8",
},
} }
} }
}

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", "id": "3688132631",
"name": "Hall", "name": "Hall",
"type": "custom", "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", "id": "2833524037",
@ -32,6 +38,44 @@
"name": "Cocina", "name": "Cocina",
"type": "kitchen", "type": "kitchen",
"module_ids": ["12:34:56:03:a0:ac"] "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": [ "modules": [
@ -75,7 +119,334 @@
"type": "NACamera", "type": "NACamera",
"name": "Hall", "name": "Hall",
"setup_date": 1544828430, "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": [ "schedules": [

View file

@ -14,6 +14,25 @@
"vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.123.45/609e27de5699fb18147ab47d06846631/MTRPn_BeWCav5RBq4U1OMDruTW4dkQ0NuMwNDAw11g,,", "vpn_url": "https://prodvpn-eu-2.netatmo.net/restricted/10.255.123.45/609e27de5699fb18147ab47d06846631/MTRPn_BeWCav5RBq4U1OMDruTW4dkQ0NuMwNDAw11g,,",
"is_local": true "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", "id": "12:34:56:00:fa:d0",
"type": "NAPlug", "type": "NAPlug",
@ -50,6 +69,805 @@
"rf_strength": 59, "rf_strength": 59,
"bridge": "12:34:56:00:fa:d0", "bridge": "12:34:56:00:fa:d0",
"battery_state": "full" "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": [ "rooms": [
@ -85,6 +903,19 @@
"therm_setpoint_end_time": 0, "therm_setpoint_end_time": 0,
"anticipating": false, "anticipating": false,
"open_window": 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", "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_indoor).state == "streaming"
assert hass.states.get(camera_entity_outdoor).attributes["light_state"] == "auto" 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( await hass.services.async_call(
"camera", "turn_off", service_data={"entity_id": "camera.hall"} "camera", "turn_off", service_data={"entity_id": "camera.hall"}
) )
await hass.async_block_till_done() await hass.async_block_till_done()
mock_set_state.assert_called_once_with( mock_set_state.assert_called_once_with(
home_id="91763b24c43d3e344f424e8b", {
camera_id="12:34:56:00:f1:62", "modules": [
monitoring="off", {
"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( await hass.services.async_call(
"camera", "turn_on", service_data={"entity_id": "camera.hall"} "camera", "turn_on", service_data={"entity_id": "camera.hall"}
) )
await hass.async_block_till_done() await hass.async_block_till_done()
mock_set_state.assert_called_once_with( mock_set_state.assert_called_once_with(
home_id="91763b24c43d3e344f424e8b", {
camera_id="12:34:56:00:f1:62", "modules": [
monitoring="on", {
"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 is not None
assert cam.state == STATE_STREAMING assert cam.state == STATE_STREAMING
assert cam.name == "Hall"
stream_source = await camera.async_get_stream_source(hass, camera_entity_indoor) stream_source = await camera.async_get_stream_source(hass, camera_entity_indoor)
assert stream_source == stream_uri 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) image = await camera.async_get_image(hass, camera_entity_indoor)
assert image.content == IMAGE_BYTES_FROM_STREAM 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() await hass.async_block_till_done()
uri = ( uri = "https://prodvpn-eu-6.netatmo.net/10.20.30.41/333333333333/444444444444,,"
"https://prodvpn-eu-2.netatmo.net/restricted/10.255.248.91/"
"6d278460699e56180d47ab47169efb31/MpEylTU2MDYzNjRVD-LJxUnIndumKzLboeAwMDqTTw,,"
)
stream_uri = uri + "/live/files/high/index.m3u8" stream_uri = uri + "/live/files/high/index.m3u8"
camera_entity_indoor = "camera.garden" camera_entity_indoor = "camera.garden"
cam = hass.states.get(camera_entity_indoor) 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) stream_source = await camera.async_get_stream_source(hass, camera_entity_indoor)
assert stream_source == stream_uri 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) image = await camera.async_get_image(hass, camera_entity_indoor)
assert image.content == IMAGE_BYTES_FROM_STREAM 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", "person": "Richard Doe",
} }
with patch( with patch("pyatmo.home.Home.async_set_persons_away") as mock_set_persons_away:
"pyatmo.camera.AsyncCameraData.async_set_persons_away"
) as mock_set_persons_away:
await hass.services.async_call( await hass.services.async_call(
"netatmo", SERVICE_SET_PERSON_AWAY, service_data=data "netatmo", SERVICE_SET_PERSON_AWAY, service_data=data
) )
await hass.async_block_till_done() await hass.async_block_till_done()
mock_set_persons_away.assert_called_once_with( mock_set_persons_away.assert_called_once_with(
person_id="91827376-7e04-5298-83af-a0cb8372dff3", person_id="91827376-7e04-5298-83af-a0cb8372dff3",
home_id="91763b24c43d3e344f424e8b",
) )
data = { data = {
"entity_id": "camera.hall", "entity_id": "camera.hall",
} }
with patch( with patch("pyatmo.home.Home.async_set_persons_away") as mock_set_persons_away:
"pyatmo.camera.AsyncCameraData.async_set_persons_away"
) as mock_set_persons_away:
await hass.services.async_call( await hass.services.async_call(
"netatmo", SERVICE_SET_PERSON_AWAY, service_data=data "netatmo", SERVICE_SET_PERSON_AWAY, service_data=data
) )
await hass.async_block_till_done() await hass.async_block_till_done()
mock_set_persons_away.assert_called_once_with( mock_set_persons_away.assert_called_once_with(
person_id=None, 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", "persons": "John Doe",
} }
with patch( with patch("pyatmo.home.Home.async_set_persons_home") as mock_set_persons_home:
"pyatmo.camera.AsyncCameraData.async_set_persons_home"
) as mock_set_persons_home:
await hass.services.async_call( await hass.services.async_call(
"netatmo", SERVICE_SET_PERSONS_HOME, service_data=data "netatmo", SERVICE_SET_PERSONS_HOME, service_data=data
) )
await hass.async_block_till_done() await hass.async_block_till_done()
mock_set_persons_home.assert_called_once_with( mock_set_persons_home.assert_called_once_with(
person_ids=["91827374-7e04-5298-83ad-a0cb8372dff1"], 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", "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( await hass.services.async_call(
"netatmo", SERVICE_SET_CAMERA_LIGHT, service_data=data "netatmo", SERVICE_SET_CAMERA_LIGHT, service_data=data
) )
await hass.async_block_till_done() await hass.async_block_till_done()
mock_set_state.assert_called_once_with( mock_set_state.assert_called_once_with(expected_data)
home_id="91763b24c43d3e344f424e8b",
camera_id="12:34:56:00:a5:a4",
floodlight="on", 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 @pytest.mark.skip
@ -342,13 +367,13 @@ async def test_camera_reconnect_webhook(hass, config_entry):
with patch( with patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth"
) as mock_auth, patch( ) as mock_auth, patch(
"homeassistant.components.netatmo.PLATFORMS", ["camera"] "homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"]
), patch( ), patch(
"homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation",
), patch( ), patch(
"homeassistant.components.netatmo.webhook_generate_url" "homeassistant.components.netatmo.webhook_generate_url"
) as mock_webhook: ) 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_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
mock_webhook.return_value = "https://example.com" 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.""" """Fake error during requesting backend data."""
nonlocal fake_post_hits nonlocal fake_post_hits
fake_post_hits += 1 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) return await fake_post_request(*args, **kwargs)
with patch( with patch(
@ -478,7 +465,45 @@ async def test_camera_image_raises_exception(hass, config_entry, requests_mock):
), patch( ), patch(
"homeassistant.components.netatmo.webhook_generate_url" "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_get_image.side_effect = fake_post
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() mock_auth.return_value.async_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.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" climate_entity_livingroom = "climate.livingroom"
# Test setting a valid schedule # Test setting a valid schedule
with patch( with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_schedule:
"pyatmo.climate.AsyncClimate.async_switch_home_schedule"
) as mock_switch_home_schedule:
await hass.services.async_call( await hass.services.async_call(
"netatmo", "netatmo",
SERVICE_SET_SCHEDULE, SERVICE_SET_SCHEDULE,
@ -423,7 +421,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
mock_switch_home_schedule.assert_called_once_with( mock_switch_schedule.assert_called_once_with(
schedule_id="b1b54a2f45795764f59d50d8" schedule_id="b1b54a2f45795764f59d50d8"
) )
@ -442,9 +440,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_
) )
# Test setting an invalid schedule # Test setting an invalid schedule
with patch( with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_home_schedule:
"pyatmo.climate.AsyncClimate.async_switch_home_schedule"
) as mock_switch_home_schedule:
await hass.services.async_call( await hass.services.async_call(
"netatmo", "netatmo",
SERVICE_SET_SCHEDULE, SERVICE_SET_SCHEDULE,

View file

@ -1,6 +1,8 @@
"""Test the Netatmo config flow.""" """Test the Netatmo config flow."""
from unittest.mock import patch from unittest.mock import patch
from pyatmo.const import ALL_SCOPES
from homeassistant import config_entries, data_entry_flow, setup from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.components.netatmo import config_flow 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.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from .common import ALL_SCOPES
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
CLIENT_ID = "1234" 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 ( from homeassistant.components.netatmo.const import (
CLIMATE_TRIGGERS, CLIMATE_TRIGGERS,
INDOOR_CAMERA_TRIGGERS, INDOOR_CAMERA_TRIGGERS,
MODEL_NACAMERA,
MODEL_NAPLUG,
MODEL_NATHERM1,
MODEL_NOC,
MODEL_NRV,
NETATMO_EVENT, NETATMO_EVENT,
OUTDOOR_CAMERA_TRIGGERS, OUTDOOR_CAMERA_TRIGGERS,
) )
@ -52,10 +47,10 @@ def calls(hass):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"platform,device_type,event_types", "platform,device_type,event_types",
[ [
("camera", MODEL_NOC, OUTDOOR_CAMERA_TRIGGERS), ("camera", "NOC", OUTDOOR_CAMERA_TRIGGERS),
("camera", MODEL_NACAMERA, INDOOR_CAMERA_TRIGGERS), ("camera", "NACamera", INDOOR_CAMERA_TRIGGERS),
("climate", MODEL_NRV, CLIMATE_TRIGGERS), ("climate", "NRV", CLIMATE_TRIGGERS),
("climate", MODEL_NATHERM1, CLIMATE_TRIGGERS), ("climate", "NATherm1", CLIMATE_TRIGGERS),
], ],
) )
async def test_get_triggers( async def test_get_triggers(
@ -110,15 +105,15 @@ async def test_get_triggers(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"platform,camera_type,event_type", "platform,camera_type,event_type",
[("camera", MODEL_NOC, trigger) for trigger in OUTDOOR_CAMERA_TRIGGERS] [("camera", "NOC", trigger) for trigger in OUTDOOR_CAMERA_TRIGGERS]
+ [("camera", MODEL_NACAMERA, trigger) for trigger in INDOOR_CAMERA_TRIGGERS] + [("camera", "NACamera", trigger) for trigger in INDOOR_CAMERA_TRIGGERS]
+ [ + [
("climate", MODEL_NRV, trigger) ("climate", "NRV", trigger)
for trigger in CLIMATE_TRIGGERS for trigger in CLIMATE_TRIGGERS
if trigger not in SUBTYPES if trigger not in SUBTYPES
] ]
+ [ + [
("climate", MODEL_NATHERM1, trigger) ("climate", "NATherm1", trigger)
for trigger in CLIMATE_TRIGGERS for trigger in CLIMATE_TRIGGERS
if trigger not in SUBTYPES if trigger not in SUBTYPES
], ],
@ -188,12 +183,12 @@ async def test_if_fires_on_event(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"platform,camera_type,event_type,sub_type", "platform,camera_type,event_type,sub_type",
[ [
("climate", MODEL_NRV, trigger, subtype) ("climate", "NRV", trigger, subtype)
for trigger in SUBTYPES for trigger in SUBTYPES
for subtype in SUBTYPES[trigger] for subtype in SUBTYPES[trigger]
] ]
+ [ + [
("climate", MODEL_NATHERM1, trigger, subtype) ("climate", "NATherm1", trigger, subtype)
for trigger in SUBTYPES for trigger in SUBTYPES
for subtype in SUBTYPES[trigger] for subtype in SUBTYPES[trigger]
], ],
@ -267,7 +262,7 @@ async def test_if_fires_on_event_with_subtype(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"platform,device_type,event_type", "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( async def test_if_invalid_device(
hass, device_reg, entity_reg, platform, device_type, event_type 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( ), patch(
"homeassistant.components.netatmo.webhook_generate_url" "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_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(hass, "netatmo", {}) assert await async_setup_component(hass, "netatmo", {})
@ -39,16 +39,27 @@ async def test_entry_diagnostics(hass, hass_client, config_entry):
"expires_in": 60, "expires_in": 60,
"refresh_token": REDACTED, "refresh_token": REDACTED,
"scope": [ "scope": [
"read_station",
"read_camera",
"access_camera", "access_camera",
"write_camera", "access_doorbell",
"read_presence",
"access_presence", "access_presence",
"write_presence", "read_bubendorff",
"read_camera",
"read_carbonmonoxidedetector",
"read_doorbell",
"read_homecoach", "read_homecoach",
"read_magellan",
"read_mx",
"read_presence",
"read_smarther",
"read_smokedetector", "read_smokedetector",
"read_station",
"read_thermostat", "read_thermostat",
"write_bubendorff",
"write_camera",
"write_magellan",
"write_mx",
"write_presence",
"write_smarther",
"write_thermostat", "write_thermostat",
], ],
"type": "Bearer", "type": "Bearer",
@ -88,5 +99,5 @@ async def test_entry_diagnostics(hass, hass_client, config_entry):
"webhook_registered": False, "webhook_registered": False,
} }
for home in result["data"]["AsyncClimateTopology"]["homes"]: for home in result["data"]["account"]["homes"]:
assert home["coordinates"] == REDACTED assert home["coordinates"] == REDACTED

View file

@ -1,11 +1,10 @@
"""The tests for Netatmo component.""" """The tests for Netatmo component."""
import asyncio
from datetime import timedelta from datetime import timedelta
from time import time from time import time
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import aiohttp import aiohttp
import pyatmo from pyatmo.const import ALL_SCOPES
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.netatmo import DOMAIN from homeassistant.components.netatmo import DOMAIN
@ -15,7 +14,6 @@ from homeassistant.setup import async_setup_component
from homeassistant.util import dt from homeassistant.util import dt
from .common import ( from .common import (
ALL_SCOPES,
FAKE_WEBHOOK_ACTIVATION, FAKE_WEBHOOK_ACTIVATION,
fake_post_request, fake_post_request,
selected_platforms, selected_platforms,
@ -60,7 +58,7 @@ async def test_setup_component(hass, config_entry):
) as mock_impl, patch( ) as mock_impl, patch(
"homeassistant.components.netatmo.webhook_generate_url" "homeassistant.components.netatmo.webhook_generate_url"
) as mock_webhook: ) 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_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(hass, "netatmo", {}) 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( ) as mock_webhook, patch(
"homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth", "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth",
) as mock_auth, patch( ) 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_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.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() await hass.async_block_till_done()
assert fake_post_hits == 9 assert fake_post_hits == 8
mock_impl.assert_called_once() mock_impl.assert_called_once()
mock_webhook.assert_called_once() mock_webhook.assert_called_once()
@ -162,7 +160,7 @@ async def test_setup_without_https(hass, config_entry, caplog):
), patch( ), patch(
"homeassistant.components.netatmo.webhook_generate_url" "homeassistant.components.netatmo.webhook_generate_url"
) as mock_async_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" mock_async_generate_url.return_value = "http://example.com"
assert await async_setup_component( assert await async_setup_component(
hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}}
@ -200,7 +198,7 @@ async def test_setup_with_cloud(hass, config_entry):
), patch( ), patch(
"homeassistant.components.netatmo.webhook_generate_url" "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( assert await async_setup_component(
hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}} hass, "netatmo", {"netatmo": {"client_id": "123", "client_secret": "abc"}}
) )
@ -266,7 +264,7 @@ async def test_setup_with_cloudhook(hass):
), patch( ), patch(
"homeassistant.components.netatmo.webhook_generate_url" "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_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(hass, "netatmo", {}) 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) 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): async def test_setup_component_with_delay(hass, config_entry):
"""Test setup of the netatmo component with delayed startup.""" """Test setup of the netatmo component with delayed startup."""
hass.state = CoreState.not_running hass.state = CoreState.not_running
@ -348,9 +300,9 @@ async def test_setup_component_with_delay(hass, config_entry):
) as mock_impl, patch( ) as mock_impl, patch(
"homeassistant.components.netatmo.webhook_generate_url" "homeassistant.components.netatmo.webhook_generate_url"
) as mock_webhook, patch( ) as mock_webhook, patch(
"pyatmo.AbstractAsyncAuth.async_post_request", side_effect=fake_post_request "pyatmo.AbstractAsyncAuth.async_post_api_request", side_effect=fake_post_request
) as mock_post_request, patch( ) as mock_post_api_request, patch(
"homeassistant.components.netatmo.PLATFORMS", ["light"] "homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"]
): ):
assert await async_setup_component( 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() 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_impl.assert_called_once()
mock_webhook.assert_not_called() mock_webhook.assert_not_called()
@ -422,7 +374,7 @@ async def test_setup_component_invalid_token_scope(hass):
) as mock_impl, patch( ) as mock_impl, patch(
"homeassistant.components.netatmo.webhook_generate_url" "homeassistant.components.netatmo.webhook_generate_url"
) as mock_webhook: ) 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_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
assert await async_setup_component(hass, "netatmo", {}) 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( ) as mock_webhook, patch(
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session" "homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session"
) as mock_session: ) 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_addwebhook.side_effect = AsyncMock()
mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock() mock_auth.return_value.async_dropwebhook.side_effect = AsyncMock()
mock_session.return_value.async_ensure_token_valid.side_effect = ( 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 from tests.test_util.aiohttp import AiohttpClientMockResponse
async def test_light_setup_and_services(hass, config_entry, netatmo_auth): async def test_camera_light_setup_and_services(hass, config_entry, netatmo_auth):
"""Test setup and services.""" """Test camera ligiht setup and services."""
with selected_platforms(["light"]): with selected_platforms(["light"]):
await hass.config_entries.async_setup(config_entry.entry_id) 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" assert hass.states.get(light_entity).state == "on"
# Test turning light off # 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( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
SERVICE_TURN_OFF, 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() await hass.async_block_till_done()
mock_set_state.assert_called_once_with( mock_set_state.assert_called_once_with(
home_id="91763b24c43d3e344f424e8b", {"modules": [{"id": "12:34:56:00:a5:a4", "floodlight": "auto"}]}
camera_id="12:34:56:00:a5:a4",
floodlight="auto",
) )
# Test turning light on # 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( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
SERVICE_TURN_ON, 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() await hass.async_block_till_done()
mock_set_state.assert_called_once_with( mock_set_state.assert_called_once_with(
home_id="91763b24c43d3e344f424e8b", {"modules": [{"id": "12:34:56:00:a5:a4", "floodlight": "on"}]}
camera_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 fake_post_hits += 1
return AiohttpClientMockResponse( return AiohttpClientMockResponse(
method="POST", method="POST",
url=kwargs["url"], url=kwargs["endpoint"],
json={}, json={},
) )
@ -106,7 +102,7 @@ async def test_setup_component_no_devices(hass, config_entry):
), patch( ), patch(
"homeassistant.components.netatmo.webhook_generate_url" "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 fake_post_request_no_data
) )
mock_auth.return_value.async_addwebhook.side_effect = AsyncMock() 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 hass.config_entries.async_entries(DOMAIN)
assert len(hass.states.async_all()) == 0 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 # Test successful event listing
media = await async_browse_media( 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 assert media
# Test successful event resolve # Test successful event resolve
media = await async_resolve_media( 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( assert media == PlayMedia(
url="http:///files/high/index.m3u8", mime_type="application/x-mpegURL" 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() await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID] 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" 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 # Test setting a different schedule
with patch( with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_home_schedule:
"pyatmo.climate.AsyncClimate.async_switch_home_schedule"
) as mock_switch_home_schedule:
await hass.services.async_call( await hass.services.async_call(
SELECT_DOMAIN, SELECT_DOMAIN,
SERVICE_SELECT_OPTION, SERVICE_SELECT_OPTION,

View file

@ -4,7 +4,6 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant.components.netatmo import sensor from homeassistant.components.netatmo import sensor
from homeassistant.components.netatmo.sensor import MODULE_TYPE_WIND
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .common import TEST_TIME, selected_platforms 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() 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}temperature").state == "24.6"
assert hass.states.get(f"{prefix}humidity").state == "36" 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 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( @pytest.mark.parametrize(
"uid, name, expected", "uid, name, expected",
[ [
("12:34:56:37:11:ca-reachable", "netatmo_mystation_reachable", "True"), ("12:34:56:37:11:ca-reachable", "mystation_reachable", "True"),
("12:34:56:03:1b:e4-rf_status", "netatmo_mystation_yard_radio", "Full"), ("12:34:56:03:1b:e4-rf_status", "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-wifi_status", "12:34:56:37:11:ca-wifi_status",
"netatmo_mystation_wifi_status", "mystation_wifi_strength",
"Full", "Full",
), ),
( (
"12:34:56:37:11:ca-temp_trend", "12:34:56:37:11:ca-temp_trend",
"netatmo_mystation_temperature_trend", "mystation_temperature_trend",
"stable", "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_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: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", "12:34:56:03:1b:e4-windangle_value",
"netatmo_mystation_garden_angle", "netatmoindoor_garden_angle",
"217", "217",
), ),
("12:34:56:03:1b:e4-gustangle", "mystation_garden_gust_direction", "S"), ("12:34:56:03:1b:e4-gustangle", "mystation_garden_gust_direction", "S"),
( (
"12:34:56:03:1b:e4-gustangle", "12:34:56:03:1b:e4-gustangle",
"netatmo_mystation_garden_gust_direction", "netatmoindoor_garden_gust_direction",
"S", "S",
), ),
( (
"12:34:56:03:1b:e4-gustangle_value", "12:34:56:03:1b:e4-gustangle_value",
"netatmo_mystation_garden_gust_angle_value", "netatmoindoor_garden_gust_angle",
"206", "206",
), ),
( (
"12:34:56:03:1b:e4-guststrength", "12:34:56:03:1b:e4-guststrength",
"netatmo_mystation_garden_gust_strength", "netatmoindoor_garden_gust_strength",
"9", "9",
), ),
(
"12:34:56:03:1b:e4-rf_status",
"netatmoindoor_garden_rf_strength",
"Full",
),
( (
"12:34:56:26:68:92-health_idx", "12:34:56:26:68:92-health_idx",
"netatmo_baby_bedroom_health", "baby_bedroom_health",
"Fine", "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( 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",
}
]
}
)