diff --git a/custom_components/meross_lan/__init__.py b/custom_components/meross_lan/__init__.py index f2ffa7a8..ed8a2c7d 100644 --- a/custom_components/meross_lan/__init__.py +++ b/custom_components/meross_lan/__init__.py @@ -1,5 +1,6 @@ """The Meross IoT local LAN integration.""" from __future__ import annotations +import asyncio import typing from time import time from logging import WARNING, INFO, DEBUG @@ -172,7 +173,7 @@ def get_device_with_mac(self, macaddress:str): return device return None - def build_device(self, device_id: str, entry: ConfigEntry): + def build_device(self, device_id: str, entry: ConfigEntry) -> MerossDevice: """ scans device descriptor to build a 'slightly' specialized MerossDevice The base MerossDevice class is a bulk 'do it all' implementation @@ -551,9 +552,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if cloud_key is not None: api.cloud_key = cloud_key # last loaded overwrites existing: shouldnt it be the same ?! device = api.build_device(device_id, entry) - # this api is too recent (around April 2021): hass.config_entries.async_setup_platforms(entry, device.platforms.keys()) - for platform in device.platforms.keys(): - hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, platform)) + await asyncio.gather( + *(hass.config_entries.async_forward_entry_setup(entry, platform) for platform in device.platforms.keys()) + ) + device.start() + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -567,7 +570,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await hass.config_entries.async_unload_platforms(entry, device.platforms.keys()): return False api.devices.pop(device_id) - device.shutdown() + await device.async_shutdown() #don't cleanup: the MerossApi is still needed to detect MQTT discoveries #if (not api.devices) and (len(hass.config_entries.async_entries(DOMAIN)) == 1): # api.shutdown() diff --git a/custom_components/meross_lan/cover.py b/custom_components/meross_lan/cover.py index 64eb18c1..c1358b81 100644 --- a/custom_components/meross_lan/cover.py +++ b/custom_components/meross_lan/cover.py @@ -195,6 +195,7 @@ def __init__(self, cover: MLGarage, key: str): ) / 2) async def async_added_to_hass(self): + await super().async_added_to_hass() try: if last_state := await get_entity_last_state(self.hass, self.entity_id): self._attr_state = float(last_state.state) # type: ignore @@ -260,6 +261,7 @@ def is_closed(self): return self._attr_state == STATE_CLOSED async def async_added_to_hass(self): + await super().async_added_to_hass() """ we're trying to recover the '_transition_duration' from previous state """ @@ -336,7 +338,8 @@ def _ack_callback(acknowledge: bool, header: dict, payload: dict): async def async_will_remove_from_hass(self): self._cancel_transition() - + await super().async_will_remove_from_hass() + def set_unavailable(self): self._open = None self._cancel_transition() @@ -604,6 +607,7 @@ def is_closed(self): return self._attr_state == STATE_CLOSED async def async_added_to_hass(self): + await super().async_added_to_hass() """ we're trying to recover the 'timed' position from previous state if it happens it wasn't updated too far in time diff --git a/custom_components/meross_lan/emulator/__init__.py b/custom_components/meross_lan/emulator/__init__.py index e8eee71a..1d0903dd 100644 --- a/custom_components/meross_lan/emulator/__init__.py +++ b/custom_components/meross_lan/emulator/__init__.py @@ -10,6 +10,7 @@ is just a 'reply' service of what's inside a trace """ from __future__ import annotations + import os from aiohttp import web @@ -31,14 +32,24 @@ def build_emulator(tracefile, uuid, key) -> MerossEmulator: if mc.KEY_THERMOSTAT in descriptor.digest: from .mixins.thermostat import ThermostatMixin + mixin_classes.append(ThermostatMixin) if mc.KEY_GARAGEDOOR in descriptor.digest: from .mixins.garagedoor import GarageDoorMixin + mixin_classes.append(GarageDoorMixin) + if mc.NS_APPLIANCE_CONTROL_ELECTRICITY in descriptor.ability: + from .mixins.electricity import ElectricityMixin + + mixin_classes.append(ElectricityMixin) + if mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX in descriptor.ability: + from .mixins.electricity import ConsumptionMixin + + mixin_classes.append(ConsumptionMixin) mixin_classes.append(MerossEmulator) # build a label to cache the set - class_name = '' + class_name = "" for m in mixin_classes: class_name = class_name + m.__name__ class_type = type(class_name, tuple(mixin_classes), {}) @@ -59,9 +70,9 @@ def generate_emulators(tracespath: str, defaultuuid: str, defaultkey: str): uuidsub = 0 for f in os.listdir(tracespath): fullpath = os.path.join(tracespath, f) - #expect only valid csv or json files - f = f.split('.') - if f[-1] not in ('csv','txt','json'): + # expect only valid csv or json files + f = f.split(".") + if f[-1] not in ("csv", "txt", "json"): continue # filename could be formatted to carry device definitions parameters: @@ -69,15 +80,15 @@ def generate_emulators(tracespath: str, defaultuuid: str, defaultkey: str): # this way, parameters will be 'binded' to that trace in an easy way key = defaultkey uuid = None - for _f in f[0].split('-'): - if _f.startswith('K'): + for _f in f[0].split("-"): + if _f.startswith("K"): key = _f[1:].strip() - elif _f.startswith('U'): + elif _f.startswith("U"): uuid = _f[1:].strip() if uuid is None: uuidsub = uuidsub + 1 _uuidsub = str(uuidsub) - uuid = defaultuuid[:-len(_uuidsub)] + _uuidsub + uuid = defaultuuid[: -len(_uuidsub)] + _uuidsub yield build_emulator(fullpath, uuid, key) @@ -87,14 +98,14 @@ def run(argv): command line invocation: 'python -m aiohttp.web -H localhost -P 80 meross_lan.emulator:run tracefilepath' """ - key = '' - uuid = '01234567890123456789001122334455' - tracefilepath = '.' + key = "" + uuid = "01234567890123456789001122334455" + tracefilepath = "." for arg in argv: arg: str - if arg.startswith('-K'): + if arg.startswith("-K"): key = arg[2:].strip() - elif arg.startswith('-U'): + elif arg.startswith("-U"): uuid = arg[2:].strip() else: tracefilepath = arg @@ -104,13 +115,16 @@ def run(argv): def make_post_handler(emulator: MerossEmulator): async def _callback(request: web.Request) -> web.Response: return web.json_response(emulator.handle(await request.text())) + return _callback if os.path.isdir(tracefilepath): for emulator in generate_emulators(tracefilepath, uuid, key): - app.router.add_post(f"/{emulator.descriptor.uuid}/config", make_post_handler(emulator)) + app.router.add_post( + f"/{emulator.descriptor.uuid}/config", make_post_handler(emulator) + ) else: emulator = build_emulator(tracefilepath, uuid, key) app.router.add_post("/config", make_post_handler(emulator)) - return app \ No newline at end of file + return app diff --git a/custom_components/meross_lan/emulator/descriptor.py b/custom_components/meross_lan/emulator/descriptor.py index b37bf297..cf999fc0 100644 --- a/custom_components/meross_lan/emulator/descriptor.py +++ b/custom_components/meross_lan/emulator/descriptor.py @@ -5,9 +5,8 @@ in case we need some special behavor """ from __future__ import annotations -from json import ( - loads as json_loads, -) + +from json import loads as json_loads from custom_components.meross_lan.merossclient import ( MerossDeviceDescriptor, @@ -18,14 +17,12 @@ class MerossEmulatorDescriptor(MerossDeviceDescriptor): - namespaces: dict - - def __init__(self, tracefile:str, uuid): + def __init__(self, tracefile: str, uuid): self.namespaces = {} - with open(tracefile, 'r', encoding='utf8') as f: - if tracefile.endswith('.json.txt'): + with open(tracefile, "r", encoding="utf8") as f: + if tracefile.endswith(".json.txt"): # HA diagnostics trace self._import_json(f) else: @@ -38,25 +35,23 @@ def __init__(self, tracefile:str, uuid): hardware[mc.KEY_UUID] = uuid hardware[mc.KEY_MACADDRESS] = uuid[-12:] - def _import_tsv(self, f): """ parse a legacy tab separated values meross_lan trace """ for line in f: - row = line.split('\t') + row = line.split("\t") self._import_tracerow(row) - def _import_json(self, f): """ parse a 'diagnostics' HA trace """ try: _json = json_loads(f.read()) - data = _json['data'] + data = _json["data"] columns = None - for row in data['trace']: + for row in data["trace"]: if columns is None: columns = row # we could parse and setup a 'column search' @@ -70,17 +65,20 @@ def _import_json(self, f): return - def _import_tracerow(self, values: list): - #rxtx = values[1] + # rxtx = values[1] protocol = values[-4] method = values[-3] namespace = values[-2] data = values[-1] if method == mc.METHOD_GETACK: - if protocol == 'auto': + if protocol == "auto": self.namespaces[namespace] = { - get_namespacekey(namespace): data if isinstance(data, dict) else json_loads(data) + get_namespacekey(namespace): data + if isinstance(data, dict) + else json_loads(data) } else: - self.namespaces[namespace] = data if isinstance(data, dict) else json_loads(data) + self.namespaces[namespace] = ( + data if isinstance(data, dict) else json_loads(data) + ) diff --git a/custom_components/meross_lan/emulator/emulator.py b/custom_components/meross_lan/emulator/emulator.py index 3eb031a3..922a0bc0 100644 --- a/custom_components/meross_lan/emulator/emulator.py +++ b/custom_components/meross_lan/emulator/emulator.py @@ -12,11 +12,10 @@ """ from __future__ import annotations + +from json import dumps as json_dumps, loads as json_loads from time import time -from json import ( - dumps as json_dumps, - loads as json_loads, -) +from zoneinfo import ZoneInfo from custom_components.meross_lan.merossclient import ( build_payload, @@ -30,38 +29,57 @@ class MerossEmulator: + _tzinfo: ZoneInfo | None = None def __init__(self, descriptor: MerossEmulatorDescriptor, key): self.key = key self.descriptor = descriptor self.p_all_system_time = descriptor.system.get(mc.KEY_TIME) if mc.NS_APPLIANCE_SYSTEM_DNDMODE in descriptor.ability: - self.p_dndmode = { mc.KEY_DNDMODE: { mc.KEY_MODE: 0 }} - + self.p_dndmode = {mc.KEY_DNDMODE: {mc.KEY_MODE: 0}} + self.update_epoch() print(f"Initialized {descriptor.productname} (model:{descriptor.productmodel})") - #async def post_config(self, request: web_Request): + @property + def tzinfo(self): + tz_name = self.descriptor.timezone + if not tz_name: + return None + if (self._tzinfo is not None) and (self._tzinfo.key == tz_name): + return self._tzinfo + try: + self._tzinfo = ZoneInfo(tz_name) + except Exception: + self._tzinfo = None + return self._tzinfo + + # async def post_config(self, request: web_Request): def handle(self, request: str) -> dict: jsonrequest = json_loads(request) - header:dict = jsonrequest[mc.KEY_HEADER] - payload:dict = jsonrequest[mc.KEY_PAYLOAD] - namespace:str = header[mc.KEY_NAMESPACE] - method:str = header[mc.KEY_METHOD] - - print(f"Emulator({self.descriptor.uuid}) " - f"RX: namespace={namespace} method={method} payload={json_dumps(payload)}") + header: dict = jsonrequest[mc.KEY_HEADER] + payload: dict = jsonrequest[mc.KEY_PAYLOAD] + namespace: str = header[mc.KEY_NAMESPACE] + method: str = header[mc.KEY_METHOD] + + print( + f"Emulator({self.descriptor.uuid}) " + f"RX: namespace={namespace} method={method} payload={json_dumps(payload)}" + ) try: - if self.p_all_system_time is not None: - self.p_all_system_time[mc.KEY_TIMESTAMP] = int(time()) + self.update_epoch() if namespace not in self.descriptor.ability: raise Exception(f"{namespace} not supported in ability") elif get_replykey(header, self.key) is not self.key: method = mc.METHOD_ERROR - payload = { mc.KEY_ERROR: { mc.KEY_CODE: mc.ERROR_INVALIDKEY} } + payload = {mc.KEY_ERROR: {mc.KEY_CODE: mc.ERROR_INVALIDKEY}} - elif (handler := getattr(self, f"_{method}_{namespace.replace('.', '_')}", None)) is not None: + elif ( + handler := getattr( + self, f"_{method}_{namespace.replace('.', '_')}", None + ) + ) is not None: method, payload = handler(header, payload) else: @@ -69,12 +87,26 @@ def handle(self, request: str) -> dict: except Exception as e: method = mc.METHOD_ERROR - payload = { mc.KEY_ERROR: { mc.KEY_CODE: -1, "message": str(e)} } - - data = build_payload(namespace, method, payload, self.key, mc.MANUFACTURER, header[mc.KEY_MESSAGEID]) - print(f"Emulator({self.descriptor.uuid}) TX: namespace={namespace} method={method} payload={json_dumps(payload)}") + payload = {mc.KEY_ERROR: {mc.KEY_CODE: -1, "message": str(e)}} + + data = build_payload( + namespace, + method, + payload, + self.key, + mc.MANUFACTURER, + header[mc.KEY_MESSAGEID], + ) + print( + f"Emulator({self.descriptor.uuid}) TX: namespace={namespace} method={method} payload={json_dumps(payload)}" + ) return data + def update_epoch(self): + self.epoch = int(time()) + if self.p_all_system_time is not None: + self.p_all_system_time[mc.KEY_TIMESTAMP] = self.epoch + def _get_key_state(self, namespace: str) -> tuple[str, dict]: """ general device state is usually carried in NS_ALL into the "digest" key @@ -83,8 +115,8 @@ def _get_key_state(self, namespace: str) -> tuple[str, dict]: For some devices not all state is carried there tho, so we'll inspect the GETACK payload for the relevant namespace looking for state there too """ - n = namespace.split('.') - if n[1] != 'Control': + n = namespace.split(".") + if n[1] != "Control": raise Exception(f"{namespace} not supported in emulator") key = get_namespacekey(namespace) @@ -119,9 +151,9 @@ def _handler_default(self, method: str, namespace: str, payload: dict): if (method == mc.METHOD_GET) and (namespace in self.descriptor.namespaces): return mc.METHOD_GETACK, self.descriptor.namespaces[namespace] raise error - + if method == mc.METHOD_GET: - return mc.METHOD_GETACK, { key: p_state } + return mc.METHOD_GETACK, {key: p_state} if method != mc.METHOD_SET: # TODO..... @@ -147,7 +179,9 @@ def _update(payload: dict): if p_state[mc.KEY_CHANNEL] == p_payload[mc.KEY_CHANNEL]: p_state.update(p_payload) else: - raise Exception(f"{p_payload[mc.KEY_CHANNEL]} not present in digest.{key}") + raise Exception( + f"{p_payload[mc.KEY_CHANNEL]} not present in digest.{key}" + ) return mc.METHOD_SETACK, {} @@ -169,12 +203,14 @@ def _get_control_key(self, key): def _GET_Appliance_Control_Toggle(self, header, payload): # only acual example of this usage comes from legacy firmwares # carrying state in all->control - return mc.METHOD_GETACK, { mc.KEY_TOGGLE: self._get_control_key(mc.KEY_TOGGLE) } + return mc.METHOD_GETACK, {mc.KEY_TOGGLE: self._get_control_key(mc.KEY_TOGGLE)} def _SET_Appliance_Control_Toggle(self, header, payload): # only acual example of this usage comes from legacy firmwares # carrying state in all->control - self._get_control_key(mc.KEY_TOGGLE)[mc.KEY_ONOFF] = payload[mc.KEY_TOGGLE][mc.KEY_ONOFF] + self._get_control_key(mc.KEY_TOGGLE)[mc.KEY_ONOFF] = payload[mc.KEY_TOGGLE][ + mc.KEY_ONOFF + ] return mc.METHOD_SETACK, {} def _SET_Appliance_Control_Light(self, header, payload): @@ -199,7 +235,9 @@ def _SET_Appliance_Control_Light(self, header, payload): def _SET_Appliance_Control_Mp3(self, header, payload): if mc.NS_APPLIANCE_CONTROL_MP3 not in self.descriptor.namespaces: - raise Exception(f"{mc.NS_APPLIANCE_CONTROL_MP3} not supported in namespaces") + raise Exception( + f"{mc.NS_APPLIANCE_CONTROL_MP3} not supported in namespaces" + ) mp3 = self.descriptor.namespaces[mc.NS_APPLIANCE_CONTROL_MP3] mp3[mc.KEY_MP3].update(payload[mc.KEY_MP3]) return mc.METHOD_SETACK, {} diff --git a/custom_components/meross_lan/emulator/mixins/electricity.py b/custom_components/meross_lan/emulator/mixins/electricity.py new file mode 100644 index 00000000..91b9ad46 --- /dev/null +++ b/custom_components/meross_lan/emulator/mixins/electricity.py @@ -0,0 +1,152 @@ +"""""" +from __future__ import annotations + +from datetime import datetime, timezone +from random import randint +from time import gmtime, mktime +import typing + +from custom_components.meross_lan.merossclient import const as mc + +from ..emulator import MerossEmulator, MerossEmulatorDescriptor + + +class ElectricityMixin(MerossEmulator if typing.TYPE_CHECKING else object): + def __init__(self, descriptor: MerossEmulatorDescriptor, key): + super().__init__(descriptor, key) + self.payload_electricity = descriptor.namespaces[ + mc.NS_APPLIANCE_CONTROL_ELECTRICITY + ] + self.electricity = self.payload_electricity[mc.KEY_ELECTRICITY] + self.voltage_average: int = self.electricity[mc.KEY_VOLTAGE] or 2280 + self.power = self.electricity[mc.KEY_POWER] + + def _GET_Appliance_Control_Electricity(self, header, payload): + """ + { + "electricity": { + "channel":0, + "current":34, + "voltage":2274, + "power":1015, + "config":{"voltageRatio":188,"electricityRatio":100} + } + } + """ + p_electricity = self.electricity + power: int = p_electricity[mc.KEY_POWER] # power in mW + if randint(0, 5) == 0: + # make a big power step + power += randint(-1000000, 1000000) + else: + # make some noise + power += randint(-1000, 1000) + + if power > 3600000: + p_electricity[mc.KEY_POWER] = self.power = 3600000 + elif power < 0: + p_electricity[mc.KEY_POWER] = self.power = 0 + else: + p_electricity[mc.KEY_POWER] = self.power = int(power) + + p_electricity[mc.KEY_VOLTAGE] = self.voltage_average + randint(-20, 20) + p_electricity[mc.KEY_CURRENT] = int( + 10 * self.power / p_electricity[mc.KEY_VOLTAGE] + ) + return mc.METHOD_GETACK, self.payload_electricity + + +class ConsumptionMixin(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 + energy = 0.0 # in Wh + epoch_prev: int + power_prev = 0.0 + + BUG_RESET = True + + def __init__(self, descriptor: MerossEmulatorDescriptor, key): + super().__init__(descriptor, key) + self.payload_consumptionx = descriptor.namespaces[ + mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX + ] + p_consumptionx: list = self.payload_consumptionx[mc.KEY_CONSUMPTIONX] + if (consumptionx_len := len(p_consumptionx)) == 0: + p_consumptionx.append( + { + mc.KEY_DATE: "1970-01-01", + mc.KEY_TIME: 0, + mc.KEY_VALUE: 1, + } + ) + else: + + def _get_timestamp(consumptionx_item): + return consumptionx_item[mc.KEY_TIME] + + p_consumptionx = sorted(p_consumptionx, key=_get_timestamp) + self.payload_consumptionx[mc.KEY_CONSUMPTIONX] = p_consumptionx + + self.consumptionx = p_consumptionx + self.epoch_prev = self.epoch + # REMOVE + # "Asia/Bangkok" GMT + 7 + # "Asia/Baku" GMT + 4 + descriptor.timezone = descriptor.time[mc.KEY_TIMEZONE] = "Asia/Baku" + + def _GET_Appliance_Control_ConsumptionX(self, header, payload): + """ + { + "consumptionx": [ + {"date":"2023-03-01","time":1677711486,"value":52}, + {"date":"2023-03-02","time":1677797884,"value":53}, + {"date":"2023-03-03","time":1677884282,"value":51}, + ... + ] + } + """ + # energy will be reset every time we update our consumptionx array + self.energy += ( + (self.power + self.power_prev) * (self.epoch - self.epoch_prev) / 7200000 + ) + self.epoch_prev = self.epoch + self.power_prev = self.power + + if self.energy < 1.0: + return mc.METHOD_GETACK, self.payload_consumptionx + + energy = int(self.energy) + self.energy -= energy + + y, m, d, hh, mm, ss, weekday, jday, dst = gmtime(self.epoch) + ss = min(ss, 59) # clamp out leap seconds if the platform has them + devtime = datetime(y, m, d, hh, mm, ss, 0, timezone.utc) + if (tzinfo := self.tzinfo) is not None: # REMOVE + devtime = devtime.astimezone(tzinfo) + + date_value = "{:04d}-{:02d}-{:02d}".format( + devtime.year, devtime.month, devtime.day + ) + + p_consumptionx = self.consumptionx + consumptionx_last = p_consumptionx[-1] + if consumptionx_last[mc.KEY_DATE] != date_value: + if len(p_consumptionx) >= 30: + p_consumptionx.pop(0) + p_consumptionx.append( + { + mc.KEY_DATE: date_value, + mc.KEY_TIME: self.epoch, + mc.KEY_VALUE: energy + consumptionx_last[mc.KEY_VALUE] + if self.BUG_RESET + else 0, + } + ) + + else: + consumptionx_last[mc.KEY_TIME] = self.epoch + consumptionx_last[mc.KEY_VALUE] += energy + + return mc.METHOD_GETACK, self.payload_consumptionx diff --git a/custom_components/meross_lan/emulator/mixins/garagedoor.py b/custom_components/meross_lan/emulator/mixins/garagedoor.py index 143af0d7..f1ef12b1 100644 --- a/custom_components/meross_lan/emulator/mixins/garagedoor.py +++ b/custom_components/meross_lan/emulator/mixins/garagedoor.py @@ -1,20 +1,24 @@ """""" from __future__ import annotations -import typing + import asyncio -from ..emulator import MerossEmulator # pylint: disable=relative-beyond-top-level -from ...merossclient import const as mc, get_element_by_key # pylint: disable=relative-beyond-top-level +import typing +from custom_components.meross_lan.merossclient import const as mc, get_element_by_key -class GarageDoorMixin(MerossEmulator if typing.TYPE_CHECKING else object): +from ..emulator import MerossEmulator + +class GarageDoorMixin(MerossEmulator if typing.TYPE_CHECKING else object): def _SET_Appliance_GarageDoor_Config(self, header, payload): - p_config = self.descriptor.namespaces[mc.NS_APPLIANCE_GARAGEDOOR_CONFIG][mc.KEY_CONFIG] + p_config = self.descriptor.namespaces[mc.NS_APPLIANCE_GARAGEDOOR_CONFIG][ + mc.KEY_CONFIG + ] p_request = payload[mc.KEY_CONFIG] for _key, _value in p_request.items(): if _key in p_config: p_config[_key] = _value - return mc.METHOD_SETACK, { } + return mc.METHOD_SETACK, {} def _GET_Appliance_GarageDoor_State(self, header, payload): # return everything...at the moment we always query all @@ -22,9 +26,9 @@ def _GET_Appliance_GarageDoor_State(self, header, payload): if len(p_garageDoor) == 1: # un-pack the list since real traces show no list # in this response payloads (we only have msg100 so far..) - return mc.METHOD_GETACK, { mc.KEY_STATE: p_garageDoor[0] } + return mc.METHOD_GETACK, {mc.KEY_STATE: p_garageDoor[0]} else: - return mc.METHOD_GETACK, { mc.KEY_STATE: p_garageDoor } + return mc.METHOD_GETACK, {mc.KEY_STATE: p_garageDoor} def _SET_Appliance_GarageDoor_State(self, header, payload): p_request = payload[mc.KEY_STATE] @@ -33,9 +37,7 @@ def _SET_Appliance_GarageDoor_State(self, header, payload): p_digest = self.descriptor.digest p_state = get_element_by_key( - p_digest[mc.KEY_GARAGEDOOR], - mc.KEY_CHANNEL, - request_channel + p_digest[mc.KEY_GARAGEDOOR], mc.KEY_CHANNEL, request_channel ) p_response = dict(p_state) @@ -48,5 +50,4 @@ def _state_update_callback(): loop.call_later(2 if request_open else 10, _state_update_callback) p_response[mc.KEY_EXECUTE] = 1 - return mc.METHOD_SETACK, { mc.KEY_STATE: p_response } - + return mc.METHOD_SETACK, {mc.KEY_STATE: p_response} diff --git a/custom_components/meross_lan/emulator/mixins/thermostat.py b/custom_components/meross_lan/emulator/mixins/thermostat.py index 5adf49cf..731e9412 100644 --- a/custom_components/meross_lan/emulator/mixins/thermostat.py +++ b/custom_components/meross_lan/emulator/mixins/thermostat.py @@ -1,12 +1,14 @@ """""" from __future__ import annotations + import typing -from ..emulator import MerossEmulator # pylint: disable=relative-beyond-top-level -from ...merossclient import const as mc, get_element_by_key # pylint: disable=relative-beyond-top-level +from custom_components.meross_lan.merossclient import const as mc, get_element_by_key -class ThermostatMixin(MerossEmulator if typing.TYPE_CHECKING else object): +from ..emulator import MerossEmulator + +class ThermostatMixin(MerossEmulator if typing.TYPE_CHECKING else object): def _SET_Appliance_Control_Thermostat_Mode(self, header, payload): p_digest = self.descriptor.digest p_digest_mode_list = p_digest[mc.KEY_THERMOSTAT][mc.KEY_MODE] @@ -15,9 +17,7 @@ def _SET_Appliance_Control_Thermostat_Mode(self, header, payload): for p_mode in p_mode_list: channel = p_mode[mc.KEY_CHANNEL] p_digest_mode = get_element_by_key( - p_digest_mode_list, - mc.KEY_CHANNEL, - channel + p_digest_mode_list, mc.KEY_CHANNEL, channel ) p_digest_mode.update(p_mode) mode = p_digest_mode[mc.KEY_MODE] @@ -25,21 +25,30 @@ def _SET_Appliance_Control_Thermostat_Mode(self, header, payload): mc.MTS200_MODE_HEAT: mc.KEY_HEATTEMP, mc.MTS200_MODE_COOL: mc.KEY_COOLTEMP, mc.MTS200_MODE_ECO: mc.KEY_ECOTEMP, - mc.MTS200_MODE_CUSTOM: mc.KEY_MANUALTEMP + mc.MTS200_MODE_CUSTOM: mc.KEY_MANUALTEMP, } if mode in MODE_KEY_MAP: p_digest_mode[mc.KEY_TARGETTEMP] = p_digest_mode[MODE_KEY_MAP[mode]] - else:# we use this to trigger a windowOpened later in code - p_digest_windowopened_list = p_digest[mc.KEY_THERMOSTAT][mc.KEY_WINDOWOPENED] + else: # we use this to trigger a windowOpened later in code + p_digest_windowopened_list = p_digest[mc.KEY_THERMOSTAT][ + mc.KEY_WINDOWOPENED + ] if p_digest_mode[mc.KEY_ONOFF]: - p_digest_mode[mc.KEY_STATE] = 1 if p_digest_mode[mc.KEY_TARGETTEMP] > p_digest_mode[mc.KEY_CURRENTTEMP] else 0 + p_digest_mode[mc.KEY_STATE] = ( + 1 + if p_digest_mode[mc.KEY_TARGETTEMP] + > p_digest_mode[mc.KEY_CURRENTTEMP] + else 0 + ) else: p_digest_mode[mc.KEY_STATE] = 0 # randomly switch the window for p_digest_windowopened in p_digest_windowopened_list: if p_digest_windowopened[mc.KEY_CHANNEL] == channel: - p_digest_windowopened[mc.KEY_STATUS] = 0 if p_digest_windowopened[mc.KEY_STATUS] else 1 + p_digest_windowopened[mc.KEY_STATUS] = ( + 0 if p_digest_windowopened[mc.KEY_STATUS] else 1 + ) break return mc.METHOD_SETACK, {} diff --git a/custom_components/meross_lan/helpers.py b/custom_components/meross_lan/helpers.py index a39e5681..757c126e 100644 --- a/custom_components/meross_lan/helpers.py +++ b/custom_components/meross_lan/helpers.py @@ -5,13 +5,20 @@ from logging import getLogger from functools import partial from time import time +import typing + +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.util.dt import utcnow from .merossclient import const as mc -LOGGER = getLogger(__name__[:-8]) #get base custom_component name for logging +if typing.TYPE_CHECKING: + from homeassistant.core import State + +LOGGER = getLogger(__name__[:-8]) # get base custom_component name for logging _TRAP_DICT = {} + def LOGGER_trap(level, timeout, msg, *args): """ avoid repeating the same last log message until something changes or timeout expires @@ -22,7 +29,7 @@ def LOGGER_trap(level, timeout, msg, *args): epoch = time() trap_key = (msg, *args) trap_time = _TRAP_DICT.get(trap_key, 0) - if ((epoch - trap_time) < timeout): + if (epoch - trap_time) < timeout: return LOGGER.log(level, msg, *args) @@ -67,9 +74,16 @@ def versiontuple(version: str): deobfuscate on the previously obfuscated payload """ OBFUSCATE_KEYS = ( - mc.KEY_UUID, mc.KEY_MACADDRESS, mc.KEY_WIFIMAC, mc.KEY_INNERIP, - mc.KEY_SERVER, mc.KEY_PORT, mc.KEY_SECONDSERVER, mc.KEY_SECONDPORT, - mc.KEY_USERID, mc.KEY_TOKEN, + mc.KEY_UUID, + mc.KEY_MACADDRESS, + mc.KEY_WIFIMAC, + mc.KEY_INNERIP, + mc.KEY_SERVER, + mc.KEY_PORT, + mc.KEY_SECONDSERVER, + mc.KEY_SECONDPORT, + mc.KEY_USERID, + mc.KEY_TOKEN, ) @@ -89,7 +103,7 @@ def obfuscate(payload: dict): obfuscated[key] = o elif key in OBFUSCATE_KEYS: obfuscated[key] = value - payload[key] = '#' * len(str(value)) + payload[key] = "#" * len(str(value)) return obfuscated @@ -105,35 +119,52 @@ def deobfuscate(payload: dict, obfuscated: dict): """ RECORDER helpers """ -async def get_entity_last_state(hass, entity_id): +async def get_entity_last_states( + hass, number_of_states: int, entity_id: str +) -> list[State] | None: """ recover the last known good state from recorder in order to restore transient state information when restarting HA """ from homeassistant.components.recorder import history - if hasattr(history, 'get_state'):# removed in 2022.6.x - return history.get_state(hass, utcnow(), entity_id) # type: ignore + if hasattr(history, "get_state"): # removed in 2022.6.x + return history.get_state(hass, utcnow(), entity_id) # type: ignore - elif hasattr(history, 'get_last_state_changes'): + elif hasattr(history, "get_last_state_changes"): """ get_instance too is relatively new: I hope it was in place when get_last_state_changes was added """ from homeassistant.components.recorder import get_instance - _last_state: dict = await get_instance(hass).async_add_executor_job( - partial( - history.get_last_state_changes, - hass, - 1, - entity_id, - ) - ) # type: ignore - if entity_id in _last_state: - _last_entity_state: list = _last_state[entity_id] - if _last_entity_state: - return _last_entity_state[0] - - return None + + _last_state = await get_instance(hass).async_add_executor_job( + partial( + history.get_last_state_changes, + hass, + number_of_states, + entity_id, + ) + ) + return _last_state.get(entity_id) + else: - raise Exception("Cannot find history.get_last_state_changes api") \ No newline at end of file + raise Exception("Cannot find history.get_last_state_changes api") + +async def get_entity_last_state(hass, entity_id: str) -> State | None: + if states := await get_entity_last_states(hass, 1, entity_id): + return states[0] + return None + +async def get_entity_last_state_available(hass, entity_id: str) -> State | None: + """ + if the device/entity was disconnected before restarting and we need + the last good reading from the device, we need to skip the last + state since it is 'unavailable' + """ + states = await get_entity_last_states(hass, 2, entity_id) + if states is not None: + for state in reversed(states): + if state.state not in {STATE_UNKNOWN, STATE_UNAVAILABLE}: + return state + return None diff --git a/custom_components/meross_lan/manifest.json b/custom_components/meross_lan/manifest.json index 53d7cf27..671a31ac 100644 --- a/custom_components/meross_lan/manifest.json +++ b/custom_components/meross_lan/manifest.json @@ -1,22 +1,22 @@ { "domain": "meross_lan", "name": "Meross LAN", - "integration_type": "hub", + "after_dependencies": ["mqtt", "dhcp", "recorder", "persistent_notification"], + "codeowners": ["@krahabb"], "config_flow": true, - "iot_class": "local_polling", - "documentation": "https://github.com/krahabb/meross_lan", - "issue_tracker": "https://github.com/krahabb/meross_lan/issues", - "requirements": [], "dhcp": [ {"hostname": "*", "macaddress": "48E1E9*"}, {"hostname": "*", "macaddress": "34298F1*"}, {"registered_devices": true} ], + "documentation": "https://github.com/krahabb/meross_lan", + "integration_type": "hub", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/krahabb/meross_lan/issues", + "loggers": ["custom_components.meross_lan"], "mqtt": [ "/appliance/+/publish" ], - "after_dependencies": ["mqtt", "dhcp", "recorder", "persistent_notification"], - "loggers": ["custom_components.meross_lan"], - "codeowners": ["@krahabb"], - "version": "3.0.2" -} + "requirements": [], + "version": "3.0.3" +} \ 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 e9ef3413..9c9ff51b 100644 --- a/custom_components/meross_lan/meross_device.py +++ b/custom_components/meross_lan/meross_device.py @@ -147,6 +147,7 @@ class MerossDevice: _polling_delay: int = CONF_POLLING_PERIOD_DEFAULT # other default property values _deviceentry = None # weakly cached entry to the device registry + _tzinfo: ZoneInfo | None = None # smart cache of device tzinfo def __init__( self, @@ -163,7 +164,7 @@ def __init__( self.needsave = ( False # while parsing ns.ALL code signals to persist ConfigEntry ) - self.device_timestamp: int = 0 + self.device_timestamp = 0.0 self.device_timedelta = 0 self.device_timedelta_log_epoch = 0 self.device_timedelta_config_epoch = 0 @@ -252,27 +253,33 @@ def __init__( else: _init(payload) - self._unsub_polling_callback = api.schedule_async_callback( - 0, self._async_polling_callback - ) - def __del__(self): LOGGER.debug("MerossDevice(%s) destroy", self.device_id) return - def shutdown(self): + def start(self): + # called by async_setup_entry after the entities have been registered + # here we'll start polling after the states have been eventually + # restored (some entities need this) + self._unsub_polling_callback = self.api.schedule_async_callback( + 0, self._async_polling_callback + ) + + async def async_shutdown(self): """ called when the config entry is unloaded we'll try to clear everything here """ - if self._unsub_polling_callback is not None: - self._unsub_polling_callback.cancel() - self._unsub_polling_callback = None - if self._unsub_entry_update_listener is not None: - self._unsub_entry_update_listener() - self._unsub_entry_update_listener = None - if self._trace_file: + while self._unsub_polling_callback is None: + # wait for the polling loop to finish in case + await asyncio.sleep(1) + self._unsub_polling_callback.cancel() + self._unsub_polling_callback = None + self._unsub_entry_update_listener() + if self._trace_file is not None: self._trace_close() + self.entities.clear() + self.entity_dnd = MerossFakeEntity @property def host(self): @@ -285,8 +292,13 @@ def tzname(self): @property def tzinfo(self) -> tzinfo: tz_name = self.descriptor.timezone + if not tz_name: + return timezone.utc + if (self._tzinfo is not None) and (self._tzinfo.key == tz_name): + return self._tzinfo try: - return ZoneInfo(tz_name) if tz_name else timezone.utc + self._tzinfo = ZoneInfo(tz_name) + return self._tzinfo except Exception: self.log( WARNING, @@ -295,7 +307,8 @@ def tzinfo(self) -> tzinfo: self.name, tz_name, ) - return timezone.utc + self._tzinfo = None + return timezone.utc @property def name(self) -> str: @@ -327,7 +340,7 @@ def receive(self, header: dict, payload: dict, protocol) -> bool: # We ignore delays below PARAM_TIMESTAMP_TOLERANCE since # we'll always be a bit late in processing epoch = time() - self.device_timestamp = int(header.get(mc.KEY_TIMESTAMP, epoch)) + self.device_timestamp = float(header.get(mc.KEY_TIMESTAMP, epoch)) device_timedelta = epoch - self.device_timestamp if abs(device_timedelta) > PARAM_TIMESTAMP_TOLERANCE: self._config_timestamp(epoch, device_timedelta) @@ -361,8 +374,7 @@ def receive(self, header: dict, payload: dict, protocol) -> bool: self.lastupdate = epoch if not self._online: - self.log(DEBUG, 0, "MerossDevice(%s) back online!", self.name) - self._online = True + self._set_online() self.api.hass.async_create_task( self.async_request_updates(epoch, namespace) ) @@ -547,7 +559,7 @@ async def async_http_request( _httpclient: MerossHttpClient = getattr(self, VOLATILE_ATTR_HTTPCLIENT, None) # type: ignore if _httpclient is None: _httpclient = MerossHttpClient( - self.host, self.key, async_get_clientsession(self.api.hass), LOGGER + self.host, self.key, async_get_clientsession(self.api.hass), LOGGER # type: ignore ) self._httpclient = _httpclient @@ -689,6 +701,7 @@ async def async_request_updates(self, epoch, namespace): async def _async_polling_callback(self): LOGGER.log(DEBUG, "MerossDevice(%s) polling start", self.name) 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 @@ -995,6 +1008,23 @@ def _config_timezone(self, epoch, tzname): payload={mc.KEY_TIME: {mc.KEY_TIMEZONE: "", mc.KEY_TIMERULE: []}}, ) + def _set_online(self): + self.log(DEBUG, 0, "MerossDevice(%s) back online!", self.name) + self._online = True + self._polling_delay = self.polling_period + # retrigger the polling loop since we're already + # scheduling an immediate async_request_updates. + # This is needed to avoid startup staggering and also + # as an optimization against asynchronous onlining events (on MQTT) + # which could come anytime and so the (next) + # polling might be too early + if self._unsub_polling_callback is not None: + # might be None when we're already inside a polling loop + self._unsub_polling_callback.cancel() + self._unsub_polling_callback = self.api.schedule_async_callback( + self._polling_delay, self._async_polling_callback + ) + def _set_offline(self): self.log(DEBUG, 0, "MerossDevice(%s) going offline!", self.name) self._online = False diff --git a/custom_components/meross_lan/meross_entity.py b/custom_components/meross_lan/meross_entity.py index b78807c1..7a01ecdb 100644 --- a/custom_components/meross_lan/meross_entity.py +++ b/custom_components/meross_lan/meross_entity.py @@ -55,6 +55,9 @@ class MerossEntity(Entity if typing.TYPE_CHECKING else object): _attr_name: str | None = None _attr_entity_category: EntityCategory | str | None = None + # used to speed-up checks if entity is enabled and loaded + _hass_connected = False + def __init__( self, device: MerossDevice, @@ -163,12 +166,20 @@ def available(self): def assumed_state(self): return False + async def async_added_to_hass(self): + self._hass_connected = True + + async def async_will_remove_from_hass(self): + self._hass_connected = False + def update_state(self, state: StateType): if self._attr_state != state: self._attr_state = state - if self.hass and self.enabled: # pylint: disable=no-member - self.async_write_ha_state() # pylint: disable=no-member - + if self._hass_connected: + # optimize hass checks since we're (pretty) + # sure they're ok (DANGER) + self._async_write_ha_state() + def set_unavailable(self): self.update_state(None) diff --git a/custom_components/meross_lan/sensor.py b/custom_components/meross_lan/sensor.py index b334eb30..06396569 100644 --- a/custom_components/meross_lan/sensor.py +++ b/custom_components/meross_lan/sensor.py @@ -1,13 +1,15 @@ from __future__ import annotations import typing -from logging import DEBUG -from time import localtime -from datetime import datetime, timedelta, timezone +from logging import DEBUG, WARNING +from time import gmtime +from datetime import datetime, timedelta from homeassistant.components.sensor import ( DOMAIN as PLATFORM_SENSOR, ) -from homeassistant.util.dt import now +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.util import dt as dt_util try: from homeassistant.components.sensor import SensorEntity @@ -57,9 +59,9 @@ ELECTRIC_CURRENT_AMPERE = "A" ELECTRIC_POTENTIAL_VOLT = "V" - -from .merossclient import MerossDeviceDescriptor, const as mc # mEROSS cONST +from .helpers import get_entity_last_state_available from . import meross_entity as me +from .merossclient import MerossDeviceDescriptor, const as mc # mEROSS cONST from .const import ( PARAM_ENERGY_UPDATE_PERIOD, PARAM_SIGNAL_UPDATE_PERIOD, @@ -95,9 +97,10 @@ class MLSensor(me.MerossEntity, SensorEntity): # type: ignore PLATFORM = PLATFORM_SENSOR + _attr_state: int | float | None = None _attr_state_class: str | None = STATE_CLASS_MEASUREMENT _attr_last_reset: datetime | None = None - _attr_native_unit_of_measurement: str | None + _attr_native_unit_of_measurement: str | None = None def __init__( self, @@ -146,129 +149,476 @@ def state(self): return self._attr_state +class EnergyEstimateSensor(MLSensor): + + _attr_state: int = 0 + _attr_state_float: float = 0.0 + + def __init__(self, device: MerossDevice): + super().__init__(device, None, "energy_estimate", DEVICE_CLASS_ENERGY, None) + + @property + def entity_registry_enabled_default(self): + return False + + @property + def available(self): + return True + + @property + def state_class(self): + return STATE_CLASS_TOTAL_INCREASING + + 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 + + try: + 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) + except Exception as e: + self.device.log( + WARNING, + 0, + "EnergyEstimateSensor(%s): error(%s) while trying to restore previous state", + self.name, + str(e), + ) + + 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 + + # This is actually reset in ConsumptionMixin + _consumption_estimate = 0.0 + def __init__(self, api, descriptor: MerossDeviceDescriptor, entry): super().__init__(api, descriptor, entry) self._sensor_power = MLSensor.build_for_device(self, DEVICE_CLASS_POWER) self._sensor_current = MLSensor.build_for_device(self, DEVICE_CLASS_CURRENT) self._sensor_voltage = MLSensor.build_for_device(self, DEVICE_CLASS_VOLTAGE) + self._sensor_energy_estimate = EnergyEstimateSensor(self) + + def start(self): + self._schedule_next_reset(dt_util.now()) + super().start() + + async def async_shutdown(self): + 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 + if self._cancel_energy_reset is not None: + self._cancel_energy_reset() + self._cancel_energy_reset = None def _handle_Appliance_Control_Electricity(self, header: dict, payload: dict): - electricity = payload.get(mc.KEY_ELECTRICITY) - self._sensor_power.update_state(electricity.get(mc.KEY_POWER) / 1000) # type: ignore - self._sensor_current.update_state(electricity.get(mc.KEY_CURRENT) / 1000) # type: ignore - self._sensor_voltage.update_state(electricity.get(mc.KEY_VOLTAGE) / 10) # type: ignore + electricity = payload[mc.KEY_ELECTRICITY] + power: float = 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.lastupdate - self._electricity_lastupdate) + ) / 7200 + self._consumption_estimate += de + self._sensor_energy_estimate.update_estimate(de) + + self._electricity_lastupdate = self.lastupdate + 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 async def async_request_updates(self, epoch, namespace): await super().async_request_updates(epoch, namespace) - # we're not checking context namespace since it should be very unusual - # to enter here with one of those following - if ( - self._sensor_power.enabled - or self._sensor_voltage.enabled - or self._sensor_current.enabled - ): + # we're always asking updates even if sensors could be disabled since + # there are far too many dependencies for these readings (energy sensor + # in ConsumptionMixin too depends on us) but it's unlikely all of these + # are disabled! + if self.online: await self.async_request_get(mc.NS_APPLIANCE_CONTROL_ELECTRICITY) + def _schedule_next_reset(self, _now: datetime): + try: + 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=1, + tzinfo=dt_util.DEFAULT_TIME_ZONE, + ) + self._cancel_energy_reset = async_track_point_in_time( + self.api.hass, self._energy_reset, next_reset + ) + self.log( + DEBUG, + 0, + "ElectricityMixin(%s) _schedule_next_reset: %s", + self.name, + next_reset.isoformat(), + ) + except Exception as error: + # really? log something + self.log( + DEBUG, + 0, + "ElectricityMixin(%s) _schedule_next_reset Exception: %s", + self.name, + str(error), + ) + + @callback + def _energy_reset(self, _now: datetime): + self.log( + DEBUG, + 0, + "ElectricityMixin(%s) _energy_reset: %s", + self.name, + _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 + + _attr_state: int = 0 + + def __init__(self, device: MerossDevice): + self._attr_extra_state_attributes = {} + super().__init__(device, None, DEVICE_CLASS_ENERGY, DEVICE_CLASS_ENERGY, None) + + @property + def available(self): + return True + + @property + def state_class(self): + return STATE_CLASS_TOTAL_INCREASING + + 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) or self._attr_extra_state_attributes: + return + + try: + state = await get_entity_last_state_available(self.hass, self.entity_id) + if state is None: + 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) + self._attr_state = int(state.state) + except Exception as e: + self.device.log( + WARNING, + 0, + "ConsumptionSensor(%s): error(%s) while trying to restore previous state", + self.name, + str(e), + ) + + def set_unavailable(self): + # we need to preserve our state 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 + class ConsumptionMixin( MerossDevice if typing.TYPE_CHECKING else object ): # pylint: disable=used-before-assignment - _lastupdate_energy = 0 - _lastreset_energy = ( - 0 # store the last 'device time' we passed onto to _attr_last_reset - ) + _consumption_lastupdate = 0.0 + _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, api, descriptor: MerossDeviceDescriptor, entry): super().__init__(api, descriptor, entry) - self._sensor_energy = MLSensor.build_for_device(self, DEVICE_CLASS_ENERGY) - self._sensor_energy._attr_state_class = STATE_CLASS_TOTAL_INCREASING + self._sensor_consumption: ConsumptionSensor = ConsumptionSensor(self) + + 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): - self._lastupdate_energy = self.lastupdate - days: list = payload.get(mc.KEY_CONSUMPTIONX) # type: ignore - days_len = len(days) - if days_len < 1: - if STATE_CLASS_TOTAL_INCREASING == STATE_CLASS_MEASUREMENT: - self._sensor_energy._attr_last_reset = now() - self._sensor_energy.update_state(0) # type: ignore - return + self._consumption_lastupdate = self.lastupdate # 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' - st = localtime() - dt = datetime( - st.tm_year, - st.tm_mon, - st.tm_mday, - tzinfo=timezone( - timedelta(seconds=st.tm_gmtoff), st.tm_zone - ) if st.tm_zone is not None else None, - ) - timestamp_lastreset = dt.timestamp() - self.device_timedelta - self.log( - DEBUG, - 0, - "MerossDevice(%s) Energy: device midnight = %d", - self.name, - timestamp_lastreset, - ) - - def get_timestamp(day): - return day.get(mc.KEY_TIME) + if self.device_timestamp > self._tomorrow_midnight_epoch: + # catch the device starting a new day since our last update (yesterday) + y, m, d, hh, mm, ss, weekday, jday, dst = gmtime(self.device_timestamp) + ss = min(ss, 59) # clamp out leap seconds if the platform has them + devtime_utc = datetime(y, m, d, hh, mm, ss, 0, dt_util.UTC) + devtime_devlocaltz = devtime_utc.astimezone(self.tzinfo) + devtime_today_midnight = datetime( + devtime_devlocaltz.year, + devtime_devlocaltz.month, + devtime_devlocaltz.day, + tzinfo=self.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) + devtime_tomorrow_midnight = devtime_today_midnight + daydelta + self._tomorrow_midnight_epoch = devtime_tomorrow_midnight.timestamp() + devtime_yesterday_midnight = devtime_today_midnight - daydelta + self._yesterday_midnight_epoch = devtime_yesterday_midnight.timestamp() + self.log( + DEBUG, + 0, + "ConsumptionMixin(%s) updated midnight epochs: yesterday=%s - today=%s - tomorrow=%s", + self.name, + str(self._yesterday_midnight_epoch), + str(self._today_midnight_epoch), + str(self._tomorrow_midnight_epoch), + ) - days = sorted(days, key=get_timestamp, reverse=True) - day_last: dict = days[0] - if day_last.get(mc.KEY_TIME) < timestamp_lastreset: # type: ignore + # 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: return - if days_len > 1: - timestamp_lastreset = days[1].get(mc.KEY_TIME) - if self._lastreset_energy != timestamp_lastreset: - # we 'cache' timestamp_last_reset so we don't 'jitter' _attr_last_reset - # should device_timedelta change (and it will!) - # this is not really working until days_len is >= 2 - self._lastreset_energy = timestamp_lastreset - # we'll add .5 (sec) to the device last reading since the reset - # occurs right after that - # update the entity last_reset only for a 'corner case' - # when the feature was initially added (2021.8) and - # STATE_CLASS_TOTAL_INCREASING was not defined yet - if STATE_CLASS_TOTAL_INCREASING == STATE_CLASS_MEASUREMENT: - self._sensor_energy._attr_last_reset = datetime.utcfromtimestamp( - timestamp_lastreset + self.device_timedelta + 0.5 - ) + + elif days_len > 1: + + def _get_timestamp(day): + return day[mc.KEY_TIME] + + days = sorted(days, key=_get_timestamp) + + _sensor_consumption = self._sensor_consumption + 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 + if self._consumption_last_value is not None: + self._consumption_last_value = None + _sensor_consumption._attr_state = 0 + _sensor_consumption._attr_extra_state_attributes = {} + _sensor_consumption.offset = 0 + _sensor_consumption.reset_ts = 0 + if _sensor_consumption._hass_connected: + _sensor_consumption.async_write_ha_state() self.log( DEBUG, 0, - "MerossDevice(%s) Energy: update last_reset to %s", + "ConsumptionMixin(%s): no readings available for new day - resetting", self.name, - self._sensor_energy._attr_last_reset.isoformat(), ) - self._sensor_energy.update_state(day_last.get(mc.KEY_VALUE)) + 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, + 0, + "ConsumptionMixin(%s): first reading for new day, offset=%d", + self.name, + _sensor_consumption.offset, + ) + + elif day_last_value == self._consumption_last_value: + # no change in consumption..skip updating + 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, + 0, + "ConsumptionMixin(%s): updating consumption=%d", + self.name, + day_last_value, + ) async def async_request_updates(self, epoch, namespace): await super().async_request_updates(epoch, namespace) - if self._sensor_energy.enabled and ( - ((epoch - self._lastupdate_energy) > PARAM_ENERGY_UPDATE_PERIOD) - or ( - (namespace is not None) - and ( # namespace is not None when coming online - namespace != mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX - ) - ) + if ( + self.online + and self._sensor_consumption.enabled + and ((epoch - self._consumption_lastupdate) > PARAM_ENERGY_UPDATE_PERIOD) ): await self.async_request_get(mc.NS_APPLIANCE_CONTROL_CONSUMPTIONX) + def _set_offline(self): + super()._set_offline() + self._yesterday_midnight_epoch = 0 + self._today_midnight_epoch = 0 + self._tomorrow_midnight_epoch = 0 + class RuntimeMixin( MerossDevice if typing.TYPE_CHECKING else object ): # pylint: disable=used-before-assignment + _sensor_runtime: MLSensor _lastupdate_runtime = 0 def __init__(self, api, descriptor: MerossDeviceDescriptor, entry): @@ -283,6 +633,10 @@ def __init__(self, api, descriptor: MerossDeviceDescriptor, entry): self._sensor_runtime._attr_native_unit_of_measurement = PERCENTAGE self._sensor_runtime._attr_icon = "mdi:wifi" + async def async_shutdown(self): + await super().async_shutdown() + self._sensor_runtime = None # type: ignore + def _handle_Appliance_System_Runtime(self, header: dict, payload: dict): self._lastupdate_runtime = self.lastupdate if isinstance(runtime := payload.get(mc.KEY_RUNTIME), dict):