Skip to content

Commit

Permalink
Add utility: switch_heat_mode (#19)
Browse files Browse the repository at this point in the history
switch_heat_mode will take a Dyson fan (e.g; --device="Living room") and toggle the heat setting to match --heat_mode (e.g; --heat_mode="on" or --heat_mode="off")

This is intended to support cron jobs and/or other tools that might want to time turning the fan on/off.
  • Loading branch information
seanrees authored Jul 5, 2022
1 parent b9feb15 commit 3164be1
Show file tree
Hide file tree
Showing 8 changed files with 378 additions and 175 deletions.
13 changes: 12 additions & 1 deletion BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ load("@rules_pkg//:pkg.bzl", "pkg_deb", "pkg_tar")
py_library(
name = "config",
srcs = ["config.py"],
visibility = ["//:__subpackages__"]
)

py_test(
Expand All @@ -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"],
Expand All @@ -39,9 +50,9 @@ py_binary(
srcs = ["main.py"],
deps = [
":config",
":connect",
":metrics",
requirement("prometheus_client"),
requirement("libdyson"),
],
)

Expand Down
11 changes: 7 additions & 4 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
DysonLinkCredentials = collections.namedtuple(
'DysonLinkCredentials', ['username', 'password', 'country'])

logger = logging.getLogger(__name__)


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)
Expand All @@ -31,12 +34,12 @@ def load(cls, filename: str):
"""
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
Expand All @@ -60,7 +63,7 @@ def dyson_credentials(self) -> Optional[DysonLinkCredentials]:
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

Expand All @@ -78,7 +81,7 @@ def hosts(self) -> Dict[str, str]:
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'))
Expand Down
6 changes: 4 additions & 2 deletions config_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import config

logger = logging.getLogger(__name__)


def _query_credentials() -> config.DysonLinkCredentials:
"""Asks the user for their DysonLink/Cloud credentials.
Expand Down Expand Up @@ -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)

Expand All @@ -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':
Expand Down
191 changes: 191 additions & 0 deletions connect.py
Original file line number Diff line number Diff line change
@@ -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)

Loading

0 comments on commit 3164be1

Please sign in to comment.