diff --git a/BUILD b/BUILD index 8f77eb9..bb797bb 100644 --- a/BUILD +++ b/BUILD @@ -1,13 +1,34 @@ load("@rules_python//python:defs.bzl", "py_binary", "py_library") -load("@pip_deps//:requirements.bzl", "requirement") -load("@rules_pkg//:pkg.bzl", "pkg_tar", "pkg_deb") +load("@pip//:requirements.bzl", "requirement") +load("@rules_pkg//:pkg.bzl", "pkg_deb", "pkg_tar") + +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 = "metrics", srcs = ["metrics.py"], deps = [ - requirement("libpurecool"), - requirement("prometheus_client") + requirement("libdyson"), + requirement("prometheus_client"), ], ) @@ -16,74 +37,77 @@ py_test( srcs = ["metrics_test.py"], deps = [ ":metrics", - requirement("libpurecool"), - requirement("prometheus_client") + requirement("libdyson"), + requirement("prometheus_client"), ], ) + py_binary( name = "main", srcs = ["main.py"], deps = [ + ":account", + ":config", ":metrics", - requirement("libpurecool"), - 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( name = "main-deb", - # libpurecool has native deps. + # libdyson includes native deps. architecture = "amd64", built_using = "bazel", data = ":debian-data", depends = [ "python3", ], - prerm = "debian/prerm", - postrm = "debian/postrm", description_file = "debian/description", maintainer = "Sean Rees ", package = "prometheus-dyson", - version = "0.0.2", + postrm = "debian/postrm", + prerm = "debian/prerm", + version = "0.2.0", ) diff --git a/README.md b/README.md index 1a3dce5..c610a1d 100644 --- a/README.md +++ b/README.md @@ -6,33 +6,18 @@ 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. -## Help! It stopped working! +## Updating instructions for 0.2.0 -_(Updated: 7th March 2021)_ +Due to changes in Dyson's Cloud API, automatic device detection based on your +Dyson login/password no longer works reliably. -Hi! If you use this and it's not working now (but it was before), you're likely -affected by etheralm/libpurecool#37. The long-and-short of it is that Dyson -changed their _unpublished_ online API, which broke `libpurecool` which we -rely on. You won't notice this breakage until you try to restart -prometheus-dyson. +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. -There is an _experimental_ fix in the libdyson branch. This begins a process -of switching this exporter over toto shenxn/libdyson (which should provide for -long-term stability against this type of problem). - -To give it a go (once you've checked out the most recent source): - -1. Build from the `libdyson` branch -1. Run the binary with `--create_device_cache`, follow the prompts - and add the generated configuration to your prometheus-dyson.ini. -1. Restart the service as normal. - -Rough set of steps: +The manual step is to run this command and follow the prompts: ``` -% git clone https://github.com/seanrees/prometheus-dyson.git prometheus-dyson -% cd prometheus-dyson -% git checkout libdyson -% bazel run :main -- --config=path-to-your-config.ini --create_device_cache +% /opt/prometheus-dyson/bin/main --create_device_cache ``` ## Build @@ -55,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 ``` @@ -107,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 30547ac..6adf9fb 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -3,30 +3,44 @@ 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. +#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/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..3dee251 --- /dev/null +++ b/config.py @@ -0,0 +1,118 @@ +"""Manages configuration file.""" + +import collections +import configparser +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) + + @classmethod + def load(cls, 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]: + """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'] + 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[Device]: + """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 Device objects. + """ + sections = self._config.sections() + + ret = [] + for sect in sections: + if not self._config.has_option(sect, 'LocalCredentials'): + # This is probably not a device entry, so ignore it. + continue + + 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 new file mode 100644 index 0000000..0b74732 --- /dev/null +++ b/config_test.py @@ -0,0 +1,80 @@ +"""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.create_temporary_file(EMPTY) + self.empty = config.Config(self._empty_file.name) + + 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() + + @classmethod + def create_temporary_file(cls, contents: str): + ret = tempfile.NamedTemporaryFile() + ret.write(contents.encode('utf-8')) + ret.flush() + return ret + + def testDysonCredentials(self): + self.assertIsNone(self.empty.dyson_credentials) + + 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) + 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/main.py b/main.py index 4bfb533..faf76ad 100755 --- a/main.py +++ b/main.py @@ -1,88 +1,156 @@ #!/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 collections -import configparser import functools import logging import sys import time +import threading + +from typing import Callable, Dict, List + +import prometheus_client +import libdyson +import libdyson.dyson_device +import libdyson.exceptions -from typing import Callable, Dict, Optional, Tuple +import account +import config +import metrics -from libpurecool import dyson -import prometheus_client # type: ignore[import] -from metrics import Metrics +class DeviceWrapper: + """Wrapper for a config.Device. -DysonLinkCredentials = collections.namedtuple( - 'DysonLinkCredentials', ['username', 'password', 'country']) + 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. + Args: + device: a config.Device to wrap + environment_refresh_secs: how frequently to refresh environmental data + """ -class DysonClient: - """Connects to and monitors Dyson fans.""" + 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() + + @property + def name(self) -> str: + """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._config_device.serial + + @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_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. + + Args: + 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, username, password, country, hosts: Optional[Dict] = None): - self.username = username - self.password = password - self.country = country - self.hosts = hosts or {} + 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 - self._account = None + logging.info('Starting discovery...') + self._discovery = libdyson.discovery.DysonDiscovery() + self._discovery.start_discovery() - 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 + for device in devices: + self._add_device(DeviceWrapper(device)) - return True + def _add_device(self, device: DeviceWrapper, add_listener=True): + """Adds and connects to a device. - def monitor(self, update_fn: Callable[[str, str, object], None], only_active=True) -> None: - """Sets up a background monitoring thread on each 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) + if add_listener: + callback_fn = functools.partial(self._device_callback, device) + device.libdyson.add_message_listener(callback_fn) + + 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) + 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, device) + self._discovery.register_device(device.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.connect(address) + + def _device_callback(self, device, message): + logging.debug('Received update from %s: %s', 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: @@ -94,45 +162,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,11 +169,18 @@ 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\'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( '--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() @@ -156,29 +192,38 @@ 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') - 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) - metrics = Metrics() + devices = cfg.devices + if len(devices) == 0: + 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) + prometheus_client.start_http_server(args.port) - client = DysonClient(credentials.username, - credentials.password, credentials.country, hosts) - if not client.login(): - sys.exit(-1) + ConnectionManager(metrics.Metrics().update, devices, cfg.hosts) - client.monitor( - metrics.update, only_active=not args.include_inactive_devices) _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/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..662eaa2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -libpurecool prometheus_client +libdyson \ No newline at end of file