Skip to content

Commit

Permalink
Use micloud for miotspec cloud connectivity (#1610)
Browse files Browse the repository at this point in the history
Convert miot_cloud to use micloud.MiotSpec for cloud accesses.
This PR also makes micloud a mandatory dependency.
  • Loading branch information
rytilahti committed Jan 6, 2023
1 parent b6fde94 commit 03880f0
Show file tree
Hide file tree
Showing 3 changed files with 69 additions and 64 deletions.
109 changes: 57 additions & 52 deletions miio/miot_cloud.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
"""Module implementing handling of miot schema files."""
import json
import logging
from datetime import datetime, timedelta
from operator import attrgetter
from pathlib import Path
from typing import List
from typing import Dict, List, Optional

import appdirs
import requests # TODO: externalize HTTP requests to avoid direct dependency
from pydantic import BaseModel
from micloud.miotspec import MiotSpec
from pydantic import BaseModel, Field

from miio.miot_models import DeviceModel

_LOGGER = logging.getLogger(__name__)


class ReleaseInfo(BaseModel):
"""Information about individual miotspec release."""

model: str
status: str
status: Optional[str] # only available on full listing
type: str
version: int

Expand All @@ -26,92 +29,94 @@ def filename(self) -> str:


class ReleaseList(BaseModel):
instances: List[ReleaseInfo]
"""Model for miotspec release list."""

releases: List[ReleaseInfo] = Field(alias="instances")

def info_for_model(self, model: str, *, status_filter="released") -> ReleaseInfo:
matches = [inst for inst in self.instances if inst.model == model]
releases = [inst for inst in self.releases if inst.model == model]

if len(matches) > 1:
if not releases:
raise Exception(f"No releases found for {model=} with {status_filter=}")
elif len(releases) > 1:
_LOGGER.warning(
"more than a single match for model %s: %s, filtering with status=%s",
"%s versions found for model %s: %s, using the newest one",
len(releases),
model,
matches,
releases,
status_filter,
)

released_versions = [inst for inst in matches if inst.status == status_filter]
if not released_versions:
raise Exception(f"No releases for {model}, adjust status_filter?")

_LOGGER.debug("Got %s releases, picking the newest one", released_versions)
newest_release = max(releases, key=attrgetter("version"))
_LOGGER.debug("Using %s", newest_release)

match = max(released_versions, key=attrgetter("version"))
_LOGGER.debug("Using %s", match)

return match
return newest_release


class MiotCloud:
"""Interface for miotspec data."""

def __init__(self):
self._cache_dir = Path(appdirs.user_cache_dir("python-miio"))

def get_device_model(self, model: str) -> DeviceModel:
"""Get device model for model name."""
file = self._cache_dir / f"{model}.json"
if file.exists():
_LOGGER.debug("Using cached %s", file)
return DeviceModel.parse_raw(file.read_text())
spec = self._file_from_cache(file)
if spec is not None:
return DeviceModel.parse_obj(spec)

return DeviceModel.parse_raw(self.get_model_schema(model))
return DeviceModel.parse_obj(self.get_model_schema(model))

def get_model_schema(self, model: str) -> str:
def get_model_schema(self, model: str) -> Dict:
"""Get the preferred schema for the model."""
instances = self.fetch_release_list()
release_info = instances.info_for_model(model)
specs = self.get_release_list()
release_info = specs.info_for_model(model)

model_file = self._cache_dir / f"{release_info.model}.json"
url = f"https://miot-spec.org/miot-spec-v2/instance?type={release_info.type}"

data = self._fetch(url, model_file)
spec = self._file_from_cache(model_file)
if spec is not None:
return spec

return data
spec = MiotSpec.get_spec_for_urn(device_urn=release_info.type)
self._write_to_cache(model_file, spec)

def fetch_release_list(self):
"""Fetch a list of available schemas."""
mapping_file = "model-to-urn.json"
url = "http://miot-spec.org/miot-spec-v2/instances?status=all"
data = self._fetch(url, self._cache_dir / mapping_file)

return ReleaseList.parse_raw(data)
return spec

def _write_to_cache(self, file: Path, data: str):
def _write_to_cache(self, file: Path, data: Dict):
"""Write given *data* to cache file *file*."""
file.parent.mkdir(exist_ok=True)
written = file.write_text(data)
written = file.write_text(json.dumps(data))
_LOGGER.debug("Written %s bytes to %s", written, file)

def _fetch(self, url: str, target_file: Path, cache_hours=6):
"""Fetch the URL and cache results, if expired."""

def valid_cache():
def _file_from_cache(self, file, cache_hours=6) -> Optional[Dict]:
def _valid_cache():
expiration = timedelta(hours=cache_hours)
if (
datetime.fromtimestamp(target_file.stat().st_mtime) + expiration
datetime.fromtimestamp(file.stat().st_mtime) + expiration
> datetime.utcnow()
):
return True

return False

if target_file.exists() and valid_cache():
_LOGGER.debug("Returning data from cache: %s", target_file)
return target_file.read_text()
if file.exists() and _valid_cache():
_LOGGER.debug("Returning data from cache file %s", file)
return json.loads(file.read_text())

_LOGGER.debug("Cache file %s not found or it is stale", file)
return None

def get_release_list(self) -> ReleaseList:
"""Fetch a list of available releases."""
mapping_file = "model-to-urn.json"

_LOGGER.debug("Going to download %s to %s", url, target_file)
content = requests.get(url)
content.raise_for_status()
cache_file = self._cache_dir / mapping_file
mapping = self._file_from_cache(cache_file)
if mapping is not None:
return ReleaseList.parse_obj(mapping)

response = content.text
self._write_to_cache(target_file, response)
specs = MiotSpec.get_specs()
self._write_to_cache(cache_file, specs)

return response
return ReleaseList.parse_obj(specs)
22 changes: 11 additions & 11 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ appdirs = "^1"
tqdm = "^4"
netifaces = { version = "^0", optional = true }
android_backup = { version = "^0", optional = true }
micloud = { version = "*", optional = true }
micloud = { version = ">=0.6" }
croniter = ">=1"
defusedxml = "^0"
pydantic = "*"
Expand Down

0 comments on commit 03880f0

Please sign in to comment.