diff --git a/BUILD b/BUILD index 9230b07daa27d6d95fd3759f126365e068ea7ce4..8e98e4eca6eb225a2cfc90ff674528a0fcc5131a 100644 --- a/BUILD +++ b/BUILD @@ -5,6 +5,7 @@ load("@rules_pkg//:pkg.bzl", "pkg_deb", "pkg_tar") py_library( name = "config", srcs = ["config.py"], + visibility = ["//:__subpackages__"] ) py_test( @@ -15,6 +16,16 @@ py_test( ], ) +py_library( + name = "connect", + srcs = ["connect.py"], + visibility = ["//:__subpackages__"], + deps = [ + ":config", + requirement("libdyson"), + ], +) + py_library( name = "metrics", srcs = ["metrics.py"], @@ -39,9 +50,9 @@ py_binary( srcs = ["main.py"], deps = [ ":config", + ":connect", ":metrics", requirement("prometheus_client"), - requirement("libdyson"), ], ) diff --git a/config.py b/config.py index ba58310f00911c608df0278cf71e6fb3e6b9b180..79a8a4996104802769fabdabb15b5fe1080e475c 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,8 @@ Device = collections.namedtuple( DysonLinkCredentials = collections.namedtuple( 'DysonLinkCredentials', ['username', 'password', 'country']) +logger = logging.getLogger(__name__) + class Config: """Reads the configuration file and provides handy accessors. @@ -18,6 +20,7 @@ class Config: Args: filename: path (absolute or relative) to the config file (ini format). """ + def __init__(self, filename: str): self._filename = filename self._config = self.load(filename) @@ -31,12 +34,12 @@ class Config: """ config = configparser.ConfigParser() - logging.info('Reading "%s"', filename) + logger.info('Reading "%s"', filename) try: config.read(filename) except configparser.Error as ex: - logging.critical('Could not read "%s": %s', filename, ex) + logger.critical('Could not read "%s": %s', filename, ex) raise ex return config @@ -60,7 +63,7 @@ class Config: country = self._config['Dyson Link']['country'] return DysonLinkCredentials(username, password, country) except KeyError as ex: - logging.warning( + logger.warning( 'Required key missing in "%s": %s', self._filename, ex) return None @@ -78,7 +81,7 @@ class Config: hosts = self._config.items('Hosts') except configparser.NoSectionError: hosts = [] - logging.debug( + logger.debug( 'No "Hosts" section found in config file, no manual IP overrides are available') # Convert the hosts tuple (('serial0', 'ip0'), ('serial1', 'ip1')) diff --git a/config_builder.py b/config_builder.py index cda99a34b635136e2c76b721203ff2b4ee79c878..b00d6b8d3eca6da8e82384a91a01d6d066467caf 100644 --- a/config_builder.py +++ b/config_builder.py @@ -19,6 +19,8 @@ from libdyson.exceptions import DysonOTPTooFrequently, DysonLoginFailure import config +logger = logging.getLogger(__name__) + def _query_credentials() -> config.DysonLinkCredentials: """Asks the user for their DysonLink/Cloud credentials. @@ -170,7 +172,7 @@ def main(argv): args = parser.parse_args() logging.basicConfig( - format='%(asctime)s [%(thread)d] %(levelname)10s %(message)s', + format='%(asctime)s [%(name)8s %(thread)d] %(levelname)10s %(message)s', datefmt='%Y/%m/%d %H:%M:%S', level=level) @@ -188,7 +190,7 @@ def main(argv): creds = cfg.dyson_credentials hosts = cfg.hosts except: - logging.info( + logger.info( 'Could not load configuration: %s (assuming no configuration)', args.config) if args.mode == 'cloud': diff --git a/connect.py b/connect.py new file mode 100644 index 0000000000000000000000000000000000000000..4a4716c947dbccb3e3cb690d9c823e344f82b216 --- /dev/null +++ b/connect.py @@ -0,0 +1,191 @@ +"""Wraps libdyson's connections with support for config & retries.""" + +import functools +import logging +import threading + +from typing import Callable, Dict, List, Optional + +import libdyson +import libdyson.dyson_device +import libdyson.exceptions + +import config + +logger = logging.getLogger(__name__) + + +class DeviceWrapper: + """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. + + 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._environment_timer : Optional[threading.Timer] = None + self._timeout_timer : Optional[threading.Timer] = None + self.libdyson = self._create_libdyson_device() + + @property + def name(self) -> str: + """Returns device name, e.g; 'Living Room'.""" + return self._config_device.name + + @property + def serial(self) -> str: + """Returns device serial number, e.g; AB1-XX-1234ABCD.""" + return self._config_device.serial + + @property + def is_connected(self) -> bool: + """True if we're connected to the Dyson device.""" + return self.libdyson.is_connected + + def connect(self, host: str, retry_on_timeout_secs: int = 30): + """Connect to the device and start the environmental monitoring timer. + + Args: + host: ip or hostname of Dyson device + retry_on_timeout_secs: number of seconds to wait in between retries. this will block the running thread. + """ + self._timeout_timer = None + + if self.is_connected: + logger.info( + 'Already connected to %s (%s); no need to reconnect.', host, self.serial) + else: + try: + self.libdyson.connect(host) + self._refresh_timer() + except libdyson.exceptions.DysonConnectTimeout: + logger.error( + 'Timeout connecting to %s (%s); will retry', host, self.serial) + self._timeout_timer = threading.Timer( + retry_on_timeout_secs, self.connect, args=[host]) + self._timeout_timer.start() + + def disconnect(self): + """Disconnect from the Dyson device.""" + if self._environment_timer: + self._environment_timer.cancel() + if self._timeout_timer: + self._timeout_timer.cancel() + + self.libdyson.disconnect() + + def _refresh_timer(self): + self._environment_timer = threading.Timer(self._environment_refresh_secs, + self._timer_callback) + self._environment_timer.start() + + def _timer_callback(self): + self._environment_timer = None + + if self.is_connected: + logger.debug( + 'Requesting updated environmental data from %s', self.serial) + try: + self.libdyson.request_environmental_data() + except AttributeError: + logger.error('Race with a disconnect? Skipping an iteration.') + self._refresh_timer() + else: + logger.debug('Device %s is disconnected.', self.serial) + + def _create_libdyson_device(self): + return libdyson.get_device(self.serial, self._config_device.credentials, + self._config_device.product_type) + + +class ConnectionManager: + """Manages connections via manual IP or via libdyson Discovery. + + Args: + update_fn: A callable taking a name, serial, + devices: a list of config.Device entities + hosts: a dict of serial -> IP address, for direct (non-zeroconf) connections. + """ + + def __init__(self, update_fn: Callable[[str, str, bool, bool], None], + devices: List[config.Device], hosts: Dict[str, str], reconnect: bool = True): + self._update_fn = update_fn + self._hosts = hosts + self._reconnect = reconnect + self._devices = [DeviceWrapper(d) for d in devices] + + logger.info('Starting discovery...') + self._discovery = libdyson.discovery.DysonDiscovery() + self._discovery.start_discovery() + + for device in self._devices: + self._add_device(device) + + def shutdown(self) -> None: + """Disconnects from all devices.""" + self._discovery.stop_discovery() + + for device in self._devices: + logger.info('Disconnecting from %s (%s)', device.name, device.serial) + device.disconnect() + + 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 + initialisation, otherwise we will attempt discovery via zeroconf. + + Args: + device: a config.Device to add + add_listener: if True, will add callback listeners. Set to False if + add_device() has been called on this device already. + """ + if add_listener: + callback_fn = functools.partial(self._device_callback, device) + device.libdyson.add_message_listener(callback_fn) + + manual_ip = self._hosts.get(device.serial.upper()) + if manual_ip: + logger.info('Attempting connection to device "%s" (serial=%s) via configured IP %s', + device.name, device.serial, manual_ip) + device.connect(manual_ip) + else: + logger.info('Attempting to discover device "%s" (serial=%s) via zeroconf', + device.name, device.serial) + callback_fn = functools.partial(self._discovery_callback, device) + self._discovery.register_device(device.libdyson, callback_fn) + + @classmethod + def _discovery_callback(cls, device: DeviceWrapper, address: str): + # A note on concurrency: used with DysonDiscovery, this will be called + # back in a separate thread created by the underlying zeroconf library. + # When we call connect() on libpurecool or libdyson, that code spawns + # a new thread for MQTT and returns. In other words: we don't need to + # worry about connect() blocking zeroconf here. + logger.info('Discovered %s on %s', device.serial, address) + device.connect(address) + + def _device_callback(self, device, message): + logger.debug('Received update from %s: %s', device.serial, message) + if not device.is_connected and self._reconnect: + logger.info( + 'Device %s is now disconnected, clearing it and re-adding', device.serial) + device.disconnect() + self._discovery.stop_discovery() + self._discovery.start_discovery() + 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) + diff --git a/main.py b/main.py index 79c3e59a94f6973578f58233c1db30f20bcd8b7f..46175695f8430e6227cbf09c0f0cb0a285fc9483 100755 --- a/main.py +++ b/main.py @@ -2,174 +2,18 @@ """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, List import prometheus_client -import libdyson -import libdyson.dyson_device -import libdyson.exceptions import config +import connect import metrics -class DeviceWrapper: - """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. - - 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() - - @property - def name(self) -> str: - """Returns device name, e.g; 'Living Room'.""" - return self._config_device.name - - @property - def serial(self) -> str: - """Returns device serial number, e.g; AB1-XX-1234ABCD.""" - return self._config_device.serial - - @property - def is_connected(self) -> bool: - """True if we're connected to the Dyson device.""" - return self.libdyson.is_connected - - def connect(self, host: str, retry_on_timeout_secs: int = 30): - """Connect to the device and start the environmental monitoring timer. - - Args: - host: ip or hostname of Dyson device - retry_on_timeout_secs: number of seconds to wait in between retries. this will block the running thread. - """ - if self.is_connected: - logging.info( - 'Already connected to %s (%s); no need to reconnect.', host, self.serial) - else: - try: - self.libdyson.connect(host) - self._refresh_timer() - except libdyson.exceptions.DysonConnectTimeout: - logging.error( - 'Timeout connecting to %s (%s); will retry', host, self.serial) - threading.Timer(retry_on_timeout_secs, - self.connect, args=[host]).start() - - 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) - try: - self.libdyson.request_environmental_data() - except AttributeError: - logging.error('Race with a disconnect? Skipping an iteration.') - self._refresh_timer() - else: - logging.debug('Device %s is disconnected.', self.serial) - - def _create_libdyson_device(self): - return libdyson.get_device(self.serial, self._config_device.credentials, - self._config_device.product_type) - - -class ConnectionManager: - """Manages connections via manual IP or via libdyson Discovery. - - Args: - update_fn: A callable taking a name, serial, - devices: a list of config.Device entities - hosts: a dict of serial -> IP address, for direct (non-zeroconf) connections. - """ - - def __init__(self, update_fn: Callable[[str, str, bool, bool], None], - devices: List[config.Device], hosts: Dict[str, str]): - self._update_fn = update_fn - self._hosts = hosts - - logging.info('Starting discovery...') - self._discovery = libdyson.discovery.DysonDiscovery() - self._discovery.start_discovery() - - 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 - initialisation, otherwise we will attempt discovery via zeroconf. - - Args: - device: a config.Device to add - add_listener: if True, will add callback listeners. Set to False if - add_device() has been called on this device already. - """ - if add_listener: - callback_fn = functools.partial(self._device_callback, device) - device.libdyson.add_message_listener(callback_fn) - - manual_ip = self._hosts.get(device.serial.upper()) - if manual_ip: - logging.info('Attempting connection to device "%s" (serial=%s) via configured IP %s', - device.name, device.serial, manual_ip) - device.connect(manual_ip) - else: - logging.info('Attempting to discover device "%s" (serial=%s) via zeroconf', - device.name, device.serial) - callback_fn = functools.partial(self._discovery_callback, device) - self._discovery.register_device(device.libdyson, callback_fn) - - @classmethod - def _discovery_callback(cls, device: DeviceWrapper, address: str): - # A note on concurrency: used with DysonDiscovery, this will be called - # back in a separate thread created by the underlying zeroconf library. - # When we call connect() on libpurecool or libdyson, that code spawns - # a new thread for MQTT and returns. In other words: we don't need to - # worry about connect() blocking zeroconf here. - logging.info('Discovered %s on %s', device.serial, address) - device.connect(address) - - def _device_callback(self, device, message): - logging.debug('Received update from %s: %s', device.serial, message) - if not device.is_connected: - logging.info( - 'Device %s is now disconnected, clearing it and re-adding', device.serial) - device.disconnect() - self._discovery.stop_discovery() - self._discovery.start_discovery() - 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) +logger = logging.getLogger(__name__) def _sleep_forever() -> None: @@ -204,31 +48,31 @@ def main(argv): args = parser.parse_args() logging.basicConfig( - format='%(asctime)s [%(thread)d] %(levelname)10s %(message)s', + format='%(asctime)s [%(name)24s %(thread)d] %(levelname)10s %(message)s', datefmt='%Y/%m/%d %H:%M:%S', level=level) - logging.info('Starting up on port=%s', args.port) + logger.info('Starting up on port=%s', args.port) if args.include_inactive_devices: - logging.warning( + logger.warning( '--include_inactive_devices is now inoperative and will be removed in a future release') try: cfg = config.Config(args.config) except: - logging.exception('Could not load configuration: %s', args.config) + logger.exception('Could not load configuration: %s', args.config) sys.exit(-1) devices = cfg.devices if len(devices) == 0: - logging.fatal( + logger.fatal( 'No devices configured; please re-run this program with --create_device_cache.') sys.exit(-2) prometheus_client.start_http_server(args.port) - ConnectionManager(metrics.Metrics().update, devices, cfg.hosts) + connect.ConnectionManager(metrics.Metrics().update, devices, cfg.hosts) _sleep_forever() diff --git a/metrics.py b/metrics.py index 0dd8a6c1f9e7671e333808c6e6f4277d42800495..e8c4fdee86d45cc142fcbd4e8505699e2799cbe8 100644 --- a/metrics.py +++ b/metrics.py @@ -16,6 +16,8 @@ from prometheus_client import Gauge, Enum, REGISTRY # slightly rounded value instead. KELVIN_TO_CELSIUS = -273 +logger = logging.getLogger(__name__) + def enum_values(cls): return [x.value for x in list(cls)] @@ -190,7 +192,7 @@ class Metrics: is_enviromental: is an environmental (temperature, humidity, etc) update. """ if not device: - logging.error('Ignoring update, device is None') + logger.error('Ignoring update, device is None') serial = device.serial @@ -207,8 +209,8 @@ class Metrics: 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(device)) + logger.warning('Received unknown update from "%s" (serial=%s): %s; ignoring', + name, serial, type(device)) def update_v1_environmental(self, name: str, device) -> None: self.update_common_environmental(name, device) @@ -238,7 +240,8 @@ class Metrics: update_env_gauge(self.nox, name, device.serial, nox) if isinstance(device, libdyson.DysonPureCoolFormaldehyde): - update_env_gauge(self.formaldehyde, name, device.serial, device.formaldehyde) + update_env_gauge(self.formaldehyde, name, + device.serial, device.formaldehyde) def update_common_environmental(self, name: str, device) -> None: update_gauge(self.last_update_environmental, diff --git a/misc/BUILD b/misc/BUILD new file mode 100644 index 0000000000000000000000000000000000000000..dfe82c82ebf5b3552fb495588aaa62a26a9132ed --- /dev/null +++ b/misc/BUILD @@ -0,0 +1,14 @@ +load("@rules_python//python:defs.bzl", "py_binary", "py_library") +load("@pip//:requirements.bzl", "requirement") +load("@rules_pkg//:pkg.bzl", "pkg_deb", "pkg_tar") + +py_binary( + name = "switch_heat_mode", + srcs = ["switch_heat_mode.py"], + deps = [ + "//:config", + "//:connect", + requirement("prometheus_client"), + requirement("libdyson"), + ], +) diff --git a/misc/switch_heat_mode.py b/misc/switch_heat_mode.py new file mode 100644 index 0000000000000000000000000000000000000000..d1b8a3f4efc19b59480cf75aaee8fbbd960fbf15 --- /dev/null +++ b/misc/switch_heat_mode.py @@ -0,0 +1,135 @@ +#!/usr/bin/python3 +"""Toggles the heat mode for a Dyson heating fan on or off.""" + +import argparse +import functools +import logging +import sys +import threading + +import config +import connect + +import libdyson.dyson_device + +logger = logging.getLogger(__name__) + +_one_more_event = threading.Event() +_ok_to_shutdown = threading.Event() + + +def device_callback( + want_heat_mode: str, + name: str, + device: libdyson.dyson_device.DysonFanDevice, + is_state=False, + is_environmental=False, +) -> None: + """Callback for libdyson. + + Args: + want_heat_mode: string, "on" if you want heat turned on, "off" if you want heat turned off. + """ + # If we sent a command to the device (e.g; enable or disable heat), we need to wait + # for one more update to confirm the device mode change. Once we have it, we can + # shutdown. + if _one_more_event.is_set(): + _ok_to_shutdown.set() + return + + if is_state: + current_heat_mode_is_on = device.heat_mode_is_on + want_heat_on = want_heat_mode == 'on' + + if current_heat_mode_is_on == want_heat_on: + logger.info( + 'Fan heat for %s (%s) already in desired state of %s', + name, + device.serial, + want_heat_mode.upper(), + ) + _ok_to_shutdown.set() + return + + if want_heat_mode == 'off': + device.disable_heat_mode() + else: + device.enable_heat_mode() + + logger.info( + 'Turning %s heat on %s (%s)', want_heat_mode.upper( + ), name, device.serial + ) + _one_more_event.set() + + +turn_on_heat = functools.partial(device_callback, 'on') +turn_off_heat = functools.partial(device_callback, 'off') + + +def main(argv): + """Main body of the program.""" + parser = argparse.ArgumentParser(prog=argv[0]) + parser.add_argument( + '--config', help='Configuration file (INI file)', default='config.ini') + parser.add_argument( + '--device', help='Device name (from config) to operate on') + parser.add_argument( + '--heat_mode', help='Desired mode, on or off', default='off') + parser.add_argument( + '--log_level', + help='Logging level (DEBUG, INFO, WARNING, ERROR)', + type=str, + default='INFO', + ) + 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 [%(name)24s %(thread)d] %(levelname)10s %(message)s', + datefmt='%Y/%m/%d %H:%M:%S', + level=level, + ) + + try: + cfg = config.Config(args.config) + except: + logger.exception('Could not load configuration: %s', args.config) + sys.exit(-1) + + devices = cfg.devices + if len(devices) == 0: + logger.fatal( + 'No devices configured; please re-run this program with --create_device_cache.' + ) + sys.exit(-2) + + dev = [d for d in devices if d.name == args.device] + if not dev: + logger.fatal( + 'Could not find device "%s" in configuration', args.device) + sys.exit(-3) + + if args.heat_mode == 'on': + callback_fn = turn_on_heat + elif args.heat_mode == 'off': + callback_fn = turn_off_heat + else: + logger.fatal('Invalid --heat_mode, must be one of "on" or "off"') + sys.exit(-3) + + conn_mgr = connect.ConnectionManager( + callback_fn, dev, cfg.hosts, reconnect=False) + + _ok_to_shutdown.wait() + conn_mgr.shutdown() + + +if __name__ == '__main__': + main(sys.argv)