From 56441f5c83badbe8a726c7bb259642c4b2b4c0a2 Mon Sep 17 00:00:00 2001 From: Sean Rees Date: Sun, 7 Mar 2021 15:38:33 +0000 Subject: [PATCH 1/3] 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 ", 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 @@ """ 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 From 792f39c648fe1d2b6da1e52b4b581d51590bf0dd Mon Sep 17 00:00:00 2001 From: Sean Rees Date: Sun, 14 Mar 2021 17:39:15 +0000 Subject: [PATCH 2/3] Switch to libdyson-based discovery This is the second part of a three-part change to refactor this code from libpurecool to libdyson. This part replaces the discovery components (e.g; DysonDevice.auto_connect in libpurecool) to the separate API provided by libdyson. This also adapts the libpurecool_adapter shim to have a libdyson-like get_device() method to make switching fully over easier in the next change. --- BUILD | 38 +++++----- config.py | 40 +++++++--- config_test.py | 23 +++--- libpurecool_adapter.py | 83 ++++++++++---------- libpurecool_adapter_test.py | 62 +++++---------- main.py | 146 ++++++++++++++++++++++++------------ 6 files changed, 219 insertions(+), 173 deletions(-) diff --git a/BUILD b/BUILD index 85e0b44..95db543 100644 --- a/BUILD +++ b/BUILD @@ -1,6 +1,6 @@ load("@rules_python//python:defs.bzl", "py_binary", "py_library") load("@pip//:requirements.bzl", "requirement") -load("@rules_pkg//:pkg.bzl", "pkg_tar", "pkg_deb") +load("@rules_pkg//:pkg.bzl", "pkg_deb", "pkg_tar") py_library( name = "account", @@ -45,7 +45,7 @@ py_library( srcs = ["metrics.py"], deps = [ requirement("libpurecool"), - requirement("prometheus_client") + requirement("prometheus_client"), ], ) @@ -55,9 +55,10 @@ py_test( deps = [ ":metrics", requirement("libpurecool"), - requirement("prometheus_client") + requirement("prometheus_client"), ], ) + py_binary( name = "main", srcs = ["main.py"], @@ -66,49 +67,50 @@ py_binary( ":config", ":libpurecool_adapter", ":metrics", - requirement("prometheus_client") + requirement("prometheus_client"), + requirement("libdyson"), ], ) pkg_tar( name = "deb-bin", - package_dir = "/opt/prometheus-dyson/bin", # This depends on --build_python_zip. srcs = [":main"], mode = "0755", + package_dir = "/opt/prometheus-dyson/bin", ) pkg_tar( name = "deb-config-sample", - package_dir = "/etc/prometheus-dyson", srcs = ["config-sample.ini"], mode = "0644", + package_dir = "/etc/prometheus-dyson", ) pkg_tar( name = "deb-default", - package_dir = "/etc/default", srcs = ["debian/prometheus-dyson"], mode = "0644", - strip_prefix = "debian/" + package_dir = "/etc/default", + strip_prefix = "debian/", ) pkg_tar( name = "deb-service", - package_dir = "/lib/systemd/system", srcs = ["debian/prometheus-dyson.service"], mode = "0644", - strip_prefix = "debian/" + package_dir = "/lib/systemd/system", + strip_prefix = "debian/", ) pkg_tar( name = "debian-data", deps = [ - ":deb-bin", - ":deb-config-sample", - ":deb-default", - ":deb-service", - ] + ":deb-bin", + ":deb-config-sample", + ":deb-default", + ":deb-service", + ], ) pkg_deb( @@ -120,10 +122,10 @@ pkg_deb( depends = [ "python3", ], - prerm = "debian/prerm", - postrm = "debian/postrm", description_file = "debian/description", maintainer = "Sean Rees ", package = "prometheus-dyson", - version = "0.1.0", + postrm = "debian/postrm", + prerm = "debian/prerm", + version = "0.1.1", ) diff --git a/config.py b/config.py index 252b90b..3dee251 100644 --- a/config.py +++ b/config.py @@ -2,20 +2,28 @@ import collections import configparser -import copy import logging from typing import Dict, List, Optional +Device = collections.namedtuple( + 'Device', ['name', 'serial', 'credentials', 'product_type']) + DysonLinkCredentials = collections.namedtuple( 'DysonLinkCredentials', ['username', 'password', 'country']) class Config: + """Reads the configuration file and provides handy accessors. + + Args: + filename: path (absolute or relative) to the config file (ini format). + """ def __init__(self, filename: str): self._filename = filename self._config = self.load(filename) - def load(self, filename: str): + @classmethod + def load(cls, filename: str): """Reads configuration file. Returns DysonLinkCredentials or None on error, and a dict of @@ -35,6 +43,17 @@ def load(self, filename: str): @property def dyson_credentials(self) -> Optional[DysonLinkCredentials]: + """Cloud Dyson API credentials. + + In the config, this looks like: + [Dyson Link] + username = user + password = pass + country = XX + + Returns: + DysonLinkCredentials. + """ try: username = self._config['Dyson Link']['username'] password = self._config['Dyson Link']['password'] @@ -68,7 +87,7 @@ def hosts(self) -> Dict[str, str]: return {h[0].upper(): h[1] for h in hosts} @property - def devices(self) -> List[object]: + def devices(self) -> List[Device]: """Consumes all sections looking for device entries. A device looks a bit like this: @@ -80,19 +99,20 @@ def devices(self) -> List[object]: ... (and a few other fields) Returns: - A list of dict-like objects. This interface is unstable; do not rely on it. + A list of Device objects. """ sections = self._config.sections() ret = [] - for s in sections: - if not self._config.has_option(s, 'LocalCredentials'): + for sect in sections: + if not self._config.has_option(sect, '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])) + ret.append(Device( + self._config[sect]['Name'], + self._config[sect]['Serial'], + self._config[sect]['LocalCredentials'], + self._config[sect]['ProductType'])) return ret diff --git a/config_test.py b/config_test.py index e9df496..0b74732 100644 --- a/config_test.py +++ b/config_test.py @@ -5,8 +5,8 @@ import config -empty = '' -good = """ +EMPTY = '' +GOOD = """ [Dyson Link] username = Username password = Password @@ -39,17 +39,18 @@ class TestConfig(unittest.TestCase): def setUp(self): - self._empty_file = self.createTemporaryFile(empty) + self._empty_file = self.create_temporary_file(EMPTY) self.empty = config.Config(self._empty_file.name) - self._good_file = self.createTemporaryFile(good) + self._good_file = self.create_temporary_file(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): + @classmethod + def create_temporary_file(cls, contents: str): ret = tempfile.NamedTemporaryFile() ret.write(contents.encode('utf-8')) ret.flush() @@ -58,10 +59,10 @@ def createTemporaryFile(self, contents: str): 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') + creds = self.good.dyson_credentials + self.assertEqual(creds.username, 'Username') + self.assertEqual(creds.password, 'Password') + self.assertEqual(creds.country, 'IE') def testHosts(self): self.assertTrue(not self.empty.hosts) @@ -71,8 +72,8 @@ 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') + self.assertEqual(self.good.devices[0].name, 'Living room') + self.assertEqual(self.good.devices[1].name, 'Bedroom') if __name__ == '__main__': diff --git a/libpurecool_adapter.py b/libpurecool_adapter.py index 3e385f9..509dcef 100644 --- a/libpurecool_adapter.py +++ b/libpurecool_adapter.py @@ -1,50 +1,47 @@ """An adapter to use libpurecool's Dyson support without the Cloud API.""" -import collections import logging -from typing import Callable, Dict, List, Optional +from typing import 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 +# We expect unencrypted credentials only, so monkey-patch this. +dyson_device.decrypt_password = lambda s: s + + +def get_device(name: str, serial: str, credentials: str, product_type: str) -> Optional[object]: + """Creates a libpurecool DysonDevice based on the input parameters. + + Args: + name: name of device (e.g; "Living room") + serial: serial number, e.g; AB1-XX-1234ABCD + credentials: unencrypted credentials for accessing the device locally + product_type: stringified int for the product type (e.g; "455") + """ + device = {'Serial': serial, 'Name': name, + 'LocalCredentials': credentials, 'ProductType': product_type, + 'Version': '', 'AutoUpdate': '', 'NewVersionAvailable': ''} + + if dyson.is_360_eye_device(device): + logging.info( + 'Identified %s as a Dyson 360 Eye device which is unsupported (ignoring)') + return None + + if dyson.is_heating_device(device): + logging.info( + 'Identified %s as a Dyson Pure Hot+Cool Link (V1) device', serial) + return dyson.DysonPureHotCoolLink(device) + if dyson.is_dyson_pure_cool_device(device): + logging.info( + 'Identified %s as a Dyson Pure Cool (V2) device', serial) + return dyson.DysonPureCool(device) + + if dyson.is_heating_device_v2(device): + logging.info( + 'Identified %s as a Dyson Pure Hot+Cool (V2) device',serial) + return dyson.DysonPureHotCool(device) + + # Last chance. + logging.info('Identified %s as a Dyson Pure Cool Link (V1) device', serial) + return dyson.DysonPureCoolLink(device) diff --git a/libpurecool_adapter_test.py b/libpurecool_adapter_test.py index 23e49e7..093e0f2 100644 --- a/libpurecool_adapter_test.py +++ b/libpurecool_adapter_test.py @@ -1,54 +1,30 @@ """Unit test for the libpurecool_adapter module.""" -import configparser import unittest -import libpurecool_adapter - from libpurecool import dyson, const +import libpurecool_adapter + 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) + def testGetDevice(self): + name = 'name' + serial = 'serial' + credentials = 'credentials' + + test_cases = { + const.DYSON_PURE_COOL_LINK_DESK: dyson.DysonPureCoolLink, + const.DYSON_PURE_COOL: dyson.DysonPureCool, + const.DYSON_PURE_HOT_COOL_LINK_TOUR: dyson.DysonPureHotCoolLink, + const.DYSON_PURE_HOT_COOL: dyson.DysonPureHotCool + } + for product_type, want in test_cases.items(): + got = libpurecool_adapter.get_device(name, serial, credentials, product_type) + self.assertIsInstance(got, want) + + got = libpurecool_adapter.get_device(name, serial, credentials, const.DYSON_360_EYE) + self.assertIsNone(got) if __name__ == '__main__': diff --git a/main.py b/main.py index b280d58..70d3036 100755 --- a/main.py +++ b/main.py @@ -11,59 +11,103 @@ import sys import time -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict import prometheus_client # type: ignore[import] +import libdyson # type: ignore[import] import account import config import libpurecool_adapter -from metrics import Metrics +import metrics -class DysonClient: - """Connects to and monitors Dyson fans.""" +class DeviceWrapper: + """Wraps a configured device and holds onto the underlying Dyson device + object.""" - def __init__(self, device_cache: List[Dict[str, str]], hosts: Optional[Dict] = None): - self._account = libpurecool_adapter.DysonAccountCache(device_cache) - self.hosts = hosts or {} + def __init__(self, device: config.Device): + self._device = device + self.libdyson = self._create_libdyson_device() + self.libpurecool = self._create_libpurecool_device() - def monitor(self, update_fn: Callable[[str, str, object], None], only_active=True) -> None: - """Sets up a background monitoring thread on each device. + @property + def name(self) -> str: + """Returns device name, e.g; 'Living Room'""" + return self._device.name + + @property + def serial(self) -> str: + """Returns device serial number, e.g; AB1-XX-1234ABCD""" + return self._device.serial + + def _create_libdyson_device(self): + return libdyson.get_device(self.serial, self._device.credentials, self._device.product_type) + + def _create_libpurecool_device(self): + return libpurecool_adapter.get_device(self.name, self.serial, + self._device.credentials, self._device.product_type) + + +class ConnectionManager: + """Manages connections via manual IP or via libdyson Discovery. + + At the moment, callbacks are done via libpurecool. + + Args: + update_fn: A callable taking a name, serial, and libpurecool update message + hosts: a dict of serial -> IP address, for direct (non-zeroconf) connections. + """ + + def __init__(self, update_fn: Callable[[str, str, object], None], hosts: Dict[str, str]): + self._update_fn = update_fn + self._hosts = hosts + + logging.info('Starting discovery...') + self._discovery = libdyson.discovery.DysonDiscovery() + self._discovery.start_discovery() + + def add_device(self, device: config.Device, add_listener=True): + """Adds and connects to a device. + + This will connect directly if the host is specified in hosts at + initialisation, otherwise we will attempt discovery via zeroconf. Args: - update_fn: callback function that will receive the device name, serial number, and - Dyson*State message for each update event from a device. - only_active: if True, will only setup monitoring on "active" devices. + device: a config.Device to add + add_listener: if True, will add callback listeners. Set to False if + add_device() has been called on this device already. """ - devices = self._account.devices() - for dev in devices: - if only_active and not dev.active: - logging.info('Found device "%s" (serial=%s) but is not active; skipping', - dev.name, dev.serial) - continue - - manual_ip = self.hosts.get(dev.serial.upper()) - if manual_ip: - logging.info('Attempting connection to device "%s" (serial=%s) via configured IP %s', - dev.name, dev.serial, manual_ip) - connected = dev.connect(manual_ip) - else: - logging.info('Attempting to discover device "%s" (serial=%s) via zeroconf', - dev.name, dev.serial) - connected = dev.auto_connect() - if not connected: - logging.error('Could not connect to device "%s" (serial=%s); skipping', - dev.name, dev.serial) - continue - - logging.info('Monitoring "%s" (serial=%s)', dev.name, dev.serial) - wrapped_fn = functools.partial(update_fn, dev.name, dev.serial) - - # Populate initial state values. Without this, we'll run without fan operating - # state until the next change event (which could be a while). - wrapped_fn(dev.state) - dev.add_message_listener(wrapped_fn) + wrap = DeviceWrapper(device) + + if add_listener: + wrap.libpurecool.add_message_listener( + functools.partial(self._lpc_callback, wrap)) + + manual_ip = self._hosts.get(wrap.serial.upper()) + if manual_ip: + logging.info('Attempting connection to device "%s" (serial=%s) via configured IP %s', + device.name, device.serial, manual_ip) + wrap.libpurecool.connect(manual_ip) + else: + logging.info('Attempting to discover device "%s" (serial=%s) via zeroconf', + device.name, device.serial) + callback_fn = functools.partial(self._discovery_callback, wrap) + self._discovery.register_device(wrap.libdyson, callback_fn) + + @classmethod + def _discovery_callback(cls, device: DeviceWrapper, address: str): + # A note on concurrency: used with DysonDiscovery, this will be called + # back in a separate thread created by the underlying zeroconf library. + # When we call connect() on libpurecool or libdyson, that code spawns + # a new thread for MQTT and returns. In other words: we don't need to + # worry about connect() blocking zeroconf here. + logging.info('Discovered %s on %s', device.serial, address) + device.libpurecool.connect(address) + + def _lpc_callback(self, device: DeviceWrapper, message): + logging.debug('Received update from %s: %s', device.serial, message) + self._update_fn(device.name, device.serial, message) def _sleep_forever() -> None: @@ -83,7 +127,12 @@ def main(argv): 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') + help=('Performs a one-time login to Dyson\'s cloud service ' + 'to identify your devices. This produces a config snippet ' + 'to add to your config, which will be used to connect to ' + 'your device. Use this when you first use this program and ' + 'when you add or remove devices.'), + action='store_true') parser.add_argument( '--log_level', help='Logging level (DEBUG, INFO, WARNING, ERROR)', type=str, default='INFO') parser.add_argument( @@ -100,14 +149,15 @@ def main(argv): args = parser.parse_args() logging.basicConfig( - format='%(asctime)s %(levelname)10s %(message)s', + format='%(asctime)s [%(thread)d] %(levelname)10s %(message)s', datefmt='%Y/%m/%d %H:%M:%S', level=level) logging.info('Starting up on port=%s', args.port) if args.include_inactive_devices: - logging.info('Including devices marked "inactive" from the Dyson API') + logging.warning( + '--include_inactive_devices is now inoperative and will be removed in a future release') try: cfg = config.Config(args.config) @@ -116,7 +166,7 @@ def main(argv): sys.exit(-1) devices = cfg.devices - if not len(devices): + if len(devices) == 0: logging.fatal( 'No devices configured; please re-run this program with --create_device_cache.') sys.exit(-2) @@ -127,12 +177,12 @@ def main(argv): account.generate_device_cache(cfg.dyson_credentials, args.config) sys.exit(0) - metrics = Metrics() prometheus_client.start_http_server(args.port) - client = DysonClient(devices, cfg.hosts) - client.monitor( - metrics.update, only_active=not args.include_inactive_devices) + connect_mgr = ConnectionManager(metrics.Metrics().update, cfg.hosts) + for dev in devices: + connect_mgr.add_device(dev) + _sleep_forever() From f4c1d21a36633221642142c3029737e68c78a14f Mon Sep 17 00:00:00 2001 From: Sean Rees Date: Fri, 19 Mar 2021 17:59:03 +0000 Subject: [PATCH 3/3] Bump release to 0.2.0; finish conversion to libdyson This converts metrics.py over to use libdyson \o/ and removes the now no-longer required libpurecool_adapter. This change also adds automatic reconnection to devices if they disconnect. --- BUILD | 30 +-- README.md | 61 ++++-- WORKSPACE | 7 +- libpurecool_adapter.py | 47 ----- libpurecool_adapter_test.py | 31 --- main.py | 127 ++++++++---- metrics.py | 381 ++++++++++++++++++++---------------- metrics_test.py | 294 ++++++++++++++-------------- requirements.txt | 1 - 9 files changed, 503 insertions(+), 476 deletions(-) delete mode 100644 libpurecool_adapter.py delete mode 100644 libpurecool_adapter_test.py diff --git a/BUILD b/BUILD index 95db543..bb797bb 100644 --- a/BUILD +++ b/BUILD @@ -23,28 +23,11 @@ py_test( ], ) -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"], deps = [ - requirement("libpurecool"), + requirement("libdyson"), requirement("prometheus_client"), ], ) @@ -54,7 +37,7 @@ py_test( srcs = ["metrics_test.py"], deps = [ ":metrics", - requirement("libpurecool"), + requirement("libdyson"), requirement("prometheus_client"), ], ) @@ -65,7 +48,6 @@ py_binary( deps = [ ":account", ":config", - ":libpurecool_adapter", ":metrics", requirement("prometheus_client"), requirement("libdyson"), @@ -92,7 +74,7 @@ pkg_tar( srcs = ["debian/prometheus-dyson"], mode = "0644", package_dir = "/etc/default", - strip_prefix = "debian/", + strip_prefix = "/debian", ) pkg_tar( @@ -100,7 +82,7 @@ pkg_tar( srcs = ["debian/prometheus-dyson.service"], mode = "0644", package_dir = "/lib/systemd/system", - strip_prefix = "debian/", + strip_prefix = "/debian", ) pkg_tar( @@ -115,7 +97,7 @@ pkg_tar( pkg_deb( name = "main-deb", - # libpurecool has native deps. + # libdyson includes native deps. architecture = "amd64", built_using = "bazel", data = ":debian-data", @@ -127,5 +109,5 @@ pkg_deb( package = "prometheus-dyson", postrm = "debian/postrm", prerm = "debian/prerm", - version = "0.1.1", + version = "0.2.0", ) diff --git a/README.md b/README.md index 50878c6..c610a1d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,20 @@ the V1 model (reports VOC and Dust) and the V2 models (those that report PM2.5, PM10, NOx, and VOC). Other Dyson fans may work out of the box or with minor modifications. +## Updating instructions for 0.2.0 + +Due to changes in Dyson's Cloud API, automatic device detection based on your +Dyson login/password no longer works reliably. + +This means you need to take a _one-time_ manual step to upgrade. The upside +to this is that it removes the runtime dependency on the Dyson API, because +it will cache the device information locally. + +The manual step is to run this command and follow the prompts: +``` +% /opt/prometheus-dyson/bin/main --create_device_cache +``` + ## Build ``` @@ -26,7 +40,7 @@ directory). This is _optional_ and not required. You'll need these dependencies: ``` -% pip install libpurecool +% pip install libdyson % pip install prometheus_client ``` @@ -78,33 +92,56 @@ dyson_continuous_monitoring_mode | gauge | V2 fans only | continuous monitoring This script reads `config.ini` (or another file, specified with `--config`) for your DysonLink login credentials. +#### Device Configuration + +Devices must be specifically listed in your `config.ini`. You can create this +automatically by running the binary with `--create_device_cache` and following +the prompts. A device entry looks like this: + +``` +[XX1-ZZ-ABC1234A] +active = true +name = My Fan +serial = XX1-ZZ-ABC1234A +version = 21.04.03 +localcredentials = a_random_looking_string== +autoupdate = True +newversionavailable = True +producttype = 455 +``` + +#### Manual IP Overrides + +By default, fans are auto-detected with Zeroconf. It is possible to provide +manual IP overrides in the configuraton however in the `Hosts` section. + +``` +[Hosts] +XX1-ZZ-ABC1234A = 10.10.100.55 +``` + ### Args ``` % ./prometheus_dyson.py --help -usage: ./prometheus_dyson.py [-h] [--port PORT] [--config CONFIG] [--log_level LOG_LEVEL] [--include_inactive_devices] +usage: ./prometheus_dyson.py [-h] [--port PORT] [--config CONFIG] [--create_device_cache] [--log_level LOG_LEVEL] optional arguments: -h, --help show this help message and exit --port PORT HTTP server port --config CONFIG Configuration file (INI file) + --create_device_cache + Performs a one-time login to Dyson's cloud service to identify your devices. This produces + a config snippet to add to your config, which will be used to connect to your device. Use + this when you first use this program and when you add or remove devices. --log_level LOG_LEVEL Logging level (DEBUG, INFO, WARNING, ERROR) - --include_inactive_devices - Monitor devices marked as inactive by Dyson (default is only active) ``` ### Scrape Frequency -Metrics are updated at approximately 30 second intervals by `libpurecool`. +Environmental metrics are updated at approximately 30 second intervals. Fan state changes (e.g; FAN -> HEAT) are published ~immediately on change. -### Other Notes - -`libpurecool` by default uses a flavour of mDNS to automatically discover -the Dyson fan. If automatic discovery isn't available on your network, it is possible -to specify IP addresses mapped to device serial numbers in config.ini - see -`config-sample.ini` for usage. - ## Dashboard I've provided a sample Grafana dashboard in `grafana.json`. diff --git a/WORKSPACE b/WORKSPACE index ae3753b..6adf9fb 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -33,13 +33,14 @@ pip_install( ) # Packaging rules. +#load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") http_archive( name = "rules_pkg", urls = [ - "https://github.com/bazelbuild/rules_pkg/releases/download/0.2.6-1/rules_pkg-0.2.6.tar.gz", - "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.2.6/rules_pkg-0.2.6.tar.gz", + "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz", + "https://github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz", ], - sha256 = "aeca78988341a2ee1ba097641056d168320ecc51372ef7ff8e64b139516a4937", + sha256 = "038f1caa773a7e35b3663865ffb003169c6a71dc995e39bf4815792f385d837d", ) load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies") rules_pkg_dependencies() diff --git a/libpurecool_adapter.py b/libpurecool_adapter.py deleted file mode 100644 index 509dcef..0000000 --- a/libpurecool_adapter.py +++ /dev/null @@ -1,47 +0,0 @@ -"""An adapter to use libpurecool's Dyson support without the Cloud API.""" - -import logging -from typing import Optional - -from libpurecool import dyson, dyson_device - - -# We expect unencrypted credentials only, so monkey-patch this. -dyson_device.decrypt_password = lambda s: s - - -def get_device(name: str, serial: str, credentials: str, product_type: str) -> Optional[object]: - """Creates a libpurecool DysonDevice based on the input parameters. - - Args: - name: name of device (e.g; "Living room") - serial: serial number, e.g; AB1-XX-1234ABCD - credentials: unencrypted credentials for accessing the device locally - product_type: stringified int for the product type (e.g; "455") - """ - device = {'Serial': serial, 'Name': name, - 'LocalCredentials': credentials, 'ProductType': product_type, - 'Version': '', 'AutoUpdate': '', 'NewVersionAvailable': ''} - - if dyson.is_360_eye_device(device): - logging.info( - 'Identified %s as a Dyson 360 Eye device which is unsupported (ignoring)') - return None - - if dyson.is_heating_device(device): - logging.info( - 'Identified %s as a Dyson Pure Hot+Cool Link (V1) device', serial) - return dyson.DysonPureHotCoolLink(device) - if dyson.is_dyson_pure_cool_device(device): - logging.info( - 'Identified %s as a Dyson Pure Cool (V2) device', serial) - return dyson.DysonPureCool(device) - - if dyson.is_heating_device_v2(device): - logging.info( - 'Identified %s as a Dyson Pure Hot+Cool (V2) device',serial) - return dyson.DysonPureHotCool(device) - - # Last chance. - logging.info('Identified %s as a Dyson Pure Cool Link (V1) device', serial) - return dyson.DysonPureCoolLink(device) diff --git a/libpurecool_adapter_test.py b/libpurecool_adapter_test.py deleted file mode 100644 index 093e0f2..0000000 --- a/libpurecool_adapter_test.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Unit test for the libpurecool_adapter module.""" - -import unittest - -from libpurecool import dyson, const - -import libpurecool_adapter - - -class TestLibpurecoolAdapter(unittest.TestCase): - def testGetDevice(self): - name = 'name' - serial = 'serial' - credentials = 'credentials' - - test_cases = { - const.DYSON_PURE_COOL_LINK_DESK: dyson.DysonPureCoolLink, - const.DYSON_PURE_COOL: dyson.DysonPureCool, - const.DYSON_PURE_HOT_COOL_LINK_TOUR: dyson.DysonPureHotCoolLink, - const.DYSON_PURE_HOT_COOL: dyson.DysonPureHotCool - } - for product_type, want in test_cases.items(): - got = libpurecool_adapter.get_device(name, serial, credentials, product_type) - self.assertIsInstance(got, want) - - got = libpurecool_adapter.get_device(name, serial, credentials, const.DYSON_360_EYE) - self.assertIsNone(got) - - -if __name__ == '__main__': - unittest.main() diff --git a/main.py b/main.py index 70d3036..faf76ad 100755 --- a/main.py +++ b/main.py @@ -1,65 +1,97 @@ #!/usr/bin/python3 -"""Exports Dyson Pure Hot+Cool (DysonLink) statistics as Prometheus metrics. - -This module depends on two libraries to function: pip install -libpurecool pip install prometheus_client -""" +"""Exports Dyson Pure Hot+Cool (DysonLink) statistics as Prometheus metrics.""" import argparse import functools import logging import sys import time +import threading -from typing import Callable, Dict +from typing import Callable, Dict, List -import prometheus_client # type: ignore[import] -import libdyson # type: ignore[import] +import prometheus_client +import libdyson +import libdyson.dyson_device +import libdyson.exceptions import account import config -import libpurecool_adapter import metrics class DeviceWrapper: - """Wraps a configured device and holds onto the underlying Dyson device - object.""" + """Wrapper for a config.Device. + + This class has two main purposes: + 1) To associate a device name & libdyson.DysonFanDevice together + 2) To start background thread that asks the DysonFanDevice for updated + environmental data on a periodic basis. - def __init__(self, device: config.Device): - self._device = device + Args: + device: a config.Device to wrap + environment_refresh_secs: how frequently to refresh environmental data + """ + + def __init__(self, device: config.Device, environment_refresh_secs=30): + self._config_device = device + self._environment_refresh_secs = environment_refresh_secs self.libdyson = self._create_libdyson_device() - self.libpurecool = self._create_libpurecool_device() @property def name(self) -> str: - """Returns device name, e.g; 'Living Room'""" - return self._device.name + """Returns device name, e.g; 'Living Room'.""" + return self._config_device.name @property def serial(self) -> str: - """Returns device serial number, e.g; AB1-XX-1234ABCD""" - return self._device.serial + """Returns device serial number, e.g; AB1-XX-1234ABCD.""" + return self._config_device.serial - def _create_libdyson_device(self): - return libdyson.get_device(self.serial, self._device.credentials, self._device.product_type) + @property + def is_connected(self) -> bool: + """True if we're connected to the Dyson device.""" + return self.libdyson.is_connected + + def connect(self, host: str): + """Connect to the device and start the environmental monitoring timer.""" + self.libdyson.connect(host) + self._refresh_timer() + + def disconnect(self): + """Disconnect from the Dyson device.""" + self.libdyson.disconnect() + + def _refresh_timer(self): + timer = threading.Timer(self._environment_refresh_secs, + self._timer_callback) + timer.start() + + def _timer_callback(self): + if self.is_connected: + logging.debug( + 'Requesting updated environmental data from %s', self.serial) + self.libdyson.request_environmental_data() + self._refresh_timer() + else: + logging.debug('Device %s is disconnected.') - def _create_libpurecool_device(self): - return libpurecool_adapter.get_device(self.name, self.serial, - self._device.credentials, self._device.product_type) + def _create_libdyson_device(self): + return libdyson.get_device(self.serial, self._config_device.credentials, + self._config_device.product_type) class ConnectionManager: """Manages connections via manual IP or via libdyson Discovery. - At the moment, callbacks are done via libpurecool. - Args: - update_fn: A callable taking a name, serial, and libpurecool update message + update_fn: A callable taking a name, serial, + devices: a list of config.Device entities hosts: a dict of serial -> IP address, for direct (non-zeroconf) connections. """ - def __init__(self, update_fn: Callable[[str, str, object], None], hosts: Dict[str, str]): + def __init__(self, update_fn: Callable[[str, str, bool, bool], None], + devices: List[config.Device], hosts: Dict[str, str]): self._update_fn = update_fn self._hosts = hosts @@ -67,7 +99,10 @@ def __init__(self, update_fn: Callable[[str, str, object], None], hosts: Dict[st self._discovery = libdyson.discovery.DysonDiscovery() self._discovery.start_discovery() - def add_device(self, device: config.Device, add_listener=True): + for device in devices: + self._add_device(DeviceWrapper(device)) + + def _add_device(self, device: DeviceWrapper, add_listener=True): """Adds and connects to a device. This will connect directly if the host is specified in hosts at @@ -78,22 +113,20 @@ def add_device(self, device: config.Device, add_listener=True): add_listener: if True, will add callback listeners. Set to False if add_device() has been called on this device already. """ - wrap = DeviceWrapper(device) - if add_listener: - wrap.libpurecool.add_message_listener( - functools.partial(self._lpc_callback, wrap)) + callback_fn = functools.partial(self._device_callback, device) + device.libdyson.add_message_listener(callback_fn) - manual_ip = self._hosts.get(wrap.serial.upper()) + manual_ip = self._hosts.get(device.serial.upper()) if manual_ip: logging.info('Attempting connection to device "%s" (serial=%s) via configured IP %s', device.name, device.serial, manual_ip) - wrap.libpurecool.connect(manual_ip) + device.connect(manual_ip) else: logging.info('Attempting to discover device "%s" (serial=%s) via zeroconf', device.name, device.serial) - callback_fn = functools.partial(self._discovery_callback, wrap) - self._discovery.register_device(wrap.libdyson, callback_fn) + callback_fn = functools.partial(self._discovery_callback, device) + self._discovery.register_device(device.libdyson, callback_fn) @classmethod def _discovery_callback(cls, device: DeviceWrapper, address: str): @@ -103,11 +136,21 @@ def _discovery_callback(cls, device: DeviceWrapper, address: str): # a new thread for MQTT and returns. In other words: we don't need to # worry about connect() blocking zeroconf here. logging.info('Discovered %s on %s', device.serial, address) - device.libpurecool.connect(address) + device.connect(address) - def _lpc_callback(self, device: DeviceWrapper, message): + def _device_callback(self, device, message): logging.debug('Received update from %s: %s', device.serial, message) - self._update_fn(device.name, device.serial, message) + if not device.is_connected: + logging.info( + 'Device %s is now disconnected, clearing it and re-adding.', device.serial) + device.disconnect() + self._add_device(device, add_listener=False) + return + + is_state = message == libdyson.MessageType.STATE + is_environ = message == libdyson.MessageType.ENVIRONMENTAL + self._update_fn(device.name, device.libdyson, is_state=is_state, + is_environmental=is_environ) def _sleep_forever() -> None: @@ -137,7 +180,7 @@ def main(argv): '--log_level', help='Logging level (DEBUG, INFO, WARNING, ERROR)', type=str, default='INFO') parser.add_argument( '--include_inactive_devices', - help='Monitor devices marked as inactive by Dyson (default is only active)', + help='Do not use; this flag has no effect and remains for compatibility only', action='store_true') args = parser.parse_args() @@ -179,9 +222,7 @@ def main(argv): prometheus_client.start_http_server(args.port) - connect_mgr = ConnectionManager(metrics.Metrics().update, cfg.hosts) - for dev in devices: - connect_mgr.add_device(dev) + ConnectionManager(metrics.Metrics().update, devices, cfg.hosts) _sleep_forever() diff --git a/metrics.py b/metrics.py index 512376e..4344dc9 100644 --- a/metrics.py +++ b/metrics.py @@ -1,9 +1,13 @@ """Creates and maintains Prometheus metric values.""" +import datetime import enum import logging -from libpurecool import const, dyson_pure_state, dyson_pure_state_v2 +import libdyson +import libdyson.const +import libdyson.dyson_device + from prometheus_client import Gauge, Enum, REGISTRY @@ -21,17 +25,61 @@ def update_gauge(gauge, name: str, serial: str, value): gauge.labels(name=name, serial=serial).set(value) -def update_enum(enum, name: str, serial: str, state): - enum.labels(name=name, serial=serial).state(state) +def update_env_gauge(gauge, name: str, serial, value): + if value in (libdyson.const.ENVIRONMENTAL_OFF, libdyson.const.ENVIRONMENTAL_FAIL): + return + if value == libdyson.const.ENVIRONMENTAL_INIT: + value = 0 + update_gauge(gauge, name, serial, value) + + +def update_enum(enum_metric, name: str, serial: str, state): + enum_metric.labels(name=name, serial=serial).state(state) -class _OscillationState(enum.Enum): - """On V2 devices, oscillation_status can return 'IDLE' in auto mode.""" +def timestamp() -> str: + return f'{int(datetime.datetime.now().timestamp())}' + + +class OffOn(enum.Enum): + OFF = 'OFF' ON = 'ON' + + @staticmethod + def translate_bool(value: bool): + return OffOn.ON.value if value else OffOn.OFF.value + + +class OffFan(enum.Enum): OFF = 'OFF' + FAN = 'FAN' + + @staticmethod + def translate_bool(value: bool): + return OffFan.FAN.value if value else OffFan.OFF.value + + +class OffFanAuto(enum.Enum): + OFF = 'OFF' + FAN = 'FAN' + AUTO = 'AUTO' + + +class OffOnIdle(enum.Enum): + OFF = 'OFF' + ON = 'ON' IDLE = 'IDLE' +class OffHeat(enum.Enum): + OFF = 'OFF' + HEAT = 'HEAT' + + @staticmethod + def translate_bool(value: bool): + return OffHeat.HEAT.value if value else OffHeat.OFF.value + + class Metrics: """Registers/exports and updates Prometheus metrics for DysonLink fans.""" @@ -42,7 +90,18 @@ def make_gauge(name, documentation): return Gauge(name, documentation, labels, registry=registry) def make_enum(name, documentation, state_cls): - return Enum(name, documentation, labels, states=enum_values(state_cls), registry=registry) + return Enum(name, documentation, labels, states=enum_values(state_cls), + registry=registry) + + # Last update timestamps. Use Gauge here as we can set arbitrary + # values; Counter requires inc(). + self.last_update_state = make_gauge( + 'dyson_last_state_timestamp_seconds', + 'Last Unix time we received an STATE update') + + self.last_update_environmental = make_gauge( + 'dyson_last_environmental_timestamp_seconds', + 'Last Unix timestamp we received an ENVIRONMENTAL update') # Environmental Sensors (v1 & v2 common) self.humidity = make_gauge( @@ -57,7 +116,6 @@ def make_enum(name, documentation, state_cls): 'Level of Dust (V1 units only)') # Environmental Sensors (v2 units only) - # Not included: p10r and p25r as they are marked as "unknown" in libpurecool. self.pm25 = make_gauge( 'dyson_pm25_units', 'Level of PM2.5 particulate matter (V2 units only)') self.pm10 = make_gauge( @@ -69,42 +127,45 @@ def make_enum(name, documentation, state_cls): # Not included: tilt (known values: "OK", others?), standby_monitoring. # Synthesised: fan_mode (for V2), fan_power & auto_mode (for V1) self.fan_mode = make_enum( - 'dyson_fan_mode', 'Current mode of the fan', const.FanMode) + 'dyson_fan_mode', 'Current mode of the fan', OffFanAuto) self.fan_power = make_enum( - 'dyson_fan_power_mode', 'Current power mode of the fan (like fan_mode but binary)', const.FanPower) + 'dyson_fan_power_mode', + 'Current power mode of the fan (like fan_mode but binary)', + OffOn) self.auto_mode = make_enum( - 'dyson_fan_auto_mode', 'Current auto mode of the fan (like fan_mode but binary)', const.AutoMode) + 'dyson_fan_auto_mode', 'Current auto mode of the fan (like fan_mode but binary)', + OffOn) self.fan_state = make_enum( - 'dyson_fan_state', 'Current running state of the fan', const.FanState) + 'dyson_fan_state', 'Current running state of the fan', OffFan) self.fan_speed = make_gauge( 'dyson_fan_speed_units', 'Current speed of fan (-1 = AUTO)') self.oscillation = make_enum( - 'dyson_oscillation_mode', 'Current oscillation mode (will the fan move?)', const.Oscillation) + 'dyson_oscillation_mode', 'Current oscillation mode (will the fan move?)', OffOn) self.oscillation_state = make_enum( - 'dyson_oscillation_state', 'Current oscillation state (is the fan moving?)', _OscillationState) + 'dyson_oscillation_state', 'Current oscillation state (is the fan moving?)', OffOnIdle) self.night_mode = make_enum( - 'dyson_night_mode', 'Night mode', const.NightMode) + 'dyson_night_mode', 'Night mode', OffOn) self.heat_mode = make_enum( - 'dyson_heat_mode', 'Current heat mode', const.HeatMode) + 'dyson_heat_mode', 'Current heat mode', OffHeat) self.heat_state = make_enum( - 'dyson_heat_state', 'Current heat state', const.HeatState) + 'dyson_heat_state', 'Current heat state', OffHeat) self.heat_target = make_gauge( 'dyson_heat_target_celsius', 'Heat target temperature (celsius)') + self.continuous_monitoring = make_enum( + 'dyson_continuous_monitoring_mode', 'Monitor air quality continuously', OffOn) # Operational State (v1 only) self.focus_mode = make_enum( - 'dyson_focus_mode', 'Current focus mode (V1 units only)', const.FocusMode) + 'dyson_focus_mode', 'Current focus mode (V1 units only)', OffOn) self.quality_target = make_gauge( 'dyson_quality_target_units', 'Quality target for fan (V1 units only)') self.filter_life = make_gauge( 'dyson_filter_life_seconds', 'Remaining HEPA filter life (seconds, V1 units only)') # Operational State (v2 only) - # Not included: oscillation (known values: "ON", "OFF", "OION", "OIOF") using oscillation_state instead - self.continuous_monitoring = make_enum( - 'dyson_continuous_monitoring_mode', 'Monitor air quality continuously (V2 units only)', const.ContinuousMonitoring) self.carbon_filter_life = make_gauge( - 'dyson_carbon_filter_life_percent', 'Percent remaining of carbon filter (V2 units only)') + 'dyson_carbon_filter_life_percent', + 'Percent remaining of carbon filter (V2 units only)') self.hepa_filter_life = make_gauge( 'dyson_hepa_filter_life_percent', 'Percent remaining of HEPA filter (V2 units only)') self.night_mode_speed = make_gauge( @@ -114,178 +175,166 @@ def make_enum(name, documentation, state_cls): self.oscillation_angle_high = make_gauge( 'dyson_oscillation_angle_high_degrees', 'High oscillation angle (V2 units only)') self.dyson_front_direction_mode = make_enum( - 'dyson_front_direction_mode', 'Airflow direction from front (V2 units only)', const.FrontalDirection) + 'dyson_front_direction_mode', 'Airflow direction from front (V2 units only)', OffOn) - def update(self, name: str, serial: str, message: object) -> None: + def update(self, name: str, device: libdyson.dyson_device.DysonFanDevice, is_state=False, + is_environmental=False) -> None: """Receives device/environment state and updates Prometheus metrics. Args: - name: (str) Name of device. - serial: (str) Serial number of device. - message: must be one of a DysonEnvironmentalSensor{,V2}State, DysonPureHotCool{,V2}State - or DysonPureCool{,V2}State. + name: device name (e.g; "Living Room") + device: a libdyson.Device instance. + is_state: is a device state (power, fan mode, etc) update. + is_enviromental: is an environmental (temperature, humidity, etc) update. """ - if not name or not serial: - logging.error( - 'Ignoring update with name=%s, serial=%s', name, serial) - - logging.debug('Received update for %s (serial=%s): %s', - name, serial, message) - - if isinstance(message, dyson_pure_state.DysonEnvironmentalSensorState): - self.updateEnvironmentalState(name, serial, message) - elif isinstance(message, dyson_pure_state_v2.DysonEnvironmentalSensorV2State): - self.updateEnvironmentalV2State(name, serial, message) - elif isinstance(message, dyson_pure_state.DysonPureCoolState): - self.updatePureCoolState(name, serial, message) - elif isinstance(message, dyson_pure_state_v2.DysonPureCoolV2State): - self.updatePureCoolV2State(name, serial, message) + if not device: + logging.error('Ignoring update, device is None') + + serial = device.serial + + heating = isinstance(device, libdyson.dyson_device.DysonHeatingDevice) + + if isinstance(device, libdyson.DysonPureCool): + if is_environmental: + self.update_v2_environmental(name, device) + if is_state: + self.update_v2_state(name, device, heating) + elif isinstance(device, libdyson.DysonPureCoolLink): + if is_environmental: + self.update_v1_environmental(name, device) + if is_state: + self.update_v1_state(name, device, heating) else: logging.warning('Received unknown update from "%s" (serial=%s): %s; ignoring', - name, serial, type(message)) - - def updateEnviromentalStateCommon(self, name: str, serial: str, message): - temp = round(message.temperature + KELVIN_TO_CELSIUS, 1) + name, serial, type(device)) - update_gauge(self.humidity, name, serial, message.humidity) - update_gauge(self.temperature, name, serial, temp) + def update_v1_environmental(self, name: str, device) -> None: + self.update_common_environmental(name, device) + update_env_gauge(self.dust, name, device.serial, device.particulates) + update_env_gauge(self.voc, name, device.serial, + device.volatile_organic_compounds) - def updateEnvironmentalState(self, name: str, serial: str, message: dyson_pure_state.DysonEnvironmentalSensorState): - self.updateEnviromentalStateCommon(name, serial, message) + def update_v2_environmental(self, name: str, device) -> None: + self.update_common_environmental(name, device) - update_gauge(self.dust, name, serial, message.dust) - update_gauge(self.voc, name, serial, - message.volatil_organic_compounds) - - def updateEnvironmentalV2State(self, name: str, serial: str, message: dyson_pure_state_v2.DysonEnvironmentalSensorV2State): - self.updateEnviromentalStateCommon(name, serial, message) - - update_gauge(self.pm25, name, serial, - message.particulate_matter_25) - update_gauge(self.pm10, name, serial, - message.particulate_matter_10) + update_env_gauge(self.pm25, name, device.serial, + device.particulate_matter_2_5) + update_env_gauge(self.pm10, name, device.serial, + device.particulate_matter_10) # Previously, Dyson normalised the VOC range from [0,10]. Issue #5 # discovered on V2 devices, the range is [0, 100]. NOx seems to be # similarly ranged. For compatibility and consistency we rerange the values # values to the original [0,10]. - voc = message.volatile_organic_compounds/10 - nox = message.nitrogen_dioxide/10 - update_gauge(self.voc, name, serial, voc) - update_gauge(self.nox, name, serial, nox) - - def updateHeatStateCommon(self, name: str, serial: str, message): - # Convert from Decikelvin to to Celsius. - heat_target = round(int(message.heat_target) / - 10 + KELVIN_TO_CELSIUS, 1) - - update_enum(self.heat_mode, name, serial, message.heat_mode) - update_enum(self.heat_state, name, serial, message.heat_state) - update_gauge(self.heat_target, name, serial, heat_target) - - def updatePureCoolStateCommon(self, name: str, serial: str, message): - update_enum(self.fan_state, name, serial, message.fan_state) - update_enum(self.night_mode, name, serial, message.night_mode) - - # The API can return 'AUTO' rather than a speed when the device is in - # automatic mode. Provide -1 to keep it an int. - speed = message.speed - if speed == 'AUTO': - speed = -1 - update_gauge(self.fan_speed, name, serial, speed) + voc = device.volatile_organic_compounds + nox = device.nitrogen_dioxide + if voc >= 0: + voc = voc/10 + if nox >= 0: + nox = nox/10 + update_env_gauge(self.voc, name, device.serial, voc) + update_env_gauge(self.nox, name, device.serial, nox) - def updatePureCoolState(self, name: str, serial: str, message: dyson_pure_state.DysonPureCoolState): - self.updatePureCoolStateCommon(name, serial, message) + def update_common_environmental(self, name: str, device) -> None: + update_gauge(self.last_update_environmental, + name, device.serial, timestamp()) - update_enum(self.fan_mode, name, serial, message.fan_mode) - update_gauge(self.quality_target, name, - serial, message.quality_target) + temp = round(device.temperature + KELVIN_TO_CELSIUS, 1) + update_env_gauge(self.humidity, name, device.serial, device.humidity) + update_env_gauge(self.temperature, name, device.serial, temp) - # Synthesize compatible values for V2-originated metrics: - auto = const.AutoMode.AUTO_OFF.value - power = const.FanPower.POWER_OFF.value - if message.fan_mode == const.FanMode.AUTO.value: - auto = const.AutoMode.AUTO_ON.value - if message.fan_mode in (const.FanMode.AUTO.value, const.FanMode.FAN.value): - power = const.FanPower.POWER_ON.value - - update_enum(self.auto_mode, name, serial, auto) - update_enum(self.fan_power, name, serial, power) - - oscillation_state = message.oscillation - oscillation_on = message.oscillation == const.Oscillation.OSCILLATION_ON.value - auto_on = message.fan_mode == const.FanMode.AUTO.value - fan_off = message.fan_state == const.FanState.FAN_OFF.value - if oscillation_on and auto_on and fan_off: - # Compatibility with V2's behaviour for this value. - oscillation_state = _OscillationState.IDLE.value + def update_v1_state(self, name: str, device, is_heating=False) -> None: + self.update_common_state(name, device) + + update_enum(self.fan_mode, name, device.serial, device.fan_mode) - update_enum(self.oscillation, name, serial, message.oscillation) - update_enum(self.oscillation_state, name, serial, oscillation_state) + update_enum(self.oscillation, name, device.serial, + OffOn.translate_bool(device.oscillation)) + + quality_target = int(device.air_quality_target.value) + update_gauge(self.quality_target, name, device.serial, quality_target) # Convert filter_life from hours to seconds. - filter_life = int(message.filter_life) * 60 * 60 - update_gauge(self.filter_life, name, serial, filter_life) + filter_life = int(device.filter_life) * 60 * 60 + update_gauge(self.filter_life, name, device.serial, filter_life) - # Metrics only available with DysonPureHotCoolState - if isinstance(message, dyson_pure_state.DysonPureHotCoolState): - self.updateHeatStateCommon(name, serial, message) - update_enum(self.focus_mode, name, serial, message.focus_mode) + if is_heating: + self.update_common_heating(name, device) + update_enum(self.focus_mode, name, device.serial, + OffOn.translate_bool(device.focus_mode)) - def updatePureCoolV2State(self, name: str, serial: str, message: dyson_pure_state_v2.DysonPureCoolV2State): - self.updatePureCoolStateCommon(name, serial, message) + # Synthesize compatible values for V2-originated metrics: + update_enum(self.auto_mode, name, device.serial, + OffOn.translate_bool(device.auto_mode)) + + oscillation_state = OffOnIdle.ON.value if device.oscillation else OffOnIdle.OFF.value + if device.oscillation and device.auto_mode and not device.fan_state: + # Compatibility with V2's behaviour for this value. + oscillation_state = OffOnIdle.IDLE.value + + update_enum(self.oscillation_state, name, + device.serial, oscillation_state) + + def update_v2_state(self, name: str, device, is_heating=False) -> None: + self.update_common_state(name, device) - update_enum(self.fan_power, name, serial, message.fan_power) - update_enum(self.continuous_monitoring, name, - serial, message.continuous_monitoring) update_enum(self.dyson_front_direction_mode, - name, serial, message.front_direction) - - update_gauge(self.carbon_filter_life, name, serial, - int(message.carbon_filter_state)) - update_gauge(self.hepa_filter_life, name, serial, - int(message.hepa_filter_state)) - update_gauge(self.night_mode_speed, name, serial, - int(message.night_mode_speed)) - - # V2 devices differentiate between _current_ oscillation status ('on', 'off', 'idle') - # and configured mode ('on', 'off'). This is roughly the difference between - # "is it oscillating right now" (oscillation_status) and "will it oscillate" (oscillation). - # - # This issue https://github.com/etheralm/libpurecool/issues/4#issuecomment-563358021 - # seems to indicate that oscillation can be one of the OscillationV2 values (OION, OIOF) - # or one of the Oscillation values (ON, OFF) -- so support and translate both. - v2_to_v1_map = { - const.OscillationV2.OSCILLATION_ON.value: const.Oscillation.OSCILLATION_ON.value, - const.OscillationV2.OSCILLATION_OFF.value: const.Oscillation.OSCILLATION_OFF.value, - const.Oscillation.OSCILLATION_ON.value: const.Oscillation.OSCILLATION_ON.value, - const.Oscillation.OSCILLATION_OFF.value: const.Oscillation.OSCILLATION_OFF.value - } - oscillation = v2_to_v1_map.get(message.oscillation, None) - if oscillation: - update_enum(self.oscillation, name, serial, oscillation) - else: - logging.warning('Received unknown oscillation setting from "%s" (serial=%s): %s; ignoring', - name, serial, message.oscillation) - update_enum(self.oscillation_state, name, serial, - message.oscillation_status) + name, device.serial, OffOn.translate_bool(device.front_airflow)) + update_gauge(self.night_mode_speed, name, + device.serial, device.night_mode_speed) + update_enum(self.oscillation, name, device.serial, + OffOn.translate_bool(device.oscillation)) + + # TODO: figure out a better way than this. 'oscs' is a tri-state: + # OFF, ON, IDLE. However, libdyson exposes as a bool only (true if ON). + oscs = device._get_field_value(device._status, 'oscs') + update_enum(self.oscillation_state, name, device.serial, oscs) + update_gauge(self.oscillation_angle_low, name, - serial, int(message.oscillation_angle_low)) + device.serial, device.oscillation_angle_low) update_gauge(self.oscillation_angle_high, name, - serial, int(message.oscillation_angle_high)) + device.serial, device.oscillation_angle_high) + + if device.carbon_filter_life: + update_gauge(self.carbon_filter_life, name, + device.serial, device.carbon_filter_life) + + if device.hepa_filter_life: + update_gauge(self.hepa_filter_life, name, + device.serial, device.hepa_filter_life) # Maintain compatibility with the V1 fan metrics. - fan_mode = const.FanMode.OFF.value - if message.auto_mode == const.AutoMode.AUTO_ON.value: - fan_mode = 'AUTO' - elif message.fan_power == const.FanPower.POWER_ON.value: - fan_mode = 'FAN' - elif message.fan_power == const.FanPower.POWER_OFF.value: - pass - else: - logging.warning('Received unknown fan_power setting from "%s" (serial=%s): "%s", defaulting to "%s"', - name, serial, message.fan_power, fan_mode) - update_enum(self.fan_mode, name, serial, fan_mode) + fan_mode = OffFanAuto.FAN.value if device.is_on else OffFanAuto.OFF.value + if device.auto_mode: + fan_mode = OffFanAuto.AUTO.value + update_enum(self.fan_mode, name, device.serial, fan_mode) + + if is_heating: + self.update_common_heating(name, device) + + def update_common_state(self, name: str, device) -> None: + update_gauge(self.last_update_state, name, device.serial, timestamp()) + + update_enum(self.fan_state, name, device.serial, + OffFan.translate_bool(device.fan_state)) + update_enum(self.night_mode, name, device.serial, + OffOn.translate_bool(device.night_mode)) + update_enum(self.fan_power, name, device.serial, + OffOn.translate_bool(device.is_on)) + update_enum(self.continuous_monitoring, name, device.serial, + OffOn.translate_bool(device.continuous_monitoring)) + + # libdyson will return None if the fan is on automatic. + speed = device.speed + if not speed: + speed = -1 + update_gauge(self.fan_speed, name, device.serial, speed) + + def update_common_heating(self, name: str, device) -> None: + heat_target = round(device.heat_target + KELVIN_TO_CELSIUS, 1) + update_gauge(self.heat_target, name, device.serial, heat_target) - if isinstance(message, dyson_pure_state_v2.DysonPureHotCoolV2State): - self.updateHeatStateCommon(name, serial, message) + update_enum(self.heat_mode, name, device.serial, + OffHeat.translate_bool(device.heat_mode_is_on)) + update_enum(self.heat_state, name, device.serial, + OffHeat.translate_bool(device.heat_status_is_on)) diff --git a/metrics_test.py b/metrics_test.py index e30ae45..a6f3c28 100644 --- a/metrics_test.py +++ b/metrics_test.py @@ -4,53 +4,26 @@ and V2 devices are executed in case folks working on the codebase have one type of unit and not the other. -The underlying libpurecool Dyson{PureCool,EnvironmentalSensor}{,V2}State -classes take JSON as an input. To make authoring this test a bit more -straightforward, we provide local stubs for each type and a simplified -initialiser to set properties. This comes at the cost of some boilerplate -and possible fragility down the road. +This test is a little gross; it forcibly injects a dict that looks a lot +like unmarshalled JSON from the device into libdyson's handlers. We then +check for the values on the far-side of the Prometheus metrics, which +ensures things are hooked up right, but limits our ability to test. For +example, we cannot currently test enum values with this test. """ import enum import unittest -from libpurecool import const, dyson_pure_state, dyson_pure_state_v2 from prometheus_client import registry +import libdyson import metrics -# pylint: disable=too-few-public-methods - -class KeywordInitialiserMixin: - def __init__(self, *unused_args, **kwargs): - for k, val in kwargs.items(): - setattr(self, '_' + k, val) - - -class DysonEnvironmentalSensorState(KeywordInitialiserMixin, dyson_pure_state.DysonEnvironmentalSensorState): - pass - - -class DysonPureCoolState(KeywordInitialiserMixin, dyson_pure_state.DysonPureCoolState): - pass - - -class DysonPureHotCoolState(KeywordInitialiserMixin, dyson_pure_state.DysonPureHotCoolState): - pass - - -class DysonEnvironmentalSensorV2State(KeywordInitialiserMixin, dyson_pure_state_v2.DysonEnvironmentalSensorV2State): - pass - - -class DysonPureCoolV2State(KeywordInitialiserMixin, dyson_pure_state_v2.DysonPureCoolV2State): - pass - - -class DysonPureHotCoolV2State(KeywordInitialiserMixin, dyson_pure_state_v2.DysonPureHotCoolV2State): - pass +NAME = 'test device' +SERIAL = 'XX1-ZZ-1234ABCD' +CREDENTIALS = 'credz' class TestMetrics(unittest.TestCase): @@ -58,125 +31,148 @@ def setUp(self): self.registry = registry.CollectorRegistry(auto_describe=True) self.metrics = metrics.Metrics(registry=self.registry) - def testEnumValues(self): - testEnum = enum.Enum('testEnum', 'RED GREEN BLUE') - self.assertEqual(metrics.enum_values(testEnum), [1, 2, 3]) + def test_enum_values(self): + test = enum.Enum('testEnum', 'RED GREEN BLUE') + self.assertEqual(metrics.enum_values(test), [1, 2, 3]) + + def test_update_v1_environmental(self): + device = libdyson.DysonPureCoolLink( + SERIAL, CREDENTIALS, libdyson.DEVICE_TYPE_PURE_COOL_LINK) + payload = { + 'msg': 'ENVIRONMENTAL-CURRENT-SENSOR-DATA', + 'time': '2021-03-17T15:09:23.000Z', + 'data': {'tact': '2956', 'hact': '0047', 'pact': '0005', 'vact': 'INIT', 'sltm': 'OFF'} + } + device._handle_message(payload) - def testEnvironmentalSensorState(self): - args = { - 'humidity': 50, - 'temperature': 21.0 - metrics.KELVIN_TO_CELSIUS, - 'volatil_compounds': 5, - 'dust': 4 + labels = {'name': NAME, 'serial': SERIAL} + self.metrics.update(NAME, device, is_state=False, + is_environmental=True) + + cases = { + 'dyson_temperature_celsius': 22.6, + 'dyson_volatile_organic_compounds_units': 0, + 'dyson_dust_units': 5 + } + for metric, want in cases.items(): + got = self.registry.get_sample_value(metric, labels) + self.assertEqual(got, want, f'metric {metric}') + + def test_update_v2_environmental(self): + device = libdyson.DysonPureCool( + SERIAL, CREDENTIALS, libdyson.DEVICE_TYPE_PURE_COOL) + payload = { + 'msg': 'ENVIRONMENTAL-CURRENT-SENSOR-DATA', + 'time': '2021-03-17T15:09:23.000Z', + 'data': {'tact': '2956', 'hact': '0047', 'pm10': '3', 'pm25': 'INIT', + 'noxl': 30, 'va10': 'INIT', 'sltm': 'OFF'} } - self.assertExpectedValues(DysonEnvironmentalSensorState, args, expected={ - 'dyson_humidity_percent': args['humidity'], - 'dyson_temperature_celsius': args['temperature'] + metrics.KELVIN_TO_CELSIUS, - 'dyson_volatile_organic_compounds_units': args['volatil_compounds'], - 'dyson_dust_units': args['dust'] - }) - - def testEnvironmentalSensorStateV2(self): - args = { - 'humidity': 50, - 'temperature': 21.0 - metrics.KELVIN_TO_CELSIUS, - 'volatile_organic_compounds': 50, - 'particulate_matter_25': 2, - 'particulate_matter_10': 10, - 'nitrogen_dioxide': 4, + device._handle_message(payload) + + labels = {'name': NAME, 'serial': SERIAL} + self.metrics.update(NAME, device, is_state=False, + is_environmental=True) + + cases = { + 'dyson_temperature_celsius': 22.6, + 'dyson_pm25_units': 0, + 'dyson_pm10_units': 3, + 'dyson_volatile_organic_compounds_units': 0, + 'dyson_nitrogen_oxide_units': 3 } - self.assertExpectedValues(DysonEnvironmentalSensorV2State, args, expected={ - 'dyson_humidity_percent': args['humidity'], - 'dyson_temperature_celsius': args['temperature'] + metrics.KELVIN_TO_CELSIUS, - 'dyson_volatile_organic_compounds_units': args['volatile_organic_compounds']/10, - 'dyson_nitrogen_oxide_units': args['nitrogen_dioxide']/10, - 'dyson_pm25_units': args['particulate_matter_25'], - 'dyson_pm10_units': args['particulate_matter_10'], - }) - - def testPureCoolState(self): - args = { - 'fan_mode': const.FanMode.FAN.value, - 'fan_state': const.FanState.FAN_ON.value, - 'speed': const.FanSpeed.FAN_SPEED_4.value, - 'night_mode': const.NightMode.NIGHT_MODE_OFF.value, - 'oscilation': const.Oscillation.OSCILLATION_ON.value, - 'filter_life': 1, # hour. - 'quality_target': const.QualityTarget.QUALITY_NORMAL.value, + for metric, want in cases.items(): + got = self.registry.get_sample_value(metric, labels) + self.assertEqual(got, want, f'metric {metric}') + + def test_update_v1_state(self): + device = libdyson.DysonPureHotCoolLink( + SERIAL, CREDENTIALS, libdyson.DEVICE_TYPE_PURE_HOT_COOL_LINK) + payload = { + 'msg': 'STATE-CHANGE', + 'time': '2021-03-17T15:27:30.000Z', + 'mode-reason': 'PRC', + 'state-reason': 'MODE', + 'product-state': { + 'fmod': ['AUTO', 'FAN'], + 'fnst': ['FAN', 'FAN'], + 'fnsp': ['AUTO', 'AUTO'], + 'qtar': ['0003', '0003'], + 'oson': ['ON', 'ON'], + 'rhtm': ['ON', 'ON'], + 'filf': ['2209', '2209'], + 'ercd': ['NONE', 'NONE'], + 'nmod': ['OFF', 'OFF'], + 'wacd': ['NONE', 'NONE'], + 'hmod': ['OFF', 'OFF'], + 'hmax': ['2960', '2960'], + 'hsta': ['OFF', 'OFF'], + 'ffoc': ['ON', 'ON'], + 'tilt': ['OK', 'OK']}, + 'scheduler': {'srsc': 'a58d', 'dstv': '0001', 'tzid': '0001'}} + device._handle_message(payload) + + labels = {'name': NAME, 'serial': SERIAL} + self.metrics.update(NAME, device, is_state=True, + is_environmental=False) + + cases = { + 'dyson_fan_speed_units': -1, + 'dyson_filter_life_seconds': 2209 * 60 * 60, + 'dyson_quality_target_units': 3, + 'dyson_heat_target_celsius': 23, } # We can't currently test Enums, so we skip those for now and only evaluate gauges. - self.assertExpectedValues(DysonPureCoolState, args, expected={ - 'dyson_fan_speed_units': int(args['speed']), - 'dyson_filter_life_seconds': 1 * 60 * 60, - 'dyson_quality_target_units': int(args['quality_target']) - }) - - # Test the auto -> -1 conversion. - args.update({ - 'fan_mode': const.FanMode.AUTO.value, - 'speed': 'AUTO', - }) - self.assertExpectedValues(DysonPureCoolState, args, expected={ - 'dyson_fan_speed_units': -1 - }) - - # Test the heat type. - args.update({ - 'fan_focus': const.FocusMode.FOCUS_OFF.value, - # Decikelvin - 'heat_target': (24 - metrics.KELVIN_TO_CELSIUS) * 10, - 'heat_mode': const.HeatMode.HEAT_ON.value, - 'heat_state': const.HeatState.HEAT_STATE_ON.value - }) - self.assertExpectedValues(DysonPureHotCoolState, args, expected={ - 'dyson_heat_target_celsius': 24 - }) - - def testPureCoolStateV2(self): - args = { - 'fan_power': const.FanPower.POWER_ON.value, - 'front_direction': const.FrontalDirection.FRONTAL_ON.value, - 'auto_mode': const.AutoMode.AUTO_ON.value, - 'oscillation_status': const.Oscillation.OSCILLATION_ON.value, - 'oscillation': const.OscillationV2.OSCILLATION_ON.value, - 'night_mode': const.NightMode.NIGHT_MODE_OFF.value, - 'continuous_monitoring': const.ContinuousMonitoring.MONITORING_ON.value, - 'fan_state': const.FanState.FAN_ON.value, - 'night_mode_speed': const.FanSpeed.FAN_SPEED_2.value, - 'speed': const.FanSpeed.FAN_SPEED_10.value, - 'carbon_filter_state': 50.0, - 'hepa_filter_state': 60.0, - 'oscillation_angle_low': 100.0, - 'oscillation_angle_high': 180.0, + for metric, want in cases.items(): + got = self.registry.get_sample_value(metric, labels) + self.assertEqual(got, want, f'metric {metric}') + + def test_update_v2_state(self): + device = libdyson.DysonPureHotCool( + SERIAL, CREDENTIALS, libdyson.DEVICE_TYPE_PURE_HOT_COOL) + payload = { + 'msg': 'STATE-CHANGE', + 'time': '2021-03-17T15:27:30.000Z', + 'mode-reason': 'PRC', + 'state-reason': 'MODE', + 'product-state': { + 'auto': ['ON', 'ON'], + 'fpwr': ['ON', 'ON'], + 'fmod': ['AUTO', 'FAN'], + 'fnst': ['FAN', 'FAN'], + 'fnsp': ['AUTO', 'AUTO'], + 'nmdv': ['0002', '0002'], + 'oson': ['ON', 'ON'], + 'oscs': ['ON', 'ON'], + 'osal': ['0136', '0136'], + 'osau': ['0226', '0226'], + 'rhtm': ['ON', 'ON'], + 'cflr': ['0055', '0055'], + 'hflr': ['0097', '0097'], + 'cflt': ['SCOF', 'SCOF'], + 'hflt': ['GCOM', 'GCOM'], + 'ercd': ['NONE', 'NONE'], + 'nmod': ['OFF', 'OFF'], + 'wacd': ['NONE', 'NONE'], + 'hmod': ['OFF', 'OFF'], + 'hmax': ['2960', '2960'], + 'hsta': ['OFF', 'OFF'], + 'fdir': ['ON', 'ON']}} + device._handle_message(payload) + + labels = {'name': NAME, 'serial': SERIAL} + self.metrics.update(NAME, device, is_state=True, + is_environmental=False) + + # We can't currently test Enums, so we skip those for now and only evaluate gauges. + cases = { + 'dyson_fan_speed_units': -1, + 'dyson_carbon_filter_life_percent': 55, + 'dyson_hepa_filter_life_percent': 97, + 'dyson_heat_target_celsius': 23, } - self.assertExpectedValues(DysonPureCoolV2State, args, expected={ - 'dyson_fan_speed_units': int(args['speed']), - 'dyson_night_mode_fan_speed_units': int(args['night_mode_speed']), - 'dyson_carbon_filter_life_percent': int(args['carbon_filter_state']), - 'dyson_hepa_filter_life_percent': int(args['hepa_filter_state']), - 'dyson_oscillation_angle_low_degrees': args['oscillation_angle_low'], - 'dyson_oscillation_angle_high_degrees': args['oscillation_angle_high'] - }) - - # Test the heat type. - args.update({ - # Decikelvin - 'heat_target': (24 - metrics.KELVIN_TO_CELSIUS) * 10, - 'heat_mode': const.HeatMode.HEAT_ON.value, - 'heat_state': const.HeatState.HEAT_STATE_ON.value - }) - self.assertExpectedValues(DysonPureHotCoolV2State, args, expected={ - 'dyson_heat_target_celsius': 24 - }) - - def assertExpectedValues(self, cls, args, expected): - labels = {'name': 'n', 'serial': 's'} - - obj = cls(**args) - self.metrics.update(labels['name'], labels['serial'], obj) - for k, want in expected.items(): - got = self.registry.get_sample_value(k, labels) - self.assertEqual(got, want, f'metric {k} (class={cls.__name__})') + for metric, want in cases.items(): + got = self.registry.get_sample_value(metric, labels) + self.assertEqual(got, want, f'metric {metric}') if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index b5b35e5..662eaa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ prometheus_client -libpurecool libdyson \ No newline at end of file