diff --git a/BUILD b/BUILD
index 9230b07daa27d6d95fd3759f126365e068ea7ce4..8e98e4eca6eb225a2cfc90ff674528a0fcc5131a 100644
--- a/BUILD
+++ b/BUILD
@@ -5,6 +5,7 @@ load("@rules_pkg//:pkg.bzl", "pkg_deb", "pkg_tar")
 py_library(
     name = "config",
     srcs = ["config.py"],
+    visibility = ["//:__subpackages__"]
 )
 
 py_test(
@@ -15,6 +16,16 @@ py_test(
     ],
 )
 
+py_library(
+    name = "connect",
+    srcs = ["connect.py"],
+    visibility = ["//:__subpackages__"],
+    deps = [
+        ":config",
+        requirement("libdyson"),
+    ],
+)
+
 py_library(
     name = "metrics",
     srcs = ["metrics.py"],
@@ -39,9 +50,9 @@ py_binary(
     srcs = ["main.py"],
     deps = [
         ":config",
+        ":connect",
         ":metrics",
         requirement("prometheus_client"),
-        requirement("libdyson"),
     ],
 )
 
diff --git a/config.py b/config.py
index ba58310f00911c608df0278cf71e6fb3e6b9b180..79a8a4996104802769fabdabb15b5fe1080e475c 100644
--- a/config.py
+++ b/config.py
@@ -11,6 +11,8 @@ Device = collections.namedtuple(
 DysonLinkCredentials = collections.namedtuple(
     'DysonLinkCredentials', ['username', 'password', 'country'])
 
+logger = logging.getLogger(__name__)
+
 
 class Config:
     """Reads the configuration file and provides handy accessors.
@@ -18,6 +20,7 @@ class Config:
     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)
@@ -31,12 +34,12 @@ class Config:
         """
         config = configparser.ConfigParser()
 
-        logging.info('Reading "%s"', filename)
+        logger.info('Reading "%s"', filename)
 
         try:
             config.read(filename)
         except configparser.Error as ex:
-            logging.critical('Could not read "%s": %s', filename, ex)
+            logger.critical('Could not read "%s": %s', filename, ex)
             raise ex
 
         return config
@@ -60,7 +63,7 @@ class Config:
             country = self._config['Dyson Link']['country']
             return DysonLinkCredentials(username, password, country)
         except KeyError as ex:
-            logging.warning(
+            logger.warning(
                 'Required key missing in "%s": %s', self._filename, ex)
             return None
 
@@ -78,7 +81,7 @@ class Config:
             hosts = self._config.items('Hosts')
         except configparser.NoSectionError:
             hosts = []
-            logging.debug(
+            logger.debug(
                 'No "Hosts" section found in config file, no manual IP overrides are available')
 
         # Convert the hosts tuple (('serial0', 'ip0'), ('serial1', 'ip1'))
diff --git a/config_builder.py b/config_builder.py
index cda99a34b635136e2c76b721203ff2b4ee79c878..b00d6b8d3eca6da8e82384a91a01d6d066467caf 100644
--- a/config_builder.py
+++ b/config_builder.py
@@ -19,6 +19,8 @@ from libdyson.exceptions import DysonOTPTooFrequently, DysonLoginFailure
 
 import config
 
+logger = logging.getLogger(__name__)
+
 
 def _query_credentials() -> config.DysonLinkCredentials:
     """Asks the user for their DysonLink/Cloud credentials.
@@ -170,7 +172,7 @@ def main(argv):
     args = parser.parse_args()
 
     logging.basicConfig(
-        format='%(asctime)s [%(thread)d] %(levelname)10s %(message)s',
+        format='%(asctime)s [%(name)8s %(thread)d] %(levelname)10s %(message)s',
         datefmt='%Y/%m/%d %H:%M:%S',
         level=level)
 
@@ -188,7 +190,7 @@ def main(argv):
         creds = cfg.dyson_credentials
         hosts = cfg.hosts
     except:
-        logging.info(
+        logger.info(
             'Could not load configuration: %s (assuming no configuration)', args.config)
 
     if args.mode == 'cloud':
diff --git a/connect.py b/connect.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a4716c947dbccb3e3cb690d9c823e344f82b216
--- /dev/null
+++ b/connect.py
@@ -0,0 +1,191 @@
+"""Wraps libdyson's connections with support for config & retries."""
+
+import functools
+import logging
+import threading
+
+from typing import Callable, Dict, List, Optional
+
+import libdyson
+import libdyson.dyson_device
+import libdyson.exceptions
+
+import config
+
+logger = logging.getLogger(__name__)
+
+
+class DeviceWrapper:
+    """Wrapper for a config.Device.
+
+    This class has two main purposes:
+      1) To associate a device name & libdyson.DysonFanDevice together
+      2) To start background thread that asks the DysonFanDevice for updated
+         environmental data on a periodic basis.
+
+    Args:
+      device: a config.Device to wrap
+      environment_refresh_secs: how frequently to refresh environmental data
+    """
+
+    def __init__(self, device: config.Device, environment_refresh_secs=30):
+        self._config_device = device
+        self._environment_refresh_secs = environment_refresh_secs
+        self._environment_timer : Optional[threading.Timer] = None
+        self._timeout_timer : Optional[threading.Timer] = None
+        self.libdyson = self._create_libdyson_device()
+
+    @property
+    def name(self) -> str:
+        """Returns device name, e.g; 'Living Room'."""
+        return self._config_device.name
+
+    @property
+    def serial(self) -> str:
+        """Returns device serial number, e.g; AB1-XX-1234ABCD."""
+        return self._config_device.serial
+
+    @property
+    def is_connected(self) -> bool:
+        """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.
+
+        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.
+        """
+        self._timeout_timer = None
+
+        if self.is_connected:
+            logger.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:
+                logger.error(
+                    'Timeout connecting to %s (%s); will retry', host, self.serial)
+                self._timeout_timer = threading.Timer(
+                    retry_on_timeout_secs, self.connect, args=[host])
+                self._timeout_timer.start()
+
+    def disconnect(self):
+        """Disconnect from the Dyson device."""
+        if self._environment_timer:
+            self._environment_timer.cancel()
+        if self._timeout_timer:
+            self._timeout_timer.cancel()
+
+        self.libdyson.disconnect()
+
+    def _refresh_timer(self):
+        self._environment_timer = threading.Timer(self._environment_refresh_secs,
+                                self._timer_callback)
+        self._environment_timer.start()
+
+    def _timer_callback(self):
+        self._environment_timer = None
+
+        if self.is_connected:
+            logger.debug(
+                'Requesting updated environmental data from %s', self.serial)
+            try:
+                 self.libdyson.request_environmental_data()
+            except AttributeError:
+                 logger.error('Race with a disconnect? Skipping an iteration.')
+            self._refresh_timer()
+        else:
+            logger.debug('Device %s is disconnected.', self.serial)
+
+    def _create_libdyson_device(self):
+        return libdyson.get_device(self.serial, self._config_device.credentials,
+                                   self._config_device.product_type)
+
+
+class ConnectionManager:
+    """Manages connections via manual IP or via libdyson Discovery.
+
+    Args:
+      update_fn: A callable taking a name, serial,
+      devices: a list of config.Device entities
+      hosts: a dict of serial -> IP address, for direct (non-zeroconf) connections.
+    """
+
+    def __init__(self, update_fn: Callable[[str, str, bool, bool], None],
+                 devices: List[config.Device], hosts: Dict[str, str], reconnect: bool = True):
+        self._update_fn = update_fn
+        self._hosts = hosts
+        self._reconnect = reconnect
+        self._devices = [DeviceWrapper(d) for d in devices]
+
+        logger.info('Starting discovery...')
+        self._discovery = libdyson.discovery.DysonDiscovery()
+        self._discovery.start_discovery()
+
+        for device in self._devices:
+            self._add_device(device)
+
+    def shutdown(self) -> None:
+        """Disconnects from all devices."""
+        self._discovery.stop_discovery()
+
+        for device in self._devices:
+            logger.info('Disconnecting from %s (%s)', device.name, device.serial)
+            device.disconnect()
+
+    def _add_device(self, device: DeviceWrapper, 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.
+        """
+        if add_listener:
+            callback_fn = functools.partial(self._device_callback, device)
+            device.libdyson.add_message_listener(callback_fn)
+
+        manual_ip = self._hosts.get(device.serial.upper())
+        if manual_ip:
+            logger.info('Attempting connection to device "%s" (serial=%s) via configured IP %s',
+                         device.name, device.serial, manual_ip)
+            device.connect(manual_ip)
+        else:
+            logger.info('Attempting to discover device "%s" (serial=%s) via zeroconf',
+                         device.name, device.serial)
+            callback_fn = functools.partial(self._discovery_callback, device)
+            self._discovery.register_device(device.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.
+        logger.info('Discovered %s on %s', device.serial, address)
+        device.connect(address)
+
+    def _device_callback(self, device, message):
+        logger.debug('Received update from %s: %s', device.serial, message)
+        if not device.is_connected and self._reconnect:
+            logger.info(
+                'Device %s is now disconnected, clearing it and re-adding', device.serial)
+            device.disconnect()
+            self._discovery.stop_discovery()
+            self._discovery.start_discovery()
+            self._add_device(device, add_listener=False)
+            return
+
+        is_state = message == libdyson.MessageType.STATE
+        is_environ = message == libdyson.MessageType.ENVIRONMENTAL
+        self._update_fn(device.name, device.libdyson, is_state=is_state,
+                        is_environmental=is_environ)
+
diff --git a/main.py b/main.py
index 79c3e59a94f6973578f58233c1db30f20bcd8b7f..46175695f8430e6227cbf09c0f0cb0a285fc9483 100755
--- a/main.py
+++ b/main.py
@@ -2,174 +2,18 @@
 """Exports Dyson Pure Hot+Cool (DysonLink) statistics as Prometheus metrics."""
 
 import argparse
-import functools
 import logging
 import sys
 import time
-import threading
-
-from typing import Callable, Dict, List
 
 import prometheus_client
-import libdyson
-import libdyson.dyson_device
-import libdyson.exceptions
 
 import config
+import connect
 import metrics
 
 
-class DeviceWrapper:
-    """Wrapper for a config.Device.
-
-    This class has two main purposes:
-      1) To associate a device name & libdyson.DysonFanDevice together
-      2) To start background thread that asks the DysonFanDevice for updated
-         environmental data on a periodic basis.
-
-    Args:
-      device: a config.Device to wrap
-      environment_refresh_secs: how frequently to refresh environmental data
-    """
-
-    def __init__(self, device: config.Device, environment_refresh_secs=30):
-        self._config_device = device
-        self._environment_refresh_secs = environment_refresh_secs
-        self.libdyson = self._create_libdyson_device()
-
-    @property
-    def name(self) -> str:
-        """Returns device name, e.g; 'Living Room'."""
-        return self._config_device.name
-
-    @property
-    def serial(self) -> str:
-        """Returns device serial number, e.g; AB1-XX-1234ABCD."""
-        return self._config_device.serial
-
-    @property
-    def is_connected(self) -> bool:
-        """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.
-
-        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)
-        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()
-
-    def disconnect(self):
-        """Disconnect from the Dyson device."""
-        self.libdyson.disconnect()
-
-    def _refresh_timer(self):
-        timer = threading.Timer(self._environment_refresh_secs,
-                                self._timer_callback)
-        timer.start()
-
-    def _timer_callback(self):
-        if self.is_connected:
-            logging.debug(
-                'Requesting updated environmental data from %s', self.serial)
-            try:
-                self.libdyson.request_environmental_data()
-            except AttributeError:
-                logging.error('Race with a disconnect? Skipping an iteration.')
-            self._refresh_timer()
-        else:
-            logging.debug('Device %s is disconnected.', self.serial)
-
-    def _create_libdyson_device(self):
-        return libdyson.get_device(self.serial, self._config_device.credentials,
-                                   self._config_device.product_type)
-
-
-class ConnectionManager:
-    """Manages connections via manual IP or via libdyson Discovery.
-
-    Args:
-      update_fn: A callable taking a name, serial,
-      devices: a list of config.Device entities
-      hosts: a dict of serial -> IP address, for direct (non-zeroconf) connections.
-    """
-
-    def __init__(self, update_fn: Callable[[str, str, bool, bool], None],
-                 devices: List[config.Device], 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()
-
-        for device in devices:
-            self._add_device(DeviceWrapper(device))
-
-    def _add_device(self, device: DeviceWrapper, 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.
-        """
-        if add_listener:
-            callback_fn = functools.partial(self._device_callback, device)
-            device.libdyson.add_message_listener(callback_fn)
-
-        manual_ip = self._hosts.get(device.serial.upper())
-        if manual_ip:
-            logging.info('Attempting connection to device "%s" (serial=%s) via configured IP %s',
-                         device.name, device.serial, manual_ip)
-            device.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, device)
-            self._discovery.register_device(device.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.connect(address)
-
-    def _device_callback(self, device, message):
-        logging.debug('Received update from %s: %s', device.serial, message)
-        if not device.is_connected:
-            logging.info(
-                'Device %s is now disconnected, clearing it and re-adding', device.serial)
-            device.disconnect()
-            self._discovery.stop_discovery()
-            self._discovery.start_discovery()
-            self._add_device(device, add_listener=False)
-            return
-
-        is_state = message == libdyson.MessageType.STATE
-        is_environ = message == libdyson.MessageType.ENVIRONMENTAL
-        self._update_fn(device.name, device.libdyson, is_state=is_state,
-                        is_environmental=is_environ)
+logger = logging.getLogger(__name__)
 
 
 def _sleep_forever() -> None:
@@ -204,31 +48,31 @@ def main(argv):
     args = parser.parse_args()
 
     logging.basicConfig(
-        format='%(asctime)s [%(thread)d] %(levelname)10s %(message)s',
+        format='%(asctime)s [%(name)24s %(thread)d] %(levelname)10s %(message)s',
         datefmt='%Y/%m/%d %H:%M:%S',
         level=level)
 
-    logging.info('Starting up on port=%s', args.port)
+    logger.info('Starting up on port=%s', args.port)
 
     if args.include_inactive_devices:
-        logging.warning(
+        logger.warning(
             '--include_inactive_devices is now inoperative and will be removed in a future release')
 
     try:
         cfg = config.Config(args.config)
     except:
-        logging.exception('Could not load configuration: %s', args.config)
+        logger.exception('Could not load configuration: %s', args.config)
         sys.exit(-1)
 
     devices = cfg.devices
     if len(devices) == 0:
-        logging.fatal(
+        logger.fatal(
             'No devices configured; please re-run this program with --create_device_cache.')
         sys.exit(-2)
 
     prometheus_client.start_http_server(args.port)
 
-    ConnectionManager(metrics.Metrics().update, devices, cfg.hosts)
+    connect.ConnectionManager(metrics.Metrics().update, devices, cfg.hosts)
 
     _sleep_forever()
 
diff --git a/metrics.py b/metrics.py
index 0dd8a6c1f9e7671e333808c6e6f4277d42800495..e8c4fdee86d45cc142fcbd4e8505699e2799cbe8 100644
--- a/metrics.py
+++ b/metrics.py
@@ -16,6 +16,8 @@ from prometheus_client import Gauge, Enum, REGISTRY
 # slightly rounded value instead.
 KELVIN_TO_CELSIUS = -273
 
+logger = logging.getLogger(__name__)
+
 
 def enum_values(cls):
     return [x.value for x in list(cls)]
@@ -190,7 +192,7 @@ class Metrics:
           is_enviromental: is an environmental (temperature, humidity, etc) update.
         """
         if not device:
-            logging.error('Ignoring update, device is None')
+            logger.error('Ignoring update, device is None')
 
         serial = device.serial
 
@@ -207,8 +209,8 @@ class Metrics:
             if is_state:
                 self.update_v1_state(name, device, heating)
         else:
-            logging.warning('Received unknown update from "%s" (serial=%s): %s; ignoring',
-                            name, serial, type(device))
+            logger.warning('Received unknown update from "%s" (serial=%s): %s; ignoring',
+                           name, serial, type(device))
 
     def update_v1_environmental(self, name: str, device) -> None:
         self.update_common_environmental(name, device)
@@ -238,7 +240,8 @@ class Metrics:
         update_env_gauge(self.nox, name, device.serial, nox)
 
         if isinstance(device, libdyson.DysonPureCoolFormaldehyde):
-          update_env_gauge(self.formaldehyde, name, device.serial, device.formaldehyde)
+            update_env_gauge(self.formaldehyde, name,
+                             device.serial, device.formaldehyde)
 
     def update_common_environmental(self, name: str, device) -> None:
         update_gauge(self.last_update_environmental,
diff --git a/misc/BUILD b/misc/BUILD
new file mode 100644
index 0000000000000000000000000000000000000000..dfe82c82ebf5b3552fb495588aaa62a26a9132ed
--- /dev/null
+++ b/misc/BUILD
@@ -0,0 +1,14 @@
+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_binary(
+    name = "switch_heat_mode",
+    srcs = ["switch_heat_mode.py"],
+    deps = [
+        "//:config",
+        "//:connect",
+        requirement("prometheus_client"),
+        requirement("libdyson"),
+    ],
+)
diff --git a/misc/switch_heat_mode.py b/misc/switch_heat_mode.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1b8a3f4efc19b59480cf75aaee8fbbd960fbf15
--- /dev/null
+++ b/misc/switch_heat_mode.py
@@ -0,0 +1,135 @@
+#!/usr/bin/python3
+"""Toggles the heat mode for a Dyson heating fan on or off."""
+
+import argparse
+import functools
+import logging
+import sys
+import threading
+
+import config
+import connect
+
+import libdyson.dyson_device
+
+logger = logging.getLogger(__name__)
+
+_one_more_event = threading.Event()
+_ok_to_shutdown = threading.Event()
+
+
+def device_callback(
+    want_heat_mode: str,
+    name: str,
+    device: libdyson.dyson_device.DysonFanDevice,
+    is_state=False,
+    is_environmental=False,
+) -> None:
+    """Callback for libdyson.
+
+    Args:
+        want_heat_mode: string, "on" if you want heat turned on, "off" if you want heat turned off.
+    """
+    # If we sent a command to the device (e.g; enable or disable heat), we need to wait
+    # for one more update to confirm the device mode change. Once we have it, we can
+    # shutdown.
+    if _one_more_event.is_set():
+        _ok_to_shutdown.set()
+        return
+
+    if is_state:
+        current_heat_mode_is_on = device.heat_mode_is_on
+        want_heat_on = want_heat_mode == 'on'
+
+        if current_heat_mode_is_on == want_heat_on:
+            logger.info(
+                'Fan heat for %s (%s) already in desired state of %s',
+                name,
+                device.serial,
+                want_heat_mode.upper(),
+            )
+            _ok_to_shutdown.set()
+            return
+
+        if want_heat_mode == 'off':
+            device.disable_heat_mode()
+        else:
+            device.enable_heat_mode()
+
+        logger.info(
+            'Turning %s heat on %s (%s)', want_heat_mode.upper(
+            ), name, device.serial
+        )
+        _one_more_event.set()
+
+
+turn_on_heat = functools.partial(device_callback, 'on')
+turn_off_heat = functools.partial(device_callback, 'off')
+
+
+def main(argv):
+    """Main body of the program."""
+    parser = argparse.ArgumentParser(prog=argv[0])
+    parser.add_argument(
+        '--config', help='Configuration file (INI file)', default='config.ini')
+    parser.add_argument(
+        '--device', help='Device name (from config) to operate on')
+    parser.add_argument(
+        '--heat_mode', help='Desired mode, on or off', default='off')
+    parser.add_argument(
+        '--log_level',
+        help='Logging level (DEBUG, INFO, WARNING, ERROR)',
+        type=str,
+        default='INFO',
+    )
+    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 [%(name)24s %(thread)d] %(levelname)10s %(message)s',
+        datefmt='%Y/%m/%d %H:%M:%S',
+        level=level,
+    )
+
+    try:
+        cfg = config.Config(args.config)
+    except:
+        logger.exception('Could not load configuration: %s', args.config)
+        sys.exit(-1)
+
+    devices = cfg.devices
+    if len(devices) == 0:
+        logger.fatal(
+            'No devices configured; please re-run this program with --create_device_cache.'
+        )
+        sys.exit(-2)
+
+    dev = [d for d in devices if d.name == args.device]
+    if not dev:
+        logger.fatal(
+            'Could not find device "%s" in configuration', args.device)
+        sys.exit(-3)
+
+    if args.heat_mode == 'on':
+        callback_fn = turn_on_heat
+    elif args.heat_mode == 'off':
+        callback_fn = turn_off_heat
+    else:
+        logger.fatal('Invalid --heat_mode, must be one of "on" or "off"')
+        sys.exit(-3)
+
+    conn_mgr = connect.ConnectionManager(
+        callback_fn, dev, cfg.hosts, reconnect=False)
+
+    _ok_to_shutdown.wait()
+    conn_mgr.shutdown()
+
+
+if __name__ == '__main__':
+    main(sys.argv)