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