Update Netatmo climate platform (#59974)

This commit is contained in:
Tobias Sauerwein 2021-12-02 10:31:54 +01:00 committed by GitHub
parent 3307e54363
commit 653fb5b637
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 626 additions and 754 deletions

View file

@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from typing import Any, cast
from typing import Any
import pyatmo
import voluptuous as vol
@ -22,7 +22,6 @@ from homeassistant.components.climate.const import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_SUGGESTED_AREA,
ATTR_TEMPERATURE,
PRECISION_HALVES,
@ -32,7 +31,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import async_get_registry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -41,7 +39,6 @@ from .const import (
ATTR_HEATING_POWER_REQUEST,
ATTR_SCHEDULE_NAME,
ATTR_SELECTED_SCHEDULE,
DATA_DEVICE_IDS,
DATA_HANDLER,
DATA_HOMES,
DATA_SCHEDULES,
@ -50,17 +47,15 @@ from .const import (
EVENT_TYPE_SCHEDULE,
EVENT_TYPE_SET_POINT,
EVENT_TYPE_THERM_MODE,
MANUFACTURER,
SERVICE_SET_SCHEDULE,
SIGNAL_NAME,
TYPE_ENERGY,
)
from .data_handler import (
HOMEDATA_DATA_CLASS_NAME,
HOMESTATUS_DATA_CLASS_NAME,
CLIMATE_STATE_CLASS_NAME,
CLIMATE_TOPOLOGY_CLASS_NAME,
NetatmoDataHandler,
)
from .helper import get_all_home_ids, update_climate_schedules
from .netatmo_entity_base import NetatmoBase
_LOGGER = logging.getLogger(__name__)
@ -125,44 +120,42 @@ async def async_setup_entry(
data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER]
await data_handler.register_data_class(
HOMEDATA_DATA_CLASS_NAME, HOMEDATA_DATA_CLASS_NAME, None
CLIMATE_TOPOLOGY_CLASS_NAME, CLIMATE_TOPOLOGY_CLASS_NAME, None
)
home_data = data_handler.data.get(HOMEDATA_DATA_CLASS_NAME)
climate_topology = data_handler.data.get(CLIMATE_TOPOLOGY_CLASS_NAME)
if not home_data or home_data.raw_data == {}:
if not climate_topology or climate_topology.raw_data == {}:
raise PlatformNotReady
entities = []
for home_id in get_all_home_ids(home_data):
for room_id in home_data.rooms[home_id]:
signal_name = f"{HOMESTATUS_DATA_CLASS_NAME}-{home_id}"
for home_id in climate_topology.home_ids:
signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}"
await data_handler.register_data_class(
HOMESTATUS_DATA_CLASS_NAME, signal_name, None, home_id=home_id
CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id
)
home_status = data_handler.data.get(signal_name)
if home_status and room_id in home_status.rooms:
entities.append(NetatmoThermostat(data_handler, home_id, room_id))
climate_state = data_handler.data[signal_name]
climate_topology.register_handler(home_id, climate_state.process_topology)
hass.data[DOMAIN][DATA_SCHEDULES].update(
update_climate_schedules(
home_ids=get_all_home_ids(home_data),
schedules=data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules,
)
)
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_HOMES] = {
home_id: home_data.get("name")
for home_id, home_data in (
data_handler.data[HOMEDATA_DATA_CLASS_NAME].homes.items()
)
}
hass.data[DOMAIN][DATA_SCHEDULES][home_id] = climate_state.homes[
home_id
].schedules
hass.data[DOMAIN][DATA_HOMES][home_id] = climate_state.homes[home_id].name
_LOGGER.debug("Adding climate devices %s", entities)
async_add_entities(entities, True)
platform = entity_platform.async_get_current_platform()
if home_data is not None:
if climate_topology is not None:
platform.async_register_entity_service(
SERVICE_SET_SCHEDULE,
{vol.Required(ATTR_SCHEDULE_NAME): cv.string},
@ -174,67 +167,61 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
"""Representation a Netatmo thermostat."""
_attr_hvac_mode = HVAC_MODE_AUTO
_attr_hvac_modes = [HVAC_MODE_AUTO, HVAC_MODE_HEAT]
_attr_max_temp = DEFAULT_MAX_TEMP
_attr_preset_modes = SUPPORT_PRESET
_attr_supported_features = SUPPORT_FLAGS
_attr_target_temperature_step = PRECISION_HALVES
_attr_temperature_unit = TEMP_CELSIUS
def __init__(
self, data_handler: NetatmoDataHandler, home_id: str, room_id: str
self, data_handler: NetatmoDataHandler, room: pyatmo.climate.NetatmoRoom
) -> None:
"""Initialize the sensor."""
ClimateEntity.__init__(self)
super().__init__(data_handler)
self._id = room_id
self._home_id = home_id
self._room = room
self._id = self._room.entity_id
self._home_status_class = f"{HOMESTATUS_DATA_CLASS_NAME}-{self._home_id}"
self._climate_state_class = (
f"{CLIMATE_STATE_CLASS_NAME}-{self._room.home.entity_id}"
)
self._climate_state: pyatmo.AsyncClimate = data_handler.data[
self._climate_state_class
]
self._data_classes.extend(
[
{
"name": HOMEDATA_DATA_CLASS_NAME,
SIGNAL_NAME: HOMEDATA_DATA_CLASS_NAME,
"name": CLIMATE_TOPOLOGY_CLASS_NAME,
SIGNAL_NAME: CLIMATE_TOPOLOGY_CLASS_NAME,
},
{
"name": HOMESTATUS_DATA_CLASS_NAME,
"home_id": self._home_id,
SIGNAL_NAME: self._home_status_class,
"name": CLIMATE_STATE_CLASS_NAME,
"home_id": self._room.home.entity_id,
SIGNAL_NAME: self._climate_state_class,
},
]
)
self._home_status = self.data_handler.data[self._home_status_class]
self._room_status = self._home_status.rooms[room_id]
self._room_data: dict = self._data.rooms[home_id][room_id]
self._model: str = NA_VALVE
for module in self._room_data.get("module_ids", []):
if self._home_status.thermostats.get(module):
self._model = NA_THERM
break
self._model: str = getattr(room.device_type, "value")
self._netatmo_type = TYPE_ENERGY
self._device_name = self._data.rooms[home_id][room_id]["name"]
self._attr_name = f"{MANUFACTURER} {self._device_name}"
self._attr_name = self._room.name
self._away: bool | None = None
self._support_flags = SUPPORT_FLAGS
self._battery_level = None
self._connected: bool | None = None
self._away_temperature: float | None = None
self._hg_temperature: float | None = None
self._boilerstatus: bool | None = None
self._setpoint_duration = None
self._selected_schedule = None
self._attr_hvac_modes = [HVAC_MODE_AUTO, HVAC_MODE_HEAT]
if self._model == NA_THERM:
self._attr_hvac_modes.append(HVAC_MODE_OFF)
self._attr_unique_id = f"{self._id}-{self._model}"
self._attr_unique_id = f"{self._room.entity_id}-{self._model}"
async def async_added_to_hass(self) -> None:
"""Entity created."""
@ -254,33 +241,32 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
)
)
registry = await async_get_registry(self.hass)
device = registry.async_get_device({(DOMAIN, self._id)}, set())
assert device
self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._home_id] = device.id
@callback
def handle_event(self, event: dict) -> None:
"""Handle webhook events."""
data = event["data"]
if self._home_id != data["home_id"]:
if self._room.home.entity_id != data["home_id"]:
return
if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data:
self._selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][
self._home_id
].get(data["schedule_id"])
self._attr_extra_state_attributes.update(
{"selected_schedule": self._selected_schedule}
self._selected_schedule = getattr(
self.hass.data[DOMAIN][DATA_SCHEDULES][self._room.home.entity_id].get(
data["schedule_id"]
),
"name",
None,
)
self._attr_extra_state_attributes[
ATTR_SELECTED_SCHEDULE
] = self._selected_schedule
self.async_write_ha_state()
self.data_handler.async_force_update(self._home_status_class)
self.data_handler.async_force_update(self._climate_state_class)
return
home = data["home"]
if self._home_id != home["id"]:
if self._room.home.entity_id != home["id"]:
return
if data["event_type"] == EVENT_TYPE_THERM_MODE:
@ -292,12 +278,15 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
self._attr_target_temperature = self._away_temperature
elif self._attr_preset_mode == PRESET_SCHEDULE:
self.async_update_callback()
self.data_handler.async_force_update(self._home_status_class)
self.data_handler.async_force_update(self._climate_state_class)
self.async_write_ha_state()
return
for room in home.get("rooms", []):
if data["event_type"] == EVENT_TYPE_SET_POINT and self._id == room["id"]:
if (
data["event_type"] == EVENT_TYPE_SET_POINT
and self._room.entity_id == room["id"]
):
if room["therm_setpoint_mode"] == STATE_NETATMO_OFF:
self._attr_hvac_mode = HVAC_MODE_OFF
self._attr_preset_mode = STATE_NETATMO_OFF
@ -318,31 +307,21 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
if (
data["event_type"] == EVENT_TYPE_CANCEL_SET_POINT
and self._id == room["id"]
and self._room.entity_id == room["id"]
):
self.async_update_callback()
self.async_write_ha_state()
return
@property
def _data(self) -> pyatmo.AsyncHomeData:
"""Return data for this entity."""
return cast(
pyatmo.AsyncHomeData, self.data_handler.data[self._data_classes[0]["name"]]
)
@property
def supported_features(self) -> int:
"""Return the list of supported features."""
return self._support_flags
@property
def hvac_action(self) -> str | None:
"""Return the current running hvac operation if supported."""
if self._model == NA_THERM and self._boilerstatus is not None:
return CURRENT_HVAC_MAP_NETATMO[self._boilerstatus]
# Maybe it is a valve
if self._room_status and self._room_status.get("heating_power_request", 0) > 0:
if (
heating_req := getattr(self._room, "heating_power_request", 0)
) is not None and heating_req > 0:
return CURRENT_HVAC_HEAT
return CURRENT_HVAC_IDLE
@ -363,8 +342,8 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
await self.async_turn_on()
if self.target_temperature == 0:
await self._home_status.async_set_room_thermpoint(
self._id,
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id,
STATE_NETATMO_HOME,
)
@ -373,15 +352,15 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
and self._model == NA_VALVE
and self.hvac_mode == HVAC_MODE_HEAT
):
await self._home_status.async_set_room_thermpoint(
self._id,
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id,
STATE_NETATMO_HOME,
)
elif (
preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX) and self._model == NA_VALVE
):
await self._home_status.async_set_room_thermpoint(
self._id,
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id,
STATE_NETATMO_MANUAL,
DEFAULT_MAX_TEMP,
)
@ -389,15 +368,17 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX)
and self.hvac_mode == HVAC_MODE_HEAT
):
await self._home_status.async_set_room_thermpoint(
self._id, STATE_NETATMO_HOME
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id, STATE_NETATMO_HOME
)
elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX):
await self._home_status.async_set_room_thermpoint(
self._id, PRESET_MAP_NETATMO[preset_mode]
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id, PRESET_MAP_NETATMO[preset_mode]
)
elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY):
await self._home_status.async_set_thermmode(PRESET_MAP_NETATMO[preset_mode])
await self._climate_state.async_set_thermmode(
PRESET_MAP_NETATMO[preset_mode]
)
else:
_LOGGER.error("Preset mode '%s' not available", preset_mode)
@ -407,8 +388,8 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
"""Set new target temperature for 2 hours."""
if (temp := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self._home_status.async_set_room_thermpoint(
self._id, STATE_NETATMO_MANUAL, min(temp, 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()
@ -416,20 +397,22 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
async def async_turn_off(self) -> None:
"""Turn the entity off."""
if self._model == NA_VALVE:
await self._home_status.async_set_room_thermpoint(
self._id,
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id,
STATE_NETATMO_MANUAL,
DEFAULT_MIN_TEMP,
)
elif self.hvac_mode != HVAC_MODE_OFF:
await self._home_status.async_set_room_thermpoint(
self._id, STATE_NETATMO_OFF
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id, STATE_NETATMO_OFF
)
self.async_write_ha_state()
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self._home_status.async_set_room_thermpoint(self._id, STATE_NETATMO_HOME)
await self._climate_state.async_set_room_thermpoint(
self._room.entity_id, STATE_NETATMO_HOME
)
self.async_write_ha_state()
@property
@ -440,135 +423,57 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
@callback
def async_update_callback(self) -> None:
"""Update the entity's state."""
self._home_status = self.data_handler.data[self._home_status_class]
if self._home_status is None:
if not self._room.reachable:
if self.available:
self._connected = False
return
self._room_status = self._home_status.rooms.get(self._id)
self._room_data = self._data.rooms.get(self._home_id, {}).get(self._id, {})
if not self._room_status or not self._room_data:
if self._connected:
_LOGGER.info(
"The thermostat in room %s seems to be out of reach",
self._device_name,
)
self._connected = False
return
roomstatus = {"roomID": self._room_status.get("id", {})}
if self._room_status.get("reachable"):
roomstatus.update(self._build_room_status())
self._away_temperature = self._data.get_away_temp(self._home_id)
self._hg_temperature = self._data.get_hg_temp(self._home_id)
self._setpoint_duration = self._data.setpoint_duration[self._home_id]
self._selected_schedule = roomstatus.get("selected_schedule")
if "current_temperature" not in roomstatus:
return
self._attr_current_temperature = roomstatus["current_temperature"]
self._attr_target_temperature = roomstatus["target_temperature"]
self._attr_preset_mode = NETATMO_MAP_PRESET[roomstatus["setpoint_mode"]]
self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode]
self._battery_level = roomstatus.get("battery_state")
self._connected = True
self._away_temperature = self._room.home.get_away_temp()
self._hg_temperature = self._room.home.get_hg_temp()
self._attr_current_temperature = self._room.therm_measured_temperature
self._attr_target_temperature = self._room.therm_setpoint_temperature
self._attr_preset_mode = NETATMO_MAP_PRESET[
getattr(self._room, "therm_setpoint_mode", STATE_NETATMO_SCHEDULE)
]
self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode]
self._away = self._attr_hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY]
if self._battery_level is not None:
self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = self._battery_level
if self._model == NA_VALVE:
self._attr_extra_state_attributes[
ATTR_HEATING_POWER_REQUEST
] = self._room_status.get("heating_power_request", 0)
if self._selected_schedule is not None:
self._selected_schedule = getattr(
self._room.home.get_selected_schedule(), "name", None
)
self._attr_extra_state_attributes[
ATTR_SELECTED_SCHEDULE
] = self._selected_schedule
def _build_room_status(self) -> dict:
"""Construct room status."""
try:
roomstatus = {
"roomname": self._room_data["name"],
"target_temperature": self._room_status["therm_setpoint_temperature"],
"setpoint_mode": self._room_status["therm_setpoint_mode"],
"current_temperature": self._room_status["therm_measured_temperature"],
"module_type": self._data.get_thermostat_type(
home_id=self._home_id, room_id=self._id
),
"module_id": None,
"heating_status": None,
"heating_power_request": None,
"selected_schedule": self._data._get_selected_schedule( # pylint: disable=protected-access
home_id=self._home_id
).get(
"name"
),
}
batterylevel = None
for module_id in self._room_data["module_ids"]:
if (
self._data.modules[self._home_id][module_id]["type"] == NA_THERM
or roomstatus["module_id"] is None
):
roomstatus["module_id"] = module_id
if roomstatus["module_type"] == NA_THERM:
self._boilerstatus = self._home_status.boiler_status(
roomstatus["module_id"]
)
roomstatus["heating_status"] = self._boilerstatus
batterylevel = self._home_status.thermostats[
roomstatus["module_id"]
].get("battery_state")
elif roomstatus["module_type"] == NA_VALVE:
roomstatus["heating_power_request"] = self._room_status[
"heating_power_request"
]
roomstatus["heating_status"] = roomstatus["heating_power_request"] > 0
if self._boilerstatus is not None:
roomstatus["heating_status"] = (
self._boilerstatus and roomstatus["heating_status"]
)
batterylevel = self._home_status.valves[roomstatus["module_id"]].get(
"battery_state"
)
if batterylevel:
roomstatus["battery_state"] = batterylevel
return roomstatus
except KeyError as err:
_LOGGER.error("Update of room %s failed. Error: %s", self._id, err)
return {}
if self._model == NA_VALVE:
self._attr_extra_state_attributes[
ATTR_HEATING_POWER_REQUEST
] = self._room.heating_power_request
else:
for module in self._room.modules.values():
self._boilerstatus = module.boiler_status
break
async def _async_service_set_schedule(self, **kwargs: Any) -> None:
schedule_name = kwargs.get(ATTR_SCHEDULE_NAME)
schedule_id = None
for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items():
if name == schedule_name:
for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][
self._room.home.entity_id
].items():
if schedule.name == schedule_name:
schedule_id = sid
break
if not schedule_id:
_LOGGER.error("%s is not a valid schedule", kwargs.get(ATTR_SCHEDULE_NAME))
return
await self._data.async_switch_home_schedule(
home_id=self._home_id, schedule_id=schedule_id
)
await self._climate_state.async_switch_home_schedule(schedule_id=schedule_id)
_LOGGER.debug(
"Setting %s schedule to %s (%s)",
self._home_id,
self._room.home.entity_id,
kwargs.get(ATTR_SCHEDULE_NAME),
schedule_id,
)
@ -577,5 +482,5 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity):
def device_info(self) -> DeviceInfo:
"""Return the device info for the thermostat."""
device_info: DeviceInfo = super().device_info
device_info[ATTR_SUGGESTED_AREA] = self._room_data["name"]
device_info[ATTR_SUGGESTED_AREA] = self._room.name
return device_info

