├── .coveragerc ├── .coveralls.yml ├── .gitattributes ├── .github └── workflows │ ├── automerge.yml │ ├── pythonpublish.yml │ └── tox.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── demo.py ├── miflora ├── __init__.py ├── _static_version.py ├── _version.py ├── miflora_poller.py └── miflora_scanner.py ├── pylintrc ├── requirements-test.txt ├── requirements.txt ├── run_integration_tests ├── setup.py ├── test ├── __init__.py ├── conftest.py ├── helper.py ├── integration_tests │ ├── __init__.py │ ├── test_bluepy_backend.py │ ├── test_demo.py │ ├── test_everything.py │ ├── test_gatttool_backend.py │ └── test_pygatt_backend.py ├── no_imports │ ├── __init__.py │ └── test_noimports.py └── unit_tests │ ├── __init__.py │ ├── test_miflora_poller.py │ ├── test_miflora_scanner.py │ ├── test_parse.py │ └── test_versioncheck.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = miflora 3 | 4 | omit = 5 | miflora/backends/bluepy.py 6 | miflora/backends/pygatt.py 7 | miflora/_static_version.py 8 | miflora/_version.py 9 | 10 | [report] 11 | exclude_lines = 12 | # Have to re-enable the standard pragma 13 | pragma: no cover 14 | 15 | # Don't complain about missing debug-only code: 16 | def __repr__ 17 | 18 | # Don't complain if tests don't hit defensive assertion code: 19 | raise AssertionError 20 | raise NotImplementedError 21 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: GA2OyGA13fanrPLLHl5zB5MMNRb15z3wY 2 | parallel: true 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | miflora/_static_version.py export-subst 2 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: automerge 2 | on: 3 | pull_request: 4 | types: 5 | - labeled 6 | - unlabeled 7 | - synchronize 8 | - opened 9 | - edited 10 | - ready_for_review 11 | - reopened 12 | - unlocked 13 | pull_request_review: 14 | types: 15 | - submitted 16 | check_suite: 17 | types: 18 | - completed 19 | status: {} 20 | jobs: 21 | automerge: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: automerge 25 | uses: "pascalgn/automerge-action@f81beb99aef41bb55ad072857d43073fba833a98" 26 | env: 27 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 28 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: __token__ 28 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: tox 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 4 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8] 18 | 19 | steps: 20 | - uses: actions/checkout@v1 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install tox tox-gh-actions 29 | - name: Test with tox 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | run: tox 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | .hypothesis/ 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | #Ipython Notebook 61 | .ipynb_checkpoints 62 | 63 | .DS_Store 64 | .project 65 | .pydevproject 66 | 67 | #PyCharm project files 68 | .idea/ 69 | .test_mac 70 | .pytest_cache/ 71 | venv 72 | 73 | #vim swap files 74 | *.swp 75 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-docstring-first 8 | - id: check-yaml 9 | - id: debug-statements 10 | - id: check-ast 11 | - repo: https://github.com/ambv/black 12 | rev: 20.8b1 13 | hooks: 14 | - id: black 15 | - repo: https://github.com/asottile/blacken-docs 16 | rev: v1.8.0 17 | hooks: 18 | - id: blacken-docs 19 | additional_dependencies: [black==20.8b1] 20 | - repo: https://github.com/asottile/pyupgrade 21 | rev: v2.7.3 22 | hooks: 23 | - id: pyupgrade 24 | args: ['--py37-plus'] 25 | - repo: https://github.com/timothycrosley/isort 26 | rev: 5.6.4 27 | hooks: 28 | - id: isort 29 | - repo: https://gitlab.com/pycqa/flake8 30 | rev: 3.8.4 31 | hooks: 32 | - id: flake8 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Build status: 2 | [![GitHub Actions](https://github.com/basnijholt/miflora/workflows/tox/badge.svg)](https://github.com/basnijholt/miflora/actions) 3 | [![Coverage Status](https://coveralls.io/repos/github/basnijholt/miflora/badge.svg?branch=master)](https://coveralls.io/github/basnijholt/miflora?branch=master) 4 | 5 | # Testing 6 | The project uses [tox](https://tox.readthedocs.io/en/latest/) for automated testing and dependency mangement and 7 | [pytest](https://docs.pytest.org/en/latest/) as test framework. 8 | 9 | ## Unit tests 10 | Install tox and run 'tox' on your command line. This will execute all unit tests. Unit tests do **not** depend on a 11 | bluetooth dongle or a sensor. 12 | 13 | These unit tests are run on GitHub Actions. 14 | 15 | ## integration tests 16 | These tests depend on the presence of the real Xiaomi Mi sensors and a Bluteooth LE dongle. 17 | To run these tests call 18 | ```bash 19 | tox -e integration_tests -- --mac= 20 | ``` 21 | These test are NOT run on 22 | the GitHub Actions CI, as they require additional hardware. 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2020 miflora authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # miflora - Library for Xiaomi Mi plant sensor 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/miflora.svg)](https://pypi.python.org/pypi/miflora) 4 | [![PyPI](https://img.shields.io/pypi/status/miflora.svg)](https://pypi.python.org/pypi/miflora) 5 | [![PyPI](https://img.shields.io/pypi/format/miflora.svg)](https://pypi.python.org/pypi/miflora) 6 | [![GitHub Actions](https://github.com/basnijholt/miflora/workflows/tox/badge.svg)](https://github.com/basnijholt/miflora/actions) 7 | [![Coveralls github](https://img.shields.io/coveralls/github/basnijholt/miflora.svg)](https://coveralls.io/github/basnijholt/miflora) 8 | [![Plants healty and growing](https://img.shields.io/badge/plants-healthy%20and%20growing-green.svg)](https://github.com/basnijholt/miflora) 9 | [![GitHub license](https://img.shields.io/github/license/basnijholt/miflora.svg)](https://github.com/basnijholt/miflora/blob/master/LICENSE) 10 | 11 | > This repo used to live under the [open-homeautomation/miflora](https://github.com/open-homeautomation/miflora) namespace and was originally created by [@open-homeautomation](https://github.com/open-homeautomation). 12 | 13 | This library lets you read sensor data from a Xiaomi Mi Flora plant sensor. 14 | 15 | * Latest release download: https://pypi.python.org/pypi/miflora 16 | * Build status: https://github.com/basnijholt/miflora/actions 17 | * Test coverage: https://coveralls.io/github/basnijholt/miflora 18 | 19 | ## Functionality 20 | It supports reading the different measurements from the sensor 21 | - temperature 22 | - moisture 23 | - conductivity 24 | - brightness 25 | 26 | To use this library you will need a Bluetooth Low Energy dongle attached to your computer. You will also need a 27 | Xiaomi Mi Flora plant sensor. 28 | 29 | ## Backends 30 | As there is unfortunately no universally working Bluetooth Low Energy library for Python, the project currently 31 | offers support for two Bluetooth implementations: 32 | 33 | * bluepy library 34 | * bluez tools (deprecated, via a wrapper around gatttool) 35 | * pygatt library 36 | 37 | 38 | ### bluepy (recommended) 39 | To use the [bluepy](https://github.com/IanHarvey/bluepy) library you have to install it on your machine, in most cases this can be done via: 40 | ```pip3 install bluepy``` 41 | 42 | Example to use the bluepy backend: 43 | ```python 44 | from miflora.miflora_poller import MiFloraPoller 45 | from btlewrap.bluepy import BluepyBackend 46 | 47 | poller = MiFloraPoller("some mac address", BluepyBackend) 48 | ``` 49 | This is the backend library to be used. 50 | 51 | ### bluez/gatttool wrapper (deprecated) 52 | :warning: The bluez team marked gatttool as deprecated. This solution may still work on some Linux distributions, but it is not recommended any more. 53 | 54 | To use the bluez wrapper, you need to install the bluez tools on your machine. No additional python 55 | libraries are required. Some distributions moved the gatttool binary to a separate package. Make sure you have this 56 | binary available on your machine. 57 | 58 | Example to use the bluez/gatttool wrapper: 59 | ```python 60 | from miflora.miflora_poller import MiFloraPoller 61 | from btlewrap.gatttool import GatttoolBackend 62 | 63 | poller = MiFloraPoller("some mac address", GatttoolBackend) 64 | ``` 65 | 66 | This backend should only be used, if your platform is not supported by bluepy. 67 | 68 | ### pygatt 69 | If you have a Blue Giga based device that is supported by [pygatt](https://github.com/peplin/pygatt), you have to 70 | install the bluepy library on your machine. In most cases this can be done via: 71 | ```pip3 install pygatt``` 72 | 73 | Example to use the pygatt backend: 74 | ```python 75 | from miflora.miflora_poller import MiFloraPoller 76 | from btlewrap.pygatt import PygattBackend 77 | 78 | poller = MiFloraPoller("some mac address", PygattBackend) 79 | ``` 80 | ## Dependencies 81 | miflora depends on the [btlewrap](https://github.com/ChristianKuehnel/btlewrap) library. If you install miflora via PIP btlewrap will automatically be installed. If not, you will have to install btlewrap manually: 82 | 83 | ```pip3 install btlewrap``` 84 | 85 | ## Troubleshooting 86 | 87 | Users frequently have problems with the communication between their Bluetooth dongle and the sensors. Here are the usual things to try. 88 | 89 | ### Battery empty 90 | While the battery usually lasts about a year indoor, it may also fail for unknown reasons before that. So the first thing to check if the battery is still good: take out the battery, wait 3 secs and put it back in. The light on the sensor should be flashing. If it is not: get a new battery. 91 | 92 | ### Range 93 | The distance between Bluetooth dongle and sensor should be less than 5 meters. Try moving the sensor and dongle closer together and see if that solves the problem. If range is an issue, there are a few proxies/relays via MQTT available: 94 | * Linux 95 | * [plantgateway](https://github.com/ChristianKuehnel/plantgateway) 96 | * [miflora-mqtt-daemon](https://github.com/ThomDietrich/miflora-mqtt-daemon) 97 | * ESP32 98 | * [flora](https://github.com/sidddy/flora) 99 | 100 | ### Outside 101 | If you're operating your sensors outside, make sure the sensor is protected against rain. The power of the battery is decreasing blow -10°C. Sou you might not get readings at that temperature. Also make sure that you have a Bluetooth dongle close by. 102 | 103 | ### Radio interference 104 | The Bluetooth LE communication is not always reliable. There might be outages due to other radio interferences. The standard solution is to try again or poll your sensor more often that you really need it. It's also the hardest issue to analyse and debug. 105 | 106 | ### Raspberry Pi 107 | If you're using a Raspberry Pi, make sure, that you OS is up to date, including the latest kernel and firmware. There are sometimes useful Bluetooth fixes. Also make sure that you have a good power supply (3 A recommended) as this causes sporadic problems in many places. 108 | 109 | ## Conttributing 110 | please have a look at [CONTRIBUTING.md](CONTRIBUTING.md) 111 | 112 | ## Projects Depending on `miflora` 113 | 114 | The following shows a selected list of projects using this library: 115 | 116 | * https://github.com/ThomDietrich/miflora-mqtt-daemon - An MQTT Client/Daemon for Smart Home solution integration 117 | * https://home-assistant.io/components/sensor.miflora/ - Integration in Home Assistant 118 | * https://github.com/zewelor/bt-mqtt-gateway - A BT to MQTT gateway which support MiFlora sensors + other devices 119 | * https://github.com/ChristianKuehnel/plantgateway - A MQTT Client to relay sensor data 120 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Demo file showing how to use the miflora library.""" 3 | 4 | import argparse 5 | import logging 6 | import re 7 | import sys 8 | 9 | from btlewrap import BluepyBackend, GatttoolBackend, PygattBackend, available_backends 10 | 11 | from miflora import miflora_scanner 12 | from miflora.miflora_poller import ( 13 | MI_BATTERY, 14 | MI_CONDUCTIVITY, 15 | MI_LIGHT, 16 | MI_MOISTURE, 17 | MI_TEMPERATURE, 18 | MiFloraPoller, 19 | ) 20 | 21 | 22 | def valid_miflora_mac( 23 | mac, pat=re.compile(r"(80:EA:CA)|(C4:7C:8D):[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}") 24 | ): 25 | """Check for valid mac adresses.""" 26 | if not pat.match(mac.upper()): 27 | raise argparse.ArgumentTypeError( 28 | f'The MAC address "{mac}" seems to be in the wrong format' 29 | ) 30 | return mac 31 | 32 | 33 | def poll(args): 34 | """Poll data from the sensor.""" 35 | backend = _get_backend(args) 36 | poller = MiFloraPoller(args.mac, backend) 37 | print("Getting data from Mi Flora") 38 | print(f"FW: {poller.firmware_version()}") 39 | print(f"Name: {poller.name()}") 40 | print("Temperature: {}".format(poller.parameter_value(MI_TEMPERATURE))) 41 | print("Moisture: {}".format(poller.parameter_value(MI_MOISTURE))) 42 | print("Light: {}".format(poller.parameter_value(MI_LIGHT))) 43 | print("Conductivity: {}".format(poller.parameter_value(MI_CONDUCTIVITY))) 44 | print("Battery: {}".format(poller.parameter_value(MI_BATTERY))) 45 | 46 | 47 | def scan(args): 48 | """Scan for sensors.""" 49 | backend = _get_backend(args) 50 | print("Scanning for 10 seconds...") 51 | devices = miflora_scanner.scan(backend, 10) 52 | print("Found {} devices:".format(len(devices))) 53 | for device in devices: 54 | print(f" {device}") 55 | 56 | 57 | def _get_backend(args): 58 | """Extract the backend class from the command line arguments.""" 59 | if args.backend == "gatttool": 60 | backend = GatttoolBackend 61 | elif args.backend == "bluepy": 62 | backend = BluepyBackend 63 | elif args.backend == "pygatt": 64 | backend = PygattBackend 65 | else: 66 | raise Exception(f"unknown backend: {args.backend}") 67 | return backend 68 | 69 | 70 | def list_backends(_): 71 | """List all available backends.""" 72 | backends = [b.__name__ for b in available_backends()] 73 | print("\n".join(backends)) 74 | 75 | 76 | def history(args): 77 | """Read the history from the sensor.""" 78 | backend = _get_backend(args) 79 | print("Getting history from sensor...") 80 | poller = MiFloraPoller(args.mac, backend) 81 | history_list = poller.fetch_history() 82 | print("History returned {} entries.".format(len(history_list))) 83 | for entry in history_list: 84 | print(f"History from {entry.wall_time}") 85 | print(f" Temperature: {entry.temperature}") 86 | print(f" Moisture: {entry.moisture}") 87 | print(f" Light: {entry.light}") 88 | print(f" Conductivity: {entry.conductivity}") 89 | 90 | 91 | def clear_history(args): 92 | """Clear the sensor history.""" 93 | backend = _get_backend(args) 94 | print("Deleting sensor history data...") 95 | poller = MiFloraPoller(args.mac, backend) 96 | poller.clear_history() 97 | 98 | 99 | def main(): 100 | """Main function. 101 | 102 | Mostly parsing the command line arguments. 103 | """ 104 | parser = argparse.ArgumentParser() 105 | parser.add_argument( 106 | "--backend", choices=["gatttool", "bluepy", "pygatt"], default="gatttool" 107 | ) 108 | parser.add_argument("-v", "--verbose", action="store_const", const=True) 109 | subparsers = parser.add_subparsers(help="sub-command help") 110 | 111 | parser_poll = subparsers.add_parser("poll", help="poll data from a sensor") 112 | parser_poll.add_argument("mac", type=valid_miflora_mac) 113 | parser_poll.set_defaults(func=poll) 114 | 115 | parser_scan = subparsers.add_parser("scan", help="scan for devices") 116 | parser_scan.set_defaults(func=scan) 117 | 118 | parser_scan = subparsers.add_parser("backends", help="list the available backends") 119 | parser_scan.set_defaults(func=list_backends) 120 | 121 | parser_history = subparsers.add_parser("history", help="get device history") 122 | parser_history.add_argument("mac", type=valid_miflora_mac) 123 | parser_history.set_defaults(func=history) 124 | 125 | parser_history = subparsers.add_parser("clear-history", help="clear device history") 126 | parser_history.add_argument("mac", type=valid_miflora_mac) 127 | parser_history.set_defaults(func=clear_history) 128 | 129 | args = parser.parse_args() 130 | 131 | if args.verbose: 132 | logging.basicConfig(level=logging.DEBUG) 133 | 134 | if not hasattr(args, "func"): 135 | parser.print_help() 136 | sys.exit(0) 137 | 138 | args.func(args) 139 | 140 | 141 | if __name__ == "__main__": 142 | main() 143 | -------------------------------------------------------------------------------- /miflora/__init__.py: -------------------------------------------------------------------------------- 1 | """Library to read data from Mi Flora sensor""" 2 | import sys 3 | 4 | # This check must be run first, so that it fails before loading the other modules. 5 | # Otherwise we do not get a clean error message. 6 | min_python_version = (3, 6) 7 | if sys.version_info < min_python_version: 8 | raise ValueError( 9 | "miflora requires Python≥{}.{}. You're running version {}.{} from {}.".format( 10 | *min_python_version, *sys.version_info[:2], sys.executable 11 | ) 12 | ) 13 | from ._version import __version__ # noqa: E402, pylint: disable=wrong-import-position 14 | 15 | __all__ = ["__version__"] 16 | -------------------------------------------------------------------------------- /miflora/_static_version.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | # This file is part of 'miniver': https://github.com/jbweston/miniver 3 | # 4 | # This file will be overwritten by setup.py when a source or binary 5 | # distribution is made. The magic value "__use_git__" is interpreted by 6 | # version.py. 7 | 8 | version = "__use_git__" 9 | 10 | # These values are only set if the distribution was created with 'git archive' 11 | refnames = "HEAD -> master, tag: v0.7.2" 12 | git_hash = "e092814" 13 | -------------------------------------------------------------------------------- /miflora/_version.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | # This file is part of 'miniver': https://github.com/jbweston/miniver 3 | # 4 | import os 5 | import subprocess 6 | from collections import namedtuple 7 | 8 | from setuptools.command.build_py import build_py as build_py_orig 9 | from setuptools.command.sdist import sdist as sdist_orig 10 | 11 | Version = namedtuple("Version", ("release", "dev", "labels")) 12 | 13 | # No public API 14 | __all__ = [] 15 | 16 | package_root = os.path.dirname(os.path.realpath(__file__)) 17 | package_name = os.path.basename(package_root) 18 | distr_root = os.path.dirname(package_root) 19 | # If the package is inside a "src" directory the 20 | # distribution root is 1 level up. 21 | if os.path.split(distr_root)[1] == "src": 22 | _package_root_inside_src = True 23 | distr_root = os.path.dirname(distr_root) 24 | else: 25 | _package_root_inside_src = False 26 | 27 | STATIC_VERSION_FILE = "_static_version.py" 28 | 29 | 30 | def get_version(version_file=STATIC_VERSION_FILE): 31 | version_info = get_static_version_info(version_file) 32 | version = version_info["version"] 33 | if version == "__use_git__": 34 | version = get_version_from_git() 35 | if not version: 36 | version = get_version_from_git_archive(version_info) 37 | if not version: 38 | version = Version("unknown", None, None) 39 | return pep440_format(version) 40 | else: 41 | return version 42 | 43 | 44 | def get_static_version_info(version_file=STATIC_VERSION_FILE): 45 | version_info = {} 46 | with open(os.path.join(package_root, version_file), "rb") as f: 47 | exec(f.read(), {}, version_info) 48 | return version_info 49 | 50 | 51 | def version_is_from_git(version_file=STATIC_VERSION_FILE): 52 | return get_static_version_info(version_file)["version"] == "__use_git__" 53 | 54 | 55 | def pep440_format(version_info): 56 | release, dev, labels = version_info 57 | 58 | version_parts = [release] 59 | if dev: 60 | if release.endswith("-dev") or release.endswith(".dev"): 61 | version_parts.append(dev) 62 | else: # prefer PEP440 over strict adhesion to semver 63 | version_parts.append(f".dev{dev}") 64 | 65 | if labels: 66 | version_parts.append("+") 67 | version_parts.append(".".join(labels)) 68 | 69 | return "".join(version_parts) 70 | 71 | 72 | def get_version_from_git(): 73 | try: 74 | p = subprocess.Popen( 75 | ["git", "rev-parse", "--show-toplevel"], 76 | cwd=distr_root, 77 | stdout=subprocess.PIPE, 78 | stderr=subprocess.PIPE, 79 | ) 80 | except OSError: 81 | return 82 | if p.wait() != 0: 83 | return 84 | if not os.path.samefile(p.communicate()[0].decode().rstrip("\n"), distr_root): 85 | # The top-level directory of the current Git repository is not the same 86 | # as the root directory of the distribution: do not extract the 87 | # version from Git. 88 | return 89 | 90 | # git describe --first-parent does not take into account tags from branches 91 | # that were merged-in. The '--long' flag gets us the 'dev' version and 92 | # git hash, '--always' returns the git hash even if there are no tags. 93 | for opts in [["--first-parent"], []]: 94 | try: 95 | p = subprocess.Popen( 96 | ["git", "describe", "--long", "--always", "--tags"] + opts, 97 | cwd=distr_root, 98 | stdout=subprocess.PIPE, 99 | stderr=subprocess.PIPE, 100 | ) 101 | except OSError: 102 | return 103 | if p.wait() == 0: 104 | break 105 | else: 106 | return 107 | 108 | description = ( 109 | p.communicate()[0] 110 | .decode() 111 | .strip("v") # Tags can have a leading 'v', but the version should not 112 | .rstrip("\n") 113 | .rsplit("-", 2) # Split the latest tag, commits since tag, and hash 114 | ) 115 | 116 | try: 117 | release, dev, git = description 118 | except ValueError: # No tags, only the git hash 119 | # prepend 'g' to match with format returned by 'git describe' 120 | git = "g{}".format(*description) 121 | release = "unknown" 122 | dev = None 123 | 124 | labels = [] 125 | if dev == "0": 126 | dev = None 127 | else: 128 | labels.append(git) 129 | 130 | try: 131 | p = subprocess.Popen(["git", "diff", "--quiet"], cwd=distr_root) 132 | except OSError: 133 | labels.append("confused") # This should never happen. 134 | else: 135 | if p.wait() == 1: 136 | labels.append("dirty") 137 | 138 | return Version(release, dev, labels) 139 | 140 | 141 | # TODO: change this logic when there is a git pretty-format 142 | # that gives the same output as 'git describe'. 143 | # Currently we can only tell the tag the current commit is 144 | # pointing to, or its hash (with no version info) 145 | # if it is not tagged. 146 | def get_version_from_git_archive(version_info): 147 | try: 148 | refnames = version_info["refnames"] 149 | git_hash = version_info["git_hash"] 150 | except KeyError: 151 | # These fields are not present if we are running from an sdist. 152 | # Execution should never reach here, though 153 | return None 154 | 155 | if git_hash.startswith("$Format") or refnames.startswith("$Format"): 156 | # variables not expanded during 'git archive' 157 | return None 158 | 159 | VTAG = "tag: v" 160 | refs = {r.strip() for r in refnames.split(",")} 161 | version_tags = {r[len(VTAG) :] for r in refs if r.startswith(VTAG)} 162 | if version_tags: 163 | release, *_ = sorted(version_tags) # prefer e.g. "2.0" over "2.0rc1" 164 | return Version(release, dev=None, labels=None) 165 | else: 166 | return Version("unknown", dev=None, labels=[f"g{git_hash}"]) 167 | 168 | 169 | __version__ = get_version() 170 | 171 | 172 | # The following section defines a module global 'cmdclass', 173 | # which can be used from setup.py. The 'package_name' and 174 | # '__version__' module globals are used (but not modified). 175 | 176 | 177 | def _write_version(fname): 178 | # This could be a hard link, so try to delete it first. Is there any way 179 | # to do this atomically together with opening? 180 | try: 181 | os.remove(fname) 182 | except OSError: 183 | pass 184 | with open(fname, "w") as f: 185 | f.write( 186 | "# This file has been created by setup.py.\n" 187 | "version = '{}'\n".format(__version__) 188 | ) 189 | 190 | 191 | class _build_py(build_py_orig): 192 | def run(self): 193 | super().run() 194 | _write_version(os.path.join(self.build_lib, package_name, STATIC_VERSION_FILE)) 195 | 196 | 197 | class _sdist(sdist_orig): 198 | def make_release_tree(self, base_dir, files): 199 | super().make_release_tree(base_dir, files) 200 | if _package_root_inside_src: 201 | p = os.path.join("src", package_name) 202 | else: 203 | p = package_name 204 | _write_version(os.path.join(base_dir, p, STATIC_VERSION_FILE)) 205 | 206 | 207 | cmdclass = dict(sdist=_sdist, build_py=_build_py) 208 | -------------------------------------------------------------------------------- /miflora/miflora_poller.py: -------------------------------------------------------------------------------- 1 | """" 2 | Read data from Mi Flora plant sensor. 3 | """ 4 | 5 | import logging 6 | import time 7 | from datetime import datetime, timedelta 8 | from struct import unpack 9 | from threading import Lock 10 | 11 | from btlewrap.base import BluetoothBackendException, BluetoothInterface 12 | 13 | _HANDLE_READ_VERSION_BATTERY = 0x38 14 | _HANDLE_READ_NAME = 0x03 15 | _HANDLE_READ_SENSOR_DATA = 0x35 16 | _HANDLE_WRITE_MODE_CHANGE = 0x33 17 | _DATA_MODE_CHANGE = bytes([0xA0, 0x1F]) 18 | 19 | MI_TEMPERATURE = "temperature" 20 | MI_LIGHT = "light" 21 | MI_MOISTURE = "moisture" 22 | MI_CONDUCTIVITY = "conductivity" 23 | MI_BATTERY = "battery" 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | BYTEORDER = "little" 28 | 29 | _HANDLE_DEVICE_TIME = 0x41 30 | _HANDLE_HISTORY_CONTROL = 0x3E 31 | _HANDLE_HISTORY_READ = 0x3C 32 | 33 | _CMD_HISTORY_READ_INIT = b"\xa0\x00\x00" 34 | _CMD_HISTORY_READ_SUCCESS = b"\xa2\x00\x00" 35 | _CMD_HISTORY_READ_FAILED = b"\xa3\x00\x00" 36 | 37 | _INVALID_HISTORY_DATA = [ 38 | b"\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", 39 | b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", 40 | b"\xaa\xbb\xcc\xdd\xee\xff\x99\x88\x77\x66\x55\x44\x33\x22\x11\x10", 41 | ] 42 | 43 | 44 | def format_bytes(raw_data): 45 | """Prettyprint a byte array.""" 46 | if raw_data is None: 47 | return "None" 48 | return " ".join([format(c, "02x") for c in raw_data]).upper() 49 | 50 | 51 | class MiFloraPoller: 52 | """A class to read data from Mi Flora plant sensors.""" 53 | 54 | def __init__(self, mac, backend, cache_timeout=600, adapter="hci0"): 55 | """ 56 | Initialize a Mi Flora Poller for the given MAC address. 57 | """ 58 | 59 | self._mac = mac 60 | self._bt_interface = BluetoothInterface(backend, adapter=adapter) 61 | self._cache = None 62 | self._cache_timeout = timedelta(seconds=cache_timeout) 63 | self._last_read = None 64 | self._fw_last_read = None 65 | self.lock = Lock() 66 | self._firmware_version = None 67 | self.battery = None 68 | 69 | def name(self): 70 | """Return the name of the sensor.""" 71 | with self._bt_interface.connect(self._mac) as connection: 72 | name = connection.read_handle( 73 | _HANDLE_READ_NAME 74 | ) # pylint: disable=no-member 75 | 76 | if not name: 77 | raise BluetoothBackendException( 78 | "Could not read data from Mi Flora sensor %s" % self._mac 79 | ) 80 | return "".join(chr(n) for n in name) 81 | 82 | def fill_cache(self): 83 | """Fill the cache with new data from the sensor.""" 84 | _LOGGER.debug("Filling cache with new sensor data.") 85 | try: 86 | firmware_version = self.firmware_version() 87 | except BluetoothBackendException: 88 | # If a sensor doesn't work, wait 5 minutes before retrying 89 | self._last_read = ( 90 | datetime.now() - self._cache_timeout + timedelta(seconds=300) 91 | ) 92 | raise 93 | 94 | with self._bt_interface.connect(self._mac) as connection: 95 | if firmware_version >= "2.6.6": 96 | # for the newer models a magic number must be written before we can read the current data 97 | try: 98 | connection.write_handle( 99 | _HANDLE_WRITE_MODE_CHANGE, _DATA_MODE_CHANGE 100 | ) # pylint: disable=no-member 101 | # If a sensor doesn't work, wait 5 minutes before retrying 102 | except BluetoothBackendException: 103 | self._last_read = ( 104 | datetime.now() - self._cache_timeout + timedelta(seconds=300) 105 | ) 106 | return 107 | self._cache = connection.read_handle( 108 | _HANDLE_READ_SENSOR_DATA 109 | ) # pylint: disable=no-member 110 | _LOGGER.debug( 111 | "Received result for handle %s: %s", 112 | _HANDLE_READ_SENSOR_DATA, 113 | format_bytes(self._cache), 114 | ) 115 | self._check_data() 116 | if self.cache_available(): 117 | self._last_read = datetime.now() 118 | else: 119 | # If a sensor doesn't work, wait 5 minutes before retrying 120 | self._last_read = ( 121 | datetime.now() - self._cache_timeout + timedelta(seconds=300) 122 | ) 123 | 124 | def battery_level(self): 125 | """Return the battery level. 126 | 127 | The battery level is updated when reading the firmware version. This 128 | is done only once every 24h 129 | """ 130 | self.firmware_version() 131 | return self.battery 132 | 133 | def firmware_version(self): 134 | """Return the firmware version.""" 135 | if (self._firmware_version is None) or ( 136 | datetime.now() - timedelta(hours=24) > self._fw_last_read 137 | ): 138 | self._fw_last_read = datetime.now() 139 | with self._bt_interface.connect(self._mac) as connection: 140 | res = connection.read_handle( 141 | _HANDLE_READ_VERSION_BATTERY 142 | ) # pylint: disable=no-member 143 | _LOGGER.debug( 144 | "Received result for handle %s: %s", 145 | _HANDLE_READ_VERSION_BATTERY, 146 | format_bytes(res), 147 | ) 148 | if res is None: 149 | self.battery = 0 150 | self._firmware_version = None 151 | else: 152 | self.battery = res[0] 153 | self._firmware_version = "".join(map(chr, res[2:])) 154 | return self._firmware_version 155 | 156 | def parameter_value(self, parameter, read_cached=True): 157 | """Return a value of one of the monitored paramaters. 158 | 159 | This method will try to retrieve the data from cache and only 160 | request it by bluetooth if no cached value is stored or the cache is 161 | expired. 162 | This behaviour can be overwritten by the "read_cached" parameter. 163 | """ 164 | # Special handling for battery attribute 165 | if parameter == MI_BATTERY: 166 | return self.battery_level() 167 | 168 | # Use the lock to make sure the cache isn't updated multiple times 169 | with self.lock: 170 | if ( 171 | (read_cached is False) 172 | or (self._last_read is None) 173 | or (datetime.now() - self._cache_timeout > self._last_read) 174 | ): 175 | self.fill_cache() 176 | else: 177 | _LOGGER.debug( 178 | "Using cache (%s < %s)", 179 | datetime.now() - self._last_read, 180 | self._cache_timeout, 181 | ) 182 | 183 | if self.cache_available() and (len(self._cache) == 16): 184 | return self._parse_data()[parameter] 185 | if self.cache_available() and (self.is_ropot()): 186 | if parameter == MI_LIGHT: 187 | return False 188 | return self._parse_data()[parameter] 189 | raise BluetoothBackendException( 190 | "Could not read data from Mi Flora sensor %s" % self._mac 191 | ) 192 | 193 | def _check_data(self): 194 | """Ensure that the data in the cache is valid. 195 | 196 | If it's invalid, the cache is wiped. 197 | """ 198 | if not self.cache_available(): 199 | return 200 | if self._cache[7] > 100: # moisture over 100 procent 201 | self.clear_cache() 202 | return 203 | if self._firmware_version >= "2.6.6": 204 | if sum(self._cache[10:]) == 0: 205 | self.clear_cache() 206 | return 207 | if sum(self._cache) == 0: 208 | self.clear_cache() 209 | return 210 | 211 | def clear_cache(self): 212 | """Manually force the cache to be cleared.""" 213 | self._cache = None 214 | self._last_read = None 215 | 216 | def cache_available(self): 217 | """Check if there is data in the cache.""" 218 | return self._cache is not None 219 | 220 | def is_ropot(self): 221 | """Check if the sensor is a ropot.""" 222 | return len(self._cache) == 24 223 | 224 | def _parse_data(self): 225 | """Parses the byte array returned by the sensor. 226 | 227 | The sensor returns 16 bytes in total. It's unclear what the meaning of these bytes 228 | is beyond what is decoded in this method. 229 | 230 | semantics of the data (in little endian encoding): 231 | bytes 0-1: temperature in 0.1 °C 232 | byte 2: unknown 233 | bytes 3-6: brightness in Lux (MiFlora only) 234 | byte 7: moisture in % 235 | byted 8-9: conductivity in µS/cm 236 | bytes 10-15: unknown 237 | """ 238 | data = self._cache 239 | res = dict() 240 | if self.is_ropot(): 241 | temp, res[MI_MOISTURE], res[MI_CONDUCTIVITY] = unpack( 242 | " 0: 269 | for i in range(history_length): 270 | payload = self._cmd_history_address(i) 271 | try: 272 | connection.write_handle( 273 | _HANDLE_HISTORY_CONTROL, payload 274 | ) # pylint: disable=no-member 275 | response = connection.read_handle( 276 | _HANDLE_HISTORY_READ 277 | ) # pylint: disable=no-member 278 | if response in _INVALID_HISTORY_DATA: 279 | msg = f"Got invalid history data: {response}" 280 | _LOGGER.error(msg) 281 | else: 282 | data.append(HistoryEntry(response)) 283 | except Exception: # pylint: disable=broad-except 284 | # find a more narrow exception here 285 | # when reading fails, we're probably at the end of the history 286 | # even when the history_length might suggest something else 287 | _LOGGER.error( 288 | "Could only retrieve %d of %d entries from the history. " 289 | "The rest is not readable", 290 | i, 291 | history_length, 292 | ) 293 | # connection.write_handle(_HANDLE_HISTORY_CONTROL, _CMD_HISTORY_READ_FAILED) 294 | break 295 | _LOGGER.info( 296 | "Progress: reading entry %d of %d", i + 1, history_length 297 | ) 298 | 299 | (device_time, wall_time) = self._fetch_device_time() 300 | time_diff = wall_time - device_time 301 | for entry in data: 302 | entry.compute_wall_time(time_diff) 303 | 304 | return data 305 | 306 | def clear_history(self): 307 | """Clear the device history. 308 | 309 | On the next fetch_history, you will only get new data. 310 | Note: The data is deleted from the device. There is no way to recover it! 311 | """ 312 | with self._bt_interface.connect(self._mac) as connection: 313 | connection.write_handle( 314 | _HANDLE_HISTORY_CONTROL, _CMD_HISTORY_READ_INIT 315 | ) # pylint: disable=no-member 316 | connection.write_handle( 317 | _HANDLE_HISTORY_CONTROL, _CMD_HISTORY_READ_SUCCESS 318 | ) # pylint: disable=no-member 319 | 320 | @staticmethod 321 | def _cmd_history_address(addr): 322 | """Calculate this history address""" 323 | return b"\xa1" + addr.to_bytes(2, BYTEORDER) 324 | 325 | def _fetch_device_time(self): 326 | """Fetch device time. 327 | 328 | The device time is in seconds. 329 | """ 330 | start = time.time() 331 | with self._bt_interface.connect(self._mac) as connection: 332 | response = connection.read_handle( 333 | _HANDLE_DEVICE_TIME 334 | ) # pylint: disable=no-member 335 | _LOGGER.debug("device time raw: %s", response) 336 | wall_time = (time.time() + start) / 2 337 | device_time = int.from_bytes(response, BYTEORDER) 338 | _LOGGER.info("device time: %s local time: %s", device_time, wall_time) 339 | 340 | return device_time, wall_time 341 | 342 | 343 | class HistoryEntry: # pylint: disable=too-few-public-methods 344 | """Entry in the history of the device.""" 345 | 346 | def __init__(self, byte_array): 347 | self.device_time = None 348 | self.wall_time = None 349 | self.temperature = None 350 | self.light = None 351 | self.moisture = None 352 | self.conductivity = None 353 | self._decode_history(byte_array) 354 | 355 | def _decode_history(self, byte_array): 356 | """Perform byte magic when decoding history data.""" 357 | # negative numbers are stored in one's complement 358 | # pylint: disable=trailing-comma-tuple 359 | 360 | temp_bytes = byte_array[4:6] 361 | if temp_bytes[1] & 0x80 > 0: 362 | temp_bytes = [temp_bytes[0] ^ 0xFF, temp_bytes[1] ^ 0xFF] 363 | 364 | self.device_time = int.from_bytes(byte_array[:4], BYTEORDER) 365 | self.temperature = int.from_bytes(temp_bytes, BYTEORDER) / 10.0 366 | self.light = int.from_bytes(byte_array[7:10], BYTEORDER) 367 | self.moisture = byte_array[11] 368 | self.conductivity = int.from_bytes(byte_array[12:14], BYTEORDER) 369 | 370 | _LOGGER.debug("Raw data for char 0x3c: %s", format_bytes(byte_array)) 371 | _LOGGER.debug("device time: %d", self.device_time) 372 | _LOGGER.debug("temp: %f", self.temperature) 373 | _LOGGER.debug("brightness: %d", self.light) 374 | _LOGGER.debug("conductivity: %d", self.conductivity) 375 | _LOGGER.debug("moisture: %d", self.moisture) 376 | 377 | def compute_wall_time(self, time_diff): 378 | """Correct the device time to the wall time. """ 379 | self.wall_time = datetime.fromtimestamp(self.device_time + time_diff) 380 | -------------------------------------------------------------------------------- /miflora/miflora_scanner.py: -------------------------------------------------------------------------------- 1 | """Scan for miflora devices""" 2 | 3 | # use only lower case names here 4 | VALID_DEVICE_NAMES = ["flower mate", "flower care"] 5 | 6 | DEVICE_PREFIX = "C4:7C:8D:" 7 | 8 | 9 | def scan(backend, timeout=10): 10 | """Scan for miflora devices. 11 | 12 | Note: this must be run as root! 13 | """ 14 | return [ 15 | mac.upper() 16 | for (mac, name) in backend.scan_for_devices(timeout) 17 | if ( 18 | (name is not None and name.lower() in VALID_DEVICE_NAMES) 19 | or mac is not None 20 | and mac.upper().startswith(DEVICE_PREFIX) 21 | ) 22 | ] 23 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable = abstract-method, too-many-instance-attributes, too-many-arguments, bad-continuation 3 | 4 | [FORMAT] 5 | max-line-length=120 6 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-timeout 4 | pylint==2.6.0 5 | flake8 6 | pexpect 7 | coveralls 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bluepy==1.3.0 2 | pygatt==3.2.0 3 | btlewrap==0.1.0 4 | -------------------------------------------------------------------------------- /run_integration_tests: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # simple script to run the integration tests 3 | # 4 | # - tests need to run as root so we can scan for new devices 5 | # - store the mac address of the device you're using in a file 6 | # called ".test_mac" so you can run the test with one call. 7 | 8 | MAC=`cat .test_mac` 9 | 10 | if [ $# -eq 0 ]; then 11 | SUITE=test/integration_tests 12 | else 13 | SUITE=$1 14 | fi 15 | TOX=$(which tox) 16 | sudo ${TOX} -e integration_tests -- --mac=$MAC $SUITE 17 | # clean up the file system permissions after running the tests as root 18 | sudo chown -R $UID .cache .tox .pytest_cache 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Python package description.""" 2 | import os 3 | from importlib.util import module_from_spec, spec_from_file_location 4 | 5 | from setuptools import find_packages, setup 6 | 7 | 8 | def readme(): 9 | """Load the readme file.""" 10 | with open("README.md") as readme_file: 11 | return readme_file.read() 12 | 13 | 14 | def get_version_and_cmdclass(package_path): 15 | """Load version.py module without importing the whole package. 16 | 17 | Template code from miniver 18 | """ 19 | spec = spec_from_file_location("version", os.path.join(package_path, "_version.py")) 20 | module = module_from_spec(spec) 21 | spec.loader.exec_module(module) 22 | return module.__version__, module.cmdclass 23 | 24 | 25 | version, cmdclass = get_version_and_cmdclass("miflora") 26 | 27 | 28 | setup( 29 | name="miflora", 30 | version=version, 31 | cmdclass=cmdclass, 32 | description="Library to read data from Mi Flora sensor", 33 | long_description=readme(), 34 | long_description_content_type="text/markdown", 35 | url="https://github.com/basnijholt/miflora", 36 | author="Daniel Matuschek", 37 | author_email="daniel@matuschek.net", 38 | maintainer="Bas Nijholt", 39 | maintainer_email="bas@nijho.lt", 40 | license="MIT", 41 | python_requires=">=3.6", 42 | classifiers=[ 43 | "Development Status :: 5 - Production/Stable", 44 | "Intended Audience :: Developers", 45 | "Topic :: System :: Hardware :: Hardware Drivers", 46 | "License :: OSI Approved :: MIT License", 47 | "Programming Language :: Python :: 3", 48 | "Programming Language :: Python :: 3.6", 49 | "Programming Language :: Python :: 3.7", 50 | "Programming Language :: Python :: 3.8", 51 | ], 52 | packages=find_packages(exclude=["test", "test.*"]), 53 | keywords="plant sensor bluetooth low-energy ble", 54 | zip_safe=False, 55 | install_requires=["btlewrap>=0.0.10,<0.2"], 56 | extras_require={"testing": ["pytest"]}, 57 | include_package_data=True, 58 | ) 59 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | """Constants unsed in the tests.""" 2 | 3 | HANDLE_READ_VERSION_BATTERY = 0x38 4 | HANDLE_READ_NAME = 0x03 5 | HANDLE_READ_SENSOR_DATA = 0x35 6 | HANDLE_WRITE_MODE_CHANGE = 0x33 7 | 8 | HANDLE_DEVICE_TIME = 0x41 9 | HANDLE_HISTORY_CONTROL = 0x3E 10 | HANDLE_HISTORY_READ = 0x3C 11 | 12 | DATA_MODE_CHANGE = bytes([0xA0, 0x1F]) 13 | 14 | TEST_MAC = "11:22:33:44:55:66" 15 | 16 | INVALID_DATA = b"\xaa\xbb\xcc\xdd\xee\xff\x99\x88\x77\x66\x00\x00\x00\x00\x00\x00" 17 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | """Configure pytest for integration tests.""" 2 | import logging 3 | 4 | import pytest 5 | 6 | 7 | def pytest_addoption(parser): 8 | """Setup test environment for pytest. 9 | 10 | Changes: 11 | - Add command line parameter '--mac=' to pytest. 12 | - enable logging to console 13 | """ 14 | parser.addoption( 15 | "--mac", action="store", help="mac address of sensor to be used for testing" 16 | ) 17 | logging.basicConfig(level=logging.DEBUG) 18 | 19 | 20 | @pytest.fixture(scope="class") 21 | def mac(request): 22 | """Get command line parameter and store it in class""" 23 | request.cls.mac = request.config.getoption("--mac") 24 | -------------------------------------------------------------------------------- /test/helper.py: -------------------------------------------------------------------------------- 1 | """Helper functions for unit tests.""" 2 | from struct import unpack 3 | from test import ( 4 | HANDLE_DEVICE_TIME, 5 | HANDLE_HISTORY_CONTROL, 6 | HANDLE_HISTORY_READ, 7 | HANDLE_READ_NAME, 8 | HANDLE_READ_SENSOR_DATA, 9 | HANDLE_READ_VERSION_BATTERY, 10 | ) 11 | 12 | from btlewrap.base import AbstractBackend, BluetoothBackendException 13 | 14 | 15 | class MockBackend(AbstractBackend): 16 | """Mockup of a Backend and Sensor. 17 | 18 | The behaviour of this Sensors is based on the knowledge there 19 | is so far on the behaviour of the sensor. So if our knowledge 20 | is wrong, so is the behaviour of this sensor! Thus is always 21 | makes sensor to also test against a real sensor. 22 | """ 23 | 24 | def __init__(self, adapter: str = "hci0", *, address_type: str): 25 | super().__init__(adapter, address_type) 26 | self._address_type = address_type 27 | self._version = (0, 0, 0) 28 | self.name = "" 29 | self.battery_level = 0 30 | self.temperature = 0.0 31 | self.brightness = 0 32 | self.moisture = 0 33 | self.conductivity = 0 34 | self.written_handles = [] 35 | self.expected_write_handles = set() 36 | self.override_read_handles = dict() 37 | self.is_available = True 38 | self._handle_0x35_raw_set = False 39 | self._handle_0x35_raw = None 40 | self._handle_0x03_raw_set = False 41 | self._handle_0x03_raw = None 42 | self._handle_0x38_raw_set = False 43 | self._handle_0x38_raw = None 44 | self.history_info = None 45 | self.history_data = [] 46 | self.local_time = None 47 | self._history_control = None 48 | 49 | def check_backend(self): 50 | """This backend is available when the field is set accordingly.""" 51 | return self.is_available 52 | 53 | def set_version(self, major, minor, patch): 54 | """Sets the version number to be returned.""" 55 | self._version = (major, minor, patch) 56 | 57 | @property 58 | def version(self): 59 | """Get the stored version number as string.""" 60 | return "{}.{}.{}".format(*self._version) 61 | 62 | def read_handle(self, handle): 63 | """Read one of the handles that are implemented.""" 64 | # this rule produces false positives in pylint 2.3.1, so disabling it 65 | # pylint: disable=no-else-return 66 | 67 | if handle in self.override_read_handles: 68 | return self.override_read_handles[handle] 69 | elif handle == HANDLE_READ_VERSION_BATTERY: 70 | return self._read_battery_version() 71 | elif handle == HANDLE_READ_SENSOR_DATA: 72 | return self._read_sensor_data() 73 | elif handle == HANDLE_READ_NAME: 74 | return self._read_name() 75 | elif handle == HANDLE_HISTORY_READ: 76 | return self._read_history() 77 | elif handle == HANDLE_DEVICE_TIME: 78 | return self.local_time 79 | raise ValueError("handle not implemented in mockup") 80 | 81 | def write_handle(self, handle, value): 82 | """Writing handles just stores the results in a list.""" 83 | if handle == HANDLE_HISTORY_CONTROL: 84 | self._history_control = value 85 | else: 86 | self.written_handles.append((handle, value)) 87 | return handle in self.expected_write_handles 88 | 89 | def _read_battery_version(self): 90 | """Recreate the battery level and version string from the fields of this class.""" 91 | if self._handle_0x38_raw_set: 92 | return self._handle_0x38_raw 93 | result = [self.battery_level, 0xFF] 94 | result += [ord(c) for c in self.version] 95 | return bytes(result) 96 | 97 | def _read_sensor_data(self): 98 | """Recreate sensor data from the fields of this class.""" 99 | if self._handle_0x35_raw_set: 100 | return self._handle_0x35_raw 101 | result = [0xFE] * 16 102 | temp = int(self.temperature * 10) 103 | result[0] = int(temp % 256) 104 | result[1] = int(temp / 256) 105 | 106 | result[3] = int(self.brightness % 256) 107 | result[4] = int(self.brightness >> 8) 108 | result[5] = 0 109 | result[6] = 0 110 | 111 | result[7] = int(self.moisture) 112 | 113 | result[8] = int(self.conductivity % 256) 114 | result[9] = int(self.conductivity >> 8) 115 | return bytes(result) 116 | 117 | def _read_name(self): 118 | """Convert the name into a byte array and return it.""" 119 | if self._handle_0x03_raw_set: 120 | return self._handle_0x03_raw 121 | return [ord(c) for c in self.name] 122 | 123 | @property 124 | def handle_0x35_raw(self): 125 | """Getter for handle_0x35_raw.""" 126 | return self._handle_0x35_raw 127 | 128 | @handle_0x35_raw.setter 129 | def handle_0x35_raw(self, value): 130 | """Setter for handle_0x35_raw. 131 | 132 | This needs a separate flag so that we can also use "None" as return value. 133 | """ 134 | self._handle_0x35_raw_set = True 135 | self._handle_0x35_raw = value 136 | 137 | @property 138 | def handle_0x03_raw(self): 139 | """Getter for handle_0x03_raw.""" 140 | return self._handle_0x03_raw 141 | 142 | @handle_0x03_raw.setter 143 | def handle_0x03_raw(self, value): 144 | """Setter for handle_0x33_raw. 145 | 146 | This needs a separate flag so that we can also use "None" as return value. 147 | """ 148 | self._handle_0x03_raw_set = True 149 | self._handle_0x03_raw = value 150 | 151 | @property 152 | def handle_0x38_raw(self): 153 | """Getter for handle_0x38_raw.""" 154 | return self._handle_0x38_raw 155 | 156 | @handle_0x38_raw.setter 157 | def handle_0x38_raw(self, value): 158 | """Setter for handle_0x33_raw. 159 | 160 | This needs a separate flag so that we can also use "None" as return value. 161 | """ 162 | self._handle_0x38_raw_set = True 163 | self._handle_0x38_raw = value 164 | 165 | def _read_history(self): 166 | """Read the history data with the index set in previous HISTORY_CONTROL operation.""" 167 | # this rule produces false positives in pylint 2.3.1, so disabling it 168 | # pylint: disable=no-else-return 169 | 170 | if self.history_data is None: 171 | raise ValueError("history not set") 172 | (cmd, index) = unpack("= self.MIN_SUPPORTED_VERSION: 17 | return 18 | try: 19 | import miflora # noqa: F401 # pylint: disable=unused-import,import-outside-toplevel 20 | 21 | self.fail("Should have thrown an exception") 22 | except ValueError as val_err: 23 | self.assertIn("version", str(val_err)) 24 | 25 | def test_py3(self): 26 | """Make sure newer python versions do not throw an exception.""" 27 | if sys.version_info < self.MIN_SUPPORTED_VERSION: 28 | return 29 | import miflora # noqa: F401 # pylint: disable=unused-import,import-outside-toplevel 30 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, py37, py38, pylint, flake8, integration_tests, noimport, pre-commit 3 | skip_missing_interpreters = True 4 | 5 | [gh-actions] 6 | python = 7 | 3.6: py36, flake8, pylint, noimport 8 | 3.7: py37 9 | 3.8: py38, pre-commit 10 | 11 | [testenv] 12 | # only run unit tests as they do not need additional hardware 13 | passenv = GITHUB_* 14 | deps = 15 | -rrequirements.txt 16 | -rrequirements-test.txt 17 | commands = 18 | pytest --cov=miflora --timeout=10 test/unit_tests 19 | coveralls --service=github 20 | 21 | [testenv:pre-commit] 22 | description = format the code 23 | deps = 24 | {[testenv]deps} 25 | pre-commit >= 2, < 3 26 | commands = 27 | pre-commit run --all-files --show-diff-on-failure 28 | 29 | [testenv:noimport] 30 | # run tests without installing any Bluetooth libraries 31 | deps= -rrequirements-test.txt 32 | commands = pytest --timeout=10 test/no_imports 33 | 34 | [testenv:integration_tests] 35 | #there tests are run separately as they require real hardware 36 | #need the command line argument --mac= to work 37 | commands = pytest --timeout=60 {posargs} 38 | 39 | [testenv:flake8] 40 | base=python3 41 | ignore_errors=True 42 | commands=flake8 demo.py setup.py miflora test 43 | 44 | [testenv:pylint] 45 | basepython = python3 46 | skip_install = true 47 | commands = pylint -j4 miflora test setup.py demo.py 48 | 49 | [flake8] 50 | install-hook=git 51 | 52 | max-line-length = 100 53 | ignore = E501, W503, E203, E266 54 | max-complexity = 18 55 | select = B, C, E, F, W, T4, B9 56 | exclude = .git, .tox, __pycache__, dist 57 | 58 | [isort] 59 | profile = black 60 | 61 | [mypy] 62 | strict_optional = True 63 | disallow_untyped_calls = True 64 | ignore_missing_imports = True 65 | --------------------------------------------------------------------------------