From 56441f5c83badbe8a726c7bb259642c4b2b4c0a2 Mon Sep 17 00:00:00 2001 From: Sean Rees <sean@erifax.org> Date: Sun, 7 Mar 2021 15:38:33 +0000 Subject: [PATCH] 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] https://github.com/etheralm/libpurecool/issues/37 --- BUILD | 46 +++++++++++++++-- WORKSPACE | 35 ++++++++----- account.py | 83 +++++++++++++++++++++++++++++++ config.py | 98 +++++++++++++++++++++++++++++++++++++ config_test.py | 79 ++++++++++++++++++++++++++++++ libpurecool_adapter.py | 50 +++++++++++++++++++ libpurecool_adapter_test.py | 55 +++++++++++++++++++++ main.py | 96 ++++++++++-------------------------- mypy.ini | 10 ++++ requirements.txt | 3 +- 10 files changed, 469 insertions(+), 86 deletions(-) create mode 100644 account.py create mode 100644 config.py create mode 100644 config_test.py create mode 100644 libpurecool_adapter.py create mode 100644 libpurecool_adapter_test.py create mode 100644 mypy.ini diff --git a/BUILD b/BUILD index 8f77eb9..85e0b44 100644 --- a/BUILD +++ b/BUILD @@ -1,7 +1,45 @@ load("@rules_python//python:defs.bzl", "py_binary", "py_library") -load("@pip_deps//:requirements.bzl", "requirement") +load("@pip//:requirements.bzl", "requirement") load("@rules_pkg//:pkg.bzl", "pkg_tar", "pkg_deb") +py_library( + name = "account", + srcs = ["account.py"], + deps = [ + requirement("libdyson"), + ], +) + +py_library( + name = "config", + srcs = ["config.py"], +) + +py_test( + name = "config_test", + srcs = ["config_test.py"], + deps = [ + ":config", + ], +) + +py_library( + name = "libpurecool_adapter", + srcs = ["libpurecool_adapter.py"], + deps = [ + requirement("libpurecool"), + ], +) + +py_test( + name = "libpurecool_adapter_test", + srcs = ["libpurecool_adapter_test.py"], + deps = [ + ":libpurecool_adapter", + requirement("libpurecool"), + ], +) + py_library( name = "metrics", srcs = ["metrics.py"], @@ -24,8 +62,10 @@ py_binary( name = "main", srcs = ["main.py"], deps = [ + ":account", + ":config", + ":libpurecool_adapter", ":metrics", - requirement("libpurecool"), requirement("prometheus_client") ], ) @@ -85,5 +125,5 @@ pkg_deb( description_file = "debian/description", maintainer = "Sean Rees <sean at erifax.org>", package = "prometheus-dyson", - version = "0.0.2", + version = "0.1.0", ) diff --git a/WORKSPACE b/WORKSPACE index 30547ac..ae3753b 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -3,21 +3,34 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # Python rules. http_archive( name = "rules_python", - url = "https://github.com/bazelbuild/rules_python/releases/download/0.0.2/rules_python-0.0.2.tar.gz", - strip_prefix = "rules_python-0.0.2", - sha256 = "b5668cde8bb6e3515057ef465a35ad712214962f0b3a314e551204266c7be90c", + url = "https://github.com/bazelbuild/rules_python/releases/download/0.1.0/rules_python-0.1.0.tar.gz", + sha256 = "b6d46438523a3ec0f3cead544190ee13223a52f6a6765a29eae7b7cc24cc83a0", ) -load("@rules_python//python:pip.bzl", "pip_repositories") -pip_repositories() +load("@rules_python//python:pip.bzl", "pip_install") -load("@rules_python//python:pip.bzl", "pip3_import") -pip3_import( # or pip3_import - name = "pip_deps", - requirements = "//:requirements.txt", +pip_install( + # (Optional) You can provide extra parameters to pip. + # Here, make pip output verbose (this is usable with `quiet = False`). + #extra_pip_args = ["-v"], + + # (Optional) You can exclude custom elements in the data section of the generated BUILD files for pip packages. + # Exclude directories with spaces in their names in this example (avoids build errors if there are such directories). + #pip_data_exclude = ["**/* */**"], + + # (Optional) You can provide a python_interpreter (path) or a python_interpreter_target (a Bazel target, that + # acts as an executable). The latter can be anything that could be used as Python interpreter. E.g.: + # 1. Python interpreter that you compile in the build file (as above in @python_interpreter). + # 2. Pre-compiled python interpreter included with http_archive + # 3. Wrapper script, like in the autodetecting python toolchain. + #python_interpreter_target = "@python_interpreter//:python_bin", + + # (Optional) You can set quiet to False if you want to see pip output. + #quiet = False, + + # Uses the default repository name "pip" + requirements = "//:requirements.txt", ) -load("@pip_deps//:requirements.bzl", "pip_install") -pip_install() # Packaging rules. http_archive( diff --git a/account.py b/account.py new file mode 100644 index 0000000..3af4e85 --- /dev/null +++ b/account.py @@ -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()) diff --git a/config.py b/config.py new file mode 100644 index 0000000..252b90b --- /dev/null +++ b/config.py @@ -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 diff --git a/config_test.py b/config_test.py new file mode 100644 index 0000000..e9df496 --- /dev/null +++ b/config_test.py @@ -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() diff --git a/libpurecool_adapter.py b/libpurecool_adapter.py new file mode 100644 index 0000000..3e385f9 --- /dev/null +++ b/libpurecool_adapter.py @@ -0,0 +1,50 @@ +"""An adapter to use libpurecool's Dyson support without the Cloud API.""" + +import collections +import logging +from typing import Callable, Dict, List, Optional + +from libpurecool import dyson, dyson_device + + +class DysonAccountCache: + def __init__(self, device_cache: List[Dict[str, str]]): + self._devices = self._load(device_cache) + + def _identify(self, device: Dict[str, str]) -> Optional[Callable[[object], object]]: + if dyson.is_360_eye_device(device): + logging.info( + 'Identified %s as a Dyson 360 Eye device which is unsupported (ignoring)') + return None + elif dyson.is_heating_device(device): + logging.info( + 'Identified %s as a Dyson Pure Hot+Cool Link (V1) device', device['Serial']) + return dyson.DysonPureHotCoolLink + elif dyson.is_dyson_pure_cool_device(device): + logging.info( + 'Identified %s as a Dyson Pure Cool (V2) device', device['Serial']) + return dyson.DysonPureCool + elif dyson.is_heating_device_v2(device): + logging.info( + 'Identified %s as a Dyson Pure Hot+Cool (V2) device', device['Serial']) + return dyson.DysonPureHotCool + else: + logging.info( + 'Identified %s as a Dyson Pure Cool Link (V1) device', device['Serial']) + return dyson.DysonPureCoolLink + + def _load(self, device_cache: List[Dict[str, str]]): + ret = [] + + # Monkey-patch this as we store the local credential unencrypted. + dyson_device.decrypt_password = lambda s: s + + for d in device_cache: + typ = self._identify(d) + if typ: + ret.append(typ(d)) + + return ret + + def devices(self): + return self._devices diff --git a/libpurecool_adapter_test.py b/libpurecool_adapter_test.py new file mode 100644 index 0000000..23e49e7 --- /dev/null +++ b/libpurecool_adapter_test.py @@ -0,0 +1,55 @@ +"""Unit test for the libpurecool_adapter module.""" + +import configparser +import unittest + +import libpurecool_adapter + +from libpurecool import dyson, const + + +class TestLibpurecoolAdapter(unittest.TestCase): + def testIdentify(self): + def makeStub(p): return {'ProductType': p, 'Serial': 'serial'} + + c = configparser.ConfigParser() + c['360Eye'] = makeStub(const.DYSON_360_EYE) + c['CoolLinkV1'] = makeStub(const.DYSON_PURE_COOL_LINK_DESK) + c['CoolV2'] = makeStub(const.DYSON_PURE_COOL) + c['HotCoolLinkV1'] = makeStub(const.DYSON_PURE_HOT_COOL_LINK_TOUR) + c['HotCoolV2'] = makeStub(const.DYSON_PURE_HOT_COOL) + + ac = libpurecool_adapter.DysonAccountCache([]) + self.assertIsNone(ac._identify(c['360Eye'])) + self.assertEqual(ac._identify( + c['CoolLinkV1']), dyson.DysonPureCoolLink) + self.assertEqual(ac._identify(c['CoolV2']), dyson.DysonPureCool) + self.assertEqual(ac._identify( + c['HotCoolLinkV1']), dyson.DysonPureHotCoolLink) + self.assertEqual(ac._identify(c['HotCoolV2']), dyson.DysonPureHotCool) + + def testLoad(self): + devices = [ + {'Active': 'true', 'Name': 'first', 'Serial': 'AB1-US-12345678', 'Version': '1.0', + 'LocalCredentials': 'ABCD', 'AutoUpdate': 'true', 'NewVersionAvailable': 'true', + 'ProductType': '455'}, # 455 = Pure Hot+Cool Link (V1) + {'Active': 'true', 'Name': 'ignore', 'Serial': 'AB2-US-12345678', 'Version': '1.0', + 'LocalCredentials': 'ABCD', 'AutoUpdate': 'true', 'NewVersionAvailable': 'true', + 'ProductType': 'N223'}, # N223 = 360 Eye (we should skip this) + {'Active': 'true', 'Name': 'third', 'Serial': 'AB3-US-12345678', 'Version': '1.0', + 'LocalCredentials': 'ABCD', 'AutoUpdate': 'true', 'NewVersionAvailable': 'true', + 'ProductType': '438'} # 438 = Pure Cool (V2) + ] + + ac = libpurecool_adapter.DysonAccountCache(devices) + devices = ac.devices() + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0].name, 'first') + self.assertEqual(devices[1].name, 'third') + + ac = libpurecool_adapter.DysonAccountCache([]) + self.assertEqual(len(ac.devices()), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/main.py b/main.py index 4bfb533..b280d58 100755 --- a/main.py +++ b/main.py @@ -6,47 +6,28 @@ libpurecool pip install prometheus_client """ import argparse -import collections -import configparser import functools import logging import sys import time -from typing import Callable, Dict, Optional, Tuple +from typing import Callable, Dict, List, Optional -from libpurecool import dyson import prometheus_client # type: ignore[import] +import account +import config +import libpurecool_adapter from metrics import Metrics -DysonLinkCredentials = collections.namedtuple( - 'DysonLinkCredentials', ['username', 'password', 'country']) - class DysonClient: """Connects to and monitors Dyson fans.""" - def __init__(self, username, password, country, hosts: Optional[Dict] = None): - self.username = username - self.password = password - self.country = country + def __init__(self, device_cache: List[Dict[str, str]], hosts: Optional[Dict] = None): + self._account = libpurecool_adapter.DysonAccountCache(device_cache) self.hosts = hosts or {} - self._account = None - - def login(self) -> bool: - """Attempts a login to DysonLink, returns True on success (False - otherwise).""" - self._account = dyson.DysonAccount( - self.username, self.password, self.country) - if not self._account.login(): - logging.critical( - 'Could not login to Dyson with username %s', self.username) - return False - - return True - def monitor(self, update_fn: Callable[[str, str, object], None], only_active=True) -> None: """Sets up a background monitoring thread on each device. @@ -94,45 +75,6 @@ def _sleep_forever() -> None: break -def _read_config(filename) -> Tuple[Optional[DysonLinkCredentials], Dict]: - """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) - return None, {} - - try: - username = config['Dyson Link']['username'] - password = config['Dyson Link']['password'] - country = config['Dyson Link']['country'] - creds = DysonLinkCredentials(username, password, country) - except KeyError as ex: - logging.critical('Required key missing in "%s": %s', filename, ex) - return None, {} - - try: - hosts = config.items('Hosts') - except configparser.NoSectionError: - hosts = [] - logging.debug('No "Devices" 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) - host_dict = {h[0].upper(): h[1] for h in hosts} - - return creds, host_dict - - def main(argv): """Main body of the program.""" parser = argparse.ArgumentParser(prog=argv[0]) @@ -140,6 +82,8 @@ def main(argv): type=int, default=8091) parser.add_argument( '--config', help='Configuration file (INI file)', default='config.ini') + parser.add_argument('--create_device_cache', + help='Performs a one-time login to Dyson to locally cache device information. Use this for the first invocation of this binary or when you add/remove devices.', action='store_true') parser.add_argument( '--log_level', help='Logging level (DEBUG, INFO, WARNING, ERROR)', type=str, default='INFO') parser.add_argument( @@ -165,18 +109,28 @@ def main(argv): if args.include_inactive_devices: logging.info('Including devices marked "inactive" from the Dyson API') - credentials, hosts = _read_config(args.config) - if not credentials: + try: + cfg = config.Config(args.config) + except: + logging.exception('Could not load configuration: %s', args.config) sys.exit(-1) + devices = cfg.devices + if not len(devices): + logging.fatal( + 'No devices configured; please re-run this program with --create_device_cache.') + sys.exit(-2) + + if args.create_device_cache: + logging.info( + '--create_device_cache supplied; breaking out to perform this.') + account.generate_device_cache(cfg.dyson_credentials, args.config) + sys.exit(0) + metrics = Metrics() prometheus_client.start_http_server(args.port) - client = DysonClient(credentials.username, - credentials.password, credentials.country, hosts) - if not client.login(): - sys.exit(-1) - + client = DysonClient(devices, cfg.hosts) client.monitor( metrics.update, only_active=not args.include_inactive_devices) _sleep_forever() diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..c5481a5 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,10 @@ +[mypy] + +[mypy-libpurecool.*] +ignore_missing_imports = True + +[mypy-libdyson.*] +ignore_missing_imports = True + +[mypy-prometheus_client] +ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index b4e906f..b5b35e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -libpurecool prometheus_client +libpurecool +libdyson \ No newline at end of file -- GitLab