View file

@ -32,23 +32,23 @@ _LOGGER = logging.getLogger(__name__)
CAMERA_DATA_CLASS_NAME = "AsyncCameraData"
WEATHERSTATION_DATA_CLASS_NAME = "AsyncWeatherStationData"
HOMECOACH_DATA_CLASS_NAME = "AsyncHomeCoachData"
HOMEDATA_DATA_CLASS_NAME = "AsyncHomeData"
HOMESTATUS_DATA_CLASS_NAME = "AsyncHomeStatus"
CLIMATE_TOPOLOGY_CLASS_NAME = "AsyncClimateTopology"
CLIMATE_STATE_CLASS_NAME = "AsyncClimate"
PUBLICDATA_DATA_CLASS_NAME = "AsyncPublicData"
DATA_CLASSES = {
WEATHERSTATION_DATA_CLASS_NAME: pyatmo.AsyncWeatherStationData,
HOMECOACH_DATA_CLASS_NAME: pyatmo.AsyncHomeCoachData,
CAMERA_DATA_CLASS_NAME: pyatmo.AsyncCameraData,
HOMEDATA_DATA_CLASS_NAME: pyatmo.AsyncHomeData,
HOMESTATUS_DATA_CLASS_NAME: pyatmo.AsyncHomeStatus,
CLIMATE_TOPOLOGY_CLASS_NAME: pyatmo.AsyncClimateTopology,
CLIMATE_STATE_CLASS_NAME: pyatmo.AsyncClimate,
PUBLICDATA_DATA_CLASS_NAME: pyatmo.AsyncPublicData,
}
BATCH_SIZE = 3
DEFAULT_INTERVALS = {
HOMEDATA_DATA_CLASS_NAME: 900,
HOMESTATUS_DATA_CLASS_NAME: 300,
CLIMATE_TOPOLOGY_CLASS_NAME: 3600,
CLIMATE_STATE_CLASS_NAME: 300,
CAMERA_DATA_CLASS_NAME: 900,
WEATHERSTATION_DATA_CLASS_NAME: 600,
HOMECOACH_DATA_CLASS_NAME: 300,

View file

@ -4,8 +4,6 @@ from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID, uuid4
import pyatmo
@dataclass
class NetatmoArea:
@ -19,25 +17,3 @@ class NetatmoArea:
mode: str
show_on_map: bool
uuid: UUID = uuid4()
def get_all_home_ids(home_data: pyatmo.HomeData | None) -> list[str]:
"""Get all the home ids returned by NetAtmo API."""
if home_data is None:
return []
return [
home_data.homes[home_id]["id"]
for home_id in home_data.homes
if "modules" in home_data.homes[home_id]
]
def update_climate_schedules(home_ids: list[str], schedules: dict) -> dict:
"""Get updated list of all climate schedules."""
return {
home_id: {
schedule_id: schedule_data.get("name")
for schedule_id, schedule_data in schedules[home_id].items()
}
for home_id in home_ids
}

View file

@ -66,7 +66,7 @@ class NetatmoBase(Entity):
await self.data_handler.unregister_data_class(signal_name, None)
registry = await self.hass.helpers.device_registry.async_get_registry()
device = registry.async_get_device({(DOMAIN, self._id)}, set())
device = registry.async_get_device({(DOMAIN, self._id)})
self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._id] = device.id
self.async_update_callback()

