From e8980550784ce4dd7ff1028eb3faf8f240602fb6 Mon Sep 17 00:00:00 2001 From: KartoffelToby Date: Thu, 21 Sep 2023 21:22:46 +0200 Subject: [PATCH] [TASK] implement main cooler control --- .devcontainer/configuration.yaml | 7 + .vscode/settings.json | 6 +- .../better_thermostat/climate.py | 150 ++++++++++++++++-- .../better_thermostat/config_flow.py | 16 +- custom_components/better_thermostat/const.py | 1 + .../better_thermostat/events/cooler.py | 123 ++++++++++++++ .../better_thermostat/events/trv.py | 8 +- .../better_thermostat/strings.json | 3 +- .../better_thermostat/translations/de.json | 3 +- .../better_thermostat/translations/en.json | 3 +- .../better_thermostat/utils/controlling.py | 2 +- .../better_thermostat/utils/helpers.py | 13 +- 12 files changed, 304 insertions(+), 31 deletions(-) create mode 100644 custom_components/better_thermostat/events/cooler.py diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 8b71be77..e8034729 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -26,6 +26,13 @@ climate: heater: input_boolean.heater2 target_sensor: input_number.internal_sensor2 + - platform: generic_thermostat + name: Dummy_real_AC + heater: input_boolean.heater2 + target_sensor: input_number.internal_sensor2 + ac_mode: true + cold_tolerance: 0.3 + input_boolean: heater: name: Heater diff --git a/.vscode/settings.json b/.vscode/settings.json index 81ec2167..0a0e2de3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,9 @@ "tests" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "[python]": { + "editor.defaultFormatter": "ms-python.autopep8" + }, + "python.formatting.provider": "none" } \ No newline at end of file diff --git a/custom_components/better_thermostat/climate.py b/custom_components/better_thermostat/climate.py index d965f67d..73268ef7 100644 --- a/custom_components/better_thermostat/climate.py +++ b/custom_components/better_thermostat/climate.py @@ -7,6 +7,8 @@ from random import randint from statistics import mean +from custom_components.better_thermostat.events.cooler import trigger_cooler_change + from .utils.watcher import check_all_entities from .utils.weather import check_ambient_air_temperature, check_weather @@ -20,17 +22,23 @@ from .utils.model_quirks import load_model_quirks -from .utils.helpers import convert_to_float, find_battery_entity +from .utils.helpers import convert_to_float, find_battery_entity, get_hvac_bt_mode from homeassistant.helpers import entity_platform from homeassistant.core import callback, CoreState, Context, ServiceCall import json -from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate import ( + ClimateEntity, + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, +) from homeassistant.components.climate.const import ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_TARGET_TEMP_STEP, HVACMode, HVACAction, + ClimateEntityFeature, ) from homeassistant.const import ( CONF_NAME, @@ -58,6 +66,7 @@ ATTR_STATE_WINDOW_OPEN, ATTR_STATE_SAVED_TEMPERATURE, ATTR_STATE_HEATING_POWER, + CONF_COOLER, CONF_HEATER, CONF_HUMIDITY, CONF_MODEL, @@ -135,6 +144,7 @@ async def async_service_handler(self, data: ServiceCall): entry.data.get(CONF_OFF_TEMPERATURE, None), entry.data.get(CONF_TOLERANCE, 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", @@ -203,6 +213,7 @@ def __init__( off_temperature, tolerance, model, + cooler_entity_id, unit, unique_id, device_class, @@ -221,6 +232,7 @@ def __init__( 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 @@ -232,7 +244,8 @@ def __init__( self._unit = unit self._device_class = device_class self._state_class = state_class - self._hvac_list = [HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF] + self._hvac_list = [HVACMode.HEAT, HVACMode.OFF] + self.map_on_hvac_mode = HVACMode.HEAT self.next_valve_maintenance = datetime.now() + timedelta( hours=randint(1, 24 * 5) ) @@ -243,6 +256,7 @@ def __init__( self.bt_min_temp = 0 self.bt_max_temp = 30 self.bt_target_temp = 5 + self.bt_target_cooltemp = None self._support_flags = SUPPORT_FLAGS self.bt_hvac_mode = None self.closed_window_triggered = False @@ -295,6 +309,11 @@ async def async_added_to_hass(self): "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 ] @@ -426,6 +445,16 @@ async def _trigger_window_change(self, event): 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. @@ -439,6 +468,7 @@ async def startup(self): self.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): @@ -487,6 +517,20 @@ async def startup(self): 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.name, + self.cooler_entity_id, + ) + await asyncio.sleep(10) + continue + if self.humidity_entity_id is not None: if self.hass.states.get(self.humidity_entity_id).state in ( STATE_UNAVAILABLE, @@ -553,6 +597,18 @@ async def startup(self): self.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.name, + "startup()", + ) + if self.window_id is not None: self.all_entities.append(self.window_id) window = self.hass.states.get(self.window_id) @@ -702,7 +758,11 @@ async def startup(self): self.cur_humidity = 0 self.last_window_state = self.window_open - if self.bt_hvac_mode not in (HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT): + 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() @@ -830,6 +890,12 @@ async def startup(self): 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.name) self.async_write_ha_state() await self.async_update_ha_state(force_refresh=True) @@ -1024,7 +1090,7 @@ def hvac_mode(self): string HVAC mode only from homeassistant.components.climate.const is valid """ - return self.bt_hvac_mode + return get_hvac_bt_mode(self, self.bt_hvac_mode) @property def hvac_action(self): @@ -1041,7 +1107,7 @@ def hvac_action(self): return self.attr_hvac_action @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach. Returns @@ -1059,6 +1125,18 @@ def target_temperature(self): 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 + @property def hvac_modes(self): """List of available operation modes. @@ -1077,8 +1155,8 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: ------- None """ - if hvac_mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.OFF): - self.bt_hvac_mode = hvac_mode + 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.name, hvac_mode @@ -1098,17 +1176,55 @@ async def async_set_temperature(self, **kwargs) -> None: ------- None """ - _new_setpoint = convert_to_float( - str(kwargs.get(ATTR_TEMPERATURE, None)), - self.name, - "controlling.settarget_temperature()", - ) - if _new_setpoint is None: + _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.name, + hvac_mode, + ) + if ATTR_TEMPERATURE in kwargs: + _new_setpoint = convert_to_float( + str(kwargs.get(ATTR_TEMPERATURE, None)), + self.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.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.name, + "controlling.settarget_temperature_high()", + ) + + if _new_setpoint is None and _new_setpointlow is None: _LOGGER.debug( f"better_thermostat {self.name}: received a new setpoint from HA, but temperature attribute was not set, ignoring" ) return - self.bt_target_temp = _new_setpoint + 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.name, + self.bt_target_temp, + self.bt_target_cooltemp, + ) + self.async_write_ha_state() await self.control_queue_task.put(self) @@ -1166,4 +1282,6 @@ def supported_features(self): array Supported features. """ - return self._support_flags + if self.cooler_entity_id is not None: + return ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + return ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/custom_components/better_thermostat/config_flow.py b/custom_components/better_thermostat/config_flow.py index c92c9fe5..13bc2489 100644 --- a/custom_components/better_thermostat/config_flow.py +++ b/custom_components/better_thermostat/config_flow.py @@ -15,6 +15,7 @@ from .utils.helpers import get_device_model, get_trv_intigration from .const import ( + CONF_COOLER, CONF_PROTECT_OVERHEATING, CONF_CALIBRATION, CONF_CHILD_LOCK, @@ -253,6 +254,8 @@ async def async_step_user(self, user_input=None): self.data[CONF_OUTDOOR_SENSOR] = None if CONF_WEATHER not in self.data: self.data[CONF_WEATHER] = None + if CONF_COOLER not in self.data: + self.data[CONF_COOLER] = None if CONF_WINDOW_TIMEOUT in self.data: self.data[CONF_WINDOW_TIMEOUT] = ( @@ -302,6 +305,9 @@ async def async_step_user(self, user_input=None): vol.Required(CONF_HEATER): selector.EntitySelector( selector.EntitySelectorConfig(domain="climate", multiple=True) ), + vol.Required(CONF_COOLER): selector.EntitySelector( + selector.EntitySelectorConfig(domain="climate", multiple=False) + ), vol.Required(CONF_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig( domain=["sensor", "number", "input_number"], @@ -346,8 +352,7 @@ async def async_step_user(self, user_input=None): default=user_input.get(CONF_OFF_TEMPERATURE, 20), ): int, vol.Optional( - CONF_TOLERANCE, - default=user_input.get(CONF_TOLERANCE, 0.0), + CONF_TOLERANCE, default=user_input.get(CONF_TOLERANCE, 0.0) ): float, } ), @@ -548,7 +553,9 @@ async def async_step_user(self, user_input=None): CONF_OFF_TEMPERATURE ) - self.updated_config[CONF_TOLERANCE] = user_input.get(CONF_TOLERANCE, 0.0) + self.updated_config[CONF_TOLERANCE] = float( + user_input.get(CONF_TOLERANCE, 0.0) + ) for trv in self.updated_config[CONF_HEATER]: trv["adapter"] = None @@ -673,8 +680,7 @@ async def async_step_user(self, user_input=None): fields[ vol.Optional( - CONF_TOLERANCE, - default=self.config_entry.data.get(CONF_TOLERANCE, 0.0), + CONF_TOLERANCE, default=self.config_entry.data.get(CONF_TOLERANCE, 0.0) ) ] = float diff --git a/custom_components/better_thermostat/const.py b/custom_components/better_thermostat/const.py index 440e0dcf..48f63ea2 100644 --- a/custom_components/better_thermostat/const.py +++ b/custom_components/better_thermostat/const.py @@ -26,6 +26,7 @@ CONF_HEATER = "thermostat" +CONF_COOLER = "cooler" CONF_SENSOR = "temperature_sensor" CONF_HUMIDITY = "humidity_sensor" CONF_SENSOR_WINDOW = "window_sensors" diff --git a/custom_components/better_thermostat/events/cooler.py b/custom_components/better_thermostat/events/cooler.py new file mode 100644 index 00000000..a1ec71ae --- /dev/null +++ b/custom_components/better_thermostat/events/cooler.py @@ -0,0 +1,123 @@ +import asyncio +import logging +from homeassistant.components.climate.const import ( + ATTR_HVAC_ACTION, + HVACAction, + HVACMode, +) +from homeassistant.core import State, callback +from homeassistant.components.group.util import find_state_attributes + +from custom_components.better_thermostat.utils.helpers import convert_to_float + + +_LOGGER = logging.getLogger(__name__) + + +@callback +async def trigger_cooler_change(self, event): + """Trigger a change in the cooler state.""" + if self.startup_running: + return + if self.control_queue_task is None: + return + asyncio.create_task(update_hvac_action(self)) + _main_change = False + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + entity_id = event.data.get("entity_id") + + if None in (new_state, old_state, new_state.attributes): + _LOGGER.debug( + f"better_thermostat {self.name}: Cooler {entity_id} update contained not all necessary data for processing, skipping" + ) + return + + if not isinstance(new_state, State) or not isinstance(old_state, State): + _LOGGER.debug( + f"better_thermostat {self.name}: Cooler {entity_id} update contained not a State, skipping" + ) + return + # set context HACK TO FIND OUT IF AN EVENT WAS SEND BY BT + + # Check if the update is coming from the code + if self.context == event.context: + return + + _LOGGER.debug(f"better_thermostat {self.name}: Cooler {entity_id} update received") + + _old_cooling_setpoint = convert_to_float( + str(old_state.attributes.get("temperature", None)), + self.name, + "trigger_cooler_change()", + ) + _new_cooling_setpoint = convert_to_float( + str(new_state.attributes.get("temperature", None)), + self.name, + "trigger_cooler_change()", + ) + if ( + _new_cooling_setpoint is not None + and _old_cooling_setpoint is not None + and self.bt_hvac_mode is not HVACMode.OFF + ): + _LOGGER.debug( + f"better_thermostat {self.name}: trigger_cooler_change / _old_cooling_setpoint: {_old_cooling_setpoint} - _new_cooling_setpoint: {_new_cooling_setpoint}" + ) + if ( + _new_cooling_setpoint < self.bt_min_temp + or self.bt_max_temp < _new_cooling_setpoint + ): + _LOGGER.warning( + f"better_thermostat {self.name}: New Cooler {entity_id} setpoint outside of range, overwriting it" + ) + + if _new_cooling_setpoint < self.bt_min_temp: + _new_cooling_setpoint = self.bt_min_temp + else: + _new_cooling_setpoint = self.bt_max_temp + + self.bt_target_cooltemp = _new_cooling_setpoint + if self.bt_target_temp >= self.bt_target_cooltemp: + self.bt_target_temp = self.bt_target_cooltemp - self.bt_target_temp_step + _main_change = True + + if _main_change is True: + self.async_write_ha_state() + return await self.control_queue_task.put(self) + self.async_write_ha_state() + return + + +async def update_hvac_action(self): + """Update the hvac action.""" + if self.startup_running or self.control_queue_task is None: + return + + hvac_action = None + states = [ + state + for entity_id in [self.cooler_entity_id] + if (state := self.hass.states.get(entity_id)) is not None + ] + + hvac_actions = list(find_state_attributes(states, ATTR_HVAC_ACTION)) + + if not hvac_actions: + self.attr_hvac_action = None + elif all(a == HVACAction.OFF for a in hvac_actions): + hvac_action = HVACAction.OFF + elif self.bt_target_cooltemp < self.cur_temp and self.window_open is False: + hvac_action = HVACAction.COOLING + elif ( + self.bt_target_cooltemp < self.cur_temp + and self.attr_hvac_action == HVACAction.COOLING + and self.window_open is False + ): + hvac_action = HVACAction.COOLING + else: + hvac_action = HVACAction.IDLE + + if self.hvac_action != hvac_action: + self.attr_hvac_action = hvac_action + await self.async_update_ha_state(force_refresh=True) diff --git a/custom_components/better_thermostat/events/trv.py b/custom_components/better_thermostat/events/trv.py index bca3441a..f6e6739e 100644 --- a/custom_components/better_thermostat/events/trv.py +++ b/custom_components/better_thermostat/events/trv.py @@ -116,7 +116,7 @@ async def trigger_trv_change(self, event): ) return - if mapped_state in (HVACMode.OFF, HVACMode.HEAT): + if mapped_state in (HVACMode.OFF, HVACMode.HEAT, HVACMode.HEAT_COOL): if ( self.real_trvs[entity_id]["hvac_mode"] != _org_trv_state.state and not child_lock @@ -179,6 +179,12 @@ async def trigger_trv_change(self, event): f"better_thermostat {self.name}: TRV {entity_id} decoded TRV target temp changed from {self.bt_target_temp} to {_new_heating_setpoint}" ) self.bt_target_temp = _new_heating_setpoint + if self.cooler_entity_id is not None: + if self.bt_target_temp <= self.bt_target_cooltemp: + self.bt_target_temp = ( + self.bt_target_cooltemp + self.bt_target_temp_step + ) + _main_change = True if self.real_trvs[entity_id]["advanced"].get("no_off_system_mode", False): diff --git a/custom_components/better_thermostat/strings.json b/custom_components/better_thermostat/strings.json index 1a1c5c5a..20770603 100644 --- a/custom_components/better_thermostat/strings.json +++ b/custom_components/better_thermostat/strings.json @@ -6,6 +6,7 @@ "data": { "name": "Name", "thermostat": "The real thermostat", + "cooler": "The cooling device (optional)", "temperature_sensor": "Temperature sensor", "humidity_sensor": "Humidity sensor", "window_sensors": "Window sensor", @@ -125,4 +126,4 @@ "description": "Set the target temperature to a temporay like night mode, and save the old one." } } -} +} \ No newline at end of file diff --git a/custom_components/better_thermostat/translations/de.json b/custom_components/better_thermostat/translations/de.json index 9aa802b8..94e30690 100644 --- a/custom_components/better_thermostat/translations/de.json +++ b/custom_components/better_thermostat/translations/de.json @@ -7,6 +7,7 @@ "data": { "name": "Name", "thermostat": "Das reale Thermostat", + "cooler": "Klimmagerät AC (optional)", "temperature_sensor": "Externer Temperatursensor", "humidity_sensor": "Luftfeuchtigkeitssensor", "window_sensors": "Fenstersensor(en)", @@ -126,4 +127,4 @@ "description": "Speichert eine ECO Temperatur, die für den ECO Modus genutzt wird." } } -} +} \ No newline at end of file diff --git a/custom_components/better_thermostat/translations/en.json b/custom_components/better_thermostat/translations/en.json index d266a38f..f2b66acb 100644 --- a/custom_components/better_thermostat/translations/en.json +++ b/custom_components/better_thermostat/translations/en.json @@ -7,6 +7,7 @@ "data": { "name": "Name", "thermostat": "The real thermostat", + "cooler": "The cooling device (optional)", "temperature_sensor": "Temperature sensor", "humidity_sensor": "Humidity sensor", "window_sensors": "Window sensor", @@ -126,4 +127,4 @@ "description": "Set the target temperature to a temporay like night mode, and save the old one." } } -} +} \ No newline at end of file diff --git a/custom_components/better_thermostat/utils/controlling.py b/custom_components/better_thermostat/utils/controlling.py index bced422c..76dbfd5f 100644 --- a/custom_components/better_thermostat/utils/controlling.py +++ b/custom_components/better_thermostat/utils/controlling.py @@ -82,7 +82,7 @@ async def control_trv(self, heater_entity_id=None): """ async with self._temp_lock: self.real_trvs[heater_entity_id]["ignore_trv_states"] = True - update_hvac_action(self) + await update_hvac_action(self) await self.calculate_heating_power() _trv = self.hass.states.get(heater_entity_id) _current_set_temperature = convert_to_float( diff --git a/custom_components/better_thermostat/utils/helpers.py b/custom_components/better_thermostat/utils/helpers.py index 3b4d256c..6e44b86e 100644 --- a/custom_components/better_thermostat/utils/helpers.py +++ b/custom_components/better_thermostat/utils/helpers.py @@ -6,10 +6,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import async_entries_for_config_entry -from homeassistant.components.climate.const import ( - HVACMode, - HVACAction, -) +from homeassistant.components.climate.const import HVACMode, HVACAction from custom_components.better_thermostat.utils.model_quirks import ( fix_local_calibration, @@ -28,6 +25,14 @@ _LOGGER = logging.getLogger(__name__) +def get_hvac_bt_mode(self, mode: str) -> str: + if mode == HVACMode.HEAT: + mode = self.map_on_hvac_mode + elif mode == HVACMode.HEAT_COOL: + mode = HVACMode.HEAT + return mode + + def mode_remap(self, entity_id, hvac_mode: str, inbound: bool = False) -> str: """Remap HVAC mode to correct mode if nessesary.