├── VERSION ├── MANIFEST.in ├── pyaranet4 ├── __init__.py ├── exceptions.py ├── util.py ├── __main__.py └── pyaranet4.py ├── pyproject.toml ├── LICENSE ├── README.md └── .gitignore /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.3 -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION -------------------------------------------------------------------------------- /pyaranet4/__init__.py: -------------------------------------------------------------------------------- 1 | from .pyaranet4 import Aranet4 -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 64", 4 | "wheel >= 0.37.1", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [project] 9 | name = "pyaranet4" 10 | description = "A cross-platform Python interface for the Aranet4 CO₂ meter" 11 | readme = "README.md" 12 | requires-python = ">=3.6" 13 | license = {text = "MIT License"} 14 | classifiers = [ 15 | "License :: OSI Approved :: MIT License", 16 | "Natural Language :: English", 17 | ] 18 | dependencies = [ 19 | "bleak == 0.19.0", 20 | "requests == 2.28.1" 21 | ] 22 | dynamic = ["version"] 23 | 24 | 25 | [project.scripts] 26 | pyaranet4 = "pyaranet4.__main__:main" 27 | 28 | [tool.setuptools.dynamic] 29 | version = {file = ["VERSION"]} 30 | -------------------------------------------------------------------------------- /pyaranet4/exceptions.py: -------------------------------------------------------------------------------- 1 | class Aranet4Exception(BaseException): 2 | """ 3 | Base exception for pyaranet4 4 | """ 5 | pass 6 | 7 | 8 | class Aranet4NotFoundException(Aranet4Exception): 9 | """ 10 | Exception that occurs when no suitable Aranet4 device is available 11 | """ 12 | pass 13 | 14 | 15 | class Aranet4BusyException(Aranet4Exception): 16 | """ 17 | Exception that occurs when one attempts to fetch history from the device 18 | while history for another sensor is being read 19 | """ 20 | pass 21 | 22 | 23 | class Aranet4UnpairedException(Aranet4Exception): 24 | """ 25 | Exception that occurs when the Aranet4 device is detected but unpaired, and 26 | no data can be read from it. 27 | """ 28 | pass 29 | -------------------------------------------------------------------------------- /pyaranet4/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility functions for pyaranet4 3 | """ 4 | 5 | 6 | def le16(data, start=0): 7 | """ 8 | Read value from byte array as a long integer 9 | 10 | :param bytearray data: Array of bytes to read from 11 | :param int start: Offset to start reading at 12 | :return int: An integer, read from the first two bytes at the offset. 13 | """ 14 | raw = bytearray(data) 15 | return raw[start] + (raw[start + 1] << 8) 16 | 17 | 18 | def write_le16(data, pos, value): 19 | """ 20 | Write a value as a long integer to a byte array 21 | 22 | :param bytearray data: Array of bytes to write to 23 | :param int pos: Position to store value at as a two-byte long integer 24 | :param int value: Value to store 25 | :return bytearray: Updated bytearray 26 | """ 27 | data[pos] = (value) & 0x00FF 28 | data[pos + 1] = (value >> 8) & 0x00FF 29 | 30 | return data 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Stijn Peeters 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyaranet4 - Interface with your Aranet4 CO₂ meter via Python 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/pyaranet4?logo=pypi&logoColor=FFE873)](https://pypi.org/project/pyaranet4/) 4 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/pyaranet4?logo=python&logoColor=FFE873)](https://pypi.org/project/pyaranet4/) 5 | 6 | This is a cross-platform interface for the [Aranet4](https://aranet4.com/) CO₂ meter. You can use it to read values from 7 | the meter to then store in a database, display on a website, or generally do with whatever you want. Since the official 8 | mobile app for the device does not have automatic export features and the manual export is prone to failure, you can use 9 | this library instead for such purposes. 10 | 11 | It is built with [Bleak](https://github.com/hbldh/bleak), a cross-platform Python Bluetooth client. It is also heavily 12 | inspired by [Aranet4-Python](https://github.com/Anrijs/Aranet4-Python), an excellent library that is unfortunately only 13 | compatible with Linux. 14 | 15 | * Works on MacOS, Linux and Windows 16 | * Command-line tool and Python library 17 | * Lightweight: instantiate class, read class properties, done 18 | 19 | ## Installation 20 | 21 | ``` 22 | pip install pyaranet4 23 | ``` 24 | 25 | ## Usage 26 | 27 | Before you do anything, make sure the device is properly paired. Once it is paired, pyaranet4 will usually be able to 28 | figure out the rest (e.g. the MAC address) by itself. Note that Bluetooth LE is a slow protocol, and most commands and 29 | calls will take a couple of seconds to complete. This is not an issue with the library, but a limitation of the 30 | technology. 31 | 32 | ### As a command-line tool 33 | 34 | pyaranet4 comes with a command-line utility, which is mostly compatible with Aranet4-Python's: 35 | 36 | ``` 37 | C:\> pyaranet4 38 | -------------------------------------- 39 | Connected: Aranet4 06CDC | v0.4.4 40 | Updated 56 s ago. Intervals: 60 s 41 | 2167 total readings 42 | -------------------------------------- 43 | CO2: 511 ppm 44 | Temperature: 25.05 C 45 | Humidity: 58 % 46 | Pressure: 1014.50 hPa 47 | Battery: 98 % 48 | -------------------------------------- 49 | ``` 50 | 51 | Get stored historical values from the device: 52 | 53 | ``` 54 | C:\> pyaranet4 --history 55 | index,timestamp,temperature,humidity,pressure,co2 56 | 1,2021-09-09 22:12:20,25.1,56,1014.5,584 57 | 2,2021-09-09 22:13:20,25.1,56,1014.5,590 58 | 3,2021-09-09 22:14:20,25.1,56,1014.5,579 59 | ... 60 | ``` 61 | 62 | Or save them to a file: 63 | 64 | ``` 65 | C:\> pyaranet4 --history --output-file=readings.csv 66 | ``` 67 | 68 | Or view the full list of command-line arguments and parameters: 69 | 70 | ``` 71 | C:\> pyaranet4 --help 72 | ``` 73 | 74 | ### As a library 75 | 76 | You can also use pyaranet4 as a library: 77 | 78 | ```python 79 | from pyaranet4 import Aranet4 80 | 81 | a4 = Aranet4() 82 | print("Battery level: %s%%" % a4.battery_level) 83 | print("Current CO₂ level: %i ppm" % a4.current_readings.co2) 84 | print("Stored CO2 values:") 85 | print(a4.history.co2) 86 | ``` 87 | 88 | The `Aranet4` object has the following public properties and methods: 89 | 90 | * `current_readings` (namespace): The current readings of the device's sensors, as a namespace with properties `co2` ( 91 | integer), `temperature` (float) `pressure` (float), `humidity` (integer), `battery_level` (integer) 92 | , `update_interval` (integer), and `since_last_update` (integer) 93 | * `current_readings_simple` (namespace): Identical to `current_readings`, but without the `update_interval` 94 | and `since_last_update` properties; may be faster to request 95 | * `history` (namespace): Historical readings stored on the device, as a namespace with properties `co2`, `temperature` 96 | , `pressure`, `humidity`, `sensors`, and `timestamps`. The sensor values are dictionaries with the interval index as 97 | keys and the sensor reading as values. `sensors` is a tuple of sensors included in the result. `timestamps` is a 98 | dictionary with indexes as keys and corresponding UNIX timestamps as values. The latter can be used to determine what 99 | the timestamp of a given value is. 100 | * `mac_address` (string): The MAC address of the Bluetooth device 101 | * `battery_level` (integer): Battery level, 0-100. 102 | * `manufacturer_name` (string): The manufacturer of the device, e.g. `SAF Tehnika` 103 | * `model_name` (string): The name of the device model, e.g. `Aranet4` 104 | * `device_name` (string): The name of the device, e.g. `Aranet4 06CDC` 105 | * `hardware_revision` (integer): Hardware revision number, e.g. `9` 106 | * `software_revision` (string): Software (firmware) version, e.g. `v0.4.4` 107 | * `update_interval` (integer): Amount of seconds between sensor updates 108 | * `since_last_update` (integer): Amount of seconds since last sensor update 109 | * `stored_readings_amount` (integer): Amount of sensor readings stored on the device 110 | * `get_history(sensors: tuple, start: int, end: int)` (namespace): Same return type as `history`, but allows one to 111 | limit results to a given tuple of sensors and a given range of indexes, which can be faster to receive than the full 112 | history. Sensors should be a tuple of a combination of `Aranet4.SENSOR_CO2`, `Aranet4.SENSOR_HUMIDITY`, and so on. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### JetBrains template 2 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 3 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 4 | 5 | # User-specific stuff 6 | .idea/**/workspace.xml 7 | .idea/**/tasks.xml 8 | .idea/**/usage.statistics.xml 9 | .idea/**/dictionaries 10 | .idea/**/shelf 11 | 12 | # Generated files 13 | .idea/**/contentModel.xml 14 | 15 | # Sensitive or high-churn files 16 | .idea/**/dataSources/ 17 | .idea/**/dataSources.ids 18 | .idea/**/dataSources.local.xml 19 | .idea/**/sqlDataSources.xml 20 | .idea/**/dynamic.xml 21 | .idea/**/uiDesigner.xml 22 | .idea/**/dbnavigator.xml 23 | 24 | # Gradle 25 | .idea/**/gradle.xml 26 | .idea/**/libraries 27 | 28 | # Gradle and Maven with auto-import 29 | # When using Gradle or Maven with auto-import, you should exclude module files, 30 | # since they will be recreated, and may cause churn. Uncomment if using 31 | # auto-import. 32 | # .idea/artifacts 33 | # .idea/compiler.xml 34 | # .idea/jarRepositories.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### Windows template 75 | # Windows thumbnail cache files 76 | Thumbs.db 77 | Thumbs.db:encryptable 78 | ehthumbs.db 79 | ehthumbs_vista.db 80 | 81 | # Dump file 82 | *.stackdump 83 | 84 | # Folder config file 85 | [Dd]esktop.ini 86 | 87 | # Recycle Bin used on file shares 88 | $RECYCLE.BIN/ 89 | 90 | # Windows Installer files 91 | *.cab 92 | *.msi 93 | *.msix 94 | *.msm 95 | *.msp 96 | 97 | # Windows shortcuts 98 | *.lnk 99 | 100 | ### Python template 101 | # Byte-compiled / optimized / DLL files 102 | __pycache__/ 103 | *.py[cod] 104 | *$py.class 105 | 106 | # C extensions 107 | *.so 108 | 109 | # Distribution / packaging 110 | .Python 111 | build/ 112 | develop-eggs/ 113 | dist/ 114 | downloads/ 115 | eggs/ 116 | .eggs/ 117 | lib/ 118 | lib64/ 119 | parts/ 120 | sdist/ 121 | var/ 122 | wheels/ 123 | share/python-wheels/ 124 | *.egg-info/ 125 | .installed.cfg 126 | *.egg 127 | MANIFEST 128 | 129 | # PyInstaller 130 | # Usually these files are written by a python script from a template 131 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 132 | *.manifest 133 | *.spec 134 | 135 | # Installer logs 136 | pip-log.txt 137 | pip-delete-this-directory.txt 138 | 139 | # Unit test / coverage reports 140 | htmlcov/ 141 | .tox/ 142 | .nox/ 143 | .coverage 144 | .coverage.* 145 | .cache 146 | nosetests.xml 147 | coverage.xml 148 | *.cover 149 | *.py,cover 150 | .hypothesis/ 151 | .pytest_cache/ 152 | cover/ 153 | 154 | # Translations 155 | *.mo 156 | *.pot 157 | 158 | # Django stuff: 159 | *.log 160 | local_settings.py 161 | db.sqlite3 162 | db.sqlite3-journal 163 | 164 | # Flask stuff: 165 | instance/ 166 | .webassets-cache 167 | 168 | # Scrapy stuff: 169 | .scrapy 170 | 171 | # Sphinx documentation 172 | docs/_build/ 173 | 174 | # PyBuilder 175 | .pybuilder/ 176 | target/ 177 | 178 | # Jupyter Notebook 179 | .ipynb_checkpoints 180 | 181 | # IPython 182 | profile_default/ 183 | ipython_config.py 184 | 185 | # pyenv 186 | # For a library or package, you might want to ignore these files since the code is 187 | # intended to run in multiple environments; otherwise, check them in: 188 | # .python-version 189 | 190 | # pipenv 191 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 192 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 193 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 194 | # install all needed dependencies. 195 | #Pipfile.lock 196 | 197 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 198 | __pypackages__/ 199 | 200 | # Celery stuff 201 | celerybeat-schedule 202 | celerybeat.pid 203 | 204 | # SageMath parsed files 205 | *.sage.py 206 | 207 | # Environments 208 | .env 209 | .venv 210 | env/ 211 | venv/ 212 | ENV/ 213 | env.bak/ 214 | venv.bak/ 215 | 216 | # Spyder project settings 217 | .spyderproject 218 | .spyproject 219 | 220 | # Rope project settings 221 | .ropeproject 222 | 223 | # mkdocs documentation 224 | /site 225 | 226 | # mypy 227 | .mypy_cache/ 228 | .dmypy.json 229 | dmypy.json 230 | 231 | # Pyre type checker 232 | .pyre/ 233 | 234 | # pytype static type analyzer 235 | .pytype/ 236 | 237 | # Cython debug symbols 238 | cython_debug/ 239 | 240 | .idea/ 241 | 242 | .DS_Store 243 | 244 | -------------------------------------------------------------------------------- /pyaranet4/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command-line script to interface with an Aranet4 CO₂ meter 3 | """ 4 | import datetime 5 | import argparse 6 | import requests 7 | import time 8 | import csv 9 | import io 10 | import re 11 | 12 | from pyaranet4 import Aranet4 13 | 14 | 15 | def main(): 16 | """ 17 | pyaranet4 Command-line interface 18 | """ 19 | cli = argparse.ArgumentParser(add_help=False) 20 | cli.add_argument("--help", help="Show this help message and exit", default=False, action="help") 21 | cli.add_argument("--history", "-h", help="Retrieve historical readings saved on device", default=False, 22 | action="store_true") 23 | cli.add_argument("--history-start", "-hs", 24 | help="Start of range of historical readings to retrieve, inclusive, as UTC timestamp (2019-09-29T14:00:00Z)") 25 | cli.add_argument("--history-end", "-he", 26 | help="End of range of historical readings to retrieve, inclusive, as UTC timestamp (2019-09-29T14:00:00Z)") 27 | cli.add_argument("--output-file", "-o", 28 | help="Save retrieved historical readings to file as CSV (implies --history)") 29 | cli.add_argument("--limit", "-l", 30 | help="Get most recent historical values (implies --history, ignores --history-start and --history-end)", 31 | type=int, default=0) 32 | cli.add_argument("--url", "-u", help="Send current values to this URL as a POST request (ignores --history)") 33 | cli.add_argument("--params", "-p", default="thpc", 34 | help="Sensors to read from, as a combination of (t)emperature, (h)umidity, (p)ressure, (c)o2, default thpc, implies --history") 35 | cli.add_argument("address", nargs="*", action="extend", 36 | help="MAC address of Aranet4 device to connect to. If left empty, use autodiscovery.") 37 | args = cli.parse_args() 38 | 39 | if not args.address: 40 | args.address = None 41 | 42 | a4 = Aranet4(args.address) 43 | if args.url: 44 | post_data(a4, args.url) 45 | exit(0) 46 | 47 | elif args.history or args.limit or args.output_file: 48 | # Map "thpc"-like sensor value as provided to actual sensor IDs 49 | sensor_map = {"t": a4.SENSOR_TEMPERATURE, "h": a4.SENSOR_HUMIDITY, "p": a4.SENSOR_PRESSURE, 50 | "c": a4.SENSOR_CO2} 51 | sensors = tuple([sensor_map[c] for c in re.sub(r"[^thpc]", "", args.params)]) 52 | if not sensors: 53 | print("Must include at least one valid sensor") 54 | exit(1) 55 | 56 | collect_data(a4, args, sensors) 57 | exit(0) 58 | 59 | # If no parameters are given, simply display a short summary of the current 60 | # settings and readings. 61 | basic_overview(a4) 62 | exit(0) 63 | 64 | 65 | def basic_overview(a4): 66 | """ 67 | Display a basic sensor and settings overview 68 | 69 | :param Aranet4 a4: Aranet4 device object to read from 70 | """ 71 | print("--------------------------------------") 72 | print("Connected: {:s} | {:s}".format(a4.device_name, a4.software_revision)) 73 | print("Updated {:d} s ago. Intervals: {:d} s".format(a4.since_last_update, a4.update_interval)) 74 | print("{:d} total readings".format(a4.stored_readings_amount)) 75 | print("--------------------------------------") 76 | print("CO2: {:d} ppm".format(a4.current_readings.co2)) 77 | print("Temperature: {:.2f} C".format(a4.current_readings.temperature)) 78 | print("Humidity: {:d} %".format(a4.current_readings.humidity)) 79 | print("Pressure: {:.2f} hPa".format(a4.current_readings.pressure)) 80 | print("Battery: {:d} %".format(a4.current_readings.battery_level)) 81 | print("--------------------------------------") 82 | exit() 83 | 84 | 85 | def post_data(a4, url): 86 | """ 87 | Send current values to this URL as a POST request (ignores --history) 88 | 89 | :param Aranet4 a4: Aranet4 device object to read from 90 | :param str url: URL to POST data to 91 | """ 92 | age = a4.since_last_update 93 | values = a4.current_readings 94 | r = requests.post(url, data={ 95 | 'time': time.time() - age, 96 | 'co2': values.co2, 97 | 'temperature': values.temperature, 98 | 'pressure': values.pressure, 99 | 'humidity': values.humidity, 100 | 'battery': values.battery_level 101 | }) 102 | 103 | 104 | def collect_data(a4, args, sensors): 105 | """ 106 | Fetch and aggregate historical readings 107 | 108 | :param Aranet4 a4: Aranet4 device object to read from 109 | :param args: Command-line arguments 110 | :param tuple sensors: Sensor IDs to include in readings 111 | """ 112 | # Fetch history. It will take a while to fetch anyway, so just get everything 113 | # and filter afterwards according to parameters. 114 | history = a4.get_history(sensors) 115 | out_stream = io.StringIO() if not args.output_file else open(args.output, "w") 116 | 117 | writer = csv.DictWriter(out_stream, fieldnames=("index", "timestamp", *history.sensors)) 118 | writer.writeheader() 119 | 120 | # We're working with Unix timestamps, so convert provided range first 121 | range_start = datetime.datetime.strptime(args.history_start, 122 | "%Y-%m-%dT%H:%M:%SZ").timestamp() if args.history_start else None 123 | range_end = datetime.datetime.strptime(args.history_end, 124 | "%Y-%m-%dT%H:%M:%SZ").timestamp() if args.history_end else None 125 | 126 | # Go through history - we implement the --limit parameter here 127 | for index in list(history.__getattribute__(history.sensors[0]).keys())[-args.limit:]: 128 | # and the date range (if provided) here 129 | if range_start and range_start > history.timestamps[index]: 130 | continue 131 | 132 | if range_end and range_end < history.timestamps[index]: 133 | continue 134 | 135 | # CSV row - only requested sensors are included as columns 136 | writer.writerow({ 137 | "index": index, 138 | "timestamp": datetime.datetime.fromtimestamp(history.timestamps[index]).strftime("%Y-%m-%d %H:%M:%S"), 139 | **{ 140 | sensor: history.__getattribute__(sensor)[index] for sensor in history.sensors 141 | } 142 | }) 143 | 144 | # Either save the CSV to a file, or send it to the output buffer 145 | if args.output_file: 146 | out_stream.close() 147 | else: 148 | print(out_stream.getvalue()) 149 | 150 | 151 | if __name__ == "__main__": 152 | main() 153 | -------------------------------------------------------------------------------- /pyaranet4/pyaranet4.py: -------------------------------------------------------------------------------- 1 | """ 2 | Base class for a Bluetooth client for the Aranet4 CO₂ meter. 3 | """ 4 | import datetime 5 | import logging 6 | import asyncio 7 | import time 8 | 9 | from bleak import BleakScanner, BleakClient 10 | from bleak.exc import BleakError 11 | from types import SimpleNamespace 12 | 13 | from pyaranet4.util import le16, write_le16 14 | from pyaranet4.exceptions import Aranet4NotFoundException, Aranet4BusyException, Aranet4UnpairedException 15 | 16 | 17 | class Aranet4: 18 | """ 19 | A class to read data with from an Aranet4 CO₂ meter. 20 | 21 | When instantiated, the object provides a set of properties that can be 22 | read to get readings, settings and historical data. 23 | """ 24 | _address = None 25 | _cache = {} 26 | _use_cache = False 27 | _client = None 28 | _last_notification = 0 29 | _reading = False 30 | _magic = None 31 | 32 | # UUIDs of supported device characteristics 33 | UUID_BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb" 34 | UUID_MANUFACTURER_NAME = "00002a29-0000-1000-8000-00805f9b34fb" 35 | UUID_MODEL_NAME = "00002a24-0000-1000-8000-00805f9b34fb" 36 | UUID_DEVICE_NAME = "00002a00-0000-1000-8000-00805f9b34fb" 37 | UUID_SERIAL_NUMBER = "00002a25-0000-1000-8000-00805f9b34fb" 38 | UUID_HARDWARE_REVISION = "00002a27-0000-1000-8000-00805f9b34fb" 39 | UUID_SOFTWARE_REVISION = "00002a28-0000-1000-8000-00805f9b34fb" 40 | UUID_UPDATE_INTERVAL = "f0cd2002-95da-4f4b-9ac8-aa55d312af0c" 41 | UUID_SINCE_LAST_UPDATE = "f0cd2004-95da-4f4b-9ac8-aa55d312af0c" 42 | UUID_STORED_READINGS = "f0cd2001-95da-4f4b-9ac8-aa55d312af0c" 43 | UUID_CURRENT_READING_SIMPLE = "f0cd1503-95da-4f4b-9ac8-aa55d312af0c" 44 | UUID_CURRENT_READING_FULL = "f0cd3001-95da-4f4b-9ac8-aa55d312af0c" 45 | UUID_HISTORY_RANGE = "f0cd1402-95da-4f4b-9ac8-aa55d312af0c" 46 | UUID_HISTORY_NOTIFIER = "f0cd2003-95da-4f4b-9ac8-aa55d312af0c" 47 | 48 | # Available sensor identifiers 49 | SENSOR_TEMPERATURE = 1 50 | SENSOR_HUMIDITY = 2 51 | SENSOR_PRESSURE = 3 52 | SENSOR_CO2 = 4 53 | 54 | def __init__(self, mac_address=None, use_cache=False, magic_string="Aranet4"): 55 | """ 56 | Set up Aranet4 object 57 | 58 | :param str mac_address: Bluetooth MAC address to connect to. If left 59 | empty, `magic_string` is used to discover the Aranet4 device. 60 | :param bool use_cache: Whether to cache retrieved data. Can be useful 61 | when using the object as part of a script that accesses values 62 | multiple times, since reading is quite slow. 63 | :param str magic_string: String to look for in device names to 64 | identify them as an Aranet4. If the device name contains this string, 65 | it is considered an Aranet4 meter and it will be connected to. If 66 | `mac_address` is provided, this parameter is ignored. 67 | """ 68 | logging.debug("Initializing Aranet4 object") 69 | self.loop = asyncio.get_event_loop() 70 | self._use_cache = use_cache 71 | self._magic = magic_string 72 | 73 | if mac_address: 74 | self._address = mac_address 75 | 76 | @property 77 | def mac_address(self): 78 | """ 79 | The device's Bluetooth MAC address 80 | 81 | :return str: 82 | """ 83 | return self.loop.run_until_complete(self._discover()) 84 | 85 | @property 86 | def battery_level(self): 87 | """ 88 | The current battery level, as a percentage (0-100) 89 | 90 | :return int: 91 | """ 92 | return self.read_from_uuid(self.UUID_BATTERY_LEVEL)[0] 93 | 94 | @property 95 | def manufacturer_name(self): 96 | """ 97 | Manufacturer name 98 | 99 | :return str: 100 | """ 101 | return self.read_from_uuid(self.UUID_MANUFACTURER_NAME).decode("ascii") 102 | 103 | @property 104 | def model_name(self): 105 | """ 106 | Model name 107 | 108 | :return str: 109 | """ 110 | return self.read_from_uuid(self.UUID_MODEL_NAME).decode("ascii") 111 | 112 | @property 113 | def device_name(self): 114 | """ 115 | Device name 116 | 117 | :return str: 118 | """ 119 | return self.read_from_uuid(self.UUID_DEVICE_NAME).decode("ascii") 120 | 121 | @property 122 | def serial_number(self): 123 | """ 124 | Serial number 125 | 126 | :return int: 127 | """ 128 | return int(self.read_from_uuid(self.UUID_SERIAL_NUMBER)) 129 | 130 | @property 131 | def hardware_revision(self): 132 | """ 133 | Hardware revision number 134 | 135 | :return int: 136 | """ 137 | return int(self.read_from_uuid(self.UUID_HARDWARE_REVISION)) 138 | 139 | @property 140 | def software_revision(self): 141 | """ 142 | Software revision number 143 | 144 | :return int: 145 | """ 146 | return self.read_from_uuid(self.UUID_SOFTWARE_REVISION).decode("ascii") 147 | 148 | @property 149 | def current_readings(self): 150 | """ 151 | The current settings and latest reading of all sensors of the device 152 | 153 | :return SimpleNamespace: 154 | """ 155 | return self._get_readings(simple=False) 156 | 157 | @property 158 | def current_readings_simple(self): 159 | """ 160 | The latest reading of all sensors of the device 161 | 162 | :return SimpleNamespace: 163 | """ 164 | return self._get_readings(simple=True) 165 | 166 | @property 167 | def update_interval(self): 168 | """ 169 | The pause between sensor updates 170 | 171 | :return int: 172 | """ 173 | return le16(self.read_from_uuid(self.UUID_UPDATE_INTERVAL)) 174 | 175 | @property 176 | def since_last_update(self): 177 | """ 178 | The amount of seconds since the last sensor update 179 | 180 | :return int: 181 | """ 182 | return le16(self.read_from_uuid(self.UUID_SINCE_LAST_UPDATE)) 183 | 184 | @property 185 | def stored_readings_amount(self): 186 | """ 187 | The amount of historical readings stored on the device 188 | 189 | :return int: 190 | """ 191 | return le16(self.read_from_uuid(self.UUID_STORED_READINGS)) 192 | 193 | @property 194 | def history(self): 195 | """ 196 | The pause between sensor updates 197 | 198 | :return SimpleNamespace: A namespace with an attribute for each 199 | sensor, and two special attributes `sensors` (all included sensors) and 200 | `timestamps` (a dictionary with an index -> unix timestamp map) 201 | """ 202 | return self.loop.run_until_complete(self._get_history()) 203 | 204 | def get_history(self, sensors=None, start=0x0001, end=0xFFFF): 205 | """ 206 | Get historical readings stored on the device 207 | 208 | :param tuple sensors: Sensors to read. More is slower. By default, 209 | read all four sensors. Sensors not read will be absent from the return 210 | value. Tuple elements should correspond to `Aranet4.SENSOR_*` 211 | constants. 212 | :param int start: Index to start reading from (1-indexed). 213 | :param int end: Index to stop reading at. 214 | :return SimpleNamespace: A namespace with an attribute for each 215 | sensor, and two special attributes `sensors` (all included sensors) and 216 | `timestamps` (a dictionary with an index -> unix timestamp map) 217 | """ 218 | return self.loop.run_until_complete(self._get_history(sensors, start, end)) 219 | 220 | def read_from_uuid(self, uuid): 221 | """ 222 | Read a raw value from a Bluetooth attribute by UUID 223 | 224 | :param str uuid: The UUID of the attribute to read 225 | :return bytearray: Value 226 | """ 227 | if self._use_cache and uuid in self._cache: 228 | return self._cache[uuid] 229 | 230 | value = self.loop.run_until_complete(self._read_value(uuid)) 231 | if self._use_cache: 232 | self._cache[uuid] = value 233 | 234 | return value 235 | 236 | def _normalize_value(self, value, sensor): 237 | """ 238 | Normalize raw sensor value 239 | 240 | Some sensors may return 'magic' values or need normalization to the 241 | display unit. This method does that! 242 | 243 | :param value: Value to normalize 244 | :param sensor: What sensor the value is for 245 | :return: Normalized value. `-1` if the value is not an actual sensor 246 | value, but e.g. a 'Calibrating' status. 247 | """ 248 | if sensor == self.SENSOR_HUMIDITY: 249 | if (value & 0x80) == 0x80: 250 | return -1 251 | elif sensor == self.SENSOR_CO2: 252 | if (value & 0x8000) == 0x8000: 253 | return -1 254 | elif sensor == self.SENSOR_PRESSURE: 255 | if (value & 0x8000) == 0x8000: 256 | return -1 257 | else: 258 | return value / 10.0 259 | elif sensor == self.SENSOR_TEMPERATURE: 260 | if value == 0x4000: 261 | return -1 262 | elif value > 0x8000: 263 | return 0 264 | else: 265 | return value / 20.0 266 | else: 267 | raise ValueError() 268 | 269 | return value 270 | 271 | def _get_readings(self, simple=False): 272 | """ 273 | Get current sensor values and settings 274 | 275 | :param bool simple: Whether to only retrieve sensor values or also 276 | settings (slower) 277 | :return SimpleNamespace: 278 | """ 279 | if simple: 280 | uuid = self.UUID_CURRENT_READING_SIMPLE 281 | else: 282 | uuid = self.UUID_CURRENT_READING_FULL 283 | 284 | data = self.read_from_uuid(uuid) 285 | values = SimpleNamespace() 286 | values.co2 = self._normalize_value(le16(data), self.SENSOR_CO2) 287 | values.temperature = self._normalize_value(le16(data, 2), self.SENSOR_TEMPERATURE) 288 | values.pressure = self._normalize_value(le16(data, 4), self.SENSOR_PRESSURE) 289 | values.humidity = self._normalize_value(data[6], self.SENSOR_HUMIDITY) 290 | values.battery_level = data[7] 291 | 292 | if not simple: 293 | values.update_interval = le16(data, 9) 294 | values.since_last_update = le16(data, 11) 295 | 296 | return values 297 | 298 | def _get_history_reader(self, sensor): 299 | """ 300 | Get a callback to handle device notifications with that can access the 301 | Aranet4 object. 302 | 303 | :param int sensor: Sensor to access 304 | :return: 305 | """ 306 | self._datapoints = {} 307 | self._last_notification = time.time() 308 | 309 | def _receive_history(sender: int, data: bytearray): 310 | """ 311 | Read chunk of archived readings 312 | 313 | Chunks have the following format: 314 | byte 1 : sensor ID (int) 315 | byte 2 - 3: index of first datapoint (long) 316 | byte 4 : number of valid datapoints in this chunk (there may be 317 | more in the chunk, which can be discarded) 318 | byte 5+ : readings for the sensor, as a long or int depending on 319 | what sensor this is 320 | 321 | :param int sender: Handle of the sender Characteristic 322 | :param bytearray data: Data as received 323 | :return: 324 | """ 325 | self._last_notification = time.time() 326 | 327 | if data[0] != sensor: 328 | # notifications about a different sensor 329 | return 330 | 331 | index = le16(data, 1) 332 | num_points = data[3] 333 | data = data[4:] 334 | step = 1 if sensor == self.SENSOR_HUMIDITY else 2 335 | 336 | cursor = 0 337 | buffer = {} 338 | 339 | while len(buffer) < num_points: 340 | value = data[cursor:cursor + step] 341 | cursor += step 342 | 343 | value = value[0] if sensor == self.SENSOR_HUMIDITY else le16(value) 344 | 345 | buffer[index - 2] = self._normalize_value(value, sensor) 346 | index += 1 347 | 348 | self._datapoints = {**self._datapoints, **buffer} 349 | 350 | return _receive_history 351 | 352 | async def _get_history(self, sensors=None, start=0x0001, end=0xFFFF): 353 | """ 354 | Get historical readings stored on the device 355 | 356 | :param tuple sensors: Sensors to read. More is slower. By default, 357 | read all four sensors. Sensors not read will be absent from the return 358 | value. 359 | :param int start: Index to start reading from (1-indexed). 360 | :param int end: Index to stop reading at 361 | :return SimpleNamespace: A namespace with an attribute for each 362 | sensor, and two special attributes `sensors` (all included sensors) and 363 | `timestamps` (a dictionary with an index -> unix timestamp map) 364 | """ 365 | if not sensors: 366 | sensors = (self.SENSOR_CO2, self.SENSOR_HUMIDITY, self.SENSOR_PRESSURE, self.SENSOR_TEMPERATURE) 367 | 368 | if self._reading: 369 | raise Aranet4BusyException() 370 | 371 | self._reading = True 372 | if not self._address: 373 | await self._discover() 374 | 375 | start = start + 1 376 | if start < 1: 377 | start = 0x0001 378 | 379 | params = bytearray.fromhex("820000000100ffff") # magic value? 380 | params = write_le16(params, 4, start) 381 | params = write_le16(params, 6, end) 382 | readings = SimpleNamespace() 383 | keys = ["", "temperature", "humidity", "pressure", "co2"] 384 | included_keys = [] 385 | encountered_indexes = [] 386 | index_map = {} 387 | 388 | interval = le16(await self._client.read_gatt_char(self.UUID_UPDATE_INTERVAL)) 389 | for sensor in sensors: 390 | logging.debug("Retrieving stored values for sensor %s" % str(sensor)) 391 | params[1] = sensor 392 | 393 | last_timestamp = round(time.time()) - le16( 394 | await self._client.read_gatt_char(self.UUID_SINCE_LAST_UPDATE)) 395 | 396 | await self._client.write_gatt_char(self.UUID_HISTORY_RANGE, params) 397 | 398 | logging.debug("Asking for history") 399 | await self._client.start_notify(self.UUID_HISTORY_NOTIFIER, self._get_history_reader(sensor)) 400 | while self._last_notification and time.time() - self._last_notification < 0.5: 401 | # 0.5 seconds seems to be sufficient, but increase if data 402 | # seems to be missing 403 | await asyncio.sleep(0.1) 404 | 405 | logging.debug("Received %i stored values" % len(self._datapoints)) 406 | await self._client.stop_notify(self.UUID_HISTORY_NOTIFIER) 407 | self._reading = False 408 | encountered_indexes.append(set(self._datapoints.keys())) 409 | index_map[max(self._datapoints)] = last_timestamp 410 | 411 | # store 412 | readings.__setattr__(keys[sensor], self._datapoints) 413 | included_keys.append(keys[sensor]) 414 | 415 | # normalize keys, in case not all sensors returned the same points 416 | # this can happen if the sensors update between reading different 417 | # sensors. In that case we discard the indexes that do not occur for 418 | # all sensors 419 | common_indexes = set.intersection(*encountered_indexes) 420 | for sensor in included_keys: 421 | sensor_data = readings.__getattribute__(sensor).copy() 422 | for key in sensor_data: 423 | if key not in common_indexes: 424 | logging.debug("Discarding one uncommon datapoint from sensor %s" % str(sensor)) 425 | del readings.__getattribute__(sensor)[key] 426 | 427 | # now convert indexes to timestamps 428 | common_indexes = sorted(common_indexes, reverse=True) 429 | timestamp = index_map[common_indexes[0]] 430 | readings.timestamps = {} 431 | for index in common_indexes: 432 | readings.timestamps[index] = timestamp 433 | timestamp -= interval 434 | 435 | logging.debug( 436 | "Oldest timestamp: %s" % datetime.datetime.fromtimestamp(min(readings.timestamps.values())).strftime("%c")) 437 | readings.sensors = included_keys 438 | return readings 439 | 440 | async def _discover(self): 441 | """ 442 | Discover Aranet4 device and initialise client 443 | 444 | :return str: MAC address of the BlueTooth device 445 | :raises Aranet4NotFoundException: If no device that looks like an 446 | Aranet4 can be found. 447 | """ 448 | if not self._address: 449 | logging.debug("No MAC address known, starting discovery") 450 | devices = await BleakScanner.discover() 451 | for device in devices: 452 | if device.name is None: 453 | continue 454 | if self._magic in device.name: 455 | logging.info("Found MAC address %s for device %s" % (device.address, device.name)) 456 | self._address = device.address 457 | 458 | if not self._address: 459 | raise Aranet4NotFoundException("No Aranet4 device found. Try moving it closer to the Bluetooth receiver.") 460 | 461 | logging.info("Connecting to device %s" % self._address) 462 | self._client = BleakClient(self._address) 463 | await self._client.connect(timeout=15) 464 | return self._address 465 | 466 | async def _read_value(self, uuid): 467 | """ 468 | Read GATT value from device 469 | 470 | :param uuid: UUID of attribute to read from 471 | :return bytearray: Returned value 472 | """ 473 | if not self._address: 474 | await self._discover() 475 | 476 | try: 477 | value = await self._client.read_gatt_char(uuid) 478 | except BleakError as e: 479 | raise Aranet4UnpairedException("Error reading from device. Check if it is properly paired.") 480 | 481 | return value 482 | --------------------------------------------------------------------------------