Files
ha_config/custom_components/better_thermostat/climate.py
T
2025-11-20 20:09:50 +01:00

1405 lines
51 KiB
Python

"""Better Thermostat"""
import asyncio
import json
import logging
from abc import ABC
from datetime import datetime, timedelta
from random import randint
from statistics import mean
# Home Assistant imports
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
PRESET_NONE,
)
from homeassistant.components.climate.const import (
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_TARGET_TEMP_STEP,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.components.group.util import reduce_attribute
from homeassistant.const import (
ATTR_TEMPERATURE,
CONF_NAME,
EVENT_HOMEASSISTANT_START,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Context, CoreState, ServiceCall, callback
from homeassistant.helpers import entity_platform
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_change,
async_track_time_interval,
)
from homeassistant.helpers.restore_state import RestoreEntity
# Local imports
from .adapters.delegate import (
get_current_offset,
get_max_offset,
get_min_offset,
get_offset_step,
init,
load_adapter,
)
from .events.cooler import trigger_cooler_change
from .events.temperature import trigger_temperature_change
from .events.trv import trigger_trv_change
from .events.window import trigger_window_change, window_queue
from .model_fixes.model_quirks import load_model_quirks
from .utils.const import (
ATTR_STATE_BATTERIES,
ATTR_STATE_CALL_FOR_HEAT,
ATTR_STATE_ERRORS,
ATTR_STATE_HEATING_POWER,
ATTR_STATE_HUMIDIY,
ATTR_STATE_LAST_CHANGE,
ATTR_STATE_MAIN_MODE,
ATTR_STATE_SAVED_TEMPERATURE,
ATTR_STATE_WINDOW_OPEN,
BETTERTHERMOSTAT_SET_TEMPERATURE_SCHEMA,
CONF_COOLER,
CONF_HEATER,
CONF_HUMIDITY,
CONF_MODEL,
CONF_OFF_TEMPERATURE,
CONF_OUTDOOR_SENSOR,
CONF_SENSOR,
CONF_SENSOR_WINDOW,
CONF_TARGET_TEMP_STEP,
CONF_TOLERANCE,
CONF_WEATHER,
CONF_WINDOW_TIMEOUT,
CONF_WINDOW_TIMEOUT_AFTER,
SERVICE_RESET_HEATING_POWER,
SERVICE_RESTORE_SAVED_TARGET_TEMPERATURE,
SERVICE_SET_TEMP_TARGET_TEMPERATURE,
SUPPORT_FLAGS,
VERSION,
)
from .utils.controlling import control_queue, control_trv
from .utils.helpers import convert_to_float, find_battery_entity, get_hvac_bt_mode
from .utils.watcher import check_all_entities
from .utils.weather import check_ambient_air_temperature, check_weather
_LOGGER = logging.getLogger(__name__)
DOMAIN = "better_thermostat"
class ContinueLoop(Exception):
pass
@callback
def async_set_temperature_service_validate(service_call: ServiceCall) -> ServiceCall:
"""Validate temperature inputs for set_temperature service."""
if ATTR_TEMPERATURE in service_call.data:
temp = service_call.data[ATTR_TEMPERATURE]
if not isinstance(temp, (int, float)):
raise ValueError(f"Invalid temperature value {temp}, must be numeric")
if ATTR_TARGET_TEMP_HIGH in service_call.data:
temp_high = service_call.data[ATTR_TARGET_TEMP_HIGH]
if not isinstance(temp_high, (int, float)):
raise ValueError(
f"Invalid target high temperature value {temp_high}, must be numeric"
)
if ATTR_TARGET_TEMP_LOW in service_call.data:
temp_low = service_call.data[ATTR_TARGET_TEMP_LOW]
if not isinstance(temp_low, (int, float)):
raise ValueError(
f"Invalid target low temperature value {temp_low}, must be numeric"
)
return service_call
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Better Thermostat platform."""
platform = entity_platform.async_get_current_platform()
# Register service validators
platform.async_register_service_validator(
"set_temperature", async_set_temperature_service_validate
)
async def async_setup_entry(hass, entry, async_add_devices):
"""Setup sensor platform."""
async def async_service_handler(entity, call):
"""Handle the service calls."""
if call.service == SERVICE_RESTORE_SAVED_TARGET_TEMPERATURE:
await entity.restore_temp_temperature()
elif call.service == SERVICE_SET_TEMP_TARGET_TEMPERATURE:
await entity.set_temp_temperature(call.data[ATTR_TEMPERATURE])
elif call.service == SERVICE_RESET_HEATING_POWER:
await entity.reset_heating_power()
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_TEMP_TARGET_TEMPERATURE,
BETTERTHERMOSTAT_SET_TEMPERATURE_SCHEMA,
"set_temp_temperature",
)
platform.async_register_entity_service(
SERVICE_RESTORE_SAVED_TARGET_TEMPERATURE, {}, "restore_temp_temperature"
)
platform.async_register_entity_service(
SERVICE_RESET_HEATING_POWER, {}, "reset_heating_power"
)
async_add_devices(
[
BetterThermostat(
entry.data.get(CONF_NAME),
entry.data.get(CONF_HEATER),
entry.data.get(CONF_SENSOR),
entry.data.get(CONF_HUMIDITY, None),
entry.data.get(CONF_SENSOR_WINDOW, None),
entry.data.get(CONF_WINDOW_TIMEOUT, None),
entry.data.get(CONF_WINDOW_TIMEOUT_AFTER, None),
entry.data.get(CONF_WEATHER, None),
entry.data.get(CONF_OUTDOOR_SENSOR, None),
entry.data.get(CONF_OFF_TEMPERATURE, None),
entry.data.get(CONF_TOLERANCE, 0.0),
entry.data.get(CONF_TARGET_TEMP_STEP, "0.0"),
entry.data.get(CONF_MODEL, None),
entry.data.get(CONF_COOLER, None),
hass.config.units.temperature_unit,
entry.entry_id,
device_class="better_thermostat",
state_class="better_thermostat_state",
)
]
)
class BetterThermostat(ClimateEntity, RestoreEntity, ABC):
"""Representation of a Better Thermostat device."""
_attr_has_entity_name = True
_attr_name = None
_enable_turn_on_off_backwards_compatibility = False
async def set_temp_temperature(self, temperature):
"""Set temporary target temperature."""
if self._saved_temperature is None:
self._saved_temperature = self.bt_target_temp
self.bt_target_temp = convert_to_float(
temperature, self.device_name, "service.set_temp_temperature()"
)
self.async_write_ha_state()
await self.control_queue_task.put(self)
else:
self.bt_target_temp = convert_to_float(
temperature, self.device_name, "service.set_temp_temperature()"
)
self.async_write_ha_state()
await self.control_queue_task.put(self)
async def restore_temp_temperature(self):
"""Restore the previously saved target temperature."""
if self._saved_temperature is not None:
self.bt_target_temp = convert_to_float(
self._saved_temperature,
self.device_name,
"service.restore_temp_temperature()",
)
self._saved_temperature = None
self.async_write_ha_state()
await self.control_queue_task.put(self)
async def reset_heating_power(self):
"""Reset heating power to default value."""
self.heating_power = 0.01
self.async_write_ha_state()
@property
def device_info(self):
return {
"identifiers": {(DOMAIN, self.unique_id)},
"name": self.device_name,
"manufacturer": "Better Thermostat",
"model": self.model,
"sw_version": VERSION,
}
def __init__(
self,
name,
heater_entity_id,
sensor_entity_id,
humidity_sensor_entity_id,
window_id,
window_delay,
window_delay_after,
weather_entity,
outdoor_sensor,
off_temperature,
tolerance,
target_temp_step,
model,
cooler_entity_id,
unit,
unique_id,
device_class,
state_class,
):
"""Initialize the thermostat.
Parameters
----------
TODO
"""
self.device_name = name
self.model = model
self.real_trvs = {}
self.entity_ids = []
self.all_trvs = heater_entity_id
self.sensor_entity_id = sensor_entity_id
self.humidity_entity_id = humidity_sensor_entity_id
self.cooler_entity_id = cooler_entity_id
self.window_id = window_id or None
self.window_delay = window_delay or 0
self.window_delay_after = window_delay_after or 0
self.weather_entity = weather_entity or None
self.outdoor_sensor = outdoor_sensor or None
self.off_temperature = float(off_temperature) or None
self.tolerance = float(tolerance) or 0.0
self._unique_id = unique_id
self._unit = unit
self._device_class = device_class
self._state_class = state_class
self._hvac_list = [HVACMode.HEAT, HVACMode.OFF]
self._preset_mode = PRESET_NONE
self.map_on_hvac_mode = HVACMode.HEAT
self.next_valve_maintenance = datetime.now() + timedelta(
hours=randint(1, 24 * 5)
)
self.cur_temp = None
self._current_humidity = 0
self.window_open = None
self.bt_target_temp_step = float(target_temp_step) or 0.0
self.bt_min_temp = 0
self.bt_max_temp = 30
self.bt_target_temp = 5.0
self.bt_target_cooltemp = None
self._support_flags = SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE
self.bt_hvac_mode = None
self.closed_window_triggered = False
self.call_for_heat = True
self.ignore_states = False
self.last_dampening_timestamp = None
self.version = VERSION
self.last_change = datetime.now() - timedelta(hours=2)
self.last_external_sensor_change = datetime.now() - timedelta(hours=2)
self.last_internal_sensor_change = datetime.now() - timedelta(hours=2)
self._temp_lock = asyncio.Lock()
self.startup_running = True
self._saved_temperature = None
self.last_avg_outdoor_temp = None
self.last_main_hvac_mode = None
self.last_window_state = None
self._last_call_for_heat = None
self._available = False
self.context = None
self.attr_hvac_action = None
self.old_attr_hvac_action = None
self.heating_start_temp = None
self.heating_start_timestamp = None
self.heating_end_temp = None
self.heating_end_timestamp = None
self._async_unsub_state_changed = None
self.all_entities = []
self.devices_states = {}
self.devices_errors = []
self.control_queue_task = asyncio.Queue(maxsize=1)
if self.window_id is not None:
self.window_queue_task = asyncio.Queue(maxsize=1)
asyncio.create_task(control_queue(self))
if self.window_id is not None:
asyncio.create_task(window_queue(self))
self.heating_power = 0.01
self.last_heating_power_stats = []
self.is_removed = False
async def async_added_to_hass(self):
"""Run when entity about to be added.
Returns
-------
None
"""
if isinstance(self.all_trvs, str):
return _LOGGER.error(
"You updated from version before 1.0.0-Beta36 of the Better Thermostat integration, you need to remove the BT devices (integration) and add it again."
)
if self.cooler_entity_id is not None:
self._hvac_list.remove(HVACMode.HEAT)
self._hvac_list.append(HVACMode.HEAT_COOL)
self.map_on_hvac_mode = HVACMode.HEAT_COOL
self.entity_ids = [
entity for trv in self.all_trvs if (entity := trv["trv"]) is not None
]
for trv in self.all_trvs:
_calibration = 1
if trv["advanced"]["calibration"] == "local_calibration_based":
_calibration = 0
if trv["advanced"]["calibration"] == "hybrid_calibration":
_calibration = 2
_adapter = await load_adapter(self, trv["integration"], trv["trv"])
_model_quirks = await load_model_quirks(self, trv["model"], trv["trv"])
self.real_trvs[trv["trv"]] = {
"calibration": _calibration,
"integration": trv["integration"],
"adapter": _adapter,
"model_quirks": _model_quirks,
"model": trv["model"],
"advanced": trv["advanced"],
"ignore_trv_states": False,
"valve_position": None,
"valve_position_entity": None,
"max_temp": None,
"min_temp": None,
"target_temp_step": None,
"temperature": None,
"current_temperature": None,
"hvac_modes": None,
"hvac_mode": None,
"local_temperature_calibration_entity": None,
"local_calibration_min": None,
"local_calibration_max": None,
"calibration_received": True,
"target_temp_received": True,
"system_mode_received": True,
"last_temperature": None,
"last_valve_position": None,
"last_hvac_mode": None,
"last_current_temperature": None,
"last_calibration": None,
}
def on_remove():
self.is_removed = True
self.async_on_remove(on_remove)
await super().async_added_to_hass()
_LOGGER.info(
"better_thermostat %s: Waiting for entity to be ready...", self.device_name
)
@callback
def _async_startup(*_):
"""Init on startup.
Parameters
----------
_ :
All parameters are piped.
"""
self.context = Context()
loop = asyncio.get_event_loop()
loop.create_task(self.startup())
if self.hass.state == CoreState.running:
_async_startup()
else:
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup)
async def _trigger_check_weather(self, event=None):
_check = await check_all_entities(self)
if _check is False:
return
await check_weather(self)
if self._last_call_for_heat != self.call_for_heat:
self._last_call_for_heat = self.call_for_heat
await self.async_update_ha_state(force_refresh=True)
self.async_write_ha_state()
if event is not None:
await self.control_queue_task.put(self)
async def _trigger_time(self, event=None):
_check = await check_all_entities(self)
if _check is False:
return
_LOGGER.debug(
"better_thermostat %s: get last avg outdoor temps...", self.device_name
)
await check_ambient_air_temperature(self)
self.async_write_ha_state()
if event is not None:
await self.control_queue_task.put(self)
async def _trigger_temperature_change(self, event):
_check = await check_all_entities(self)
if _check is False:
return
self.async_set_context(event.context)
if (event.data.get("new_state")) is None:
return
self.hass.async_create_task(trigger_temperature_change(self, event))
async def _trigger_humidity_change(self, event):
_check = await check_all_entities(self)
if _check is False:
return
self.async_set_context(event.context)
if (event.data.get("new_state")) is None:
return
self._current_humidity = convert_to_float(
str(self.hass.states.get(self.humidity_entity_id).state),
self.device_name,
"humidity_update",
)
self.async_write_ha_state()
async def _trigger_trv_change(self, event):
_check = await check_all_entities(self)
if _check is False:
return
self.async_set_context(event.context)
if self._async_unsub_state_changed is None:
return
if (event.data.get("new_state")) is None:
return
self.hass.async_create_task(trigger_trv_change(self, event))
async def _trigger_window_change(self, event):
_check = await check_all_entities(self)
if _check is False:
return
self.async_set_context(event.context)
if (event.data.get("new_state")) is None:
return
self.hass.async_create_task(trigger_window_change(self, event))
async def _tigger_cooler_change(self, event):
_check = await check_all_entities(self)
if _check is False:
return
self.async_set_context(event.context)
if (event.data.get("new_state")) is None:
return
self.hass.async_create_task(trigger_cooler_change(self, event))
async def startup(self):
"""Run when entity about to be added.
Returns
-------
None
"""
while self.startup_running:
_LOGGER.info(
"better_thermostat %s: Starting version %s. Waiting for entity to be ready...",
self.device_name,
self.version,
)
sensor_state = self.hass.states.get(self.sensor_entity_id)
if sensor_state is not None:
if sensor_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
_LOGGER.info(
"better_thermostat %s: waiting for sensor entity with id '%s' to become fully available...",
self.device_name,
self.sensor_entity_id,
)
await asyncio.sleep(10)
continue
try:
for trv in self.real_trvs.keys():
trv_state = self.hass.states.get(trv)
if trv_state is None:
_LOGGER.info(
"better_thermostat %s: waiting for TRV/climate entity with id '%s' to become fully available...",
self.device_name,
trv,
)
await asyncio.sleep(10)
raise ContinueLoop
if trv_state is not None:
if trv_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
_LOGGER.info(
"better_thermostat %s: waiting for TRV/climate entity with id '%s' to become fully available...",
self.device_name,
trv,
)
await asyncio.sleep(10)
raise ContinueLoop
except ContinueLoop:
continue
if self.window_id is not None:
if self.hass.states.get(self.window_id).state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
):
_LOGGER.info(
"better_thermostat %s: waiting for window sensor entity with id '%s' to become fully available...",
self.device_name,
self.window_id,
)
await asyncio.sleep(10)
continue
if self.cooler_entity_id is not None:
if self.hass.states.get(self.cooler_entity_id).state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
):
_LOGGER.info(
"better_thermostat %s: waiting for cooler entity with id '%s' to become fully available...",
self.device_name,
self.cooler_entity_id,
)
await asyncio.sleep(10)
continue
if self.humidity_entity_id is not None:
humidity_state = self.hass.states.get(self.humidity_entity_id)
if humidity_state is None or humidity_state.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
):
_LOGGER.info(
"better_thermostat %s: waiting for humidity sensor entity with id '%s' to become fully available...",
self.device_name,
self.humidity_entity_id,
)
await asyncio.sleep(10)
continue
if self.outdoor_sensor is not None:
if self.hass.states.get(self.outdoor_sensor).state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
):
_LOGGER.info(
"better_thermostat %s: waiting for outdoor sensor entity with id '%s' to become fully available...",
self.device_name,
self.outdoor_sensor,
)
await asyncio.sleep(10)
continue
if self.weather_entity is not None:
if self.hass.states.get(self.weather_entity).state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
None,
):
_LOGGER.info(
"better_thermostat %s: waiting for weather entity with id '%s' to become fully available...",
self.device_name,
self.weather_entity,
)
await asyncio.sleep(10)
continue
states = [
state
for entity_id in self.real_trvs
if (state := self.hass.states.get(entity_id)) is not None
]
self.bt_min_temp = reduce_attribute(states, ATTR_MIN_TEMP, reduce=max)
self.bt_max_temp = reduce_attribute(states, ATTR_MAX_TEMP, reduce=min)
if self.bt_target_temp_step == 0.0:
self.bt_target_temp_step = reduce_attribute(
states, ATTR_TARGET_TEMP_STEP, reduce=max
)
self.all_entities.append(self.sensor_entity_id)
self.cur_temp = convert_to_float(
str(sensor_state.state), self.device_name, "startup()"
)
if self.humidity_entity_id is not None:
self.all_entities.append(self.humidity_entity_id)
self._current_humidity = convert_to_float(
str(self.hass.states.get(self.humidity_entity_id).state),
self.device_name,
"startup()",
)
if self.cooler_entity_id is not None:
self.bt_target_cooltemp = convert_to_float(
str(
self.hass.states.get(self.cooler_entity_id).attributes.get(
"temperature"
)
),
self.device_name,
"startup()",
)
if self.window_id is not None:
self.all_entities.append(self.window_id)
window = self.hass.states.get(self.window_id)
check = window.state
if check in ("on", "open", "true"):
self.window_open = True
else:
self.window_open = False
_LOGGER.debug(
"better_thermostat %s: detected window state at startup: %s",
self.device_name,
"Open" if self.window_open else "Closed",
)
else:
self.window_open = False
# Check If we have an old state
old_state = await self.async_get_last_state()
if old_state is not None:
# If we have no initial temperature, restore
# If we have a previously saved temperature
if old_state.attributes.get(ATTR_TEMPERATURE) is None:
self.bt_target_temp = reduce_attribute(
states, ATTR_TEMPERATURE, reduce=lambda *data: mean(data)
)
_LOGGER.debug(
"better_thermostat %s: Undefined target temperature, falling back to %s",
self.device_name,
self.bt_target_temp,
)
else:
_oldtarget_temperature = float(
old_state.attributes.get(ATTR_TEMPERATURE)
)
# if the saved temperature is lower than the min_temp, set it to min_temp
if _oldtarget_temperature < self.bt_min_temp:
_LOGGER.warning(
"better_thermostat %s: Saved target temperature %s is lower than min_temp %s, setting to min_temp",
self.device_name,
_oldtarget_temperature,
self.bt_min_temp,
)
_oldtarget_temperature = self.bt_min_temp
# if the saved temperature is higher than the max_temp, set it to max_temp
elif _oldtarget_temperature > self.bt_max_temp:
_LOGGER.warning(
"better_thermostat %s: Saved target temperature %s is higher than max_temp %s, setting to max_temp",
self.device_name,
_oldtarget_temperature,
self.bt_min_temp,
)
_oldtarget_temperature = self.bt_max_temp
self.bt_target_temp = convert_to_float(
str(_oldtarget_temperature), self.device_name, "startup()"
)
if old_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
self.bt_hvac_mode = old_state.state
if old_state.attributes.get(ATTR_STATE_CALL_FOR_HEAT, None) is not None:
self.call_for_heat = old_state.attributes.get(
ATTR_STATE_CALL_FOR_HEAT
)
if (
old_state.attributes.get(ATTR_STATE_SAVED_TEMPERATURE, None)
is not None
):
self._saved_temperature = convert_to_float(
str(
old_state.attributes.get(ATTR_STATE_SAVED_TEMPERATURE, None)
),
self.device_name,
"startup()",
)
if old_state.attributes.get(ATTR_STATE_HUMIDIY, None) is not None:
self._current_humidity = old_state.attributes.get(
ATTR_STATE_HUMIDIY
)
if old_state.attributes.get(ATTR_STATE_MAIN_MODE, None) is not None:
self.last_main_hvac_mode = old_state.attributes.get(
ATTR_STATE_MAIN_MODE
)
if old_state.attributes.get(ATTR_STATE_HEATING_POWER, None) is not None:
self.heating_power = float(
old_state.attributes.get(ATTR_STATE_HEATING_POWER)
)
else:
# No previous state, try and restore defaults
if self.bt_target_temp is None or not isinstance(
self.bt_target_temp, float
):
_LOGGER.info(
"better_thermostat %s: No previously saved temperature found on startup, get it from the TRV",
self.device_name,
)
self.bt_target_temp = reduce_attribute(
states, ATTR_TEMPERATURE, reduce=lambda *data: mean(data)
)
# if hvac mode could not be restored, turn heat off
if self.bt_hvac_mode in (STATE_UNAVAILABLE, STATE_UNKNOWN, None):
current_hvac_modes = [
x.state for x in states if x.state != HVACMode.OFF
]
# return the most common hvac mode (what the thermostat is set to do) except OFF
if current_hvac_modes:
_temp_bt_hvac_mode = max(
set(current_hvac_modes), key=current_hvac_modes.count
)
if _temp_bt_hvac_mode is not HVACMode.OFF:
self.bt_hvac_mode = HVACMode.HEAT
else:
self.bt_hvac_mode = HVACMode.OFF
_LOGGER.debug(
"better_thermostat %s: No previously hvac mode found on startup, turn bt to trv mode %s",
self.device_name,
self.bt_hvac_mode,
)
# return off if all are off
elif all(x.state == HVACMode.OFF for x in states):
self.bt_hvac_mode = HVACMode.OFF
_LOGGER.debug(
"better_thermostat %s: No previously hvac mode found on startup, turn bt to trv mode %s",
self.device_name,
self.bt_hvac_mode,
)
else:
_LOGGER.warning(
"better_thermostat %s: No previously hvac mode found on startup, turn heat off",
self.device_name,
)
self.bt_hvac_mode = HVACMode.OFF
_LOGGER.debug(
"better_thermostat %s: Startup config, BT hvac mode is %s, Target temp %s",
self.device_name,
self.bt_hvac_mode,
self.bt_target_temp,
)
if self.last_main_hvac_mode is None:
self.last_main_hvac_mode = self.bt_hvac_mode
if self.humidity_entity_id is not None:
self._current_humidity = convert_to_float(
str(self.hass.states.get(self.humidity_entity_id).state),
self.device_name,
"startup()",
)
else:
self._current_humidity = 0
self.last_window_state = self.window_open
if self.bt_hvac_mode not in (
HVACMode.OFF,
HVACMode.HEAT_COOL,
HVACMode.HEAT,
):
self.bt_hvac_mode = HVACMode.HEAT
self.async_write_ha_state()
for trv in self.real_trvs.keys():
self.all_entities.append(trv)
await init(self, trv)
if self.real_trvs[trv]["calibration"] != 1:
self.real_trvs[trv]["last_calibration"] = await get_current_offset(
self, trv
)
self.real_trvs[trv]["local_calibration_min"] = await get_min_offset(
self, trv
)
self.real_trvs[trv]["local_calibration_max"] = await get_max_offset(
self, trv
)
self.real_trvs[trv]["local_calibration_step"] = (
await get_offset_step(self, trv)
)
else:
self.real_trvs[trv]["last_calibration"] = 0
self.real_trvs[trv]["valve_position"] = convert_to_float(
str(
self.hass.states.get(trv).attributes.get("valve_position", None)
),
self.device_name,
"startup",
)
self.real_trvs[trv]["max_temp"] = convert_to_float(
str(self.hass.states.get(trv).attributes.get("max_temp", 30)),
self.device_name,
"startup",
)
self.real_trvs[trv]["min_temp"] = convert_to_float(
str(self.hass.states.get(trv).attributes.get("min_temp", 5)),
self.device_name,
"startup",
)
self.real_trvs[trv]["target_temp_step"] = convert_to_float(
str(
self.hass.states.get(trv).attributes.get(
"target_temp_step", 0.5
)
),
self.device_name,
"startup",
)
self.real_trvs[trv]["temperature"] = convert_to_float(
str(self.hass.states.get(trv).attributes.get("temperature", 5)),
self.device_name,
"startup",
)
self.real_trvs[trv]["hvac_modes"] = self.hass.states.get(
trv
).attributes.get("hvac_modes", None)
self.real_trvs[trv]["hvac_mode"] = self.hass.states.get(trv).state
self.real_trvs[trv]["last_hvac_mode"] = self.hass.states.get(trv).state
self.real_trvs[trv]["last_temperature"] = convert_to_float(
str(self.hass.states.get(trv).attributes.get("temperature")),
self.device_name,
"startup()",
)
self.real_trvs[trv]["current_temperature"] = convert_to_float(
str(
self.hass.states.get(trv).attributes.get("current_temperature")
or 5
),
self.device_name,
"startup()",
)
await control_trv(self, trv)
await self._trigger_time(None)
await self._trigger_check_weather(None)
self.startup_running = False
self._available = True
self.async_write_ha_state()
#
await asyncio.sleep(5)
# try to find battery entities for all related entities
for entity in self.all_entities:
if entity is not None:
battery_id = await find_battery_entity(self, entity)
if battery_id is not None:
self.devices_states[entity] = {
"battery_id": battery_id,
"battery": None,
}
if self.is_removed:
return
# update_hvac_action(self)
# Add listener
if self.outdoor_sensor is not None:
self.all_entities.append(self.outdoor_sensor)
self.async_on_remove(
async_track_time_change(self.hass, self._trigger_time, 5, 0, 0)
)
await check_all_entities(self)
if self.is_removed:
return
self.async_on_remove(
async_track_time_interval(
self.hass, self._trigger_check_weather, timedelta(hours=1)
)
)
self.async_on_remove(
async_track_state_change_event(
self.hass, [self.sensor_entity_id], self._trigger_temperature_change
)
)
if self.humidity_entity_id is not None:
self.async_on_remove(
async_track_state_change_event(
self.hass,
[self.humidity_entity_id],
self._trigger_humidity_change,
)
)
if self._async_unsub_state_changed is None:
self._async_unsub_state_changed = async_track_state_change_event(
self.hass, self.entity_ids, self._trigger_trv_change
)
self.async_on_remove(self._async_unsub_state_changed)
if self.window_id is not None:
self.async_on_remove(
async_track_state_change_event(
self.hass, [self.window_id], self._trigger_window_change
)
)
if self.cooler_entity_id is not None:
self.async_on_remove(
async_track_state_change_event(
self.hass, [self.cooler_entity_id], self._tigger_cooler_change
)
)
_LOGGER.info("better_thermostat %s: startup completed.", self.device_name)
self.async_write_ha_state()
await self.async_update_ha_state(force_refresh=True)
break
async def calculate_heating_power(self):
if not hasattr(self, "min_target_temp"):
self.min_target_temp = self.bt_target_temp or 18.0
if not hasattr(self, "max_target_temp"):
self.max_target_temp = self.bt_target_temp or 21.0
if (
self.heating_start_temp is not None
and self.heating_end_temp is not None
and self.cur_temp < self.heating_end_temp
):
_temp_diff = self.heating_end_temp - self.heating_start_temp
_time_diff_minutes = round(
(self.heating_end_timestamp - self.heating_start_timestamp).seconds
/ 60.0,
1,
)
if _time_diff_minutes > 1:
temp_range = max(self.max_target_temp - self.min_target_temp, 0.1)
weight_factor = max(
0.5,
min(
1.5,
0.5 + (self.bt_target_temp - self.min_target_temp) / temp_range,
),
)
_degrees_time = round(_temp_diff / _time_diff_minutes, 4)
self.heating_power = round(
(
self.heating_power * (1 - 0.1 * weight_factor)
+ _degrees_time * 0.1 * weight_factor
),
4,
)
if len(self.last_heating_power_stats) >= 10:
self.last_heating_power_stats = self.last_heating_power_stats[
len(self.last_heating_power_stats) - 9 :
]
self.last_heating_power_stats.append(
{
"temp_diff": round(_temp_diff, 1),
"time": _time_diff_minutes,
"degrees_time": round(_degrees_time, 4),
"weight_factor": weight_factor,
"heating_power": round(self.heating_power, 4),
}
)
_LOGGER.debug(
f"better_thermostat {self.device_name}: calculate_heating_power / "
f"temp_diff: {round(_temp_diff, 1)} - time: {_time_diff_minutes} - "
f"degrees_time: {round(_degrees_time, 4)} - weight_factor: {weight_factor} - "
f"heating_power: {round(self.heating_power, 4)} - "
f"heating_power_stats: {self.last_heating_power_stats}"
)
# reset for the next heating start
self.heating_start_temp = None
self.heating_end_temp = None
if self.heating_end_temp:
self.min_target_temp = min(self.min_target_temp, self.bt_target_temp)
self.max_target_temp = max(self.max_target_temp, self.bt_target_temp)
# heating starts
if (
self.attr_hvac_action == HVACAction.HEATING
and self.old_attr_hvac_action != HVACAction.HEATING
):
self.heating_start_temp = self.cur_temp
self.heating_start_timestamp = datetime.now()
# heating stops
elif (
self.attr_hvac_action != HVACAction.HEATING
and self.old_attr_hvac_action == HVACAction.HEATING
and self.heating_start_temp is not None
and self.heating_end_temp is None
):
self.heating_end_temp = self.cur_temp
self.heating_end_timestamp = datetime.now()
# check if the temp is still rising, after heating stopped
elif (
self.attr_hvac_action != HVACAction.HEATING
and self.heating_start_temp is not None
and self.heating_end_temp is not None
and self.cur_temp > self.heating_end_temp
):
self.heating_end_temp = self.cur_temp
if self.attr_hvac_action != self.old_attr_hvac_action:
self.old_attr_hvac_action = self.attr_hvac_action
self.async_write_ha_state()
@property
def extra_state_attributes(self) -> dict[str, any]:
"""Return the device specific state attributes.
Returns
-------
dict
Attribute dictionary for the extra device specific state attributes.
"""
dev_specific = {
ATTR_STATE_WINDOW_OPEN: self.window_open,
ATTR_STATE_CALL_FOR_HEAT: self.call_for_heat,
ATTR_STATE_LAST_CHANGE: self.last_change.isoformat(),
ATTR_STATE_SAVED_TEMPERATURE: self._saved_temperature,
ATTR_STATE_HUMIDIY: self._current_humidity,
ATTR_STATE_MAIN_MODE: self.last_main_hvac_mode,
CONF_TOLERANCE: self.tolerance,
CONF_TARGET_TEMP_STEP: self.bt_target_temp_step,
ATTR_STATE_HEATING_POWER: self.heating_power,
ATTR_STATE_ERRORS: json.dumps(self.devices_errors),
ATTR_STATE_BATTERIES: json.dumps(self.devices_states),
}
return dev_specific
@property
def available(self):
"""Return if thermostat is available.
Returns
-------
bool
True if the thermostat is available.
"""
return self._available
@property
def should_poll(self):
"""Return the polling state.
Returns
-------
bool
True if the thermostat uses polling.
"""
return False
@property
def unique_id(self):
"""Return the unique id of this thermostat.
Returns
-------
string
The unique id of this thermostat.
"""
return self._unique_id
@property
def precision(self):
"""Return the precision of the system.
Returns
-------
float
Precision of the thermostat.
"""
return super().precision
@property
def target_temperature_step(self) -> float | None:
"""Return the supported step of target temperature.
Returns
-------
float
Step size of target temperature.
"""
if self.bt_target_temp_step is not None:
return self.bt_target_temp_step
return super().precision
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
return self._unit
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self.cur_temp
@property
def current_humidity(self) -> float | None:
"""Return the current humidity if supported."""
return self._current_humidity if hasattr(self, "_current_humidity") else None
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current operation."""
return get_hvac_bt_mode(self, self.bt_hvac_mode)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available operation modes."""
return self._hvac_list
@property
def hvac_action(self):
"""Return the current HVAC action"""
if self.bt_target_temp is not None and self.cur_temp is not None:
if self.hvac_mode == HVACMode.OFF:
self.attr_hvac_action = HVACAction.OFF
elif (
self.bt_target_temp > self.cur_temp + self.tolerance
and self.window_open is False
):
self.attr_hvac_action = HVACAction.HEATING
elif (
self.bt_target_temp > self.cur_temp
and self.window_open is False
and self.bt_hvac_mode is not HVACMode.OFF
):
self.attr_hvac_action = HVACAction.HEATING
else:
self.attr_hvac_action = HVACAction.IDLE
return self.attr_hvac_action
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach.
Returns
-------
float
Target temperature.
"""
if None in (self.bt_max_temp, self.bt_min_temp, self.bt_target_temp):
return self.bt_target_temp
# if target temp is below minimum, return minimum
if self.bt_target_temp < self.bt_min_temp:
return self.bt_min_temp
# if target temp is above maximum, return maximum
if self.bt_target_temp > self.bt_max_temp:
return self.bt_max_temp
return self.bt_target_temp
@property
def target_temperature_low(self) -> float | None:
if self.cooler_entity_id is None:
return None
return self.bt_target_temp
@property
def target_temperature_high(self) -> float | None:
if self.cooler_entity_id is None:
return None
return self.bt_target_cooltemp
async def async_set_hvac_mode(self, hvac_mode: str) -> None:
"""Set hvac mode.
Returns
-------
None
"""
if hvac_mode in (HVACMode.HEAT, HVACMode.HEAT_COOL, HVACMode.OFF):
self.bt_hvac_mode = get_hvac_bt_mode(self, hvac_mode)
else:
_LOGGER.error(
"better_thermostat %s: Unsupported hvac_mode %s",
self.device_name,
hvac_mode,
)
self.async_write_ha_state()
await self.control_queue_task.put(self)
async def async_set_temperature(self, **kwargs) -> None:
"""Set new target temperature."""
if self.bt_hvac_mode == HVACMode.OFF:
return
_new_setpoint = None
_new_setpointlow = None
_new_setpointhigh = None
if ATTR_HVAC_MODE in kwargs:
hvac_mode = str(kwargs.get(ATTR_HVAC_MODE, None))
if hvac_mode in (HVACMode.HEAT, HVACMode.HEAT_COOL, HVACMode.OFF):
self.bt_hvac_mode = hvac_mode
else:
_LOGGER.error(
"better_thermostat %s: Unsupported hvac_mode %s",
self.device_name,
hvac_mode,
)
if ATTR_TEMPERATURE in kwargs:
_new_setpoint = convert_to_float(
str(kwargs.get(ATTR_TEMPERATURE, None)),
self.device_name,
"controlling.settarget_temperature()",
)
if ATTR_TARGET_TEMP_LOW in kwargs:
_new_setpointlow = convert_to_float(
str(kwargs.get(ATTR_TARGET_TEMP_LOW, None)),
self.device_name,
"controlling.settarget_temperature_low()",
)
if ATTR_TARGET_TEMP_HIGH in kwargs:
_new_setpointhigh = convert_to_float(
str(kwargs.get(ATTR_TARGET_TEMP_HIGH, None)),
self.device_name,
"controlling.settarget_temperature_high()",
)
if _new_setpoint is None and _new_setpointlow is None:
_LOGGER.debug(
f"better_thermostat {self.device_name}: received a new setpoint from HA, but temperature attribute was not set, ignoring"
)
return
# Validate against min/max temps
if _new_setpoint is not None:
_new_setpoint = min(self.max_temp, max(self.min_temp, _new_setpoint))
if _new_setpointlow is not None:
_new_setpointlow = min(self.max_temp, max(self.min_temp, _new_setpointlow))
if _new_setpointhigh is not None:
_new_setpointhigh = min(
self.max_temp, max(self.min_temp, _new_setpointhigh)
)
self.bt_target_temp = _new_setpoint or _new_setpointlow
if _new_setpointhigh is not None:
self.bt_target_cooltemp = _new_setpointhigh
_LOGGER.debug(
"better_thermostat %s: HA set target temperature to %s & %s",
self.device_name,
self.bt_target_temp,
self.bt_target_cooltemp,
)
self.async_write_ha_state()
await self.control_queue_task.put(self)
async def async_turn_off(self) -> None:
await self.async_set_hvac_mode(HVACMode.OFF)
async def async_turn_on(self) -> None:
await self.async_set_hvac_mode(HVACMode.HEAT)
@property
def min_temp(self):
"""Return the minimum temperature.
Returns
-------
float
the minimum temperature.
"""
if self.bt_min_temp is not None:
return self.bt_min_temp
# get default temp from super class
return super().min_temp
@property
def max_temp(self):
"""Return the maximum temperature.
Returns
-------
float
the maximum temperature.
"""
if self.bt_max_temp is not None:
return self.bt_max_temp
# Get default temp from super class
return super().max_temp
@property
def _is_device_active(self):
"""Get the current state of the device for HA.
Returns
-------
string
State of the device.
"""
if self.bt_hvac_mode == HVACMode.OFF:
return False
if self.window_open:
return False
return True
@property
def supported_features(self):
"""Return the list of supported features.
Returns
-------
array
Supported features.
"""
if self.cooler_entity_id is not None:
return (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
return (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
@property
def preset_mode(self):
return self._preset_mode
def set_preset_mode(self, preset_mode: str) -> None:
self._preset_mode = preset_mode
@property
def preset_modes(self):
return [
PRESET_NONE,
# PRESET_AWAY,
# PRESET_ECO,
# PRESET_COMFORT,
# PRESET_BOOST,
# PRESET_SLEEP,
# PRESET_ACTIVITY,
# PRESET_HOME,
]