Skip to content

Commit 11098ee

Browse files
authored
Merge pull request #16 from ehendrix23/One-request-for-all
Retrieve all device states through 1 request
2 parents 0ace49e + ad206fe commit 11098ee

File tree

7 files changed

+362
-122
lines changed

7 files changed

+362
-122
lines changed

.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
.mypy_cache
22
Pipfile.lock
33
pymyq.egg-info
4+
.DS_Store
5+
.idea/
6+
__pycache__/
7+
venv/
8+

README.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,12 @@ asyncio.get_event_loop().run_until_complete(main())
6464
* `device_id`: the device's MyQ ID
6565
* `parent_id`: the device's parent device's MyQ ID
6666
* `name`: the name of the device
67+
* `available`: if device is online
6768
* `serial`: the serial number of the device
6869
* `state`: the device's current state
6970
* `type`: the type of MyQ device
71+
* `open_allowed`: if device can be opened unattended
72+
* `close_allowed`: if device can be closed unattended
7073

7174
## Methods
7275

@@ -76,7 +79,8 @@ All of the routines on the `MyQDevice` class are coroutines and need to be
7679
* `close`: close the device
7780
* `open`: open the device
7881
* `update`: get the latest device state (which can then be accessed via the
79-
`state` property)
82+
`state` property). Retrieval of state from cloud is will only be done if more then 5 seconds has elapsed since last
83+
request. State for all devices is retrieved through (1) request.
8084

8185
# Disclaimer
8286

example.py

+69-8
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,47 @@
11
"""Run an example script to quickly test any MyQ account."""
22
import asyncio
3+
import logging
4+
import json
35

46
from aiohttp import ClientSession
57

68
import pymyq
9+
from pymyq.device import STATE_CLOSED, STATE_OPEN
710
from pymyq.errors import MyQError
811

12+
# Provide your email and password account details for MyQ.
13+
MYQ_ACCOUNT_EMAIL = '<EMAIL>'
14+
MYQ_ACCOUNT_PASSWORD = '<PASSWORD>'
15+
16+
# BRAND can be one of the following:
17+
# liftmaster
18+
# chamberlain
19+
# craftsmaster
20+
# merlin
21+
MYQ_BRAND = '<BRAND>'
22+
LOGLEVEL = 'ERROR'
23+
24+
# Set JSON_DUMP to True to dump all the device information retrieved,
25+
# this can be helpful to determine what else is available.
26+
# Set JSON_DUMP to False to open/close the doors instead. i.e.:
27+
# JSON_DUMP = False
28+
JSON_DUMP = True
29+
930

1031
async def main() -> None:
1132
"""Create the aiohttp session and run the example."""
33+
34+
loglevels = dict((logging.getLevelName(level), level)
35+
for level in [10, 20, 30, 40, 50])
36+
37+
logging.basicConfig(
38+
level=loglevels[LOGLEVEL],
39+
format='%(asctime)s:%(levelname)s:\t%(name)s\t%(message)s')
40+
1241
async with ClientSession() as websession:
1342
try:
1443
myq = await pymyq.login(
15-
'<EMAIL>', '<PASSWORD>', '<BRAND>', websession)
44+
MYQ_ACCOUNT_EMAIL, MYQ_ACCOUNT_PASSWORD, MYQ_BRAND, websession)
1645

