diff --git a/meross_iot/controller/mixins/thermostat.py b/meross_iot/controller/mixins/thermostat.py index d10a5ea..4b04ba0 100644 --- a/meross_iot/controller/mixins/thermostat.py +++ b/meross_iot/controller/mixins/thermostat.py @@ -2,7 +2,7 @@ from typing import Optional, List, Dict from meross_iot.controller.device import ChannelInfo -from meross_iot.model.enums import Namespace, ThermostatMode +from meross_iot.model.enums import Namespace, ThermostatMode, ThermostatWorkingMode, ThermostatModeBState _LOGGER = logging.getLogger(__name__) @@ -41,6 +41,22 @@ def mode(self) -> Optional[ThermostatMode]: return None return ThermostatMode(mode) + @property + def workingMode(self) -> Optional[ThermostatWorkingMode]: + """The current thermostat working mode""" + mode = self._state.get('working') + if mode is None: + return None + return ThermostatWorkingMode(mode) + + @property + def state(self) -> Optional[ThermostatModeBState]: + """The current thermostat state""" + state = self._state.get('state') + if state is None: + return None + return ThermostatModeBState(state) + @property def warning(self) -> Optional[bool]: """The warning state of the thermostat""" @@ -242,3 +258,124 @@ async def async_set_thermostat_config(self, timeout=timeout) mode_data = result.get('mode') self._update_mode(mode_data) + +class ThermostatModeBMixin: + _execute_command: callable + check_full_update_done: callable + _thermostat_state_by_channel: Dict[int, ThermostatState] + + def __init__(self, device_uuid: str, + manager, + **kwargs): + super().__init__(device_uuid=device_uuid, manager=manager, **kwargs) + self._thermostat_state_by_channel = {} + + def _update_mode(self, mode_data: Dict): + # The MTS200 thermostat does bring a object for every sensor/channel it handles. + for c in mode_data: + channel_index = c['channel'] + state = self._thermostat_state_by_channel.get(channel_index) + if state is None: + state = ThermostatState(c) + self._thermostat_state_by_channel[channel_index] = state + else: + state.update(c) + + async def async_handle_push_notification(self, namespace: Namespace, data: dict) -> bool: + locally_handled = False + + if namespace == Namespace.CONTROL_THERMOSTAT_MODEB: + _LOGGER.debug(f"{self.__class__.__name__} handling push notification for namespace " + f"{namespace}") + mode_data = data.get('modeB') + if mode_data is None: + _LOGGER.error(f"{self.__class__.__name__} could not find 'modeB' attribute in push notification data: " + f"{data}") + locally_handled = False + else: + self._update_mode(mode_data) + locally_handled = True + + # Always call the parent handler when done with local specific logic. This gives the opportunity to all + # ancestors to catch all events. + parent_handled = await super().async_handle_push_notification(namespace=namespace, data=data) + return locally_handled or parent_handled + + async def async_handle_update(self, namespace: Namespace, data: dict) -> bool: + _LOGGER.debug(f"Handling {self.__class__.__name__} mixin data update.") + locally_handled = False + if namespace == Namespace.SYSTEM_ALL: + thermostat_data = data.get('all', {}).get('digest', {}).get('thermostat', {}) + mode_data = thermostat_data.get('modeB') + if mode_data is not None: + self._update_mode(mode_data) + locally_handled = True + + super_handled = await super().async_handle_update(namespace=namespace, data=data) + return super_handled or locally_handled + + def get_thermostat_state(self, channel: int = 0, *args, **kwargs) -> Optional[ThermostatState]: + """ + Returns the current thermostat state + :param channel: + :param args: + :param kwargs: + :return: + """ + self.check_full_update_done() + state = self._thermostat_state_by_channel.get(channel) + return state + + def _align_temp(self, temperature:float, channel: int = 0) -> float: + """ + Given an input temperature for a specific channel, checks if the temperature is within the ranges + of acceptable values and rounds it to the nearest 0.5 value. It also applies the 10x multiplication + as Meross devices requires decimal-degrees + """ + # Retrieve the min/max settable values from the state. + # If this is not available, assume some defaults + # channel_state = self._thermostat_state_by_channel.get(channel) + # max_settable_temp = _THERMOSTAT_MIN_SETTABLE_TEMP + # min_settable_temp = _THERMOSTAT_MIN_SETTABLE_TEMP + # if channel_state is not None: + # min_settable_temp = channel_state.min_temperature_celsius + # max_settable_temp = channel_state.max_temperature_celsius + + # if temperature < min_settable_temp or temperature > max_settable_temp: + # raise ValueError("The provided temperature value is invalid or out of range for this device.") + + # Round temp value to 0.5 + quotient = temperature/0.5 + quotient = round(quotient) + final_temp = quotient*5 + return final_temp + + async def async_set_thermostat_config(self, + channel: int = 0, + mode: Optional[ThermostatWorkingMode] = None, + target_temperature_celsius: Optional[float] = None, + on_not_off: Optional[bool] = None, + timeout: Optional[float] = None, + *args, + **kwargs) -> None: + channel_conf = { + 'channel': channel + } + payload = {'modeB': [channel_conf]} + + # Arg check + if mode is not None: + channel_conf['working'] = mode.value + if target_temperature_celsius is not None: + channel_conf['targetTemp'] = self._align_temp(target_temperature_celsius, channel=channel) + if on_not_off is not None: + channel_conf['onoff'] = 1 if on_not_off else 0 + + _LOGGER.debug("payload:"+str(payload)) + # This api call will return the updated state of the device. We use it to update the internal state right away. + result = await self._execute_command(method="SET", + namespace=Namespace.CONTROL_THERMOSTAT_MODEB, + payload=payload, + timeout=timeout) + mode_data = result.get('modeB') + self._update_mode(mode_data) diff --git a/meross_iot/device_factory.py b/meross_iot/device_factory.py index 50ee967..8f500c0 100644 --- a/meross_iot/device_factory.py +++ b/meross_iot/device_factory.py @@ -15,7 +15,7 @@ from meross_iot.controller.mixins.runtime import SystemRuntimeMixin from meross_iot.controller.mixins.spray import SprayMixin from meross_iot.controller.mixins.system import SystemAllMixin, SystemOnlineMixin -from meross_iot.controller.mixins.thermostat import ThermostatModeMixin +from meross_iot.controller.mixins.thermostat import ThermostatModeMixin, ThermostatModeBMixin from meross_iot.controller.mixins.toggle import ToggleXMixin, ToggleMixin from meross_iot.controller.subdevice import Mts100v3Valve, Ms100Sensor from meross_iot.model.enums import Namespace @@ -80,6 +80,7 @@ # Thermostat Namespace.CONTROL_THERMOSTAT_MODE.value: ThermostatModeMixin, + Namespace.CONTROL_THERMOSTAT_MODEB.value: ThermostatModeBMixin, # TODO: BIND, UNBIND, ONLINE, WIFI, ETC! } diff --git a/meross_iot/model/enums.py b/meross_iot/model/enums.py index 2779638..947e1eb 100644 --- a/meross_iot/model/enums.py +++ b/meross_iot/model/enums.py @@ -55,6 +55,16 @@ class ThermostatMode(Enum): MANUAL = 4 +class ThermostatWorkingMode(Enum): + HEAT = 1 + COOL = 2 + + +class ThermostatModeBState(Enum): + HEATING_COOLING = 1 + NOT_HEATING_COOLING = 2 + + class RollerShutterState(Enum): UNKNOWN = -1 IDLE = 0 @@ -139,6 +149,9 @@ class Namespace(Enum): CONTROL_THERMOSTAT_MODE = 'Appliance.Control.Thermostat.Mode' CONTROL_THERMOSTAT_WINDOWOPENED = 'Appliance.Control.Thermostat.WindowOpened' + # Thermostat / MTS960 + CONTROL_THERMOSTAT_MODEB = 'Appliance.Control.Thermostat.ModeB' + def get_or_parse_namespace(namespace: Union[Namespace, str]): if isinstance(namespace, str):