From 5673c7f778375d589e09fee046c720825931e0ae Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 12:57:20 +0100 Subject: [PATCH 01/10] Initial implementation of dual thermostat actor --- .../schedy/actors/dualthermostat/config.yaml | 33 ++ .../schedy/actors/dualthermostat/index.rst | 99 +++++ hass_apps/schedy/__init__.py | 2 +- hass_apps/schedy/actor/__init__.py | 2 + hass_apps/schedy/actor/dualthermostat.py | 408 ++++++++++++++++++ 5 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 docs/apps/schedy/actors/dualthermostat/config.yaml create mode 100644 docs/apps/schedy/actors/dualthermostat/index.rst create mode 100644 hass_apps/schedy/actor/dualthermostat.py diff --git a/docs/apps/schedy/actors/dualthermostat/config.yaml b/docs/apps/schedy/actors/dualthermostat/config.yaml new file mode 100644 index 00000000..4640542d --- /dev/null +++ b/docs/apps/schedy/actors/dualthermostat/config.yaml @@ -0,0 +1,33 @@ +# Delta that is added to the temperature value sent to this +# thermostat in order to correct potential inaccuracies of +# the temperature sensor. +#high_delta: 0 +#low_delta: 0 + +# The minimum/maximum temperature the thermostat supports. +# If configured, temperatures outside the supported range are changed +# to the minimum/maximum value before they're sent to the thermostat. +# null means there is no limitation. +#high_min_temp: null +#high_max_temp: null +#low_min_temp: null +#low_max_temp: null + +# When this is set to something different than "OFF", Schedy will +# rewrite the value OFF into this temperature before sending it to +# the thermostat. You can set it to 4.0 degrees (if your thermostat +# supports this low value) in order to prevent frost-induced damage +# to your heating setup. +# This setting is required when you want to send OFF to thermostats with +# disabled HVAC mode support. +#off_temp: "OFF" + +# Set this to false if your thermostat doesn't support HVAC modes. +# Please note that you won't be able to turn it off completely without +# HVAC mode support. Remember to also configure off_temp when you +# disable this feature. +#supports_hvac_modes: true + +# These two settings can be used to tweak the names of the HVAC modes. +#hvac_mode_on: "heat_cool" +#hvac_mode_off: "off" diff --git a/docs/apps/schedy/actors/dualthermostat/index.rst b/docs/apps/schedy/actors/dualthermostat/index.rst new file mode 100644 index 00000000..09f298cb --- /dev/null +++ b/docs/apps/schedy/actors/dualthermostat/index.rst @@ -0,0 +1,99 @@ +Dual Thermostat +========== + +The ``dualthermostat`` actor is used to control the temperature of climate +entities. + +Often, people ask me whether Schedy can be used with their particular +heating setup. I always tend to repeat myself in these situations, +hence I want to explain here what the exact preconditions for using +Schedy for heating control actually are. + +1. You need at least one thermostat in each room you want to control. + Such a thermostat must be recognized as a climate entity in Home + Assistant, and setting the target temperature from the Home Assistant + web interface should work reliably. Wall thermostats can be controlled + the same way as radiator thermostats, as long as they fulfill these + conditions as well. If you only have a switchable heater and an + external temperature sensor, have a look at Home Assistant's `Generic + Thermostat platform`_ to build a virtual thermostat first. + +2. If your thermostat is used for both heating and cooling, there has + to be an automatic HVAC mode which does heating/cooling based + on the difference between current and set target temperature. Schedy + will only switch the HVAC mode between on and off (exact names + can be configured) and set the target temperature according to the + room's schedule. + +.. _`Generic Thermostat platform`: https://home-assistant.io/components/climate.generic_thermostat/ + +If you are happy with these points and your setup fulfills them, there +should be nothing stopping you from integrating Schedy's great scheduling +capabilities with your home's heating. You can then go on and create a +Schedy configuration with thermostat actors. + + +Configuration +------------- + +.. include:: ../config.rst.inc + + +Supported Values +---------------- + +Your schedules must generate tuples of valid temperature values. Those can be +integers (``(18, 20)``), floats (``(19.5, 21.5)``), or a mixture of both (``(18.5, 21)``). + +A special value is ``OFF``, which is an object available in the evaluation +environment when using the thermostat actor type. If this object is +returned from an expression, it will turn the thermostats off. The +equivalent for the ``OFF`` object to use when using plain values instead +of expressions is the string ``"OFF"`` (case-insensitive). + +.. note:: + + When working with the ``Add()`` :doc:`postprocessor + <../../schedules/expressions/postprocessors>` and the result is + ``OFF``, it will stay ``OFF``, no matter what's being added to it. + + +Statistical Parameters +---------------------- + +.. include:: ../statistics-intro.rst.inc + + +``high_temp_delta`` +~~~~~~~~~~~~~~ + +This parameter measures the difference between the high target and current +temperature for all thermostats in the associated rooms. It can be used +to control a source of heating energy, such as a fuel oven, with Home +Assistant automations. + +Options provided because this is a ``TempDeltaParameter``: + +* ``off_value``: Specify how to handle thermostats which are turned + off. Specify either the number to assume as the delta or ``null``, which + causes the thermostat to be excluded from statistics collection. The + default value is ``0``. + +``low_temp_delta`` +~~~~~~~~~~~~~~ + +This parameter measures the difference between the low target and current +temperature for all thermostats in the associated rooms. It can be used +to control a source of heating energy, such as a compressor, with Home +Assistant automations. + +Options provided because this is a ``TempDeltaParameter``: + +* ``off_value``: Specify how to handle thermostats which are turned + off. Specify either the number to assume as the delta or ``null``, which + causes the thermostat to be excluded from statistics collection. The + default value is ``0``. + +.. include:: ../../statistics/actor-value-collector.rst.inc + +.. include:: ../../statistics/min-avg-max-parameter.rst.inc diff --git a/hass_apps/schedy/__init__.py b/hass_apps/schedy/__init__.py index 6c98b992..2a4e36b3 100644 --- a/hass_apps/schedy/__init__.py +++ b/hass_apps/schedy/__init__.py @@ -3,4 +3,4 @@ various sub-modules. """ -__version__ = "0.8.3" +__version__ = "0.8.3_JM" diff --git a/hass_apps/schedy/actor/__init__.py b/hass_apps/schedy/actor/__init__.py index b3094bca..dc48307d 100644 --- a/hass_apps/schedy/actor/__init__.py +++ b/hass_apps/schedy/actor/__init__.py @@ -10,6 +10,7 @@ from .generic2 import Generic2Actor from .switch import SwitchActor from .thermostat import ThermostatActor +from .dualthermostat import DualThermostatActor __all__ = [ @@ -19,6 +20,7 @@ "Generic2Actor", "SwitchActor", "ThermostatActor", + "DualThermostatActor", ] diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py new file mode 100644 index 00000000..d0cfa210 --- /dev/null +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -0,0 +1,408 @@ +""" +This module implements the dual thermostat actor. +""" + +import typing as T + +if T.TYPE_CHECKING: + # pylint: disable=cyclic-import,unused-import + from ..room import Room + +import functools +import voluptuous as vol + +from ... import common +from .. import stats +from ..expression.helpers import HelperBase as ExpressionHelperBase +from .base import ActorBase +from .thermostat import Off, Temp + +OFF = Off() + + +class DualTemp: + """A class holding a temperature value.""" + + def __init__(self, temp_value: T.Any) -> None: + if isinstance(temp_value, DualTemp): + # Just copy the value over. + self.low_temp = temp_value.low_temp + self.high_temp = temp_value.high_temp + return + else: + parsed = self.parse_temp(temp_value) + + if parsed is None: + raise ValueError("{} is no valid temperature".format(repr(temp_value))) + + if isinstance(parsed, Off): + self.low_temp = OFF # type: T.Union[float, Off] + self.high_temp = OFF # type: T.Union[float, Off] + else: + self.low_temp = parsed[0] # type: T.Union[float, Off] + self.high_temp = parsed[1] # type: T.Union[float, Off] + + def __add__(self, other: T.Any) -> "DualTemp": + # OFF + something is OFF + if self.is_off or (isinstance(other, (Temp, DualTemp)) and other.is_off): + return type(self)(OFF) + + if isinstance(other, (float, int)): + return type(self)((self.low_temp + other, self.high_temp + other)) + if isinstance(other, list): + return type(self)((self.low_temp + other[0], self.high_temp + other[1])) + if isinstance(other, Temp): + return type(self)((self.low_temp + other.value, self.high_temp + other.value)) + if isinstance(other, DualTemp): + return type(self)((self.low_temp + other.low_temp, self.high_temp + other.high_temp)) + return NotImplemented + + def __eq__(self, other: T.Any) -> bool: + if isinstance(other, type(self)): + return (self.low_temp == other.low_temp) and (self.high_temp == other.high_temp) + if isinstance(other, Temp): + return (self.low_temp == other.value) and (self.high_temp == other.value) + return NotImplemented + + def __hash__(self) -> int: + return hash(str(self)) + + def __repr__(self) -> str: + return "{}° - {}°".format(self.low_temp, self.high_temp) + + def serialize(self) -> str: + """Converts the temperature into a string that Temp can be + initialized with again later.""" + + if self.is_off: + return "OFF" + return str("({},{})".format(self.low_temp, self.high_temp)) + + @property + def is_off(self) -> bool: + """Tells whether this temperature means OFF.""" + + return isinstance(self.low_temp, Off) + + @staticmethod + def parse_temp(value: T.Any) -> T.Union[T.List[float, float], Off, None]: + """Converts the given value to a valid temperature of type float + or Off. + If value is a string, all whitespace is removed first. + If conversion is not possible, None is returned.""" + + if isinstance(value, str): + value = "".join(value.split()) + if value.upper() == "OFF": + return OFF + + if isinstance(value, Off): + return OFF + + if isinstance(value, list) and len(value) == 2 and \ + isinstance(value[0], (float, int)) and isinstance(value[1], (float, int)): + return value + + return None + + +class ThermostatExpressionHelper(ExpressionHelperBase): + """Adds Temp and OFF to the evaluation environment.""" + + OFF = OFF + DualTemp = DualTemp + + +TEMP_SCHEMA = vol.Schema( + vol.All( + vol.Any(list, Off, vol.All(str, lambda v: v.upper(), "OFF")), + lambda v: DualTemp(v), # pylint: disable=unnecessary-lambda + ) +) + + +class _DualTempDeltaParameter(stats.ActorValueCollectorMixin, stats.MinAvgMaxParameter): + """The difference between target and current temperature.""" + + name = "temp_delta" + config_schema_dict = { + **stats.ActorValueCollectorMixin.config_schema_dict, + **stats.MinAvgMaxParameter.config_schema_dict, + vol.Optional("off_value", default=0): vol.Any(float, int, None), + } + round_places = 2 + attribute = None + + def collect_actor_value(self, actor: ActorBase) -> T.Optional[float]: + """Collects the difference between target and current temperature.""" + + assert isinstance(actor, DualThermostatActor) + assert self.attribute is not None + current = actor.current_temp + target = getattr(actor, self.attribute) + if current is None or target is None or current.is_off or target.is_off: + off_value = self.cfg["off_value"] + if off_value is None: + # thermostats that are off should be excluded + return None + return float(off_value) + return float(target - current) + + def initialize_actor_listeners(self, actor: ActorBase) -> None: + """Listens for changes of current and target temperature.""" + + self.log( + "Listening for temperature changes of {} in {}.".format(actor, actor.room), + level="DEBUG", + ) + actor.events.on("current_temp_changed", self.update_handler) + actor.events.on("value_changed", self.update_handler) + + +class HighTempDeltaParameter(_DualTempDeltaParameter): + attribute = "current_temp_high" + + +class LowTempDeltaParameter(_DualTempDeltaParameter): + attribute = "current_temp_low" + + +class DualThermostatActor(ActorBase): + """A thermostat to be controlled by Schedy.""" + + name = "dualthermostat" + config_schema_dict = { + **ActorBase.config_schema_dict, + vol.Optional("delta", default=DualTemp((0, 0))): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), + vol.Optional("min_temp", default=None): vol.Any( + vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), None + ), + vol.Optional("max_temp", default=None): vol.Any( + vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), None + ), + vol.Optional("off_temp", default=OFF): TEMP_SCHEMA, + vol.Optional("supports_hvac_modes", default=True): bool, + vol.Optional("hvac_mode_on", default="heat_cool"): str, + vol.Optional("hvac_mode_off", default="off"): str, + } + + expression_helpers = ActorBase.expression_helpers + [ThermostatExpressionHelper] + + stats_param_types = [HighTempDeltaParameter, LowTempDeltaParameter] + + def __init__(self, *args: T.Any, **kwargs: T.Any) -> None: + super().__init__(*args, **kwargs) + self._current_temp = None # type: T.Optional[Temp] + + def check_config_plausibility(self, state: dict) -> None: + """Is called during initialization to warn the user about some + possible common configuration mistakes.""" + + if not state: + self.log("Thermostat couldn't be found.", level="WARNING") + return + + required_attrs = ["target_temp_high", "target_temp_low"] + if self.cfg["supports_hvac_modes"]: + required_attrs.append("state") + for attr in required_attrs: + if attr not in state: + self.log( + "Thermostat has no attribute named {!r}. Available are {!r}. " + "Please check your config!".format(attr, list(state.keys())), + level="WARNING", + ) + + temp_attrs = ["temperature", "current_temperature"] + for attr in temp_attrs: + value = state.get(attr) + try: + value = float(value) # type: ignore + except (TypeError, ValueError): + self.log( + "The value {!r} of attribute {!r} is no valid temperature.".format( + value, attr + ), + level="WARNING", + ) + + allowed_hvac_modes = state.get("hvac_modes") + if not self.cfg["supports_hvac_modes"]: + if allowed_hvac_modes: + self.log( + "HVAC mode support has been disabled, but the modes {!r} seem to " + "be supported. Maybe disabling it was a mistake?".format( + allowed_hvac_modes + ), + level="WARNING", + ) + return + + if not allowed_hvac_modes: + self.log( + "Attributes for thermostat contain no 'hvac_modes', Consider " + "disabling HVAC mode support.", + level="WARNING", + ) + return + for hvac_mode in (self.cfg["hvac_mode_on"], self.cfg["hvac_mode_off"]): + if hvac_mode not in allowed_hvac_modes: + self.log( + "Thermostat doesn't seem to support the " + "HVAC mode {}, supported modes are: {}. " + "Please check your config!".format(hvac_mode, allowed_hvac_modes), + level="WARNING", + ) + + @property + def current_temp(self) -> T.Optional[Temp]: + """Returns the current temperature as measured by the thermostat.""" + + return self._current_temp + + @staticmethod + def deserialize_value(value: str) -> DualTemp: + """Deserializes by calling validate_value().""" + + return DualThermostatActor.validate_value(value) + + def do_send(self) -> None: + """Sends self._wanted_value to the thermostat.""" + + target_temp = self._wanted_value # type: DualTemp + if target_temp.is_off: + hvac_mode = self.cfg["hvac_mode_off"] + temp = None + else: + hvac_mode = self.cfg["hvac_mode_on"] + temp = target_temp + if not self.cfg["supports_hvac_modes"]: + hvac_mode = None + + self.log( + "Setting temperature = {!r}, HVAC mode = {!r}.".format( + "" if temp is None else temp, + "" if hvac_mode is None else hvac_mode, + ), + level="DEBUG", + prefix=common.LOG_PREFIX_OUTGOING, + ) + if hvac_mode is not None: + self.app.call_service( + "climate/set_hvac_mode", entity_id=self.entity_id, hvac_mode=hvac_mode + ) + if temp is not None: + self.app.call_service( + "climate/set_temperature", + entity_id=self.entity_id, + target_temp_low=temp.low_temp, + target_temp_high=temp.high_temp, + ) + + def filter_set_value(self, value: DualTemp) -> T.Optional[DualTemp]: + """Preprocesses the given target temperature for setting on this + thermostat. This algorithm will try best to achieve the closest + possible temperature supported by this particular thermostat. + The return value is either the temperature to set or None, + if nothing has to be sent.""" + + if value.is_off: + value = self.cfg["off_temp"] + + if not value.is_off: + value += self.cfg["delta"] + + if isinstance(self.cfg["min_temp"], DualTemp): + if value.low_temp < self.cfg["min_temp"].low_temp: + value.low_temp = self.cfg["min_temp"].low_temp + if value.high_temp < self.cfg["min_temp"].high_temp: + value.high_temp = self.cfg["min_temp"].high_temp + + if isinstance(self.cfg["max_temp"], DualTemp): + if value.low_temp > self.cfg["max_temp"].low_temp: + value.low_temp = self.cfg["max_temp"].low_temp + if value.high_temp > self.cfg["max_temp"].high_temp: + value.high_temp = self.cfg["max_temp"].high_temp + + elif not self.cfg["supports_hvac_modes"]: + self.log( + "Not turning off because it doesn't support HVAC modes.", + level="WARNING", + ) + self.log( + "Consider defining an off_temp in the actor " + "configuration for these cases.", + level="WARNING", + ) + return None + + return value + + def notify_state_changed(self, attrs: dict) -> T.Optional[DualTemp]: + """Is called when the thermostat's state changes. + This method fetches both the current and target temperature from + the thermostat and reacts accordingly.""" + + target_temp = None # type: T.Optional[DualTemp] + if self.cfg["supports_hvac_modes"]: + hvac_mode = attrs.get("state") + self.log( + "Attribute 'state' is {}.".format(repr(hvac_mode)), + level="DEBUG", + prefix=common.LOG_PREFIX_INCOMING, + ) + if hvac_mode == self.cfg["hvac_mode_off"]: + target_temp = DualTemp(OFF) + elif hvac_mode != self.cfg["hvac_mode_on"]: + self.log( + "Unknown HVAC mode {!r}, ignoring thermostat.".format(hvac_mode), + level="ERROR", + ) + return None + + if target_temp is None: + target_temp = DualTemp((attrs.get("target_temp_low"), attrs.get("target_temp_high"))) + self.log( + "Attribute 'temperature' is {}.".format(repr(target_temp)), + level="DEBUG", + prefix=common.LOG_PREFIX_INCOMING, + ) + + _current_temp = attrs.get("current_temperature") + self.log( + "Attribute 'current_temperature' is {}.".format(repr(_current_temp)), + level="DEBUG", + prefix=common.LOG_PREFIX_INCOMING, + ) + if _current_temp is not None: + try: + current_temp = Temp(_current_temp) # type: T.Optional[Temp] + except ValueError: + self.log( + "Invalid current temperature {!r}, not updating it.".format( + _current_temp + ), + level="ERROR", + ) + else: + if current_temp != self._current_temp: + self._current_temp = current_temp + self.events.trigger("current_temp_changed", self, current_temp) + + return target_temp + + @staticmethod + def serialize_value(value: DualTemp) -> str: + """Wrapper around Temp.serialize().""" + + if not isinstance(value, DualTemp): + raise ValueError( + "can only serialize Temp objects, not {}".format(repr(value)) + ) + return value.serialize() + + @staticmethod + def validate_value(value: T.Any) -> DualTemp: + """Ensures the given value is a valid temperature.""" + + return DualTemp(value) From cd353c1540a2833911930d7980ae49e2587267ec Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:03:12 +0100 Subject: [PATCH 02/10] Fix temperature parsing typing --- hass_apps/schedy/actor/dualthermostat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index d0cfa210..01430075 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -85,7 +85,7 @@ def is_off(self) -> bool: return isinstance(self.low_temp, Off) @staticmethod - def parse_temp(value: T.Any) -> T.Union[T.List[float, float], Off, None]: + def parse_temp(value: T.Any) -> T.Union[T.List[float], Off, None]: """Converts the given value to a valid temperature of type float or Off. If value is a string, all whitespace is removed first. From 51b918bb6bfc1edf8ab61c38a71276cdaa080050 Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:05:37 +0100 Subject: [PATCH 03/10] Change default delta to match new dual temperature type --- hass_apps/schedy/actor/dualthermostat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index 01430075..45890276 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -173,7 +173,7 @@ class DualThermostatActor(ActorBase): name = "dualthermostat" config_schema_dict = { **ActorBase.config_schema_dict, - vol.Optional("delta", default=DualTemp((0, 0))): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), + vol.Optional("delta", default=DualTemp([0, 0])): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), vol.Optional("min_temp", default=None): vol.Any( vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), None ), @@ -220,7 +220,7 @@ def check_config_plausibility(self, state: dict) -> None: value = float(value) # type: ignore except (TypeError, ValueError): self.log( - "The value {!r} of attribute {!r} is no valid temperature.".format( + "The value {!r} of attribute {!r} is not a valid dual temperature.".format( value, attr ), level="WARNING", From a0f415a50126e5627670ce6f31f5114b918df9d9 Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:15:59 +0100 Subject: [PATCH 04/10] Disable delta support, as this is causing issues --- hass_apps/schedy/actor/dualthermostat.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index 45890276..9c216eee 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -173,7 +173,9 @@ class DualThermostatActor(ActorBase): name = "dualthermostat" config_schema_dict = { **ActorBase.config_schema_dict, - vol.Optional("delta", default=DualTemp([0, 0])): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), + #TODO: Look into error when enabling this + # "Configuration error: expected list for dictionary value @ data['delta']. Got None" + # vol.Optional("delta", default=DualTemp([0, 0])): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), vol.Optional("min_temp", default=None): vol.Any( vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), None ), From 62202e0030e737e1764c5a6c731a1183758697cf Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:18:10 +0100 Subject: [PATCH 05/10] Fix dual temperature parsing --- hass_apps/schedy/actor/dualthermostat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index 9c216eee..6842218b 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -99,7 +99,7 @@ def parse_temp(value: T.Any) -> T.Union[T.List[float], Off, None]: if isinstance(value, Off): return OFF - if isinstance(value, list) and len(value) == 2 and \ + if isinstance(value, (tuple, list)) and len(value) == 2 and \ isinstance(value[0], (float, int)) and isinstance(value[1], (float, int)): return value @@ -115,7 +115,7 @@ class ThermostatExpressionHelper(ExpressionHelperBase): TEMP_SCHEMA = vol.Schema( vol.All( - vol.Any(list, Off, vol.All(str, lambda v: v.upper(), "OFF")), + vol.Any(list, tuple, Off, vol.All(str, lambda v: v.upper(), "OFF")), lambda v: DualTemp(v), # pylint: disable=unnecessary-lambda ) ) From bed81ffb4704c3376bb5a937f0fa4b1fc2b966bc Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:21:18 +0100 Subject: [PATCH 06/10] Attempt at fixing delta --- hass_apps/schedy/actor/dualthermostat.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index 6842218b..4de31c41 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -124,7 +124,6 @@ class ThermostatExpressionHelper(ExpressionHelperBase): class _DualTempDeltaParameter(stats.ActorValueCollectorMixin, stats.MinAvgMaxParameter): """The difference between target and current temperature.""" - name = "temp_delta" config_schema_dict = { **stats.ActorValueCollectorMixin.config_schema_dict, **stats.MinAvgMaxParameter.config_schema_dict, @@ -161,10 +160,12 @@ def initialize_actor_listeners(self, actor: ActorBase) -> None: class HighTempDeltaParameter(_DualTempDeltaParameter): attribute = "current_temp_high" + name = "high_temp_delta" class LowTempDeltaParameter(_DualTempDeltaParameter): attribute = "current_temp_low" + name = "low_temp_delta" class DualThermostatActor(ActorBase): @@ -175,7 +176,7 @@ class DualThermostatActor(ActorBase): **ActorBase.config_schema_dict, #TODO: Look into error when enabling this # "Configuration error: expected list for dictionary value @ data['delta']. Got None" - # vol.Optional("delta", default=DualTemp([0, 0])): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), + vol.Optional("delta", default=DualTemp([0, 0])): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), vol.Optional("min_temp", default=None): vol.Any( vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), None ), From f787803942f110c5067c9b4eca45af38d9f5e301 Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:24:33 +0100 Subject: [PATCH 07/10] Disable delta again --- hass_apps/schedy/actor/dualthermostat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index 4de31c41..5191d30b 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -176,7 +176,7 @@ class DualThermostatActor(ActorBase): **ActorBase.config_schema_dict, #TODO: Look into error when enabling this # "Configuration error: expected list for dictionary value @ data['delta']. Got None" - vol.Optional("delta", default=DualTemp([0, 0])): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), + # vol.Optional("delta", default=DualTemp([0, 0])): vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), vol.Optional("min_temp", default=None): vol.Any( vol.All(TEMP_SCHEMA, vol.NotIn([DualTemp(OFF)])), None ), @@ -313,7 +313,7 @@ def filter_set_value(self, value: DualTemp) -> T.Optional[DualTemp]: value = self.cfg["off_temp"] if not value.is_off: - value += self.cfg["delta"] + # value += self.cfg["delta"] if isinstance(self.cfg["min_temp"], DualTemp): if value.low_temp < self.cfg["min_temp"].low_temp: From 2498f7a3a64288bb994137cdd0370215cc9f2ee1 Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:32:55 +0100 Subject: [PATCH 08/10] Fix Parameters --- hass_apps/schedy/actor/dualthermostat.py | 64 ++++++++++++------------ 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index 5191d30b..ba51dbd7 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -26,8 +26,8 @@ class DualTemp: def __init__(self, temp_value: T.Any) -> None: if isinstance(temp_value, DualTemp): # Just copy the value over. - self.low_temp = temp_value.low_temp - self.high_temp = temp_value.high_temp + self.temp_low = temp_value.temp_low + self.temp_high = temp_value.temp_high return else: parsed = self.parse_temp(temp_value) @@ -36,11 +36,11 @@ def __init__(self, temp_value: T.Any) -> None: raise ValueError("{} is no valid temperature".format(repr(temp_value))) if isinstance(parsed, Off): - self.low_temp = OFF # type: T.Union[float, Off] - self.high_temp = OFF # type: T.Union[float, Off] + self.temp_low = OFF # type: T.Union[float, Off] + self.temp_high = OFF # type: T.Union[float, Off] else: - self.low_temp = parsed[0] # type: T.Union[float, Off] - self.high_temp = parsed[1] # type: T.Union[float, Off] + self.temp_low = parsed[0] # type: T.Union[float, Off] + self.temp_high = parsed[1] # type: T.Union[float, Off] def __add__(self, other: T.Any) -> "DualTemp": # OFF + something is OFF @@ -48,27 +48,27 @@ def __add__(self, other: T.Any) -> "DualTemp": return type(self)(OFF) if isinstance(other, (float, int)): - return type(self)((self.low_temp + other, self.high_temp + other)) + return type(self)((self.temp_low + other, self.temp_high + other)) if isinstance(other, list): - return type(self)((self.low_temp + other[0], self.high_temp + other[1])) + return type(self)((self.temp_low + other[0], self.temp_high + other[1])) if isinstance(other, Temp): - return type(self)((self.low_temp + other.value, self.high_temp + other.value)) + return type(self)((self.temp_low + other.value, self.temp_high + other.value)) if isinstance(other, DualTemp): - return type(self)((self.low_temp + other.low_temp, self.high_temp + other.high_temp)) + return type(self)((self.temp_low + other.temp_low, self.temp_high + other.temp_high)) return NotImplemented def __eq__(self, other: T.Any) -> bool: if isinstance(other, type(self)): - return (self.low_temp == other.low_temp) and (self.high_temp == other.high_temp) + return (self.temp_low == other.temp_low) and (self.temp_high == other.temp_high) if isinstance(other, Temp): - return (self.low_temp == other.value) and (self.high_temp == other.value) + return (self.temp_low == other.value) and (self.temp_high == other.value) return NotImplemented def __hash__(self) -> int: return hash(str(self)) def __repr__(self) -> str: - return "{}° - {}°".format(self.low_temp, self.high_temp) + return "{}° - {}°".format(self.temp_low, self.temp_high) def serialize(self) -> str: """Converts the temperature into a string that Temp can be @@ -76,13 +76,13 @@ def serialize(self) -> str: if self.is_off: return "OFF" - return str("({},{})".format(self.low_temp, self.high_temp)) + return str("({},{})".format(self.temp_low, self.temp_high)) @property def is_off(self) -> bool: """Tells whether this temperature means OFF.""" - return isinstance(self.low_temp, Off) + return isinstance(self.temp_low, Off) @staticmethod def parse_temp(value: T.Any) -> T.Union[T.List[float], Off, None]: @@ -132,20 +132,20 @@ class _DualTempDeltaParameter(stats.ActorValueCollectorMixin, stats.MinAvgMaxPar round_places = 2 attribute = None - def collect_actor_value(self, actor: ActorBase) -> T.Optional[float]: + def collect_actor_value(self, actor: "DualThermostatActor") -> T.Optional[float]: """Collects the difference between target and current temperature.""" assert isinstance(actor, DualThermostatActor) assert self.attribute is not None current = actor.current_temp - target = getattr(actor, self.attribute) + target = actor.current_value if current is None or target is None or current.is_off or target.is_off: off_value = self.cfg["off_value"] if off_value is None: # thermostats that are off should be excluded return None return float(off_value) - return float(target - current) + return float(getattr(target, self.attribute) - current) def initialize_actor_listeners(self, actor: ActorBase) -> None: """Listens for changes of current and target temperature.""" @@ -159,13 +159,13 @@ def initialize_actor_listeners(self, actor: ActorBase) -> None: class HighTempDeltaParameter(_DualTempDeltaParameter): - attribute = "current_temp_high" - name = "high_temp_delta" + attribute = "temp_high" + name = "temp_high_delta" class LowTempDeltaParameter(_DualTempDeltaParameter): - attribute = "current_temp_low" - name = "low_temp_delta" + attribute = "temp_low" + name = "temp_low_delta" class DualThermostatActor(ActorBase): @@ -298,8 +298,8 @@ def do_send(self) -> None: self.app.call_service( "climate/set_temperature", entity_id=self.entity_id, - target_temp_low=temp.low_temp, - target_temp_high=temp.high_temp, + target_temp_low=temp.temp_low, + target_temp_high=temp.temp_high, ) def filter_set_value(self, value: DualTemp) -> T.Optional[DualTemp]: @@ -316,16 +316,16 @@ def filter_set_value(self, value: DualTemp) -> T.Optional[DualTemp]: # value += self.cfg["delta"] if isinstance(self.cfg["min_temp"], DualTemp): - if value.low_temp < self.cfg["min_temp"].low_temp: - value.low_temp = self.cfg["min_temp"].low_temp - if value.high_temp < self.cfg["min_temp"].high_temp: - value.high_temp = self.cfg["min_temp"].high_temp + if value.temp_low < self.cfg["min_temp"].temp_low: + value.temp_low = self.cfg["min_temp"].temp_low + if value.temp_high < self.cfg["min_temp"].temp_high: + value.temp_high = self.cfg["min_temp"].temp_high if isinstance(self.cfg["max_temp"], DualTemp): - if value.low_temp > self.cfg["max_temp"].low_temp: - value.low_temp = self.cfg["max_temp"].low_temp - if value.high_temp > self.cfg["max_temp"].high_temp: - value.high_temp = self.cfg["max_temp"].high_temp + if value.temp_low > self.cfg["max_temp"].temp_low: + value.temp_low = self.cfg["max_temp"].temp_low + if value.temp_high > self.cfg["max_temp"].temp_high: + value.temp_high = self.cfg["max_temp"].temp_high elif not self.cfg["supports_hvac_modes"]: self.log( From b6da39aaea1261e53f35755c3867f5900d9a49ae Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:37:02 +0100 Subject: [PATCH 09/10] Fix DualTemp serialization --- hass_apps/schedy/actor/dualthermostat.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index ba51dbd7..35604e2f 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -8,8 +8,8 @@ # pylint: disable=cyclic-import,unused-import from ..room import Room -import functools import voluptuous as vol +import json from ... import common from .. import stats @@ -76,7 +76,7 @@ def serialize(self) -> str: if self.is_off: return "OFF" - return str("({},{})".format(self.temp_low, self.temp_high)) + return json.dumps([self.temp_low, self.temp_high]) @property def is_off(self) -> bool: @@ -95,6 +95,7 @@ def parse_temp(value: T.Any) -> T.Union[T.List[float], Off, None]: value = "".join(value.split()) if value.upper() == "OFF": return OFF + value = json.loads(value) if isinstance(value, Off): return OFF From cc246439e9a3b071eda2c0bb496bcac4930135cb Mon Sep 17 00:00:00 2001 From: Jacob Mansfield Date: Fri, 18 Jun 2021 13:39:23 +0100 Subject: [PATCH 10/10] Fix parameters --- hass_apps/schedy/actor/dualthermostat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hass_apps/schedy/actor/dualthermostat.py b/hass_apps/schedy/actor/dualthermostat.py index 35604e2f..30110fbb 100644 --- a/hass_apps/schedy/actor/dualthermostat.py +++ b/hass_apps/schedy/actor/dualthermostat.py @@ -146,7 +146,7 @@ def collect_actor_value(self, actor: "DualThermostatActor") -> T.Optional[float] # thermostats that are off should be excluded return None return float(off_value) - return float(getattr(target, self.attribute) - current) + return float(getattr(target, self.attribute) - current.value) def initialize_actor_listeners(self, actor: ActorBase) -> None: """Listens for changes of current and target temperature."""