1746
devices = await myq.get_devices()
1847
for idx, device in enumerate(devices):
@@ -23,15 +52,47 @@ async def main() -> None:
2352
print('Serial: {0}'.format(device.serial))
2453
print('Device ID: {0}'.format(device.device_id))
2554
print('Parent ID: {0}'.format(device.parent_id))
26-
print('Current State: {0}'.format(device.state))
55+
print('Online: {0}'.format(device.available))
56+
print('Unattended Open: {0}'.format(device.open_allowed))
57+
print('Unattended Close: {0}'.format(device.close_allowed))
2758
print()
28-
print('Opening the device...')
29-
await device.open()
30-
print('Current State: {0}'.format(device.state))
31-
await asyncio.sleep(15)
32-
print('Closing the device...')
33-
await device.close()
3459
print('Current State: {0}'.format(device.state))
60+
if JSON_DUMP:
61+
print(json.dumps(device._device, indent=4))
62+
else:
63+
if device.state != STATE_OPEN:
64+
print('Opening the device...')
65+
await device.open()
66+
print(' 0 Current State: {0}'.format(device.state))
67+
for waited in range(1, 30):
68+
if device.state == STATE_OPEN:
69+
break
70+
await asyncio.sleep(1)
71+
await device.update()
72+
print(' {} Current State: {}'.format(
73+
waited, device.state))
74+
75+
await asyncio.sleep(10)
76+
await device.update()
77+
print()
78+
print('Current State: {0}'.format(device.state))
79+
80+
if device.state != STATE_CLOSED:
81+
print('Closing the device...')
82+
await device.close()
83+
print(' 0 Current State: {0}'.format(device.state))
84+
for waited in range(1, 30):
85+
if device.state == STATE_CLOSED:
86+
break
87+
await asyncio.sleep(1)
88+
await device.update()
89+
print(' {} Current State: {}'.format(
90+
waited, device.state))
91+
92+
await asyncio.sleep(10)
93+
await device.update()
94+
print()
95+
print('Current State: {0}'.format(device.state))
3596
except MyQError as err:
3697
print(err)
3798

pymyq/__version__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""Define a version constant."""
2-
__version__ = '1.0.0'
2+
__version__ = '1.1.0'

pymyq/api.py

+164-31
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Define the MyQ API."""
2+
import asyncio
23
import logging
4+
from datetime import datetime, timedelta
5+
from typing import Optional
36

4-
from aiohttp import BasicAuth, ClientSession
7+
from aiohttp import ClientSession
58
from aiohttp.client_exceptions import ClientError
69

7-
from .device import MyQDevice
810
from .errors import MyQError, RequestError, UnsupportedBrandError
911

1012
_LOGGER = logging.getLogger(__name__)
@@ -13,7 +15,11 @@
1315
LOGIN_ENDPOINT = "api/v4/User/Validate"
1416
DEVICE_LIST_ENDPOINT = "api/v4/UserDeviceDetails/Get"
1517

16-
DEFAULT_TIMEOUT = 10
18+
DEFAULT_TIMEOUT = 1
19+
DEFAULT_REQUEST_RETRIES = 3
20+
21+
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
22+
1723
DEFAULT_USER_AGENT = "Chamberlain/3773 (iPhone; iOS 11.0.3; Scale/2.00)"
1824

1925
BRAND_MAPPINGS = {
@@ -52,9 +58,17 @@ def __init__(self, brand: str, websession: ClientSession) -> None:
5258
raise UnsupportedBrandError('Unknown brand: {0}'.format(brand))
5359

5460
self._brand = brand
55-
self._security_token = None
5661
self._websession = websession
5762

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+
5872
async def _request(
5973
self,
6074
method: str,
@@ -64,8 +78,16 @@ async def _request(
6478
params: dict = None,
6579
data: dict = None,
6680
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+
6991
url = '{0}/{1}'.format(API_BASE, endpoint)
7092

7193
if not headers:
@@ -77,39 +99,150 @@ async def _request(
7799
'User-Agent': DEFAULT_USER_AGENT,
78100
})
79101

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')
80142
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()
89180

90181
async def authenticate(self, username: str, password: str) -> None:
91182
"""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']
104216

105217
async def get_devices(self, covers_only: bool = True) -> list:
106218
"""Get a list of all devices associated with the account."""
219+
from .device import MyQDevice
220+
221+
_LOGGER.debug('Retrieving list of devices')
107222
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
113246

114247

115248
async def login(

0 commit comments

Comments
 (0)