From 8cc572f693661d5d64c27b195f48eda9fdd5723a Mon Sep 17 00:00:00 2001 From: Alberto Geniola Date: Mon, 2 Jan 2023 15:52:49 +0100 Subject: [PATCH] Implement device registry dumper, targeting #255 --- README.md | 9 +++-- examples/dump.py | 55 ++++++++++++++++++++++++++ meross_iot/controller/device.py | 68 +++++++++++++++++++++------------ meross_iot/device_factory.py | 3 +- meross_iot/manager.py | 39 ++++++++++++++++++- meross_iot/model/http/device.py | 2 + 6 files changed, 146 insertions(+), 30 deletions(-) create mode 100644 examples/dump.py diff --git a/README.md b/README.md index 1c3c858e..82555af9 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Due to the popularity of the library, I've decided to list it publicly on the Pi So, the installation is as simple as typing the following command: ```bash -pip install meross_iot==0.4.5.2 +pip install meross_iot==0.4.5.3 ``` ## Usage & Full Documentation @@ -184,12 +184,15 @@ Anyway, feel free to contribute via donations!

## Changelog +#### 0.4.5.3 +- Implement device registry dumper (#255) + +
+ Older #### 0.4.5.2 - Adds support for Runtime mixin (addresses #270) - Improves documentation -
- Older #### 0.4.5.1 - Adds support for MOD150 oil diffuser - Enables position set for MSR100 devices (might not work with non-HomeKit versions, though) diff --git a/examples/dump.py b/examples/dump.py new file mode 100644 index 00000000..3300b996 --- /dev/null +++ b/examples/dump.py @@ -0,0 +1,55 @@ +import asyncio +import os +import json + +from meross_iot.http_api import MerossHttpClient +from meross_iot.manager import MerossManager + +EMAIL = os.environ.get('MEROSS_EMAIL') or "YOUR_MEROSS_CLOUD_EMAIL" +PASSWORD = os.environ.get('MEROSS_PASSWORD') or "YOUR_MEROSS_CLOUD_PASSWORD" + + +async def main(): + # Setup the HTTP client API from user-password + http_api_client = await MerossHttpClient.async_from_user_password(email=EMAIL, password=PASSWORD) + + # Setup and start the device manager + manager = MerossManager(http_client=http_api_client) + await manager.async_init() + + # Issue a discovery. + await manager.async_device_discovery() + print_devices(manager) + + # Dump the registry. + manager.dump_device_registry("test.dump") + print("Registry dumped.") + + # Close the manager. + manager.close() + await http_api_client.async_logout() + + # Now start a new one using the dumped file, without issuing a discovery. + http_api_client = await MerossHttpClient.async_from_user_password(email=EMAIL, password=PASSWORD) + manager = MerossManager(http_client=http_api_client) + await manager.async_init() + manager.load_devices_from_dump("test.dump") + print("Registry dump loaded.") + print_devices(manager) + + +def print_devices(manager): + devices = manager.find_devices() + print("Discovered %d devices:", len(devices)) + for dev in devices: + print(f". {dev.name} {dev.type} ({dev.uuid})") + + +if __name__ == '__main__': + # Windows and python 3.8 requires to set up a specific event_loop_policy. + # On Linux and MacOSX this is not necessary. + if os.name == 'nt': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) + loop.close() diff --git a/meross_iot/controller/device.py b/meross_iot/controller/device.py index 3b46d792..26767028 100644 --- a/meross_iot/controller/device.py +++ b/meross_iot/controller/device.py @@ -11,6 +11,7 @@ from meross_iot.model.enums import OnlineStatus, Namespace from meross_iot.model.http.device import HttpDeviceInfo from meross_iot.model.plugin.hub import BatteryInfo +from meross_iot.model.shared import BaseDictPayload from meross_iot.utilities.network import extract_domain, extract_port _LOGGER = logging.getLogger(__name__) @@ -23,34 +24,42 @@ class BaseDevice(object): name, type (i.e. device specific model), firmware/hardware version, a Meross internal identifier, a library assigned internal identifier. """ + def __init__(self, device_uuid: str, manager, # TODO: type hinting "manager" **kwargs): self._uuid = device_uuid self._manager = manager - self._channels = self._parse_channels(kwargs.get('channels', [])) - - # Information about device - self._name = kwargs.get('devName') - self._type = kwargs.get('deviceType') - self._fwversion = kwargs.get('fmwareVersion') - self._hwversion = kwargs.get('hdwareVersion') - self._online = OnlineStatus(kwargs.get('onlineStatus', -1)) - self._inner_ip = None - - # Domain and port - domain = kwargs.get('domain') - reserved_domain = kwargs.get('reservedDomain') - - # Prefer domain over reserved domain - if domain is not None: - self._mqtt_host = extract_domain(domain) - self._mqtt_port = extract_port(domain, DEFAULT_MQTT_PORT) - elif reserved_domain is not None: - self._mqtt_host = extract_domain(reserved_domain) - self._mqtt_port = extract_port(reserved_domain, DEFAULT_MQTT_PORT) - else: - _LOGGER.warning("No MQTT DOMAIN/RESERVED DOMAIN specified in args, assuming default value %s:%d", DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT) + + self._cached_http_info = None + + # Parse device info, if any + if 'http_device_info' in kwargs: + self._cached_http_info: HttpDeviceInfo = kwargs.get('http_device_info', {}) + self._channels = self._parse_channels(self._cached_http_info.channels) + + # Information about device + self._name = self._cached_http_info.dev_name + self._type = self._cached_http_info.device_type + self._fwversion = self._cached_http_info.fmware_version + self._hwversion = self._cached_http_info.hdware_version + self._online = self._cached_http_info.online_status + self._inner_ip = None + + # Domain and port + domain = self._cached_http_info.domain + reserved_domain = self._cached_http_info.reserved_domain + + # Prefer domain to reserved domain + if domain is not None: + self._mqtt_host = extract_domain(domain) + self._mqtt_port = extract_port(domain, DEFAULT_MQTT_PORT) + elif reserved_domain is not None: + self._mqtt_host = extract_domain(reserved_domain) + self._mqtt_port = extract_port(reserved_domain, DEFAULT_MQTT_PORT) + else: + _LOGGER.warning("No MQTT DOMAIN/RESERVED DOMAIN specified in args, assuming default value %s:%d", + DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT) if hasattr(self, "_abilities_spec"): self._abilities = self._abilities_spec @@ -62,6 +71,10 @@ def __init__(self, device_uuid: str, # Set default timeout value for command execution self._timeout = DEFAULT_COMMAND_TIMEOUT + @property + def cached_http_info(self) -> Optional[HttpDeviceInfo]: + return self._cached_http_info + @property def lan_ip(self): return self._inner_ip @@ -194,6 +207,8 @@ async def update_from_http_state(self, hdevice: HttpDeviceInfo) -> BaseDevice: # Careful with online status: not all the devices might expose an online mixin. if hdevice.uuid != self.uuid: raise ValueError(f"Cannot update device ({self.uuid}) with HttpDeviceInfo for device id {hdevice.uuid}") + self._cached_http_info=hdevice + self._cached_http_info = hdevice self._name = hdevice.dev_name self._channels = self._parse_channels(hdevice.channels) self._type = hdevice.device_type @@ -413,7 +428,8 @@ async def async_get_battery_life(self, return BatteryInfo(battery_charge=battery_life_perc, sample_ts=timestamp) async def async_handle_subdevice_notification(self, namespace: Namespace, data: dict) -> bool: - _LOGGER.error("Unhandled/NotImplemented event handler for %s (data: %s) - Subdevice %s (hub %s)", namespace, json.dumps(data), self.subdevice_id, self._hub.uuid) + _LOGGER.error("Unhandled/NotImplemented event handler for %s (data: %s) - Subdevice %s (hub %s)", namespace, + json.dumps(data), self.subdevice_id, self._hub.uuid) return False @property @@ -437,7 +453,9 @@ def _prepare_push_notification_data(self, data: dict, filter_accessor: str = Non # Operate only on relative accessor context = data.get(filter_accessor) if context is None: - raise ValueError("Could not find accessor %s within data %s. This push notification will be ignored." % (filter_accessor, str(data))) + raise ValueError( + "Could not find accessor %s within data %s. This push notification will be ignored." % ( + filter_accessor, str(data))) pertinent_notifications = filter(lambda n: n.get('id') == self.subdevice_id, context) next(pertinent_notifications, None) diff --git a/meross_iot/device_factory.py b/meross_iot/device_factory.py index c2f3034e..c78f09db 100644 --- a/meross_iot/device_factory.py +++ b/meross_iot/device_factory.py @@ -197,7 +197,8 @@ def build_meross_device_from_abilities(http_device_info: HttpDeviceInfo, base_class=base_class) _dynamic_types[device_type_name] = cached_type - component = cached_type(device_uuid=http_device_info.uuid, manager=manager, **http_device_info.to_dict()) + #component = cached_type(device_uuid=http_device_info.uuid, manager=manager, **http_device_info.to_dict()) + component = cached_type(device_uuid=http_device_info.uuid, manager=manager, http_device_info=http_device_info) return component diff --git a/meross_iot/manager.py b/meross_iot/manager.py index 34e60f7f..8535b885 100644 --- a/meross_iot/manager.py +++ b/meross_iot/manager.py @@ -7,6 +7,7 @@ import sys from asyncio import Future, AbstractEventLoop from asyncio import TimeoutError +from datetime import datetime from enum import Enum from hashlib import md5 from time import time @@ -967,11 +968,48 @@ def set_proxy(self, proxy_type, proxy_addr, proxy_port): client.proxy_set(proxy_type=self._proxy_type, proxy_addr=self._proxy_addr, proxy_port=self._proxy_port) client.reconnect() + def dump_device_registry(self, filename): + """ + Save the current list of devices into a file so that you can later re-load it without issuing + a discovery. **Note**: the stored information might become out-of-date or unvalidated. For instance, + a device name might change over time, as its online status or any other info that is not immutable (as the UUID). + Use this with caution! + """ + self._device_registry.dump_to_file(filename) + + def load_devices_from_dump(self, filename): + """Reload the registry info from a dump. **Note**: this will override all the currently discovered devices.""" + self._device_registry.load_from_dump(filename, manager=self) + class DeviceRegistry(object): def __init__(self): self._devices_by_internal_id = {} + def clear(self) -> None: + """Clear all the registered devices""" + ids = [devid for devid in self._devices_by_internal_id] + for devid in ids: + self.relinquish_device(devid) + + def dump_to_file(self, filename: str)->None: + """Dump the current device list to a file""" + dumped_base_devices = [{'abilities': x.abilities, 'info': x.cached_http_info.to_dict()} for x in self._devices_by_internal_id.values() if not isinstance(x, GenericSubDevice)] + with open(filename, "wt") as f: + json.dump(dumped_base_devices, f, default=lambda x: x.isoformat() if isinstance(x, datetime) else x.value if(isinstance(x,OnlineStatus)) else 'Not-Serializable') + + def load_from_dump(self, filename: str, manager: MerossManager) -> None: + """Load the device registry from a file""" + dumped_json_data = [] + with open(filename, "rt") as f: + dumped_json_data = json.load(f) + + for deviced in dumped_json_data: + device_abilities = deviced['abilities'] + device_info = HttpDeviceInfo.from_dict(deviced['info']) + device = build_meross_device_from_abilities(http_device_info=device_info, device_abilities=device_abilities, manager=manager) + self.enroll_device(device) + def relinquish_device(self, device_internal_id: str): dev = self._devices_by_internal_id.get(device_internal_id) if dev is None: @@ -980,7 +1018,6 @@ def relinquish_device(self, device_internal_id: str): ) # Dismiss the device - # TODO: implement the dismiss() method to release device-held resources _LOGGER.debug(f"Disposing resources for {dev.name} ({dev.uuid})") dev.dismiss() del self._devices_by_internal_id[device_internal_id] diff --git a/meross_iot/model/http/device.py b/meross_iot/model/http/device.py index c47dbae7..00d7ff9a 100644 --- a/meross_iot/model/http/device.py +++ b/meross_iot/model/http/device.py @@ -47,6 +47,8 @@ def __init__(self, self.bind_time = datetime.utcfromtimestamp(bind_time) elif isinstance(bind_time, datetime): self.bind_time = bind_time + elif isinstance(bind_time, str): + self.bind_time = datetime.strptime(bind_time, "%Y-%m-%dT%H:%M:%S") else: _LOGGER.warning(f"Provided bind_time is not int neither datetime. It will be ignored.") self.bind_time = None