From 65102bdbf83e5ce0850d34a0e5922f011a03758e Mon Sep 17 00:00:00 2001 From: Sean Rees Date: Fri, 23 Apr 2021 09:07:14 +0100 Subject: [PATCH] Add a config_builder to replace --create_device_cache The config builder is capable of both initial and re-builds of the config, and can also directly write to the configuration file (as opposed to relying on the user to copy-paste). It's also a separate binary and no-longer (buggily) plumbed through main.py. --- BUILD | 25 ++++--- README.md | 41 ++++++----- account.py | 83 --------------------- config.py | 2 +- config_builder.py | 181 ++++++++++++++++++++++++++++++++++++++++++++++ main.py | 33 ++------- 6 files changed, 227 insertions(+), 138 deletions(-) delete mode 100644 account.py create mode 100644 config_builder.py diff --git a/BUILD b/BUILD index aa9bc8a..804d9f6 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 c610a1d..214dc4d 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 3af4e85..0000000 --- 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 3dee251..ba58310 100644 --- a/config.py +++ b/config.py @@ -60,7 +60,7 @@ def dyson_credentials(self) -> Optional[DysonLinkCredentials]: 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 0000000..ed56aeb --- /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 85a8143..332281e 100755 --- a/main.py +++ b/main.py @@ -15,7 +15,6 @@ import libdyson.dyson_device import libdyson.exceptions -import account import config import metrics @@ -53,23 +52,25 @@ 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. + 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(