View file

@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from typing import cast
import pyatmo
@ -22,8 +21,11 @@ from .const import (
SIGNAL_NAME,
TYPE_ENERGY,
)
from .data_handler import HOMEDATA_DATA_CLASS_NAME, NetatmoDataHandler
from .helper import get_all_home_ids, update_climate_schedules
from .data_handler import (
CLIMATE_STATE_CLASS_NAME,
CLIMATE_TOPOLOGY_CLASS_NAME,
NetatmoDataHandler,
)
from .netatmo_entity_base import NetatmoBase
_LOGGER = logging.getLogger(__name__)
@ -36,25 +38,34 @@ async def async_setup_entry(
data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER]
await data_handler.register_data_class(
HOMEDATA_DATA_CLASS_NAME, HOMEDATA_DATA_CLASS_NAME, None
CLIMATE_TOPOLOGY_CLASS_NAME, CLIMATE_TOPOLOGY_CLASS_NAME, None
)
home_data = data_handler.data.get(HOMEDATA_DATA_CLASS_NAME)
climate_topology = data_handler.data.get(CLIMATE_TOPOLOGY_CLASS_NAME)
if not home_data or home_data.raw_data == {}:
if not climate_topology or climate_topology.raw_data == {}:
raise PlatformNotReady
hass.data[DOMAIN][DATA_SCHEDULES].update(
update_climate_schedules(
home_ids=get_all_home_ids(home_data),
schedules=data_handler.data[HOMEDATA_DATA_CLASS_NAME].schedules,
)
entities = []
for home_id in climate_topology.home_ids:
signal_name = f"{CLIMATE_STATE_CLASS_NAME}-{home_id}"
await data_handler.register_data_class(
CLIMATE_STATE_CLASS_NAME, signal_name, None, home_id=home_id
)
climate_state = data_handler.data.get(signal_name)
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,
list(hass.data[DOMAIN][DATA_SCHEDULES][home_id].values()),
[
schedule.name
for schedule in hass.data[DOMAIN][DATA_SCHEDULES][home_id].values()
],
)
for home_id in hass.data[DOMAIN][DATA_SCHEDULES]
]
@ -75,16 +86,28 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity):
self._home_id = home_id
self._climate_state_class = f"{CLIMATE_STATE_CLASS_NAME}-{self._home_id}"
self._climate_state: pyatmo.AsyncClimate = data_handler.data[
self._climate_state_class
]
self._home = self._climate_state.homes[self._home_id]
self._data_classes.extend(
[
{
"name": HOMEDATA_DATA_CLASS_NAME,
SIGNAL_NAME: HOMEDATA_DATA_CLASS_NAME,
"name": CLIMATE_TOPOLOGY_CLASS_NAME,
SIGNAL_NAME: CLIMATE_TOPOLOGY_CLASS_NAME,
},
{
"name": CLIMATE_STATE_CLASS_NAME,
"home_id": self._home_id,
SIGNAL_NAME: self._climate_state_class,
},
]
)
self._device_name = self._data.homes[home_id]["name"]
self._device_name = self._home.name
self._attr_name = f"{MANUFACTURER} {self._device_name}"
self._model: str = "NATherm1"
@ -92,9 +115,7 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity):
self._attr_unique_id = f"{self._home_id}-schedule-select"
self._attr_current_option = self._data._get_selected_schedule(
home_id=self._home_id
).get("name")
self._attr_current_option = getattr(self._home.get_selected_schedule(), "name")
self._attr_options = options
async def async_added_to_hass(self) -> None:
@ -119,23 +140,20 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity):
return
if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data:
self._attr_current_option = self.hass.data[DOMAIN][DATA_SCHEDULES][
self._home_id
].get(data["schedule_id"])
self.async_write_ha_state()
@property
def _data(self) -> pyatmo.AsyncHomeData:
"""Return data for this entity."""
return cast(
pyatmo.AsyncHomeData,
self.data_handler.data[self._data_classes[0]["name"]],
self._attr_current_option = getattr(
self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].get(
data["schedule_id"]
),
"name",
)
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items():
if name != option:
for sid, schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][
self._home_id
].items():
if schedule.name != option:
continue
_LOGGER.debug(
"Setting %s schedule to %s (%s)",
@ -143,25 +161,17 @@ class NetatmoScheduleSelect(NetatmoBase, SelectEntity):
option,
sid,
)
await self._data.async_switch_home_schedule(
home_id=self._home_id, schedule_id=sid
)
await self._climate_state.async_switch_home_schedule(schedule_id=sid)
break
@callback
def async_update_callback(self) -> None:
"""Update the entity's state."""
self._attr_current_option = (
self._data._get_selected_schedule( # pylint: disable=protected-access
home_id=self._home_id
).get("name")
)
self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id] = {
schedule_id: schedule_data.get("name")
for schedule_id, schedule_data in (
self._data.schedules[self._home_id].items()
)
}
self._attr_options = list(
self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].values()
)
self._attr_current_option = getattr(self._home.get_selected_schedule(), "name")
self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id] = self._home.schedules
self._attr_options = [
schedule.name
for schedule in self.hass.data[DOMAIN][DATA_SCHEDULES][
self._home_id
].values()
]

