diff --git a/BUILD b/BUILD index 85e0b4481612119eb71af9582b73f3f32d87523f..95db5434731bc7f77186d609dc2cef6ec0673783 100644 --- a/BUILD +++ b/BUILD @@ -1,6 +1,6 @@ load("@rules_python//python:defs.bzl", "py_binary", "py_library") load("@pip//:requirements.bzl", "requirement") -load("@rules_pkg//:pkg.bzl", "pkg_tar", "pkg_deb") +load("@rules_pkg//:pkg.bzl", "pkg_deb", "pkg_tar") py_library( name = "account", @@ -45,7 +45,7 @@ py_library( srcs = ["metrics.py"], deps = [ requirement("libpurecool"), - requirement("prometheus_client") + requirement("prometheus_client"), ], ) @@ -55,9 +55,10 @@ py_test( deps = [ ":metrics", requirement("libpurecool"), - requirement("prometheus_client") + requirement("prometheus_client"), ], ) + py_binary( name = "main", srcs = ["main.py"], @@ -66,49 +67,50 @@ py_binary( ":config", ":libpurecool_adapter", ":metrics", - requirement("prometheus_client") + requirement("prometheus_client"), + requirement("libdyson"), ], ) pkg_tar( name = "deb-bin", - package_dir = "/opt/prometheus-dyson/bin", # This depends on --build_python_zip. srcs = [":main"], mode = "0755", + package_dir = "/opt/prometheus-dyson/bin", ) pkg_tar( name = "deb-config-sample", - package_dir = "/etc/prometheus-dyson", srcs = ["config-sample.ini"], mode = "0644", + package_dir = "/etc/prometheus-dyson", ) pkg_tar( name = "deb-default", - package_dir = "/etc/default", srcs = ["debian/prometheus-dyson"], mode = "0644", - strip_prefix = "debian/" + package_dir = "/etc/default", + strip_prefix = "debian/", ) pkg_tar( name = "deb-service", - package_dir = "/lib/systemd/system", srcs = ["debian/prometheus-dyson.service"], mode = "0644", - strip_prefix = "debian/" + package_dir = "/lib/systemd/system", + strip_prefix = "debian/", ) pkg_tar( name = "debian-data", deps = [ - ":deb-bin", - ":deb-config-sample", - ":deb-default", - ":deb-service", - ] + ":deb-bin", + ":deb-config-sample", + ":deb-default", + ":deb-service", + ], ) pkg_deb( @@ -120,10 +122,10 @@ pkg_deb( depends = [ "python3", ], - prerm = "debian/prerm", - postrm = "debian/postrm", description_file = "debian/description", maintainer = "Sean Rees <sean at erifax.org>", package = "prometheus-dyson", - version = "0.1.0", + postrm = "debian/postrm", + prerm = "debian/prerm", + version = "0.1.1", ) diff --git a/config.py b/config.py index 252b90bc487f963f3487b2030c53a45fa228ccbb..3dee251379d3b113c7b5ae4f39a0488d1c20a20c 100644 --- a/config.py +++ b/config.py @@ -2,20 +2,28 @@ import collections import configparser -import copy import logging from typing import Dict, List, Optional +Device = collections.namedtuple( + 'Device', ['name', 'serial', 'credentials', 'product_type']) + DysonLinkCredentials = collections.namedtuple( 'DysonLinkCredentials', ['username', 'password', 'country']) class Config: + """Reads the configuration file and provides handy accessors. + + 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) - def load(self, filename: str): + @classmethod + def load(cls, filename: str): """Reads configuration file. Returns DysonLinkCredentials or None on error, and a dict of @@ -35,6 +43,17 @@ class Config: @property def dyson_credentials(self) -> Optional[DysonLinkCredentials]: + """Cloud Dyson API credentials. + + In the config, this looks like: + [Dyson Link] + username = user + password = pass + country = XX + + Returns: + DysonLinkCredentials. + """ try: username = self._config['Dyson Link']['username'] password = self._config['Dyson Link']['password'] @@ -68,7 +87,7 @@ class Config: return {h[0].upper(): h[1] for h in hosts} @property - def devices(self) -> List[object]: + def devices(self) -> List[Device]: """Consumes all sections looking for device entries. A device looks a bit like this: @@ -80,19 +99,20 @@ class Config: ... (and a few other fields) Returns: - A list of dict-like objects. This interface is unstable; do not rely on it. + A list of Device objects. """ sections = self._config.sections() ret = [] - for s in sections: - if not self._config.has_option(s, 'LocalCredentials'): + for sect in sections: + if not self._config.has_option(sect, 'LocalCredentials'): # This is probably not a device entry, so ignore it. continue - # configparser returns a dict-like type here with case-insensitive keys. This is an effective - # stand-in for the type that libpurecool expects, and a straightforward to thing to change - # as we move towards libdyson's API. - ret.append(copy.deepcopy(self._config[s])) + ret.append(Device( + self._config[sect]['Name'], + self._config[sect]['Serial'], + self._config[sect]['LocalCredentials'], + self._config[sect]['ProductType'])) return ret diff --git a/config_test.py b/config_test.py index e9df496ede9cbb5abbafeea7cbd06f7c06e94a45..0b74732233d79a6b99b285baf171d284147ac333 100644 --- a/config_test.py +++ b/config_test.py @@ -5,8 +5,8 @@ import unittest import config -empty = '' -good = """ +EMPTY = '' +GOOD = """ [Dyson Link] username = Username password = Password @@ -39,17 +39,18 @@ producttype = 455 class TestConfig(unittest.TestCase): def setUp(self): - self._empty_file = self.createTemporaryFile(empty) + self._empty_file = self.create_temporary_file(EMPTY) self.empty = config.Config(self._empty_file.name) - self._good_file = self.createTemporaryFile(good) + self._good_file = self.create_temporary_file(GOOD) self.good = config.Config(self._good_file.name) def tearDown(self): self._empty_file.close() self._good_file.close() - def createTemporaryFile(self, contents: str): + @classmethod + def create_temporary_file(cls, contents: str): ret = tempfile.NamedTemporaryFile() ret.write(contents.encode('utf-8')) ret.flush() @@ -58,10 +59,10 @@ class TestConfig(unittest.TestCase): def testDysonCredentials(self): self.assertIsNone(self.empty.dyson_credentials) - c = self.good.dyson_credentials - self.assertEqual(c.username, 'Username') - self.assertEqual(c.password, 'Password') - self.assertEqual(c.country, 'IE') + creds = self.good.dyson_credentials + self.assertEqual(creds.username, 'Username') + self.assertEqual(creds.password, 'Password') + self.assertEqual(creds.country, 'IE') def testHosts(self): self.assertTrue(not self.empty.hosts) @@ -71,8 +72,8 @@ class TestConfig(unittest.TestCase): self.assertEqual(len(self.empty.devices), 0) self.assertEqual(len(self.good.devices), 2) - self.assertEqual(self.good.devices[0]['name'], 'Living room') - self.assertEqual(self.good.devices[1]['Name'], 'Bedroom') + self.assertEqual(self.good.devices[0].name, 'Living room') + self.assertEqual(self.good.devices[1].name, 'Bedroom') if __name__ == '__main__': diff --git a/libpurecool_adapter.py b/libpurecool_adapter.py index 3e385f94b2b4c9b2d0fd1e1a472815090c04f75d..509dcefd2d5b7646316df257a30a114c2e7d7f92 100644 --- a/libpurecool_adapter.py +++ b/libpurecool_adapter.py @@ -1,50 +1,47 @@ """An adapter to use libpurecool's Dyson support without the Cloud API.""" -import collections import logging -from typing import Callable, Dict, List, Optional +from typing import Optional from libpurecool import dyson, dyson_device -class DysonAccountCache: - def __init__(self, device_cache: List[Dict[str, str]]): - self._devices = self._load(device_cache) - - def _identify(self, device: Dict[str, str]) -> Optional[Callable[[object], object]]: - if dyson.is_360_eye_device(device): - logging.info( - 'Identified %s as a Dyson 360 Eye device which is unsupported (ignoring)') - return None - elif dyson.is_heating_device(device): - logging.info( - 'Identified %s as a Dyson Pure Hot+Cool Link (V1) device', device['Serial']) - return dyson.DysonPureHotCoolLink - elif dyson.is_dyson_pure_cool_device(device): - logging.info( - 'Identified %s as a Dyson Pure Cool (V2) device', device['Serial']) - return dyson.DysonPureCool - elif dyson.is_heating_device_v2(device): - logging.info( - 'Identified %s as a Dyson Pure Hot+Cool (V2) device', device['Serial']) - return dyson.DysonPureHotCool - else: - logging.info( - 'Identified %s as a Dyson Pure Cool Link (V1) device', device['Serial']) - return dyson.DysonPureCoolLink - - def _load(self, device_cache: List[Dict[str, str]]): - ret = [] - - # Monkey-patch this as we store the local credential unencrypted. - dyson_device.decrypt_password = lambda s: s - - for d in device_cache: - typ = self._identify(d) - if typ: - ret.append(typ(d)) - - return ret - - def devices(self): - return self._devices +# 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 index 23e49e735a200ff97eabb378120f125928cf8cb1..093e0f2753f3f2de4f3fb2aa6b5572839d82644d 100644 --- a/libpurecool_adapter_test.py +++ b/libpurecool_adapter_test.py @@ -1,54 +1,30 @@ """Unit test for the libpurecool_adapter module.""" -import configparser import unittest -import libpurecool_adapter - from libpurecool import dyson, const +import libpurecool_adapter + class TestLibpurecoolAdapter(unittest.TestCase): - def testIdentify(self): - def makeStub(p): return {'ProductType': p, 'Serial': 'serial'} - - c = configparser.ConfigParser() - c['360Eye'] = makeStub(const.DYSON_360_EYE) - c['CoolLinkV1'] = makeStub(const.DYSON_PURE_COOL_LINK_DESK) - c['CoolV2'] = makeStub(const.DYSON_PURE_COOL) - c['HotCoolLinkV1'] = makeStub(const.DYSON_PURE_HOT_COOL_LINK_TOUR) - c['HotCoolV2'] = makeStub(const.DYSON_PURE_HOT_COOL) - - ac = libpurecool_adapter.DysonAccountCache([]) - self.assertIsNone(ac._identify(c['360Eye'])) - self.assertEqual(ac._identify( - c['CoolLinkV1']), dyson.DysonPureCoolLink) - self.assertEqual(ac._identify(c['CoolV2']), dyson.DysonPureCool) - self.assertEqual(ac._identify( - c['HotCoolLinkV1']), dyson.DysonPureHotCoolLink) - self.assertEqual(ac._identify(c['HotCoolV2']), dyson.DysonPureHotCool) - - def testLoad(self): - devices = [ - {'Active': 'true', 'Name': 'first', 'Serial': 'AB1-US-12345678', 'Version': '1.0', - 'LocalCredentials': 'ABCD', 'AutoUpdate': 'true', 'NewVersionAvailable': 'true', - 'ProductType': '455'}, # 455 = Pure Hot+Cool Link (V1) - {'Active': 'true', 'Name': 'ignore', 'Serial': 'AB2-US-12345678', 'Version': '1.0', - 'LocalCredentials': 'ABCD', 'AutoUpdate': 'true', 'NewVersionAvailable': 'true', - 'ProductType': 'N223'}, # N223 = 360 Eye (we should skip this) - {'Active': 'true', 'Name': 'third', 'Serial': 'AB3-US-12345678', 'Version': '1.0', - 'LocalCredentials': 'ABCD', 'AutoUpdate': 'true', 'NewVersionAvailable': 'true', - 'ProductType': '438'} # 438 = Pure Cool (V2) - ] - - ac = libpurecool_adapter.DysonAccountCache(devices) - devices = ac.devices() - self.assertEqual(len(devices), 2) - self.assertEqual(devices[0].name, 'first') - self.assertEqual(devices[1].name, 'third') - - ac = libpurecool_adapter.DysonAccountCache([]) - self.assertEqual(len(ac.devices()), 0) + 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__': diff --git a/main.py b/main.py index b280d58b5cccecb639e9eeaef700244a963d08b7..70d303675e96381207bbdcd71aa3d4a9444c74cc 100755 --- a/main.py +++ b/main.py @@ -11,59 +11,103 @@ import logging import sys import time -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict import prometheus_client # type: ignore[import] +import libdyson # type: ignore[import] import account import config import libpurecool_adapter -from metrics import Metrics +import metrics -class DysonClient: - """Connects to and monitors Dyson fans.""" +class DeviceWrapper: + """Wraps a configured device and holds onto the underlying Dyson device + object.""" - def __init__(self, device_cache: List[Dict[str, str]], hosts: Optional[Dict] = None): - self._account = libpurecool_adapter.DysonAccountCache(device_cache) - self.hosts = hosts or {} + def __init__(self, device: config.Device): + self._device = device + self.libdyson = self._create_libdyson_device() + self.libpurecool = self._create_libpurecool_device() - def monitor(self, update_fn: Callable[[str, str, object], None], only_active=True) -> None: - """Sets up a background monitoring thread on each device. + @property + def name(self) -> str: + """Returns device name, e.g; 'Living Room'""" + return self._device.name + + @property + def serial(self) -> str: + """Returns device serial number, e.g; AB1-XX-1234ABCD""" + return self._device.serial + + def _create_libdyson_device(self): + return libdyson.get_device(self.serial, self._device.credentials, self._device.product_type) + + def _create_libpurecool_device(self): + return libpurecool_adapter.get_device(self.name, self.serial, + self._device.credentials, self._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 + 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]): + self._update_fn = update_fn + self._hosts = hosts + + logging.info('Starting discovery...') + self._discovery = libdyson.discovery.DysonDiscovery() + self._discovery.start_discovery() + + def add_device(self, device: config.Device, 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: - 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. + 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. """ - 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 - - manual_ip = self.hosts.get(dev.serial.upper()) - if manual_ip: - logging.info('Attempting connection to device "%s" (serial=%s) via configured IP %s', - dev.name, dev.serial, manual_ip) - connected = dev.connect(manual_ip) - else: - logging.info('Attempting to discover device "%s" (serial=%s) via zeroconf', - dev.name, dev.serial) - 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) + wrap = DeviceWrapper(device) + + if add_listener: + wrap.libpurecool.add_message_listener( + functools.partial(self._lpc_callback, wrap)) + + manual_ip = self._hosts.get(wrap.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) + 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) + + @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.libpurecool.connect(address) + + def _lpc_callback(self, device: DeviceWrapper, message): + logging.debug('Received update from %s: %s', device.serial, message) + self._update_fn(device.name, device.serial, message) def _sleep_forever() -> None: @@ -83,7 +127,12 @@ def main(argv): parser.add_argument( '--config', help='Configuration file (INI file)', default='config.ini') parser.add_argument('--create_device_cache', - help='Performs a one-time login to Dyson to locally cache device information. Use this for the first invocation of this binary or when you add/remove devices.', action='store_true') + help=('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.'), + action='store_true') parser.add_argument( '--log_level', help='Logging level (DEBUG, INFO, WARNING, ERROR)', type=str, default='INFO') parser.add_argument( @@ -100,14 +149,15 @@ def main(argv): args = parser.parse_args() logging.basicConfig( - format='%(asctime)s %(levelname)10s %(message)s', + format='%(asctime)s [%(thread)d] %(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') + logging.warning( + '--include_inactive_devices is now inoperative and will be removed in a future release') try: cfg = config.Config(args.config) @@ -116,7 +166,7 @@ def main(argv): sys.exit(-1) devices = cfg.devices - if not len(devices): + if len(devices) == 0: logging.fatal( 'No devices configured; please re-run this program with --create_device_cache.') sys.exit(-2) @@ -127,12 +177,12 @@ def main(argv): account.generate_device_cache(cfg.dyson_credentials, args.config) sys.exit(0) - metrics = Metrics() prometheus_client.start_http_server(args.port) - client = DysonClient(devices, cfg.hosts) - client.monitor( - metrics.update, only_active=not args.include_inactive_devices) + connect_mgr = ConnectionManager(metrics.Metrics().update, cfg.hosts) + for dev in devices: + connect_mgr.add_device(dev) + _sleep_forever()