Skip to content

Commit

Permalink
refactor light toggle (trying to fix #218 #325)
Browse files Browse the repository at this point in the history
  • Loading branch information
krahabb committed Oct 20, 2023
1 parent fff1ffd commit 32a9574
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 94 deletions.
11 changes: 4 additions & 7 deletions custom_components/meross_lan/devices/mod100.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
164 changes: 92 additions & 72 deletions custom_components/meross_lan/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))

Expand Down Expand Up @@ -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)

Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -271,45 +269,67 @@ 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
if ATTR_COLOR_TEMP in kwargs:
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,
)
Expand Down
20 changes: 17 additions & 3 deletions custom_components/meross_lan/merossclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

from hashlib import md5
from time import time
from typing import Union
from uuid import uuid4

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 25 additions & 12 deletions emulator/mixins/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down

0 comments on commit 32a9574

Please sign in to comment.