Private GIT

Skip to content
Snippets Groups Projects
Commit 792f39c6 authored by Sean Rees's avatar Sean Rees
Browse files

Switch to libdyson-based discovery

This is the second part of a three-part change to refactor this code
from libpurecool to libdyson. This part replaces the discovery components
(e.g; DysonDevice.auto_connect in libpurecool) to the separate API provided
by libdyson. This also adapts the libpurecool_adapter shim to have a
libdyson-like get_device() method to make switching fully over easier in
the next change.
parent 56441f5c
Branches
No related tags found
No related merge requests found
load("@rules_python//python:defs.bzl", "py_binary", "py_library") load("@rules_python//python:defs.bzl", "py_binary", "py_library")
load("@pip//:requirements.bzl", "requirement") 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( py_library(
name = "account", name = "account",
...@@ -45,7 +45,7 @@ py_library( ...@@ -45,7 +45,7 @@ py_library(
srcs = ["metrics.py"], srcs = ["metrics.py"],
deps = [ deps = [
requirement("libpurecool"), requirement("libpurecool"),
requirement("prometheus_client") requirement("prometheus_client"),
], ],
) )
...@@ -55,9 +55,10 @@ py_test( ...@@ -55,9 +55,10 @@ py_test(
deps = [ deps = [
":metrics", ":metrics",
requirement("libpurecool"), requirement("libpurecool"),
requirement("prometheus_client") requirement("prometheus_client"),
], ],
) )
py_binary( py_binary(
name = "main", name = "main",
srcs = ["main.py"], srcs = ["main.py"],
...@@ -66,39 +67,40 @@ py_binary( ...@@ -66,39 +67,40 @@ py_binary(
":config", ":config",
":libpurecool_adapter", ":libpurecool_adapter",
":metrics", ":metrics",
requirement("prometheus_client") requirement("prometheus_client"),
requirement("libdyson"),
], ],
) )
pkg_tar( pkg_tar(
name = "deb-bin", name = "deb-bin",
package_dir = "/opt/prometheus-dyson/bin",
# This depends on --build_python_zip. # This depends on --build_python_zip.
srcs = [":main"], srcs = [":main"],
mode = "0755", mode = "0755",
package_dir = "/opt/prometheus-dyson/bin",
) )
pkg_tar( pkg_tar(
name = "deb-config-sample", name = "deb-config-sample",
package_dir = "/etc/prometheus-dyson",
srcs = ["config-sample.ini"], srcs = ["config-sample.ini"],
mode = "0644", mode = "0644",
package_dir = "/etc/prometheus-dyson",
) )
pkg_tar( pkg_tar(
name = "deb-default", name = "deb-default",
package_dir = "/etc/default",
srcs = ["debian/prometheus-dyson"], srcs = ["debian/prometheus-dyson"],
mode = "0644", mode = "0644",
strip_prefix = "debian/" package_dir = "/etc/default",
strip_prefix = "debian/",
) )
pkg_tar( pkg_tar(
name = "deb-service", name = "deb-service",
package_dir = "/lib/systemd/system",
srcs = ["debian/prometheus-dyson.service"], srcs = ["debian/prometheus-dyson.service"],
mode = "0644", mode = "0644",
strip_prefix = "debian/" package_dir = "/lib/systemd/system",
strip_prefix = "debian/",
) )
pkg_tar( pkg_tar(
...@@ -108,7 +110,7 @@ pkg_tar( ...@@ -108,7 +110,7 @@ pkg_tar(
":deb-config-sample", ":deb-config-sample",
":deb-default", ":deb-default",
":deb-service", ":deb-service",
] ],
) )
pkg_deb( pkg_deb(
...@@ -120,10 +122,10 @@ pkg_deb( ...@@ -120,10 +122,10 @@ pkg_deb(
depends = [ depends = [
"python3", "python3",
], ],
prerm = "debian/prerm",
postrm = "debian/postrm",
description_file = "debian/description", description_file = "debian/description",
maintainer = "Sean Rees <sean at erifax.org>", maintainer = "Sean Rees <sean at erifax.org>",
package = "prometheus-dyson", package = "prometheus-dyson",
version = "0.1.0", postrm = "debian/postrm",
prerm = "debian/prerm",
version = "0.1.1",
) )
...@@ -2,20 +2,28 @@ ...@@ -2,20 +2,28 @@
import collections import collections
import configparser import configparser
import copy
import logging import logging
from typing import Dict, List, Optional from typing import Dict, List, Optional
Device = collections.namedtuple(
'Device', ['name', 'serial', 'credentials', 'product_type'])
DysonLinkCredentials = collections.namedtuple( DysonLinkCredentials = collections.namedtuple(
'DysonLinkCredentials', ['username', 'password', 'country']) 'DysonLinkCredentials', ['username', 'password', 'country'])
class Config: 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): def __init__(self, filename: str):
self._filename = filename self._filename = filename
self._config = self.load(filename) self._config = self.load(filename)
def load(self, filename: str): @classmethod
def load(cls, filename: str):
"""Reads configuration file. """Reads configuration file.
Returns DysonLinkCredentials or None on error, and a dict of Returns DysonLinkCredentials or None on error, and a dict of
...@@ -35,6 +43,17 @@ class Config: ...@@ -35,6 +43,17 @@ class Config:
@property @property
def dyson_credentials(self) -> Optional[DysonLinkCredentials]: 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: try:
username = self._config['Dyson Link']['username'] username = self._config['Dyson Link']['username']
password = self._config['Dyson Link']['password'] password = self._config['Dyson Link']['password']
...@@ -68,7 +87,7 @@ class Config: ...@@ -68,7 +87,7 @@ class Config:
return {h[0].upper(): h[1] for h in hosts} return {h[0].upper(): h[1] for h in hosts}
@property @property
def devices(self) -> List[object]: def devices(self) -> List[Device]:
"""Consumes all sections looking for device entries. """Consumes all sections looking for device entries.
A device looks a bit like this: A device looks a bit like this:
...@@ -80,19 +99,20 @@ class Config: ...@@ -80,19 +99,20 @@ class Config:
... (and a few other fields) ... (and a few other fields)
Returns: 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() sections = self._config.sections()
ret = [] ret = []
for s in sections: for sect in sections:
if not self._config.has_option(s, 'LocalCredentials'): if not self._config.has_option(sect, 'LocalCredentials'):
# This is probably not a device entry, so ignore it. # This is probably not a device entry, so ignore it.
continue continue
# configparser returns a dict-like type here with case-insensitive keys. This is an effective ret.append(Device(
# stand-in for the type that libpurecool expects, and a straightforward to thing to change self._config[sect]['Name'],
# as we move towards libdyson's API. self._config[sect]['Serial'],
ret.append(copy.deepcopy(self._config[s])) self._config[sect]['LocalCredentials'],
self._config[sect]['ProductType']))
return ret return ret
...@@ -5,8 +5,8 @@ import unittest ...@@ -5,8 +5,8 @@ import unittest
import config import config
empty = '' EMPTY = ''
good = """ GOOD = """
[Dyson Link] [Dyson Link]
username = Username username = Username
password = Password password = Password
...@@ -39,17 +39,18 @@ producttype = 455 ...@@ -39,17 +39,18 @@ producttype = 455
class TestConfig(unittest.TestCase): class TestConfig(unittest.TestCase):
def setUp(self): 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.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) self.good = config.Config(self._good_file.name)
def tearDown(self): def tearDown(self):
self._empty_file.close() self._empty_file.close()
self._good_file.close() self._good_file.close()
def createTemporaryFile(self, contents: str): @classmethod
def create_temporary_file(cls, contents: str):
ret = tempfile.NamedTemporaryFile() ret = tempfile.NamedTemporaryFile()
ret.write(contents.encode('utf-8')) ret.write(contents.encode('utf-8'))
ret.flush() ret.flush()
...@@ -58,10 +59,10 @@ class TestConfig(unittest.TestCase): ...@@ -58,10 +59,10 @@ class TestConfig(unittest.TestCase):
def testDysonCredentials(self): def testDysonCredentials(self):
self.assertIsNone(self.empty.dyson_credentials) self.assertIsNone(self.empty.dyson_credentials)
c = self.good.dyson_credentials creds = self.good.dyson_credentials
self.assertEqual(c.username, 'Username') self.assertEqual(creds.username, 'Username')
self.assertEqual(c.password, 'Password') self.assertEqual(creds.password, 'Password')
self.assertEqual(c.country, 'IE') self.assertEqual(creds.country, 'IE')
def testHosts(self): def testHosts(self):
self.assertTrue(not self.empty.hosts) self.assertTrue(not self.empty.hosts)
...@@ -71,8 +72,8 @@ class TestConfig(unittest.TestCase): ...@@ -71,8 +72,8 @@ class TestConfig(unittest.TestCase):
self.assertEqual(len(self.empty.devices), 0) self.assertEqual(len(self.empty.devices), 0)
self.assertEqual(len(self.good.devices), 2) self.assertEqual(len(self.good.devices), 2)
self.assertEqual(self.good.devices[0]['name'], 'Living room') self.assertEqual(self.good.devices[0].name, 'Living room')
self.assertEqual(self.good.devices[1]['Name'], 'Bedroom') self.assertEqual(self.good.devices[1].name, 'Bedroom')
if __name__ == '__main__': if __name__ == '__main__':
......
"""An adapter to use libpurecool's Dyson support without the Cloud API.""" """An adapter to use libpurecool's Dyson support without the Cloud API."""
import collections
import logging import logging
from typing import Callable, Dict, List, Optional from typing import Optional
from libpurecool import dyson, dyson_device from libpurecool import dyson, dyson_device
class DysonAccountCache: # We expect unencrypted credentials only, so monkey-patch this.
def __init__(self, device_cache: List[Dict[str, str]]): dyson_device.decrypt_password = lambda s: s
self._devices = self._load(device_cache)
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': ''}
def _identify(self, device: Dict[str, str]) -> Optional[Callable[[object], object]]:
if dyson.is_360_eye_device(device): if dyson.is_360_eye_device(device):
logging.info( logging.info(
'Identified %s as a Dyson 360 Eye device which is unsupported (ignoring)') 'Identified %s as a Dyson 360 Eye device which is unsupported (ignoring)')
return None return None
elif dyson.is_heating_device(device):
logging.info( if dyson.is_heating_device(device):
'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( logging.info(
'Identified %s as a Dyson Pure Hot+Cool (V2) device', device['Serial']) 'Identified %s as a Dyson Pure Hot+Cool Link (V1) device', serial)
return dyson.DysonPureHotCool return dyson.DysonPureHotCoolLink(device)
else: if dyson.is_dyson_pure_cool_device(device):
logging.info( logging.info(
'Identified %s as a Dyson Pure Cool Link (V1) device', device['Serial']) 'Identified %s as a Dyson Pure Cool (V2) device', serial)
return dyson.DysonPureCoolLink return dyson.DysonPureCool(device)
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 if dyson.is_heating_device_v2(device):
logging.info(
'Identified %s as a Dyson Pure Hot+Cool (V2) device',serial)
return dyson.DysonPureHotCool(device)
def devices(self): # Last chance.
return self._devices logging.info('Identified %s as a Dyson Pure Cool Link (V1) device', serial)
return dyson.DysonPureCoolLink(device)
"""Unit test for the libpurecool_adapter module.""" """Unit test for the libpurecool_adapter module."""
import configparser
import unittest import unittest
import libpurecool_adapter
from libpurecool import dyson, const from libpurecool import dyson, const
import libpurecool_adapter
class TestLibpurecoolAdapter(unittest.TestCase): class TestLibpurecoolAdapter(unittest.TestCase):
def testIdentify(self): def testGetDevice(self):
def makeStub(p): return {'ProductType': p, 'Serial': 'serial'} name = 'name'
serial = 'serial'
c = configparser.ConfigParser() credentials = 'credentials'
c['360Eye'] = makeStub(const.DYSON_360_EYE)
c['CoolLinkV1'] = makeStub(const.DYSON_PURE_COOL_LINK_DESK) test_cases = {
c['CoolV2'] = makeStub(const.DYSON_PURE_COOL) const.DYSON_PURE_COOL_LINK_DESK: dyson.DysonPureCoolLink,
c['HotCoolLinkV1'] = makeStub(const.DYSON_PURE_HOT_COOL_LINK_TOUR) const.DYSON_PURE_COOL: dyson.DysonPureCool,
c['HotCoolV2'] = makeStub(const.DYSON_PURE_HOT_COOL) const.DYSON_PURE_HOT_COOL_LINK_TOUR: dyson.DysonPureHotCoolLink,
const.DYSON_PURE_HOT_COOL: dyson.DysonPureHotCool
ac = libpurecool_adapter.DysonAccountCache([]) }
self.assertIsNone(ac._identify(c['360Eye'])) for product_type, want in test_cases.items():
self.assertEqual(ac._identify( got = libpurecool_adapter.get_device(name, serial, credentials, product_type)
c['CoolLinkV1']), dyson.DysonPureCoolLink) self.assertIsInstance(got, want)
self.assertEqual(ac._identify(c['CoolV2']), dyson.DysonPureCool)
self.assertEqual(ac._identify( got = libpurecool_adapter.get_device(name, serial, credentials, const.DYSON_360_EYE)
c['HotCoolLinkV1']), dyson.DysonPureHotCoolLink) self.assertIsNone(got)
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)
if __name__ == '__main__': if __name__ == '__main__':
......
...@@ -11,59 +11,103 @@ import logging ...@@ -11,59 +11,103 @@ import logging
import sys import sys
import time import time
from typing import Callable, Dict, List, Optional from typing import Callable, Dict
import prometheus_client # type: ignore[import] import prometheus_client # type: ignore[import]
import libdyson # type: ignore[import]
import account import account
import config import config
import libpurecool_adapter import libpurecool_adapter
from metrics import Metrics import metrics
class DysonClient: class DeviceWrapper:
"""Connects to and monitors Dyson fans.""" """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): def __init__(self, device: config.Device):
self._account = libpurecool_adapter.DysonAccountCache(device_cache) self._device = device
self.hosts = hosts or {} 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: @property
"""Sets up a background monitoring thread on each device. 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: Args:
update_fn: callback function that will receive the device name, serial number, and update_fn: A callable taking a name, serial, and libpurecool update message
Dyson*State message for each update event from a device. hosts: a dict of serial -> IP address, for direct (non-zeroconf) connections.
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
manual_ip = self.hosts.get(dev.serial.upper()) 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:
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.
"""
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: if manual_ip:
logging.info('Attempting connection to device "%s" (serial=%s) via configured IP %s', logging.info('Attempting connection to device "%s" (serial=%s) via configured IP %s',
dev.name, dev.serial, manual_ip) device.name, device.serial, manual_ip)
connected = dev.connect(manual_ip) wrap.libpurecool.connect(manual_ip)
else: else:
logging.info('Attempting to discover device "%s" (serial=%s) via zeroconf', logging.info('Attempting to discover device "%s" (serial=%s) via zeroconf',
dev.name, dev.serial) device.name, device.serial)
connected = dev.auto_connect() callback_fn = functools.partial(self._discovery_callback, wrap)
if not connected: self._discovery.register_device(wrap.libdyson, callback_fn)
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) @classmethod
wrapped_fn = functools.partial(update_fn, dev.name, dev.serial) 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)
# Populate initial state values. Without this, we'll run without fan operating def _lpc_callback(self, device: DeviceWrapper, message):
# state until the next change event (which could be a while). logging.debug('Received update from %s: %s', device.serial, message)
wrapped_fn(dev.state) self._update_fn(device.name, device.serial, message)
dev.add_message_listener(wrapped_fn)
def _sleep_forever() -> None: def _sleep_forever() -> None:
...@@ -83,7 +127,12 @@ def main(argv): ...@@ -83,7 +127,12 @@ def main(argv):
parser.add_argument( parser.add_argument(
'--config', help='Configuration file (INI file)', default='config.ini') '--config', help='Configuration file (INI file)', default='config.ini')
parser.add_argument('--create_device_cache', 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( parser.add_argument(
'--log_level', help='Logging level (DEBUG, INFO, WARNING, ERROR)', type=str, default='INFO') '--log_level', help='Logging level (DEBUG, INFO, WARNING, ERROR)', type=str, default='INFO')
parser.add_argument( parser.add_argument(
...@@ -100,14 +149,15 @@ def main(argv): ...@@ -100,14 +149,15 @@ def main(argv):
args = parser.parse_args() args = parser.parse_args()
logging.basicConfig( 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', datefmt='%Y/%m/%d %H:%M:%S',
level=level) level=level)
logging.info('Starting up on port=%s', args.port) logging.info('Starting up on port=%s', args.port)
if args.include_inactive_devices: 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: try:
cfg = config.Config(args.config) cfg = config.Config(args.config)
...@@ -116,7 +166,7 @@ def main(argv): ...@@ -116,7 +166,7 @@ def main(argv):
sys.exit(-1) sys.exit(-1)
devices = cfg.devices devices = cfg.devices
if not len(devices): if len(devices) == 0:
logging.fatal( logging.fatal(
'No devices configured; please re-run this program with --create_device_cache.') 'No devices configured; please re-run this program with --create_device_cache.')
sys.exit(-2) sys.exit(-2)
...@@ -127,12 +177,12 @@ def main(argv): ...@@ -127,12 +177,12 @@ def main(argv):
account.generate_device_cache(cfg.dyson_credentials, args.config) account.generate_device_cache(cfg.dyson_credentials, args.config)
sys.exit(0) sys.exit(0)
metrics = Metrics()
prometheus_client.start_http_server(args.port) prometheus_client.start_http_server(args.port)
client = DysonClient(devices, cfg.hosts) connect_mgr = ConnectionManager(metrics.Metrics().update, cfg.hosts)
client.monitor( for dev in devices:
metrics.update, only_active=not args.include_inactive_devices) connect_mgr.add_device(dev)
_sleep_forever() _sleep_forever()
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment