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