Skip to content

Commit

Permalink
Improve cloud interface and cli (#1699)
Browse files Browse the repository at this point in the history
* Expose available locales
* Convert to use pydantic
* Use rich for pretty printing
* Expose extra data contained in the cloud responses
  • Loading branch information
rytilahti committed Jan 29, 2023
1 parent aeadb4c commit 6814f32
Show file tree
Hide file tree
Showing 4 changed files with 311 additions and 62 deletions.
2 changes: 1 addition & 1 deletion miio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

# isort: on

from miio.cloud import CloudInterface
from miio.cloud import CloudDeviceInfo, CloudException, CloudInterface
from miio.devicefactory import DeviceFactory
from miio.integrations.airdog.airpurifier import AirDogX3
from miio.integrations.cgllc.airmonitor import AirQualityMonitor, AirQualityMonitorCGDN1
Expand Down
153 changes: 92 additions & 61 deletions miio/cloud.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import json
import logging
from pprint import pprint
from typing import TYPE_CHECKING, Dict, List, Optional
from typing import TYPE_CHECKING, Dict, Optional

import attr
import click
from pydantic import BaseModel, Field

try:
from rich import print as echo
except ImportError:
echo = click.echo


from miio.exceptions import CloudException

Expand All @@ -12,50 +18,65 @@
if TYPE_CHECKING:
from micloud import MiCloud # noqa: F401

AVAILABLE_LOCALES = ["cn", "de", "i2", "ru", "sg", "us"]
AVAILABLE_LOCALES = {
"all": "All",
"cn": "China",
"de": "Germany",
"i2": "i2", # unknown
"ru": "Russia",
"sg": "Singapore",
"us": "USA",
}


@attr.s(auto_attribs=True)
class CloudDeviceInfo:
"""Container for device data from the cloud.
class CloudDeviceInfo(BaseModel):
"""Model for the xiaomi cloud device information.
Note that only some selected information is directly exposed, but you can access the
raw data using `raw_data`.
Note that only some selected information is directly exposed, raw data is available
using :meth:`raw_data`.
"""

did: str
ip: str = Field(alias="localip")
token: str
did: str
mac: str
name: str
model: str
ip: str
description: str
description: str = Field(alias="desc")

locale: str

parent_id: str
parent_model: str

# network info
ssid: str
mac: str
locale: List[str]
raw_data: str = attr.ib(repr=False)
bssid: str
is_online: bool = Field(alias="isOnline")
rssi: int

@classmethod
def from_micloud(cls, response, locale):
micloud_to_info = {
"did": "did",
"token": "token",
"name": "name",
"model": "model",
"ip": "localip",
"description": "desc",
"ssid": "ssid",
"parent_id": "parent_id",
"mac": "mac",
}
data = {k: response[v] for k, v in micloud_to_info.items()}
return cls(raw_data=response, locale=[locale], **data)
_raw_data: dict

@property
def is_child(self):
"""Return True for gateway sub devices."""
return self.parent_id != ""

@property
def raw_data(self):
"""Return the raw data."""
return self._raw_data

class Config:
extra = "allow"


class CloudInterface:
"""Cloud interface using micloud library.
Currently used only for obtaining the list of registered devices.
You can use this to obtain a list of devices and their tokens.
The :meth:`get_devices` takes the locale string (e.g., 'us') as an argument,
defaulting to all known locales (accessible through :meth:`available_locales`).
Example::
Expand Down Expand Up @@ -83,10 +104,7 @@ def _login(self):
"You need to install 'micloud' package to use cloud interface"
)

self._micloud = MiCloud = MiCloud(
username=self.username, password=self.password
)

self._micloud: MiCloud = MiCloud(username=self.username, password=self.password)
try: # login() can either return False or raise an exception on failure
if not self._micloud.login():
raise CloudException("Login failed")
Expand All @@ -97,31 +115,40 @@ def _parse_device_list(self, data, locale):
"""Parse device list response from micloud."""
devs = {}
for single_entry in data:
devinfo = CloudDeviceInfo.from_micloud(single_entry, locale)
devs[devinfo.did] = devinfo
single_entry["locale"] = locale
devinfo = CloudDeviceInfo.parse_obj(single_entry)
devinfo._raw_data = single_entry
devs[f"{devinfo.did}_{locale}"] = devinfo

return devs

@classmethod
def available_locales(cls) -> Dict[str, str]:
"""Return available locales.
The value is the human-readable name of the locale.
"""
return AVAILABLE_LOCALES

def get_devices(self, locale: Optional[str] = None) -> Dict[str, CloudDeviceInfo]:
"""Return a list of available devices keyed with a device id.
If no locale is given, all known locales are browsed. If a device id is already
seen in another locale, it is excluded from the results.
"""
_LOGGER.debug("Getting devices for locale %s", locale)
self._login()
if locale is not None:
if locale is not None and locale != "all":
return self._parse_device_list(
self._micloud.get_devices(country=locale), locale=locale
)

all_devices: Dict[str, CloudDeviceInfo] = {}
for loc in AVAILABLE_LOCALES:
if loc == "all":
continue
devs = self.get_devices(locale=loc)
for did, dev in devs.items():
if did in all_devices:
_LOGGER.debug("Already seen device with %s, appending", did)
all_devices[did].locale.extend(dev.locale)
continue
all_devices[did] = dev
return all_devices

Expand All @@ -145,41 +172,45 @@ def cloud(ctx: click.Context, username, password):