View file

@ -51,7 +51,7 @@ async def fake_post_request(*args, **kwargs):
if endpoint in "snapshot_720.jpg":
return b"test stream image bytes"
elif endpoint in [
if endpoint in [
"setpersonsaway",
"setpersonshome",
"setstate",
@ -61,6 +61,10 @@ async def fake_post_request(*args, **kwargs):
]:
payload = f'{{"{endpoint}": true}}'
elif endpoint == "homestatus":
home_id = kwargs.get("params", {}).get("home_id")
payload = json.loads(load_fixture(f"netatmo/{endpoint}_{home_id}.json"))
else:
payload = json.loads(load_fixture(f"netatmo/{endpoint}.json"))

View file

@ -5,10 +5,7 @@
"id": "91763b24c43d3e344f424e8b",
"name": "MYHOME",
"altitude": 112,
"coordinates": [
52.516263,
13.377726
],
"coordinates": [52.516263, 13.377726],
"country": "DE",
"timezone": "Europe/Berlin",
"rooms": [
@ -16,33 +13,25 @@
"id": "2746182631",
"name": "Livingroom",
"type": "livingroom",
"module_ids": [
"12:34:56:00:01:ae"
]
"module_ids": ["12:34:56:00:01:ae"]
},
{
"id": "3688132631",
"name": "Hall",
"type": "custom",
"module_ids": [
"12:34:56:00:f1:62"
]
"module_ids": ["12:34:56:00:f1:62"]
},
{
"id": "2833524037",
"name": "Entrada",
"type": "lobby",
"module_ids": [
"12:34:56:03:a5:54"
]
"module_ids": ["12:34:56:03:a5:54"]
},
{
"id": "2940411577",
"name": "Cocina",
"type": "kitchen",
"module_ids": [
"12:34:56:03:a0:ac"
]
"module_ids": ["12:34:56:03:a0:ac"]
}
],
"modules": [
@ -399,18 +388,6 @@
}
],
"therm_mode": "schedule"
},
{
"id": "91763b24c43d3e344f424e8c",
"altitude": 112,
"coordinates": [
52.516263,
13.377726
],
"country": "DE",
"timezone": "Europe/Berlin",
"therm_setpoint_default_duration": 180,
"therm_mode": "schedule"
}
],
"user": {

View file

@ -0,0 +1,12 @@
{
"status": "ok",
"time_server": 1559292041,
"body": {
"home": {
"modules": [],
"rooms": [],
"id": "91763b24c43d3e344f424e8c",
"persons": []
}
}
}

View file

@ -1,5 +1,5 @@
"""The tests for the Netatmo climate platform."""
from unittest.mock import Mock, patch
from unittest.mock import patch
from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
@ -18,7 +18,6 @@ from homeassistant.components.climate.const import (
PRESET_AWAY,
PRESET_BOOST,
)
from homeassistant.components.netatmo import climate
from homeassistant.components.netatmo.climate import PRESET_FROST_GUARD, PRESET_SCHEDULE
from homeassistant.components.netatmo.const import (
ATTR_SCHEDULE_NAME,
@ -37,7 +36,7 @@ async def test_webhook_event_handling_thermostats(hass, config_entry, netatmo_au
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_livingroom = "climate.netatmo_livingroom"
climate_entity_livingroom = "climate.livingroom"
assert hass.states.get(climate_entity_livingroom).state == "auto"
assert (
@ -214,7 +213,7 @@ async def test_service_preset_mode_frost_guard_thermostat(
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_livingroom = "climate.netatmo_livingroom"
climate_entity_livingroom = "climate.livingroom"
assert hass.states.get(climate_entity_livingroom).state == "auto"
assert (
@ -287,7 +286,7 @@ async def test_service_preset_modes_thermostat(hass, config_entry, netatmo_auth)
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_livingroom = "climate.netatmo_livingroom"
climate_entity_livingroom = "climate.livingroom"
assert hass.states.get(climate_entity_livingroom).state == "auto"
assert (
@ -415,11 +414,11 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_livingroom = "climate.netatmo_livingroom"
climate_entity_livingroom = "climate.livingroom"
# Test setting a valid schedule
with patch(
"pyatmo.thermostat.AsyncHomeData.async_switch_home_schedule"
"pyatmo.climate.AsyncClimate.async_switch_home_schedule"
) as mock_switch_home_schedule:
await hass.services.async_call(
"netatmo",
@ -429,7 +428,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_
)
await hass.async_block_till_done()
mock_switch_home_schedule.assert_called_once_with(
home_id="91763b24c43d3e344f424e8b", schedule_id="b1b54a2f45795764f59d50d8"
schedule_id="b1b54a2f45795764f59d50d8"
)
# Fake backend response for valve being turned on
@ -448,7 +447,7 @@ async def test_service_schedule_thermostats(hass, config_entry, caplog, netatmo_
# Test setting an invalid schedule
with patch(
"pyatmo.thermostat.AsyncHomeData.async_switch_home_schedule"
"pyatmo.climate.AsyncClimate.async_switch_home_schedule"
) as mock_switch_home_schedule:
await hass.services.async_call(
"netatmo",
@ -472,7 +471,7 @@ async def test_service_preset_mode_already_boost_valves(
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_entrada = "climate.netatmo_entrada"
climate_entity_entrada = "climate.entrada"
assert hass.states.get(climate_entity_entrada).state == "auto"
assert (
@ -550,7 +549,7 @@ async def test_service_preset_mode_boost_valves(hass, config_entry, netatmo_auth
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_entrada = "climate.netatmo_entrada"
climate_entity_entrada = "climate.entrada"
# Test service setting the preset mode to "boost"
assert hass.states.get(climate_entity_entrada).state == "auto"
@ -602,7 +601,7 @@ async def test_service_preset_mode_invalid(hass, config_entry, caplog, netatmo_a
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_PRESET_MODE,
{ATTR_ENTITY_ID: "climate.netatmo_cocina", ATTR_PRESET_MODE: "invalid"},
{ATTR_ENTITY_ID: "climate.cocina", ATTR_PRESET_MODE: "invalid"},
blocking=True,
)
await hass.async_block_till_done()
@ -618,7 +617,12 @@ async def test_valves_service_turn_off(hass, config_entry, netatmo_auth):
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_entrada = "climate.netatmo_entrada"
climate_entity_entrada = "climate.entrada"
assert hass.states.get(climate_entity_entrada).attributes["hvac_modes"] == [
"auto",
"heat",
]
# Test turning valve off
await hass.services.async_call(
@ -663,7 +667,7 @@ async def test_valves_service_turn_on(hass, config_entry, netatmo_auth):
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_entrada = "climate.netatmo_entrada"
climate_entity_entrada = "climate.entrada"
# Test turning valve on
await hass.services.async_call(
@ -700,21 +704,6 @@ async def test_valves_service_turn_on(hass, config_entry, netatmo_auth):
assert hass.states.get(climate_entity_entrada).state == "auto"
async def test_get_all_home_ids():
"""Test extracting all home ids returned by NetAtmo API."""
# Test with backend returning no data
assert climate.get_all_home_ids(None) == []
# Test with fake data
home_data = Mock()
home_data.homes = {
"123": {"id": "123", "name": "Home 1", "modules": [], "therm_schedules": []},
"987": {"id": "987", "name": "Home 2", "modules": [], "therm_schedules": []},
}
expected = ["123", "987"]
assert climate.get_all_home_ids(home_data) == expected
async def test_webhook_home_id_mismatch(hass, config_entry, netatmo_auth):
"""Test service turn on for valves."""
with selected_platforms(["climate"]):
@ -723,7 +712,7 @@ async def test_webhook_home_id_mismatch(hass, config_entry, netatmo_auth):
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_entrada = "climate.netatmo_entrada"
climate_entity_entrada = "climate.entrada"
assert hass.states.get(climate_entity_entrada).state == "auto"
@ -761,7 +750,7 @@ async def test_webhook_set_point(hass, config_entry, netatmo_auth):
await hass.async_block_till_done()
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
climate_entity_entrada = "climate.netatmo_entrada"
climate_entity_entrada = "climate.entrada"
# Fake backend response for valve being turned on
response = {

View file

@ -136,7 +136,7 @@ async def test_setup_component_with_webhook(hass, config_entry, netatmo_auth):
await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK_ACTIVATION)
# Assert webhook is established successfully
climate_entity_livingroom = "climate.netatmo_livingroom"
climate_entity_livingroom = "climate.livingroom"
assert hass.states.get(climate_entity_livingroom).state == "auto"
await simulate_webhook(hass, webhook_id, FAKE_WEBHOOK)
assert hass.states.get(climate_entity_livingroom).state == "heat"
@ -440,7 +440,6 @@ async def test_setup_component_invalid_token(hass, config_entry):
"""Test handling of invalid token."""
async def fake_ensure_valid_token(*args, **kwargs):
print("fake_ensure_valid_token")
raise aiohttp.ClientResponseError(
request_info=aiohttp.client.RequestInfo(
url="http://example.com",

View file

@ -38,7 +38,7 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a
# Test setting a different schedule
with patch(
"pyatmo.thermostat.AsyncHomeData.async_switch_home_schedule"
"pyatmo.climate.AsyncClimate.async_switch_home_schedule"
) as mock_switch_home_schedule:
await hass.services.async_call(
SELECT_DOMAIN,
@ -51,7 +51,7 @@ async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_a
)
await hass.async_block_till_done()
mock_switch_home_schedule.assert_called_once_with(
home_id="91763b24c43d3e344f424e8b", schedule_id="591b54a2764ff4d50d8b5795"
schedule_id="591b54a2764ff4d50d8b5795"
)
# Fake backend response changing schedule