1
1
"""Define the MyQ API."""
2
2
import asyncio
3
3
import logging
4
+ from bs4 import BeautifulSoup
4
5
from datetime import datetime , timedelta
5
- from html .parser import HTMLParser
6
6
from typing import Dict , Optional , Union , Tuple
7
7
from urllib .parse import urlsplit , parse_qs
8
8
35
35
DEFAULT_TOKEN_REFRESH = 10 * 60 # 10 minutes
36
36
37
37
38
- class HTMLElementFinder (HTMLParser ):
39
- def __init__ (self , tag : str , return_attr : str , with_attr : (str , str ) = None ):
40
- self ._FindTag = tag # type: str
41
- self ._WithAttr = with_attr # type: Optional[(str, str)]
42
- self ._ReturnAttr = return_attr # type: str
43
- self ._Result = []
44
- HTMLParser .__init__ (self )
45
-
46
- @property
47
- def result (self ):
48
- return self ._Result
49
-
50
- def handle_starttag (self , tag , attrs ):
51
- if tag == self ._FindTag :
52
- store_attr = False
53
- if self ._WithAttr is None :
54
- store_attr = True
55
- else :
56
- for attr , value in attrs :
57
- if (attr , value ) == self ._WithAttr :
58
- store_attr = True
59
- break
60
-
61
- if store_attr :
62
- for attr , value in attrs :
63
- if attr == self ._ReturnAttr :
64
- self ._Result .append (value )
65
-
66
-
67
38
class API : # pylint: disable=too-many-instance-attributes
68
39
"""Define a class for interacting with the MyQ iOS App API."""
69
40
@@ -196,7 +167,9 @@ async def request(
196
167
if self ._authentication_task is not None :
197
168
authentication_task = await self .authenticate (wait = False )
198
169
if authentication_task .done ():
199
- _LOGGER .debug ("Scheduled token refresh completed, ensuring no exception." )
170
+ _LOGGER .debug (
171
+ "Scheduled token refresh completed, ensuring no exception."
172
+ )
200
173
self ._authentication_task = None
201
174
try :
202
175
# Get the result so any exception is raised.
@@ -283,7 +256,7 @@ async def request(
283
256
f"Error requesting data from { url } : { err .status } - { err .message } "
284
257
)
285
258
_LOGGER .debug (message )
286
- if getattr (err , ' status' ) and err .status == 401 :
259
+ if getattr (err , " status" ) and err .status == 401 :
287
260
# Received unauthorized, reset token and start task to get a new one.
288
261
self ._security_token = (None , None , self ._security_token [2 ])
289
262
await self .authenticate (wait = False )
@@ -292,9 +265,7 @@ async def request(
292
265
raise RequestError (message )
293
266
294
267
except ClientError as err :
295
- message = (
296
- f"Error requesting data from { url } : { str (err )} "
297
- )
268
+ message = f"Error requesting data from { url } : { str (err )} "
298
269
_LOGGER .debug (message )
299
270
raise RequestError (message )
300
271
@@ -303,7 +274,7 @@ async def _oauth_authenticate(self) -> (str, int):
303
274
async with ClientSession () as session :
304
275
# retrieve authentication page
305
276
_LOGGER .debug ("Retrieving authentication page" )
306
- resp , text = await self .request (
277
+ resp , html = await self .request (
307
278
method = "get" ,
308
279
returns = "text" ,
309
280
url = OAUTH_AUTHORIZE_URI ,
@@ -322,19 +293,64 @@ async def _oauth_authenticate(self) -> (str, int):
322
293
login_request = True ,
323
294
)
324
295
296
+ # Scanning returned web page for required fields.
297
+ _LOGGER .debug ("Scanning login page for fields to return" )
298
+ soup = BeautifulSoup (html , "html.parser" )
299
+
300
+ # Go through all potential forms in the page returned. This is in case multiple forms are returned.
301
+ forms = soup .find_all ("form" )
302
+ data = {}
303
+ for form in forms :
304
+ have_email = False
305
+ have_password = False
306
+ have_submit = False
307
+ # Go through all the input fields.
308
+ for field in form .find_all ("input" ):
309
+ if field .get ("type" ):
310
+ # Hidden value, include so we return back
311
+ if field .get ("type" ).lower () == "hidden" :
312
+ data .update (
313
+ {
314
+ field .get ("name" , "NONAME" ): field .get (
315
+ "value" , "NOVALUE"
316
+ )
317
+ }
318
+ )
319
+ # Email field
320
+ elif field .get ("type" ).lower () == "email" :
321
+ data .update ({field .get ("name" , "Email" ): self .username })
322
+ have_email = True
323
+ # Password field
324
+ elif field .get ("type" ).lower () == "password" :
325
+ data .update (
326
+ {
327
+ field .get (
328
+ "name" , "Password"
329
+ ): self .__credentials .get ("password" )
330
+ }
331
+ )
332
+ have_password = True
333
+ # To confirm this form also has a submit button
334
+ elif field .get ("type" ).lower () == "submit" :
335
+ have_submit = True
336
+
337
+ # Confirm we found email, password, and submit in the form to be submitted
338
+ if have_email and have_password and have_submit :
339
+ break
340
+
341
+ # If we're here then this is not the form to submit.
342
+ data = {}
343
+
344
+ # If data is empty then we did not find the valid form and are unable to continue.
345
+ if len (data ) == 0 :
346
+ _LOGGER .debug ("Form with required fields not found" )
347
+ raise RequestError (
348
+ "Form containing fields for email, password and submit not found."
349
+ "Unable to continue login process."
350
+ )
351
+
325
352
# Perform login to MyQ
326
353
_LOGGER .debug ("Performing login to MyQ" )
327
- parser = HTMLElementFinder (
328
- tag = "input" ,
329
- return_attr = "value" ,
330
- with_attr = ("name" , "__RequestVerificationToken" ),
331
- )
332
-
333
- # Verification token is within the returned page as <input name="__RequestVerificationToken" value=<token>>
334
- # Retrieve token from the page.
335
- parser .feed (text )
336
- request_verification_token = parser .result [0 ]
337
-
338
354
resp , _ = await self .request (
339
355
method = "post" ,
340
356
returns = "response" ,
@@ -343,22 +359,15 @@ async def _oauth_authenticate(self) -> (str, int):
343
359
headers = {
344
360
"Content-Type" : "application/x-www-form-urlencoded" ,
345
361
"Cookie" : resp .cookies .output (attrs = []),
346
- "User-Agent" : "null" ,
347
- },
348
- data = {
349
- "Email" : self .username ,
350
- "Password" : self .__credentials .get ("password" ),
351
- "__RequestVerificationToken" : request_verification_token ,
352
362
},
363
+ data = data ,
353
364
allow_redirects = False ,
354
365
login_request = True ,
355
366
)
356
367
357
368
# We're supposed to receive back at least 2 cookies. If not then authentication failed.
358
369
if len (resp .cookies ) < 2 :
359
- message = (
360
- "Invalid MyQ credentials provided. Please recheck login and password."
361
- )
370
+ message = "Invalid MyQ credentials provided. Please recheck login and password."
362
371
self ._invalid_credentials = True
363
372
_LOGGER .debug (message )
364
373
raise InvalidCredentialsError (message )
@@ -512,11 +521,15 @@ async def _get_devices_for_account(self, account) -> None:
512
521
for device in devices_resp .get ("items" ):
513
522
serial_number = device .get ("serial_number" )
514
523
if serial_number is None :
515
- _LOGGER .debug (f"No serial number for device with name { device .get ('name' )} ." )
524
+ _LOGGER .debug (
525
+ f"No serial number for device with name { device .get ('name' )} ."
526
+ )
516
527
continue
517
528
518
529
if serial_number in self .devices :
519
- _LOGGER .debug (f"Updating information for device with serial number { serial_number } " )
530
+ _LOGGER .debug (
531
+ f"Updating information for device with serial number { serial_number } "
532
+ )
520
533
myqdevice = self .devices [serial_number ]
521
534
522
535
# When performing commands we might update the state temporary, need to ensure
@@ -525,40 +538,53 @@ async def _get_devices_for_account(self, account) -> None:
525
538
last_update = myqdevice .device_json ["state" ].get ("last_update" )
526
539
myqdevice .device_json = device
527
540
528
- if myqdevice .device_json ["state" ].get ("last_update" ) is not None and \
529
- myqdevice .device_json ["state" ].get ("last_update" ) != last_update :
541
+ if (
542
+ myqdevice .device_json ["state" ].get ("last_update" ) is not None
543
+ and myqdevice .device_json ["state" ].get ("last_update" )
544
+ != last_update
545
+ ):
530
546
# MyQ has updated device state, reset ours ensuring we have the one from MyQ.
531
547
myqdevice .state = None
532
- _LOGGER .debug (f"State for device { myqdevice .name } was updated to { myqdevice .state } " )
548
+ _LOGGER .debug (
549
+ f"State for device { myqdevice .name } was updated to { myqdevice .state } "
550
+ )
533
551
534
552
myqdevice .state_update = state_update_timestmp
535
553
else :
536
554
if device .get ("device_family" ) == DEVICE_FAMILY_GARAGEDOOR :
537
- _LOGGER .debug (f"Adding new garage door with serial number { serial_number } " )
555
+ _LOGGER .debug (
556
+ f"Adding new garage door with serial number { serial_number } "
557
+ )
538
558
self .devices [serial_number ] = MyQGaragedoor (
539
559
api = self ,
540
560
account = account ,
541
561
device_json = device ,
542
562
state_update = state_update_timestmp ,
543
563
)
544
564
elif device .get ("device_family" ) == DEVICE_FAMLY_LAMP :
545
- _LOGGER .debug (f"Adding new lamp with serial number { serial_number } " )
565
+ _LOGGER .debug (
566
+ f"Adding new lamp with serial number { serial_number } "
567
+ )
546
568
self .devices [serial_number ] = MyQLamp (
547
569
api = self ,
548
570
account = account ,
549
571
device_json = device ,
550
572
state_update = state_update_timestmp ,
551
573
)
552
574
elif device .get ("device_family" ) == DEVICE_FAMILY_GATEWAY :
553
- _LOGGER .debug (f"Adding new gateway with serial number { serial_number } " )
575
+ _LOGGER .debug (
576
+ f"Adding new gateway with serial number { serial_number } "
577
+ )
554
578
self .devices [serial_number ] = MyQDevice (
555
579
api = self ,
556
580
account = account ,
557
581
device_json = device ,
558
582
state_update = state_update_timestmp ,
559
583
)
560
584
else :
561
- _LOGGER .warning (f"Unknown device family { device .get ('device_family' )} " )
585
+ _LOGGER .warning (
586
+ f"Unknown device family { device .get ('device_family' )} "
587
+ )
562
588
else :
563
589
_LOGGER .debug (f"No devices found for account { self .accounts [account ]} " )
564
590
@@ -597,10 +623,12 @@ async def update_device_info(self, for_account: str = None) -> None:
597
623
# Request is for specific account, thus restrict retrieval to the 1 account.
598
624
if self .accounts .get (for_account ) is None :
599
625
# Checking to ensure we know the account, but this should never happen.
600
- _LOGGER .debug (f"Unable to perform update request for account { for_account } as it is not known." )
626
+ _LOGGER .debug (
627
+ f"Unable to perform update request for account { for_account } as it is not known."
628
+ )
601
629
accounts = {}
602
630
else :
603
- accounts = ( {for_account : self .accounts .get (for_account )})
631
+ accounts = {for_account : self .accounts .get (for_account )}
604
632
605
633
for account in accounts :
606
634
await self ._get_devices_for_account (account = account )
@@ -619,7 +647,9 @@ async def login(username: str, password: str, websession: ClientSession = None)
619
647
try :
620
648
await api .authenticate (wait = True )
621
649
except InvalidCredentialsError as err :
622
- _LOGGER .error (f"Username and/or password are invalid. Update username/password." )
650
+ _LOGGER .error (
651
+ f"Username and/or password are invalid. Update username/password."
652
+ )
623
653
raise err
624
654
except AuthenticationError as err :
625
655
_LOGGER .error (f"Authentication failed: { str (err )} " )
0 commit comments