Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve cloud interface and cli #1699

Merged
merged 6 commits into from
Jan 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
}
]
Loading