From 116a27ae6057a5825bd6160743884626c49e1689 Mon Sep 17 00:00:00 2001 From: Sean Rees <sean@erifax.org> Date: Sun, 10 Jan 2021 08:36:30 +0000 Subject: [PATCH] Add support for V2 fans (those that report PM2.5, PM10, and NOx) This adds a bunch of new metrics specific to V2 fans (described in README). Effort was put into maintaining compatibility between both models as much as possible, e.g; VOC is scaled from V2 fans down to the original range from V1 fans (0-100 to 0-10 respectivey). Additionally, some metrics only available on one were synthesised for the other, e.g; fan_mode, fan_power, and auto_mode. Also: run pyformat on the files. --- BUILD | 21 ++- README.md | 70 ++++++---- main.py | 340 +++++++++++++++++++----------------------------- metrics.py | 253 +++++++++++++++++++++++++++++++++++ metrics_test.py | 183 ++++++++++++++++++++++++++ 5 files changed, 631 insertions(+), 236 deletions(-) create mode 100644 metrics.py create mode 100644 metrics_test.py diff --git a/BUILD b/BUILD index 0e7733e..8f77eb9 100644 --- a/BUILD +++ b/BUILD @@ -2,10 +2,29 @@ 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") +py_library( + name = "metrics", + srcs = ["metrics.py"], + deps = [ + requirement("libpurecool"), + requirement("prometheus_client") + ], +) + +py_test( + name = "metrics_test", + srcs = ["metrics_test.py"], + deps = [ + ":metrics", + requirement("libpurecool"), + requirement("prometheus_client") + ], +) py_binary( name = "main", srcs = ["main.py"], deps = [ + ":metrics", requirement("libpurecool"), requirement("prometheus_client") ], @@ -66,5 +85,5 @@ pkg_deb( description_file = "debian/description", maintainer = "Sean Rees <sean at erifax.org>", package = "prometheus-dyson", - version = "0.0.1", + version = "0.0.2", ) diff --git a/README.md b/README.md index 6a37c66..edeb389 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # prometheus_dyson Prometheus client for DysonLink fans (e.g; Pure Hot+Cool and Pure Cool). -This code only supports Pure Hot+Cool and Pure Cool fans at the moment. It should be trivial -to extend to other fan types (I just don't have one to test). +This code supports Dyson Pure Cool and Pure Hot+Cool fans. This supports both +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. ## Build @@ -17,6 +19,10 @@ If you'd like a Debian package: ### Without Bazel +Tip: a [Python virtual environment](https://docs.python.org/3/tutorial/venv.html) is a very useful +tool for keeping dependencies local to the project (rather than system-wide or in your home +directory). This is _optional_ and not required. + You'll need these dependencies: ``` @@ -24,33 +30,45 @@ You'll need these dependencies: % pip install prometheus_client ``` -Consider installing in a [Python virtual environment](https://docs.python.org/3/tutorial/venv.html). ## Metrics ### Environmental -Name | Type | Description ----- | ---- | ----------- -dyson_humidity_percent | gauge | relative humidity percentage -dyson_temperature_celsius | gauge | ambient temperature in celsius -dyson_volatile_organic_compounds_units | gauge | volatile organic compounds (range 0-10?) -dyson_dust_units | gauge | dust level (range 0-10?) +Name | Type | Availability | Description +---- | ---- | ------------ | ----------- +dyson_humidity_percent | gauge | all | relative humidity percentage +dyson_temperature_celsius | gauge | all | ambient temperature in celsius +dyson_volatile_organic_compounds_units | gauge | all | volatile organic compounds (range 0-10) +dyson_dust_units | gauge | V1 fans only | dust level (range 0-10) +dyson_pm25_units | gauge | V2 fans only | PM2.5 units (µg/m^3 ?) +dyson_pm10_units | gauge | V2 fans only | PM10 units (µg/m^3 ?) +dyson_nitrogen_oxide_units | gauge | V2 fans only | Nitrogen Oxide (NOx) levels (range 0-10) + ### Operational -Name | Type | Description ----- | ---- | ----------- -dyson_fan_mode | enum | AUTO, FAN, OFF (what the fan is set to) -dyson_fan_state | enum | FAN, OFF (what the fan is actually doing) -dyson_fan_speed_units | gauge | 0-10 (or -1 if on AUTO) -dyson_oscillation_mode | enum | ON, OFF -dyson_focus_mode | enum | ON, OFF -dyson_heat_mode | enum | HEAT, OFF (OFF means "in cooling mode") -dyson_heat_state | enum | HEAT, OFF (what the fan is actually doing) -dyson_heat_target_celsius | gauge | target temperature (celsius) -dyson_quality_target_units | gauge | air quality target (1, 3, 5?) -dyson_filter_life_seconds | gauge | seconds of filter life remaining +Name | Type | Availability | Description +---- | ---- | ------------ | ----------- +dyson_fan_mode | enum | all | AUTO, FAN, OFF (what the fan is set to) +dyson_fan_power | enum | all | ON if the fan is powered on, OFF otherwise +dyson_auto_mode | enum | all | ON if the fan is in auto mode, OFF otherwise +dyson_fan_state | enum | all | FAN, OFF (what the fan is actually doing) +dyson_fan_speed_units | gauge | all | 0-10 (or -1 if on AUTO) +dyson_oscillation_mode | enum | all | ON if the fan is oscillating, OFF otherwise +dyson_oscillation_angle_low_degrees | gauge | V2 fans only | low angle of oscillation in degrees +dyson_oscillation_angle_high_degrees | gauge | V2 fans only | high angle of oscillation in degrees +dyson_night_mode | enum | all | ON if the fan is in night mode, OFF otherwise +dyson_night_mode_speed | gauge | V2 fans only | maximum speed of the fan in night mode +dyson_heat_mode | enum | all heating fans | HEAT, OFF (OFF means "in cooling mode") +dyson_heat_state | enum | all heating fans | only HEAT, OFF (what the fan is actually doing) +dyson_heat_target_celsius | gauge | all heating fans | target temperature (celsius) +dyson_focus_mode | enum | V1 heating only | ON if the fan is providing a focused stream, OFF otherwise +dyson_quality_target_units | gauge | V1 fans only | air quality target (1, 3, 5?) +dyson_filter_life_seconds | gauge | V1 fans only | seconds of filter life remaining +dyson_carbon_filter_life_percent | gauge | V2 fans only | percent remaining of the carbon filter +dyson_hepa_filter_life_percent | gauge | V2 fans only | percent remaining of the HEPA filter +dyson_continuous_monitoring_mode | gauge | V2 fans only | continuous monitoring of air quality (ON, OFF) ## Usage @@ -62,7 +80,7 @@ for your DysonLink login credentials. ### Args ``` % ./prometheus_dyson.py --help -usage: ./prometheus_dyson.py [-h] [--port PORT] [--config CONFIG] +usage: ./prometheus_dyson.py [-h] [--port PORT] [--config CONFIG] [--log_level LOG_LEVEL] [--include_inactive_devices] optional arguments: -h, --help show this help message and exit @@ -71,20 +89,18 @@ optional arguments: --log_level LOG_LEVEL Logging level (DEBUG, INFO, WARNING, ERROR) --include_inactive_devices - Only monitor devices marked as "active" in the Dyson API + Monitor devices marked as inactive by Dyson (default is only active) ``` ### Scrape Frequency -I scrape at 15s intervals. Metrics are updated at approximately 30 second -intervals by `libpurecool`. +Metrics are updated at approximately 30 second intervals by `libpurecool`. +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. This is overridable (but this script doesn't at the moment). -The mDNS dependency makes Dockerising this script somewhat challenging at -the moment. ## Dashboard diff --git a/main.py b/main.py index 2909f49..aea970c 100755 --- a/main.py +++ b/main.py @@ -1,9 +1,8 @@ #!/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 +This module depends on two libraries to function: pip install +libpurecool pip install prometheus_client """ import argparse @@ -16,225 +15,150 @@ import time from typing import Callable -from libpurecool import dyson # type: ignore[import] -from libpurecool import dyson_pure_state # type: ignore[import] -import prometheus_client # type: ignore[import] +from libpurecool import dyson +import prometheus_client # type: ignore[import] -# Rationale: -# too-many-instance-attributes: refers to Metrics. This is an intentional design choice. -# too-few-public-methods: refers to Metrics. This is an intentional design choice. -# no-member: pylint isn't understanding labels() for Gauge and Enum updates. -# pylint: disable=too-many-instance-attributes,too-few-public-methods,no-member +from metrics import Metrics DysonLinkCredentials = collections.namedtuple( 'DysonLinkCredentials', ['username', 'password', 'country']) -class Metrics(): - """Registers/exports and updates Prometheus metrics for DysonLink fans.""" - def __init__(self): - labels = ['name', 'serial'] - - # Environmental Sensors - self.humidity = prometheus_client.Gauge( - 'dyson_humidity_percent', 'Relative humidity (percentage)', labels) - self.temperature = prometheus_client.Gauge( - 'dyson_temperature_celsius', 'Ambient temperature (celsius)', labels) - self.voc = prometheus_client.Gauge( - 'dyson_volatile_organic_compounds_units', 'Level of Volatile organic compounds', labels) - self.dust = prometheus_client.Gauge( - 'dyson_dust_units', 'Level of Dust', labels) - - # Operational State - # Ignoring: tilt (known values OK), standby_monitoring. - self.fan_mode = prometheus_client.Enum( - 'dyson_fan_mode', 'Current mode of the fan', labels, states=['AUTO', 'FAN', 'OFF']) - self.fan_state = prometheus_client.Enum( - 'dyson_fan_state', 'Current running state of the fan', labels, states=['FAN', 'OFF']) - self.fan_speed = prometheus_client.Gauge( - 'dyson_fan_speed_units', 'Current speed of fan (-1 = AUTO)', labels) - self.oscillation = prometheus_client.Enum( - 'dyson_oscillation_mode', 'Current oscillation mode', labels, states=['ON', 'OFF']) - self.focus_mode = prometheus_client.Enum( - 'dyson_focus_mode', 'Current focus mode', labels, states=['ON', 'OFF']) - self.heat_mode = prometheus_client.Enum( - 'dyson_heat_mode', 'Current heat mode', labels, states=['HEAT', 'OFF']) - self.heat_state = prometheus_client.Enum( - 'dyson_heat_state', 'Current heat state', labels, states=['HEAT', 'OFF']) - self.heat_target = prometheus_client.Gauge( - 'dyson_heat_target_celsius', 'Heat target temperature (celsius)', labels) - self.quality_target = prometheus_client.Gauge( - 'dyson_quality_target_units', 'Quality target for fan', labels) - self.filter_life = prometheus_client.Gauge( - 'dyson_filter_life_seconds', 'Remaining filter life (seconds)', labels) - - def update(self, name: str, serial: str, message: object) -> None: - """Receives a sensor or device state update and updates Prometheus metrics. - - Args: - name: (str) Name of device. - serial: (str) Serial number of device. - message: must be one of a DysonEnvironmentalSensorState, DysonPureHotCoolState - or DysonPureCoolState. - """ - 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.humidity.labels(name=name, serial=serial).set(message.humidity) - self.temperature.labels(name=name, serial=serial).set(message.temperature - 273.2) - self.voc.labels(name=name, serial=serial).set(message.volatil_organic_compounds) - self.dust.labels(name=name, serial=serial).set(message.dust) - elif isinstance(message, dyson_pure_state.DysonPureCoolState): - self.fan_mode.labels(name=name, serial=serial).state(message.fan_mode) - self.fan_state.labels(name=name, serial=serial).state(message.fan_state) - - speed = message.speed - if speed == 'AUTO': - speed = -1 - self.fan_speed.labels(name=name, serial=serial).set(speed) - - # Convert filter_life from hours to seconds - filter_life = int(message.filter_life) * 60 * 60 - - self.oscillation.labels(name=name, serial=serial).state(message.oscillation) - self.quality_target.labels(name=name, serial=serial).set(message.quality_target) - self.filter_life.labels(name=name, serial=serial).set(filter_life) - - # Metrics only available with DysonPureHotCoolState - if isinstance(message, dyson_pure_state.DysonPureHotCoolState): - # Convert from Decicelsius to Kelvin. - heat_target = int(message.heat_target) / 10 - 273.2 - - self.focus_mode.labels(name=name, serial=serial).state(message.focus_mode) - self.heat_mode.labels(name=name, serial=serial).state(message.heat_mode) - self.heat_state.labels(name=name, serial=serial).state(message.heat_state) - self.heat_target.labels(name=name, serial=serial).set(heat_target) - else: - logging.warning('Received unknown update from "%s" (serial=%s): %s; ignoring', - name, serial, type(message)) - - -class DysonClient(): - """Connects to and monitors Dyson fans.""" - def __init__(self, username, password, country): - self.username = username - self.password = password - self.country = country - - 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. - - 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. - """ - 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 - - 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) + +class DysonClient: + """Connects to and monitors Dyson fans.""" + + def __init__(self, username, password, country): + self.username = username + self.password = password + self.country = country + + 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. + + 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. + """ + 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 + + 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) + def _sleep_forever() -> None: - """Sleeps the calling thread until a keyboard interrupt occurs.""" - while True: - try: - time.sleep(1) - except KeyboardInterrupt: - break + """Sleeps the calling thread until a keyboard interrupt occurs.""" + while True: + try: + time.sleep(1) + except KeyboardInterrupt: + break + def _read_config(filename): - """Reads configuration file. Returns DysonLinkCredentials or None on error.""" - config = configparser.ConfigParser() + """Reads configuration file. + + Returns DysonLinkCredentials or None on error. + """ + config = configparser.ConfigParser() - logging.info('Reading "%s"', filename) + 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: + 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'] + return DysonLinkCredentials(username, password, country) + except KeyError as ex: + logging.critical('Required key missing in "%s": %s', filename, ex) - try: - username = config['Dyson Link']['username'] - password = config['Dyson Link']['password'] - country = config['Dyson Link']['country'] - return DysonLinkCredentials(username, password, country) - except KeyError as ex: - logging.critical('Required key missing in "%s": %s', filename, ex) + return None - return None def main(argv): - """Main body of the program.""" - parser = argparse.ArgumentParser(prog=argv[0]) - parser.add_argument('--port', help='HTTP server port', type=int, default=8091) - parser.add_argument('--config', help='Configuration file (INI file)', default='config.ini') - parser.add_argument('--log_level', help='Logging level (DEBUG, INFO, WARNING, ERROR)', type=str, default='INFO') - parser.add_argument( - '--include_inactive_devices', - help='Only monitor devices marked as "active" in the Dyson API', - action='store_true') - args = parser.parse_args() - - try: - level = getattr(logging, args.log_level) - except AttributeError: - print(f'Invalid --log_level: {args.log_level}') - exit(-1) - args = parser.parse_args() - - logging.basicConfig( - format='%(asctime)s %(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') - - credentials = _read_config(args.config) - if not credentials: - exit(-1) - - metrics = Metrics() - prometheus_client.start_http_server(args.port) - - client = DysonClient(credentials.username, credentials.password, credentials.country) - if not client.login(): - exit(-1) - - client.monitor(metrics.update, only_active=not args.include_inactive_devices) - _sleep_forever() + """Main body of the program.""" + parser = argparse.ArgumentParser(prog=argv[0]) + parser.add_argument('--port', help='HTTP server port', + type=int, default=8091) + parser.add_argument( + '--config', help='Configuration file (INI file)', default='config.ini') + 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)', + action='store_true') + args = parser.parse_args() + + try: + level = getattr(logging, args.log_level) + except AttributeError: + print(f'Invalid --log_level: {args.log_level}') + sys.exit(-1) + args = parser.parse_args() + + logging.basicConfig( + format='%(asctime)s %(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') + + credentials = _read_config(args.config) + if not credentials: + sys.exit(-1) + + metrics = Metrics() + prometheus_client.start_http_server(args.port) + + client = DysonClient(credentials.username, + credentials.password, credentials.country) + if not client.login(): + sys.exit(-1) + + client.monitor( + metrics.update, only_active=not args.include_inactive_devices) + _sleep_forever() + if __name__ == '__main__': - main(sys.argv) + main(sys.argv) diff --git a/metrics.py b/metrics.py new file mode 100644 index 0000000..8d3f997 --- /dev/null +++ b/metrics.py @@ -0,0 +1,253 @@ +"""Creates and maintains Prometheus metric values.""" + +import logging + +from libpurecool import const, dyson_pure_state, dyson_pure_state_v2 +from prometheus_client import Gauge, Enum, REGISTRY + + +# An astute reader may notice this value seems to be slightly wrong. +# The definition is 0 K = -273.15 C, but it appears Dyson use this +# slightly rounded value instead. +KELVIN_TO_CELSIUS = -273 + + +def enum_values(cls): + return [x.value for x in list(cls)] + + +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) + + +class Metrics: + """Registers/exports and updates Prometheus metrics for DysonLink fans.""" + + def __init__(self, registry=REGISTRY): + labels = ['name', 'serial'] + + 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) + + # Environmental Sensors (v1 & v2 common) + self.humidity = make_gauge( + 'dyson_humidity_percent', 'Relative humidity (percentage)') + self.temperature = make_gauge( + 'dyson_temperature_celsius', 'Ambient temperature (celsius)') + self.voc = make_gauge( + 'dyson_volatile_organic_compounds_units', 'Level of Volatile organic compounds') + + # Environmental Sensors (v1 units only) + self.dust = make_gauge('dyson_dust_units', + '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( + 'dyson_pm10_units', 'Level of PM10 particulate matter (V2 units only)') + self.nox = make_gauge('dyson_nitrogen_oxide_units', + 'Level of nitrogen oxides (NOx, V2 units only)') + + # Operational State (v1 & v2 common) + # 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) + self.fan_power = make_enum( + 'dyson_fan_power_mode', 'Current power mode of the fan (like fan_mode but binary)', const.FanPower) + self.auto_mode = make_enum( + 'dyson_fan_auto_mode', 'Current auto mode of the fan (like fan_mode but binary)', const.AutoMode) + self.fan_state = make_enum( + 'dyson_fan_state', 'Current running state of the fan', const.FanState) + 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', const.Oscillation) + self.night_mode = make_enum( + 'dyson_night_mode', 'Night mode', const.NightMode) + self.heat_mode = make_enum( + 'dyson_heat_mode', 'Current heat mode', const.HeatMode) + self.heat_state = make_enum( + 'dyson_heat_state', 'Current heat state', const.HeatState) + self.heat_target = make_gauge( + 'dyson_heat_target_celsius', 'Heat target temperature (celsius)') + + # Operational State (v1 only) + self.focus_mode = make_enum( + 'dyson_focus_mode', 'Current focus mode (V1 units only)', const.FocusMode) + 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)') + 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( + 'dyson_night_mode_fan_speed_units', 'Night mode fan speed (V2 units only)') + self.oscillation_angle_low = make_gauge( + 'dyson_oscillation_angle_low_degrees', 'Low oscillation angle (V2 units only)') + 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) + + def update(self, name: str, serial: str, message: object) -> 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. + """ + 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) + 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) + + update_gauge(self.humidity, name, serial, message.humidity) + update_gauge(self.temperature, name, serial, temp) + + def updateEnvironmentalState(self, name: str, serial: str, message: dyson_pure_state.DysonEnvironmentalSensorState): + self.updateEnviromentalStateCommon(name, serial, message) + + 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) + + # 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) + + def updatePureCoolState(self, name: str, serial: str, message: dyson_pure_state.DysonPureCoolState): + self.updatePureCoolStateCommon(name, serial, message) + + update_enum(self.fan_mode, name, serial, message.fan_mode) + update_enum(self.oscillation, name, serial, message.oscillation) + update_gauge(self.quality_target, name, + serial, message.quality_target) + + # 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) + + # Convert filter_life from hours to seconds. + filter_life = int(message.filter_life) * 60 * 60 + update_gauge(self.filter_life, name, 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) + + def updatePureCoolV2State(self, name: str, serial: str, message: dyson_pure_state_v2.DysonPureCoolV2State): + self.updatePureCoolStateCommon(name, serial, message) + + 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 provides oscillation_status and oscillation as fields, + # oscillation_status provides values compatible with V1, so we use that. + # oscillation returns as 'OION', 'OIOF.' + update_enum(self.oscillation, name, serial, + message.oscillation_status) + update_gauge(self.oscillation_angle_low, name, + serial, int(message.oscillation_angle_low)) + update_gauge(self.oscillation_angle_high, name, + serial, int(message.oscillation_angle_high)) + + # 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' + else: + logging.warning('Received unknown fan_power setting from "%s" (serial=%s): %s, defaulting to "%s', + name, serial, message.fan_mode, fan_mode) + update_enum(self.fan_mode, name, serial, fan_mode) + + if isinstance(message, dyson_pure_state_v2.DysonPureHotCoolV2State): + self.updateHeatStateCommon(name, serial, message) diff --git a/metrics_test.py b/metrics_test.py new file mode 100644 index 0000000..e30ae45 --- /dev/null +++ b/metrics_test.py @@ -0,0 +1,183 @@ +"""Unit test for the metrics library. + +This test is primarily intended to ensure the metrics codepaths for V1 +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. +""" + + +import enum +import unittest + +from libpurecool import const, dyson_pure_state, dyson_pure_state_v2 +from prometheus_client import registry + +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 + + +class TestMetrics(unittest.TestCase): + 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 testEnvironmentalSensorState(self): + args = { + 'humidity': 50, + 'temperature': 21.0 - metrics.KELVIN_TO_CELSIUS, + 'volatil_compounds': 5, + 'dust': 4 + } + 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, + } + 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, + } + # 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, + } + 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__})') + + +if __name__ == '__main__': + unittest.main() -- GitLab