11"""Module implementing handling of miot schema files."""
2+ import json
23import logging
34from datetime import datetime , timedelta
45from operator import attrgetter
56from pathlib import Path
6- from typing import List
7+ from typing import Dict , List , Optional
78
89import appdirs
9- import requests # TODO: externalize HTTP requests to avoid direct dependency
10- from pydantic import BaseModel
10+ from micloud import MiotSpec
11+ from pydantic import BaseModel , Field
1112
1213from miio .miot_models import DeviceModel
1314
1415_LOGGER = logging .getLogger (__name__ )
1516
1617
1718class ReleaseInfo (BaseModel ):
19+ """Information about individual miotspec release."""
20+
1821 model : str
19- status : str
22+ status : Optional [ str ] # only available on full listing
2023 type : str
2124 version : int
2225
@@ -26,87 +29,87 @@ def filename(self) -> str:
2629
2730
2831class ReleaseList (BaseModel ):
29- instances : List [ReleaseInfo ]
32+ """Model for miotspec release list."""
33+
34+ releases : List [ReleaseInfo ] = Field (alias = "instances" )
3035
3136 def info_for_model (self , model : str , * , status_filter = "released" ) -> ReleaseInfo :
32- matches = [inst for inst in self .instances if inst .model == model ]
37+ releases = [inst for inst in self .releases if inst .model == model ]
3338
34- if len (matches ) > 1 :
39+ if not releases :
40+ raise Exception (f"No releases found for { model = } with { status_filter = } " )
41+ elif len (releases ) > 1 :
3542 _LOGGER .warning (
36- "more than a single match for model %s: %s, filtering with status=%s" ,
43+ "%s versions found for model %s: %s, using the newest one" ,
44+ len (releases ),
3745 model ,
38- matches ,
46+ releases ,
3947 status_filter ,
4048 )
4149
42- released_versions = [inst for inst in matches if inst .status == status_filter ]
43- if not released_versions :
44- raise Exception (f"No releases for { model } , adjust status_filter?" )
45-
46- _LOGGER .debug ("Got %s releases, picking the newest one" , released_versions )
50+ newest_release = max (releases , key = attrgetter ("version" ))
51+ _LOGGER .debug ("Using %s" , newest_release )
4752
48- match = max (released_versions , key = attrgetter ("version" ))
49- _LOGGER .debug ("Using %s" , match )
50-
51- return match
53+ return newest_release
5254
5355
5456class MiotCloud :
57+ """Interface for miotspec data."""
58+
5559 def __init__ (self ):
5660 self ._cache_dir = Path (appdirs .user_cache_dir ("python-miio" ))
5761
5862 def get_device_model (self , model : str ) -> DeviceModel :
5963 """Get device model for model name."""
6064 file = self ._cache_dir / f"{ model } .json"
61- if file . exists ():
62- _LOGGER . debug ( "Using cached %s" , file )
63- return DeviceModel .parse_raw ( file . read_text () )
65+ spec = self . file_from_cache ( file )
66+ if spec is not None :
67+ return DeviceModel .parse_obj ( spec )
6468
65- return DeviceModel .parse_raw (self .get_model_schema (model ))
69+ return DeviceModel .parse_obj (self .get_model_schema (model ))
6670
67- def get_model_schema (self , model : str ) -> str :
71+ def get_model_schema (self , model : str ) -> Dict :
6872 """Get the preferred schema for the model."""
69- instances = self .fetch_release_list ()
70- release_info = instances .info_for_model (model )
73+ specs = self .get_release_list ()
74+ release_info = specs .info_for_model (model )
7175
7276 model_file = self ._cache_dir / f"{ release_info .model } .json"
73- url = f"https://miot-spec.org/miot-spec-v2/instance?type= { release_info . type } "
74-
75- data = self . _fetch ( url , model_file )
77+ spec = self . file_from_cache ( model_file )
78+ if spec is not None :
79+ return spec
7680
77- return data
81+ spec = json . loads ( MiotSpec . get_spec_for_urn ( device_urn = release_info . type ))
7882
79- def fetch_release_list (self ):
80- """Fetch a list of available schemas."""
81- mapping_file = "model-to-urn.json"
82- url = "http://miot-spec.org/miot-spec-v2/instances?status=all"
83- data = self ._fetch (url , self ._cache_dir / mapping_file )
84-
85- return ReleaseList .parse_raw (data )
86-
87- def _fetch (self , url : str , target_file : Path , cache_hours = 6 ):
88- """Fetch the URL and cache results, if expired."""
83+ return spec
8984
90- def valid_cache ():
85+ def file_from_cache (self , file , cache_hours = 6 ) -> Optional [Dict ]:
86+ def _valid_cache ():
9187 expiration = timedelta (hours = cache_hours )
9288 if (
93- datetime .fromtimestamp (target_file .stat ().st_mtime ) + expiration
89+ datetime .fromtimestamp (file .stat ().st_mtime ) + expiration
9490 > datetime .utcnow ()
9591 ):
9692 return True
9793
9894 return False
9995
100- if target_file .exists () and valid_cache ():
101- _LOGGER .debug ("Returning data from cache: %s" , target_file )
102- return target_file .read_text ()
96+ if file .exists () and _valid_cache ():
97+ _LOGGER .debug ("Returning data from cache file %s" , file )
98+ return json .loads (file .read_text ())
99+
100+ _LOGGER .debug ("Cache file %s not found or it is stale" , file )
101+ return None
102+
103+ def get_release_list (self ) -> ReleaseList :
104+ """Fetch a list of available releases."""
105+ mapping_file = "model-to-urn.json"
103106
104- _LOGGER .debug ("Going to download %s to %s" , url , target_file )
105- content = requests .get (url )
106- content .raise_for_status ()
107+ cache_file = self ._cache_dir / mapping_file
108+ mapping = self .file_from_cache (cache_file )
109+ if mapping is not None :
110+ return ReleaseList .parse_obj (mapping )
107111
108- response = content .text
109- written = target_file .write_text (response )
110- _LOGGER .debug ("Written %s bytes to %s" , written , target_file )
112+ specs = MiotSpec .get_specs ()
113+ cache_file .write_text (json .dumps (specs ))
111114
112- return response
115+ return ReleaseList . parse_obj ( specs )
0 commit comments