diff --git a/custom_components/meross_lan/devices/mod100.py b/custom_components/meross_lan/devices/mod100.py index dc804b28..1999b7da 100644 --- a/custom_components/meross_lan/devices/mod100.py +++ b/custom_components/meross_lan/devices/mod100.py @@ -44,19 +44,16 @@ async def async_turn_on(self, **kwargs): light[mc.KEY_ONOFF] = 1 if ATTR_RGB_COLOR in kwargs: - rgb = kwargs[ATTR_RGB_COLOR] - light[mc.KEY_RGB] = _rgb_to_int(rgb) + light[mc.KEY_RGB] = _rgb_to_int(kwargs[ATTR_RGB_COLOR]) # Brightness must always be set in payload if ATTR_BRIGHTNESS in kwargs: light[mc.KEY_LUMINANCE] = _sat_1_100(kwargs[ATTR_BRIGHTNESS] * 100 // 255) - else: - if mc.KEY_LUMINANCE not in light: - light[mc.KEY_LUMINANCE] = 100 + elif not light.get(mc.KEY_LUMINANCE, 0): + light[mc.KEY_LUMINANCE] = 100 if ATTR_EFFECT in kwargs: - effect = kwargs[ATTR_EFFECT] - mode = reverse_lookup(self._light_effect_map, effect) + mode = reverse_lookup(self._light_effect_map, kwargs[ATTR_EFFECT]) if mode is not None: light[mc.KEY_MODE] = mode else: diff --git a/custom_components/meross_lan/light.py b/custom_components/meross_lan/light.py index ad7d4690..7f58620c 100644 --- a/custom_components/meross_lan/light.py +++ b/custom_components/meross_lan/light.py @@ -7,17 +7,15 @@ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, - ATTR_HS_COLOR, ATTR_RGB_COLOR, ColorMode, LightEntityFeature, ) -import homeassistant.util.color as color_util from . import meross_entity as me from .const import DND_ID -from .helpers import SmartPollingStrategy, reverse_lookup -from .merossclient import const as mc +from .helpers import ApiProfile, SmartPollingStrategy, reverse_lookup +from .merossclient import const as mc, get_element_by_key_safe if typing.TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -26,13 +24,12 @@ from .meross_device import MerossDevice, ResponseCallbackType from .merossclient import MerossDeviceDescriptor -""" - map light Temperature effective range to HA mired(s): - right now we'll use a const approach since it looks like - any light bulb out there carries the same specs - MIRED <> 1000000/TEMPERATURE[K] - (thanks to @nao-pon #87) -""" +ATTR_TOGGLEX_MODE = "togglex_mode" +# map light Temperature effective range to HA mired(s): +# right now we'll use a const approach since it looks like +# any light bulb out there carries the same specs +# MIRED <> 1000000/TEMPERATURE[K] +# (thanks to @nao-pon #87) MSLANY_MIRED_MIN = 153 # math.floor(1/(6500/1000000)) MSLANY_MIRED_MAX = 371 # math.ceil(1/(2700/1000000)) @@ -135,10 +132,8 @@ def _parse_light(self, payload: dict): if mc.KEY_RGB in payload: self._attr_color_mode = ColorMode.RGB self._attr_rgb_color = _int_to_rgb(payload[mc.KEY_RGB]) - self._attr_hs_color = color_util.color_RGB_to_hs(*self._attr_rgb_color) else: self._attr_rgb_color = None - self._attr_hs_color = None self._inherited_parse_light(payload) @@ -161,12 +156,24 @@ class MLLight(MLLightBase): _attr_max_mireds = MSLANY_MIRED_MAX _attr_min_mireds = MSLANY_MIRED_MIN + _unrecorded_attributes = frozenset({ATTR_TOGGLEX_MODE}) + _capacity: int - _hastogglex: bool + _togglex_switch: bool + """ + if True the device supports/needs TOGGLEX namespace to toggle + """ + _togglex_mode: bool | None + """ + if False: the device doesn't use TOGGLEX + elif True: the device needs TOGGLEX to turn ON + elif None: the component needs to auto-learn the device behavior + """ __slots__ = ( "_capacity", - "_hastogglex", + "_togglex_switch", + "_togglex_mode", ) def __init__(self, manager: LightMixin, payload: dict): @@ -183,22 +190,20 @@ def __init__(self, manager: LightMixin, payload: dict): # we'll try implement a new command flow where we'll just use the 'Light' payload to turn on the device # skipping the initial 'ToggleX' assuming this behaviour works on any fw super().__init__(manager, payload) - channel = payload.get(mc.KEY_CHANNEL, 0) descr = manager.descriptor - self._hastogglex = False - p_togglex = descr.digest.get(mc.KEY_TOGGLEX) - if isinstance(p_togglex, list): - for t in p_togglex: - if t.get(mc.KEY_CHANNEL) == channel: - self._hastogglex = True - self.namespace = mc.NS_APPLIANCE_CONTROL_TOGGLEX - self.key_namespace = mc.KEY_TOGGLEX - break - elif isinstance(p_togglex, dict): - if p_togglex.get(mc.KEY_CHANNEL) == channel: - self._hastogglex = True - self.namespace = mc.NS_APPLIANCE_CONTROL_TOGGLEX - self.key_namespace = mc.KEY_TOGGLEX + if get_element_by_key_safe( + descr.digest.get(mc.KEY_TOGGLEX), + mc.KEY_CHANNEL, + payload.get(mc.KEY_CHANNEL, 0), + ): + self._togglex_switch = True + self._togglex_mode = None + self._attr_extra_state_attributes = {ATTR_TOGGLEX_MODE: None} + self.namespace = mc.NS_APPLIANCE_CONTROL_TOGGLEX + self.key_namespace = mc.KEY_TOGGLEX + else: + self._togglex_switch = False + self._togglex_mode = False """ capacity is set in abilities when using mc.NS_APPLIANCE_CONTROL_LIGHT @@ -211,7 +216,6 @@ def __init__(self, manager: LightMixin, payload: dict): self._attr_supported_color_modes = set() if self._capacity & mc.LIGHT_CAPACITY_RGB: self._attr_supported_color_modes.add(ColorMode.RGB) # type: ignore - self._attr_supported_color_modes.add(ColorMode.HS) # type: ignore if self._capacity & mc.LIGHT_CAPACITY_TEMPERATURE: self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) # type: ignore if not self._attr_supported_color_modes: @@ -221,40 +225,34 @@ def __init__(self, manager: LightMixin, payload: dict): self._attr_supported_color_modes.add(ColorMode.ONOFF) # type: ignore async def async_turn_on(self, **kwargs): + if not kwargs: + await self.async_request_onoff(1) + return + light = dict(self._light) - # we need to preserve actual capacity in case HA tells to just toggle - capacity = light.get(mc.KEY_CAPACITY, 0) + capacity = light.get(mc.KEY_CAPACITY, 0) | mc.LIGHT_CAPACITY_LUMINANCE + # Brightness must always be set in payload + if ATTR_BRIGHTNESS in kwargs: + light[mc.KEY_LUMINANCE] = _sat_1_100(kwargs[ATTR_BRIGHTNESS] * 100 // 255) + elif not light.get(mc.KEY_LUMINANCE, 0): + light[mc.KEY_LUMINANCE] = 100 + # Color is taken from either of these 2 values, but not both. - if (ATTR_HS_COLOR in kwargs) or (ATTR_RGB_COLOR in kwargs): - if ATTR_HS_COLOR in kwargs: - h, s = kwargs[ATTR_HS_COLOR] - rgb = color_util.color_hs_to_RGB(h, s) - else: - rgb = kwargs[ATTR_RGB_COLOR] - light[mc.KEY_RGB] = _rgb_to_int(rgb) + if ATTR_RGB_COLOR in kwargs: + light[mc.KEY_RGB] = _rgb_to_int(kwargs[ATTR_RGB_COLOR]) light.pop(mc.KEY_TEMPERATURE, None) capacity |= mc.LIGHT_CAPACITY_RGB capacity &= ~mc.LIGHT_CAPACITY_TEMPERATURE elif ATTR_COLOR_TEMP in kwargs: # map mireds: min_mireds -> 100 - max_mireds -> 1 - mired = kwargs[ATTR_COLOR_TEMP] - norm_value = (mired - self.min_mireds) / (self.max_mireds - self.min_mireds) - temperature = 100 - (norm_value * 99) - light[mc.KEY_TEMPERATURE] = _sat_1_100( - temperature - ) # meross wants temp between 1-100 + norm_value = (kwargs[ATTR_COLOR_TEMP] - self.min_mireds) / ( + self.max_mireds - self.min_mireds + ) + light[mc.KEY_TEMPERATURE] = _sat_1_100(100 - (norm_value * 99)) light.pop(mc.KEY_RGB, None) capacity |= mc.LIGHT_CAPACITY_TEMPERATURE capacity &= ~mc.LIGHT_CAPACITY_RGB - # Brightness must always be set in payload - if ATTR_BRIGHTNESS in kwargs: - light[mc.KEY_LUMINANCE] = _sat_1_100(kwargs[ATTR_BRIGHTNESS] * 100 // 255) - else: - if mc.KEY_LUMINANCE not in light: - light[mc.KEY_LUMINANCE] = 100 - capacity |= mc.LIGHT_CAPACITY_LUMINANCE - if ATTR_EFFECT in kwargs: effect = reverse_lookup(self._light_effect_map, kwargs[ATTR_EFFECT]) if effect: @@ -271,13 +269,47 @@ async def async_turn_on(self, **kwargs): light[mc.KEY_CAPACITY] = capacity - if not self._hastogglex: + if not self._togglex_switch: light[mc.KEY_ONOFF] = 1 def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: self._light = {} # invalidate so _parse_light will force-flush self._parse_light(light) + if not self.is_on: + # In general, the LIGHT payload with LUMINANCE set should rightly + # turn on the light, but this is not true for every model/fw. + # Since devices exposing TOGGLEX have different behaviors we'll + # try to learn this at runtime. + if self._togglex_mode: + # previous test showed that we need TOGGLEX + ApiProfile.hass.async_create_task(self.async_request_onoff(1)) + elif self._togglex_mode is None: + # we need to learn the device behavior... + def _togglex_getack_callback( + acknowledge: bool, header: dict, payload: dict + ): + if acknowledge: + self._parse_togglex(payload[mc.KEY_TOGGLEX][0]) + if self.is_on: + # the device won't need TOGGLEX to turn on + self._togglex_mode = False + else: + # the device will need TOGGLEX to turn on + self._togglex_mode = True + ApiProfile.hass.async_create_task( + self.async_request_onoff(1) + ) + self._attr_extra_state_attributes = { + ATTR_TOGGLEX_MODE: self._togglex_mode + } + + self.manager.request( + mc.NS_APPLIANCE_CONTROL_TOGGLEX, + mc.METHOD_GET, + {mc.KEY_TOGGLEX: [{mc.KEY_CHANNEL: self.channel}]}, + _togglex_getack_callback, + ) await self.manager.async_request_light(light, _ack_callback) # 87: @nao-pon bulbs need a 'double' send when setting Temp @@ -285,31 +317,19 @@ def _ack_callback(acknowledge: bool, header: dict, payload: dict): if self.manager.descriptor.firmwareVersion == "2.1.2": await self.manager.async_request_light(light, None) - if self._hastogglex: - # since lights could be repeatedtly 'async_turn_on' when changing attributes - # we avoid flooding the device by sending togglex only once - # this is probably unneeded since any light payload sent seems to turn on the light - # 2023-04-26: moving the "togglex" code after the "light" was sent - # to try avoid glitching in mss570 (#218). Previous patch was suppressing - # "togglex" code at all but that left out some lights not switching on - # automatically when receiving the "light" command - if not self.is_on: - await super().async_turn_on(**kwargs) - - async def async_turn_off(self, **kwargs): - if self._hastogglex: - # we suppose we have to 'toggle(x)' - await super().async_turn_off(**kwargs) + async def async_request_onoff(self, onoff: int): + if self._togglex_switch: + await super().async_request_onoff(onoff) else: def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: - self.update_onoff(0) + self.update_onoff(onoff) await self.manager.async_request_light( { mc.KEY_CHANNEL: self.channel, - mc.KEY_ONOFF: 0, + mc.KEY_ONOFF: onoff, }, _ack_callback, ) diff --git a/custom_components/meross_lan/merossclient/__init__.py b/custom_components/meross_lan/merossclient/__init__.py index cd96297a..9371a321 100644 --- a/custom_components/meross_lan/merossclient/__init__.py +++ b/custom_components/meross_lan/merossclient/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from hashlib import md5 +from time import time from typing import Union from uuid import uuid4 @@ -15,7 +16,6 @@ import asyncio import json from random import randint - from time import time class MEROSSDEBUG: # this will raise an OSError on non-dev machines missing the @@ -223,12 +223,26 @@ def get_element_by_key(payload: list, key: str, value: object) -> dict: """ scans the payload(list) looking for the first item matching the key value. Usually looking for the matching channel payload - inside list paylaods + inside list payloads """ for p in payload: if p.get(key) == value: return p - raise KeyError(f"No match for key '{key}' on value:'{value}'") + raise KeyError(f"No match for key '{key}' on value:'{str(value)}' in {str(payload)}") + + +def get_element_by_key_safe(payload, key: str, value) -> dict | None: + """ + scans the payload (expecting a list) looking for the first item matching + the key value. Usually looking for the matching channel payload + inside list payloads + """ + try: + for p in payload: + if p.get(key) == value: + return p + except Exception: + return None def get_productname(producttype: str) -> str: diff --git a/emulator/mixins/light.py b/emulator/mixins/light.py index 4df1b2d1..3397c21b 100644 --- a/emulator/mixins/light.py +++ b/emulator/mixins/light.py @@ -3,7 +3,10 @@ import typing -from custom_components.meross_lan.merossclient import const as mc +from custom_components.meross_lan.merossclient import ( + const as mc, + get_element_by_key_safe, +) from .. import MerossEmulator, MerossEmulatorDescriptor @@ -12,24 +15,34 @@ class LightMixin(MerossEmulator if typing.TYPE_CHECKING else object): def __init__(self, descriptor: MerossEmulatorDescriptor, key): super().__init__(descriptor, key) + if get_element_by_key_safe( + descriptor.digest.get(mc.KEY_TOGGLEX), + mc.KEY_CHANNEL, + 0, + ): + self._togglex_switch = True # use TOGGLEX to (auto) switch + self._togglex_mode = ( + True # True: need TOGGLEX to switch / False: auto-switch + ) + else: + self._togglex_switch = False + self._togglex_mode = False + def _SET_Appliance_Control_Light(self, header, payload): # need to override basic handler since lights turning on/off is tricky between # various firmwares: some supports onoff in light payload some use the togglex - p_light = payload[mc.KEY_LIGHT] p_digest = self.descriptor.digest - support_onoff_in_light = mc.KEY_ONOFF in p_digest[mc.KEY_LIGHT] + p_light = payload[mc.KEY_LIGHT] + channel = p_light.get(mc.KEY_CHANNEL, 0) # generally speaking set_light always turns on, unless the payload carries onoff = 0 and # the device is not using togglex - if support_onoff_in_light: - onoff = p_light.get(mc.KEY_ONOFF, 1) - p_light[mc.KEY_ONOFF] = onoff - else: - onoff = 1 + if self._togglex_switch: p_light.pop(mc.KEY_ONOFF, None) - if mc.KEY_TOGGLEX in p_digest: - # fixed channel 0..that is.. - p_digest[mc.KEY_TOGGLEX][0][mc.KEY_ONOFF] = onoff - p_digest[mc.KEY_LIGHT].update(p_light) + if not self._togglex_mode: + p_digest[mc.KEY_TOGGLEX][channel][mc.KEY_ONOFF] = 1 + else: + p_light[mc.KEY_ONOFF] = p_light.get(mc.KEY_ONOFF, 1) + p_digest[mc.KEY_LIGHT] = p_light return mc.METHOD_SETACK, {} def _GET_Appliance_Control_Light_Effect(self, header, payload):