From 7af4b56e81f18de3c91f77cdee1565e448eb9be9 Mon Sep 17 00:00:00 2001
From: Sean Rees <sean@erifax.org>
Date: Sun, 2 Dec 2018 10:21:29 +0000
Subject: [PATCH] Initial code drop of prometheus_dyson.py

---
 config.ini          |   5 ++
 prometheus_dyson.py | 212 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 217 insertions(+)
 create mode 100644 config.ini
 create mode 100755 prometheus_dyson.py

diff --git a/config.ini b/config.ini
new file mode 100644
index 0000000..526389d
--- /dev/null
+++ b/config.ini
@@ -0,0 +1,5 @@
+[Dyson Link]
+username = yourusername
+password = yourpassword
+; Two-letter country code where your account is registered.
+country = IE
diff --git a/prometheus_dyson.py b/prometheus_dyson.py
new file mode 100755
index 0000000..c7a3393
--- /dev/null
+++ b/prometheus_dyson.py
@@ -0,0 +1,212 @@
+#!/usr/bin/python3
+"""Exports Dyson Pure Hot+Cool (DysonLink) statistics as Prometheus metrics.
+
+This module depends on two libraries to function:
+  pip install libpurecoollink
+  pip install prometheus_client
+"""
+
+import argparse
+import collections
+import configparser
+import functools
+import logging
+import sys
+import time
+
+from typing import Callable
+
+from libpurecoollink import dyson
+from libpurecoollink import dyson_pure_state
+import prometheus_client
+
+# 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
+
+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('humidity', 'Relative humidity (percentage)', labels)
+    self.temperature = prometheus_client.Gauge(
+        'temperature', 'Ambient temperature (celsius)', labels)
+    self.voc = prometheus_client.Gauge('voc', 'Level of Volatile organic compounds', labels)
+    self.dust = prometheus_client.Gauge('dust', 'Level of Dust', labels)
+
+    # Operational State
+    # Ignoring: tilt (known values OK), standby_monitoring.
+    self.fan_mode = prometheus_client.Enum(
+        'fan_mode', 'Current mode of the fan', labels, states=['AUTO', 'FAN'])
+    self.fan_state = prometheus_client.Enum(
+        'fan_state', 'Current running state of the fan', labels, states=['FAN', 'OFF'])
+    self.fan_speed = prometheus_client.Gauge(
+        'fan_speed', 'Current speed of fan (-1 = AUTO)', labels)
+    self.oscillation = prometheus_client.Enum(
+        'oscillation', 'Current oscillation mode', labels, states=['ON', 'OFF'])
+    self.focus_mode = prometheus_client.Enum(
+        'focus_mode', 'Current focus mode', labels, states=['ON', 'OFF'])
+    self.heat_mode = prometheus_client.Enum(
+        'heat_mode', 'Current heat mode', labels, states=['HEAT', 'OFF'])
+    self.heat_state = prometheus_client.Enum(
+        'heat_state', 'Current heat state', labels, states=['HEAT', 'OFF'])
+    self.heat_target = prometheus_client.Gauge(
+        'heat_target', 'Heat target temperature (celsius)', labels)
+    self.quality_target = prometheus_client.Gauge(
+        'quality_target', 'Quality target for fan', labels)
+    self.filter_life = prometheus_client.Gauge(
+        'filter_life', 'Remaining filter life (hours)', 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 or DysonPureHotCoolState.
+    """
+    if not name or not serial:
+      logging.error('Ignoring update with name=%s, serial=%s', name, serial)
+
+    logging.info('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)
+      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.DysonPureHotCoolState):
+      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)
+
+      self.oscillation.labels(name=name, serial=serial).state(message.oscillation)
+      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(int(message.heat_target)/10 - 273)
+      self.quality_target.labels(name=name, serial=serial).set(message.quality_target)
+      self.filter_life.labels(name=name, serial=serial).set(message.filter_life)
+    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)
+
+def _sleep_forever() -> None:
+  """Sleeps the calling thread until a keyboard interrupt occurs."""
+  while True:
+    try:
+      time.sleep(1)
+    except KeyboardInterrupt:
+      break
+
+def _read_config(filename) -> DysonLinkCredentials:
+  """Reads configuration file. Returns DysonLinkCredentials or None on error."""
+  config = configparser.ConfigParser()
+
+  logging.info('Reading "%s"', filename)
+
+  try:
+    config.read(filename)
+  except configparser.Error as ex:
+    logging.critical('Could not read "%s": %s', filename, ex)
+    return None
+
+  try:
+    username = config['Dyson Link']['username']
+    password = config['Dyson Link']['password']
+    country = config['Dyson Link']['country']
+    return DysonLinkCredentials(username, password, country)
+  except KeyError as ex:
+    logging.critical('Required key missing in "%s": %s', filename, ex)
+
+  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')
+  args = parser.parse_args()
+
+  logging.basicConfig(
+      format='%(asctime)s %(levelname)10s %(message)s',
+      datefmt='%Y/%m/%d %H:%M:%S',
+      level=logging.DEBUG)
+
+  logging.info('Starting up on port=%s', args.port)
+
+  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)
+  _sleep_forever()
+
+if __name__ == '__main__':
+  main(sys.argv)
-- 
GitLab