Skip to content

Commit

Permalink
Merge pull request #11 from seanrees/libdyson
Browse files Browse the repository at this point in the history
Move away from libpurecool and onto libdyson
  • Loading branch information
seanrees authored Mar 19, 2021
2 parents 32b1022 + 5f9a54b commit d2055be
Show file tree
Hide file tree
Showing 11 changed files with 930 additions and 503 deletions.
70 changes: 47 additions & 23 deletions BUILD
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
load("@rules_python//python:defs.bzl", "py_binary", "py_library")
load("@pip_deps//:requirements.bzl", "requirement")
load("@rules_pkg//:pkg.bzl", "pkg_tar", "pkg_deb")
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"],
)

py_test(
name = "config_test",
srcs = ["config_test.py"],
deps = [
":config",
],
)

py_library(
name = "metrics",
srcs = ["metrics.py"],
deps = [
requirement("libpurecool"),
requirement("prometheus_client")
requirement("libdyson"),
requirement("prometheus_client"),
],
)

Expand All @@ -16,74 +37,77 @@ py_test(
srcs = ["metrics_test.py"],
deps = [
":metrics",
requirement("libpurecool"),
requirement("prometheus_client")
requirement("libdyson"),
requirement("prometheus_client"),
],
)

py_binary(
name = "main",
srcs = ["main.py"],
deps = [
":account",
":config",
":metrics",
requirement("libpurecool"),
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(
name = "main-deb",
# libpurecool has native deps.
# libdyson includes native deps.
architecture = "amd64",
built_using = "bazel",
data = ":debian-data",
depends = [
"python3",
],
prerm = "debian/prerm",
postrm = "debian/postrm",
description_file = "debian/description",
maintainer = "Sean Rees <sean at erifax.org>",
package = "prometheus-dyson",
version = "0.0.2",
postrm = "debian/postrm",
prerm = "debian/prerm",
version = "0.2.0",
)
78 changes: 43 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,18 @@ 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.

## Help! It stopped working!
## Updating instructions for 0.2.0

_(Updated: 7th March 2021)_
Due to changes in Dyson's Cloud API, automatic device detection based on your
Dyson login/password no longer works reliably.

Hi! If you use this and it's not working now (but it was before), you're likely
affected by etheralm/libpurecool#37. The long-and-short of it is that Dyson
changed their _unpublished_ online API, which broke `libpurecool` which we
rely on. You won't notice this breakage until you try to restart
prometheus-dyson.
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.

There is an _experimental_ fix in the libdyson branch. This begins a process
of switching this exporter over toto shenxn/libdyson (which should provide for
long-term stability against this type of problem).

To give it a go (once you've checked out the most recent source):

1. Build from the `libdyson` branch
1. Run the binary with `--create_device_cache`, follow the prompts
and add the generated configuration to your prometheus-dyson.ini.
1. Restart the service as normal.

Rough set of steps:
The manual step is to run this command and follow the prompts:
```
% git clone https://github.com/seanrees/prometheus-dyson.git prometheus-dyson
% cd prometheus-dyson
% git checkout libdyson
% bazel run :main -- --config=path-to-your-config.ini --create_device_cache
% /opt/prometheus-dyson/bin/main --create_device_cache
```

## Build
Expand All @@ -55,7 +40,7 @@ directory). This is _optional_ and not required.
You'll need these dependencies:

```
% pip install libpurecool
% pip install libdyson
% pip install prometheus_client
```

Expand Down Expand Up @@ -107,33 +92,56 @@ 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.

#### 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:

```
[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
```

#### Manual IP Overrides

By default, fans are auto-detected with Zeroconf. It is possible to provide
manual IP overrides in the configuraton however in the `Hosts` section.

```
[Hosts]
XX1-ZZ-ABC1234A = 10.10.100.55
```

### Args
```
% ./prometheus_dyson.py --help
usage: ./prometheus_dyson.py [-h] [--port PORT] [--config CONFIG] [--log_level LOG_LEVEL] [--include_inactive_devices]
usage: ./prometheus_dyson.py [-h] [--port PORT] [--config CONFIG] [--create_device_cache] [--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)
--include_inactive_devices
Monitor devices marked as inactive by Dyson (default is only active)
```

### Scrape Frequency

Metrics are updated at approximately 30 second intervals by `libpurecool`.
Environmental metrics are updated at approximately 30 second intervals.
Fan state changes (e.g; FAN -> HEAT) are published ~immediately on change.

### Other Notes

`libpurecool` by default uses a flavour of mDNS to automatically discover
the Dyson fan. If automatic discovery isn't available on your network, it is possible
to specify IP addresses mapped to device serial numbers in config.ini - see
`config-sample.ini` for usage.

## Dashboard

I've provided a sample Grafana dashboard in `grafana.json`.
42 changes: 28 additions & 14 deletions WORKSPACE
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,44 @@ 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.
#load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "rules_pkg",
urls = [
"https://github.com/bazelbuild/rules_pkg/releases/download/0.2.6-1/rules_pkg-0.2.6.tar.gz",
"https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.2.6/rules_pkg-0.2.6.tar.gz",
"https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
"https://github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
],
sha256 = "aeca78988341a2ee1ba097641056d168320ecc51372ef7ff8e64b139516a4937",
sha256 = "038f1caa773a7e35b3663865ffb003169c6a71dc995e39bf4815792f385d837d",
)
load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
rules_pkg_dependencies()
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())
Loading

0 comments on commit d2055be

Please sign in to comment.