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