"""Support for Netatmo Smart thermostats.""" from datetime import timedelta import logging from typing import Optional, List import pyatmo import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA from homeassistant.components.climate.const import ( HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF, PRESET_AWAY, PRESET_BOOST, CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE, DEFAULT_MIN_TEMP, ) from homeassistant.const import ( TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_NAME, PRECISION_HALVES, STATE_OFF, ATTR_BATTERY_LEVEL, ) from homeassistant.util import Throttle from .const import DATA_NETATMO_AUTH _LOGGER = logging.getLogger(__name__) PRESET_FROST_GUARD = "Frost Guard" PRESET_SCHEDULE = "Schedule" PRESET_MANUAL = "Manual" SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE SUPPORT_HVAC = [HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF] SUPPORT_PRESET = [PRESET_AWAY, PRESET_BOOST, PRESET_FROST_GUARD, PRESET_SCHEDULE] STATE_NETATMO_SCHEDULE = "schedule" STATE_NETATMO_HG = "hg" STATE_NETATMO_MAX = "max" STATE_NETATMO_AWAY = PRESET_AWAY STATE_NETATMO_OFF = STATE_OFF STATE_NETATMO_MANUAL = "manual" PRESET_MAP_NETATMO = { PRESET_FROST_GUARD: STATE_NETATMO_HG, PRESET_BOOST: STATE_NETATMO_MAX, PRESET_SCHEDULE: STATE_NETATMO_SCHEDULE, PRESET_AWAY: STATE_NETATMO_AWAY, STATE_NETATMO_OFF: STATE_NETATMO_OFF, } NETATMO_MAP_PRESET = { STATE_NETATMO_HG: PRESET_FROST_GUARD, STATE_NETATMO_MAX: PRESET_BOOST, STATE_NETATMO_SCHEDULE: PRESET_SCHEDULE, STATE_NETATMO_AWAY: PRESET_AWAY, STATE_NETATMO_OFF: STATE_NETATMO_OFF, STATE_NETATMO_MANUAL: STATE_NETATMO_MANUAL, } HVAC_MAP_NETATMO = { PRESET_SCHEDULE: HVAC_MODE_AUTO, STATE_NETATMO_HG: HVAC_MODE_AUTO, PRESET_FROST_GUARD: HVAC_MODE_AUTO, PRESET_BOOST: HVAC_MODE_HEAT, STATE_NETATMO_OFF: HVAC_MODE_OFF, STATE_NETATMO_MANUAL: HVAC_MODE_AUTO, PRESET_MANUAL: HVAC_MODE_AUTO, STATE_NETATMO_AWAY: HVAC_MODE_AUTO, } CURRENT_HVAC_MAP_NETATMO = {True: CURRENT_HVAC_HEAT, False: CURRENT_HVAC_IDLE} CONF_HOMES = "homes" CONF_ROOMS = "rooms" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) HOME_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, [cv.string]), } ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Optional(CONF_HOMES): vol.All(cv.ensure_list, [HOME_CONFIG_SCHEMA])} ) DEFAULT_MAX_TEMP = 30 NA_THERM = "NATherm1" NA_VALVE = "NRV" def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the NetAtmo Thermostat.""" homes_conf = config.get(CONF_HOMES) auth = hass.data[DATA_NETATMO_AUTH] home_data = HomeData(auth) try: home_data.setup() except pyatmo.NoDevice: return home_ids = [] rooms = {} if homes_conf is not None: for home_conf in homes_conf: home = home_conf[CONF_NAME] home_id = home_data.homedata.gethomeId(home) if home_conf[CONF_ROOMS] != []: rooms[home_id] = home_conf[CONF_ROOMS] home_ids.append(home_id) else: home_ids = home_data.get_home_ids() devices = [] for home_id in home_ids: _LOGGER.debug("Setting up %s ...", home_id) try: room_data = ThermostatData(auth, home_id) except pyatmo.NoDevice: continue for room_id in room_data.get_room_ids(): room_name = room_data.homedata.rooms[home_id][room_id]["name"] _LOGGER.debug("Setting up %s (%s) ...", room_name, room_id) if home_id in rooms and room_name not in rooms[home_id]: _LOGGER.debug("Excluding %s ...", room_name) continue _LOGGER.debug("Adding devices for room %s (%s) ...", room_name, room_id) devices.append(NetatmoThermostat(room_data, room_id)) add_entities(devices, True) class NetatmoThermostat(ClimateDevice): """Representation a Netatmo thermostat.""" def __init__(self, data, room_id): """Initialize the sensor.""" self._data = data self._state = None self._room_id = room_id self._room_name = self._data.homedata.rooms[self._data.home_id][room_id]["name"] self._name = f"netatmo_{self._room_name}" self._current_temperature = None self._target_temperature = None self._preset = None self._away = None self._operation_list = [HVAC_MODE_AUTO, HVAC_MODE_HEAT] self._support_flags = SUPPORT_FLAGS self._hvac_mode = None self._battery_level = None self.update_without_throttle = False self._module_type = self._data.room_status.get(room_id, {}).get("module_type") if self._module_type == NA_THERM: self._operation_list.append(HVAC_MODE_OFF) @property def supported_features(self): """Return the list of supported features.""" return self._support_flags @property def name(self): """Return the name of the thermostat.""" return self._name @property def temperature_unit(self): """Return the unit of measurement.""" return TEMP_CELSIUS @property def current_temperature(self): """Return the current temperature.""" return self._current_temperature @property def target_temperature(self): """Return the temperature we try to reach.""" return self._target_temperature @property def target_temperature_step(self) -> Optional[float]: """Return the supported step of target temperature.""" return PRECISION_HALVES @property def hvac_mode(self): """Return hvac operation ie. heat, cool mode.""" return self._hvac_mode @property def hvac_modes(self): """Return the list of available hvac operation modes.""" return self._operation_list @property def hvac_action(self) -> Optional[str]: """Return the current running hvac operation if supported.""" if self._module_type == NA_THERM: return CURRENT_HVAC_MAP_NETATMO[self._data.boilerstatus] # Maybe it is a valve if self._room_id in self._data.room_status: if ( self._data.room_status[self._room_id].get("heating_power_request", 0) > 0 ): return CURRENT_HVAC_HEAT return CURRENT_HVAC_IDLE def set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" mode = None if hvac_mode == HVAC_MODE_OFF: mode = STATE_NETATMO_OFF elif hvac_mode == HVAC_MODE_AUTO: mode = PRESET_SCHEDULE elif hvac_mode == HVAC_MODE_HEAT: mode = PRESET_BOOST self.set_preset_mode(mode) def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" if self.target_temperature == 0: self._data.homestatus.setroomThermpoint( self._data.home_id, self._room_id, STATE_NETATMO_MANUAL, DEFAULT_MIN_TEMP, ) if ( preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] and self._module_type == NA_VALVE ): self._data.homestatus.setroomThermpoint( self._data.home_id, self._room_id, STATE_NETATMO_MANUAL, DEFAULT_MAX_TEMP, ) elif preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX, STATE_NETATMO_OFF]: self._data.homestatus.setroomThermpoint( self._data.home_id, self._room_id, PRESET_MAP_NETATMO[preset_mode] ) elif preset_mode in [PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY]: self._data.homestatus.setThermmode( self._data.home_id, PRESET_MAP_NETATMO[preset_mode] ) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) self.update_without_throttle = True self.schedule_update_ha_state() @property def preset_mode(self) -> Optional[str]: """Return the current preset mode, e.g., home, away, temp.""" return self._preset @property def preset_modes(self) -> Optional[List[str]]: """Return a list of available preset modes.""" return SUPPORT_PRESET def set_temperature(self, **kwargs): """Set new target temperature for 2 hours.""" temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: return self._data.homestatus.setroomThermpoint( self._data.home_id, self._room_id, STATE_NETATMO_MANUAL, temp ) self.update_without_throttle = True self.schedule_update_ha_state() @property def device_state_attributes(self): """Return the state attributes of the thermostat.""" attr = {} if self._battery_level is not None: attr[ATTR_BATTERY_LEVEL] = self._battery_level return attr def update(self): """Get the latest data from NetAtmo API and updates the states.""" try: if self.update_without_throttle: self._data.update(no_throttle=True) self.update_without_throttle = False else: self._data.update() except AttributeError: _LOGGER.error("NetatmoThermostat::update() got exception") return try: if self._module_type is None: self._module_type = self._data.room_status[self._room_id]["module_type"] self._current_temperature = self._data.room_status[self._room_id][ "current_temperature" ] self._target_temperature = self._data.room_status[self._room_id][ "target_temperature" ] self._preset = NETATMO_MAP_PRESET[ self._data.room_status[self._room_id]["setpoint_mode"] ] self._hvac_mode = HVAC_MAP_NETATMO[self._preset] self._battery_level = self._data.room_status[self._room_id].get( "battery_level" ) except KeyError as err: _LOGGER.error( "The thermostat in room %s seems to be out of reach. (%s)", self._room_id, err, ) self._away = self._hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] class HomeData: """Representation Netatmo homes.""" def __init__(self, auth, home=None): """Initialize the HomeData object.""" self.auth = auth self.homedata = None self.home_ids = [] self.home_names = [] self.room_names = [] self.schedules = [] self.home = home self.home_id = None def get_home_ids(self): """Get all the home ids returned by NetAtmo API.""" if self.homedata is None: return [] for home_id in self.homedata.homes: if ( "therm_schedules" in self.homedata.homes[home_id] and "modules" in self.homedata.homes[home_id] ): self.home_ids.append(self.homedata.homes[home_id]["id"]) return self.home_ids def setup(self): """Retrieve HomeData by NetAtmo API.""" try: self.homedata = pyatmo.HomeData(self.auth) self.home_id = self.homedata.gethomeId(self.home) except TypeError: _LOGGER.error("Error when getting home data") except AttributeError: _LOGGER.error("No default_home in HomeData") except pyatmo.NoDevice: _LOGGER.debug("No thermostat devices available") except pyatmo.InvalidHome: _LOGGER.debug("Invalid home %s", self.home) class ThermostatData: """Get the latest data from Netatmo.""" def __init__(self, auth, home_id=None): """Initialize the data object.""" self.auth = auth self.homedata = None self.homestatus = None self.room_ids = [] self.room_status = {} self.schedules = [] self.home_id = home_id self.home_name = None self.away_temperature = None self.hg_temperature = None self.boilerstatus = None self.setpoint_duration = None def get_room_ids(self): """Return all module available on the API as a list.""" if not self.setup(): return [] for room in self.homestatus.rooms: self.room_ids.append(room) return self.room_ids def setup(self): """Retrieve HomeData and HomeStatus by NetAtmo API.""" try: self.homedata = pyatmo.HomeData(self.auth) self.homestatus = pyatmo.HomeStatus(self.auth, home_id=self.home_id) self.home_name = self.homedata.getHomeName(self.home_id) self.update() except TypeError: _LOGGER.error("ThermostatData::setup() got error") return False return True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Call the NetAtmo API to update the data.""" try: self.homestatus = pyatmo.HomeStatus(self.auth, home_id=self.home_id) except TypeError: _LOGGER.error("Error when getting homestatus") return except requests.exceptions.Timeout: _LOGGER.warning("Timed out when connecting to Netatmo server") return _LOGGER.debug("Following is the debugging output for homestatus:") _LOGGER.debug(self.homestatus.rawData) for room in self.homestatus.rooms: try: roomstatus = {} homestatus_room = self.homestatus.rooms[room] homedata_room = self.homedata.rooms[self.home_id][room] roomstatus["roomID"] = homestatus_room["id"] if homestatus_room["reachable"]: roomstatus["roomname"] = homedata_room["name"] roomstatus["target_temperature"] = homestatus_room[ "therm_setpoint_temperature" ] roomstatus["setpoint_mode"] = homestatus_room["therm_setpoint_mode"] roomstatus["current_temperature"] = homestatus_room[ "therm_measured_temperature" ] roomstatus["module_type"] = self.homestatus.thermostatType( home_id=self.home_id, rid=room, home=self.home_name ) roomstatus["module_id"] = None roomstatus["heating_status"] = None roomstatus["heating_power_request"] = None batterylevel = None for module_id in homedata_room["module_ids"]: if ( self.homedata.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.homestatus.boilerStatus( rid=roomstatus["module_id"] ) roomstatus["heating_status"] = self.boilerstatus batterylevel = self.homestatus.thermostats[ roomstatus["module_id"] ].get("battery_level") elif roomstatus["module_type"] == NA_VALVE: roomstatus["heating_power_request"] = homestatus_room[ "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.homestatus.valves[ roomstatus["module_id"] ].get("battery_level") if batterylevel: batterypct = interpolate( batterylevel, roomstatus["module_type"] ) if roomstatus.get("battery_level") is None: roomstatus["battery_level"] = batterypct elif batterypct < roomstatus["battery_level"]: roomstatus["battery_level"] = batterypct self.room_status[room] = roomstatus except KeyError as err: _LOGGER.error("Update of room %s failed. Error: %s", room, err) self.away_temperature = self.homestatus.getAwaytemp(home_id=self.home_id) self.hg_temperature = self.homestatus.getHgtemp(home_id=self.home_id) self.setpoint_duration = self.homedata.setpoint_duration[self.home_id] def interpolate(batterylevel, module_type): """Interpolate battery level depending on device type.""" na_battery_levels = { NA_THERM: { "full": 4100, "high": 3600, "medium": 3300, "low": 3000, "empty": 2800, }, NA_VALVE: { "full": 3200, "high": 2700, "medium": 2400, "low": 2200, "empty": 2200, }, } levels = sorted(na_battery_levels[module_type].values()) steps = [20, 50, 80, 100] na_battery_level = na_battery_levels[module_type] if batterylevel >= na_battery_level["full"]: return 100 if batterylevel >= na_battery_level["high"]: i = 3 elif batterylevel >= na_battery_level["medium"]: i = 2 elif batterylevel >= na_battery_level["low"]: i = 1 else: return 0 pct = steps[i - 1] + ( (steps[i] - steps[i - 1]) * (batterylevel - levels[i]) / (levels[i + 1] - levels[i]) ) return int(pct)