Skip to content

Commit

Permalink
Merge pull request #408 from impulsio/mts960
Browse files Browse the repository at this point in the history
Added support for Mts960
  • Loading branch information
albertogeniola authored Nov 12, 2024
2 parents 58de4e0 + 56a9acc commit e01b46c
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 2 deletions.
139 changes: 138 additions & 1 deletion meross_iot/controller/mixins/thermostat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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)
3 changes: 2 additions & 1 deletion meross_iot/device_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +80,7 @@

# Thermostat
Namespace.CONTROL_THERMOSTAT_MODE.value: ThermostatModeMixin,
Namespace.CONTROL_THERMOSTAT_MODEB.value: ThermostatModeBMixin,

# TODO: BIND, UNBIND, ONLINE, WIFI, ETC!
}
Expand Down
13 changes: 13 additions & 0 deletions meross_iot/model/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit e01b46c

Please sign in to comment.