-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Begin transition from libpurecool to libdyson
Recently Dyson changed their API which broke libpurecool[1]'s integration. This resulted in prometheus-dyson being unable to enumerate devices via Dyson, and thus fail to restart successfully. libdyson refactors libpurecool with a clearer separation between the online Dyson API & the device-interaction logic. This allows us to perform a one-time login to Dyson and cache device information locally, removing the need for repeated logins to Dyson. libdyson also has a more consistent API between different models. This change starts the transition by introducing login component (account.py) and an adapter (libpurecool_adapter) to use the cached information with libpurecool. This also adds a flag (--create_device_cache) to perform the login&OTP dance with Dyson and generate the needed configuration. [1] etheralm/libpurecool#37
- Loading branch information
Showing
10 changed files
with
469 additions
and
86 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
"""Implements device-lookup via libdyson to produce a local credential cache. | ||
This is based heavily on shenxn@'s implementation of get_devices.py: | ||
https://github.com/shenxn/libdyson/blob/main/get_devices.py | ||
""" | ||
|
||
import io | ||
import configparser | ||
import sys | ||
|
||
from typing import List | ||
|
||
from config import DysonLinkCredentials | ||
|
||
from libdyson.cloud import DysonAccount, DysonDeviceInfo | ||
from libdyson.cloud.account import DysonAccountCN | ||
from libdyson.exceptions import DysonOTPTooFrequently | ||
|
||
|
||
def _query_dyson(username: str, password: str, country: str) -> List[DysonDeviceInfo]: | ||
"""Queries Dyson's APIs for a device list. | ||
This function requires user interaction, to check either their mobile or email | ||
for a one-time password. | ||
Args: | ||
username: email address or mobile number (mobile if country is CN) | ||
password: login password | ||
country: two-letter country code for account, e.g; IE, CN | ||
Returns: | ||
list of DysonDeviceInfo | ||
""" | ||
if country == 'CN': | ||
# Treat username like a phone number and use login_mobile_otp. | ||
account = DysonAccountCN() | ||
if not username.startswith('+86'): | ||
username = '+86' + username | ||
|
||
print(f'Using Mobile OTP with {username}') | ||
print(f'Please check your mobile device for a one-time password.') | ||
verify = account.login_mobile_otp(username) | ||
else: | ||
account = DysonAccount() | ||
verify = account.login_email_otp(username, country) | ||
print(f'Using Email OTP with {username}') | ||
print(f'Please check your email for a one-time password.') | ||
|
||
print() | ||
otp = input('Enter OTP: ') | ||
verify(otp, password) | ||
|
||
return account.devices() | ||
|
||
|
||
def generate_device_cache(creds: DysonLinkCredentials, config: str) -> None: | ||
try: | ||
devices = _query_dyson(creds.username, creds.password, creds.country) | ||
except DysonOTPTooFrequently: | ||
print('DysonOTPTooFrequently: too many OTP attempts, please wait and try again') | ||
return | ||
|
||
cfg = configparser.ConfigParser() | ||
|
||
print(f'Found {len(devices)} devices.') | ||
|
||
for d in devices: | ||
cfg[d.serial] = { | ||
'Active': 'true' if d.active else 'false', | ||
'Name': d.name, | ||
'Version': d.version, | ||
'LocalCredentials': d.credential, | ||
'AutoUpdate': 'true' if d.auto_update else 'false', | ||
'NewVersionAvailable': 'true' if d.new_version_available else 'false', | ||
'ProductType': d.product_type | ||
} | ||
|
||
buf = io.StringIO() | ||
cfg.write(buf) | ||
|
||
print('') | ||
print(f'Add the following to your configuration ({config}):') | ||
print(buf.getvalue()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
"""Manages configuration file.""" | ||
|
||
import collections | ||
import configparser | ||
import copy | ||
import logging | ||
from typing import Dict, List, Optional | ||
|
||
DysonLinkCredentials = collections.namedtuple( | ||
'DysonLinkCredentials', ['username', 'password', 'country']) | ||
|
||
|
||
class Config: | ||
def __init__(self, filename: str): | ||
self._filename = filename | ||
self._config = self.load(filename) | ||
|
||
def load(self, filename: str): | ||
"""Reads configuration file. | ||
Returns DysonLinkCredentials or None on error, and a dict of | ||
configured device serial numbers mapping to IP addresses | ||
""" | ||
config = configparser.ConfigParser() | ||
|
||
logging.info('Reading "%s"', filename) | ||
|
||
try: | ||
config.read(filename) | ||
except configparser.Error as ex: | ||
logging.critical('Could not read "%s": %s', filename, ex) | ||
raise ex | ||
|
||
return config | ||
|
||
@property | ||
def dyson_credentials(self) -> Optional[DysonLinkCredentials]: | ||
try: | ||
username = self._config['Dyson Link']['username'] | ||
password = self._config['Dyson Link']['password'] | ||
country = self._config['Dyson Link']['country'] | ||
return DysonLinkCredentials(username, password, country) | ||
except KeyError as ex: | ||
logging.critical( | ||
'Required key missing in "%s": %s', self._filename, ex) | ||
return None | ||
|
||
@property | ||
def hosts(self) -> Dict[str, str]: | ||
"""Loads the Hosts section, which is a serial -> IP address override. | ||
This is useful if you don't want to discover devices using zeroconf. The Hosts section | ||
looks like this: | ||
[Hosts] | ||
AB1-UK-AAA0111A = 192.168.1.2 | ||
""" | ||
try: | ||
hosts = self._config.items('Hosts') | ||
except configparser.NoSectionError: | ||
hosts = [] | ||
logging.debug( | ||
'No "Hosts" section found in config file, no manual IP overrides are available') | ||
|
||
# Convert the hosts tuple (('serial0', 'ip0'), ('serial1', 'ip1')) | ||
# into a dict {'SERIAL0': 'ip0', 'SERIAL1': 'ip1'}, making sure that | ||
# the serial keys are upper case (configparser downcases everything) | ||
return {h[0].upper(): h[1] for h in hosts} | ||
|
||
@property | ||
def devices(self) -> List[object]: | ||
"""Consumes all sections looking for device entries. | ||
A device looks a bit like this: | ||
[AB1-UK-AAA0111A] | ||
name = Living room | ||
active = true | ||
localcredentials = 12345== | ||
serial = AB1-UK-AAA0111A | ||
... (and a few other fields) | ||
Returns: | ||
A list of dict-like objects. This interface is unstable; do not rely on it. | ||
""" | ||
sections = self._config.sections() | ||
|
||
ret = [] | ||
for s in sections: | ||
if not self._config.has_option(s, 'LocalCredentials'): | ||
# This is probably not a device entry, so ignore it. | ||
continue | ||
|
||
# configparser returns a dict-like type here with case-insensitive keys. This is an effective | ||
# stand-in for the type that libpurecool expects, and a straightforward to thing to change | ||
# as we move towards libdyson's API. | ||
ret.append(copy.deepcopy(self._config[s])) | ||
|
||
return ret |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
"""Unit test for the config module.""" | ||
|
||
import tempfile | ||
import unittest | ||
|
||
import config | ||
|
||
empty = '' | ||
good = """ | ||
[Dyson Link] | ||
username = Username | ||
password = Password | ||
country = IE | ||
[Hosts] | ||
ABC-UK-12345678 = 1.2.3.4 | ||
[ABC-UK-12345678] | ||
active = true | ||
name = Living room | ||
serial = ABC-UK-12345678 | ||
version = 21.04.03 | ||
localcredentials = A_Random_String== | ||
autoupdate = True | ||
newversionavailable = True | ||
producttype = 455 | ||
[XYZ-UK-12345678] | ||
active = true | ||
name = Bedroom | ||
serial = XYZ-UK-12345678 | ||
version = 21.04.03 | ||
localcredentials = A_Random_String== | ||
autoupdate = True | ||
newversionavailable = True | ||
producttype = 455 | ||
""" | ||
|
||
|
||
class TestConfig(unittest.TestCase): | ||
def setUp(self): | ||
self._empty_file = self.createTemporaryFile(empty) | ||
self.empty = config.Config(self._empty_file.name) | ||
|
||
self._good_file = self.createTemporaryFile(good) | ||
self.good = config.Config(self._good_file.name) | ||
|
||
def tearDown(self): | ||
self._empty_file.close() | ||
self._good_file.close() | ||
|
||
def createTemporaryFile(self, contents: str): | ||
ret = tempfile.NamedTemporaryFile() | ||
ret.write(contents.encode('utf-8')) | ||
ret.flush() | ||
return ret | ||
|
||
def testDysonCredentials(self): | ||
self.assertIsNone(self.empty.dyson_credentials) | ||
|
||
c = self.good.dyson_credentials | ||
self.assertEqual(c.username, 'Username') | ||
self.assertEqual(c.password, 'Password') | ||
self.assertEqual(c.country, 'IE') | ||
|
||
def testHosts(self): | ||
self.assertTrue(not self.empty.hosts) | ||
self.assertEqual(self.good.hosts['ABC-UK-12345678'], '1.2.3.4') | ||
|
||
def testDevices(self): | ||
self.assertEqual(len(self.empty.devices), 0) | ||
self.assertEqual(len(self.good.devices), 2) | ||
|
||
self.assertEqual(self.good.devices[0]['name'], 'Living room') | ||
self.assertEqual(self.good.devices[1]['Name'], 'Bedroom') | ||
|
||
|
||
if __name__ == '__main__': | ||
unittest.main() |
Oops, something went wrong.