1 """Adds support for generic thermostat units."""
3 from __future__
import annotations
6 from collections.abc
import Mapping
7 from datetime
import datetime, timedelta
10 from typing
import Any
12 import voluptuous
as vol
16 PLATFORM_SCHEMA
as CLIMATE_PLATFORM_SCHEMA,
29 EVENT_HOMEASSISTANT_START,
41 DOMAIN
as HOMEASSISTANT_DOMAIN,
44 EventStateChangedData,
54 async_track_state_change_event,
55 async_track_time_interval,
74 _LOGGER = logging.getLogger(__name__)
76 DEFAULT_NAME =
"Generic Thermostat"
78 CONF_INITIAL_HVAC_MODE =
"initial_hvac_mode"
79 CONF_KEEP_ALIVE =
"keep_alive"
80 CONF_MIN_TEMP =
"min_temp"
81 CONF_MAX_TEMP =
"max_temp"
82 CONF_PRECISION =
"precision"
83 CONF_TARGET_TEMP =
"target_temp"
84 CONF_TEMP_STEP =
"target_temp_step"
87 PRESETS_SCHEMA: VolDictType = {
88 vol.Optional(v): vol.Coerce(float)
for v
in CONF_PRESETS.values()
91 PLATFORM_SCHEMA_COMMON = vol.Schema(
93 vol.Required(CONF_HEATER): cv.entity_id,
94 vol.Required(CONF_SENSOR): cv.entity_id,
95 vol.Optional(CONF_AC_MODE): cv.boolean,
96 vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
97 vol.Optional(CONF_MIN_DUR): cv.positive_time_period,
98 vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
99 vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
100 vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
101 vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
102 vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float),
103 vol.Optional(CONF_KEEP_ALIVE): cv.positive_time_period,
104 vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In(
105 [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
107 vol.Optional(CONF_PRECISION): vol.All(
109 vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
111 vol.Optional(CONF_TEMP_STEP): vol.All(
112 vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE])
114 vol.Optional(CONF_UNIQUE_ID): cv.string,
120 PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend(PLATFORM_SCHEMA_COMMON.schema)
125 config_entry: ConfigEntry,
126 async_add_entities: AddEntitiesCallback,
128 """Initialize config entry."""
132 config_entry.entry_id,
140 async_add_entities: AddEntitiesCallback,
141 discovery_info: DiscoveryInfoType |
None =
None,
143 """Set up the generic thermostat platform."""
147 hass, config, config.get(CONF_UNIQUE_ID), async_add_entities
153 config: Mapping[str, Any],
154 unique_id: str |
None,
155 async_add_entities: AddEntitiesCallback,
157 """Set up the generic thermostat platform."""
159 name: str = config[CONF_NAME]
160 heater_entity_id: str = config[CONF_HEATER]
161 sensor_entity_id: str = config[CONF_SENSOR]
162 min_temp: float |
None = config.get(CONF_MIN_TEMP)
163 max_temp: float |
None = config.get(CONF_MAX_TEMP)
164 target_temp: float |
None = config.get(CONF_TARGET_TEMP)
165 ac_mode: bool |
None = config.get(CONF_AC_MODE)
166 min_cycle_duration: timedelta |
None = config.get(CONF_MIN_DUR)
167 cold_tolerance: float = config[CONF_COLD_TOLERANCE]
168 hot_tolerance: float = config[CONF_HOT_TOLERANCE]
169 keep_alive: timedelta |
None = config.get(CONF_KEEP_ALIVE)
170 initial_hvac_mode: HVACMode |
None = config.get(CONF_INITIAL_HVAC_MODE)
171 presets: dict[str, float] = {
172 key: config[value]
for key, value
in CONF_PRESETS.items()
if value
in config
174 precision: float |
None = config.get(CONF_PRECISION)
175 target_temperature_step: float |
None = config.get(CONF_TEMP_STEP)
176 unit = hass.config.units.temperature_unit
196 target_temperature_step,
205 """Representation of a Generic Thermostat device."""
207 _attr_should_poll =
False
208 _enable_turn_on_off_backwards_compatibility =
False
214 heater_entity_id: str,
215 sensor_entity_id: str,
216 min_temp: float |
None,
217 max_temp: float |
None,
218 target_temp: float |
None,
219 ac_mode: bool |
None,
220 min_cycle_duration: timedelta |
None,
221 cold_tolerance: float,
222 hot_tolerance: float,
223 keep_alive: timedelta |
None,
224 initial_hvac_mode: HVACMode |
None,
225 presets: dict[str, float],
226 precision: float |
None,
227 target_temperature_step: float |
None,
228 unit: UnitOfTemperature,
229 unique_id: str |
None,
231 """Initialize the thermostat."""
253 self.
_cur_temp_cur_temp: float |
None =
None
262 ClimateEntityFeature.TARGET_TEMPERATURE
263 | ClimateEntityFeature.TURN_OFF
264 | ClimateEntityFeature.TURN_ON
274 """Run when entity about to be added."""
297 def _async_startup(_: Event |
None =
None) ->
None:
298 """Init on startup."""
300 if sensor_state
and sensor_state.state
not in (
307 if switch_state
and switch_state.state
not in (
311 self.
hasshass.async_create_task(
315 if self.
hasshass.state
is CoreState.running:
318 self.
hasshass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup)
325 if old_state.attributes.get(ATTR_TEMPERATURE)
is None:
331 "Undefined target temperature, falling back to %s",
338 and old_state.attributes.get(ATTR_PRESET_MODE)
in self.
preset_modespreset_modes
340 self.
_attr_preset_mode_attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE)
341 if not self.
_hvac_mode_hvac_mode
and old_state.state:
342 self.
_hvac_mode_hvac_mode = HVACMode(old_state.state)
352 "No previously saved temperature, setting to %s", self.
_target_temp_target_temp
361 """Return the precision of the system."""
364 return super().precision
368 """Return the supported step of target temperature."""
376 """Return the sensor temperature."""
381 """Return current operation."""
386 """Return the current running hvac operation if supported.
388 Need to be one of CURRENT_HVAC_*.
391 return HVACAction.OFF
393 return HVACAction.IDLE
395 return HVACAction.COOLING
396 return HVACAction.HEATING
400 """Return the temperature we try to reach."""
405 if hvac_mode == HVACMode.HEAT:
408 elif hvac_mode == HVACMode.COOL:
411 elif hvac_mode == HVACMode.OFF:
416 _LOGGER.error(
"Unrecognized hvac mode: %s", hvac_mode)
422 """Set new target temperature."""
423 if (temperature := kwargs.get(ATTR_TEMPERATURE))
is None:
431 """Return the minimum temperature."""
436 return super().min_temp
440 """Return the maximum temperature."""
445 return super().max_temp
448 """Handle temperature changes."""
449 new_state = event.data[
"new_state"]
450 if new_state
is None or new_state.state
in (STATE_UNAVAILABLE, STATE_UNKNOWN):
458 """Prevent the device from keep running if HVACMode.OFF."""
462 "The climate mode is OFF, but the switch device is ON. Turning off"
471 """Handle heater switch state changes."""
472 new_state = event.data[
"new_state"]
473 old_state = event.data[
"old_state"]
474 if new_state
is None:
476 if old_state
is None:
477 self.
hasshass.async_create_task(
484 """Update thermostat with latest state from sensor."""
486 cur_temp =
float(state.state)
487 if not math.isfinite(cur_temp):
488 raise ValueError(f
"Sensor has illegal state {state.state}")
490 except ValueError
as ex:
491 _LOGGER.error(
"Unable to update from sensor: %s", ex)
494 self, time: datetime |
None =
None, force: bool =
False
496 """Check if we need to turn heating on or off."""
498 if not self.
_active_active
and None not in (
505 "Obtained current and target temperature. "
506 "Generic thermostat active. %s, %s"
521 current_state = STATE_ON
523 current_state = HVACMode.OFF
525 long_enough = condition.state(
531 except ConditionError:
541 if (self.
ac_modeac_mode
and too_cold)
or (
not self.
ac_modeac_mode
and too_hot):
542 _LOGGER.debug(
"Turning off heater %s", self.
heater_entity_idheater_entity_id)
544 elif time
is not None:
547 "Keep-alive - Turning on heater heater %s",
551 elif (self.
ac_modeac_mode
and too_hot)
or (
not self.
ac_modeac_mode
and too_cold):
552 _LOGGER.debug(
"Turning on heater %s", self.
heater_entity_idheater_entity_id)
554 elif time
is not None:
563 """If the toggleable device is currently active."""
570 """Turn heater toggleable device on."""
572 await self.
hasshass.services.async_call(
573 HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=self.
_context_context
577 """Turn heater toggleable device off."""
579 await self.
hasshass.services.async_call(
580 HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=self.
_context_context
584 """Set new preset mode."""
585 if preset_mode
not in (self.
preset_modespreset_modes
or []):
587 f
"Got unsupported preset_mode {preset_mode}. Must be one of"
588 f
" {self.preset_modes}"
593 if preset_mode == PRESET_NONE:
list[str]|None preset_modes(self)
None async_set_temperature(self, **Any kwargs)
float|None current_temperature(self)
None _async_heater_turn_off(self)
None _async_heater_turn_on(self)
None _async_switch_changed(self, Event[EventStateChangedData] event)
None async_set_preset_mode(self, str preset_mode)
float target_temperature_step(self)
None _async_update_temp(self, State state)
bool|None _is_device_active(self)
None _async_sensor_changed(self, Event[EventStateChangedData] event)
float|None target_temperature(self)
HVACAction hvac_action(self)
None _check_switch_initial_state(self)
None __init__(self, HomeAssistant hass, str name, str heater_entity_id, str sensor_entity_id, float|None min_temp, float|None max_temp, float|None target_temp, bool|None ac_mode, timedelta|None min_cycle_duration, float cold_tolerance, float hot_tolerance, timedelta|None keep_alive, HVACMode|None initial_hvac_mode, dict[str, float] presets, float|None precision, float|None target_temperature_step, UnitOfTemperature unit, str|None unique_id)
None _async_control_heating(self, datetime|None time=None, bool force=False)
None async_set_hvac_mode(self, HVACMode hvac_mode)
_temp_target_temperature_step
None async_added_to_hass(self)
None async_write_ha_state(self)
None async_on_remove(self, CALLBACK_TYPE func)
State|None async_get_last_state(self)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
None _async_setup_config(HomeAssistant hass, Mapping[str, Any] config, str|None unique_id, AddEntitiesCallback async_add_entities)
dr.DeviceInfo|None async_device_info_to_link_from_entity(HomeAssistant hass, str entity_id_or_uuid)
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
None async_setup_reload_service(HomeAssistant hass, str domain, Iterable[str] platforms)