diff --git a/BUILD b/BUILD
index 8f77eb996e82ad9b954c74c60ebb5c2365031c4a..85e0b4481612119eb71af9582b73f3f32d87523f 100644
--- a/BUILD
+++ b/BUILD
@@ -1,7 +1,45 @@
 load("@rules_python//python:defs.bzl", "py_binary", "py_library")
-load("@pip_deps//:requirements.bzl", "requirement")
+load("@pip//:requirements.bzl", "requirement")
 load("@rules_pkg//:pkg.bzl", "pkg_tar", "pkg_deb")
 
+py_library(
+    name = "account",
+    srcs = ["account.py"],
+    deps = [
+        requirement("libdyson"),
+    ],
+)
+
+py_library(
+    name = "config",
+    srcs = ["config.py"],
+)
+
+py_test(
+    name = "config_test",
+    srcs = ["config_test.py"],
+    deps = [
+        ":config",
+    ],
+)
+
+py_library(
+    name = "libpurecool_adapter",
+    srcs = ["libpurecool_adapter.py"],
+    deps = [
+        requirement("libpurecool"),
+    ],
+)
+
+py_test(
+    name = "libpurecool_adapter_test",
+    srcs = ["libpurecool_adapter_test.py"],
+    deps = [
+        ":libpurecool_adapter",
+        requirement("libpurecool"),
+    ],
+)
+
 py_library(
     name = "metrics",
     srcs = ["metrics.py"],
@@ -24,8 +62,10 @@ py_binary(
     name = "main",
     srcs = ["main.py"],
     deps = [
+        ":account",
+        ":config",
+        ":libpurecool_adapter",
         ":metrics",
-        requirement("libpurecool"),
         requirement("prometheus_client")
     ],
 )
@@ -85,5 +125,5 @@ pkg_deb(
     description_file = "debian/description",
     maintainer = "Sean Rees <sean at erifax.org>",
     package = "prometheus-dyson",
-    version = "0.0.2",
+    version = "0.1.0",
 )
diff --git a/WORKSPACE b/WORKSPACE
index 30547ac5a6708b0f90fb815fd20aebcb807f8adc..ae3753b5ee470534b63af64791362dffc7206398 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -3,21 +3,34 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
 # Python rules.
 http_archive(
     name = "rules_python",
-    url = "https://github.com/bazelbuild/rules_python/releases/download/0.0.2/rules_python-0.0.2.tar.gz",
-    strip_prefix = "rules_python-0.0.2",
-    sha256 = "b5668cde8bb6e3515057ef465a35ad712214962f0b3a314e551204266c7be90c",
+    url = "https://github.com/bazelbuild/rules_python/releases/download/0.1.0/rules_python-0.1.0.tar.gz",
+    sha256 = "b6d46438523a3ec0f3cead544190ee13223a52f6a6765a29eae7b7cc24cc83a0",
 )
 
-load("@rules_python//python:pip.bzl", "pip_repositories")
-pip_repositories()
+load("@rules_python//python:pip.bzl", "pip_install")
 
-load("@rules_python//python:pip.bzl", "pip3_import")
-pip3_import(   # or pip3_import
-   name = "pip_deps",
-   requirements = "//:requirements.txt",
+pip_install(
+    # (Optional) You can provide extra parameters to pip.
+    # Here, make pip output verbose (this is usable with `quiet = False`).
+    #extra_pip_args = ["-v"],
+
+    # (Optional) You can exclude custom elements in the data section of the generated BUILD files for pip packages.
+    # Exclude directories with spaces in their names in this example (avoids build errors if there are such directories).
+    #pip_data_exclude = ["**/* */**"],
+
+    # (Optional) You can provide a python_interpreter (path) or a python_interpreter_target (a Bazel target, that
+    # acts as an executable). The latter can be anything that could be used as Python interpreter. E.g.:
+    # 1. Python interpreter that you compile in the build file (as above in @python_interpreter).
+    # 2. Pre-compiled python interpreter included with http_archive
+    # 3. Wrapper script, like in the autodetecting python toolchain.
+    #python_interpreter_target = "@python_interpreter//:python_bin",
+
+    # (Optional) You can set quiet to False if you want to see pip output.
+    #quiet = False,
+
+    # Uses the default repository name "pip"
+    requirements = "//:requirements.txt",
 )
-load("@pip_deps//:requirements.bzl", "pip_install")
-pip_install()
 
 # Packaging rules.
 http_archive(
diff --git a/account.py b/account.py
new file mode 100644
index 0000000000000000000000000000000000000000..3af4e850f87edd2d0a7b17a413f4cea0819722f2
--- /dev/null
+++ b/account.py
@@ -0,0 +1,83 @@
+"""Implements device-lookup via libdyson to produce a local credential cache.
+
+This is based heavily on shenxn@'s implementation of get_devices.py:
+https://github.com/shenxn/libdyson/blob/main/get_devices.py
+"""
+
+import io
+import configparser
+import sys
+
+from typing import List
+
+from config import DysonLinkCredentials
+
+from libdyson.cloud import DysonAccount, DysonDeviceInfo
+from libdyson.cloud.account import DysonAccountCN
+from libdyson.exceptions import DysonOTPTooFrequently
+
+
+def _query_dyson(username: str, password: str, country: str) -> List[DysonDeviceInfo]:
+    """Queries Dyson's APIs for a device list.
+
+    This function requires user interaction, to check either their mobile or email
+    for a one-time password.
+
+    Args:
+      username: email address or mobile number (mobile if country is CN)
+      password: login password
+      country: two-letter country code for account, e.g; IE, CN
+
+    Returns:
+      list of DysonDeviceInfo
+    """
+    if country == 'CN':
+        # Treat username like a phone number and use login_mobile_otp.
+        account = DysonAccountCN()
+        if not username.startswith('+86'):
+            username = '+86' + username
+
+        print(f'Using Mobile OTP with {username}')
+        print(f'Please check your mobile device for a one-time password.')
+        verify = account.login_mobile_otp(username)
+    else:
+        account = DysonAccount()
+        verify = account.login_email_otp(username, country)
+        print(f'Using Email OTP with {username}')
+        print(f'Please check your email for a one-time password.')
+
+    print()
+    otp = input('Enter OTP: ')
+    verify(otp, password)
+
+    return account.devices()
+
+
+def generate_device_cache(creds: DysonLinkCredentials, config: str) -> None:
+    try:
+        devices = _query_dyson(creds.username, creds.password, creds.country)
+    except DysonOTPTooFrequently:
+        print('DysonOTPTooFrequently: too many OTP attempts, please wait and try again')
+        return
+
+    cfg = configparser.ConfigParser()
+
+    print(f'Found {len(devices)} devices.')
+
+    for d in devices:
+        cfg[d.serial] = {
+            'Active': 'true' if d.active else 'false',
+            'Name': d.name,
+            'Version': d.version,
+            'LocalCredentials': d.credential,
+            'AutoUpdate': 'true' if d.auto_update else 'false',
+            'NewVersionAvailable': 'true' if d.new_version_available else 'false',
+            'ProductType': d.product_type
+        }
+
+    buf = io.StringIO()
+    cfg.write(buf)
+
+    print('')
+    print(f'Add the following to your configuration ({config}):')
+    print(buf.getvalue())
diff --git a/config.py b/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..252b90bc487f963f3487b2030c53a45fa228ccbb
--- /dev/null
+++ b/config.py
@@ -0,0 +1,98 @@
+"""Manages configuration file."""
+
+import collections
+import configparser
+import copy
+import logging
+from typing import Dict, List, Optional
+
+DysonLinkCredentials = collections.namedtuple(
+    'DysonLinkCredentials', ['username', 'password', 'country'])
+
+
+class Config:
+    def __init__(self, filename: str):
+        self._filename = filename
+        self._config = self.load(filename)
+
+    def load(self, filename: str):
+        """Reads configuration file.
+
+        Returns DysonLinkCredentials or None on error, and a dict of
+        configured device serial numbers mapping to IP addresses
+        """
+        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)
+            raise ex
+
+        return config
+
+    @property
+    def dyson_credentials(self) -> Optional[DysonLinkCredentials]:
+        try:
+            username = self._config['Dyson Link']['username']
+            password = self._config['Dyson Link']['password']
+            country = self._config['Dyson Link']['country']
+            return DysonLinkCredentials(username, password, country)
+        except KeyError as ex:
+            logging.critical(
+                'Required key missing in "%s": %s', self._filename, ex)
+            return None
+
+    @property
+    def hosts(self) -> Dict[str, str]:
+        """Loads the Hosts section, which is a serial -> IP address override.
+
+        This is useful if you don't want to discover devices using zeroconf. The Hosts section
+        looks like this:
+
+        [Hosts]
+        AB1-UK-AAA0111A = 192.168.1.2
+        """
+        try:
+            hosts = self._config.items('Hosts')
+        except configparser.NoSectionError:
+            hosts = []
+            logging.debug(
+                'No "Hosts" section found in config file, no manual IP overrides are available')
+
+        # Convert the hosts tuple (('serial0', 'ip0'), ('serial1', 'ip1'))
+        # into a dict {'SERIAL0': 'ip0', 'SERIAL1': 'ip1'}, making sure that
+        # the serial keys are upper case (configparser downcases everything)
+        return {h[0].upper(): h[1] for h in hosts}
+
+    @property
+    def devices(self) -> List[object]:
+        """Consumes all sections looking for device entries.
+
+        A device looks a bit like this:
+        [AB1-UK-AAA0111A]
+        name = Living room
+        active = true
+        localcredentials = 12345==
+        serial = AB1-UK-AAA0111A
+        ... (and a few other fields)
+
+        Returns:
+          A list of dict-like objects. This interface is unstable; do not rely on it.
+        """
+        sections = self._config.sections()
+
+        ret = []
+        for s in sections:
+            if not self._config.has_option(s, '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]))
+
+        return ret
diff --git a/config_test.py b/config_test.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9df496ede9cbb5abbafeea7cbd06f7c06e94a45
--- /dev/null
+++ b/config_test.py
@@ -0,0 +1,79 @@
+"""Unit test for the config module."""
+
+import tempfile
+import unittest
+
+import config
+
+empty = ''
+good = """
+[Dyson Link]
+username = Username
+password = Password
+country = IE
+
+[Hosts]
+ABC-UK-12345678 = 1.2.3.4
+
+[ABC-UK-12345678]
+active = true
+name = Living room
+serial = ABC-UK-12345678
+version = 21.04.03
+localcredentials = A_Random_String==
+autoupdate = True
+newversionavailable = True
+producttype = 455
+
+[XYZ-UK-12345678]
+active = true
+name = Bedroom
+serial = XYZ-UK-12345678
+version = 21.04.03
+localcredentials = A_Random_String==
+autoupdate = True
+newversionavailable = True
+producttype = 455
+"""
+
+
+class TestConfig(unittest.TestCase):
+    def setUp(self):
+        self._empty_file = self.createTemporaryFile(empty)
+        self.empty = config.Config(self._empty_file.name)
+
+        self._good_file = self.createTemporaryFile(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):
+        ret = tempfile.NamedTemporaryFile()
+        ret.write(contents.encode('utf-8'))
+        ret.flush()
+        return ret
+
+    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')
+
+    def testHosts(self):
+        self.assertTrue(not self.empty.hosts)
+        self.assertEqual(self.good.hosts['ABC-UK-12345678'], '1.2.3.4')
+
+    def testDevices(self):
+        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')
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/libpurecool_adapter.py b/libpurecool_adapter.py
new file mode 100644
index 0000000000000000000000000000000000000000..3e385f94b2b4c9b2d0fd1e1a472815090c04f75d
--- /dev/null
+++ b/libpurecool_adapter.py
@@ -0,0 +1,50 @@
+"""An adapter to use libpurecool's Dyson support without the Cloud API."""
+
+import collections
+import logging
+from typing import Callable, Dict, List, 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
diff --git a/libpurecool_adapter_test.py b/libpurecool_adapter_test.py
new file mode 100644
index 0000000000000000000000000000000000000000..23e49e735a200ff97eabb378120f125928cf8cb1
--- /dev/null
+++ b/libpurecool_adapter_test.py
@@ -0,0 +1,55 @@
+"""Unit test for the libpurecool_adapter module."""
+
+import configparser
+import unittest
+
+import libpurecool_adapter
+
+from libpurecool import dyson, const
+
+
+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)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/main.py b/main.py
index 4bfb5339bca35538523039e7a263c8b96a7fd976..b280d58b5cccecb639e9eeaef700244a963d08b7 100755
--- a/main.py
+++ b/main.py
@@ -6,47 +6,28 @@ libpurecool   pip install prometheus_client
 """
 
 import argparse
-import collections
-import configparser
 import functools
 import logging
 import sys
 import time
 
-from typing import Callable, Dict, Optional, Tuple
+from typing import Callable, Dict, List, Optional
 
-from libpurecool import dyson
 import prometheus_client                    # type: ignore[import]
 
+import account
+import config
+import libpurecool_adapter
 from metrics import Metrics
 
-DysonLinkCredentials = collections.namedtuple(
-    'DysonLinkCredentials', ['username', 'password', 'country'])
-
 
 class DysonClient:
     """Connects to and monitors Dyson fans."""
 
-    def __init__(self, username, password, country, hosts: Optional[Dict] = None):
-        self.username = username
-        self.password = password
-        self.country = country
+    def __init__(self, device_cache: List[Dict[str, str]], hosts: Optional[Dict] = None):
+        self._account = libpurecool_adapter.DysonAccountCache(device_cache)
         self.hosts = hosts or {}
 
-        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.
 
@@ -94,45 +75,6 @@ def _sleep_forever() -> None:
             break
 
 
-def _read_config(filename) -> Tuple[Optional[DysonLinkCredentials], Dict]:
-    """Reads configuration file.
-
-    Returns DysonLinkCredentials or None on error, and a dict
-    of configured device serial numbers mapping to IP addresses
-    """
-    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']
-        creds = DysonLinkCredentials(username, password, country)
-    except KeyError as ex:
-        logging.critical('Required key missing in "%s": %s', filename, ex)
-        return None, {}
-
-    try:
-        hosts = config.items('Hosts')
-    except configparser.NoSectionError:
-        hosts = []
-        logging.debug('No "Devices" section found in config file, no manual IP overrides are available')
-
-    # Convert the hosts tuple (('serial0', 'ip0'), ('serial1', 'ip1'))
-    # into a dict {'SERIAL0': 'ip0', 'SERIAL1': 'ip1'}, making sure that
-    # the serial keys are upper case (configparser downcases everything)
-    host_dict = {h[0].upper(): h[1] for h in hosts}
-
-    return creds, host_dict
-
-
 def main(argv):
     """Main body of the program."""
     parser = argparse.ArgumentParser(prog=argv[0])
@@ -140,6 +82,8 @@ def main(argv):
                         type=int, default=8091)
     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')
     parser.add_argument(
         '--log_level', help='Logging level (DEBUG, INFO, WARNING, ERROR)', type=str, default='INFO')
     parser.add_argument(
@@ -165,18 +109,28 @@ def main(argv):
     if args.include_inactive_devices:
         logging.info('Including devices marked "inactive" from the Dyson API')
 
-    credentials, hosts = _read_config(args.config)
-    if not credentials:
+    try:
+        cfg = config.Config(args.config)
+    except:
+        logging.exception('Could not load configuration: %s', args.config)
         sys.exit(-1)
 
+    devices = cfg.devices
+    if not len(devices):
+        logging.fatal(
+            'No devices configured; please re-run this program with --create_device_cache.')
+        sys.exit(-2)
+
+    if args.create_device_cache:
+        logging.info(
+            '--create_device_cache supplied; breaking out to perform this.')
+        account.generate_device_cache(cfg.dyson_credentials, args.config)
+        sys.exit(0)
+
     metrics = Metrics()
     prometheus_client.start_http_server(args.port)
 
-    client = DysonClient(credentials.username,
-                         credentials.password, credentials.country, hosts)
-    if not client.login():
-        sys.exit(-1)
-
+    client = DysonClient(devices, cfg.hosts)
     client.monitor(
         metrics.update, only_active=not args.include_inactive_devices)
     _sleep_forever()
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000000000000000000000000000000000000..c5481a5027479d2265bdabfdb306cf13a9804ccc
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,10 @@
+[mypy]
+
+[mypy-libpurecool.*]
+ignore_missing_imports = True
+
+[mypy-libdyson.*]
+ignore_missing_imports = True
+
+[mypy-prometheus_client]
+ignore_missing_imports = True
diff --git a/requirements.txt b/requirements.txt
index b4e906f51f7573a71ed36987b9c001d5c8871fde..b5b35e58868d3843e62c99ffb4433ea64d9839bf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
-libpurecool
 prometheus_client
+libpurecool
+libdyson
\ No newline at end of file