1
1
"""Define the MyQ API."""
2
+ import asyncio
2
3
import logging
4
+ from datetime import datetime , timedelta
5
+ from typing import Optional
3
6
4
- from aiohttp import BasicAuth , ClientSession
7
+ from aiohttp import ClientSession
5
8
from aiohttp .client_exceptions import ClientError
6
9
7
- from .device import MyQDevice
8
10
from .errors import MyQError , RequestError , UnsupportedBrandError
9
11
10
12
_LOGGER = logging .getLogger (__name__ )
13
15
LOGIN_ENDPOINT = "api/v4/User/Validate"
14
16
DEVICE_LIST_ENDPOINT = "api/v4/UserDeviceDetails/Get"
15
17
16
- DEFAULT_TIMEOUT = 10
18
+ DEFAULT_TIMEOUT = 1
19
+ DEFAULT_REQUEST_RETRIES = 3
20
+
21
+ MIN_TIME_BETWEEN_UPDATES = timedelta (seconds = 5 )
22
+
17
23
DEFAULT_USER_AGENT = "Chamberlain/3773 (iPhone; iOS 11.0.3; Scale/2.00)"
18
24
19
25
BRAND_MAPPINGS = {
@@ -52,9 +58,17 @@ def __init__(self, brand: str, websession: ClientSession) -> None:
52
58
raise UnsupportedBrandError ('Unknown brand: {0}' .format (brand ))
53
59
54
60
self ._brand = brand
55
- self ._security_token = None
56
61
self ._websession = websession
57
62
63
+ self ._credentials = None
64
+ self ._security_token = None
65
+ self ._devices = []
66
+ self ._last_update = None
67
+ self .online = False
68
+
69
+ self ._update_lock = asyncio .Lock ()
70
+ self ._security_token_lock = asyncio .Lock ()
71
+
58
72
async def _request (
59
73
self ,
60
74
method : str ,
@@ -64,8 +78,16 @@ async def _request(
64
78
params : dict = None ,
65
79
data : dict = None ,
66
80
json : dict = None ,
67
- ** kwargs ) -> dict :
68
- """Make a request."""
81
+ login_request : bool = False ,
82
+ ** kwargs ) -> Optional [dict ]:
83
+
84
+ # Get a security token if we do not have one AND this request
85
+ # is not to get a security token.
86
+ if self ._security_token is None and not login_request :
87
+ await self ._get_security_token ()
88
+ if self ._security_token is None :
89
+ return None
90
+
69
91
url = '{0}/{1}' .format (API_BASE , endpoint )
70
92
71
93
if not headers :
@@ -77,39 +99,150 @@ async def _request(
77
99
'User-Agent' : DEFAULT_USER_AGENT ,
78
100
})
79
101
102
+ start_request_time = datetime .time (datetime .now ())
103
+ _LOGGER .debug ('%s Initiating request to %s' , start_request_time , url )
104
+ timeout = DEFAULT_TIMEOUT
105
+ # Repeat twice amount of max requests retries for timeout errors.
106
+ for attempt in range (0 , (DEFAULT_REQUEST_RETRIES * 2 ) - 1 ):
107
+ try :
108
+ async with self ._websession .request (
109
+ method , url , headers = headers , params = params ,
110
+ data = data , json = json , timeout = timeout ,
111
+ ** kwargs ) as resp :
112
+ resp .raise_for_status ()
113
+ return await resp .json (content_type = None )
114
+ except asyncio .TimeoutError :
115
+ # Start increasing timeout if already tried twice..
116
+ if attempt > 1 :
117
+ timeout = timeout * 2
118
+ _LOGGER .debug ('%s Timeout requesting from %s' ,
119
+ start_request_time , endpoint )
120
+ except ClientError as err :
121
+ if attempt == DEFAULT_REQUEST_RETRIES - 1 :
122
+ raise RequestError ('{} Client Error while requesting '
123
+ 'data from {}: {}' .format (
124
+ start_request_time , endpoint ,
125
+ err ))
126
+
127
+ _LOGGER .warning ('%s Error requesting from %s; retrying: '
128
+ '%s' , start_request_time , endpoint , err )
129
+ await asyncio .sleep (5 )
130
+
131
+ raise RequestError ('{} Constant timeouts while requesting data '
132
+ 'from {}' .format (start_request_time , endpoint ))
133
+
134
+ async def _update_device_state (self ) -> None :
135
+ async with self ._update_lock :
136
+ if datetime .utcnow () - self ._last_update > \
137
+ MIN_TIME_BETWEEN_UPDATES :
138
+ self .online = await self ._get_device_states ()
139
+
140
+ async def _get_device_states (self ) -> bool :
141
+ _LOGGER .debug ('Retrieving new device states' )
80
142
try :
81
- async with self ._websession .request (
82
- method , url , headers = headers , params = params , data = data ,
83
- json = json , timeout = DEFAULT_TIMEOUT , ** kwargs ) as resp :
84
- resp .raise_for_status ()
85
- return await resp .json (content_type = None )
86
- except ClientError as err :
87
- raise RequestError (
88
- 'Error requesting data from {0}: {1}' .format (endpoint , err ))
143
+ devices_resp = await self ._request ('get' , DEVICE_LIST_ENDPOINT )
144
+ except RequestError as err :
145
+ _LOGGER .error ('Getting device states failed: %s' , err )
146
+ return False
147
+
148
+ if devices_resp is None :
149
+ return False
150
+
151
+ return_code = int (devices_resp .get ('ReturnCode' , 1 ))
152
+
153
+ if return_code != 0 :
154
+ if return_code == - 3333 :
155
+ # Login error, need to retrieve a new token next time.
156
+ self ._security_token = None
157
+ _LOGGER .debug ('Security token expired' )
158
+ else :
159
+ _LOGGER .error (
160
+ 'Error %s while retrieving states: %s' ,
161
+ devices_resp .get ('ReturnCode' ),
162
+ devices_resp .get ('ErrorMessage' , 'Unknown Error' ))
163
+ return False
164
+
165
+ self ._store_device_states (devices_resp .get ('Devices' , []))
166
+ _LOGGER .debug ('New device states retrieved' )
167
+ return True
168
+
169
+ def _store_device_states (self , devices : dict ) -> None :
170
+ for device in self ._devices :
171
+ myq_device = next (
172
+ (element for element in devices
173
+ if element .get ('MyQDeviceId' ) == device ['device_id' ]), None )
174
+
175
+ if myq_device is not None :
176
+ device ['device_info' ] = myq_device
177
+ continue
178
+
179
+ self ._last_update = datetime .utcnow ()
89
180
90
181
async def authenticate (self , username : str , password : str ) -> None :
91
182
"""Authenticate against the API."""
92
- login_resp = await self ._request (
93
- 'post' ,
94
- LOGIN_ENDPOINT ,
95
- json = {
96
- 'username' : username ,
97
- 'password' : password
98
- })
99
-
100
- if int (login_resp ['ReturnCode' ]) != 0 :
101
- raise MyQError (login_resp ['ErrorMessage' ])
102
-
103
- self ._security_token = login_resp ['SecurityToken' ]
183
+ self ._credentials = {
184
+ 'username' : username ,
185
+ 'password' : password ,
186
+ }
187
+
188
+ await self ._get_security_token ()
189
+
190
+ async def _get_security_token (self ) -> None :
191
+ """Request a security token."""
192
+ _LOGGER .debug ('Requesting security token.' )
193
+ if self ._credentials is None :
194
+ return
195
+
196
+ # Make sure only 1 request can be sent at a time.
197
+ async with self ._security_token_lock :
198
+ # Confirm there is still no security token.
199
+ if self ._security_token is None :
200
+ login_resp = await self ._request (
201
+ 'post' ,
202
+ LOGIN_ENDPOINT ,
203
+ json = self ._credentials ,
204
+ login_request = True ,
205
+ )
206
+
207
+ return_code = int (login_resp .get ('ReturnCode' , 1 ))
208
+ if return_code != 0 :
209
+ if return_code == 203 :
210
+ # Invalid username or password.
211
+ _LOGGER .debug ('Invalid username or password' )
212
+ self ._credentials = None
213
+ raise MyQError (login_resp ['ErrorMessage' ])
214
+
215
+ self ._security_token = login_resp ['SecurityToken' ]
104
216
105
217
async def get_devices (self , covers_only : bool = True ) -> list :
106
218
"""Get a list of all devices associated with the account."""
219
+ from .device import MyQDevice
220
+
221
+ _LOGGER .debug ('Retrieving list of devices' )
107
222
devices_resp = await self ._request ('get' , DEVICE_LIST_ENDPOINT )
108
- return [
109
- MyQDevice (device , self ._brand , self ._request )
110
- for device in devices_resp ['Devices' ] if not covers_only
111
- or device ['MyQDeviceTypeName' ] in SUPPORTED_DEVICE_TYPE_NAMES
112
- ]
223
+ # print(json.dumps(devices_resp, indent=4))
224
+
225
+ device_list = []
226
+ if devices_resp is None :
227
+ return device_list
228
+
229
+ for device in devices_resp ['Devices' ]:
230
+ if not covers_only or \
231
+ device ['MyQDeviceTypeName' ] in SUPPORTED_DEVICE_TYPE_NAMES :
232
+
233
+ self ._devices .append ({
234
+ 'device_id' : device ['MyQDeviceId' ],
235
+ 'device_info' : device
236
+ })
237
+ myq_device = MyQDevice (
238
+ self ._devices [- 1 ], self ._brand , self )
239
+ device_list .append (myq_device )
240
+
241
+ # Store current device states.
242
+ self ._store_device_states (devices_resp .get ('Devices' , []))
243
+
244
+ _LOGGER .debug ('List of devices retrieved' )
245
+ return device_list
113
246
114
247
115
248
async def login (
0 commit comments