Skip to content

Commit

Permalink
Begin transition from libpurecool to libdyson
Browse files Browse the repository at this point in the history
Recently Dyson changed their API which broke libpurecool[1]'s integration.
This resulted in prometheus-dyson being unable to enumerate devices via Dyson,
and thus fail to restart successfully.

libdyson refactors libpurecool with a clearer separation between the online
Dyson API & the device-interaction logic. This allows us to perform a one-time
login to Dyson and cache device information locally, removing the need for
repeated logins to Dyson. libdyson also has a more consistent API between
different models.

This change starts the transition by introducing login component (account.py)
and an adapter (libpurecool_adapter) to use the cached information with
libpurecool. This also adds a flag (--create_device_cache) to perform the
login&OTP dance with Dyson and generate the needed configuration.

[1] etheralm/libpurecool#37
  • Loading branch information
seanrees committed Mar 7, 2021
1 parent 557e5e6 commit 56441f5
Show file tree
Hide file tree
Showing 10 changed files with 469 additions and 86 deletions.
46 changes: 43 additions & 3 deletions BUILD
Original file line number Diff line number Diff line change
@@ -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"],
Expand All @@ -24,8 +62,10 @@ py_binary(
name = "main",
srcs = ["main.py"],
deps = [
":account",
":config",
":libpurecool_adapter",
":metrics",
requirement("libpurecool"),
requirement("prometheus_client")
],
)
Expand Down Expand Up @@ -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",
)
35 changes: 24 additions & 11 deletions WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
83 changes: 83 additions & 0 deletions account.py
Original file line number Diff line number Diff line change
@@ -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())
98 changes: 98 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
@@ -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
79 changes: 79 additions & 0 deletions config_test.py
Original file line number Diff line number Diff line change
@@ -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()
Loading

0 comments on commit 56441f5

Please sign in to comment.