Skip to content

Commit

Permalink
Implement device registry dumper, targeting #255
Browse files Browse the repository at this point in the history
  • Loading branch information
albertogeniola committed Jan 2, 2023
1 parent ad5caf8 commit 8cc572f
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 30 deletions.
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -184,12 +184,15 @@ Anyway, feel free to contribute via donations!
</p>

## Changelog
#### 0.4.5.3
- Implement device registry dumper (#255)

<details>
<summary>Older</summary>
#### 0.4.5.2
- Adds support for Runtime mixin (addresses #270)
- Improves documentation

<details>
<summary>Older</summary>
#### 0.4.5.1
- Adds support for MOD150 oil diffuser
- Enables position set for MSR100 devices (might not work with non-HomeKit versions, though)
Expand Down
55 changes: 55 additions & 0 deletions examples/dump.py
Original file line number Diff line number Diff line change
@@ -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()
68 changes: 43 additions & 25 deletions meross_iot/controller/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion meross_iot/device_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
39 changes: 38 additions & 1 deletion meross_iot/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions meross_iot/model/http/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 8cc572f

Please sign in to comment.