@cloud.command(name="list")
@click.pass_context
@click.option("--locale", prompt=True, type=click.Choice(AVAILABLE_LOCALES + ["all"]))
@click.option("--locale", prompt=True, type=click.Choice(AVAILABLE_LOCALES.keys()))
@click.option("--raw", is_flag=True, default=False)
def cloud_list(ctx: click.Context, locale: Optional[str], raw: bool):
"""List devices connected to the cloud account."""

ci = ctx.obj
if locale == "all":
locale = None

devices = ci.get_devices(locale=locale)

if raw:
click.echo(f"Printing devices for {locale}")
click.echo("===================================")
for dev in devices.values():
pprint(dev.raw_data) # noqa: T203
click.echo("===================================")
jsonified = json.dumps([dev.raw_data for dev in devices.values()], indent=4)
print(jsonified) # noqa: T201
return

for dev in devices.values():
if dev.parent_id:
continue # we handle children separately

click.echo(f"== {dev.name} ({dev.description}) ==")
click.echo(f"\tModel: {dev.model}")
click.echo(f"\tToken: {dev.token}")
click.echo(f"\tIP: {dev.ip} (mac: {dev.mac})")
click.echo(f"\tDID: {dev.did}")
click.echo(f"\tLocale: {', '.join(dev.locale)}")
echo(f"== {dev.name} ({dev.description}) ==")
echo(f"\tModel: {dev.model}")
echo(f"\tToken: {dev.token}")
echo(f"\tIP: {dev.ip} (mac: {dev.mac})")
echo(f"\tDID: {dev.did}")
echo(f"\tLocale: {dev.locale}")
childs = [x for x in devices.values() if x.parent_id == dev.did]
if childs:
click.echo("\tSub devices:")
echo("\tSub devices:")
for c in childs:
click.echo(f"\t\t{c.name}")
click.echo(f"\t\t\tDID: {c.did}")
click.echo(f"\t\t\tModel: {c.model}")
echo(f"\t\t{c.name}")
echo(f"\t\t\tDID: {c.did}")
echo(f"\t\t\tModel: {c.model}")

other_fields = dev.__fields_set__ - set(dev.__fields__.keys())
echo("\tOther fields:")
for field in other_fields:
if field.startswith("_"):
continue

echo(f"\t\t{field}: {getattr(dev, field)}")

if not devices:
click.echo(f"Unable to find devices for locale {locale}")
echo(f"Unable to find devices for locale {locale}")
116 changes: 116 additions & 0 deletions miio/tests/fixtures/micloud_devices_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
[
{
"did": "1234",
"token": "token1",
"longitude": "0.0",
"latitude": "0.0",
"name": "device 1",
"pid": "0",
"localip": "192.168.xx.xx",
"mac": "xx:xx:xx:xx:xx:xx",
"ssid": "ssid",
"bssid": "xx:xx:xx:xx:xx:xx",
"parent_id": "",
"parent_model": "",
"show_mode": 1,
"model": "some.model.v2",
"adminFlag": 1,
"shareFlag": 0,
"permitLevel": 16,
"isOnline": false,
"desc": "description",
"extra": {
"isSetPincode": 0,
"pincodeType": 0,
"fw_version": "1.2.3",
"needVerifyCode": 0,
"isPasswordEncrypt": 0
},
"prop": {
"power": "off"
},
"uid": 1111,
"pd_id": 211,
"method": [
{
"allow_values": "",
"name": "set_power"
}
],
"password": "",
"p2p_id": "",
"rssi": -55,
"family_id": 0,
"reset_flag": 0,
"locale": "de"
},
{
"did": "4321",
"token": "token2",
"longitude": "0.0",
"latitude": "0.0",
"name": "device 2",
"pid": "0",
"localip": "192.168.xx.xx",
"mac": "yy:yy:yy:yy:yy:yy",
"ssid": "HomeNet",
"bssid": "yy:yy:yy:yy:yy:yy",
"parent_id": "",
"parent_model": "",
"show_mode": 1,
"model": "some.model.v2",
"adminFlag": 1,
"shareFlag": 0,
"permitLevel": 16,
"isOnline": false,
"desc": "description",
"extra": {
"isSetPincode": 0,
"pincodeType": 0,
"fw_version": "1.2.3",
"needVerifyCode": 0,
"isPasswordEncrypt": 0
},
"uid": 1111,
"pd_id": 2222,
"password": "",
"p2p_id": "",
"rssi": 0,
"family_id": 0,
"reset_flag": 0,
"locale": "us"
},
{
"did": "lumi.12341234",
"token": "",
"longitude": "0.0",
"latitude": "0.0",
"name": "example child device",
"pid": "3",
"localip": "",
"mac": "",
"ssid": "ssid",
"bssid": "xx:xx:xx:xx:xx:xx",
"parent_id": "654321",
"parent_model": "some.model.v3",
"show_mode": 1,
"model": "lumi.some.child",
"adminFlag": 1,
"shareFlag": 0,
"permitLevel": 16,
"isOnline": false,
"desc": "description",
"extra": {
"isSetPincode": 0,
"pincodeType": 0
},
"uid": 1111,
"pd_id": 753,
"password": "",
"p2p_id": "",
"rssi": 0,
"family_id": 0,
"reset_flag": 0,
"locale": "cn"
}
]

0 comments on commit 6814f32

Please sign in to comment.