diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 0b05c4af..efa04d14 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -4,7 +4,7 @@ on: pull_request: env: - DEFAULT_PYTHON: "3.10" + DEFAULT_PYTHON: "3.11" jobs: validate: diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index eb679e8d..76dee794 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -7,7 +7,7 @@ on: - dev env: - DEFAULT_PYTHON: "3.10" + DEFAULT_PYTHON: "3.11" jobs: validate: diff --git a/README.md b/README.md index 8096d188..a7cda93a 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,12 @@ Most of this software has been developed and tested on my owned Meross devices w - [GS559AH](https://www.meross.com/en-gc/smart-sensor/homekit-smoke-detector/120): Smart Smoke Sensor - Thermostats - [MTS100](https://www.meross.com/Detail/30/Smart%20Thermostat%20Valve): Smart Thermostat Valve + - [MTS150](https://www.meross.com/en-gc/smart-thermostat/smart-thermostat-valve/99): Smart Thermostat Valve - [MTS200](https://www.meross.com/Detail/116/Smart%20Wi-Fi%20Thermostat): Smart Wifi Thermostat - Covers - [MRS100](https://www.meross.com/product/91/article/): Smart WiFi Roller Shutter - [MSG100](https://www.meross.com/product/29/article/): Smart WiFi Garage Door Opener + - [MSG200](https://www.meross.com/en-gc/smart-garage-door-opener/homekit-garage-door-opener/68): Smart WiFi Garage Door Opener (3 channels) - Humidifiers - [MSXH0](https://www.meross.com/Detail/47/Smart%20Wi-Fi%20Humidifier) [experimental]: Smart WiFi Humidifier - [MOD100](https://www.meross.com/Detail/93/Smart%20Wi-Fi%20Essential%20Oil%20Diffuser) [experimental]: Smart WiFi Essential Oil Diffuser @@ -110,7 +112,7 @@ In general, many device configuration options available in Meross app are not su ## Service There is a service called `meross_lan.request` exposed to simplify communication with the device and play with it a bit. It basically requires the needed informations to setup a command request and send it over MQTT or HTTP without the hassle of signatures and timestamps computations. You can check it in the 'Developer Tools' of the HA instance, everything should be enough self-explanatory there. -Since version 3.0.2 the service allows you to publish the device response as a persistent_notification message in HA so you don't have to dig into logs in order to see the reply. The notification is optional and you have to add the `notifyresponse` key to the service call with a value of `true` +Since version 4.4.0 the service allows you to receive the 'original' device response through the new [HA service response feature](https://www.home-assistant.io/blog/2023/07/05/release-20237/#services-can-now-respond) ## Troubleshooting diff --git a/custom_components/meross_lan/__init__.py b/custom_components/meross_lan/__init__.py index 283b6a73..d167bb47 100644 --- a/custom_components/meross_lan/__init__.py +++ b/custom_components/meross_lan/__init__.py @@ -8,30 +8,22 @@ from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.core import HomeAssistant, SupportsResponse +from homeassistant.exceptions import ( + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ( - CONF_CLOUD_KEY, - CONF_DEVICE_ID, - CONF_HOST, - CONF_KEY, - CONF_NOTIFYRESPONSE, - CONF_PAYLOAD, - CONF_PROFILE_ID_LOCAL, - CONF_PROTOCOL_HTTP, - DOMAIN, - SERVICE_REQUEST, -) +from . import const as mlc from .helpers import LOGGER, ApiProfile, ConfigEntriesHelper, schedule_async_callback from .meross_device import MerossDevice from .meross_profile import MerossCloudProfile, MerossCloudProfileStore, MQTTConnection from .merossclient import ( MEROSSDEBUG, - KeyType, MerossDeviceDescriptor, - build_payload, + build_message, const as mc, get_default_payload, ) @@ -40,10 +32,12 @@ if typing.TYPE_CHECKING: from typing import Callable + from homeassistant.core import ServiceCall, ServiceResponse from homeassistant.components.mqtt import async_publish as mqtt_async_publish from homeassistant.config_entries import ConfigEntry - from .meross_device import ResponseCallbackType + from .merossclient import KeyType, ResponseCallbackType + else: # In order to avoid a static dependency we resolve these @@ -61,7 +55,7 @@ class HAMQTTConnection(MQTTConnection): ) def __init__(self, api: MerossApi): - super().__init__(api, CONF_PROFILE_ID_LOCAL, ("homeassistant", 0)) + super().__init__(api, mlc.CONF_PROFILE_ID_LOCAL, ("homeassistant", 0)) self._unsub_mqtt_subscribe: Callable | None = None self._unsub_mqtt_disconnected: Callable | None = None self._unsub_mqtt_connected: Callable | None = None @@ -104,11 +98,12 @@ def mqtt_publish( method: str, payload: dict, key: KeyType = None, + response_callback: ResponseCallbackType | None = None, messageid: str | None = None, ) -> asyncio.Future: return ApiProfile.hass.async_create_task( self.async_mqtt_publish( - device_id, namespace, method, payload, key, messageid + device_id, namespace, method, payload, key, response_callback, messageid ) ) @@ -119,11 +114,18 @@ async def async_mqtt_publish( method: str, payload: dict, key: KeyType = None, + response_callback: ResponseCallbackType | None = None, messageid: str | None = None, ): + if response_callback: + transaction = self._mqtt_transaction_init( + namespace, method, response_callback + ) + messageid = transaction.messageid + self.log( DEBUG, - "MQTT SEND device_id:(%s) method:(%s) namespace:(%s)", + "MQTT PUBLISH device_id:(%s) method:(%s) namespace:(%s)", device_id, method, namespace, @@ -132,7 +134,7 @@ async def async_mqtt_publish( ApiProfile.hass, mc.TOPIC_REQUEST.format(device_id), json_dumps( - build_payload( + build_message( namespace, method, payload, @@ -142,6 +144,8 @@ async def async_mqtt_publish( ) ), ) + if response_callback: + return await self._async_mqtt_transaction_wait(transaction) # type: ignore # interface: self @property @@ -208,20 +212,34 @@ class MerossApi(ApiProfile): @staticmethod def get(hass: HomeAssistant) -> MerossApi: - if DOMAIN not in hass.data: - hass.data[DOMAIN] = MerossApi(hass) - return hass.data[DOMAIN] + """ + Set up the MerossApi component. + 'Our' truth singleton is saved in hass.data[DOMAIN] and + ApiProfile.api is just a cache to speed access + """ + if mlc.DOMAIN not in hass.data: + hass.data[mlc.DOMAIN] = api = MerossApi(hass) + + async def _async_unload_merossapi(_event) -> None: + await api.async_shutdown() + hass.data.pop(mlc.DOMAIN) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_unload_merossapi + ) + return api + return hass.data[mlc.DOMAIN] def __init__(self, hass: HomeAssistant): ApiProfile.hass = hass ApiProfile.api = self - super().__init__(CONF_PROFILE_ID_LOCAL) + super().__init__(mlc.CONF_PROFILE_ID_LOCAL) self._deviceclasses: dict[str, type] = {} self._mqtt_connection: HAMQTTConnection | None = None - for config_entry in hass.config_entries.async_entries(DOMAIN): + for config_entry in hass.config_entries.async_entries(mlc.DOMAIN): unique_id = config_entry.unique_id - if (unique_id is None) or (unique_id == DOMAIN): + if (unique_id is None) or (unique_id == mlc.DOMAIN): continue unique_id = unique_id.split(".") if unique_id[0] == "profile": @@ -229,62 +247,91 @@ def __init__(self, hass: HomeAssistant): else: self.devices[unique_id[0]] = None - async def async_service_request(service_call): - device_id = service_call.data.get(CONF_DEVICE_ID) + async def async_service_request(service_call: ServiceCall) -> ServiceResponse: + service_response = {} + device_id = service_call.data.get(mlc.CONF_DEVICE_ID) + host = service_call.data.get(mlc.CONF_HOST) + if not device_id and not host: + raise HomeAssistantError( + "Missing both device_id and host: provide at least one valid entry" + ) + protocol = mlc.CONF_PROTOCOL_OPTIONS.get( + service_call.data.get(mlc.CONF_PROTOCOL), mlc.CONF_PROTOCOL_AUTO + ) namespace = service_call.data[mc.KEY_NAMESPACE] method = service_call.data.get(mc.KEY_METHOD, mc.METHOD_GET) if mc.KEY_PAYLOAD in service_call.data: - payload = json_loads(service_call.data[mc.KEY_PAYLOAD]) + try: + payload = json_loads(service_call.data[mc.KEY_PAYLOAD]) + except Exception as e: + raise HomeAssistantError("Payload is not a valid JSON") from e elif method == mc.METHOD_GET: payload = get_default_payload(namespace) else: payload = {} # likely failing the request... - key = service_call.data.get(CONF_KEY, self.key) - host = service_call.data.get(CONF_HOST) + key = service_call.data.get(mlc.CONF_KEY, self.key) - def response_callback(acknowledge: bool, header: dict, payload: dict): - if service_call.data.get(CONF_NOTIFYRESPONSE): - self.hass.components.persistent_notification.async_create( - title="Meross LAN service response", message=json_dumps(payload) + def response_callback(acknowledge: bool, header, payload): + service_response["response"] = { + mc.KEY_HEADER: header, + mc.KEY_PAYLOAD: payload, + } + + async def _async_device_request(device: MerossDevice): + if protocol is mlc.CONF_PROTOCOL_MQTT: + return await device.async_mqtt_request( + namespace, method, payload, response_callback + ) + elif protocol is mlc.CONF_PROTOCOL_HTTP: + return await device.async_http_request( + namespace, method, payload, response_callback + ) + else: + return await device.async_request( + namespace, method, payload, response_callback ) if device_id: if device := self.devices.get(device_id): - await device.async_request( - namespace, method, payload, response_callback - ) - return - # device not registered (yet?) try direct MQTT + await _async_device_request(device) + return service_response if ( - mqtt_connection := self._mqtt_connection - ) and mqtt_connection.mqtt_is_connected: + protocol is not mlc.CONF_PROTOCOL_HTTP + and (mqtt_connection := self._mqtt_connection) + and mqtt_connection.mqtt_is_connected + ): await mqtt_connection.async_mqtt_publish( - device_id, namespace, method, payload, key - ) - return - if not host: - self.warning( - "cannot execute service call on %s - missing MQTT connectivity or device not registered", device_id, + namespace, + method, + payload, + key, + response_callback, ) - return - elif not host: - self.warning("cannot execute service call (missing device_id and host)") - return - # host is not None - for device in self.active_devices(): - if device.host == host: - await device.async_request( - namespace, method, payload, response_callback + return service_response + + if host: + for device in self.active_devices(): + if device.host == host: + await _async_device_request(device) + return service_response + + if protocol is not mlc.CONF_PROTOCOL_MQTT: + service_response["response"] = await self.async_http_request( + host, namespace, method, payload, key ) - return - self.hass.async_create_task( - self.async_http_request( - host, namespace, method, payload, key, response_callback - ) + return service_response + + raise HomeAssistantError( + f"Unable to find a route to {device_id or host} using {protocol} protocol" ) - hass.services.async_register(DOMAIN, SERVICE_REQUEST, async_service_request) + hass.services.async_register( + mlc.DOMAIN, + mlc.SERVICE_REQUEST, + async_service_request, + supports_response=SupportsResponse.OPTIONAL, + ) return # interface: EntityManager @@ -315,7 +362,7 @@ def build_device(self, config_entry: ConfigEntry) -> MerossDevice: The base MerossDevice class is a bulk 'do it all' implementation but some devices (i.e. Hub) need a (radically?) different behaviour """ - descriptor = MerossDeviceDescriptor(config_entry.data.get(CONF_PAYLOAD)) + descriptor = MerossDeviceDescriptor(config_entry.data.get(mlc.CONF_PAYLOAD)) ability = descriptor.ability digest = descriptor.digest @@ -354,13 +401,17 @@ def build_device(self, config_entry: ConfigEntry) -> MerossDevice: mixin_classes.append(LightMixin) if mc.NS_APPLIANCE_CONTROL_ELECTRICITY in ability: - from .sensor import ElectricityMixin + from .devices.mss import ElectricityMixin mixin_classes.append(ElectricityMixin) if mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX in ability: - from .sensor import ConsumptionMixin + from .devices.mss import ConsumptionXMixin + + mixin_classes.append(ConsumptionXMixin) + if mc.NS_APPLIANCE_CONFIG_OVERTEMP in ability: + from .devices.mss import OverTempMixin - mixin_classes.append(ConsumptionMixin) + mixin_classes.append(OverTempMixin) if mc.KEY_SPRAY in digest: from .select import SprayMixin @@ -382,7 +433,7 @@ def build_device(self, config_entry: ConfigEntry) -> MerossDevice: mixin_classes.append(DiffuserMixin) if mc.NS_APPLIANCE_CONTROL_SCREEN_BRIGHTNESS in ability: - from .number import ScreenBrightnessMixin + from .devices.mts200 import ScreenBrightnessMixin mixin_classes.append(ScreenBrightnessMixin) # We must be careful when ordering the mixin and leave MerossDevice as last class. @@ -415,7 +466,6 @@ async def async_http_request( method: str, payload: dict, key: KeyType = None, - callback_or_device: ResponseCallbackType | MerossDevice | None = None, ): with self.exception_warning("async_http_request"): _httpclient: MerossHttpClient = getattr(self, "_httpclient", None) # type: ignore @@ -426,40 +476,8 @@ async def async_http_request( self._httpclient = _httpclient = MerossHttpClient( host, key, async_get_clientsession(self.hass), LOGGER ) - - response = await _httpclient.async_request(namespace, method, payload) - r_header = response[mc.KEY_HEADER] - if callback_or_device: - if isinstance(callback_or_device, MerossDevice): - callback_or_device.receive( - r_header, response[mc.KEY_PAYLOAD], CONF_PROTOCOL_HTTP - ) - else: - callback_or_device( - r_header[mc.KEY_METHOD] != mc.METHOD_ERROR, - r_header, - response[mc.KEY_PAYLOAD], - ) - - -async def async_setup(hass: HomeAssistant, config: dict): - """ - Set up the Meross IoT local LAN component. - "async_setup" is just called when loading entries for - the first time after boot but the api might need - initialization for the ConfigFlow. - 'Our' truth singleton is saved in hass.data[DOMAIN] and - ApiProfile.api is just a cache to speed access - """ - api = MerossApi.get(hass) - - async def _async_unload_merossapi(_event) -> None: - await api.async_shutdown() - hass.data.pop(DOMAIN) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_unload_merossapi) - - return True + return await _httpclient.async_request(namespace, method, payload) + return None async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): @@ -471,9 +489,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): unique_id, config_entry.entry_id, ) - api = ApiProfile.api + api = MerossApi.api or MerossApi.get(hass) - if unique_id == DOMAIN: + if unique_id == mlc.DOMAIN: # MQTT Hub entry await api.entry_update_listener(hass, config_entry) if not await api.mqtt_connection.async_mqtt_subscribe(): @@ -527,12 +545,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): profile.link(device) else: # trigger a cloud profile discovery if we guess it reasonable - if profile_id and (config_entry.data.get(CONF_CLOUD_KEY) == device.key): + if profile_id and (config_entry.data.get(mlc.CONF_CLOUD_KEY) == device.key): helper = ConfigEntriesHelper(hass) flow_unique_id = f"profile.{profile_id}" if not helper.get_config_flow(flow_unique_id): await hass.config_entries.flow.async_init( - DOMAIN, + mlc.DOMAIN, context={ "source": SOURCE_INTEGRATION_DISCOVERY, "unique_id": flow_unique_id, @@ -572,7 +590,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): unique_id, entry.entry_id, ) - if unique_id == DOMAIN: + if unique_id == mlc.DOMAIN: return assert unique_id diff --git a/custom_components/meross_lan/climate.py b/custom_components/meross_lan/climate.py index 8cd4b837..c6a3d666 100644 --- a/custom_components/meross_lan/climate.py +++ b/custom_components/meross_lan/climate.py @@ -31,24 +31,15 @@ class MtsClimate(me.MerossEntity, climate.ClimateEntity): ATTR_TEMPERATURE: Final = climate.ATTR_TEMPERATURE TEMP_CELSIUS: Final = hac.TEMP_CELSIUS - PRESET_OFF: Final = "off" PRESET_CUSTOM: Final = "custom" PRESET_COMFORT: Final = "comfort" PRESET_SLEEP: Final = "sleep" PRESET_AWAY: Final = "away" PRESET_AUTO: Final = "auto" - # when HA requests an HVAC mode we'll map it to a 'preset' - HVAC_TO_PRESET_MAP: Final = { - HVACMode.OFF: PRESET_OFF, - HVACMode.HEAT: PRESET_CUSTOM, - HVACMode.AUTO: PRESET_AUTO, - } - manager: MerossDeviceBase - _attr_hvac_modes: Final = [HVACMode.OFF, HVACMode.HEAT, HVACMode.AUTO] + _attr_preset_modes: Final = [ - PRESET_OFF, PRESET_CUSTOM, PRESET_COMFORT, PRESET_SLEEP, @@ -62,49 +53,55 @@ class MtsClimate(me.MerossEntity, climate.ClimateEntity): # these mappings are defined in inherited MtsXXX # they'll map between mts device 'mode' and HA 'preset' - MTS_MODE_AUTO: ClassVar[int] MTS_MODE_TO_PRESET_MAP: ClassVar[dict[int, str]] PRESET_TO_TEMPERATUREKEY_MAP: ClassVar[dict[str, str]] + # in general Mts thermostats are only heating..MTS200 with 'summer mode' could override this + MTS_HVAC_MODES: Final = [HVACMode.OFF, HVACMode.HEAT] __slots__ = ( "_attr_current_temperature", "_attr_hvac_action", "_attr_hvac_mode", + "_attr_hvac_modes", "_attr_max_temp", "_attr_min_temp", "_attr_preset_mode", "_attr_target_temperature", + "_mts_active", "_mts_mode", "_mts_onoff", - "_mts_heating", + "_mts_summermode", ) def __init__(self, manager: MerossDeviceBase, channel: object): self._attr_current_temperature = None self._attr_hvac_action = None self._attr_hvac_mode = None + self._attr_hvac_modes = self.MTS_HVAC_MODES self._attr_max_temp = 35 self._attr_min_temp = 5 self._attr_preset_mode = None self._attr_target_temperature = None + self._mts_active = None self._mts_mode: int | None = None self._mts_onoff = None - self._mts_heating = None + self._mts_summermode = None super().__init__(manager, channel, None, None) - def update_modes(self): + def update_mts_state(self): + self._attr_preset_mode = self.MTS_MODE_TO_PRESET_MAP.get(self._mts_mode) # type: ignore if self._mts_onoff: - self._attr_preset_mode = self.MTS_MODE_TO_PRESET_MAP.get(self._mts_mode) # type: ignore - self._attr_hvac_mode = ( - HVACMode.AUTO - if self._attr_preset_mode is MtsClimate.PRESET_AUTO - else HVACMode.HEAT - ) - self._attr_hvac_action = ( - HVACAction.HEATING if self._mts_heating else HVACAction.IDLE - ) + if self._mts_summermode: + self._attr_hvac_mode = HVACMode.COOL + self._attr_hvac_action = ( + HVACAction.COOLING if self._mts_active else HVACAction.IDLE + ) + else: + self._attr_hvac_mode = HVACMode.HEAT + self._attr_hvac_action = ( + HVACAction.HEATING if self._mts_active else HVACAction.IDLE + ) else: - self._attr_preset_mode = MtsClimate.PRESET_OFF self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = HVACAction.OFF @@ -171,17 +168,6 @@ async def async_turn_on(self): async def async_turn_off(self): await self.async_request_onoff(0) - async def async_set_hvac_mode(self, hvac_mode: HVACMode): - if hvac_mode == HVACMode.HEAT: - # when requesting HEAT we'll just switch ON the MTS - # while leaving it's own mode (#48) if it's one of - # the manual modes, else switch it to MTS100MODE_CUSTOM - # through HVAC_TO_PRESET_MAP - if self._mts_mode != self.MTS_MODE_AUTO: - await self.async_request_onoff(1) - return - await self.async_set_preset_mode(MtsClimate.HVAC_TO_PRESET_MAP[hvac_mode]) - async def async_set_preset_mode(self, preset_mode: str): raise NotImplementedError() diff --git a/custom_components/meross_lan/config_flow.py b/custom_components/meross_lan/config_flow.py index 0cc12e0e..477882fc 100644 --- a/custom_components/meross_lan/config_flow.py +++ b/custom_components/meross_lan/config_flow.py @@ -53,11 +53,6 @@ def __init__(self, reason): class MerossFlowHandlerMixin(FlowHandler if typing.TYPE_CHECKING else object): """Mixin providing commons for Config and Option flows""" - _MENU_KEYERROR = { - "step_id": "keyerror", - "menu_options": ["profile", "device"], - } - # this is set for an OptionsFlow _profile_entry: config_entries.ConfigEntry | None = None @@ -79,11 +74,6 @@ class MerossFlowHandlerMixin(FlowHandler if typing.TYPE_CHECKING else object): def async_abort(self, *, reason: str = "already_configured"): return super().async_abort(reason=reason) - def show_keyerror(self): - self._is_keyerror = True - self.profile_config = {} # type: ignore[assignment] - return self.async_show_menu(**self._MENU_KEYERROR) - async def async_step_profile(self, user_input=None): """configure a Meross cloud profile""" errors = {} @@ -225,7 +215,9 @@ async def async_step_profile(self, user_input=None): config_schema[ vol.Optional( mlc.CONF_CHECK_FIRMWARE_UPDATES, - description={DESCR: profile_config.get(mlc.CONF_CHECK_FIRMWARE_UPDATES)}, + description={ + DESCR: profile_config.get(mlc.CONF_CHECK_FIRMWARE_UPDATES) + }, ) ] = bool config_schema[ @@ -243,6 +235,16 @@ async def async_step_profile(self, user_input=None): errors=errors, ) + async def async_step_device(self, user_input=None): + raise NotImplementedError() + + async def async_step_keyerror(self, user_input=None): + self._is_keyerror = True + self.profile_config = {} # type: ignore[assignment] + return self.async_show_menu( + step_id="keyerror", menu_options=["profile", "device"] + ) + async def _async_http_discovery( self, host: str, key: str | None ) -> tuple[mlc.DeviceConfigType, MerossDeviceDescriptor]: @@ -280,9 +282,6 @@ async def _async_http_discovery( descriptor, ) - async def async_step_device(self, user_input=None): - raise NotImplementedError() - class ConfigFlow(MerossFlowHandlerMixin, config_entries.ConfigFlow, domain=mlc.DOMAIN): """Handle a config flow for Meross IoT local LAN.""" @@ -333,7 +332,7 @@ async def async_step_device(self, user_input=None): except ConfigError as error: errors[ERR_BASE] = error.reason except MerossKeyError: - return self.show_keyerror() + return await self.async_step_keyerror() except AbortFlow: errors[ERR_BASE] = ERR_ALREADY_CONFIGURED_DEVICE except Exception as error: @@ -598,7 +597,7 @@ async def async_step_device(self, user_input: mlc.DeviceConfigType | None = None _host, device_config.get(mlc.CONF_KEY) ) except MerossKeyError: - return self.show_keyerror() + return await self.async_step_keyerror() except Exception: pass if self._device_id != _descriptor.uuid: @@ -636,7 +635,7 @@ async def async_step_device(self, user_input: mlc.DeviceConfigType | None = None return self.async_create_entry(data=None) # type: ignore except MerossKeyError: - return self.show_keyerror() + return await self.async_step_keyerror() except ConfigError as error: errors[ERR_BASE] = error.reason except Exception: diff --git a/custom_components/meross_lan/const.py b/custom_components/meross_lan/const.py index 32263cb3..07cf2084 100644 --- a/custom_components/meross_lan/const.py +++ b/custom_components/meross_lan/const.py @@ -101,41 +101,45 @@ class ProfileConfigType(cloudapi.MerossCloudCredentials, total=False): SERVICE_REQUEST = "request" -# key used in service 'request' call +"""name of the general purpose device send request service exposed by meross_lan""" CONF_NOTIFYRESPONSE = "notifyresponse" -# label for MerossApi as a 'fake' cloud profile +"""key used in service 'request' call""" CONF_PROFILE_ID_LOCAL: Final = "" -""" - general working/configuration parameters (waiting to be moved to CONF_ENTRY) -""" -# (maximum) delay of initial poll after device setup +"""label for MerossApi as a 'fake' cloud profile""" + +# general working/configuration parameters +PARAM_INFINITE_EPOCH = 2147483647 # inifinite epoch (2038 bug?) +"""the (infinite) timeout in order to disable timed schedules""" PARAM_COLDSTARTPOLL_DELAY = 2 -# number of seconds since last inquiry to consider the device unavailable +"""(maximum) delay of initial poll after device setup""" PARAM_UNAVAILABILITY_TIMEOUT = 20 -# whatever the connection state periodically inquire the device is there +"""number of seconds since last inquiry/response to consider the device unavailable""" PARAM_HEARTBEAT_PERIOD = 295 -# for polled entities over cloud MQTT use 'at least' this +"""whatever the connection state periodically inquire the device is available""" +PARAM_TIMEZONE_CHECK_OK_PERIOD = 604800 +"""period between checks of timezone infos on locally mqtt binded devices""" +PARAM_TIMEZONE_CHECK_NOTOK_PERIOD = 86400 +"""period between checks of failing timezone infos on locally mqtt binded devices""" +PARAM_TIMESTAMP_TOLERANCE = 5 +"""max device timestamp diff against our and trigger warning and (eventually) fix it""" +PARAM_TRACING_ABILITY_POLL_TIMEOUT = 2 +"""used to delay the iteration of abilities while tracing""" PARAM_CLOUDMQTT_UPDATE_PERIOD = 1795 -# used when restoring 'calculated' state after HA restart +"""for polled entities over cloud MQTT use 'at least' this""" PARAM_RESTORESTATE_TIMEOUT = 300 -# read energy consumption only every ... second +"""used when restoring 'calculated' state after HA restart""" PARAM_ENERGY_UPDATE_PERIOD = 55 -# read energy consumption only every ... second +"""read energy consumption only every ... second""" PARAM_SIGNAL_UPDATE_PERIOD = 295 -# read battery levels only every ... second +"""read energy consumption only every ... second""" PARAM_HUBBATTERY_UPDATE_PERIOD = 3595 +"""read battery levels only every ... second""" PARAM_HUBSENSOR_UPDATE_PERIOD = 55 -# 1 week before retrying timezone updates -PARAM_TIMEZONE_CHECK_PERIOD = 604800 -# PARAM_STALE_DEVICE_REMOVE_TIMEOUT = 60 # disable config_entry when device is offline for more than... PARAM_GARAGEDOOR_TRANSITION_MAXDURATION = 60 PARAM_GARAGEDOOR_TRANSITION_MINDURATION = 10 -# max device timestamp diff against our and trigger warning and (eventually) fix it -PARAM_TIMESTAMP_TOLERANCE = 5 -# used to delay the iteration of abilities while tracing -PARAM_TRACING_ABILITY_POLL_TIMEOUT = 2 -# timeout for querying cloud api deviceInfo endpoint PARAM_CLOUDPROFILE_QUERY_DEVICELIST_TIMEOUT = 86400 # 1 day -# timeout for querying cloud api latestVersion endpoint +"""timeout for querying cloud api deviceInfo endpoint""" PARAM_CLOUDPROFILE_QUERY_LATESTVERSION_TIMEOUT = 604800 # 1 week +"""timeout for querying cloud api latestVersion endpoint""" PARAM_CLOUDPROFILE_DELAYED_SAVE_TIMEOUT = 30 +"""used to delay updated profile data to storage""" diff --git a/custom_components/meross_lan/cover.py b/custom_components/meross_lan/cover.py index 1ef51032..55c83ca0 100644 --- a/custom_components/meross_lan/cover.py +++ b/custom_components/meross_lan/cover.py @@ -17,6 +17,7 @@ ) from homeassistant.const import TIME_SECONDS from homeassistant.core import callback +from homeassistant.helpers import entity_registry from homeassistant.util.dt import now from . import meross_entity as me @@ -30,7 +31,6 @@ PollingStrategy, SmartPollingStrategy, clamp, - get_entity_last_state, get_entity_last_state_available, schedule_async_callback, schedule_callback, @@ -46,11 +46,6 @@ from .meross_device import MerossDevice, MerossDeviceDescriptor -SUPPORT_OPEN = CoverEntityFeature.OPEN -SUPPORT_CLOSE = CoverEntityFeature.CLOSE -SUPPORT_SET_POSITION = CoverEntityFeature.SET_POSITION -SUPPORT_STOP = CoverEntityFeature.STOP - STATE_MAP = {0: STATE_CLOSED, 1: STATE_OPEN} POSITION_FULLY_CLOSED = 0 @@ -79,6 +74,8 @@ async def async_setup_entry( class MLGarageTimeoutBinarySensor(MLBinarySensor): + _attr_entity_category = MLBinarySensor.EntityCategory.DIAGNOSTIC + def __init__(self, cover: MLGarage): self._attr_extra_state_attributes = {} super().__init__( @@ -90,10 +87,6 @@ def __init__(self, cover: MLGarage): def available(self): return True - @property - def entity_category(self): - return me.EntityCategory.DIAGNOSTIC - def set_unavailable(self): pass @@ -111,43 +104,84 @@ def update_timeout(self, target_state): class MLGarageConfigSwitch(MLSwitch): + """ + switch entity to manage MSG configuration (buzzer, enable) + either 'x device' through mc.NS_APPLIANCE_GARAGEDOOR_CONFIG + or 'x channel' through mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG + """ + manager: GarageMixin - def __init__(self, manager: GarageMixin, key: str): + _attr_entity_category = MLSwitch.EntityCategory.CONFIG + + def __init__(self, manager: GarageMixin, channel, key: str): self.key_onoff = key self._attr_name = key - super().__init__(manager, None, f"config_{key}", None) - - @property - def entity_category(self): - return me.EntityCategory.CONFIG + super().__init__(manager, channel, f"config_{key}", None) async def async_request_onoff(self, onoff: int): - config = dict(self.manager.garageDoor_config) - config[self.key_onoff] = onoff - def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: self.update_onoff(onoff) - self.manager.garageDoor_config[self.key_onoff] = config[self.key_onoff] - await self.manager.async_request( - mc.NS_APPLIANCE_GARAGEDOOR_CONFIG, - mc.METHOD_SET, - {mc.KEY_CONFIG: config}, - _ack_callback, - ) + if self.channel is None: + await self.manager.async_request( + mc.NS_APPLIANCE_GARAGEDOOR_CONFIG, + mc.METHOD_SET, + {mc.KEY_CONFIG: {self.key_onoff: onoff}}, + _ack_callback, + ) + else: + await self.manager.async_request( + mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG, + mc.METHOD_SET, + { + mc.KEY_CONFIG: [ + { + mc.KEY_CHANNEL: self.channel, + self.key_onoff: onoff, + } + ] + }, + _ack_callback, + ) + + +class MLGarageDoorEnableSwitch(MLGarageConfigSwitch): + """ + Dedicated entity for "doorEnable" config option in mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG + in order to try enable/disable the same channel associated entities in HA too + when done with the Meross app (#330) + """ + + def update_onoff(self, onoff): + MLGarageConfigSwitch.update_onoff(self, onoff) + registry = entity_registry.async_get(self.hass) + disabler = entity_registry.RegistryEntryDisabler.INTEGRATION + for entity in self.manager.entities.values(): + if ( + (entity.channel == self.channel) + and (entity is not self) + and (entry := entity.registry_entry) + ): + if onoff and entry.disabled_by is disabler: + registry.async_update_entity(entry.entity_id, disabled_by=None) + elif not onoff and not entry.disabled_by: + registry.async_update_entity(entry.entity_id, disabled_by=disabler) class MLGarageConfigNumber(MLConfigNumber): """ - Helper entity to configure MSG open/close duration - this entity reflects the configuration 'per device' and - is managed through mc.NS_APPLIANCE_GARAGEDOOR_CONFIG namespace + number entity to manage MSG configuration (open/close timeout and the likes) + either 'x device' through mc.NS_APPLIANCE_GARAGEDOOR_CONFIG + or 'x channel' through mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG """ manager: GarageMixin + namespace = mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG + key_namespace = mc.KEY_CONFIG + def __init__(self, manager: GarageMixin, channel, key: str, value=None): self.key_value = key self._attr_name = key @@ -165,87 +199,36 @@ def native_unit_of_measurement(self): return TIME_SECONDS async def async_set_native_value(self, value: float): - config: dict[str, object] = dict(self.manager.garageDoor_config) - config[self.key_value] = int(value * self.ml_multiplier) - - def _ack_callback(acknowledge: bool, header: dict, payload: dict): - if acknowledge: - self.update_native_value(config[self.key_value]) - self.manager.garageDoor_config[self.key_value] = config[self.key_value] - - await self.manager.async_request( - mc.NS_APPLIANCE_GARAGEDOOR_CONFIG, - mc.METHOD_SET, - {mc.KEY_CONFIG: config}, - _ack_callback, - ) + if self.channel is None: + native_value = int(value * self.ml_multiplier) + + def _ack_callback(acknowledge: bool, header: dict, payload: dict): + if acknowledge: + self.update_native_value(native_value) + + await self.manager.async_request( + mc.NS_APPLIANCE_GARAGEDOOR_CONFIG, + mc.METHOD_SET, + {mc.KEY_CONFIG: {self.key_value: native_value}}, + _ack_callback, + ) + else: + await MLConfigNumber.async_set_native_value(self, value) @property def ml_multiplier(self): return 1000 -class MLGarageOpenCloseDurationNumber(MLGarageConfigNumber): - """ - Helper entity to configure MSG open/close duration - This entity is bound to the garage channel (i.e. we have - a pair of open/close for each garage entity) and - is not linked to mc.NS_APPLIANCE_GARAGEDOOR_CONFIG. - Newer MSG devices appear to have a door/open configuration - for each channel but we're still lacking the knowledge - in order to configure them. These MLGarageOpenCloseDurationNumber - will try their best (ganbatte neee!) to work out the - mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG namespace - trying to atleast preserve th elocal (HA) state in case the - protocol is not working as expected - """ - - namespace = mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG - key_namespace = mc.KEY_CONFIG - - def __init__(self, cover: MLGarage, key: str, value=None): - super().__init__( - cover.manager, - cover.channel, - key, - value, - ) - - @property - def available(self): - return True - - def set_unavailable(self): - pass - - async def async_added_to_hass(self): - await super().async_added_to_hass() - if self._attr_state is None: - # this is for when we're unsure we can correctly manage MULTIPLECONFIG namespace - # this way we'll at least provide a 'locally managed' entity - self._attr_state = ( - PARAM_GARAGEDOOR_TRANSITION_MAXDURATION - + PARAM_GARAGEDOOR_TRANSITION_MINDURATION - ) / 2 - with self.exception_warning("restoring previous state"): - if last_state := await get_entity_last_state_available( - self.hass, self.entity_id - ): - self._attr_state = float(last_state.state) # type: ignore - - async def async_set_native_value(self, value: float): - if mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG in self.manager.descriptor.ability: - await MLConfigNumber.async_set_native_value(self, value) - else: - self.update_state(value) - - class MLGarage(me.MerossEntity, cover.CoverEntity): PLATFORM = cover.DOMAIN manager: GarageMixin - number_doorOpenDuration: MLGarageConfigNumber | None - number_doorCloseDuration: MLGarageConfigNumber | None + binary_sensor_timeout: MLGarageTimeoutBinarySensor + number_signalClose: MLGarageConfigNumber | None + number_signalOpen: MLGarageConfigNumber | None + switch_buzzerEnable: MLGarageConfigSwitch | None + switch_doorEnable: MLGarageConfigSwitch | None __slots__ = ( "_transition_duration", @@ -255,8 +238,10 @@ class MLGarage(me.MerossEntity, cover.CoverEntity): "_open", "_open_request", "binary_sensor_timeout", - "number_doorOpenDuration", - "number_doorCloseDuration", + "number_signalClose", + "number_signalOpen", + "switch_buzzerEnable", + "switch_doorEnable", ) def __init__(self, manager: GarageMixin, channel: object): @@ -276,32 +261,34 @@ def __init__(self, manager: GarageMixin, channel: object): EXTRA_ATTR_TRANSITION_DURATION: self._transition_duration } self.binary_sensor_timeout = MLGarageTimeoutBinarySensor(self) - if mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG in self.manager.descriptor.ability: - self.number_doorOpenDuration = MLGarageOpenCloseDurationNumber( - self, mc.KEY_DOOROPENDURATION + if mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG in manager.descriptor.ability: + self.number_signalClose = MLGarageConfigNumber( + manager, channel, mc.KEY_SIGNALCLOSE ) - self.number_doorCloseDuration = MLGarageOpenCloseDurationNumber( - self, mc.KEY_DOORCLOSEDURATION + self.number_signalOpen = MLGarageConfigNumber( + manager, channel, mc.KEY_SIGNALOPEN + ) + self.switch_buzzerEnable = MLGarageConfigSwitch( + manager, channel, mc.KEY_BUZZERENABLE + ) + self.switch_doorEnable = MLGarageDoorEnableSwitch( + manager, channel, mc.KEY_DOORENABLE ) else: - self.number_doorOpenDuration = None - self.number_doorCloseDuration = None - - @property - def supported_features(self): - return SUPPORT_OPEN | SUPPORT_CLOSE - - @property - def is_opening(self): - return self._attr_state == STATE_OPENING - - @property - def is_closing(self): - return self._attr_state == STATE_CLOSING + self.number_signalClose = None + self.number_signalOpen = None + self.switch_buzzerEnable = None + self.switch_doorEnable = None - @property - def is_closed(self): - return self._attr_state == STATE_CLOSED + # interface: MerossEntity + async def async_shutdown(self): + self._transition_cancel() + await super().async_shutdown() + self.binary_sensor_timeout = None # type: ignore + self.number_signalClose = None + self.number_signalOpen = None + self.switch_buzzerEnable = None + self.switch_doorEnable = None async def async_added_to_hass(self): await super().async_added_to_hass() @@ -309,7 +296,9 @@ async def async_added_to_hass(self): we're trying to recover the '_transition_duration' from previous state """ with self.exception_warning("restoring previous state"): - if last_state := await get_entity_last_state(self.hass, self.entity_id): + if last_state := await get_entity_last_state_available( + self.hass, self.entity_id + ): _attr = last_state.attributes # type: ignore if EXTRA_ATTR_TRANSITION_DURATION in _attr: # restore anyway besides PARAM_RESTORESTATE_TIMEOUT @@ -320,12 +309,39 @@ async def async_added_to_hass(self): EXTRA_ATTR_TRANSITION_DURATION ] = self._transition_duration + async def async_will_remove_from_hass(self): + self._transition_cancel() + await super().async_will_remove_from_hass() + + def set_unavailable(self): + self._open = None + self._transition_cancel() + super().set_unavailable() + + # interface: cover.CoverEntity + @property + def supported_features(self): + return CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + @property + def is_opening(self): + return self._attr_state == STATE_OPENING + + @property + def is_closing(self): + return self._attr_state == STATE_CLOSING + + @property + def is_closed(self): + return self._attr_state == STATE_CLOSED + async def async_open_cover(self, **kwargs): await self.async_request_position(1) async def async_close_cover(self, **kwargs): await self.async_request_position(0) + # interface: self async def async_request_position(self, open_request: int): def _ack_callback(acknowledge: bool, header: dict, payload: dict): """ @@ -335,40 +351,30 @@ def _ack_callback(acknowledge: bool, header: dict, payload: dict): "execute" represents command ack (I guess: never seen this == 0) Beware: if the garage is 'closed' and we send a 'close' "execute" will be replied as "1" and the garage will stay closed + Update (2023-10-29): the trace in issue #272 shows "execute" == 0 when + the command is not executed because already opened (maybe fw is smarter now) """ if acknowledge: + self._transition_cancel() p_state = payload.get(mc.KEY_STATE, {}) - if p_state.get(mc.KEY_EXECUTE) and open_request != p_state.get( - mc.KEY_OPEN - ): - self._transition_cancel() + self._open = p_state.get(mc.KEY_OPEN) + if p_state.get(mc.KEY_EXECUTE) and open_request != self._open: self._open_request = open_request self._transition_start = time() self.update_state(STATE_OPENING if open_request else STATE_CLOSING) if open_request: - try: - timeout = self.number_doorOpenDuration.native_value # type: ignore - except AttributeError: - # should really not happen if the GarageMixin has global conf key - # for closeDuration. Else, this fw supports 'per channel' conf (#82) - self.number_doorOpenDuration = ( - MLGarageOpenCloseDurationNumber( - self, mc.KEY_DOOROPENDURATION - ) - ) - timeout = self.number_doorOpenDuration.native_value + number = ( + self.number_signalOpen + or self.manager.number_doorOpenDuration + ) else: - try: - timeout = self.number_doorCloseDuration.native_value # type: ignore - except AttributeError: - # should really not happen if the GarageMixin has global conf key - # for closeDuration. Else, this fw supports 'per channel' conf (#82) - self.number_doorCloseDuration = ( - MLGarageOpenCloseDurationNumber( - self, mc.KEY_DOORCLOSEDURATION - ) - ) - timeout = self.number_doorCloseDuration.native_value + number = ( + self.number_signalClose + or self.manager.number_doorCloseDuration + ) + timeout = ( + number.native_value if number else self._transition_duration + ) self._transition_unsub = schedule_async_callback( self.hass, 0.9, self._async_transition_callback @@ -380,6 +386,8 @@ def _ack_callback(acknowledge: bool, header: dict, payload: dict): timeout + 1, # type: ignore self._transition_end_callback, ) + else: + self.update_state(STATE_MAP.get(self._open)) await self.manager.async_request( mc.NS_APPLIANCE_GARAGEDOOR_STATE, @@ -388,69 +396,55 @@ def _ack_callback(acknowledge: bool, header: dict, payload: dict): _ack_callback, ) - async def async_will_remove_from_hass(self): - self._transition_cancel() - await super().async_will_remove_from_hass() - - def set_unavailable(self): - self._open = None - self._transition_cancel() - super().set_unavailable() - def _parse_state(self, payload: dict): # {"channel": 0, "open": 1, "lmTime": 0} self._open = _open = payload[mc.KEY_OPEN] - epoch = self.manager.lastresponse - if self._transition_start is None: - # our state machine is idle and we could be polling a + if not self._transition_start: + # our state machine is idle and we could be receiving a # state change triggered by any external means (app, remote) self.update_state(STATE_MAP.get(_open)) - else: - # state will be updated on _transition_end_callback - # but we monitor the contact switch in order to - # update our estimates for transition duration - if self._open_request != _open: - # keep monitoring the transition in less than 1 sec - if not self._transition_unsub: - self._transition_unsub = schedule_async_callback( - self.hass, 0.9, self._async_transition_callback - ) - else: - # we can monitor the (sampled) exact time when the garage closes to - # estimate the transition_duration and dynamically update it since - # during the transition the state will be closed only at the end - # while during opening the garagedoor contact will open right at the beginning - # and so will be unuseful - # Also to note: if we're on HTTP this sampled time could happen anyway after the 'real' - # state switched to 'closed' so we're likely going to measure in exceed of real transition duration - if not _open: - transition_duration = epoch - self._transition_start - # autoregression filtering applying 20% of last updated sample - self._update_transition_duration( - int((4 * self._transition_duration + transition_duration) / 5) - ) - self._transition_cancel() - self.update_state(STATE_CLOSED) + return - def _parse_config(self, payload): - if mc.KEY_DOOROPENDURATION in payload: - if self.number_doorOpenDuration: - self.number_doorOpenDuration.update_native_value( - payload[mc.KEY_DOOROPENDURATION] - ) - else: - self.number_doorOpenDuration = MLGarageOpenCloseDurationNumber( - self, mc.KEY_DOOROPENDURATION, payload[mc.KEY_DOOROPENDURATION] - ) - if mc.KEY_DOORCLOSEDURATION in payload: - if self.number_doorCloseDuration: - self.number_doorCloseDuration.update_native_value( - payload[mc.KEY_DOORCLOSEDURATION] - ) - else: - self.number_doorCloseDuration = MLGarageOpenCloseDurationNumber( - self, mc.KEY_DOORCLOSEDURATION, payload[mc.KEY_DOORCLOSEDURATION] + # state will be updated on _transition_end_callback + # but we monitor the contact switch in order to + # update our estimates for transition duration + if self._open_request != _open: + # keep monitoring the transition in less than 1 sec + if not self._transition_unsub: + self._transition_unsub = schedule_async_callback( + self.hass, 0.9, self._async_transition_callback ) + return + + # We're "in transition" and the physical contact has reached the target. + # we can monitor the (sampled) exact time when the garage closes to + # estimate the transition_duration and dynamically update it since + # during the transition the state will be closed only at the end + # while during opening the garagedoor contact will open right at the beginning + # and so will be unuseful + # Also to note: if we're on HTTP this sampled time could happen anyway after the 'real' + # state switched to 'closed' so we're likely going to measure in exceed of real transition duration + if not _open: + transition_duration = self.manager.lastresponse - self._transition_start + # autoregression filtering applying 20% of last updated sample + self._update_transition_duration( + int((4 * self._transition_duration + transition_duration) / 5) + ) + self._transition_cancel() + self.update_state(STATE_CLOSED) + + # garage contact is opened but since it opens way sooner than the transition + # ending we'll wait our transition_end in order to update the state + + def _parse_config(self, payload): + if mc.KEY_SIGNALCLOSE in payload: + self.number_signalClose.update_native_value(payload[mc.KEY_SIGNALCLOSE]) # type: ignore + if mc.KEY_SIGNALOPEN in payload: + self.number_signalOpen.update_native_value(payload[mc.KEY_SIGNALOPEN]) # type: ignore + if mc.KEY_BUZZERENABLE in payload: + self.switch_buzzerEnable.update_onoff(payload[mc.KEY_BUZZERENABLE]) # type: ignore + if mc.KEY_DOORENABLE in payload: + self.switch_doorEnable.update_onoff(payload[mc.KEY_DOORENABLE]) # type: ignore def _parse_togglex(self, payload: dict): """ @@ -503,12 +497,10 @@ def _transition_end_callback(self): if self._transition_duration < transition_duration: self._update_transition_duration(self._transition_duration + 1) + self.update_state(STATE_MAP.get(self._open)) # type: ignore if self._open_request == self._open: - # transition correctly ended: set the state according to our last known hardware status self.binary_sensor_timeout.update_ok() - self.update_state(STATE_MAP.get(self._open_request)) # type: ignore else: - # let the current opening/closing state be updated only on subsequent poll self.binary_sensor_timeout.update_timeout(STATE_MAP.get(self._open_request)) # type: ignore self._open_request = None @@ -528,21 +520,16 @@ def _update_transition_duration(self, transition_duration): class GarageMixin( MerossDevice if typing.TYPE_CHECKING else object ): # pylint: disable=used-before-assignment - number_signalDuration: MLGarageConfigNumber - switch_buzzerEnable: MLGarageConfigSwitch - number_doorOpenDuration: MLGarageConfigNumber | None = None - number_doorCloseDuration: MLGarageConfigNumber | None = None + number_signalDuration: MLGarageConfigNumber = None # type: ignore + switch_buzzerEnable: MLGarageConfigSwitch = None # type: ignore + number_doorOpenDuration: MLGarageConfigNumber = None # type: ignore + number_doorCloseDuration: MLGarageConfigNumber = None # type: ignore def __init__(self, descriptor: MerossDeviceDescriptor, entry): - self.garageDoor_config = {} self._polling_payload = [] super().__init__(descriptor, entry) - self.number_signalDuration = MLGarageConfigNumber( - self, None, mc.KEY_SIGNALDURATION - ) - self.number_signalDuration._attr_native_step = 0.1 - self.number_signalDuration._attr_native_min_value = 0.1 - self.switch_buzzerEnable = MLGarageConfigSwitch(self, mc.KEY_BUZZERENABLE) + self.platforms.setdefault(MLGarageConfigNumber.PLATFORM, None) + self.platforms.setdefault(MLGarageConfigSwitch.PLATFORM, None) if mc.NS_APPLIANCE_GARAGEDOOR_CONFIG in descriptor.ability: self.polling_dictionary[ mc.NS_APPLIANCE_GARAGEDOOR_CONFIG @@ -559,12 +546,11 @@ async def async_shutdown(self): await super().async_shutdown() self.number_signalDuration = None # type: ignore self.switch_buzzerEnable = None # type: ignore - self.number_doorOpenDuration = None - self.number_doorCloseDuration = None + self.number_doorOpenDuration = None # type: ignore + self.number_doorCloseDuration = None # type: ignore def _init_garageDoor(self, payload: dict): - channel = payload[mc.KEY_CHANNEL] - MLGarage(self, channel) + MLGarage(self, channel := payload[mc.KEY_CHANNEL]) self._polling_payload.append({mc.KEY_CHANNEL: channel}) def _handle_Appliance_GarageDoor_State(self, header: dict, payload: dict): @@ -572,80 +558,71 @@ def _handle_Appliance_GarageDoor_State(self, header: dict, payload: dict): def _handle_Appliance_GarageDoor_Config(self, header: dict, payload: dict): # {"config": {"signalDuration": 1000, "buzzerEnable": 0, "doorOpenDuration": 30000, "doorCloseDuration": 30000}} - # no channel here ?!..need to parse the manual way - if isinstance(payload := payload.get(mc.KEY_CONFIG), dict): # type: ignore - self.garageDoor_config.update(payload) - - if mc.KEY_SIGNALDURATION in payload: + payload = payload[mc.KEY_CONFIG] + if mc.KEY_SIGNALDURATION in payload: + try: self.number_signalDuration.update_native_value( payload[mc.KEY_SIGNALDURATION] ) + except AttributeError: + self.number_signalDuration = MLGarageConfigNumber( + self, + None, + mc.KEY_SIGNALDURATION, + payload[mc.KEY_SIGNALDURATION], + ) + self.number_signalDuration._attr_native_step = 0.1 + self.number_signalDuration._attr_native_min_value = 0.1 - if mc.KEY_BUZZERENABLE in payload: + if mc.KEY_BUZZERENABLE in payload: + try: self.switch_buzzerEnable.update_onoff(payload[mc.KEY_BUZZERENABLE]) + except AttributeError: + self.switch_buzzerEnable = MLGarageConfigSwitch( + self, None, mc.KEY_BUZZERENABLE + ) - if mc.KEY_DOOROPENDURATION in payload: - # this config key has been removed in recent firmwares - # now we have door open/close duration set per channel (#82) - # but legacy ones still manage this - try: - self.number_doorOpenDuration.update_native_value( # type: ignore - payload[mc.KEY_DOOROPENDURATION] - ) - except AttributeError: - _number_doorOpenDuration = MLGarageConfigNumber( - self, - None, - mc.KEY_DOOROPENDURATION, - payload[mc.KEY_DOOROPENDURATION], - ) - self.number_doorOpenDuration = _number_doorOpenDuration - for i in self._polling_payload: - garage: MLGarage = self.entities[i[mc.KEY_CHANNEL]] # type: ignore - garage.number_doorOpenDuration = _number_doorOpenDuration - else: - # no config for doorOpenDuration: we'll let every channel manage it's own - if not self.number_doorOpenDuration: # use as a guard... - for i in self._polling_payload: - garage: MLGarage = self.entities[i[mc.KEY_CHANNEL]] # type: ignore - garage.number_doorOpenDuration = ( - MLGarageOpenCloseDurationNumber( - garage, mc.KEY_DOOROPENDURATION - ) - ) - self.number_doorOpenDuration = garage.number_doorOpenDuration - - if mc.KEY_DOORCLOSEDURATION in payload: - # this config key has been removed in recent firmwares - # now we have door open/close duration set per channel (#82) - try: - self.number_doorCloseDuration.update_native_value( # type: ignore - payload[mc.KEY_DOORCLOSEDURATION] - ) - except AttributeError: - _number_doorCloseDuration = MLGarageConfigNumber( - self, - None, - mc.KEY_DOORCLOSEDURATION, - payload[mc.KEY_DOORCLOSEDURATION], - ) - self.number_doorCloseDuration = _number_doorCloseDuration - for i in self._polling_payload: - garage: MLGarage = self.entities[i[mc.KEY_CHANNEL]] # type: ignore - garage.number_doorCloseDuration = _number_doorCloseDuration - else: - # no config for doorCloseDuration: we'll let every channel manage it's own - if not self.number_doorCloseDuration: # use as a guard... - for i in self._polling_payload: - garage: MLGarage = self.entities[i[mc.KEY_CHANNEL]] # type: ignore - garage.number_doorCloseDuration = ( - MLGarageOpenCloseDurationNumber( - garage, mc.KEY_DOORCLOSEDURATION - ) - ) - self.number_doorCloseDuration = garage.number_doorCloseDuration + if mc.KEY_DOOROPENDURATION in payload: + # this config key has been removed in recent firmwares + # now we have door open/close duration set per channel (#82) + # but legacy ones still manage this + try: + self.number_doorOpenDuration.update_native_value( # type: ignore + payload[mc.KEY_DOOROPENDURATION] + ) + except AttributeError: + self.number_doorOpenDuration = MLGarageConfigNumber( + self, + None, + mc.KEY_DOOROPENDURATION, + payload[mc.KEY_DOOROPENDURATION], + ) + + if mc.KEY_DOORCLOSEDURATION in payload: + # this config key has been removed in recent firmwares + # now we have door open/close duration set per channel (#82) + try: + self.number_doorCloseDuration.update_native_value( # type: ignore + payload[mc.KEY_DOORCLOSEDURATION] + ) + except AttributeError: + self.number_doorCloseDuration = MLGarageConfigNumber( + self, + None, + mc.KEY_DOORCLOSEDURATION, + payload[mc.KEY_DOORCLOSEDURATION], + ) def _handle_Appliance_GarageDoor_MultipleConfig(self, header: dict, payload: dict): + """ + payload := { + "config": [ + {"channel": 1,"doorEnable": 1,"timestamp": 0,"timestampMs": 0,"signalClose": 2000,"signalOpen": 2000,"buzzerEnable": 1}, + {"channel": 2,"doorEnable": 0,"timestamp": 1699130744,"timestampMs": 87,"signalClose": 2000,"signalOpen": 2000,"buzzerEnable": 1}, + {"channel": 3,"doorEnable": 0,"timestamp": 1699130748,"timestampMs": 663,"signalClose": 2000,"signalOpen": 2000,"buzzerEnable": 1}, + ] + } + """ self._parse__generic(mc.KEY_CONFIG, payload.get(mc.KEY_CONFIG)) def _parse_garageDoor(self, payload): @@ -693,7 +670,7 @@ def __init__(self, manager: RollerShutterMixin, channel: object): try: self._position_native_isgood = versiontuple( manager.descriptor.firmwareVersion - ) >= versiontuple("7.6.10") + ) >= versiontuple("6.6.6") except Exception: self._position_native_isgood = None @@ -704,7 +681,12 @@ def assumed_state(self): @property def supported_features(self): - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + return ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) @property def is_opening(self): @@ -725,7 +707,9 @@ async def async_added_to_hass(self): if it happens it wasn't updated too far in time """ with self.exception_warning("restoring previous state"): - if last_state := await get_entity_last_state(self.hass, self.entity_id): + if last_state := await get_entity_last_state_available( + self.hass, self.entity_id + ): _attr = last_state.attributes # type: ignore if EXTRA_ATTR_DURATION_OPEN in _attr: self._signalOpen = _attr[EXTRA_ATTR_DURATION_OPEN] 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/devices/mss.py b/custom_components/meross_lan/devices/mss.py new file mode 100644 index 00000000..09c3ef84 --- /dev/null +++ b/custom_components/meross_lan/devices/mss.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from logging import DEBUG +from time import time +import typing + +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.util import dt as dt_util + +from ..const import PARAM_ENERGY_UPDATE_PERIOD +from ..helpers import ( + ApiProfile, + EntityPollingStrategy, + SmartPollingStrategy, + get_entity_last_state_available, +) +from ..merossclient import const as mc +from ..sensor import MLSensor +from ..switch import MLSwitch + +if typing.TYPE_CHECKING: + from ..meross_device import MerossDevice, MerossDeviceDescriptor + + +class EnergyEstimateSensor(MLSensor): + _attr_state: int + _attr_state_float: float = 0.0 + + def __init__(self, manager: ElectricityMixin): + super().__init__(manager, None, "energy_estimate", self.DeviceClass.ENERGY) + self._attr_state = 0 + + @property + def entity_registry_enabled_default(self): + return False + + @property + def available(self): + return True + + async def async_added_to_hass(self): + await super().async_added_to_hass() + # state restoration is only needed on cold-start and we have to discriminate + # from when this happens while the device is already working. In general + # the sensor state is always kept in the instance even when it's disabled + # so we don't want to overwrite that should we enable an entity after + # it has been initialized. Checking _attr_state here should be enough + # since it's surely 0 on boot/initial setup (entities are added before + # device reading data). If an entity is disabled on startup of course our state + # will start resetted and our sums will restart (disabled means not interesting + # anyway) + if self._attr_state != 0: + return + + with self.exception_warning("restoring previous state"): + state = await get_entity_last_state_available(self.hass, self.entity_id) + if state is None: + return + if state.last_updated < dt_util.start_of_local_day(): + # tbh I don't know what when last_update == start_of_day + return + # state should be an int though but in case we decide some + # tweaks here or there this conversion is safer (allowing for a float state) + # and more consistent + self._attr_state_float = float(state.state) + self._attr_state = int(self._attr_state_float) + + def set_unavailable(self): + # we need to preserve our sum so we don't reset + # it on disconnection. Also, it's nice to have it + # available since this entity has a computed value + # not directly related to actual connection state + pass + + def update_estimate(self, de: float): + # this is the 'estimated' sensor update api + # based off ElectricityMixin power readings + self._attr_state_float += de + state = int(self._attr_state_float) + if self._attr_state != state: + self._attr_state = state + if self._hass_connected: + self.async_write_ha_state() + + def reset_estimate(self): + self._attr_state_float -= self._attr_state # preserve fraction + self._attr_state = 0 + if self._hass_connected: + self.async_write_ha_state() + + +class ElectricityMixin( + MerossDevice if typing.TYPE_CHECKING else object +): # pylint: disable=used-before-assignment + _electricity_lastupdate = 0.0 + _sensor_power: MLSensor + _sensor_current: MLSensor + _sensor_voltage: MLSensor + # implement an estimated energy measure from _sensor_power. + # Estimate is a trapezoidal integral sum on power. Using class + # initializers to ease instance sharing (and type-checks) + # between ElectricityMixin and ConsumptionMixin. Based on experience + # ElectricityMixin and ConsumptionMixin are always present together + # in metering plugs (mss310 is the historical example). + # Based on observations this estimate is falling a bit behind + # the consumption reported from the device at least when the + # power is very low (likely due to power readings being a bit off) + _sensor_energy_estimate: EnergyEstimateSensor + _cancel_energy_reset = None + + # This is actually reset in ConsumptionMixin + _consumption_estimate = 0.0 + + def __init__(self, descriptor, entry): + super().__init__(descriptor, entry) + self._sensor_power = MLSensor.build_for_device(self, MLSensor.DeviceClass.POWER) + self._sensor_current = MLSensor.build_for_device( + self, MLSensor.DeviceClass.CURRENT + ) + self._sensor_voltage = MLSensor.build_for_device( + self, MLSensor.DeviceClass.VOLTAGE + ) + self._sensor_energy_estimate = EnergyEstimateSensor(self) + self.polling_dictionary[ + mc.NS_APPLIANCE_CONTROL_ELECTRICITY + ] = SmartPollingStrategy(mc.NS_APPLIANCE_CONTROL_ELECTRICITY) + + def start(self): + self._schedule_next_reset(dt_util.now()) + super().start() + + async def async_shutdown(self): + if self._cancel_energy_reset: + self._cancel_energy_reset() + self._cancel_energy_reset = None + await super().async_shutdown() + self._sensor_power = None # type: ignore + self._sensor_current = None # type: ignore + self._sensor_voltage = None # type: ignore + self._sensor_energy_estimate = None # type: ignore + + def _handle_Appliance_Control_Electricity(self, header: dict, payload: dict): + electricity = payload[mc.KEY_ELECTRICITY] + power = float(electricity[mc.KEY_POWER]) / 1000 + if (last_power := self._sensor_power._attr_state) is not None: + # dt = self.lastupdate - self._electricity_lastupdate + # de = (((last_power + power) / 2) * dt) / 3600 + de = ( + (last_power + power) + * (self.lastresponse - self._electricity_lastupdate) + ) / 7200 + self._consumption_estimate += de + self._sensor_energy_estimate.update_estimate(de) + + self._electricity_lastupdate = self.lastresponse + self._sensor_power.update_state(power) + self._sensor_current.update_state(electricity[mc.KEY_CURRENT] / 1000) # type: ignore + self._sensor_voltage.update_state(electricity[mc.KEY_VOLTAGE] / 10) # type: ignore + + def _schedule_next_reset(self, _now: datetime): + with self.exception_warning("_schedule_next_reset"): + today = _now.date() + tomorrow = today + timedelta(days=1) + next_reset = datetime( + year=tomorrow.year, + month=tomorrow.month, + day=tomorrow.day, + hour=0, + minute=0, + second=0, + microsecond=0, + tzinfo=dt_util.DEFAULT_TIME_ZONE, + ) + self._cancel_energy_reset = async_track_point_in_time( + ApiProfile.hass, self._energy_reset, next_reset + ) + self.log(DEBUG, "_schedule_next_reset at %s", next_reset.isoformat()) + + @callback + def _energy_reset(self, _now: datetime): + self._cancel_energy_reset = None + self.log(DEBUG, "_energy_reset at %s", _now.isoformat()) + self._sensor_energy_estimate.reset_estimate() + self._schedule_next_reset(_now) + + +class ConsumptionXSensor(MLSensor): + ATTR_OFFSET = "offset" + offset: int = 0 + ATTR_RESET_TS = "reset_ts" + reset_ts: int = 0 + + manager: ConsumptionXMixin + _attr_state: int | None + + def __init__(self, manager: ConsumptionXMixin): + self._attr_extra_state_attributes = {} + super().__init__( + manager, None, str(self.DeviceClass.ENERGY), self.DeviceClass.ENERGY + ) + + async def async_added_to_hass(self): + await super().async_added_to_hass() + # state restoration is only needed on cold-start and we have to discriminate + # from when this happens while the device is already working. In general + # the sensor state is always kept in the instance even when it's disabled + # so we don't want to overwrite that should we enable an entity after + # it has been initialized. Checking _attr_state here should be enough + # since it's surely 0 on boot/initial setup (entities are added before + # device reading data). If an entity is disabled on startup of course our state + # will start resetted and our sums will restart (disabled means not interesting + # anyway) + if (self._attr_state is not None) or self._attr_extra_state_attributes: + return + + with self.exception_warning("restoring previous state"): + state = await get_entity_last_state_available(self.hass, self.entity_id) + if state is None: + return + # check if the restored sample is fresh enough i.e. it was + # updated after the device midnight for today..else it is too + # old to be good. Since we don't have actual device epoch we + # 'guess' it is nicely synchronized so we'll use our time + devicetime = self.manager.get_device_datetime(time()) + devicetime_today_midnight = datetime( + devicetime.year, + devicetime.month, + devicetime.day, + tzinfo=devicetime.tzinfo, + ) + if state.last_updated < devicetime_today_midnight: + return + # fix beta/preview attr names (sometime REMOVE) + if "energy_offset" in state.attributes: + _attr_value = state.attributes["energy_offset"] + self._attr_extra_state_attributes[self.ATTR_OFFSET] = _attr_value + setattr(self, self.ATTR_OFFSET, _attr_value) + if "energy_reset_ts" in state.attributes: + _attr_value = state.attributes["energy_reset_ts"] + self._attr_extra_state_attributes[self.ATTR_RESET_TS] = _attr_value + setattr(self, self.ATTR_RESET_TS, _attr_value) + for _attr_name in (self.ATTR_OFFSET, self.ATTR_RESET_TS): + if _attr_name in state.attributes: + _attr_value = state.attributes[_attr_name] + self._attr_extra_state_attributes[_attr_name] = _attr_value + # we also set the value as an instance attr for faster access + setattr(self, _attr_name, _attr_value) + # HA adds decimals when the display precision is set for the entity + # according to this issue #268. In order to try not mess statistics + # we're reverting to the old design where the sensor state is + # reported as 'unavailable' when the device is disconnected and so + # we don't restore the state value at all but just wait for a 'fresh' + # consumption value from the device. The attributes restoration will + # instead keep patching the 'consumption reset bug' + + def reset_consumption(self): + if self._attr_state != 0: + self._attr_state = 0 + self._attr_extra_state_attributes = {} + self.offset = 0 + self.reset_ts = 0 + if self._hass_connected: + self.async_write_ha_state() + self.log(DEBUG, "no readings available for new day - resetting") + + +class ConsumptionXMixin( + MerossDevice if typing.TYPE_CHECKING else object +): # pylint: disable=used-before-assignment + _consumption_last_value: int | None = None + _consumption_last_time: int | None = None + # these are the device actual EPOCHs of the last midnight + # and the midnight of they before. midnight epoch(s) are + # the times at which the device local time trips around + # midnight (which could be different than GMT tripping of course) + _yesterday_midnight_epoch = 0 # 12:00 am yesterday + _today_midnight_epoch = 0 # 12:00 am today + _tomorrow_midnight_epoch = 0 # 12:00 am tomorrow + + # instance value shared with ElectricityMixin + _consumption_estimate = 0.0 + + def __init__(self, descriptor, entry): + super().__init__(descriptor, entry) + self._sensor_consumption: ConsumptionXSensor = ConsumptionXSensor(self) + self.polling_dictionary[ + mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX + ] = EntityPollingStrategy( + mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX, + self._sensor_consumption, + PARAM_ENERGY_UPDATE_PERIOD, + ) + + async def async_shutdown(self): + await super().async_shutdown() + self._sensor_consumption = None # type: ignore + + def _handle_Appliance_Control_ConsumptionX(self, header: dict, payload: dict): + _sensor_consumption = self._sensor_consumption + # we'll look through the device array values to see + # data timestamped (in device time) after last midnight + # since we usually reset this around midnight localtime + # the device timezone should be aligned else it will roundtrip + # against it's own midnight and we'll see a delayed 'sawtooth' + if self.device_timestamp > self._tomorrow_midnight_epoch: + # catch the device starting a new day since our last update (yesterday) + devtime = self.get_device_datetime(self.device_timestamp) + devtime_today_midnight = datetime( + devtime.year, + devtime.month, + devtime.day, + tzinfo=devtime.tzinfo, + ) + # we'd better not trust our cached tomorrow, today and yesterday + # epochs (even if 99% of the times they should be good) + # so we fully recalculate them on each 'midnight trip update' + # and spend some cpu resources this way... + self._today_midnight_epoch = devtime_today_midnight.timestamp() + daydelta = timedelta(days=1) + self._tomorrow_midnight_epoch = ( + devtime_today_midnight + daydelta + ).timestamp() + self._yesterday_midnight_epoch = ( + devtime_today_midnight - daydelta + ).timestamp() + self.log( + DEBUG, + "updated midnight epochs: yesterday=%s - today=%s - tomorrow=%s", + str(self._yesterday_midnight_epoch), + str(self._today_midnight_epoch), + str(self._tomorrow_midnight_epoch), + ) + + # the days array contains a month worth of data + # but we're only interested in the last few days (today + # and maybe yesterday) so we discard a bunch of + # elements before sorting (in order to not waste time) + # checks for 'not enough meaningful data' are post-poned + # and just for safety since they're unlikely to happen + # in a normal running environment over few days + days = [ + day + for day in payload[mc.KEY_CONSUMPTIONX] + if day[mc.KEY_TIME] >= self._yesterday_midnight_epoch + ] + if (days_len := len(days)) == 0: + _sensor_consumption.reset_consumption() + return + + elif days_len > 1: + + def _get_timestamp(day): + return day[mc.KEY_TIME] + + days = sorted(days, key=_get_timestamp) + + day_last: dict = days[-1] + day_last_time: int = day_last[mc.KEY_TIME] + + if day_last_time < self._today_midnight_epoch: + # this could happen right after midnight when the device + # should start a new cycle but the consumption is too low + # (device starts reporting from 1 wh....) so, even if + # new day has come, new data have not + self._consumption_last_value = None + _sensor_consumption.reset_consumption() + return + + # now day_last 'should' contain today data in HA time. + day_last_value: int = day_last[mc.KEY_VALUE] + # check if the device tripped its own midnight and started a + # new day readings + if days_len > 1 and ( + _sensor_consumption.reset_ts + != (day_yesterday_time := days[-2][mc.KEY_TIME]) + ): + # this is the first time after device midnight that we receive new data. + # in order to fix #264 we're going to set our internal energy offset. + # This is very dangerous since we must discriminate between faulty + # resets and good resets from the device. Typically the device resets + # itself correctly and we have new 0-based readings but we can't + # reliably tell when the error happens since the 'new' reading could be + # any positive value depending on actual consumption of the device + + # first off we consider the device readings good + _sensor_consumption.reset_ts = day_yesterday_time + _sensor_consumption.offset = 0 + _sensor_consumption._attr_extra_state_attributes = { + _sensor_consumption.ATTR_RESET_TS: day_yesterday_time + } + if (self._consumption_last_time is not None) and ( + self._consumption_last_time <= day_yesterday_time + ): + # In order to fix #264 and any further bug in consumption + # we'll check it against _consumption_estimate from ElectricityMixin. + # _consumption_estimate is reset in ConsumptionMixin every time we + # get a new fresh consumption value and should contain an estimate + # over the last (device) accumulation period. Here we're across the + # device midnight reset so our _consumption_estimate is trying + # to measure the effective consumption since the last updated + # reading of yesterday. The check on _consumption_last_time is + # to make sure we're not applying any offset when we start 'fresh' + # reading during a day and HA has no state carried over since + # midnight on this sensor + energy_estimate = int(self._consumption_estimate) + 1 + if day_last_value > energy_estimate: + _sensor_consumption._attr_extra_state_attributes[ + _sensor_consumption.ATTR_OFFSET + ] = _sensor_consumption.offset = (day_last_value - energy_estimate) + self.log( + DEBUG, + "first consumption reading for new day, offset=%d", + _sensor_consumption.offset, + ) + + elif day_last_value == self._consumption_last_value: + # no change in consumption..skip updating unless sensor was disconnected + if _sensor_consumption._attr_state is None: + _sensor_consumption._attr_state = ( + day_last_value - _sensor_consumption.offset + ) + if _sensor_consumption._hass_connected: + _sensor_consumption.async_write_ha_state() + return + + self._consumption_last_time = day_last_time + self._consumption_last_value = day_last_value + self._consumption_estimate = 0.0 # reset ElecticityMixin estimate cycle + _sensor_consumption._attr_state = day_last_value - _sensor_consumption.offset + if _sensor_consumption._hass_connected: + _sensor_consumption.async_write_ha_state() + self.log(DEBUG, "updating consumption=%d", day_last_value) + + def _set_offline(self): + super()._set_offline() + self._yesterday_midnight_epoch = 0 + self._today_midnight_epoch = 0 + self._tomorrow_midnight_epoch = 0 + + +class OverTempEnableSwitch(MLSwitch): + _attr_entity_category = MLSwitch.EntityCategory.CONFIG + + def __init__(self, manager: OverTempMixin): + super().__init__( + manager, None, "config_overtemp_enable", self.DeviceClass.SWITCH + ) + + async def async_request_onoff(self, onoff: int): + def _ack_callback(acknowledge: bool, header: dict, payload: dict): + if acknowledge: + self.update_onoff(onoff) + + await self.manager.async_request( + mc.NS_APPLIANCE_CONFIG_OVERTEMP, + mc.METHOD_SET, + {mc.KEY_OVERTEMP: {mc.KEY_ENABLE: onoff}}, + _ack_callback, + ) + + +class OverTempMixin( + MerossDevice if typing.TYPE_CHECKING else object +): # pylint: disable=used-before-assignment + def __init__(self, descriptor: MerossDeviceDescriptor, entry): + super().__init__(descriptor, entry) + self._switch_overtemp_enable: OverTempEnableSwitch = OverTempEnableSwitch(self) + self._sensor_overtemp_type: MLSensor = MLSensor( + self, None, "config_overtemp_type", MLSensor.DeviceClass.ENUM + ) + self.polling_dictionary[ + mc.NS_APPLIANCE_CONFIG_OVERTEMP + ] = EntityPollingStrategy( + mc.NS_APPLIANCE_CONFIG_OVERTEMP, + self._switch_overtemp_enable, + ) + + async def async_shutdown(self): + await super().async_shutdown() + self._switch_overtemp_enable = None # type: ignore + self._sensor_overtemp_type = None # type: ignore + + def _handle_Appliance_Config_OverTemp(self, header: dict, payload: dict): + """{"overTemp": {"enable": 1,"type": 1}}""" + overtemp = payload[mc.KEY_OVERTEMP] + if mc.KEY_ENABLE in overtemp: + self._switch_overtemp_enable.update_onoff(overtemp[mc.KEY_ENABLE]) + if mc.KEY_TYPE in overtemp: + self._sensor_overtemp_type.update_state(overtemp[mc.KEY_TYPE]) + + def _handle_Appliance_Control_OverTemp(self, header: dict, payload: dict): + pass diff --git a/custom_components/meross_lan/devices/mts100.py b/custom_components/meross_lan/devices/mts100.py index 74504b61..4c621248 100644 --- a/custom_components/meross_lan/devices/mts100.py +++ b/custom_components/meross_lan/devices/mts100.py @@ -7,7 +7,6 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt -from .. import meross_entity as me from ..calendar import ( EVENT_END, EVENT_RRULE, @@ -17,7 +16,7 @@ CalendarEvent, MLCalendar, ) -from ..climate import MtsClimate, MtsSetPointNumber +from ..climate import HVACMode, MtsClimate, MtsSetPointNumber from ..helpers import clamp, reverse_lookup from ..merossclient import const as mc # mEROSS cONST @@ -28,7 +27,6 @@ class Mts100Climate(MtsClimate): """Climate entity for hub paired devices MTS100, MTS100V3, MTS150""" - MTS_MODE_AUTO = mc.MTS100_MODE_AUTO MTS_MODE_TO_PRESET_MAP = { mc.MTS100_MODE_CUSTOM: MtsClimate.PRESET_CUSTOM, mc.MTS100_MODE_HEAT: MtsClimate.PRESET_COMFORT, @@ -42,7 +40,6 @@ class Mts100Climate(MtsClimate): # target temp but of course the valve will not follow # this temp since it's mode is not set to follow a manual set PRESET_TO_TEMPERATUREKEY_MAP = { - MtsClimate.PRESET_OFF: mc.KEY_CUSTOM, MtsClimate.PRESET_CUSTOM: mc.KEY_CUSTOM, MtsClimate.PRESET_COMFORT: mc.KEY_COMFORT, MtsClimate.PRESET_SLEEP: mc.KEY_ECONOMY, @@ -67,27 +64,30 @@ def scheduleBMode(self, value): else: self._attr_extra_state_attributes.pop(mc.KEY_SCHEDULEBMODE) - async def async_set_preset_mode(self, preset_mode: str): - if preset_mode == MtsClimate.PRESET_OFF: + async def async_set_hvac_mode(self, hvac_mode: HVACMode): + if hvac_mode == HVACMode.OFF: await self.async_request_onoff(0) else: - mode = reverse_lookup(Mts100Climate.MTS_MODE_TO_PRESET_MAP, preset_mode) - if mode is not None: - - def _ack_callback(acknowledge: bool, header: dict, payload: dict): - if acknowledge: - self._mts_mode = mode - self.update_modes() - - await self.manager.async_request( - mc.NS_APPLIANCE_HUB_MTS100_MODE, - mc.METHOD_SET, - {mc.KEY_MODE: [{mc.KEY_ID: self.id, mc.KEY_STATE: mode}]}, - _ack_callback, - ) + await self.async_request_onoff(1) - if not self._mts_onoff: - await self.async_request_onoff(1) + async def async_set_preset_mode(self, preset_mode: str): + mode = reverse_lookup(Mts100Climate.MTS_MODE_TO_PRESET_MAP, preset_mode) + if mode is not None: + + def _ack_callback(acknowledge: bool, header: dict, payload: dict): + if acknowledge: + self._mts_mode = mode + self.update_mts_state() + + await self.manager.async_request( + mc.NS_APPLIANCE_HUB_MTS100_MODE, + mc.METHOD_SET, + {mc.KEY_MODE: [{mc.KEY_ID: self.id, mc.KEY_STATE: mode}]}, + _ack_callback, + ) + + if not self._mts_onoff: + await self.async_request_onoff(1) async def async_set_temperature(self, **kwargs): t = kwargs[Mts100Climate.ATTR_TEMPERATURE] @@ -98,7 +98,7 @@ async def async_set_temperature(self, **kwargs): def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: self._attr_target_temperature = t - self.update_modes() + self.update_mts_state() # when sending a temp this way the device will automatically # exit auto mode if needed @@ -115,7 +115,7 @@ async def async_request_onoff(self, onoff: int): def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: self._mts_onoff = onoff - self.update_modes() + self.update_mts_state() await self.manager.async_request( mc.NS_APPLIANCE_HUB_TOGGLEX, @@ -190,9 +190,11 @@ def get_event(self) -> CalendarEvent: class Mts100Schedule(MLCalendar): manager: MTS100SubDevice - _schedule: dict[str, list] | None + _attr_entity_category = MLCalendar.EntityCategory.CONFIG _attr_state: dict[str, list] | None + _schedule: dict[str, list] | None + __slots__ = ( "climate", "_schedule", @@ -228,10 +230,6 @@ def __init__(self, climate: Mts100Climate): self._schedule_entry_count = 0 super().__init__(climate.manager, climate.id, mc.KEY_SCHEDULE, None) - @property - def entity_category(self): - return me.EntityCategory.CONFIG - @property def schedule(self): if self._schedule is None: @@ -366,7 +364,9 @@ async def async_get_events( ) -> list[CalendarEvent]: """Return calendar events within a datetime range.""" events = [] - event_entry = self._get_event_entry(start_date.astimezone(self.manager.hub.tzinfo)) + event_entry = self._get_event_entry( + start_date.astimezone(self.manager.hub.tzinfo) + ) while event_entry: event = event_entry.get_event() if event.start >= end_date: @@ -642,7 +642,7 @@ def _parse_schedule(self, payload: dict): if self._hass_connected: self._async_write_ha_state() - def update_climate_modes(self): + def update_mts_state(self): # since our state/active event is dependent on climate mode # we'll force a state update when the climate entity if self._hass_connected: diff --git a/custom_components/meross_lan/devices/mts200.py b/custom_components/meross_lan/devices/mts200.py index e3370b4d..e34fe1cb 100644 --- a/custom_components/meross_lan/devices/mts200.py +++ b/custom_components/meross_lan/devices/mts200.py @@ -3,11 +3,10 @@ import typing from ..binary_sensor import MLBinarySensor -from ..climate import MtsClimate, MtsSetPointNumber +from ..climate import HVACMode, MtsClimate, MtsSetPointNumber from ..helpers import SmartPollingStrategy, reverse_lookup -from ..meross_entity import EntityCategory from ..merossclient import const as mc -from ..number import MLConfigNumber +from ..number import PERCENTAGE, MLConfigNumber from ..sensor import MLSensor from ..switch import MLSwitch @@ -24,6 +23,40 @@ class Mts200SetPointNumber(MtsSetPointNumber): key_namespace = mc.KEY_MODE +class Mts200CalibrationNumber(MLConfigNumber): + """ + customize MLConfigNumber to interact with thermostat calibration + """ + + _attr_name = "Calibration" + + namespace = mc.NS_APPLIANCE_CONTROL_THERMOSTAT_CALIBRATION + key_namespace = mc.KEY_CALIBRATION + key_value = mc.KEY_VALUE + + def __init__(self, manager: ThermostatMixin, channel: object | None): + self._attr_native_max_value = 8 + self._attr_native_min_value = -8 + super().__init__( + manager, + channel, + mc.KEY_CALIBRATION, + MLConfigNumber.DeviceClass.TEMPERATURE, + ) + + @property + def native_step(self): + return 0.1 + + @property + def native_unit_of_measurement(self): + return MtsClimate.TEMP_CELSIUS + + @property + def ml_multiplier(self): + return 10 + + class Mts200OverheatThresholdNumber(MLConfigNumber): """ customize MLConfigNumber to interact with overheat protection value @@ -59,6 +92,8 @@ def ml_multiplier(self): class Mts200ConfigSwitch(MLSwitch): + _attr_entity_category = MLSwitch.EntityCategory.CONFIG + namespace: str def __init__(self, climate: Mts200Climate, entitykey: str, namespace: str): @@ -71,10 +106,6 @@ def __init__(self, climate: Mts200Climate, entitykey: str, namespace: str): namespace, ) - @property - def entity_category(self): - return EntityCategory.CONFIG - async def async_request_onoff(self, onoff: int): def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: @@ -98,7 +129,6 @@ def _ack_callback(acknowledge: bool, header: dict, payload: dict): class Mts200Climate(MtsClimate): """Climate entity for MTS200 devices""" - MTS_MODE_AUTO = mc.MTS200_MODE_AUTO MTS_MODE_TO_PRESET_MAP = { mc.MTS200_MODE_CUSTOM: MtsClimate.PRESET_CUSTOM, mc.MTS200_MODE_HEAT: MtsClimate.PRESET_COMFORT, @@ -112,7 +142,6 @@ class Mts200Climate(MtsClimate): # target temp but of course the valve will not follow # this temp since it's mode is not set to follow a manual set PRESET_TO_TEMPERATUREKEY_MAP = { - MtsClimate.PRESET_OFF: mc.KEY_MANUALTEMP, MtsClimate.PRESET_CUSTOM: mc.KEY_MANUALTEMP, MtsClimate.PRESET_COMFORT: mc.KEY_HEATTEMP, MtsClimate.PRESET_SLEEP: mc.KEY_COOLTEMP, @@ -121,24 +150,32 @@ class Mts200Climate(MtsClimate): } manager: ThermostatMixin + number_comfort_temperature: Mts200SetPointNumber + number_sleep_temperature: Mts200SetPointNumber + number_away_temperature: Mts200SetPointNumber + number_calibration_value: Mts200CalibrationNumber + switch_overheat_onoff: Mts200ConfigSwitch + sensor_overheat_warning: MLSensor + number_overheat_value: Mts200OverheatThresholdNumber + switch_sensor_mode: Mts200ConfigSwitch + sensor_externalsensor_temperature: MLSensor + binary_sensor_windowOpened: MLBinarySensor __slots__ = ( "number_comfort_temperature", "number_sleep_temperature", "number_away_temperature", - "binary_sensor_windowOpened", - "switch_sensor_mode", + "number_calibration_value", "switch_overheat_onoff", "sensor_overheat_warning", "number_overheat_value", + "switch_sensor_mode", "sensor_externalsensor_temperature", + "binary_sensor_windowOpened", ) def __init__(self, manager: ThermostatMixin, channel: object): super().__init__(manager, channel) - # TODO: better cleanup since these circular dependencies - # prevent proper object release (likely the Mts200SetPointNumber - # which keeps a reference to this climate) self.number_comfort_temperature = Mts200SetPointNumber( self, MtsClimate.PRESET_COMFORT ) @@ -148,14 +185,11 @@ def __init__(self, manager: ThermostatMixin, channel: object): self.number_away_temperature = Mts200SetPointNumber( self, MtsClimate.PRESET_AWAY ) - self.binary_sensor_windowOpened = MLBinarySensor( - manager, channel, mc.KEY_WINDOWOPENED, MLBinarySensor.DeviceClass.WINDOW - ) - # sensor mode: use internal(0) vs external(1) sensor as temperature loopback - self.switch_sensor_mode = Mts200ConfigSwitch( - self, "external sensor mode", mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR + # calibration + self.number_calibration_value = Mts200CalibrationNumber( + manager, + channel, ) - self.switch_sensor_mode.key_onoff = mc.KEY_MODE # overheat protection self.switch_overheat_onoff = Mts200ConfigSwitch( self, "overheat protection", mc.NS_APPLIANCE_CONTROL_THERMOSTAT_OVERHEAT @@ -168,31 +202,104 @@ def __init__(self, manager: ThermostatMixin, channel: object): manager, channel, ) + # sensor mode: use internal(0) vs external(1) sensor as temperature loopback + self.switch_sensor_mode = Mts200ConfigSwitch( + self, "external sensor mode", mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR + ) + self.switch_sensor_mode.key_onoff = mc.KEY_MODE self.sensor_externalsensor_temperature = MLSensor( manager, channel, "external sensor", MLSensor.DeviceClass.TEMPERATURE ) + # windowOpened + self.binary_sensor_windowOpened = MLBinarySensor( + manager, channel, mc.KEY_WINDOWOPENED, MLBinarySensor.DeviceClass.WINDOW + ) - async def async_set_preset_mode(self, preset_mode: str): - if preset_mode == MtsClimate.PRESET_OFF: + if mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SUMMERMODE in manager.descriptor.ability: + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL] + + # interface: MtsClimate + async def async_shutdown(self): + self.number_comfort_temperature = None # type: ignore + self.number_sleep_temperature = None # type: ignore + self.number_away_temperature = None # type: ignore + self.number_calibration_value = None # type: ignore + self.switch_overheat_onoff = None # type: ignore + self.sensor_overheat_warning = None # type: ignore + self.number_overheat_value = None # type: ignore + self.switch_sensor_mode = None # type: ignore + self.sensor_externalsensor_temperature = None # type: ignore + self.binary_sensor_windowOpened = None # type: ignore + await super().async_shutdown() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode): + if hvac_mode == HVACMode.OFF: await self.async_request_onoff(0) - else: - mode = reverse_lookup(Mts200Climate.MTS_MODE_TO_PRESET_MAP, preset_mode) - if mode is not None: + return + + if hvac_mode == HVACMode.COOL: + if not self._mts_summermode: def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: - self._mts_mode = mode - self.update_modes() + self._mts_summermode = 1 + self.update_mts_state() await self.manager.async_request( - mc.NS_APPLIANCE_CONTROL_THERMOSTAT_MODE, + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SUMMERMODE, mc.METHOD_SET, - {mc.KEY_MODE: [{mc.KEY_CHANNEL: self.channel, mc.KEY_MODE: mode}]}, + { + mc.KEY_SUMMERMODE: [ + {mc.KEY_CHANNEL: self.channel, mc.KEY_MODE: 1} + ] + }, _ack_callback, ) + elif hvac_mode == HVACMode.HEAT: + if self._mts_summermode: + + def _ack_callback(acknowledge: bool, header: dict, payload: dict): + if acknowledge: + self._mts_summermode = 0 + self.update_mts_state() - if not self._mts_onoff: - await self.async_request_onoff(1) + await self.manager.async_request( + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SUMMERMODE, + mc.METHOD_SET, + { + mc.KEY_SUMMERMODE: [ + {mc.KEY_CHANNEL: self.channel, mc.KEY_MODE: 0} + ] + }, + _ack_callback, + ) + + await self.async_request_onoff(1) + + async def async_set_preset_mode(self, preset_mode: str): + mode = reverse_lookup(Mts200Climate.MTS_MODE_TO_PRESET_MAP, preset_mode) + if mode is not None: + + def _ack_callback(acknowledge: bool, header: dict, payload: dict): + if acknowledge: + self._mts_mode = mode + self._mts_onoff = 1 + self.update_mts_state() + + await self.manager.async_request( + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_MODE, + mc.METHOD_SET, + { + mc.KEY_MODE: [ + { + mc.KEY_CHANNEL: self.channel, + mc.KEY_MODE: mode, + mc.KEY_ONOFF: 1, + } + ] + }, + _ack_callback, + ) async def async_set_temperature(self, **kwargs): t = kwargs[Mts200Climate.ATTR_TEMPERATURE] @@ -203,7 +310,7 @@ async def async_set_temperature(self, **kwargs): def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: self._attr_target_temperature = t - self.update_modes() + self.update_mts_state() await self.manager.async_request( mc.NS_APPLIANCE_CONTROL_THERMOSTAT_MODE, @@ -216,7 +323,7 @@ async def async_request_onoff(self, onoff: int): def _ack_callback(acknowledge: bool, header: dict, payload: dict): if acknowledge: self._mts_onoff = onoff - self.update_modes() + self.update_mts_state() await self.manager.async_request( mc.NS_APPLIANCE_CONTROL_THERMOSTAT_MODE, @@ -225,6 +332,19 @@ def _ack_callback(acknowledge: bool, header: dict, payload: dict): _ack_callback, ) + # message handlers + def _parse_calibration(self, payload: dict): + """{"channel": 0, "value": 0, "min": -80, "max": 80, "lmTime": 1697010767}""" + if mc.KEY_MIN in payload: + self.number_calibration_value._attr_native_min_value = ( + payload[mc.KEY_MIN] / 10 + ) + if mc.KEY_MAX in payload: + self.number_calibration_value._attr_native_max_value = ( + payload[mc.KEY_MAX] / 10 + ) + self.number_calibration_value.update_native_value(payload[mc.KEY_VALUE]) + def _parse_mode(self, payload: dict): """{ "channel": 0, @@ -247,7 +367,7 @@ def _parse_mode(self, payload: dict): if mc.KEY_ONOFF in payload: self._mts_onoff = payload[mc.KEY_ONOFF] if mc.KEY_STATE in payload: - self._mts_heating = payload[mc.KEY_STATE] + self._mts_active = payload[mc.KEY_STATE] if isinstance(_t := payload.get(mc.KEY_CURRENTTEMP), int): self._attr_current_temperature = _t / 10 if isinstance(_t := payload.get(mc.KEY_TARGETTEMP), int): @@ -262,15 +382,7 @@ def _parse_mode(self, payload: dict): self.number_sleep_temperature.update_native_value(_t) if isinstance(_t := payload.get(mc.KEY_ECOTEMP), int): self.number_away_temperature.update_native_value(_t) - self.update_modes() - - def _parse_windowOpened(self, payload: dict): - """{ "channel": 0, "status": 0, "lmTime": 1642425303 }""" - self.binary_sensor_windowOpened.update_onoff(payload[mc.KEY_STATUS]) - - def _parse_sensor(self, payload: dict): - """{ "channel": 0, "mode": 0 }""" - self.switch_sensor_mode.update_onoff(payload[mc.KEY_MODE]) + self.update_mts_state() def _parse_overheat(self, payload: dict): """{"warning": 0, "value": 335, "onoff": 1, "min": 200, "max": 700, @@ -293,12 +405,30 @@ def _parse_overheat(self, payload: dict): payload[mc.KEY_CURRENTTEMP] / 10 ) + def _parse_sensor(self, payload: dict): + """{ "channel": 0, "mode": 0 }""" + self.switch_sensor_mode.update_onoff(payload[mc.KEY_MODE]) + + def _parse_summerMode(self, payload: dict): + """{ "channel": 0, "mode": 0 }""" + # guessed code right now since we don't have any summerMode payload example + if mc.KEY_MODE in payload: + summermode = payload[mc.KEY_MODE] + if self._mts_summermode != summermode: + self._mts_summermode = summermode + self.update_mts_state() + + def _parse_windowOpened(self, payload: dict): + """{ "channel": 0, "status": 0, "lmTime": 1642425303 }""" + self.binary_sensor_windowOpened.update_onoff(payload[mc.KEY_STATUS]) + class ThermostatMixin( MerossDevice if typing.TYPE_CHECKING else object ): # pylint: disable=used-before-assignment _polling_payload: list + # interface: self def _init_thermostat(self, payload: dict): self._polling_payload = [] mode = payload.get(mc.KEY_MODE) @@ -307,12 +437,15 @@ def _init_thermostat(self, payload: dict): Mts200Climate(self, m[mc.KEY_CHANNEL]) self._polling_payload.append({mc.KEY_CHANNEL: m[mc.KEY_CHANNEL]}) if self._polling_payload: - if mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR in self.descriptor.ability: + if ( + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_CALIBRATION + in self.descriptor.ability + ): self.polling_dictionary[ - mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_CALIBRATION ] = SmartPollingStrategy( - mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR, - {mc.KEY_SENSOR: self._polling_payload}, + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_CALIBRATION, + {mc.KEY_CALIBRATION: self._polling_payload}, ) if mc.NS_APPLIANCE_CONTROL_THERMOSTAT_OVERHEAT in self.descriptor.ability: self.polling_dictionary[ @@ -321,9 +454,20 @@ def _init_thermostat(self, payload: dict): mc.NS_APPLIANCE_CONTROL_THERMOSTAT_OVERHEAT, {mc.KEY_OVERHEAT: self._polling_payload}, ) - - def _handle_Appliance_Control_Thermostat_Mode(self, header: dict, payload: dict): - self._parse__generic_array(mc.KEY_MODE, payload[mc.KEY_MODE]) + if mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR in self.descriptor.ability: + self.polling_dictionary[ + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR + ] = SmartPollingStrategy( + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR, + {mc.KEY_SENSOR: self._polling_payload}, + ) + if mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SUMMERMODE in self.descriptor.ability: + self.polling_dictionary[ + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SUMMERMODE + ] = SmartPollingStrategy( + mc.NS_APPLIANCE_CONTROL_THERMOSTAT_SUMMERMODE, + {mc.KEY_SUMMERMODE: self._polling_payload}, + ) def _handle_Appliance_Control_Thermostat_Calibration( self, header: dict, payload: dict @@ -338,28 +482,36 @@ def _handle_Appliance_Control_Thermostat_DeadZone( def _handle_Appliance_Control_Thermostat_Frost(self, header: dict, payload: dict): self._parse__generic_array(mc.KEY_FROST, payload[mc.KEY_FROST]) - def _handle_Appliance_Control_Thermostat_Overheat( + def _handle_Appliance_Control_Thermostat_HoldAction( self, header: dict, payload: dict ): - self._parse__generic_array(mc.KEY_OVERHEAT, payload[mc.KEY_OVERHEAT]) + self._parse__generic_array(mc.KEY_HOLDACTION, payload[mc.KEY_HOLDACTION]) - def _handle_Appliance_Control_Thermostat_windowOpened( + def _handle_Appliance_Control_Thermostat_Mode(self, header: dict, payload: dict): + self._parse__generic_array(mc.KEY_MODE, payload[mc.KEY_MODE]) + + def _handle_Appliance_Control_Thermostat_Overheat( self, header: dict, payload: dict ): - self._parse__generic_array(mc.KEY_WINDOWOPENED, payload[mc.KEY_WINDOWOPENED]) + self._parse__generic_array(mc.KEY_OVERHEAT, payload[mc.KEY_OVERHEAT]) def _handle_Appliance_Control_Thermostat_Schedule( self, header: dict, payload: dict ): self._parse__generic_array(mc.KEY_SCHEDULE, payload[mc.KEY_SCHEDULE]) - def _handle_Appliance_Control_Thermostat_HoldAction( + def _handle_Appliance_Control_Thermostat_Sensor(self, header: dict, payload: dict): + self._parse__generic_array(mc.KEY_SENSOR, payload[mc.KEY_SENSOR]) + + def _handle_Appliance_Control_Thermostat_SummerMode( self, header: dict, payload: dict ): - self._parse__generic_array(mc.KEY_HOLDACTION, payload[mc.KEY_HOLDACTION]) + self._parse__generic_array(mc.KEY_SUMMERMODE, payload[mc.KEY_SUMMERMODE]) - def _handle_Appliance_Control_Thermostat_Sensor(self, header: dict, payload: dict): - self._parse__generic_array(mc.KEY_SENSOR, payload[mc.KEY_SENSOR]) + def _handle_Appliance_Control_Thermostat_WindowOpened( + self, header: dict, payload: dict + ): + self._parse__generic_array(mc.KEY_WINDOWOPENED, payload[mc.KEY_WINDOWOPENED]) def _parse_thermostat(self, payload: dict): """ @@ -389,3 +541,93 @@ def _parse_thermostat(self, payload: dict): """ for key, value in payload.items(): self._parse__generic_array(key, value) + + +class MLScreenBrightnessNumber(MLConfigNumber): + manager: ScreenBrightnessMixin + + _attr_icon = "mdi:brightness-percent" + + def __init__(self, manager: ScreenBrightnessMixin, channel: object, key: str): + self.key_value = key + self._attr_name = f"Screen brightness ({key})" + super().__init__(manager, channel, f"screenbrightness_{key}") + + @property + def native_max_value(self): + return 100 + + @property + def native_min_value(self): + return 0 + + @property + def native_step(self): + return 12.5 + + @property + def native_unit_of_measurement(self): + return PERCENTAGE + + async def async_set_native_value(self, value: float): + brightness = { + mc.KEY_CHANNEL: self.channel, + mc.KEY_OPERATION: self.manager._number_brightness_operation.native_value, + mc.KEY_STANDBY: self.manager._number_brightness_standby.native_value, + } + brightness[self.key_value] = value + + def _ack_callback(acknowledge: bool, header: dict, payload: dict): + if acknowledge: + self.update_native_value(value) + + await self.manager.async_request( + mc.NS_APPLIANCE_CONTROL_SCREEN_BRIGHTNESS, + mc.METHOD_SET, + {mc.KEY_BRIGHTNESS: [brightness]}, + _ack_callback, + ) + + +class ScreenBrightnessMixin( + MerossDevice if typing.TYPE_CHECKING else object +): # pylint: disable=used-before-assignment + _number_brightness_operation: MLScreenBrightnessNumber + _number_brightness_standby: MLScreenBrightnessNumber + + def __init__(self, descriptor, entry): + super().__init__(descriptor, entry) + + with self.exception_warning("ScreenBrightnessMixin init"): + # the 'ScreenBrightnessMixin' actually doesnt have a clue of how many entities + # are controllable since the digest payload doesnt carry anything (like MerossShutter) + # So we're not implementing _init_xxx and _parse_xxx methods here and + # we'll just add a couple of number entities to control 'active' and 'standby' brightness + # on channel 0 which will likely be the only one available + self._number_brightness_operation = MLScreenBrightnessNumber( + self, 0, mc.KEY_OPERATION + ) + self._number_brightness_standby = MLScreenBrightnessNumber( + self, 0, mc.KEY_STANDBY + ) + self.polling_dictionary[ + mc.NS_APPLIANCE_CONTROL_SCREEN_BRIGHTNESS + ] = SmartPollingStrategy(mc.NS_APPLIANCE_CONTROL_SCREEN_BRIGHTNESS) + + # interface: MerossDevice + async def async_shutdown(self): + await super().async_shutdown() + self._number_brightness_operation = None # type: ignore + self._number_brightness_standby = None # type: ignore + + # interface: self + def _handle_Appliance_Control_Screen_Brightness(self, header: dict, payload: dict): + for p_channel in payload[mc.KEY_BRIGHTNESS]: + if p_channel.get(mc.KEY_CHANNEL) == 0: + self._number_brightness_operation.update_native_value( + p_channel[mc.KEY_OPERATION] + ) + self._number_brightness_standby.update_native_value( + p_channel[mc.KEY_STANDBY] + ) + break diff --git a/custom_components/meross_lan/helpers.py b/custom_components/meross_lan/helpers.py index b6f0deab..273ad5bc 100644 --- a/custom_components/meross_lan/helpers.py +++ b/custom_components/meross_lan/helpers.py @@ -28,19 +28,24 @@ from .merossclient import const as mc, get_default_arguments try: - from homeassistant.backports.enum import ( - StrEnum, # type: ignore pylint: disable=unused-import - ) + # since we're likely on python3.11 this should quickly + # set our StrEnum symbol + from enum import StrEnum # type: ignore pylint: disable=unused-import except Exception: - import enum + try: + from homeassistant.backports.enum import ( + StrEnum, # type: ignore pylint: disable=unused-import + ) + except Exception: + import enum - class StrEnum(enum.Enum): - """ - convenience alias for homeassistant.backports.StrEnum - """ + class StrEnum(enum.Enum): + """ + convenience alias for homeassistant.backports.StrEnum + """ - def __str__(self): - return str(self.value) + def __str__(self): + return str(self.value) if typing.TYPE_CHECKING: @@ -194,11 +199,16 @@ def _log(self, level, msg, args, **kwargs): mc.KEY_UUID: OBFUSCATE_DEVICE_ID_MAP, mc.KEY_MACADDRESS: {}, mc.KEY_WIFIMAC: {}, + mc.KEY_SSID: {}, + mc.KEY_GATEWAYMAC: {}, mc.KEY_INNERIP: OBFUSCATE_HOST_MAP, mc.KEY_SERVER: OBFUSCATE_SERVER_MAP, mc.KEY_PORT: OBFUSCATE_PORT_MAP, mc.KEY_SECONDSERVER: OBFUSCATE_SERVER_MAP, mc.KEY_SECONDPORT: OBFUSCATE_PORT_MAP, + mc.KEY_ACTIVESERVER: OBFUSCATE_SERVER_MAP, + mc.KEY_MAINSERVER: OBFUSCATE_SERVER_MAP, + mc.KEY_MAINPORT: OBFUSCATE_PORT_MAP, mc.KEY_USERID: OBFUSCATE_USERID_MAP, mc.KEY_TOKEN: {}, mc.KEY_KEY: OBFUSCATE_KEY_MAP, @@ -445,21 +455,29 @@ def __init__(self, namespace: str, payload: dict | None = None): ) self.lastrequest = 0 - async def __call__(self, device: MerossDevice, epoch: float, namespace: str | None): + async def poll(self, device: MerossDevice, epoch: float, namespace: str | None): """ This is a basic 'default' policy: - avoid the request when MQTT available (this is for general 'state' namespaces like NS_ALL) and we expect this namespace to be updated by PUSH(es) - unless the passed in 'namespace' is not None which means we're re-onlining the device and so we like to re-query the full state (even on MQTT) - - as an optimization, when onlining, we'll skip the request if it's for the same namespace + - as an optimization, when onlining (namespace == None), we'll skip the request if it's for + the same namespace by not calling this strategy (see MerossDevice.async_request_updates) """ - if (namespace or (not device._mqtt_active)) and (namespace != self.namespace): + if namespace or (not device._mqtt_active): await device.async_request(*self.request) self.lastrequest = epoch class SmartPollingStrategy(PollingStrategy): + """ + This is a strategy for polling states which are not actively pushed so we should + always query them (eventually with a variable timeout depending on the relevant + time dynamics of the sensor/state). When using cloud MQTT though we have to be very + conservative on traffic so we delay the request even more + """ + __slots__ = ("polling_period",) def __init__( @@ -468,19 +486,12 @@ def __init__( super().__init__(namespace, payload) self.polling_period = polling_period - async def __call__(self, device: MerossDevice, epoch: float, namespace: str | None): - """ - This is a strategy for polling states which are not actively pushed so we should - always query them (eventually with a variable timeout depending on the relevant - time dynamics of the sensor/state). When using cloud MQTT though we have to be very - conservative on traffic so we delay the request even more - """ - if namespace != self.namespace: + async def poll(self, device: MerossDevice, epoch: float, namespace: str | None): + if (epoch - self.lastrequest) >= self.polling_period: if await device.async_request_smartpoll( epoch, self.lastrequest, self.request, - self.polling_period, ): self.lastrequest = epoch @@ -492,19 +503,13 @@ def __init__(self, namespace: str, entity: MerossEntity, polling_period: int = 0 super().__init__(namespace, None, polling_period) self.entity = entity - async def __call__(self, device: MerossDevice, epoch: float, namespace: str | None): + async def poll(self, device: MerossDevice, epoch: float, namespace: str | None): """ Same as SmartPollingStrategy but we have a 'relevant' entity associated with the state of this paylod so we'll skip the smartpoll should the entity be disabled """ - if (namespace != self.namespace) and self.entity.enabled: - if await device.async_request_smartpoll( - epoch, - self.lastrequest, - self.request, - self.polling_period, - ): - self.lastrequest = epoch + if self.entity.enabled: + await super().poll(device, epoch, namespace) class ConfigEntriesHelper: @@ -617,6 +622,7 @@ class EntityManager(Loggable): "key", "config", "_unsub_entry_update_listener", + "_unsub_entry_reload_scheduler", ) def __init__( @@ -648,6 +654,7 @@ def __init__( self.config = config_entry_or_id.data self.key = config_entry_or_id.data.get(CONF_KEY) or "" self._unsub_entry_update_listener = None + self._unsub_entry_reload_scheduler: asyncio.TimerHandle | None = None @property def name(self) -> str: @@ -658,10 +665,19 @@ def online(self): return True async def async_shutdown(self): - # extra-safety cleanup: in an ideal world the config_entry - # shouldnt be loaded/listened at this point - self.unlisten_entry_update() + """ + Cleanup code called when the config entry is unloaded. + Beware, when a derived class owns some direct member pointers to entities, + be sure to invalidate them after calling the super() implementation. + This is especially true for MerossDevice(s) classes which need to stop + their async polling before invalidating the member pointers (which are + usually referred to inside the polling /parsing code) + """ + self.unlisten_entry_update() # extra-safety cleanup: shouldnt be loaded/listened at this point + self.unschedule_entry_reload() ApiProfile.managers.pop(self.config_entry_id, None) + for entity in self.entities.values(): + await entity.async_shutdown() self.entities.clear() self.platforms.clear() @@ -684,6 +700,7 @@ async def async_unload_entry(self, hass: HomeAssistant, config_entry: ConfigEntr ): return False self.unlisten_entry_update() + self.unschedule_entry_reload() ApiProfile.managers.pop(self.config_entry_id) return True @@ -692,6 +709,23 @@ def unlisten_entry_update(self): self._unsub_entry_update_listener() self._unsub_entry_update_listener = None + def schedule_entry_reload(self): + """Schedules a reload (in 15 sec) of the config_entry performing a full re-initialization""" + self.unschedule_entry_reload() + + async def _async_entry_reload(): + self._unsub_entry_reload_scheduler = None + await ApiProfile.hass.config_entries.async_reload(self.config_entry_id) + + self._unsub_entry_reload_scheduler = schedule_async_callback( + ApiProfile.hass, 15, _async_entry_reload + ) + + def unschedule_entry_reload(self): + if self._unsub_entry_reload_scheduler: + self._unsub_entry_reload_scheduler.cancel() + self._unsub_entry_reload_scheduler = None + def managed_entities(self, platform): """entities list for platform setup""" return [ diff --git a/custom_components/meross_lan/light.py b/custom_components/meross_lan/light.py index ad7d4690..38edd92e 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)) @@ -46,15 +43,19 @@ async def async_setup_entry( def _rgb_to_int(rgb) -> int: if isinstance(rgb, int): return rgb - elif isinstance(rgb, tuple): - red, green, blue = rgb - elif isinstance(rgb, dict): - red = rgb["red"] - green = rgb["green"] - blue = rgb["blue"] - else: - raise ValueError("Invalid value for RGB!") - return (red << 16) + (green << 8) + blue + try: + if isinstance(rgb, tuple): + red, green, blue = rgb + else: # assume dict + red = rgb["red"] + green = rgb["green"] + blue = rgb["blue"] + # even if HA states the tuple should be int we have float(s) in the wild (#309) + return (round(red) << 16) + (round(green) << 8) + round(blue) + except Exception as exception: + raise ValueError( + f"Invalid value for RGB (value: {str(rgb)} - type: {rgb.__class__.__name__} - error: {str(exception)})" + ) def _int_to_rgb(rgb: int): @@ -135,10 +136,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 +160,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 +194,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 +220,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 +229,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 +273,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 +321,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, ) @@ -369,18 +393,16 @@ class MLDNDLightEntity(me.MerossEntity, light.LightEntity): through a light feature (presence light or so) """ + manager: MerossDevice + PLATFORM = light.DOMAIN - manager: MerossDevice + _attr_entity_category = me.EntityCategory.CONFIG _attr_supported_color_modes = {ColorMode.ONOFF} def __init__(self, manager: MerossDevice): super().__init__(manager, None, DND_ID, mc.KEY_DNDMODE) - @property - def entity_category(self): - return me.EntityCategory.CONFIG - @property def supported_color_modes(self): return self._attr_supported_color_modes diff --git a/custom_components/meross_lan/manifest.json b/custom_components/meross_lan/manifest.json index b649f216..177af4a5 100644 --- a/custom_components/meross_lan/manifest.json +++ b/custom_components/meross_lan/manifest.json @@ -18,5 +18,5 @@ "/appliance/+/publish" ], "requirements": [], - "version": "4.3.0" + "version": "4.4.0" } \ No newline at end of file diff --git a/custom_components/meross_lan/meross_device.py b/custom_components/meross_lan/meross_device.py index 3493b687..078e88a6 100644 --- a/custom_components/meross_lan/meross_device.py +++ b/custom_components/meross_lan/meross_device.py @@ -2,6 +2,7 @@ import abc import asyncio +import bisect from datetime import datetime, timezone from io import TextIOWrapper from json import dumps as json_dumps @@ -9,7 +10,6 @@ import os from time import localtime, strftime, time import typing -from uuid import uuid4 import weakref from zoneinfo import ZoneInfo @@ -41,9 +41,11 @@ PARAM_CLOUDMQTT_UPDATE_PERIOD, PARAM_COLDSTARTPOLL_DELAY, PARAM_HEARTBEAT_PERIOD, + PARAM_INFINITE_EPOCH, PARAM_SIGNAL_UPDATE_PERIOD, PARAM_TIMESTAMP_TOLERANCE, - PARAM_TIMEZONE_CHECK_PERIOD, + PARAM_TIMEZONE_CHECK_NOTOK_PERIOD, + PARAM_TIMEZONE_CHECK_OK_PERIOD, PARAM_TRACING_ABILITY_POLL_TIMEOUT, DeviceConfigType, ) @@ -60,6 +62,7 @@ ) from .meross_entity import MerossFakeEntity from .merossclient import ( # mEROSS cONST + MEROSSDEBUG, const as mc, get_default_arguments, get_message_signature, @@ -70,7 +73,6 @@ from .sensor import PERCENTAGE, MLSensor, ProtocolSensor from .update import MLUpdate -ResponseCallbackType = typing.Callable[[bool, dict, dict], None] if typing.TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -78,7 +80,13 @@ from .meross_entity import MerossEntity from .meross_profile import MerossCloudProfile, MQTTConnection - from .merossclient import MerossDeviceDescriptor + from .merossclient import ( + MerossDeviceDescriptor, + MerossHeaderType, + MerossMessageType, + MerossPayloadType, + ResponseCallbackType, + ) from .merossclient.cloudapi import ( DeviceInfoType, LatestVersionType, @@ -99,6 +107,7 @@ mc.NS_APPLIANCE_SYSTEM_REPORT, mc.NS_APPLIANCE_SYSTEM_DEBUG, mc.NS_APPLIANCE_SYSTEM_CLOCK, + mc.NS_APPLIANCE_SYSTEM_POSITION, mc.NS_APPLIANCE_DIGEST_TRIGGERX, mc.NS_APPLIANCE_DIGEST_TIMERX, mc.NS_APPLIANCE_CONFIG_KEY, @@ -128,37 +137,17 @@ TIMEZONES_SET = None -class _MQTTTransaction: - __slots__ = ( - "namespace", - "messageid", - "method", - "request_time", - "response_callback", - ) - - def __init__( - self, namespace: str, method: str, response_callback: ResponseCallbackType - ): - self.namespace = namespace - self.messageid = uuid4().hex - self.method = method - self.request_time = time() - self.response_callback = response_callback - - class MerossDeviceBase(EntityManager): """ Abstract base class for MerossDevice and MerossSubDevice (from hub) giving common behaviors like device_registry interface """ - deviceentry_id: dict[str, set] + deviceentry_id: dict[str, set[tuple[str, str]]] # device info dict from meross cloud api device_info: DeviceInfoType | SubDeviceInfoType | None __slots__ = ( - "deviceentry_id", "device_info", "_online", "_device_registry_entry", @@ -189,7 +178,7 @@ def __init__( model=model, sw_version=sw_version, via_device=via_device, - **self.deviceentry_id, + **self.deviceentry_id, # type: ignore ) ) @@ -249,7 +238,7 @@ def request( payload: dict, response_callback: ResponseCallbackType | None = None, ): - ApiProfile.hass.async_create_task( + return ApiProfile.hass.async_create_task( self.async_request(namespace, method, payload, response_callback) ) @@ -260,7 +249,7 @@ async def async_request( method: str, payload: dict, response_callback: ResponseCallbackType | None = None, - ): + ) -> MerossMessageType | None: raise NotImplementedError @abc.abstractmethod @@ -325,7 +314,6 @@ class MerossDevice(MerossDeviceBase): "_mqtt_active", # the broker receives valid traffic i.e. the device is 'mqtt' reachable "_mqtt_lastrequest", "_mqtt_lastresponse", - "_mqtt_transactions", "_http", # cached MerossHttpClient "_http_active", # HTTP is 'online' i.e. reachable "_http_lastrequest", @@ -336,9 +324,10 @@ class MerossDevice(MerossDeviceBase): "_trace_endtime", "_trace_ability_iter", "polling_dictionary", - "_tzinfo", "_unsub_polling_callback", "_queued_poll_requests", + "_tzinfo", + "_timezone_next_check", "entity_dnd", "sensor_protocol", "sensor_signal_strength", @@ -352,13 +341,13 @@ def __init__( ): self.descriptor = descriptor self.needsave = False - self.device_timestamp = 0.0 + self.device_timestamp: int = 0 self.device_timedelta = 0 self.device_timedelta_log_epoch = 0 self.device_timedelta_config_epoch = 0 self.device_debug = {} - self.lastrequest = 0 - self.lastresponse = 0 + self.lastrequest = 0.0 + self.lastresponse = 0.0 self._cloud_profile: MerossCloudProfile | None = None self._mqtt_connection: MQTTConnection | None = None self._mqtt_connected: MQTTConnection | None = None @@ -386,6 +375,8 @@ def __init__( self.polling_dictionary[mc.NS_APPLIANCE_SYSTEM_ALL] = PollingStrategy( mc.NS_APPLIANCE_SYSTEM_ALL ) + self._unsub_polling_callback = None + self._queued_poll_requests = 0 # Message handling is actually very hybrid: # when a message (device reply or originated) is received it gets routed to the # device instance in 'receive'. Here, it was traditionally parsed with a @@ -399,12 +390,14 @@ def __init__( # The dicionary keys are Meross namespaces matched against when the message enters the handling # self.handlers: Dict[str, Callable] = {} actually disabled! - # The list of pending MQTT requests (SET or GET) which are waiting their SETACK (or GETACK) - # in order to complete the transaction - self._mqtt_transactions: dict[str, _MQTTTransaction] = {} self._tzinfo = None - self._unsub_polling_callback = None - self._queued_poll_requests = 0 + self._timezone_next_check = ( + 0 + if mc.NS_APPLIANCE_SYSTEM_TIME in descriptor.ability + else PARAM_INFINITE_EPOCH + ) + """Indicates the (next) time we shoulc to perform a check (only when localmqtt) + in order to see if the device has correct timezone/dst configuration""" # base init after setting some key properties needed for logging super().__init__( @@ -515,14 +508,10 @@ async def entry_update_listener( # config_entry update might come from DHCP or OptionsFlowHandler address update # so we'll eventually retry querying the device if not self._online: - await self.async_request(*get_default_arguments(mc.NS_APPLIANCE_SYSTEM_ALL)) + self.request(*get_default_arguments(mc.NS_APPLIANCE_SYSTEM_ALL)) # interface: MerossDeviceBase async def async_shutdown(self): - """ - called when the config entry is unloaded - we'll try to clear everything here - """ if self._mqtt_connection: self._mqtt_connection.detach(self) if self._cloud_profile: @@ -535,12 +524,12 @@ async def async_shutdown(self): self.polling_dictionary.clear() if self._trace_file: self._trace_close() + await super().async_shutdown() + ApiProfile.devices[self.id] = None self.entity_dnd = None # type: ignore self.sensor_signal_strength = None # type: ignore self.sensor_protocol = None # type: ignore self.update_firmware = None - await super().async_shutdown() - ApiProfile.devices[self.id] = None async def async_request( self, @@ -548,7 +537,7 @@ async def async_request( method: str, payload: dict, response_callback: ResponseCallbackType | None = None, - ): + ) -> MerossMessageType | None: """ route the request through MQTT or HTTP to the physical device. callback will be called on successful replies and actually implemented @@ -556,30 +545,39 @@ async def async_request( confirmation/status updates """ self.lastrequest = time() + mqttfailed = False if self.curr_protocol is CONF_PROTOCOL_MQTT: if self._mqtt_publish: - await self.async_mqtt_request( + if response := await self.async_mqtt_request( namespace, method, payload, response_callback - ) - return + ): + return response + + mqttfailed = True + # MQTT not connected or not allowing publishing if self.conf_protocol is CONF_PROTOCOL_MQTT: - return + return None # protocol is AUTO self._switch_protocol(CONF_PROTOCOL_HTTP) # curr_protocol is HTTP - if not await self.async_http_request( - namespace, method, payload, callback=response_callback, attempts=3 + if response := await self.async_http_request( + namespace, method, payload, response_callback, attempts=3 ): - if ( - self._mqtt_active - and self._mqtt_publish - and (self.conf_protocol is CONF_PROTOCOL_AUTO) - ): - await self.async_mqtt_request( - namespace, method, payload, response_callback - ) + return response + + if ( + self._mqtt_active + and self._mqtt_publish + and (self.conf_protocol is CONF_PROTOCOL_AUTO) + and not mqttfailed + ): + return await self.async_mqtt_request( + namespace, method, payload, response_callback + ) + + return None def _get_device_info_name_key(self) -> str: return mc.KEY_DEVNAME @@ -623,10 +621,6 @@ def profile_id(self): profile_id if profile_id in ApiProfile.profiles else CONF_PROFILE_ID_LOCAL ) - @property - def tzname(self): - return self.descriptor.timezone - @property def tzinfo(self): tz_name = self.descriptor.timezone @@ -698,7 +692,7 @@ def start(self): self._async_polling_callback, ) - def get_datetime(self, epoch): + def get_device_datetime(self, epoch): """ given the epoch (utc timestamp) returns the datetime in device local timezone @@ -709,12 +703,11 @@ async def async_request_smartpoll( self, epoch: float, lastupdate: float | int, - polling_args: tuple, - polling_period_min: int, - polling_period_cloud: int = PARAM_CLOUDMQTT_UPDATE_PERIOD, + requests_args: tuple, + *, + cloud_polling_period: int = PARAM_CLOUDMQTT_UPDATE_PERIOD, + cloud_queue_max: int = 1, ): - if (epoch - lastupdate) < polling_period_min: - return False if ( self.pref_protocol is CONF_PROTOCOL_HTTP or self.curr_protocol is CONF_PROTOCOL_HTTP @@ -722,17 +715,17 @@ async def async_request_smartpoll( # this is likely the scenario for Meross cloud MQTT # or in general local devices with HTTP conf # we try HTTP first without any protocol auto-switching... - if await self.async_http_request(*polling_args): + if await self.async_http_request(*requests_args): return True # going on we should rely on MQTT but we skip it # and all of the autoswitch logic if not feasible if not self._mqtt_publish: return False if self.mqtt_locallyactive or ( - (self._queued_poll_requests == 0) - and ((epoch - lastupdate) > polling_period_cloud) + (self._queued_poll_requests < cloud_queue_max) + and ((epoch - lastupdate) > cloud_polling_period) ): - await self.async_request(*polling_args) + await self.async_request(*requests_args) self._queued_poll_requests += 1 return True return False @@ -765,9 +758,12 @@ async def async_request_updates(self, epoch: float, namespace: str | None): for _strategy in self.polling_dictionary.values(): if not self._online: return - await _strategy(self, epoch, namespace) + if namespace != _strategy.namespace: + await _strategy.poll(self, epoch, namespace) - def receive(self, header: dict, payload: dict, protocol) -> bool: + def receive( + self, header: MerossHeaderType, payload: MerossPayloadType, protocol + ) -> bool: """ default (received) message handling entry point """ @@ -782,22 +778,36 @@ def receive(self, header: dict, payload: dict, protocol) -> bool: # and we want to 'translate' this timings in our (local) time. # We ignore delays below PARAM_TIMESTAMP_TOLERANCE since # we'll always be a bit late in processing - self.device_timestamp = float(header.get(mc.KEY_TIMESTAMP, epoch)) + self.device_timestamp: int = header[mc.KEY_TIMESTAMP] device_timedelta = epoch - self.device_timestamp if abs(device_timedelta) > PARAM_TIMESTAMP_TOLERANCE: - self._config_timestamp(epoch, device_timedelta) + if ( + abs(self.device_timedelta - device_timedelta) + > PARAM_TIMESTAMP_TOLERANCE + ): + # big step so we're not averaging + self.device_timedelta = device_timedelta + else: # average the sampled timedelta + self.device_timedelta = ( + 4 * self.device_timedelta + device_timedelta + ) / 5 + self._config_device_timestamp(epoch) else: self.device_timedelta = 0 - sign = get_message_signature( - header[mc.KEY_MESSAGEID], self.key, header[mc.KEY_TIMESTAMP] - ) - if sign != header[mc.KEY_SIGN]: - self.warning( - "received signature error: computed=%s, header=%s", - sign, - json_dumps(header), + if MEROSSDEBUG: + # it appears sometimes the devices + # send an incorrect signature hash + # but at the moment this is unlikely to be critical + sign = get_message_signature( + header[mc.KEY_MESSAGEID], self.key, header[mc.KEY_TIMESTAMP] ) + if sign != header[mc.KEY_SIGN]: + self.warning( + "received signature error: computed=%s, header=%s", + sign, + json_dumps(header), + ) if not self._online: self._set_online() @@ -854,16 +864,16 @@ def _parse__generic(self, key: str, payload, entitykey: str | None = None): for p in payload: self._parse__generic(key, p, entitykey) - def _handle_undefined(self, header: dict, payload: dict): + def _handle_undefined(self, header: MerossHeaderType, payload: MerossPayloadType): self.log( DEBUG, "handler undefined for method:(%s) namespace:(%s) payload:(%s)", header[mc.KEY_METHOD], header[mc.KEY_NAMESPACE], - payload, + obfuscated_dict_copy(payload), ) - def _handle_generic(self, header: dict, payload: dict): + def _handle_generic(self, header: MerossHeaderType, payload: MerossPayloadType): """ This is a basic implementation for dynamic protocol handlers since most of the payloads just need to extract a key and @@ -883,7 +893,9 @@ def _parse__generic_array(self, key: str, payload, entitykey: str | None = None) ] getattr(entity, f"_parse_{key}", entity._parse_undefined)(channel_payload) - def _handle_generic_array(self, header: dict, payload: dict): + def _handle_generic_array( + self, header: MerossHeaderType, payload: MerossPayloadType + ): """ This is a basic implementation for dynamic protocol handlers since most of the payloads just need to extract a key and @@ -892,13 +904,39 @@ def _handle_generic_array(self, header: dict, payload: dict): key = get_namespacekey(header[mc.KEY_NAMESPACE]) self._parse__generic_array(key, payload[key]) + def _handle_Appliance_System_Ability(self, header: dict, payload: dict): + # This is only requested when we want to update a config_entry due + # to a detected fw change or whatever... + # before saving, we're checking the abilities did (or didn't) change too + self.needsave = False + with self.exception_warning("ConfigEntry update"): + entries = ApiProfile.hass.config_entries + if entry := entries.async_get_entry(self.config_entry_id): + data = dict(entry.data) + descr = self.descriptor + data[CONF_TIMESTAMP] = time() # force ConfigEntry update.. + data[CONF_PAYLOAD][mc.KEY_ALL] = descr.all + + oldability = descr.ability + newability = payload[mc.KEY_ABILITY] + if oldability != newability: + data[CONF_PAYLOAD][mc.KEY_ABILITY] = newability + oldabilities = oldability.keys() + newabilities = newability.keys() + self.warning( + "Trying schedule device configuration reload since the abilities changed (added: %s - removed: %s)", + str(newabilities - oldabilities), + str(oldabilities - newabilities), + ) + self.schedule_entry_reload() + entries.async_update_entry(entry, data=data) + def _handle_Appliance_System_All(self, header: dict, payload: dict): descr = self.descriptor oldfirmware = descr.firmware descr.update(payload) if oldfirmware != descr.firmware: - # persist changes to configentry only when relevant properties change self.needsave = True if update_firmware := self.update_firmware: # self.update_firmware is dynamically created only when the cloud api @@ -919,18 +957,6 @@ def _handle_Appliance_System_All(self, header: dict, payload: dict): except Exception: pass - if self.mqtt_locallyactive: - # only deal with time related settings when devices are un-paired - # from the meross cloud - if self.device_timedelta and mc.NS_APPLIANCE_SYSTEM_CLOCK in descr.ability: - # timestamp misalignment: try to fix it - # only when devices are paired on our MQTT - self.mqtt_request(mc.NS_APPLIANCE_SYSTEM_CLOCK, mc.METHOD_PUSH, {}) - - if mc.NS_APPLIANCE_SYSTEM_TIME in descr.ability: - # check the appliance timeoffsets are updated (see #36) - self._config_timezone(int(self.lastresponse), descr.time.get(mc.KEY_TIMEZONE)) # type: ignore - for _key, _value in descr.digest.items(): if _parse := getattr(self, f"_parse_{_key}", None): _parse(_value) @@ -942,8 +968,9 @@ def _handle_Appliance_System_All(self, header: dict, payload: dict): _parse(_value) if self.needsave: - self.needsave = False - self._save_config_entry(payload) + # fw update or whatever might have modified the device abilities. + # we refresh the abilities list before saving the new config_entry + self.request(*get_default_arguments(mc.NS_APPLIANCE_SYSTEM_ABILITY)) def _handle_Appliance_System_Debug(self, header: dict, payload: dict): self.device_debug = p_debug = payload[mc.KEY_DEBUG] @@ -988,18 +1015,10 @@ def _handle_Appliance_Control_Bind(self, header: dict, payload: dict): header[mc.KEY_MESSAGEID], ) - def mqtt_receive(self, header: dict, payload: dict): + def mqtt_receive(self, header: MerossHeaderType, payload: MerossPayloadType): assert self._mqtt_connected and (self.conf_protocol is not CONF_PROTOCOL_HTTP) - messageid = header[mc.KEY_MESSAGEID] - if messageid in self._mqtt_transactions: - mqtt_transaction = self._mqtt_transactions[messageid] - if mqtt_transaction.namespace == header[mc.KEY_NAMESPACE]: - self._mqtt_transactions.pop(messageid) - mqtt_transaction.response_callback( - header[mc.KEY_METHOD] != mc.METHOD_ERROR, header, payload - ) if not self._mqtt_active: - self._mqtt_active = self._mqtt_connection + self._mqtt_active = self._mqtt_connected if self._online: self.sensor_protocol.update_attr_active(ProtocolSensor.ATTR_MQTT) if self.curr_protocol is not CONF_PROTOCOL_MQTT: @@ -1060,7 +1079,7 @@ def mqtt_request( response_callback: ResponseCallbackType | None = None, messageid: str | None = None, ): - ApiProfile.hass.async_create_task( + return ApiProfile.hass.async_create_task( self.async_mqtt_request( namespace, method, payload, response_callback, messageid ) @@ -1073,7 +1092,7 @@ async def async_mqtt_request( payload: dict, response_callback: ResponseCallbackType | None = None, messageid: str | None = None, - ): + ) -> MerossMessageType | None: if not self._mqtt_publish: # even if we're smart enough to not call async_mqtt_request when no mqtt # available, it could happen we loose that when asynchronously coming here @@ -1081,11 +1100,7 @@ async def async_mqtt_request( DEBUG, "attempting to use async_mqtt_request with no publishing profile", ) - return - if response_callback: - transaction = _MQTTTransaction(namespace, method, response_callback) - self._mqtt_transactions[transaction.messageid] = transaction - messageid = transaction.messageid + return None self._mqtt_lastrequest = time() if self._trace_file: self._trace( @@ -1096,8 +1111,8 @@ async def async_mqtt_request( CONF_PROTOCOL_MQTT, TRACE_DIRECTION_TX, ) - await self._mqtt_publish.async_mqtt_publish( - self.id, namespace, method, payload, self.key, messageid + return await self._mqtt_publish.async_mqtt_publish( + self.id, namespace, method, payload, self.key, response_callback, messageid ) async def async_http_request( @@ -1105,10 +1120,9 @@ async def async_http_request( namespace: str, method: str, payload: dict, - *, - callback: ResponseCallbackType | None = None, + response_callback: ResponseCallbackType | None = None, attempts: int = 1, - ): + ) -> MerossMessageType | None: with self.exception_warning( "async_http_request %s %s", method, @@ -1117,7 +1131,12 @@ async def async_http_request( ): if not (http := self._http): http = MerossHttpClient( - self.host, self.key, async_get_clientsession(ApiProfile.hass), LOGGER # type: ignore + self.host, # type: ignore + self.key, + async_get_clientsession(ApiProfile.hass), + LOGGER + if MEROSSDEBUG and MEROSSDEBUG.http_client_log_enable + else None, ) self._http = http @@ -1169,10 +1188,10 @@ async def async_http_request( self._switch_protocol(CONF_PROTOCOL_HTTP) r_header = response[mc.KEY_HEADER] r_payload = response[mc.KEY_PAYLOAD] - if callback: + if response_callback: # we're actually only using this for SET->SETACK command confirmation - callback( - r_header[mc.KEY_METHOD] != mc.METHOD_ERROR, r_header, r_payload + response_callback( + r_header[mc.KEY_METHOD] != mc.METHOD_ERROR, r_header, r_payload # type: ignore ) self.receive(r_header, r_payload, CONF_PROTOCOL_HTTP) self._http_lastresponse = self.lastresponse @@ -1186,25 +1205,6 @@ async def _async_polling_callback(self): try: self._unsub_polling_callback = None epoch = time() - # this is a kind of 'heartbeat' to check if the device is still there - # especially on MQTT where we might see no messages for a long time - # This is also triggered at device setup to immediately request a fresh state - # if ((epoch - self.lastrequest) > PARAM_HEARTBEAT_PERIOD) and ( - # (epoch - self.lastupdate) > PARAM_HEARTBEAT_PERIOD - # ): - # await self.async_request_get(mc.NS_APPLIANCE_SYSTEM_ALL) - # return - if self._mqtt_transactions: - # check and cleanup stale transactions - _mqtt_transaction_stale_list = None - for _mqtt_transaction in self._mqtt_transactions.values(): - if (epoch - _mqtt_transaction.request_time) > 15: - if _mqtt_transaction_stale_list is None: - _mqtt_transaction_stale_list = [] - _mqtt_transaction_stale_list.append(_mqtt_transaction.messageid) - if _mqtt_transaction_stale_list: - for messageid in _mqtt_transaction_stale_list: - self._mqtt_transactions.pop(messageid) if self._online: # evaluate device availability by checking lastrequest got answered in less than polling_period @@ -1223,7 +1223,6 @@ async def _async_polling_callback(self): self._set_offline() return - # assert self._online # when mqtt is working as a fallback for HTTP # we should periodically check if http comes back # in case our self.pref_protocol is HTTP. @@ -1246,18 +1245,35 @@ async def _async_polling_callback(self): # implement an heartbeat since mqtt might # be unused for quite a bit if (epoch - self._mqtt_lastresponse) > PARAM_HEARTBEAT_PERIOD: - self._mqtt_active = None - await self.async_mqtt_request( + if not await self.async_mqtt_request( *get_default_arguments(mc.NS_APPLIANCE_SYSTEM_ALL) - ) - # this is rude..we would want to async wait on - # the mqtt response but we lack the infrastructure - await asyncio.sleep(2) - if not self._mqtt_active: + ): + self._mqtt_active = None self.sensor_protocol.update_attr_inactive( ProtocolSensor.ATTR_MQTT ) # going on could eventually try/switch to HTTP + elif epoch > self._timezone_next_check: + # when on local mqtt we have the responsibility for + # setting the device timezone/dst transition times + # but this is a process potentially consuming a lot + # (checking future DST) so we'll be lazy on this by + # scheduling not so often and depending on a bunch of + # side conditions (like the device being time-aligned) + self._timezone_next_check = ( + epoch + PARAM_TIMEZONE_CHECK_NOTOK_PERIOD + ) + if self.device_timedelta < PARAM_TIMESTAMP_TOLERANCE: + with self.exception_warning("_check_device_timezone"): + if self._check_device_timezone(): + # timezone trans not good..fix and check again soon + self._config_device_timezone( + self.descriptor.timezone + ) + else: # timezone trans good..check again in more time + self._timezone_next_check = ( + epoch + PARAM_TIMEZONE_CHECK_OK_PERIOD + ) await self.async_request_updates(epoch, None) @@ -1330,13 +1346,9 @@ def entry_option_update(self, user_input: DeviceConfigType): if self.mqtt_locallyactive and ( mc.NS_APPLIANCE_SYSTEM_TIME in self.descriptor.ability ): - self._config_timezone(int(time()), user_input.get(mc.KEY_TIMEZONE)) + self._config_device_timezone(user_input.get(mc.KEY_TIMEZONE)) - def _config_timestamp(self, epoch, device_timedelta): - if abs(self.device_timedelta - device_timedelta) > PARAM_TIMESTAMP_TOLERANCE: - self.device_timedelta = device_timedelta - else: # average the sampled timedelta - self.device_timedelta = (4 * self.device_timedelta + device_timedelta) / 5 + def _config_device_timestamp(self, epoch): if self.mqtt_locallyactive and ( mc.NS_APPLIANCE_SYSTEM_CLOCK in self.descriptor.ability ): @@ -1360,75 +1372,168 @@ def _config_timestamp(self, epoch, device_timedelta): int(self.device_timedelta), ) - def _config_timezone(self, epoch, tzname): - p_time = self.descriptor.time - assert p_time and self.mqtt_locallyactive - p_timerule: list = p_time.get(mc.KEY_TIMERULE, []) - p_timezone = p_time.get(mc.KEY_TIMEZONE) + def _check_device_timezone(self) -> bool: """ - timeRule should contain 2 entries: the actual time offsets and - the next (incoming). If 'now' is after 'incoming' it means the - first entry became stale and so we'll update the daylight offsets - to current/next DST time window + verify the data about DST changes in the configured timezone are ok by checking + the "time" key in the Appliance.System.All payload: + "time": { + "timestamp": 1560670665, + "timezone": "Australia/Sydney", + "timeRule": [ + [1554566400,36000,0], + [1570291200,39600,1], + ... + ] + } + returns True in case we need to fix the device configuration + see https://github.com/arandall/meross/blob/main/doc/protocol.md#appliancesystemtime """ - if (p_timezone != tzname) or len(p_timerule) < 2 or p_timerule[1][0] < epoch: - if tzname: - """ - we'll look through the list of transition times for current tz - and provide the actual (last past daylight) and the next to the - appliance so it knows how and when to offset utc to localtime - """ - timerules = [] - try: - import bisect + timestamp = self.device_timestamp # we'll check against its own timestamp + time = self.descriptor.time + timerules: list = time.get(mc.KEY_TIMERULE, []) + timezone = time.get(mc.KEY_TIMEZONE) + if timezone: + # assume "timeRule" entries are ordered on epoch(s) + # timerule: [1554566400,36000,0] -> [epoch, utcoffset, isdst] + if not timerules: + # array empty? + return True + + def _get_epoch(_timerule: list): + return _timerule[0] + + idx = bisect.bisect_right(timerules, timestamp, key=_get_epoch) + if idx == 0: + # epoch is not (yet) covered in timerules + return True + + timerule = timerules[idx - 1] # timerule in effect at the 'epoch' + device_tzinfo = self.tzinfo + + def _check_incorrect_timerule(_epoch, _timerule): + _device_datetime = datetime_from_epoch(_epoch, device_tzinfo) + _utcoffset = device_tzinfo.utcoffset(_device_datetime) + if _timerule[1] != (_utcoffset.seconds if _utcoffset else 0): + return True + _dstoffset = device_tzinfo.dst(_device_datetime) + return _timerule[2] != (1 if _dstoffset else 0) + + if _check_incorrect_timerule(timestamp, timerule): + return True + # actual device time is covered but we also check if the device timerules + # are ok in the near future + timestamp_future = timestamp + PARAM_TIMEZONE_CHECK_OK_PERIOD + # we have to search (again) in the timerules but we do some + # short-circuit checks to see if epoch_future is still + # contained in current timerule + if idx == len(timerules): + # timerule is already the last in the list so it will be the only active + # from now on + pass + else: + timerule_next = timerules[idx] + timestamp_next = timerule_next[0] + if timestamp_future >= timestamp_next: + # the next timerule will take over + # so we check if the transition time set in the device + # is correct with the tz database + if _check_incorrect_timerule(timestamp_next - 1, timerule): + return True + if _check_incorrect_timerule(timestamp_next + 1, timerule_next): + return True + # transition set in timerule_next is coming soon + # and will be ok + return False + + if _check_incorrect_timerule(timestamp_future, timerule): + return True + + else: + # no timezone set in the device so we'd expect an empty timerules + if timerules: + return True + + return False + def _config_device_timezone(self, tzname): + # assert self.mqtt_locallyactive + timestamp = self.device_timestamp + timerules = [] + if tzname: + """ + we'll look through the list of transition times for current tz + and provide the actual (last past daylight) and the next to the + appliance so it knows how and when to offset utc to localtime + """ + try: + try: import pytz tz_local = pytz.timezone(tzname) - idx = bisect.bisect_right( - tz_local._utc_transition_times, # type: ignore - datetime.utcfromtimestamp(epoch), - ) - # idx would be the next transition offset index - _transition_info = tz_local._transition_info[idx - 1] # type: ignore - timerules.append( - [ - int(tz_local._utc_transition_times[idx - 1].timestamp()), # type: ignore - int(_transition_info[0].total_seconds()), - 1 if _transition_info[1].total_seconds() else 0, - ] - ) - _transition_info = tz_local._transition_info[idx] # type: ignore - timerules.append( - [ - int(tz_local._utc_transition_times[idx].timestamp()), # type: ignore - int(_transition_info[0].total_seconds()), - 1 if _transition_info[1].total_seconds() else 0, - ] - ) + if isinstance(tz_local, pytz.tzinfo.DstTzInfo): + idx = bisect.bisect_right( + tz_local._utc_transition_times, # type: ignore + datetime.utcfromtimestamp(timestamp), + ) + # idx would be the next transition offset index + _transition_info = tz_local._transition_info[idx - 1] # type: ignore + timerules.append( + [ + int(tz_local._utc_transition_times[idx - 1].timestamp()), # type: ignore + int(_transition_info[0].total_seconds()), + 1 if _transition_info[1].total_seconds() else 0, + ] + ) + _transition_info = tz_local._transition_info[idx] # type: ignore + timerules.append( + [ + int(tz_local._utc_transition_times[idx].timestamp()), # type: ignore + int(_transition_info[0].total_seconds()), + 1 if _transition_info[1].total_seconds() else 0, + ] + ) + elif isinstance(tz_local, pytz.tzinfo.StaticTzInfo): + timerules = [[0, tz_local.utcoffset(None), 0]] + except Exception as e: self.warning( - "error while building timezone info (%s)", + "error(%s) while using pytz to build timezone(%s) ", str(e), + tzname, ) - timerules = [[0, 0, 0], [epoch + PARAM_TIMEZONE_CHECK_PERIOD, 0, 1]] - - self.mqtt_request( + # if pytx fails we'll fall-back to some euristics + device_tzinfo = ZoneInfo(tzname) + device_datetime = datetime_from_epoch(timestamp, device_tzinfo) + utcoffset = device_tzinfo.utcoffset(device_datetime) + utcoffset = utcoffset.seconds if utcoffset else 0 + isdst = device_tzinfo.dst(device_datetime) + timerules = [[timestamp, utcoffset, 1 if isdst else 0]] + + except Exception as e: + self.warning( + "error(%s) while building timezone(%s) info for %s", + str(e), + tzname, mc.NS_APPLIANCE_SYSTEM_TIME, - mc.METHOD_SET, - payload={ - mc.KEY_TIME: { - mc.KEY_TIMEZONE: tzname, - mc.KEY_TIMERULE: timerules, - } - }, - ) - elif p_timezone: # and !timezone - self.mqtt_request( - mc.NS_APPLIANCE_SYSTEM_TIME, - mc.METHOD_SET, - payload={mc.KEY_TIME: {mc.KEY_TIMEZONE: "", mc.KEY_TIMERULE: []}}, ) + timerules = [ + [0, 0, 0], + [timestamp + PARAM_TIMEZONE_CHECK_OK_PERIOD, 0, 1], + ] + + else: + tzname = "" + + self.mqtt_request( + mc.NS_APPLIANCE_SYSTEM_TIME, + mc.METHOD_SET, + payload={ + mc.KEY_TIME: { + mc.KEY_TIMEZONE: tzname, + mc.KEY_TIMERULE: timerules, + } + }, + ) def _switch_protocol(self, protocol): self.log( @@ -1440,15 +1545,6 @@ def _switch_protocol(self, protocol): if self._online: self.sensor_protocol.update_connected() - def _save_config_entry(self, payload: dict): - with self.exception_warning("ConfigEntry update"): - entries = ApiProfile.hass.config_entries - if entry := entries.async_get_entry(self.config_entry_id): - data = dict(entry.data) - data[CONF_PAYLOAD].update(payload) - data[CONF_TIMESTAMP] = time() # force ConfigEntry update.. - entries.async_update_entry(entry, data=data) - def _update_config(self): """ common properties caches, read from ConfigEntry on __init__ or when a configentry updates @@ -1557,25 +1653,21 @@ def _trace_open(self, epoch: float, endtime): "custom_components", DOMAIN, CONF_TRACE_DIRECTORY ) os.makedirs(tracedir, exist_ok=True) + descr = self.descriptor self._trace_file = open( os.path.join( tracedir, - CONF_TRACE_FILENAME.format(self.descriptor.type, int(endtime)), + CONF_TRACE_FILENAME.format(descr.type, int(endtime)), ), mode="w", encoding="utf8", ) self._trace_endtime = endtime + self._trace(epoch, descr.all, mc.NS_APPLIANCE_SYSTEM_ALL, mc.METHOD_GETACK) self._trace( - epoch, self.descriptor.all, mc.NS_APPLIANCE_SYSTEM_ALL, mc.METHOD_GETACK - ) - self._trace( - epoch, - self.descriptor.ability, - mc.NS_APPLIANCE_SYSTEM_ABILITY, - mc.METHOD_GETACK, + epoch, descr.ability, mc.NS_APPLIANCE_SYSTEM_ABILITY, mc.METHOD_GETACK ) - self._trace_ability_iter = iter(self.descriptor.ability) + self._trace_ability_iter = iter(descr.ability) self._trace_ability() except Exception as exception: if self._trace_file: diff --git a/custom_components/meross_lan/meross_device_hub.py b/custom_components/meross_lan/meross_device_hub.py index 01c9c76e..af5ddeaf 100644 --- a/custom_components/meross_lan/meross_device_hub.py +++ b/custom_components/meross_lan/meross_device_hub.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import typing from homeassistant.helpers import device_registry @@ -14,12 +13,12 @@ ApiProfile, PollingStrategy, SmartPollingStrategy, - schedule_async_callback, ) from .meross_device import MerossDevice, MerossDeviceBase from .merossclient import ( # mEROSS cONST const as mc, get_default_arguments, + get_namespacekey, get_productnameuuid, is_device_online, ) @@ -29,7 +28,7 @@ if typing.TYPE_CHECKING: from .devices.mts100 import Mts100Climate, Mts100Schedule, Mts100SetPointNumber - from .meross_device import ResponseCallbackType + from .meross_device import MerossPayloadType, ResponseCallbackType from .meross_entity import MerossEntity @@ -47,23 +46,72 @@ TRICK = False -class MerossDeviceHub(MerossDevice): +class SubDevicePollingStrategy(PollingStrategy): """ - Specialized MerossDevice for smart hub(s) like MSH300 + This is a strategy for polling (general) subdevices state with special care for messages + possibly generating huge payloads (see #244). We should avoid this + poll when the device is MQTT pushing its state """ __slots__ = ( - "subdevices", - "_lastupdate_sensor", - "_lastupdate_mts100", - "_unsub_setup_again", + "_types", + "_included", + "_count", ) + def __init__( + self, namespace: str, types: typing.Collection, included: bool, count: int + ): + super().__init__(namespace) + self._types = types + self._included = included + self._count = count + + async def poll(self, device: MerossDeviceHub, epoch: float, namespace: str | None): + if namespace or (not device._mqtt_active) or (self.lastrequest == 0): + max_queuable = 1 + # for hubs, this payload request might be splitted + # in order to query a small amount of devices per iteration + # see #244 for insights + for p in device._build_subdevices_payload( + self._types, self._included, self._count + ): + # in case we're going through cloud mqtt + # async_request_smartpoll would check how many + # polls are standing in queue in order to + # not burst the meross mqtt. We want to + # send these requests (in loop) as a whole + # so, we start with max_queuable == 1 in order + # to avoid starting when something is already + # sent in the current poll cycle but then, + # if we're good to go on the first iteration, + # we don't want to break this cycle else it + # would restart (stateless) at the next polling cycle + if await device.async_request_smartpoll( + epoch, + self.lastrequest, + ( + self.namespace, + mc.METHOD_GET, + {get_namespacekey(self.namespace): p}, + ), + cloud_queue_max=max_queuable, + ): + max_queuable = max_queuable + 1 + + if max_queuable > 1: + self.lastrequest = epoch + + +class MerossDeviceHub(MerossDevice): + """ + Specialized MerossDevice for smart hub(s) like MSH300 + """ + + __slots__ = ("subdevices",) + def __init__(self, descriptor, entry): self.subdevices: dict[object, MerossSubDevice] = {} - self._lastupdate_sensor = None - self._lastupdate_mts100 = None - self._unsub_setup_again: asyncio.TimerHandle | None = None super().__init__(descriptor, entry) # invoke platform(s) async_setup_entry # in order to be able to eventually add entities when they 'pop up' @@ -108,10 +156,6 @@ def managed_entities(self, platform): # interface: MerossDevice async def async_shutdown(self): - if self._unsub_setup_again: - self._unsub_setup_again.cancel() - self._unsub_setup_again = None - # shutdown the base first to stop polling in case await super().async_shutdown() for subdevice in self.subdevices.values(): await subdevice.async_shutdown() @@ -126,44 +170,6 @@ def _init_hub(self, payload: dict): for p_subdevice in payload[mc.KEY_SUBDEVICE]: self._subdevice_build(p_subdevice) - async def async_request_updates(self, epoch: float, namespace: str | None): - await super().async_request_updates(epoch, namespace) - # we just ask for updates when something pops online (_lastupdate_xxxx == 0) - # relying on push (over MQTT) or base polling updates (only HTTP) for any other changes - # if _lastupdate_xxxx is None then it means that device class is not present in the hub - # and we totally skip the request. This is especially true since I've discovered hubs - # don't expose the full set of namespaces until a real subdevice type is binded. - # If this is the case we would ask a namespace which is not supported at the moment - # (see #167). - # Also, we check here and there if the hub went offline while polling and we skip - # the rest of the sequence (see super().async_request_updates for the same logic) - if not self._online: - return - - needpoll = namespace or (not self._mqtt_active) - if self._lastupdate_sensor is not None: - if needpoll or (self._lastupdate_sensor == 0): - for p in self._build_subdevices_payload(MTS100_ALL_TYPESET, False, 8): - await self.async_request( - mc.NS_APPLIANCE_HUB_SENSOR_ALL, mc.METHOD_GET, {mc.KEY_ALL: p} - ) - - if not self._online: - return - if self._lastupdate_mts100 is not None: - if needpoll or (self._lastupdate_mts100 == 0): - for p in self._build_subdevices_payload(MTS100_ALL_TYPESET, True, 8): - await self.async_request( - mc.NS_APPLIANCE_HUB_MTS100_ALL, mc.METHOD_GET, {mc.KEY_ALL: p} - ) - for p in self._build_subdevices_payload(MTS100_ALL_TYPESET, True, 4): - if mc.NS_APPLIANCE_HUB_MTS100_SCHEDULEB in self.descriptor.ability: - await self.async_request( - mc.NS_APPLIANCE_HUB_MTS100_SCHEDULEB, - mc.METHOD_GET, - {mc.KEY_SCHEDULE: p}, - ) - # interface: self def _handle_Appliance_Digest_Hub(self, header: dict, payload: dict): self._parse_hub(payload[mc.KEY_HUB]) @@ -172,7 +178,6 @@ def _handle_Appliance_Hub_Sensor_Adjust(self, header: dict, payload: dict): self._subdevice_parse(mc.KEY_ADJUST, payload) def _handle_Appliance_Hub_Sensor_All(self, header: dict, payload: dict): - self._lastupdate_sensor = self.lastresponse self._subdevice_parse(mc.KEY_ALL, payload) def _handle_Appliance_Hub_Sensor_DoorWindow(self, header: dict, payload: dict): @@ -188,7 +193,6 @@ def _handle_Appliance_Hub_Mts100_Adjust(self, header: dict, payload: dict): self._subdevice_parse(mc.KEY_ADJUST, payload) def _handle_Appliance_Hub_Mts100_All(self, header: dict, payload: dict): - self._lastupdate_mts100 = self.lastresponse self._subdevice_parse(mc.KEY_ALL, payload) def _handle_Appliance_Hub_Mts100_Mode(self, header: dict, payload: dict): @@ -251,20 +255,11 @@ def _parse_hub(self, p_hub: dict): for p_id in subdevices_actual: subdevice = self.subdevices[p_id] self.warning( - "removing subdevice %s(%s) - configuration will be reloaded in 15 sec", + "removing subdevice %s(%s) - configuration will be reloaded in few sec", subdevice.name, p_id, ) - - # before reloading we have to be sure configentry data were persisted - # so we'll wait a bit.. - async def _async_setup_again(): - self._unsub_setup_again = None - await ApiProfile.hass.config_entries.async_reload(self.config_entry_id) - - self._unsub_setup_again = schedule_async_callback( - ApiProfile.hass, 15, _async_setup_again - ) + self.schedule_entry_reload() def _subdevice_build(self, p_subdevice: dict): # parses the subdevice payload in 'digest' to look for a well-known type @@ -288,19 +283,48 @@ def _subdevice_build(self, p_subdevice: dict): except Exception: return None + abilities = self.descriptor.ability if _type in MTS100_ALL_TYPESET: - self._lastupdate_mts100 = 0 - if mc.NS_APPLIANCE_HUB_MTS100_ADJUST not in self.polling_dictionary: + if (mc.NS_APPLIANCE_HUB_MTS100_ALL in abilities) and not ( + mc.NS_APPLIANCE_HUB_MTS100_ALL in self.polling_dictionary + ): + self.polling_dictionary[ + mc.NS_APPLIANCE_HUB_MTS100_ALL + ] = SubDevicePollingStrategy( + mc.NS_APPLIANCE_HUB_MTS100_ALL, MTS100_ALL_TYPESET, True, 8 + ) + if (mc.NS_APPLIANCE_HUB_MTS100_SCHEDULEB in abilities) and not ( + mc.NS_APPLIANCE_HUB_MTS100_SCHEDULEB in self.polling_dictionary + ): + self.polling_dictionary[ + mc.NS_APPLIANCE_HUB_MTS100_SCHEDULEB + ] = SubDevicePollingStrategy( + mc.NS_APPLIANCE_HUB_MTS100_SCHEDULEB, MTS100_ALL_TYPESET, True, 4 + ) + if (mc.NS_APPLIANCE_HUB_MTS100_ADJUST in abilities) and not ( + mc.NS_APPLIANCE_HUB_MTS100_ADJUST in self.polling_dictionary + ): self.polling_dictionary[ mc.NS_APPLIANCE_HUB_MTS100_ADJUST ] = SmartPollingStrategy(mc.NS_APPLIANCE_HUB_MTS100_ADJUST) else: - self._lastupdate_sensor = 0 - if mc.NS_APPLIANCE_HUB_SENSOR_ADJUST not in self.polling_dictionary: + if (mc.NS_APPLIANCE_HUB_SENSOR_ALL in abilities) and not ( + mc.NS_APPLIANCE_HUB_SENSOR_ALL in self.polling_dictionary + ): + self.polling_dictionary[ + mc.NS_APPLIANCE_HUB_SENSOR_ALL + ] = SubDevicePollingStrategy( + mc.NS_APPLIANCE_HUB_SENSOR_ALL, MTS100_ALL_TYPESET, False, 8 + ) + if (mc.NS_APPLIANCE_HUB_SENSOR_ADJUST in abilities) and not ( + mc.NS_APPLIANCE_HUB_SENSOR_ADJUST in self.polling_dictionary + ): self.polling_dictionary[ mc.NS_APPLIANCE_HUB_SENSOR_ADJUST ] = SmartPollingStrategy(mc.NS_APPLIANCE_HUB_SENSOR_ADJUST) - if mc.NS_APPLIANCE_HUB_TOGGLEX in self.descriptor.ability: + if (mc.NS_APPLIANCE_HUB_TOGGLEX in abilities) and not ( + mc.NS_APPLIANCE_HUB_TOGGLEX in self.polling_dictionary + ): # this is a status message irrelevant for mts100(s) and # other types. If not use an MQTT-PUSH friendly startegy if _type not in (mc.TYPE_MS100,): @@ -313,7 +337,7 @@ def _subdevice_build(self, p_subdevice: dict): # build something anyway... return MerossSubDevice(self, p_subdevice, _type) # type: ignore - def _subdevice_parse(self, key: str, payload: dict): + def _subdevice_parse(self, key: str, payload: MerossPayloadType): for p_subdevice in payload[key]: if subdevice := self.subdevices.get(p_subdevice[mc.KEY_ID]): subdevice._parse(key, p_subdevice) @@ -326,7 +350,7 @@ def _subdevice_parse(self, key: str, payload: dict): self.request(*get_default_arguments(mc.NS_APPLIANCE_SYSTEM_ALL)) def _build_subdevices_payload( - self, types: typing.Collection, included: bool, count: int + self, subdevice_types: typing.Collection, included: bool, count: int ): """ This generator helps dealing with hubs hosting an high number @@ -340,7 +364,7 @@ def _build_subdevices_payload( if len(subdevices := self.subdevices) > count: payload = [] for subdevice in subdevices.values(): - if (subdevice.type in types) == included: + if (subdevice.type in subdevice_types) == included: payload.append({mc.KEY_ID: subdevice.id}) if len(payload) == count: yield payload @@ -426,7 +450,7 @@ async def async_request( payload: dict, response_callback: ResponseCallbackType | None = None, ): - await self.hub.async_request(namespace, method, payload, response_callback) + return await self.hub.async_request(namespace, method, payload, response_callback) def _get_device_info_name_key(self) -> str: return mc.KEY_SUBDEVICENAME @@ -434,6 +458,17 @@ def _get_device_info_name_key(self) -> str: def _get_internal_name(self) -> str: return get_productnameuuid(self.type, self.id) + def _set_online(self): + super()._set_online() + # force a re-poll even on MQTT + _strategy = self.hub.polling_dictionary.get( + mc.NS_APPLIANCE_HUB_MTS100_ALL + if self.type in MTS100_ALL_TYPESET + else mc.NS_APPLIANCE_HUB_SENSOR_ALL + ) + if _strategy: + _strategy.lastrequest = 0 + # interface: self def build_sensor( self, entitykey: str, device_class: MLSensor.DeviceClass | None = None @@ -468,7 +503,13 @@ def _parse(self, key: str, payload: dict): for subkey, subvalue in payload.items(): if ( subkey - in {mc.KEY_ID, mc.KEY_LMTIME, mc.KEY_LMTIME_, mc.KEY_SYNCEDTIME, mc.KEY_LATESTSAMPLETIME} + in { + mc.KEY_ID, + mc.KEY_LMTIME, + mc.KEY_LMTIME_, + mc.KEY_SYNCEDTIME, + mc.KEY_LATESTSAMPLETIME, + } or isinstance(subvalue, list) or isinstance(subvalue, dict) ): @@ -625,10 +666,6 @@ async def async_shutdown(self): self.number_adjust_temperature: MLHubAdjustNumber = None # type: ignore self.number_adjust_humidity: MLHubAdjustNumber = None # type: ignore - def _set_online(self): - super()._set_online() - self.hub._lastupdate_sensor = 0 - def _parse_humidity(self, p_humidity: dict): if isinstance(p_latest := p_humidity.get(mc.KEY_LATEST), int): self.sensor_humidity.update_state(p_latest / 10) @@ -708,10 +745,6 @@ async def async_shutdown(self): self.sensor_temperature: MLSensor = None # type: ignore self.number_adjust_temperature = None # type: ignore - def _set_online(self): - super()._set_online() - self.hub._lastupdate_mts100 = 0 - def _parse_all(self, p_all: dict): self._parse_online(p_all.get(mc.KEY_ONLINE, {})) @@ -728,14 +761,14 @@ def _parse_all(self, p_all: dict): if isinstance(p_temperature := p_all.get(mc.KEY_TEMPERATURE), dict): self._parse_temperature(p_temperature) else: - climate.update_modes() - self.schedule.update_climate_modes() + climate.update_mts_state() + self.schedule.update_mts_state() def _parse_mode(self, p_mode: dict): climate = self.climate climate._mts_mode = p_mode.get(mc.KEY_STATE) - climate.update_modes() - self.schedule.update_climate_modes() + climate.update_mts_state() + self.schedule.update_mts_state() def _parse_mts100(self, p_mts100: dict): pass @@ -755,9 +788,9 @@ def _parse_temperature(self, p_temperature: dict): if isinstance(_t := p_temperature.get(mc.KEY_MAX), int): climate._attr_max_temp = _t / 10 if mc.KEY_HEATING in p_temperature: - climate._mts_heating = p_temperature[mc.KEY_HEATING] - climate.update_modes() - self.schedule.update_climate_modes() + climate._mts_active = p_temperature[mc.KEY_HEATING] + climate.update_mts_state() + self.schedule.update_mts_state() if isinstance(_t := p_temperature.get(mc.KEY_COMFORT), int): self.number_comfort_temperature.update_native_value(_t) @@ -772,8 +805,8 @@ def _parse_temperature(self, p_temperature: dict): def _parse_togglex(self, p_togglex: dict): climate = self.climate climate._mts_onoff = p_togglex.get(mc.KEY_ONOFF) - climate.update_modes() - self.schedule.update_climate_modes() + climate.update_mts_state() + self.schedule.update_mts_state() WELL_KNOWN_TYPE_MAP[mc.TYPE_MTS100] = MTS100SubDevice @@ -850,10 +883,6 @@ async def async_shutdown(self): self.sensor_status: MLSensor = None # type: ignore self.sensor_interConn: MLSensor = None # type: ignore - def _set_online(self): - super()._set_online() - self.hub._lastupdate_sensor = 0 - def _parse_smokeAlarm(self, p_smokealarm: dict): if isinstance(value := p_smokealarm.get(mc.KEY_STATUS), int): self.binary_sensor_alarm.update_onoff(value in GS559SubDevice.STATUS_ALARM) @@ -883,10 +912,6 @@ async def async_shutdown(self): await super().async_shutdown() self.binary_sensor_window: MLBinarySensor = None # type: ignore - def _set_online(self): - super()._set_online() - self.hub._lastupdate_sensor = 0 - def _parse_doorWindow(self, p_doorwindow: dict): self.binary_sensor_window.update_onoff(p_doorwindow[mc.KEY_STATUS]) diff --git a/custom_components/meross_lan/meross_entity.py b/custom_components/meross_lan/meross_entity.py index 6c91ac95..d73ede00 100644 --- a/custom_components/meross_lan/meross_entity.py +++ b/custom_components/meross_lan/meross_entity.py @@ -65,7 +65,7 @@ class MyCustomSwitch(MerossEntity, Switch) EntityCategory = EntityCategory _attr_device_class: object | str | None - _attr_entity_category: EntityCategory | str | None + _attr_entity_category: EntityCategory | str | None = None # provides a class empty default since the state writing api # would create an empty anyway.... _attr_extra_state_attributes: dict[str, object] = {} @@ -81,7 +81,6 @@ class MyCustomSwitch(MerossEntity, Switch) "manager", "channel", "_attr_device_class", - "_attr_entity_category", "_attr_state", "_attr_unique_id", "_hass_connected", @@ -122,9 +121,10 @@ def __init__( self.manager = manager self.channel = channel self._attr_device_class = device_class - self._attr_entity_category = None if self._attr_name is None: - self._attr_name = entitykey or device_class # type: ignore + self._attr_name = f"{entitykey or device_class}" + if channel: # when channel == 0 it might be the only one so skip it + self._attr_name = f"{self._attr_name} {channel}" self._attr_state = None self._attr_unique_id = manager.generate_unique_id(self) self._hass_connected = False @@ -206,6 +206,9 @@ async def async_will_remove_from_hass(self): self._hass_connected = False # interface: self + async def async_shutdown(self): + pass + def update_state(self, state: StateType): if self._attr_state != state: self._attr_state = state diff --git a/custom_components/meross_lan/meross_profile.py b/custom_components/meross_lan/meross_profile.py index 0f46b798..715b3f4d 100644 --- a/custom_components/meross_lan/meross_profile.py +++ b/custom_components/meross_lan/meross_profile.py @@ -4,11 +4,13 @@ from __future__ import annotations import abc +import asyncio from contextlib import asynccontextmanager from json import dumps as json_dumps, loads as json_loads from logging import DEBUG, INFO from time import time import typing +from uuid import uuid4 from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY from homeassistant.core import callback @@ -45,7 +47,7 @@ from .meross_device_hub import MerossDeviceHub from .merossclient import ( MEROSSDEBUG, - build_payload, + build_message, const as mc, get_default_arguments, get_namespacekey, @@ -66,7 +68,6 @@ from .sensor import MLSensor if typing.TYPE_CHECKING: - import asyncio from typing import Final from homeassistant.config_entries import ConfigEntry @@ -75,7 +76,7 @@ from . import MerossApi from .const import ProfileConfigType from .meross_device import MerossDevice, MerossDeviceDescriptor - from .merossclient import KeyType + from .merossclient import KeyType, MerossMessageType, ResponseCallbackType from .merossclient.cloudapi import ( DeviceInfoType, LatestVersionType, @@ -111,6 +112,8 @@ class AttrDictType(typing.TypedDict): ATTR_QUEUE_LENGTH: Final = "queue_length" manager: ApiProfile + + _attr_entity_category = MLSensor.EntityCategory.DIAGNOSTIC _attr_extra_state_attributes: AttrDictType _attr_state: str _attr_options = [STATE_DISCONNECTED, STATE_CONNECTED, STATE_QUEUING, STATE_DROPPING] @@ -134,10 +137,6 @@ def __init__(self, connection: MQTTConnection): def available(self): return True - @property - def entity_category(self): - return self.EntityCategory.DIAGNOSTIC - @property def options(self) -> list[str] | None: return self._attr_options @@ -181,6 +180,34 @@ def inc_queued(self, queue_length: int): self._async_write_ha_state() +class _MQTTTransaction: + """Context for pending MQTT publish(es) waiting for responses. + This will allow to synchronize message request-response flow on MQTT + """ + + __slots__ = ( + "namespace", + "messageid", + "method", + "request_time", + "response_callback", + "response_future", + ) + + def __init__( + self, + namespace: str, + method: str, + response_callback: ResponseCallbackType, + ): + self.namespace = namespace + self.messageid = uuid4().hex + self.method = method + self.request_time = time() + self.response_callback = response_callback + self.response_future = asyncio.get_running_loop().create_future() + + class MQTTConnection(Loggable): """ Base abstract class representing a connection to an MQTT @@ -197,12 +224,15 @@ class MQTTConnection(Loggable): _KEY_REQUESTTIME = "__requesttime" _KEY_REQUESTCOUNT = "__requestcount" + DEFAULT_RESPONSE_TIMEOUT = 5 + __slots__ = ( "profile", "broker", "mqttdevices", "mqttdiscovering", "sensor_connection", + "_mqtt_transactions", "_mqtt_is_connected", "_unsub_discovery_callback", ) @@ -218,6 +248,7 @@ def __init__( self.mqttdevices: dict[str, MerossDevice] = {} self.mqttdiscovering: dict[str, dict] = {} self.sensor_connection = None + self._mqtt_transactions: dict[str, _MQTTTransaction] = {} self._mqtt_is_connected = False self._unsub_discovery_callback: asyncio.TimerHandle | None = None super().__init__(connection_id) @@ -276,6 +307,7 @@ def mqtt_publish( method: str, payload: dict, key: KeyType = None, + response_callback: ResponseCallbackType | None = None, messageid: str | None = None, ) -> asyncio.Future: """ @@ -292,8 +324,9 @@ async def async_mqtt_publish( method: str, payload: dict, key: KeyType = None, + response_callback: ResponseCallbackType | None = None, messageid: str | None = None, - ): + ) -> MerossMessageType | None: """ awaits message publish in asyncio style """ @@ -314,16 +347,25 @@ async def async_mqtt_message(self, msg): header[mc.KEY_METHOD], header[mc.KEY_NAMESPACE], ) - if device_id in self.mqttdevices: - self.mqttdevices[device_id].mqtt_receive( - header, message[mc.KEY_PAYLOAD] - ) - return + messageid = header[mc.KEY_MESSAGEID] + if messageid in self._mqtt_transactions: + mqtt_transaction = self._mqtt_transactions[messageid] + if mqtt_transaction.namespace == header[mc.KEY_NAMESPACE]: + self._mqtt_transactions.pop(messageid, None) + mqtt_transaction.response_future.set_result(message) + mqtt_transaction.response_callback( + header[mc.KEY_METHOD] != mc.METHOD_ERROR, + header, + message[mc.KEY_PAYLOAD], + ) if device := ApiProfile.devices.get(device_id): + if device._mqtt_connection == self: + device.mqtt_receive(header, message[mc.KEY_PAYLOAD]) + return # we have the device registered but somehow it is not 'mqtt binded' - # either it's configuration is ONLY_HTTP or it is paired to the - # Meross cloud. In this case we shouldn't receive 'local' MQTT + # either it's configuration is ONLY_HTTP or it is paired to + # another profile. In this case we shouldn't receive 'local' MQTT self.warning( "device(%s) not registered for MQTT handling on this profile", device.name, @@ -427,13 +469,13 @@ def get_or_set_discovering(self, device_id: str): async def _async_progress_discovery(self, discovered: dict, device_id: str): for namespace in (mc.NS_APPLIANCE_SYSTEM_ALL, mc.NS_APPLIANCE_SYSTEM_ABILITY): if get_namespacekey(namespace) not in discovered: + discovered[MQTTConnection._KEY_REQUESTTIME] = time() + discovered[MQTTConnection._KEY_REQUESTCOUNT] += 1 await self.async_mqtt_publish( device_id, *get_default_arguments(namespace), self.profile.key, ) - discovered[MQTTConnection._KEY_REQUESTTIME] = time() - discovered[MQTTConnection._KEY_REQUESTCOUNT] += 1 return True return False @@ -469,6 +511,46 @@ async def _async_discovery_callback(self): self._async_discovery_callback, ) + def _mqtt_transaction_init( + self, namespace: str, method: str, response_callback: ResponseCallbackType + ): + transaction = _MQTTTransaction(namespace, method, response_callback) + self._mqtt_transactions[transaction.messageid] = transaction + return transaction + + async def _async_mqtt_transaction_wait( + self, transaction: _MQTTTransaction, timeout=DEFAULT_RESPONSE_TIMEOUT + ) -> MerossMessageType | None: + try: + return await asyncio.wait_for(transaction.response_future, timeout) + except Exception as e: + self.log_exception( + DEBUG, + e, + "waiting for MQTT reply on message %s %s (messageId: %s)", + transaction.method, + transaction.namespace, + transaction.messageid, + ) + return None + finally: + self._mqtt_transactions.pop(transaction.messageid, None) + + def _mqtt_transaction_cancel(self, transaction: _MQTTTransaction): + transaction.response_future.cancel() + self._mqtt_transactions.pop(transaction.messageid, None) + + def _mqtt_transactions_clean(self): + if self._mqtt_transactions: + # check and cleanup stale transactions + _mqtt_transaction_stale_list = [] + epoch = time() + for _mqtt_transaction in self._mqtt_transactions.values(): + if (epoch - _mqtt_transaction.request_time) > 15: + _mqtt_transaction_stale_list.append(_mqtt_transaction.messageid) + for messageid in _mqtt_transaction_stale_list: + self._mqtt_transactions.pop(messageid) + @callback def _mqtt_connected(self): """called when the underlying mqtt.Client connects to the broker""" @@ -562,26 +644,38 @@ def mqtt_publish( method: str, payload: dict, key: KeyType = None, + response_callback: ResponseCallbackType | None = None, messageid: str | None = None, - ) -> asyncio.Future: - """ - this function runs in an executor since the mqtt.Client is synchronous code. - Beware when calling HA api's (like when we want to update sensors) - """ + ) -> asyncio.Future[_MQTTTransaction | mqtt.MQTTMessageInfo | bool]: + if response_callback: + transaction = self._mqtt_transaction_init( + namespace, method, response_callback + ) + messageid = transaction.messageid + else: + transaction = None - def _publish(): + def _publish() -> _MQTTTransaction | mqtt.MQTTMessageInfo | bool: + """ + this function runs in an executor since the mqtt.Client is synchronous code. + Beware when calling HA api's (like when we want to update sensors) + """ if not self.allow_mqtt_publish: self.warning( "MQTT publishing is not allowed for this profile (device_id=%s)", device_id, timeout=14400, ) - return + if transaction: + ApiProfile.hass.loop.call_soon_threadsafe( + self._mqtt_transaction_cancel, transaction + ) + return False ret = self.rl_publish( mc.TOPIC_REQUEST.format(device_id), json_dumps( - build_payload( + build_message( namespace, method, payload, @@ -606,7 +700,12 @@ def _publish(): namespace, timeout=14000, ) - elif ret is True: + if transaction: + ApiProfile.hass.loop.call_soon_threadsafe( + self._mqtt_transaction_cancel, transaction + ) + return False + if ret is True: if sensor_connection := self.sensor_connection: ApiProfile.hass.loop.call_soon_threadsafe( sensor_connection.inc_queued, @@ -627,6 +726,7 @@ def _publish(): method, namespace, ) + return transaction or ret return ApiProfile.hass.async_add_executor_job(_publish) @@ -637,9 +737,17 @@ async def async_mqtt_publish( method: str, payload: dict, key: KeyType = None, + response_callback: ResponseCallbackType | None = None, messageid: str | None = None, ): - await self.mqtt_publish(device_id, namespace, method, payload, key, messageid) + result = await self.mqtt_publish( + device_id, namespace, method, payload, key, response_callback, messageid + ) + + if isinstance(result, _MQTTTransaction): + return await self._async_mqtt_transaction_wait( + result, self.rl_queue_duration + self.DEFAULT_RESPONSE_TIMEOUT + ) @callback def _mqtt_published(self, mid): @@ -1026,9 +1134,11 @@ async def async_check_query_device_info(self): return None async def async_check_query_latest_version(self, epoch: float, token: str): - if self.config.get(CONF_CHECK_FIRMWARE_UPDATES) and ( - epoch - self._data[MerossCloudProfile.KEY_LATEST_VERSION_TIME] - ) > PARAM_CLOUDPROFILE_QUERY_LATESTVERSION_TIMEOUT: + if ( + self.config.get(CONF_CHECK_FIRMWARE_UPDATES) + and (epoch - self._data[MerossCloudProfile.KEY_LATEST_VERSION_TIME]) + > PARAM_CLOUDPROFILE_QUERY_LATESTVERSION_TIMEOUT + ): self._data[MerossCloudProfile.KEY_LATEST_VERSION_TIME] = epoch with self.exception_warning("async_check_query_latest_version"): self._data[ diff --git a/custom_components/meross_lan/merossclient/__init__.py b/custom_components/meross_lan/merossclient/__init__.py index cd96297a..e694e9d4 100644 --- a/custom_components/meross_lan/merossclient/__init__.py +++ b/custom_components/meross_lan/merossclient/__init__.py @@ -3,19 +3,38 @@ """ from __future__ import annotations +import asyncio from hashlib import md5 -from typing import Union +from time import time +import typing from uuid import uuid4 from . import const as mc -KeyType = Union[dict, str, None] +MerossHeaderType = typing.TypedDict( + "MerossHeaderType", + { + "messageId": str, + "namespace": str, + "method": str, + "payloadVersion": int, + "from": str, + "timestamp": int, + "timestampMs": int, + "sign": str, + }, +) +MerossPayloadType = dict[str, typing.Any] +MerossMessageType = typing.TypedDict( + "MerossMessageType", {"header": MerossHeaderType, "payload": MerossPayloadType} +) +KeyType = typing.Union[MerossHeaderType, str, None] +ResponseCallbackType = typing.Callable[[bool, dict, dict], None] + try: - 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 @@ -75,6 +94,7 @@ def mqtt_random_disconnect(): return randint(0, 99) < MEROSSDEBUG.mqtt_disconnect_probability # MerossHTTPClient debug patching + http_client_log_enable = False http_disc_end = 0 http_disc_duration = 25 http_disc_probability = 0 @@ -105,7 +125,7 @@ class MerossProtocolError(Exception): - reason is an additional context error """ - def __init__(self, response: dict, reason: object | None = None): + def __init__(self, response, reason: object | None = None): self.response = response self.reason = reason super().__init__(reason) @@ -117,7 +137,7 @@ class MerossKeyError(MerossProtocolError): reported by device """ - def __init__(self, response: dict): + def __init__(self, response: MerossMessageType): super().__init__(response, "Invalid key") @@ -127,18 +147,18 @@ class MerossSignatureError(MerossProtocolError): when validating the received header """ - def __init__(self, response: dict): + def __init__(self, response: MerossMessageType): super().__init__(response, "Signature error") -def build_payload( +def build_message( namespace: str, method: str, - payload: dict, + payload: MerossPayloadType, key: KeyType, from_: str, messageid: str | None = None, -) -> dict: +) -> MerossMessageType: if isinstance(key, dict): key[mc.KEY_NAMESPACE] = namespace key[mc.KEY_METHOD] = method @@ -200,7 +220,7 @@ def get_message_signature(messageid: str, key: str, timestamp): ).hexdigest() -def get_replykey(header: dict, key: KeyType = None) -> KeyType: +def get_replykey(header: MerossHeaderType, key: KeyType = None) -> KeyType: """ checks header signature against key: if ok return sign itsef else return the full header { "messageId", "timestamp", "sign", ...} @@ -223,12 +243,28 @@ 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/custom_components/meross_lan/merossclient/cloudapi.py b/custom_components/meross_lan/merossclient/cloudapi.py index 3700b334..b6fb4aab 100644 --- a/custom_components/meross_lan/merossclient/cloudapi.py +++ b/custom_components/meross_lan/merossclient/cloudapi.py @@ -380,6 +380,10 @@ def __init__(self, credentials: MerossCloudCredentials, app_id: str | None = Non def rl_queue_length(self): return self._rl_queue_length + @property + def rl_queue_duration(self): + return self._rl_queue_length * self.RATELIMITER_MINDELAY + @property def stateext(self): return self._stateext diff --git a/custom_components/meross_lan/merossclient/const.py b/custom_components/meross_lan/merossclient/const.py index 7120a145..7093d012 100644 --- a/custom_components/meross_lan/merossclient/const.py +++ b/custom_components/meross_lan/merossclient/const.py @@ -33,12 +33,14 @@ NS_APPLIANCE_SYSTEM_TIME = "Appliance.System.Time" NS_APPLIANCE_SYSTEM_DNDMODE = "Appliance.System.DNDMode" NS_APPLIANCE_SYSTEM_RUNTIME = "Appliance.System.Runtime" +NS_APPLIANCE_SYSTEM_POSITION = "Appliance.System.Position" NS_APPLIANCE_CONFIG_KEY = "Appliance.Config.Key" NS_APPLIANCE_CONFIG_WIFI = "Appliance.Config.Wifi" NS_APPLIANCE_CONFIG_WIFIX = "Appliance.Config.WifiX" NS_APPLIANCE_CONFIG_WIFILIST = "Appliance.Config.WifiList" NS_APPLIANCE_CONFIG_TRACE = "Appliance.Config.Trace" NS_APPLIANCE_CONFIG_INFO = "Appliance.Config.Info" +NS_APPLIANCE_CONFIG_OVERTEMP = "Appliance.Config.OverTemp" NS_APPLIANCE_DIGEST_TRIGGERX = "Appliance.Digest.TriggerX" NS_APPLIANCE_DIGEST_TIMERX = "Appliance.Digest.TimerX" NS_APPLIANCE_CONTROL_MULTIPLE = "Appliance.Control.Multiple" @@ -52,7 +54,9 @@ NS_APPLIANCE_CONTROL_TIMERX = "Appliance.Control.TimerX" NS_APPLIANCE_CONTROL_CONSUMPTIONCONFIG = "Appliance.Control.ConsumptionConfig" NS_APPLIANCE_CONTROL_CONSUMPTIONX = "Appliance.Control.ConsumptionX" +NS_APPLIANCE_CONTROL_CONSUMPTIONH = "Appliance.Control.ConsumptionH" NS_APPLIANCE_CONTROL_ELECTRICITY = "Appliance.Control.Electricity" +NS_APPLIANCE_CONTROL_OVERTEMP = "Appliance.Control.OverTemp" # Light Abilities NS_APPLIANCE_CONTROL_LIGHT = "Appliance.Control.Light" NS_APPLIANCE_CONTROL_LIGHT_EFFECT = "Appliance.Control.Light.Effect" @@ -78,6 +82,7 @@ NS_APPLIANCE_HUB_TOGGLEX = "Appliance.Hub.ToggleX" NS_APPLIANCE_HUB_ONLINE = "Appliance.Hub.Online" NS_APPLIANCE_HUB_PAIRSUBDEV = "Appliance.Hub.PairSubDev" +NS_APPLIANCE_HUB_SENSITIVITY = "Appliance.Hub.Sensitivity" # miscellaneous NS_APPLIANCE_HUB_SUBDEVICE_MOTORADJUST = "Appliance.Hub.SubDevice.MotorAdjust" NS_APPLIANCE_HUB_SUBDEVICE_BEEP = "Appliance.Hub.SubDevice.Beep" @@ -89,6 +94,8 @@ NS_APPLIANCE_HUB_SENSOR_LATEST = "Appliance.Hub.Sensor.Latest" NS_APPLIANCE_HUB_SENSOR_SMOKE = "Appliance.Hub.Sensor.Smoke" NS_APPLIANCE_HUB_SENSOR_WATERLEAK = "Appliance.Hub.Sensor.WaterLeak" +NS_APPLIANCE_HUB_SENSOR_MOTION = "Appliance.Hub.Sensor.Motion" +NS_APPLIANCE_HUB_SENSOR_DOORWINDOW = "Appliance.Hub.Sensor.DoorWindow" # MTS100 NS_APPLIANCE_HUB_MTS100_ALL = "Appliance.Hub.Mts100.All" NS_APPLIANCE_HUB_MTS100_TEMPERATURE = "Appliance.Hub.Mts100.Temperature" @@ -97,6 +104,7 @@ NS_APPLIANCE_HUB_MTS100_SCHEDULE = "Appliance.Hub.Mts100.Schedule" NS_APPLIANCE_HUB_MTS100_SCHEDULEB = "Appliance.Hub.Mts100.ScheduleB" NS_APPLIANCE_HUB_MTS100_TIMESYNC = "Appliance.Hub.Mts100.TimeSync" +NS_APPLIANCE_HUB_MTS100_SUPERCTL = "Appliance.Hub.Mts100.SuperCtl" # Smart cherub HP110A NS_APPLIANCE_MCU_HP110_FIRMWARE = "Appliance.Mcu.Hp110.Firmware" NS_APPLIANCE_MCU_HP110_FAVORITE = "Appliance.Mcu.Hp110.Favorite" @@ -104,17 +112,18 @@ NS_APPLIANCE_MCU_HP110_LOCK = "Appliance.Mcu.Hp110.Lock" NS_APPLIANCE_CONTROL_MP3 = "Appliance.Control.Mp3" # MTS200 smart thermostat -NS_APPLIANCE_CONTROL_THERMOSTAT_MODE = "Appliance.Control.Thermostat.Mode" NS_APPLIANCE_CONTROL_THERMOSTAT_CALIBRATION = "Appliance.Control.Thermostat.Calibration" NS_APPLIANCE_CONTROL_THERMOSTAT_DEADZONE = "Appliance.Control.Thermostat.DeadZone" NS_APPLIANCE_CONTROL_THERMOSTAT_FROST = "Appliance.Control.Thermostat.Frost" +NS_APPLIANCE_CONTROL_THERMOSTAT_HOLDACTION = "Appliance.Control.Thermostat.HoldAction" +NS_APPLIANCE_CONTROL_THERMOSTAT_MODE = "Appliance.Control.Thermostat.Mode" NS_APPLIANCE_CONTROL_THERMOSTAT_OVERHEAT = "Appliance.Control.Thermostat.Overheat" +NS_APPLIANCE_CONTROL_THERMOSTAT_SCHEDULE = "Appliance.Control.Thermostat.Schedule" +NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR = "Appliance.Control.Thermostat.Sensor" +NS_APPLIANCE_CONTROL_THERMOSTAT_SUMMERMODE = "Appliance.Control.Thermostat.SummerMode" NS_APPLIANCE_CONTROL_THERMOSTAT_WINDOWOPENED = ( "Appliance.Control.Thermostat.WindowOpened" ) -NS_APPLIANCE_CONTROL_THERMOSTAT_SCHEDULE = "Appliance.Control.Thermostat.Schedule" -NS_APPLIANCE_CONTROL_THERMOSTAT_HOLDACTION = "Appliance.Control.Thermostat.HoldAction" -NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR = "Appliance.Control.Thermostat.Sensor" # MOD100-MOD150 diffuser NS_APPLIANCE_CONTROL_DIFFUSER_SPRAY = "Appliance.Control.Diffuser.Spray" NS_APPLIANCE_CONTROL_DIFFUSER_LIGHT = "Appliance.Control.Diffuser.Light" @@ -217,7 +226,10 @@ KEY_CURRENT = "current" KEY_VOLTAGE = "voltage" KEY_CONSUMPTIONX = "consumptionx" +KEY_CONSUMPTIONH = "consumptionH" KEY_CONSUMPTIONCONFIG = "consumptionconfig" +KEY_OVERTEMP = "overTemp" +KEY_ENABLE = "enable" KEY_DATE = "date" KEY_GARAGEDOOR = "garageDoor" KEY_STATE = "state" @@ -227,6 +239,7 @@ KEY_SIGNALCLOSE = "signalClose" KEY_SIGNALDURATION = "signalDuration" KEY_BUZZERENABLE = "buzzerEnable" +KEY_DOORENABLE = "doorEnable" KEY_DOOROPENDURATION = "doorOpenDuration" KEY_DOORCLOSEDURATION = "doorCloseDuration" KEY_OPEN = "open" @@ -256,6 +269,7 @@ KEY_OVERHEAT = "overheat" KEY_HOLDACTION = "holdAction" KEY_SENSOR = "sensor" +KEY_SUMMERMODE = "summerMode" KEY_WARNING = "warning" KEY_DIFFUSER = "diffuser" KEY_DNDMODE = "DNDMode" @@ -269,6 +283,8 @@ KEY_VOLUME = "volume" KEY_DEBUG = "debug" KEY_NETWORK = "network" +KEY_SSID = "ssid" +KEY_GATEWAYMAC = "gatewayMac" KEY_CLOUD = "cloud" KEY_ACTIVESERVER = "activeServer" KEY_MAINSERVER = "mainServer" @@ -311,6 +327,7 @@ NS_APPLIANCE_CONTROL_LIGHT_EFFECT: {KEY_EFFECT: []}, NS_APPLIANCE_CONTROL_SPRAY: {KEY_SPRAY: {}}, NS_APPLIANCE_CONTROL_MP3: {KEY_MP3: {}}, + NS_APPLIANCE_CONTROL_OVERTEMP: {KEY_OVERTEMP: [{KEY_CHANNEL: 0}]}, NS_APPLIANCE_ROLLERSHUTTER_POSITION: {KEY_POSITION: []}, NS_APPLIANCE_ROLLERSHUTTER_STATE: {KEY_STATE: []}, NS_APPLIANCE_ROLLERSHUTTER_CONFIG: {KEY_CONFIG: []}, @@ -326,15 +343,18 @@ NS_APPLIANCE_HUB_SUBDEVICE_MOTORADJUST: { KEY_ADJUST: [] }, # unconfirmed but 'motoradjust' is wrong for sure - NS_APPLIANCE_CONTROL_THERMOSTAT_MODE: {KEY_MODE: []}, NS_APPLIANCE_CONTROL_THERMOSTAT_CALIBRATION: {KEY_CALIBRATION: [{KEY_CHANNEL: 0}]}, NS_APPLIANCE_CONTROL_THERMOSTAT_DEADZONE: {KEY_DEADZONE: [{KEY_CHANNEL: 0}]}, NS_APPLIANCE_CONTROL_THERMOSTAT_FROST: {KEY_FROST: [{KEY_CHANNEL: 0}]}, + NS_APPLIANCE_CONTROL_THERMOSTAT_HOLDACTION: {KEY_HOLDACTION: [{KEY_CHANNEL: 0}]}, + NS_APPLIANCE_CONTROL_THERMOSTAT_MODE: {KEY_MODE: []}, NS_APPLIANCE_CONTROL_THERMOSTAT_OVERHEAT: {KEY_OVERHEAT: [{KEY_CHANNEL: 0}]}, - NS_APPLIANCE_CONTROL_THERMOSTAT_WINDOWOPENED: {KEY_WINDOWOPENED: []}, NS_APPLIANCE_CONTROL_THERMOSTAT_SCHEDULE: {KEY_SCHEDULE: [{KEY_CHANNEL: 0}]}, - NS_APPLIANCE_CONTROL_THERMOSTAT_HOLDACTION: {KEY_HOLDACTION: [{KEY_CHANNEL: 0}]}, NS_APPLIANCE_CONTROL_THERMOSTAT_SENSOR: {KEY_SENSOR: [{KEY_CHANNEL: 0}]}, + NS_APPLIANCE_CONTROL_THERMOSTAT_SUMMERMODE: {KEY_SUMMERMODE: [{KEY_CHANNEL: 0}]}, + NS_APPLIANCE_CONTROL_THERMOSTAT_WINDOWOPENED: { + KEY_WINDOWOPENED: [{KEY_CHANNEL: 0}] + }, NS_APPLIANCE_CONTROL_SCREEN_BRIGHTNESS: {KEY_BRIGHTNESS: [{KEY_CHANNEL: 0}]}, NS_APPLIANCE_CONTROL_DIFFUSER_SENSOR: {KEY_SENSOR: {}}, } diff --git a/custom_components/meross_lan/merossclient/httpclient.py b/custom_components/meross_lan/merossclient/httpclient.py index 626ccd66..8e2f4490 100644 --- a/custom_components/meross_lan/merossclient/httpclient.py +++ b/custom_components/meross_lan/merossclient/httpclient.py @@ -17,8 +17,10 @@ MEROSSDEBUG, KeyType, MerossKeyError, + MerossMessageType, + MerossPayloadType, MerossProtocolError, - build_payload, + build_message, const as mc, get_replykey, ) @@ -74,7 +76,7 @@ def set_logger(self, _logger: Logger): self._logger = _logger self._logid = None - async def async_request_raw(self, request: dict) -> dict: + async def async_request_raw(self, request: MerossMessageType) -> MerossMessageType: timeout = 1 try: self._logid = None @@ -108,7 +110,7 @@ async def async_request_raw(self, request: dict) -> dict: text_body = await response.text() if self._logid: self._logger.debug("%s: HTTP Response (%s)", self._logid, text_body) # type: ignore - json_body: dict = json_loads(text_body) + json_body: MerossMessageType = json_loads(text_body) if self.key is None: self.replykey = get_replykey(json_body[mc.KEY_HEADER], self.key) except Exception as e: @@ -124,9 +126,11 @@ async def async_request_raw(self, request: dict) -> dict: return json_body - async def async_request(self, namespace: str, method: str, payload: dict) -> dict: + async def async_request( + self, namespace: str, method: str, payload: MerossPayloadType + ) -> MerossMessageType: key = self.key - request = build_payload( + request = build_message( namespace, method, payload, @@ -163,18 +167,18 @@ async def async_request(self, namespace: str, method: str, payload: dict) -> dic return response async def async_request_strict( - self, namespace: str, method: str, payload: dict - ) -> dict: + self, namespace: str, method: str, payload: MerossPayloadType + ) -> MerossMessageType: """ check the protocol layer is correct and no protocol ERROR is being reported """ response = await self.async_request(namespace, method, payload) try: - r_header: dict = response[mc.KEY_HEADER] + r_header = response[mc.KEY_HEADER] r_header[mc.KEY_NAMESPACE] - r_method: str = r_header[mc.KEY_METHOD] - r_payload: dict = response[mc.KEY_PAYLOAD] + r_method = r_header[mc.KEY_METHOD] + r_payload = response[mc.KEY_PAYLOAD] except Exception as e: raise MerossProtocolError(response, str(e)) from e diff --git a/custom_components/meross_lan/number.py b/custom_components/meross_lan/number.py index 8a78a2ac..0a58d52d 100644 --- a/custom_components/meross_lan/number.py +++ b/custom_components/meross_lan/number.py @@ -6,7 +6,6 @@ from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from . import meross_entity as me -from .helpers import SmartPollingStrategy from .merossclient import const as mc, get_namespacekey # mEROSS cONST if typing.TYPE_CHECKING: @@ -54,6 +53,8 @@ class MLConfigNumber(me.MerossEntity, number.NumberEntity): DeviceClass = NumberDeviceClass manager: MerossDevice + + _attr_entity_category = me.EntityCategory.CONFIG _attr_native_max_value: float _attr_native_min_value: float _attr_native_step: float @@ -73,10 +74,6 @@ class MLConfigNumber(me.MerossEntity, number.NumberEntity): "_attr_native_unit_of_measurement", ) - @property - def entity_category(self): - return me.EntityCategory.CONFIG - @property def mode(self) -> number.NumberMode: """Return the mode of the entity.""" @@ -161,83 +158,3 @@ def __init__( @property def ml_multiplier(self): return 100 - - -class MLScreenBrightnessNumber(MLConfigNumber): - manager: ScreenBrightnessMixin - - _attr_icon = "mdi:brightness-percent" - - def __init__(self, manager: ScreenBrightnessMixin, channel: object, key: str): - self.key_value = key - self._attr_name = f"Screen brightness ({key})" - super().__init__(manager, channel, f"screenbrightness_{key}") - - @property - def native_max_value(self): - return 100 - - @property - def native_min_value(self): - return 0 - - @property - def native_step(self): - return 12.5 - - @property - def native_unit_of_measurement(self): - return PERCENTAGE - - async def async_set_native_value(self, value: float): - brightness = { - mc.KEY_CHANNEL: self.channel, - mc.KEY_OPERATION: self.manager._number_brightness_operation.native_value, - mc.KEY_STANDBY: self.manager._number_brightness_standby.native_value, - } - brightness[self.key_value] = value - - def _ack_callback(acknowledge: bool, header: dict, payload: dict): - if acknowledge: - self.update_native_value(value) - - await self.manager.async_request( - mc.NS_APPLIANCE_CONTROL_SCREEN_BRIGHTNESS, - mc.METHOD_SET, - {mc.KEY_BRIGHTNESS: [brightness]}, - _ack_callback, - ) - - -class ScreenBrightnessMixin( - MerossDevice if typing.TYPE_CHECKING else object -): # pylint: disable=used-before-assignment - def __init__(self, descriptor, entry): - super().__init__(descriptor, entry) - - with self.exception_warning("ScreenBrightnessMixin init"): - # the 'ScreenBrightnessMixin' actually doesnt have a clue of how many entities - # are controllable since the digest payload doesnt carry anything (like MerossShutter) - # So we're not implementing _init_xxx and _parse_xxx methods here and - # we'll just add a couple of number entities to control 'active' and 'standby' brightness - # on channel 0 which will likely be the only one available - self._number_brightness_operation = MLScreenBrightnessNumber( - self, 0, mc.KEY_OPERATION - ) - self._number_brightness_standby = MLScreenBrightnessNumber( - self, 0, mc.KEY_STANDBY - ) - self.polling_dictionary[ - mc.NS_APPLIANCE_CONTROL_SCREEN_BRIGHTNESS - ] = SmartPollingStrategy(mc.NS_APPLIANCE_CONTROL_SCREEN_BRIGHTNESS) - - def _handle_Appliance_Control_Screen_Brightness(self, header: dict, payload: dict): - for p_channel in payload[mc.KEY_BRIGHTNESS]: - if p_channel.get(mc.KEY_CHANNEL) == 0: - self._number_brightness_operation.update_native_value( - p_channel[mc.KEY_OPERATION] - ) - self._number_brightness_standby.update_native_value( - p_channel[mc.KEY_STANDBY] - ) - break diff --git a/custom_components/meross_lan/sensor.py b/custom_components/meross_lan/sensor.py index ea4fdb94..9952fa8e 100644 --- a/custom_components/meross_lan/sensor.py +++ b/custom_components/meross_lan/sensor.py @@ -1,8 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta -from logging import DEBUG -from time import time +from datetime import datetime import typing from homeassistant.components import sensor @@ -14,20 +12,10 @@ POWER_WATT, TEMP_CELSIUS, ) -from homeassistant.core import callback -from homeassistant.helpers.event import async_track_point_in_time -from homeassistant.util import dt as dt_util from . import meross_entity as me -from .const import CONF_PROTOCOL_HTTP, CONF_PROTOCOL_MQTT, PARAM_ENERGY_UPDATE_PERIOD -from .helpers import ( - ApiProfile, - EntityPollingStrategy, - SmartPollingStrategy, - StrEnum, - get_entity_last_state_available, -) -from .merossclient import const as mc # mEROSS cONST +from .const import CONF_PROTOCOL_HTTP, CONF_PROTOCOL_MQTT +from .helpers import StrEnum if typing.TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -139,6 +127,8 @@ class ProtocolSensor(MLSensor): ATTR_MQTT_BROKER = "mqtt_broker" manager: MerossDevice + + _attr_entity_category = me.EntityCategory.DIAGNOSTIC _attr_state: str _attr_options = [STATE_DISCONNECTED, CONF_PROTOCOL_MQTT, CONF_PROTOCOL_HTTP] @@ -158,10 +148,6 @@ def __init__( def available(self): return True - @property - def entity_category(self): - return me.EntityCategory.DIAGNOSTIC - @property def entity_registry_enabled_default(self): return False @@ -236,419 +222,3 @@ def update_attrs_inactive(self, *attrnames): flush = True if flush and self._hass_connected: self._async_write_ha_state() - - -class EnergyEstimateSensor(MLSensor): - _attr_state: int - _attr_state_float: float = 0.0 - - def __init__(self, manager: ElectricityMixin): - super().__init__(manager, None, "energy_estimate", self.DeviceClass.ENERGY) - self._attr_state = 0 - - @property - def entity_registry_enabled_default(self): - return False - - @property - def available(self): - return True - - async def async_added_to_hass(self): - await super().async_added_to_hass() - # state restoration is only needed on cold-start and we have to discriminate - # from when this happens while the device is already working. In general - # the sensor state is always kept in the instance even when it's disabled - # so we don't want to overwrite that should we enable an entity after - # it has been initialized. Checking _attr_state here should be enough - # since it's surely 0 on boot/initial setup (entities are added before - # device reading data). If an entity is disabled on startup of course our state - # will start resetted and our sums will restart (disabled means not interesting - # anyway) - if self._attr_state != 0: - return - - with self.exception_warning("restoring previous state"): - state = await get_entity_last_state_available(self.hass, self.entity_id) - if state is None: - return - if state.last_updated < dt_util.start_of_local_day(): - # tbh I don't know what when last_update == start_of_day - return - # state should be an int though but in case we decide some - # tweaks here or there this conversion is safer (allowing for a float state) - # and more consistent - self._attr_state_float = float(state.state) - self._attr_state = int(self._attr_state_float) - - def set_unavailable(self): - # we need to preserve our sum so we don't reset - # it on disconnection. Also, it's nice to have it - # available since this entity has a computed value - # not directly related to actual connection state - pass - - def update_estimate(self, de: float): - # this is the 'estimated' sensor update api - # based off ElectricityMixin power readings - self._attr_state_float += de - state = int(self._attr_state_float) - if self._attr_state != state: - self._attr_state = state - if self._hass_connected: - self.async_write_ha_state() - - def reset_estimate(self): - self._attr_state_float -= self._attr_state # preserve fraction - self._attr_state = 0 - if self._hass_connected: - self.async_write_ha_state() - - -class ElectricityMixin( - MerossDevice if typing.TYPE_CHECKING else object -): # pylint: disable=used-before-assignment - _electricity_lastupdate = 0.0 - _sensor_power: MLSensor - _sensor_current: MLSensor - _sensor_voltage: MLSensor - # implement an estimated energy measure from _sensor_power. - # Estimate is a trapezoidal integral sum on power. Using class - # initializers to ease instance sharing (and type-checks) - # between ElectricityMixin and ConsumptionMixin. Based on experience - # ElectricityMixin and ConsumptionMixin are always present together - # in metering plugs (mss310 is the historical example). - # Based on observations this estimate is falling a bit behind - # the consumption reported from the device at least when the - # power is very low (likely due to power readings being a bit off) - _sensor_energy_estimate: EnergyEstimateSensor - _cancel_energy_reset = None - - # This is actually reset in ConsumptionMixin - _consumption_estimate = 0.0 - - def __init__(self, descriptor, entry): - super().__init__(descriptor, entry) - self._sensor_power = MLSensor.build_for_device(self, MLSensor.DeviceClass.POWER) - self._sensor_current = MLSensor.build_for_device( - self, MLSensor.DeviceClass.CURRENT - ) - self._sensor_voltage = MLSensor.build_for_device( - self, MLSensor.DeviceClass.VOLTAGE - ) - self._sensor_energy_estimate = EnergyEstimateSensor(self) - self.polling_dictionary[ - mc.NS_APPLIANCE_CONTROL_ELECTRICITY - ] = SmartPollingStrategy(mc.NS_APPLIANCE_CONTROL_ELECTRICITY) - - def start(self): - self._schedule_next_reset(dt_util.now()) - super().start() - - async def async_shutdown(self): - if self._cancel_energy_reset: - self._cancel_energy_reset() - self._cancel_energy_reset = None - await super().async_shutdown() - self._sensor_power = None # type: ignore - self._sensor_current = None # type: ignore - self._sensor_voltage = None # type: ignore - self._sensor_energy_estimate = None # type: ignore - - def _handle_Appliance_Control_Electricity(self, header: dict, payload: dict): - electricity = payload[mc.KEY_ELECTRICITY] - power = float(electricity[mc.KEY_POWER]) / 1000 - if (last_power := self._sensor_power._attr_state) is not None: - # dt = self.lastupdate - self._electricity_lastupdate - # de = (((last_power + power) / 2) * dt) / 3600 - de = ( - (last_power + power) - * (self.lastresponse - self._electricity_lastupdate) - ) / 7200 - self._consumption_estimate += de - self._sensor_energy_estimate.update_estimate(de) - - self._electricity_lastupdate = self.lastresponse - self._sensor_power.update_state(power) - self._sensor_current.update_state(electricity[mc.KEY_CURRENT] / 1000) # type: ignore - self._sensor_voltage.update_state(electricity[mc.KEY_VOLTAGE] / 10) # type: ignore - - def _schedule_next_reset(self, _now: datetime): - with self.exception_warning("_schedule_next_reset"): - today = _now.date() - tomorrow = today + timedelta(days=1) - next_reset = datetime( - year=tomorrow.year, - month=tomorrow.month, - day=tomorrow.day, - hour=0, - minute=0, - second=0, - microsecond=0, - tzinfo=dt_util.DEFAULT_TIME_ZONE, - ) - self._cancel_energy_reset = async_track_point_in_time( - ApiProfile.hass, self._energy_reset, next_reset - ) - self.log(DEBUG, "_schedule_next_reset at %s", next_reset.isoformat()) - - @callback - def _energy_reset(self, _now: datetime): - self._cancel_energy_reset = None - self.log(DEBUG, "_energy_reset at %s", _now.isoformat()) - self._sensor_energy_estimate.reset_estimate() - self._schedule_next_reset(_now) - - -class ConsumptionSensor(MLSensor): - ATTR_OFFSET = "offset" - offset: int = 0 - ATTR_RESET_TS = "reset_ts" - reset_ts: int = 0 - - manager: ConsumptionMixin - _attr_state: int | None - - def __init__(self, manager: ConsumptionMixin): - self._attr_extra_state_attributes = {} - super().__init__( - manager, None, str(self.DeviceClass.ENERGY), self.DeviceClass.ENERGY - ) - - async def async_added_to_hass(self): - await super().async_added_to_hass() - # state restoration is only needed on cold-start and we have to discriminate - # from when this happens while the device is already working. In general - # the sensor state is always kept in the instance even when it's disabled - # so we don't want to overwrite that should we enable an entity after - # it has been initialized. Checking _attr_state here should be enough - # since it's surely 0 on boot/initial setup (entities are added before - # device reading data). If an entity is disabled on startup of course our state - # will start resetted and our sums will restart (disabled means not interesting - # anyway) - if (self._attr_state is not None) or self._attr_extra_state_attributes: - return - - with self.exception_warning("restoring previous state"): - state = await get_entity_last_state_available(self.hass, self.entity_id) - if state is None: - return - # check if the restored sample is fresh enough i.e. it was - # updated after the device midnight for today..else it is too - # old to be good. Since we don't have actual device epoch we - # 'guess' it is nicely synchronized so we'll use our time - devicetime = self.manager.get_datetime(time()) - devicetime_today_midnight = datetime( - devicetime.year, - devicetime.month, - devicetime.day, - tzinfo=devicetime.tzinfo, - ) - if state.last_updated < devicetime_today_midnight: - return - # fix beta/preview attr names (sometime REMOVE) - if "energy_offset" in state.attributes: - _attr_value = state.attributes["energy_offset"] - self._attr_extra_state_attributes[self.ATTR_OFFSET] = _attr_value - setattr(self, self.ATTR_OFFSET, _attr_value) - if "energy_reset_ts" in state.attributes: - _attr_value = state.attributes["energy_reset_ts"] - self._attr_extra_state_attributes[self.ATTR_RESET_TS] = _attr_value - setattr(self, self.ATTR_RESET_TS, _attr_value) - for _attr_name in (self.ATTR_OFFSET, self.ATTR_RESET_TS): - if _attr_name in state.attributes: - _attr_value = state.attributes[_attr_name] - self._attr_extra_state_attributes[_attr_name] = _attr_value - # we also set the value as an instance attr for faster access - setattr(self, _attr_name, _attr_value) - # HA adds decimals when the display precision is set for the entity - # according to this issue #268. In order to try not mess statistics - # we're reverting to the old design where the sensor state is - # reported as 'unavailable' when the device is disconnected and so - # we don't restore the state value at all but just wait for a 'fresh' - # consumption value from the device. The attributes restoration will - # instead keep patching the 'consumption reset bug' - - def reset_consumption(self): - if self._attr_state != 0: - self._attr_state = 0 - self._attr_extra_state_attributes = {} - self.offset = 0 - self.reset_ts = 0 - if self._hass_connected: - self.async_write_ha_state() - self.log(DEBUG, "no readings available for new day - resetting") - - -class ConsumptionMixin( - MerossDevice if typing.TYPE_CHECKING else object -): # pylint: disable=used-before-assignment - _consumption_last_value: int | None = None - _consumption_last_time: int | None = None - # these are the device actual EPOCHs of the last midnight - # and the midnight of they before. midnight epoch(s) are - # the times at which the device local time trips around - # midnight (which could be different than GMT tripping of course) - _yesterday_midnight_epoch = 0 # 12:00 am yesterday - _today_midnight_epoch = 0 # 12:00 am today - _tomorrow_midnight_epoch = 0 # 12:00 am tomorrow - - # instance value shared with ElectricityMixin - _consumption_estimate = 0.0 - - def __init__(self, descriptor, entry): - super().__init__(descriptor, entry) - self._sensor_consumption: ConsumptionSensor = ConsumptionSensor(self) - self.polling_dictionary[ - mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX - ] = EntityPollingStrategy( - mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX, - self._sensor_consumption, - PARAM_ENERGY_UPDATE_PERIOD, - ) - - async def async_shutdown(self): - await super().async_shutdown() - self._sensor_consumption = None # type: ignore - - def _handle_Appliance_Control_ConsumptionX(self, header: dict, payload: dict): - _sensor_consumption = self._sensor_consumption - # we'll look through the device array values to see - # data timestamped (in device time) after last midnight - # since we usually reset this around midnight localtime - # the device timezone should be aligned else it will roundtrip - # against it's own midnight and we'll see a delayed 'sawtooth' - if self.device_timestamp > self._tomorrow_midnight_epoch: - # catch the device starting a new day since our last update (yesterday) - devtime = self.get_datetime(self.device_timestamp) - devtime_today_midnight = datetime( - devtime.year, - devtime.month, - devtime.day, - tzinfo=devtime.tzinfo, - ) - # we'd better not trust our cached tomorrow, today and yesterday - # epochs (even if 99% of the times they should be good) - # so we fully recalculate them on each 'midnight trip update' - # and spend some cpu resources this way... - self._today_midnight_epoch = devtime_today_midnight.timestamp() - daydelta = timedelta(days=1) - self._tomorrow_midnight_epoch = ( - devtime_today_midnight + daydelta - ).timestamp() - self._yesterday_midnight_epoch = ( - devtime_today_midnight - daydelta - ).timestamp() - self.log( - DEBUG, - "updated midnight epochs: yesterday=%s - today=%s - tomorrow=%s", - str(self._yesterday_midnight_epoch), - str(self._today_midnight_epoch), - str(self._tomorrow_midnight_epoch), - ) - - # the days array contains a month worth of data - # but we're only interested in the last few days (today - # and maybe yesterday) so we discard a bunch of - # elements before sorting (in order to not waste time) - # checks for 'not enough meaningful data' are post-poned - # and just for safety since they're unlikely to happen - # in a normal running environment over few days - days = [ - day - for day in payload[mc.KEY_CONSUMPTIONX] - if day[mc.KEY_TIME] >= self._yesterday_midnight_epoch - ] - if (days_len := len(days)) == 0: - _sensor_consumption.reset_consumption() - return - - elif days_len > 1: - - def _get_timestamp(day): - return day[mc.KEY_TIME] - - days = sorted(days, key=_get_timestamp) - - day_last: dict = days[-1] - day_last_time: int = day_last[mc.KEY_TIME] - - if day_last_time < self._today_midnight_epoch: - # this could happen right after midnight when the device - # should start a new cycle but the consumption is too low - # (device starts reporting from 1 wh....) so, even if - # new day has come, new data have not - self._consumption_last_value = None - _sensor_consumption.reset_consumption() - return - - # now day_last 'should' contain today data in HA time. - day_last_value: int = day_last[mc.KEY_VALUE] - # check if the device tripped its own midnight and started a - # new day readings - if days_len > 1 and ( - _sensor_consumption.reset_ts - != (day_yesterday_time := days[-2][mc.KEY_TIME]) - ): - # this is the first time after device midnight that we receive new data. - # in order to fix #264 we're going to set our internal energy offset. - # This is very dangerous since we must discriminate between faulty - # resets and good resets from the device. Typically the device resets - # itself correctly and we have new 0-based readings but we can't - # reliably tell when the error happens since the 'new' reading could be - # any positive value depending on actual consumption of the device - - # first off we consider the device readings good - _sensor_consumption.reset_ts = day_yesterday_time - _sensor_consumption.offset = 0 - _sensor_consumption._attr_extra_state_attributes = { - _sensor_consumption.ATTR_RESET_TS: day_yesterday_time - } - if (self._consumption_last_time is not None) and ( - self._consumption_last_time <= day_yesterday_time - ): - # In order to fix #264 and any further bug in consumption - # we'll check it against _consumption_estimate from ElectricityMixin. - # _consumption_estimate is reset in ConsumptionMixin every time we - # get a new fresh consumption value and should contain an estimate - # over the last (device) accumulation period. Here we're across the - # device midnight reset so our _consumption_estimate is trying - # to measure the effective consumption since the last updated - # reading of yesterday. The check on _consumption_last_time is - # to make sure we're not applying any offset when we start 'fresh' - # reading during a day and HA has no state carried over since - # midnight on this sensor - energy_estimate = int(self._consumption_estimate) + 1 - if day_last_value > energy_estimate: - _sensor_consumption._attr_extra_state_attributes[ - _sensor_consumption.ATTR_OFFSET - ] = _sensor_consumption.offset = (day_last_value - energy_estimate) - self.log( - DEBUG, - "first consumption reading for new day, offset=%d", - _sensor_consumption.offset, - ) - - elif day_last_value == self._consumption_last_value: - # no change in consumption..skip updating unless sensor was disconnected - if _sensor_consumption._attr_state is None: - _sensor_consumption._attr_state = ( - day_last_value - _sensor_consumption.offset - ) - if _sensor_consumption._hass_connected: - _sensor_consumption.async_write_ha_state() - return - - self._consumption_last_time = day_last_time - self._consumption_last_value = day_last_value - self._consumption_estimate = 0.0 # reset ElecticityMixin estimate cycle - _sensor_consumption._attr_state = day_last_value - _sensor_consumption.offset - if _sensor_consumption._hass_connected: - _sensor_consumption.async_write_ha_state() - self.log(DEBUG, "updating consumption=%d", day_last_value) - - def _set_offline(self): - super()._set_offline() - self._yesterday_midnight_epoch = 0 - self._today_midnight_epoch = 0 - self._tomorrow_midnight_epoch = 0 diff --git a/custom_components/meross_lan/services.yaml b/custom_components/meross_lan/services.yaml index 7e3f681d..47a01936 100644 --- a/custom_components/meross_lan/services.yaml +++ b/custom_components/meross_lan/services.yaml @@ -34,6 +34,19 @@ request: example: "192.168.1.1" selector: text: + protocol: + name: Protocol + description: The protocol used to make the request + required: false + advanced: false + example: "auto" + default: "auto" + selector: + select: + options: + - "auto" + - "mqtt" + - "http" method: name: Method description: The method to set in the message @@ -95,7 +108,7 @@ request: - "Appliance.Hub.Mts100.Mode" key: name: Key - description: The key used to encrypt message signatures (unneeded if device already configured) + description: The key used to encrypt message signatures (ignored if device already configured) required: false advanced: false selector: @@ -108,12 +121,4 @@ request: example: '{ "togglex": { "onoff": 0, "channel": 0 } }' default: '{}' selector: - text: - notifyresponse: - name: Notify response - description: Forwards the device response to homeassistant persistent notifications - required: false - advanced: false - default: True - selector: - boolean: + text: \ No newline at end of file diff --git a/custom_components/meross_lan/update.py b/custom_components/meross_lan/update.py index 69cd942b..b50fd6b5 100644 --- a/custom_components/meross_lan/update.py +++ b/custom_components/meross_lan/update.py @@ -18,6 +18,8 @@ class MLUpdate(me.MerossEntity, update.UpdateEntity): PLATFORM = update.DOMAIN DeviceClass = update.UpdateDeviceClass + _attr_entity_category = me.EntityCategory.DIAGNOSTIC + def __init__(self, manager: MerossDevice): super().__init__(manager, None, "update_firmware", self.DeviceClass.FIRMWARE) @@ -25,17 +27,15 @@ def __init__(self, manager: MerossDevice): def available(self): return True - @property - def entity_category(self): - return me.EntityCategory.DIAGNOSTIC - @property def supported_features(self): """Flag supported features.""" return update.UpdateEntityFeature.INSTALL async def async_install(self, version: str | None, backup: bool, **kwargs) -> None: - self.warning("The firmware update feature is not (yet) available: use the Meross app to carry the process") + self.warning( + "The firmware update feature is not (yet) available: use the Meross app to carry the process" + ) def set_unavailable(self): pass diff --git a/emulator/__init__.py b/emulator/__init__.py index 5da953b5..dc3a6be9 100644 --- a/emulator/__init__.py +++ b/emulator/__init__.py @@ -51,7 +51,7 @@ # homeassistant.helpers.storage from custom_components.meross_lan.merossclient import ( MerossDeviceDescriptor, - build_payload, + build_message, const as mc, get_namespacekey, get_replykey, @@ -201,7 +201,7 @@ def handle(self, request: str) -> dict: method = mc.METHOD_ERROR payload = {mc.KEY_ERROR: {mc.KEY_CODE: -1, "message": str(e)}} - data = build_payload( + data = build_message( namespace, method, payload, @@ -365,9 +365,9 @@ def build_emulator(tracefile, uuid, key) -> MerossEmulator: mixin_classes.append(ElectricityMixin) if mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX in descriptor.ability: - from .mixins.electricity import ConsumptionMixin + from .mixins.electricity import ConsumptionXMixin - mixin_classes.append(ConsumptionMixin) + mixin_classes.append(ConsumptionXMixin) if mc.NS_APPLIANCE_CONTROL_LIGHT in descriptor.ability: from .mixins.light import LightMixin diff --git a/emulator/mixins/electricity.py b/emulator/mixins/electricity.py index ea6832ab..708d4738 100644 --- a/emulator/mixins/electricity.py +++ b/emulator/mixins/electricity.py @@ -65,7 +65,7 @@ def _GET_Appliance_Control_Electricity(self, header, payload): return mc.METHOD_GETACK, self.payload_electricity -class ConsumptionMixin(MerossEmulator if typing.TYPE_CHECKING else object): +class ConsumptionXMixin(MerossEmulator if typing.TYPE_CHECKING else object): # this is a static default but we're likely using # the current 'power' state managed by the ElectricityMixin power = 0.0 # in mW diff --git a/emulator/mixins/garagedoor.py b/emulator/mixins/garagedoor.py index a43ec365..017cb97d 100644 --- a/emulator/mixins/garagedoor.py +++ b/emulator/mixins/garagedoor.py @@ -20,6 +20,17 @@ def _SET_Appliance_GarageDoor_Config(self, header, payload): p_config[_key] = _value return mc.METHOD_SETACK, {} + def _SET_Appliance_GarageDoor_MultipleConfig(self, header, payload): + p_config = self.descriptor.namespaces[ + mc.NS_APPLIANCE_GARAGEDOOR_MULTIPLECONFIG + ][mc.KEY_CONFIG] + for p_payload_channel in payload[mc.KEY_CONFIG]: + channel = p_payload_channel[mc.KEY_CHANNEL] + p_config_channel = get_element_by_key(p_config, mc.KEY_CHANNEL, channel) + p_config_channel.update(p_payload_channel) + p_config_channel[mc.KEY_TIMESTAMP] = self.epoch + return mc.METHOD_SETACK, {} + def _GET_Appliance_GarageDoor_State(self, header, payload): # return everything...at the moment we always query all p_garageDoor: list = self.descriptor.digest[mc.KEY_GARAGEDOOR] @@ -34,10 +45,9 @@ def _SET_Appliance_GarageDoor_State(self, header, payload): p_request = payload[mc.KEY_STATE] request_channel = p_request[mc.KEY_CHANNEL] request_open = p_request[mc.KEY_OPEN] - p_digest = self.descriptor.digest p_state = get_element_by_key( - p_digest[mc.KEY_GARAGEDOOR], mc.KEY_CHANNEL, request_channel + self.descriptor.digest[mc.KEY_GARAGEDOOR], mc.KEY_CHANNEL, request_channel ) p_response = dict(p_state) 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): diff --git a/emulator_traces/U01234567890123456789012345678912-Kpippo-msh300hk-mts150.csv b/emulator_traces/U01234567890123456789012345678912-Kpippo-msh300hk-mts150.csv new file mode 100644 index 00000000..305f7da4 --- /dev/null +++ b/emulator_traces/U01234567890123456789012345678912-Kpippo-msh300hk-mts150.csv @@ -0,0 +1,175 @@ +2023/11/07 - 11:28:24 auto GETACK Appliance.System.All {"system": {"hardware": {"type": "msh300hk", "subType": "un", "version": "5.0.0", "chipType": "MT7686", "uuid": "###############################0", "macAddress": "################0"}, "firmware": {"version": "5.5.32", "homekitVersion": "4.1", "compileTime": "2023/10/11 11:31:05 GMT +08:00", "encrypt": 1, "wifiMac": "################1", "innerIp": "############0", "server": "###################0", "port": "@0", "userId": "@0"}, "time": {"timestamp": 1699348685, "timezone": "Europe/Rome", "timeRule": [[1679792400, 7200, 1], [1698541200, 3600, 0], [1711846800, 7200, 1], [1729990800, 3600, 0], [1743296400, 7200, 1], [1761440400, 3600, 0], [1774746000, 7200, 1], [1792890000, 3600, 0], [1806195600, 7200, 1], [1824944400, 3600, 0], [1837645200, 7200, 1], [1856394000, 3600, 0], [1869094800, 7200, 1], [1887843600, 3600, 0], [1901149200, 7200, 1], [1919293200, 3600, 0], [1932598800, 7200, 1], [1950742800, 3600, 0], [1964048400, 7200, 1], [1982797200, 3600, 0]]}, "online": {"status": 1, "bindId": "agvgaM9yldlrGDya", "who": 1}}, "digest": {"hub": {"hubId": 3922155501, "mode": 0, "nvdmChl": 0, "workChl": 1, "curChl": 1, "subdevice": [{"id": "03002B0E", "status": 1, "scheduleBMode": 6, "onoff": 1, "lastActiveTime": 1699348536, "mts150": {"mode": 1, "currentSet": 210, "updateMode": 1, "updateTemp": 175, "motorCurLocation": 248, "motorStartCtr": 670, "motorTotalPath": 66522}}, {"id": "03004B5A", "status": 1, "scheduleBMode": 6, "onoff": 1, "lastActiveTime": 1699348681, "mts150": {"mode": 1, "currentSet": 210, "updateMode": 1, "updateTemp": 210, "motorCurLocation": 1296, "motorStartCtr": 977, "motorTotalPath": 111890}}]}}} +2023/11/07 - 11:28:24 auto GETACK Appliance.System.Ability {"Appliance.Config.Key": {}, "Appliance.Config.WifiList": {}, "Appliance.Config.Wifi": {}, "Appliance.Config.WifiX": {}, "Appliance.Config.Trace": {}, "Appliance.Config.Info": {}, "Appliance.System.All": {}, "Appliance.System.Hardware": {}, "Appliance.System.Firmware": {}, "Appliance.System.Debug": {}, "Appliance.System.Online": {}, "Appliance.System.Time": {}, "Appliance.System.Clock": {}, "Appliance.System.Ability": {}, "Appliance.System.Runtime": {}, "Appliance.System.Report": {}, "Appliance.System.Position": {}, "Appliance.System.DNDMode": {}, "Appliance.Control.Multiple": {"maxCmdNum": 5}, "Appliance.Control.Bind": {}, "Appliance.Control.Unbind": {}, "Appliance.Control.Upgrade": {}, "Appliance.Hub.Online": {}, "Appliance.Hub.ToggleX": {}, "Appliance.Hub.SubdeviceList": {}, "Appliance.Hub.Battery": {}, "Appliance.Hub.Sensitivity": {}, "Appliance.Hub.PairSubDev": {}, "Appliance.Hub.Report": {}, "Appliance.Hub.Mts100.All": {}, "Appliance.Hub.Mts100.Temperature": {}, "Appliance.Hub.Mts100.ScheduleB": {"scheduleUnitTime": 15}, "Appliance.Hub.Mts100.Mode": {}, "Appliance.Hub.Mts100.Adjust": {}, "Appliance.Hub.Mts100.SuperCtl": {}, "Appliance.Hub.SubDevice.MotorAdjust": {}, "Appliance.Hub.SubDevice.Beep": {}, "Appliance.Hub.Sensor.All": {}, "Appliance.Hub.Sensor.WaterLeak": {}, "Appliance.Hub.Sensor.TempHum": {}, "Appliance.Hub.Sensor.Adjust": {}, "Appliance.Hub.Sensor.Motion": {}, "Appliance.Hub.Sensor.DoorWindow": {}, "Appliance.Hub.Sensor.Smoke": {}, "Appliance.Hub.Sensor.Alert": {}} +2023/11/07 - 11:28:24 TX http GET Appliance.Config.Info {"info": {}} +2023/11/07 - 11:28:24 RX http GETACK Appliance.Config.Info {"info": {"homekit": {"model": "MSH300HK", "sn": "620802230500553", "category": 2, "setupId": "BT19", "setupCode": "621-48-139", "uuid": "###################################1", "token": "#######################################################################################################################################################################################################################################0"}}} +2023/11/07 - 11:28:24 auto LOG DEBUG handler undefined for method:(GETACK) namespace:(Appliance.Config.Info) payload:({'info': {'homekit': {'model': 'MSH300HK', 'sn': '620802230500553', 'category': 2, 'setupId': 'BT19', 'setupCode': '621-48-139', 'uuid': '23052603-4102-ff27-7236-1d60163235bd', 'token': 'MYGpME4CAQECAQEERjBEAiBDLRNlj4zpSuJlCDKF4EusN/YeYiQOOJ4hWSXcv6GeWQIgUUBP5iMc2t6nCCwORM0HlCAd6GL/6TgEbq1/Fmoj0E4wVwIBAgIBAQRPMU0wCQIBZgIBAQQBATAQAgFlAgEBBAge5B9WiAEAADAUAgIAyQIBAQQLMjUzNjY4LTAwNDcwGAIBZwIBAQQQz+PctqJ3SZK+5ohyZRC6XA=='}}}) +2023/11/07 - 11:28:26 TX http GET Appliance.System.Runtime {"runtime": {}} +2023/11/07 - 11:28:26 RX http GETACK Appliance.System.Runtime {"runtime": {"signal": 100}} +2023/11/07 - 11:28:28 TX http GET Appliance.System.Position {"position": {}} +2023/11/07 - 11:28:28 RX http GETACK Appliance.System.Position {"position": {"longitude": 8701752, "latitude": 45680761}} +2023/11/07 - 11:28:28 auto LOG DEBUG handler undefined for method:(GETACK) namespace:(Appliance.System.Position) payload:({'position': {'longitude': 8701752, 'latitude': 45680761}}) +2023/11/07 - 11:28:30 TX http GET Appliance.Hub.Online {"online": []} +2023/11/07 - 11:28:30 RX http GETACK Appliance.Hub.Online {"online": [{"id": "03002B0E", "status": 1, "lastActiveTime": 1699352777}, {"id": "03004B5A", "status": 1, "lastActiveTime": 1699352830}]} +2023/11/07 - 11:28:32 TX http GET Appliance.Hub.ToggleX {"toggleX": []} +2023/11/07 - 11:28:32 RX http GETACK Appliance.Hub.ToggleX {"togglex": [{"id": "03002B0E", "onoff": 1}, {"id": "03004B5A", "onoff": 1}]} +2023/11/07 - 11:28:34 TX http GET Appliance.Hub.Battery {"battery": []} +2023/11/07 - 11:28:34 RX http GETACK Appliance.Hub.Battery {"battery": [{"id": "03002B0E", "value": 100}, {"id": "03004B5A", "value": 100}]} +2023/11/07 - 11:28:36 TX http GET Appliance.Hub.Sensitivity {"sensitivity": []} +2023/11/07 - 11:28:36 RX http GETACK Appliance.Hub.Sensitivity {"sensitivity": []} +2023/11/07 - 11:28:36 auto LOG DEBUG handler undefined for method:(GETACK) namespace:(Appliance.Hub.Sensitivity) payload:({'sensitivity': []}) +2023/11/07 - 11:28:38 TX http GET Appliance.Hub.Mts100.All {"all": []} +2023/11/07 - 11:28:38 RX http GETACK Appliance.Hub.Mts100.All {"all": [{"id": "03002B0E", "scheduleBMode": 6, "online": {"status": 1, "lastActiveTime": 1699352777}, "togglex": {"onoff": 1}, "timeSync": {"state": 1}, "mode": {"state": 3}, "temperature": {"room": 185, "currentSet": 180, "custom": 200, "comfort": 210, "economy": 180, "away": 150, "max": 350, "min": 50, "heating": 0, "openWindow": 0, "id": "03002B0E"}}, {"id": "03004B5A", "scheduleBMode": 6, "online": {"status": 1, "lastActiveTime": 1699352830}, "togglex": {"onoff": 1}, "timeSync": {"state": 1}, "mode": {"state": 1}, "temperature": {"room": 205, "currentSet": 210, "custom": 210, "comfort": 210, "economy": 180, "away": 150, "max": 350, "min": 50, "heating": 0, "openWindow": 0, "id": "03004B5A"}}]} +2023/11/07 - 11:28:40 TX http GET Appliance.Hub.Mts100.Temperature {"temperature": []} +2023/11/07 - 11:28:40 RX http GETACK Appliance.Hub.Mts100.Temperature {"temperature": [{"room": 185, "currentSet": 180, "custom": 200, "comfort": 210, "economy": 180, "away": 150, "max": 350, "min": 50, "heating": 0, "openWindow": 0, "id": "03002B0E"}, {"room": 205, "currentSet": 210, "custom": 210, "comfort": 210, "economy": 180, "away": 150, "max": 350, "min": 50, "heating": 0, "openWindow": 0, "id": "03004B5A"}]} +2023/11/07 - 11:28:42 TX http GET Appliance.Hub.Mts100.ScheduleB {"schedule": []} +2023/11/07 - 11:28:42 RX http GETACK Appliance.Hub.Mts100.ScheduleB {"schedule": [{"id": "03002B0E", "mon": [[390, 150], [90, 180], [300, 180], [300, 210], [210, 210], [150, 150]], "tue": [[390, 150], [90, 180], [300, 180], [300, 210], [210, 210], [150, 150]], "wed": [[390, 150], [90, 180], [300, 180], [300, 210], [210, 210], [150, 150]], "thu": [[390, 150], [90, 180], [300, 180], [300, 210], [210, 210], [150, 150]], "fri": [[390, 150], [90, 180], [300, 180], [300, 210], [210, 210], [150, 150]], "sat": [[390, 155], [165, 170], [225, 210], [300, 210], [195, 210], [165, 150]], "sun": [[390, 155], [165, 170], [225, 210], [300, 210], [195, 210], [165, 150]]}, {"id": "03004B5A", "mon": [[360, 150], [180, 150], [510, 180], [135, 210], [135, 215], [120, 150]], "tue": [[360, 150], [180, 150], [510, 180], [135, 210], [135, 215], [120, 150]], "wed": [[360, 150], [180, 150], [510, 180], [135, 210], [135, 215], [120, 150]], "thu": [[360, 150], [180, 150], [510, 180], [135, 210], [135, 215], [120, 150]], "fri": [[360, 150], [180, 150], [510, 180], [135, 210], [135, 215], [120, 150]], "sat": [[390, 150], [180, 150], [180, 200], [330, 210], [255, 220], [105, 150]], "sun": [[390, 150], [180, 150], [180, 200], [330, 200], [255, 220], [105, 150]]}]} +2023/11/07 - 11:28:44 TX http GET Appliance.Hub.Mts100.Mode {"mode": []} +2023/11/07 - 11:28:44 RX http GETACK Appliance.Hub.Mts100.Mode {"mode": [{"id": "03002B0E", "state": 3}, {"id": "03004B5A", "state": 1}]} +2023/11/07 - 11:28:46 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:28:46 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:28:48 TX http GET Appliance.Hub.Mts100.SuperCtl {"superCtl": []} +2023/11/07 - 11:28:48 RX http GETACK Appliance.Hub.Mts100.SuperCtl {"superCtl": [{"id": "03002B0E", "enable": 1, "level": 1, "alert": 1}, {"id": "03004B5A", "enable": 1, "level": 1, "alert": 1}]} +2023/11/07 - 11:28:48 auto LOG DEBUG handler undefined for method:(GETACK) namespace:(Appliance.Hub.Mts100.SuperCtl) payload:({'superCtl': [{'id': '03002B0E', 'enable': 1, 'level': 1, 'alert': 1}, {'id': '03004B5A', 'enable': 1, 'level': 1, 'alert': 1}]}) +2023/11/07 - 11:28:50 TX http GET Appliance.Hub.SubDevice.MotorAdjust {"adjust": []} +2023/11/07 - 11:28:50 RX http ERROR Appliance.Hub.SubDevice.MotorAdjust {"error": {"code": 5000}} +2023/11/07 - 11:28:50 auto LOG WARNING protocol error: namespace = 'Appliance.Hub.SubDevice.MotorAdjust' payload = '{"error": {"code": 5000}}' +2023/11/07 - 11:28:52 auto LOG DEBUG polling start +2023/11/07 - 11:28:52 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:28:52 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:28:52 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:28:52 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:28:52 auto LOG DEBUG polling end +2023/11/07 - 11:28:52 TX http GET Appliance.Hub.SubDevice.Beep {"beep": []} +2023/11/07 - 11:28:52 RX http ERROR Appliance.Hub.SubDevice.Beep {"error": {"code": 5000}} +2023/11/07 - 11:28:52 auto LOG WARNING protocol error: namespace = 'Appliance.Hub.SubDevice.Beep' payload = '{"error": {"code": 5000}}' +2023/11/07 - 11:28:54 TX http GET Appliance.Hub.Sensor.All {"all": []} +2023/11/07 - 11:28:54 RX http GETACK Appliance.Hub.Sensor.All {"all": []} +2023/11/07 - 11:28:56 TX http GET Appliance.Hub.Sensor.WaterLeak {"waterLeak": []} +2023/11/07 - 11:28:56 RX http GETACK Appliance.Hub.Sensor.WaterLeak {"waterLeak": []} +2023/11/07 - 11:28:56 auto LOG DEBUG handler undefined for method:(GETACK) namespace:(Appliance.Hub.Sensor.WaterLeak) payload:({'waterLeak': []}) +2023/11/07 - 11:28:58 TX http GET Appliance.Hub.Sensor.TempHum {"tempHum": []} +2023/11/07 - 11:28:58 RX http GETACK Appliance.Hub.Sensor.TempHum {"tempHum": []} +2023/11/07 - 11:29:00 TX http GET Appliance.Hub.Sensor.Adjust {"adjust": []} +2023/11/07 - 11:29:00 RX http GETACK Appliance.Hub.Sensor.Adjust {"adjust": []} +2023/11/07 - 11:29:02 TX http GET Appliance.Hub.Sensor.Motion {"motion": []} +2023/11/07 - 11:29:02 RX http GETACK Appliance.Hub.Sensor.Motion {"motion": []} +2023/11/07 - 11:29:02 auto LOG DEBUG handler undefined for method:(GETACK) namespace:(Appliance.Hub.Sensor.Motion) payload:({'motion': []}) +2023/11/07 - 11:29:04 TX http GET Appliance.Hub.Sensor.DoorWindow {"doorWindow": []} +2023/11/07 - 11:29:04 RX http GETACK Appliance.Hub.Sensor.DoorWindow {"doorWindow": []} +2023/11/07 - 11:29:06 TX http GET Appliance.Hub.Sensor.Smoke {"smokeAlarm": []} +2023/11/07 - 11:29:06 RX http GETACK Appliance.Hub.Sensor.Smoke {"smokeAlarm": []} +2023/11/07 - 11:29:08 TX http GET Appliance.Hub.Sensor.Alert {"alert": []} +2023/11/07 - 11:29:08 RX http GETACK Appliance.Hub.Sensor.Alert {"alert": []} +2023/11/07 - 11:29:08 auto LOG DEBUG handler undefined for method:(GETACK) namespace:(Appliance.Hub.Sensor.Alert) payload:({'alert': []}) +2023/11/07 - 11:29:22 auto LOG DEBUG polling start +2023/11/07 - 11:29:22 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:29:22 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:29:22 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:29:22 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:29:22 auto LOG DEBUG polling end +2023/11/07 - 11:29:52 auto LOG DEBUG polling start +2023/11/07 - 11:29:52 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:29:53 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:29:53 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:29:53 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:29:53 auto LOG DEBUG polling end +2023/11/07 - 11:30:23 auto LOG DEBUG polling start +2023/11/07 - 11:30:23 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:30:23 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:30:23 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:30:23 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:30:23 auto LOG DEBUG polling end +2023/11/07 - 11:30:53 auto LOG DEBUG polling start +2023/11/07 - 11:30:53 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:30:53 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:30:53 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:30:53 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:30:53 auto LOG DEBUG polling end +2023/11/07 - 11:31:23 auto LOG DEBUG polling start +2023/11/07 - 11:31:23 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:31:23 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:31:23 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:31:23 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:31:23 auto LOG DEBUG polling end +2023/11/07 - 11:31:53 auto LOG DEBUG polling start +2023/11/07 - 11:31:53 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:31:53 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:31:53 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:31:53 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:31:53 auto LOG DEBUG polling end +2023/11/07 - 11:32:23 auto LOG DEBUG polling start +2023/11/07 - 11:32:23 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:32:23 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:32:23 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:32:23 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:32:23 auto LOG DEBUG polling end +2023/11/07 - 11:32:53 auto LOG DEBUG polling start +2023/11/07 - 11:32:53 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:32:53 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:32:53 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:32:53 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:32:53 auto LOG DEBUG polling end +2023/11/07 - 11:33:23 auto LOG DEBUG polling start +2023/11/07 - 11:33:23 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:33:23 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:33:23 TX http GET Appliance.System.Runtime {"runtime": {}} +2023/11/07 - 11:33:23 RX http GETACK Appliance.System.Runtime {"runtime": {"signal": 100}} +2023/11/07 - 11:33:23 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:33:23 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:33:23 auto LOG DEBUG polling end +2023/11/07 - 11:33:53 auto LOG DEBUG polling start +2023/11/07 - 11:33:53 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:33:53 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:33:53 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:33:53 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:33:53 auto LOG DEBUG polling end +2023/11/07 - 11:34:23 auto LOG DEBUG polling start +2023/11/07 - 11:34:23 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:34:23 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:34:23 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:34:23 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:34:23 auto LOG DEBUG polling end +2023/11/07 - 11:34:53 auto LOG DEBUG polling start +2023/11/07 - 11:34:53 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:34:53 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:34:53 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:34:53 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:34:53 auto LOG DEBUG polling end +2023/11/07 - 11:35:23 auto LOG DEBUG polling start +2023/11/07 - 11:35:23 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:35:24 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:35:24 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:35:24 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:35:24 auto LOG DEBUG polling end +2023/11/07 - 11:35:54 auto LOG DEBUG polling start +2023/11/07 - 11:35:54 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:35:54 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:35:54 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:35:54 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:35:54 auto LOG DEBUG polling end +2023/11/07 - 11:36:24 auto LOG DEBUG polling start +2023/11/07 - 11:36:24 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:36:24 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:36:24 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:36:24 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:36:24 auto LOG DEBUG polling end +2023/11/07 - 11:36:54 auto LOG DEBUG polling start +2023/11/07 - 11:36:54 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:36:54 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:36:54 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:36:54 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:36:54 auto LOG DEBUG polling end +2023/11/07 - 11:37:24 auto LOG DEBUG polling start +2023/11/07 - 11:37:24 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:37:24 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:37:24 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:37:24 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:37:24 auto LOG DEBUG polling end +2023/11/07 - 11:37:54 auto LOG DEBUG polling start +2023/11/07 - 11:37:54 TX http GET Appliance.System.DNDMode {"DNDMode": {}} +2023/11/07 - 11:37:54 RX http GETACK Appliance.System.DNDMode {"DNDMode": {"mode": 0}} +2023/11/07 - 11:37:54 TX http GET Appliance.Hub.Mts100.Adjust {"adjust": []} +2023/11/07 - 11:37:54 RX http GETACK Appliance.Hub.Mts100.Adjust {"adjust": [{"id": "03002B0E", "temperature": -150}, {"id": "03004B5A", "temperature": -350}]} +2023/11/07 - 11:37:54 auto LOG DEBUG polling end +2023/11/07 - 11:38:24 auto LOG DEBUG polling start +2023/11/07 - 11:38:24 TX http GET Appliance.System.DNDMode {"DNDMode": {}} diff --git a/emulator_traces/U01234567890123456789012345678913-Kpippo-msg200-faked.csv b/emulator_traces/U01234567890123456789012345678913-Kpippo-msg200-faked.csv new file mode 100644 index 00000000..321a07e7 --- /dev/null +++ b/emulator_traces/U01234567890123456789012345678913-Kpippo-msg200-faked.csv @@ -0,0 +1,10 @@ +2021/11/23 - 07:46:59 auto GETACK Appliance.System.All {"system": {"hardware": {"type": "msg200", "subType": "us", "version": "4.0.0", "chipType": "MT7686", "uuid": "", "macAddress": ""}, "firmware": {"version": "4.2.1", "homekitVersion": "2.0.1", "compileTime": "Sep 15 2021 10:21:01", "encrypt": 1, "wifiMac": "", "innerIp": "", "server": "", "port": "", "userId": ""}, "time": {"timestamp": 1637650015, "timezone": "Europe/Rome", "timeRule": [[1616893200, 7200, 1], [1635642000, 3600, 0], [1648342800, 7200, 1], [1667091600, 3600, 0], [1679792400, 7200, 1], [1698541200, 3600, 0], [1711846800, 7200, 1], [1729990800, 3600, 0], [1743296400, 7200, 1], [1761440400, 3600, 0], [1774746000, 7200, 1], [1792890000, 3600, 0], [1806195600, 7200, 1], [1824944400, 3600, 0], [1837645200, 7200, 1], [1856394000, 3600, 0], [1869094800, 7200, 1], [1887843600, 3600, 0], [1901149200, 7200, 1], [1919293200, 3600, 0]]}, "online": {"status": 1, "bindId": "ZtGfG5c5G8B4PGYX", "who": 1}}, "digest": {"togglex": [{"channel": 1, "onoff": 0, "lmTime": 0}], "triggerx": [], "timerx": [], "garageDoor": [{"channel": 1, "open": 1, "lmTime": 0},{"channel": 2, "open": 1, "lmTime": 0},{"channel": 3, "open": 0, "lmTime": 0}]}} +2021/11/23 - 07:46:59 auto GETACK Appliance.System.Ability {"Appliance.Config.Key": {}, "Appliance.Config.WifiList": {}, "Appliance.Config.Wifi": {}, "Appliance.Config.WifiX": {}, "Appliance.Config.Trace": {}, "Appliance.Config.Info": {}, "Appliance.System.All": {}, "Appliance.System.Hardware": {}, "Appliance.System.Firmware": {}, "Appliance.System.Debug": {}, "Appliance.System.Online": {}, "Appliance.System.Time": {}, "Appliance.System.Clock": {}, "Appliance.System.Ability": {}, "Appliance.System.Runtime": {}, "Appliance.System.Report": {}, "Appliance.System.Position": {}, "Appliance.System.DNDMode": {}, "Appliance.Control.Multiple": {"maxCmdNum": 5}, "Appliance.Control.ToggleX": {}, "Appliance.Control.TimerX": {"sunOffsetSupport": 1, "notify": ["Appliance.GarageDoor.State"]}, "Appliance.Control.TriggerX": {"notify": ["Appliance.GarageDoor.State"]}, "Appliance.Control.Bind": {}, "Appliance.Control.Unbind": {}, "Appliance.Control.Upgrade": {}, "Appliance.GarageDoor.State": {}, "Appliance.GarageDoor.Config": {}, "Appliance.GarageDoor.MultipleConfig": {}, "Appliance.Digest.TriggerX": {}, "Appliance.Digest.TimerX": {}} +2021/11/23 - 07:47:09 http GET Appliance.System.Runtime {"runtime": {}} +2021/11/23 - 07:47:09 http GETACK Appliance.System.Runtime {"runtime": {"signal": 100}} +2021/11/23 - 07:47:34 http GET Appliance.GarageDoor.State {"state": {}} +2021/11/23 - 07:47:37 http GETACK Appliance.GarageDoor.State {"state": {"channel": 1, "open": 1, "lmTime": 0}} +2021/11/23 - 07:47:39 http GET Appliance.GarageDoor.Config {"config": {}} +2021/11/23 - 07:47:45 http GETACK Appliance.GarageDoor.Config {"config": {"signalDuration": 1000, "buzzerEnable": 0, "doorOpenDuration": 30000, "doorCloseDuration": 30000}} +2021/11/23 - 07:47:39 http GET Appliance.GarageDoor.MultipleConfig {"config": []} +2021/11/23 - 07:47:45 http GETACK Appliance.GarageDoor.MultipleConfig {"config":[{"channel":1,"doorEnable":1,"timestamp":0,"timestampMs":0,"signalClose":10000,"signalOpen":10000,"buzzerEnable":1},{"channel":2,"doorEnable":0,"timestamp":1699130744,"timestampMs":87,"signalClose":10000,"signalOpen":10000,"buzzerEnable":1},{"channel":3,"doorEnable":0,"timestamp":1699130748,"timestampMs":663,"signalClose":10000,"signalOpen":10000,"buzzerEnable":1}]} \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 8807b9ab..cbf9474b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -4,4 +4,5 @@ aiohttp-cors scapy janus fnv-hash-fast -pytest-homeassistant-custom-component==0.13.45 +psutil-home-assistant +pytest-homeassistant-custom-component==0.13.76 diff --git a/tests/conftest.py b/tests/conftest.py index f5075889..0a06d017 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,15 +50,15 @@ def skip_notifications_fixture(): @pytest.fixture(name="disable_debug", autouse=True) def disable_debug_fixture(): - """Skip notification calls.""" + """Disable development debug code so to test in a production env.""" with patch("custom_components.meross_lan.MEROSSDEBUG", None), patch( "custom_components.meross_lan.meross_profile.MEROSSDEBUG", None - ), patch("custom_components.meross_lan.merossclient.MEROSSDEBUG", None), patch( - "custom_components.meross_lan.merossclient.httpclient.MEROSSDEBUG", - None, + ), patch("custom_components.meross_lan.meross_device.MEROSSDEBUG", None), patch( + "custom_components.meross_lan.merossclient.MEROSSDEBUG", None + ), patch( + "custom_components.meross_lan.merossclient.httpclient.MEROSSDEBUG", None ), patch( - "custom_components.meross_lan.merossclient.cloudapi.MEROSSDEBUG", - None, + "custom_components.meross_lan.merossclient.cloudapi.MEROSSDEBUG", None ): yield diff --git a/tests/helpers.py b/tests/helpers.py index 99a01090..3a238877 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,6 +7,7 @@ import hashlib import json import re +import time from typing import Any, Callable, Coroutine from unittest.mock import MagicMock, Mock, patch @@ -412,7 +413,7 @@ async def async_enable_entity(self, entity_id): await self.perform_coldstart() async def async_tick(self, tick: timedelta): - print(f"async_tick: time={self.time} tick={tick}") + # print(f"async_tick: time={self.time} tick={tick}") self.time.tick(tick) async_fire_time_changed_exact(self.hass) await self.hass.async_block_till_done() @@ -452,16 +453,21 @@ def warp(self, tick: float | int | timedelta = 0.5): tick = timedelta(seconds=tick) def _warp(): + print("DeviceContext.warp: entering executor") count = 0 while self._warp_run: + _time = self.time() run_coroutine_threadsafe(self.async_tick(tick), self.hass.loop) - print(f"Executor: _warp count={count}") + while _time == self.time(): + time.sleep(0.01) count += 1 + print(f"DeviceContext.warp: exiting executor (_warp count={count})") self._warp_run = True self._warp_task = self.hass.async_add_executor_job(_warp) async def async_stopwarp(self): + print("DeviceContext.warp: stopping executor") assert self._warp_task self._warp_run = False await self._warp_task diff --git a/tests/test_config_entry.py b/tests/test_config_entry.py index 47db6f02..e2a5cea5 100644 --- a/tests/test_config_entry.py +++ b/tests/test_config_entry.py @@ -26,7 +26,7 @@ async def test_mqtthub_entry(hass: HomeAssistant, hamqtt_mock: helpers.HAMQTTMoc # Unload the entry and verify that the data has not been removed # we actually never remove the MerossApi... - assert type(hass.data[mlc.DOMAIN]) == MerossApi + assert type(hass.data[mlc.DOMAIN]) is MerossApi async def test_mqtthub_entry_notready(hass: HomeAssistant): diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3cc2e946..c8f886ab 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -10,7 +10,7 @@ from custom_components.meross_lan import const as mlc from custom_components.meross_lan.merossclient import ( - build_payload, + build_message, cloudapi, const as mc, ) @@ -146,7 +146,7 @@ async def test_mqtt_discovery_config_flow(hass: HomeAssistant, hamqtt_mock): emulator.key = "" # patch the key so the default hub key will work device_id = emulator.descriptor.uuid topic = mc.TOPIC_RESPONSE.format(device_id) - payload = build_payload( + payload = build_message( mc.NS_APPLIANCE_CONTROL_TOGGLEX, mc.METHOD_PUSH, {mc.KEY_TOGGLEX: {mc.KEY_CHANNEL: 0, mc.KEY_ONOFF: 0}}, diff --git a/tests/test_consumption.py b/tests/test_consumption.py index d304c95e..56937e56 100644 --- a/tests/test_consumption.py +++ b/tests/test_consumption.py @@ -1,5 +1,5 @@ """ - Test the ConsumptionMixin works, especially on reset bugs (#264,#268) + Test the ConsumptionxMixin works, especially on reset bugs (#264,#268) """ import datetime as dt import typing @@ -15,9 +15,9 @@ from custom_components.meross_lan.const import PARAM_ENERGY_UPDATE_PERIOD from custom_components.meross_lan.merossclient import const as mc -from custom_components.meross_lan.sensor import ConsumptionMixin, ElectricityMixin +from custom_components.meross_lan.devices.mss import ConsumptionXMixin, ElectricityMixin from emulator.mixins.electricity import ( - ConsumptionMixin as EmulatorConsumptionMixin, + ConsumptionXMixin as EmulatorConsumptionMixin, ElectricityMixin as EmulatorElectricityMixin, ) @@ -41,7 +41,6 @@ def _configure_dates(tz): - today = dt.datetime.now(tz) today = dt.datetime( today.year, @@ -62,7 +61,6 @@ def _configure_dates(tz): async def _async_configure_context(context: "DeviceContext", timezone: str): - emulator = context.emulator assert isinstance(emulator, EmulatorConsumptionMixin) assert isinstance(emulator, EmulatorElectricityMixin) @@ -72,7 +70,7 @@ async def _async_configure_context(context: "DeviceContext", timezone: str): await context.async_load_config_entry() device = context.device - assert isinstance(device, ConsumptionMixin) + assert isinstance(device, ConsumptionXMixin) assert isinstance(device, ElectricityMixin) assert ( device.polling_period < 60 @@ -110,8 +108,9 @@ async def test_consumption(hass: HomeAssistant, aioclient_mock): """ today, tomorrow, todayseconds = _configure_dates(dt_util.DEFAULT_TIME_ZONE) - async with helpers.DeviceContext(hass, mc.TYPE_MSS310, aioclient_mock, today) as context: - + async with helpers.DeviceContext( + hass, mc.TYPE_MSS310, aioclient_mock, today + ) as context: device, sensor_consumption, sensor_estimate = await _async_configure_context( context, dt_util.DEFAULT_TIME_ZONE.key # type: ignore ) @@ -195,8 +194,9 @@ async def test_consumption_with_timezone(hass: HomeAssistant, aioclient_mock): """ today, tomorrow, todayseconds = _configure_dates(ZoneInfo(DEVICE_TIMEZONE)) - async with helpers.DeviceContext(hass, mc.TYPE_MSS310, aioclient_mock, today) as context: - + async with helpers.DeviceContext( + hass, mc.TYPE_MSS310, aioclient_mock, today + ) as context: device, sensor_consumption, sensor_estimate = await _async_configure_context( context, DEVICE_TIMEZONE ) @@ -274,8 +274,9 @@ async def test_consumption_with_reload(hass: HomeAssistant, aioclient_mock): today, tomorrow, todayseconds = _configure_dates(dt_util.DEFAULT_TIME_ZONE) - async with helpers.DeviceContext(hass, mc.TYPE_MSS310, aioclient_mock, today) as context: - + async with helpers.DeviceContext( + hass, mc.TYPE_MSS310, aioclient_mock, today + ) as context: device, sensor_consumption, sensor_estimate = await _async_configure_context( context, dt_util.DEFAULT_TIME_ZONE.key # type: ignore ) @@ -310,7 +311,6 @@ def _check_energy_states(power, duration, msg): ), f"consumption in {msg}" async def _async_unload_reload(msg: str, offset: int): - estimatestate = hass.states.get(sensor_estimate_entity_id) assert estimatestate saved_estimated_energy_value = estimatestate.state