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()