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

Add interface to obtain miot schemas #1578

Merged
merged 1 commit into from
Nov 7, 2022
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
15 changes: 12 additions & 3 deletions miio/devtools/simulators/miotsimulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pydantic import Field, validator

from miio import PushServer
from miio.miot_cloud import MiotCloud
from miio.miot_models import DeviceModel, MiotProperty, MiotService

from .common import create_info_response, mac_from_model
Expand Down Expand Up @@ -111,10 +112,12 @@ def __init__(self, device_model):
def initialize_state(self):
"""Create initial state for the device."""
for serv in self._model.services:
_LOGGER.debug("Found service: %s", serv)
for act in serv.actions:
_LOGGER.debug("Found action: %s", act)
for prop in serv.properties:
self._state[serv.siid][prop.piid] = prop
_LOGGER.debug("Found property: %s", prop)

def get_properties(self, payload):
"""Handle get_properties method."""
Expand Down Expand Up @@ -202,12 +205,18 @@ async def main(dev, model):


@click.command()
@click.option("--file", type=click.File("r"), required=True)
@click.option("--file", type=click.File("r"), required=False)
@click.option("--model", type=str, required=True, default=None)
def miot_simulator(file, model):
"""Simulate miot device."""
data = file.read()
dev = SimulatedDeviceModel.parse_raw(data)
if file is not None:
data = file.read()
dev = SimulatedDeviceModel.parse_raw(data)
else:
cloud = MiotCloud()
# TODO: fix HACK
dev = SimulatedDeviceModel.parse_raw(cloud.get_model_schema(model))

loop = asyncio.get_event_loop()
random.seed(1) # nosec
loop.run_until_complete(main(dev, model=model))
Expand Down
112 changes: 112 additions & 0 deletions miio/miot_cloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Module implementing handling of miot schema files."""
import logging
from datetime import datetime, timedelta
from operator import attrgetter
from pathlib import Path
from typing import List

import appdirs
import requests # TODO: externalize HTTP requests to avoid direct dependency
from pydantic import BaseModel

from miio.miot_models import DeviceModel

_LOGGER = logging.getLogger(__name__)


class ReleaseInfo(BaseModel):
model: str
status: str
type: str
version: int

@property
def filename(self) -> str:
return f"{self.model}_{self.status}_{self.version}.json"


class ReleaseList(BaseModel):
instances: List[ReleaseInfo]

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

if len(matches) > 1:
_LOGGER.warning(
"more than a single match for model %s: %s, filtering with status=%s",
model,
matches,
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)

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

return match


class MiotCloud:
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())

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

def get_model_schema(self, model: str) -> str:
"""Get the preferred schema for the model."""
instances = self.fetch_release_list()
release_info = instances.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)

return data

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)

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

def valid_cache():
expiration = timedelta(hours=cache_hours)
if (
datetime.fromtimestamp(target_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()

_LOGGER.debug("Going to download %s to %s", url, target_file)
content = requests.get(url)
content.raise_for_status()

response = content.text
written = target_file.write_text(response)
_LOGGER.debug("Written %s bytes to %s", written, target_file)

return response