Skip to content

Commit

Permalink
Switch to libdyson-based discovery
Browse files Browse the repository at this point in the history
This is the second part of a three-part change to refactor this code
from libpurecool to libdyson. This part replaces the discovery components
(e.g; DysonDevice.auto_connect in libpurecool) to the separate API provided
by libdyson. This also adapts the libpurecool_adapter shim to have a
libdyson-like get_device() method to make switching fully over easier in
the next change.
  • Loading branch information
seanrees committed Mar 14, 2021
1 parent 56441f5 commit 792f39c
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 173 deletions.
38 changes: 20 additions & 18 deletions BUILD
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -45,7 +45,7 @@ py_library(
srcs = ["metrics.py"],
deps = [
requirement("libpurecool"),
requirement("prometheus_client")
requirement("prometheus_client"),
],
)

Expand All @@ -55,9 +55,10 @@ py_test(
deps = [
":metrics",
requirement("libpurecool"),
requirement("prometheus_client")
requirement("prometheus_client"),
],
)

py_binary(
name = "main",
srcs = ["main.py"],
Expand All @@ -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(
Expand All @@ -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",
)
40 changes: 30 additions & 10 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +43,17 @@ def load(self, filename: str):

@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']
Expand Down Expand Up @@ -68,7 +87,7 @@ def hosts(self) -> Dict[str, str]:
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:
Expand All @@ -80,19 +99,20 @@ def devices(self) -> List[object]:
... (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
23 changes: 12 additions & 11 deletions config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

import config

empty = ''
good = """
EMPTY = ''
GOOD = """
[Dyson Link]
username = Username
password = Password
Expand Down Expand Up @@ -39,17 +39,18 @@

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()
Expand All @@ -58,10 +59,10 @@ def createTemporaryFile(self, contents: str):
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)
Expand All @@ -71,8 +72,8 @@ 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')
self.assertEqual(self.good.devices[0].name, 'Living room')
self.assertEqual(self.good.devices[1].name, 'Bedroom')


if __name__ == '__main__':
Expand Down
83 changes: 40 additions & 43 deletions libpurecool_adapter.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 792f39c

Please sign in to comment.