diff --git a/BUILD b/BUILD
index aa9bc8aed07fcc60a21c8c4c39bb32cfbb2993e5..804d9f6b13abf2dd377bd02c93acc51ac62c1052 100644
--- a/BUILD
+++ b/BUILD
@@ -2,14 +2,6 @@ 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_library(
-    name = "account",
-    srcs = ["account.py"],
-    deps = [
-        requirement("libdyson"),
-    ],
-)
-
 py_library(
     name = "config",
     srcs = ["config.py"],
@@ -46,7 +38,6 @@ py_binary(
     name = "main",
     srcs = ["main.py"],
     deps = [
-        ":account",
         ":config",
         ":metrics",
         requirement("prometheus_client"),
@@ -54,10 +45,22 @@ py_binary(
     ],
 )
 
+py_binary(
+    name = "config_builder",
+    srcs = ["config_builder.py"],
+    deps = [
+        ":config",
+        requirement("libdyson"),
+    ],
+)
+
 pkg_tar(
     name = "deb-bin",
     # This depends on --build_python_zip.
-    srcs = [":main"],
+    srcs = [
+      ":main",
+      ":config_builder"
+    ],
     mode = "0755",
     package_dir = "/opt/prometheus-dyson/bin",
 )
@@ -109,5 +112,5 @@ pkg_deb(
     package = "prometheus-dyson",
     postrm = "debian/postrm",
     prerm = "debian/prerm",
-    version = "0.2.1",
+    version = "0.3.0",
 )
diff --git a/README.md b/README.md
index c610a1dd8c1792da88c6bf5d0c23e4e5009c55e2..214dc4d90b739089be2532b0e764f770097aab7f 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ the V1 model (reports VOC and Dust) and the V2 models (those that report
 PM2.5, PM10, NOx, and VOC). Other Dyson fans may work out of the box or with
 minor modifications.
 
-## Updating instructions for 0.2.0
+## Updating instructions from 0.1.x (to 0.2.x or beyond)
 
 Due to changes in Dyson's Cloud API, automatic device detection based on your
 Dyson login/password no longer works reliably.
@@ -15,10 +15,8 @@ This means you need to take a _one-time_ manual step to upgrade. The upside
 to this is that it removes the runtime dependency on the Dyson API, because
 it will cache the device information locally.
 
-The manual step is to run this command and follow the prompts:
-```
-% /opt/prometheus-dyson/bin/main --create_device_cache
-```
+Please see the _Usage_ > _Configuration_ > _Automatic Setup_ section below for instructions.
+
 
 ## Build
 
@@ -44,7 +42,6 @@ You'll need these dependencies:
 % pip install prometheus_client
 ```
 
-
 ## Metrics
 
 ### Environmental
@@ -92,24 +89,36 @@ dyson_continuous_monitoring_mode | gauge | V2 fans only | continuous monitoring
 This script reads `config.ini` (or another file, specified with `--config`)
 for your DysonLink login credentials.
 
+#### Automatic Setup
+
+TIP: you must do this if you're upgrading from 0.1.x (to 0.2.x or beyond)
+
+prometheus-dyson requires a configuration file to operate. In the Debian-based
+installation, this lives in ```/etc/prometheus-dyson/config.ini```.
+
+To generate this configuration, run the config builder, like this:
+```
+% /opt/prometheus-dyson/bin/config_builder
+```
+
+You will need to run this as root (or a user with write permissions to
+/etc/prometheus-dyson).
+
 #### Device Configuration
 
-Devices must be specifically listed in your `config.ini`. You can create this
-automatically by running the binary with `--create_device_cache` and following
-the prompts. A device entry looks like this:
+A device entry looks like this:
 
 ```
 [XX1-ZZ-ABC1234A]
-active = true
 name = My Fan
 serial = XX1-ZZ-ABC1234A
-version = 21.04.03
 localcredentials = a_random_looking_string==
-autoupdate = True
-newversionavailable = True
 producttype = 455
 ```
 
+The ```localcredentials``` field is provided by the Dyson Cloud API, please see
+the _Automatic Setup_ section.
+
 #### Manual IP Overrides
 
 By default, fans are auto-detected with Zeroconf. It is possible to provide
@@ -123,16 +132,12 @@ XX1-ZZ-ABC1234A = 10.10.100.55
 ### Args
 ```
 % ./prometheus_dyson.py --help
-usage: ./prometheus_dyson.py [-h] [--port PORT] [--config CONFIG] [--create_device_cache] [--log_level LOG_LEVEL]
+usage: ./prometheus_dyson.py [-h] [--port PORT] [--config CONFIG] [--log_level LOG_LEVEL]
 
 optional arguments:
   -h, --help            show this help message and exit
   --port PORT           HTTP server port
   --config CONFIG       Configuration file (INI file)
-  --create_device_cache
-                        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.
   --log_level LOG_LEVEL
                         Logging level (DEBUG, INFO, WARNING, ERROR)
 ```
diff --git a/account.py b/account.py
deleted file mode 100644
index 3af4e850f87edd2d0a7b17a413f4cea0819722f2..0000000000000000000000000000000000000000
--- a/account.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""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
index 3dee251379d3b113c7b5ae4f39a0488d1c20a20c..ba58310f00911c608df0278cf71e6fb3e6b9b180 100644
--- a/config.py
+++ b/config.py
@@ -60,7 +60,7 @@ class Config:
             country = self._config['Dyson Link']['country']
             return DysonLinkCredentials(username, password, country)
         except KeyError as ex:
-            logging.critical(
+            logging.warning(
                 'Required key missing in "%s": %s', self._filename, ex)
             return None
 
diff --git a/config_builder.py b/config_builder.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed56aebee023fe6976aaad5d46e531b6b9dc5daa
--- /dev/null
+++ b/config_builder.py
@@ -0,0 +1,181 @@
+"""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 argparse
+import configparser
+import io
+import logging
+import sys
+
+from typing import Dict, List
+
+from libdyson.cloud import DysonAccount, DysonDeviceInfo
+from libdyson.cloud.account import DysonAccountCN
+from libdyson.exceptions import DysonOTPTooFrequently, DysonLoginFailure
+
+import config
+
+
+def _query_credentials() -> config.DysonLinkCredentials:
+    """Asks the user for their DysonLink/Cloud credentials.
+
+    Returns:
+      DysonLinkCredentials based on what the user supplied
+    """
+    print('First, we need your app/DysonLink login details.')
+    print('This is used to get a list of your devices from Dyson. This')
+    print('should be the same username&password you use to login into')
+    print('the Dyson app (e.g; on your phone:')
+    username = input('Username (or number phone if in China): ')
+    password = input('Password: ')
+    country = input('Country code (e.g; IE): ')
+
+    return config.DysonLinkCredentials(username, password, country)
+
+
+def _query_dyson(creds: config.DysonLinkCredentials) -> 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
+    """
+    username = creds.username
+    country = creds.country
+
+    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'Please check your mobile device ({username}) for a one-time password.')
+        verify_fn = account.login_mobile_otp(username)
+    else:
+        account = DysonAccount()
+        verify_fn = account.login_email_otp(username, country)
+        print(f'Please check your email ({username}) for a one-time password.')
+
+    print()
+    otp = input('Enter OTP: ')
+    try:
+        verify_fn(otp, creds.password)
+        return account.devices()
+    except DysonLoginFailure:
+        print('Incorrect OTP.')
+        sys.exit(-1)
+
+
+def write_config(filename: str, creds: config.DysonLinkCredentials,
+                 devices: List[DysonDeviceInfo], hosts: Dict[str, str]) -> None:
+    """Writes the config out to filename.
+
+    Args:
+        filename: relative or fully-qualified path to the config file (ini format)
+        creds: DysonLinkCredentials with Dyson username/password/country.
+        devices: a list of Devices
+        hosts: a serial->IP address (or host) map for direct (non-zeroconf) connection
+    """
+    cfg = configparser.ConfigParser()
+
+    cfg['Dyson Link'] = {
+        'Username': creds.username,
+        'Password': creds.password,
+        'Country': creds.country
+    }
+
+    cfg['Hosts'] = hosts
+
+    for dev in devices:
+        cfg[dev.serial] = {
+            'Name': dev.name,
+            'Serial': dev.serial,
+            'LocalCredentials': dev.credential,
+            'ProductType': dev.product_type
+        }
+
+    input('Configuration generated; press return to view.')
+
+    buf = io.StringIO()
+    cfg.write(buf)
+
+    print(buf.getvalue())
+    print('--------------------------------------------------------------------------------')
+    print(f'Answering yes to the following question will overwrite {filename}')
+    ack = input('Does this look reasonable? [Y/N]: ')
+    if len(ack) > 0 and ack.upper()[0] == 'Y':
+        with open(filename, 'w') as f:
+            cfg.write(f)
+        print(f'Config written to {config}.')
+    else:
+        print('Received negative answer; nothing written.')
+
+
+def main(argv):
+    """Main body of the program."""
+    parser = argparse.ArgumentParser(prog=argv[0])
+    parser.add_argument(
+        '--log_level',
+        help='Logging level (DEBUG, INFO, WARNING, ERROR)',
+        type=str,
+        default='ERROR')
+    parser.add_argument(
+        '--config', help='Configuration file (INI file)', default='/etc/prometheus-dyson/config.ini')
+    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 [%(thread)d] %(levelname)10s %(message)s',
+        datefmt='%Y/%m/%d %H:%M:%S',
+        level=level)
+
+    print('Welcome to the prometheus-dyson config builder.')
+
+    cfg = None
+    creds = None
+    hosts = {}
+    try:
+        cfg = config.Config(args.config)
+        creds = cfg.dyson_credentials
+        hosts = cfg.hosts
+    except:
+        logging.info(
+            'Could not load configuration: %s (assuming no configuration)', args.config)
+
+    if not creds:
+        print('')
+        creds = _query_credentials()
+    else:
+        print(f'Using Dyson credentials from {args.config}')
+
+    try:
+        print()
+        devices = _query_dyson(creds)
+        print(f'Found {len(devices)} devices.')
+    except DysonOTPTooFrequently:
+        print('DysonOTPTooFrequently: too many OTP attempts, please wait and try again')
+        sys.exit(-1)
+
+    print()
+    write_config(args.config, creds, devices, hosts)
+
+
+if __name__ == '__main__':
+    main(sys.argv)
diff --git a/main.py b/main.py
index 85a81433f25ce5eec41c59eae2fe76e5b6accc86..332281e1c02eb80389c9c3fdb976cc6518edf66b 100755
--- a/main.py
+++ b/main.py
@@ -15,7 +15,6 @@ import libdyson
 import libdyson.dyson_device
 import libdyson.exceptions
 
-import account
 import config
 import metrics
 
@@ -53,23 +52,25 @@ class DeviceWrapper:
         """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.
+    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)
+            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()
+                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."""
@@ -184,13 +185,6 @@ 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\'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(
@@ -223,17 +217,6 @@ def main(argv):
         logging.exception('Could not load configuration: %s', args.config)
         sys.exit(-1)
 
-    if args.create_device_cache:
-        if not cfg.dyson_credentials:
-            logging.error('DysonLink credentials not found in %s, cannot generate device cache',
-                          args.config)
-            sys.exit(-1)
-
-        logging.info(
-            '--create_device_cache supplied; breaking out to perform this.')
-        account.generate_device_cache(cfg.dyson_credentials, args.config)
-        sys.exit(0)
-
     devices = cfg.devices
     if len(devices) == 0:
         logging.fatal(