├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── LICENSE ├── README.md ├── aranet4 ├── __init__.py ├── aranetctl.py └── client.py ├── docs └── UUIDs.md ├── examples ├── history │ └── export_csv.py ├── influx │ ├── README.md │ ├── import.py │ └── influx.py ├── mqtt │ ├── README.md │ └── publish.py └── scanner │ ├── scanner_advanced.py │ └── scanner_simple.py ├── pytest.ini ├── setup.py └── tests ├── data └── aranet4_readings.csv ├── test_advertisements.py ├── test_csv.py └── test_values.py /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10", "3.11"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install flake8 pytest bleak requests 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | dist/ 9 | .eggs/ 10 | sdist/ 11 | *.egg-info/ 12 | *.egg 13 | 14 | # DEV 15 | build/ 16 | .venv/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Anrijs Jargans 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Aranet4 Python client 3 | Python library and command line interface for [Aranet4](https://aranet.com/products/aranet4-home), [Aranet2](https://aranet.com/products/aranet2-home), [Aranet Radiation](https://aranet.com/products/aranet-radiation-sensor) and [Aranet Radon Plus](https://aranet.com/products/aranet-radon-sensor) sensors. 4 | 5 | ## Installation 6 | 1. Install aranet4 and its dependencies: 7 | ``` 8 | pip3 install aranet4 9 | ``` 10 | 2. Pair Aranet device 11 | 3. Run `aranetctl` or use as a library 12 | 13 | **Note:** "Smart Home integrations" must be enabled in the [Aranet Home](https://aranet.com/aranet-home-app) mobile application for full support. 14 | 15 | ## aranetctl usage 16 | ```text 17 | $ aranetctl -h 18 | usage: aranetctl.py [-h] [--scan] [-u URL] [-r] [-s DATE] [-e DATE] [-o FILE] [-w] [-l COUNT] [--xt] [--xh] [--xp] [--xc] [--set-interval MINUTES] 19 | [--set-integrations {on,off}] [--set-range {normal,extended}] 20 | [device_mac] 21 | 22 | positional arguments: 23 | device_mac Aranet Bluetooth Address 24 | 25 | options: 26 | -h, --help show this help message and exit 27 | --scan Scan for Aranet devices 28 | -r, --records Fetch historical log records 29 | 30 | Options for current reading: 31 | -u URL, --url URL Remote url for current value push 32 | 33 | Filter History Log Records: 34 | -s DATE, --start DATE 35 | Records range start (UTC time, example: 2019-09-29T14:00:00 36 | -e DATE, --end DATE Records range end (UTC time, example: 2019-09-30T14:00:00 37 | -o FILE, --output FILE 38 | Save records to a file 39 | -w, --wait Wait until new data point available 40 | -l COUNT, --last COUNT 41 | Get last records 42 | --xt Don't get temperature records 43 | --xh Don't get humidity records 44 | --xp Don't get pressure records 45 | --xc Don't get co2 records 46 | 47 | Change device settings: 48 | --set-interval MINUTES 49 | Change update interval 50 | --set-integrations {on,off} 51 | Toggle Smart Home Integrations 52 | --set-range {normal,extended} 53 | Change bluetooth range 54 | ``` 55 | ### Scan devices 56 | Usage: `aranetctl --scan` 57 | 58 | Output: 59 | ``` 60 | ======================================= 61 | Name: Aranet4 00001 62 | Address: AA:BB:CC:DD:EE:FF 63 | RSSI: -83 dBm 64 | --------------------------------------- 65 | CO2: 484 ppm 66 | Temperature: 20.9 °C 67 | Humidity: 43 % 68 | Pressure: 1024.8 hPa 69 | Battery: 3 % 70 | Status Display: GREEN 71 | Age: 9/60 72 | ``` 73 | 74 | **Note:** To receive current measurements directly from the Bluetooth advertisement data, "Smart Home integrations" must be enabled and device firmware version must be v1.2.0 or newer. 75 | 76 | ### Current Readings Example 77 | Usage: `aranetctl XX:XX:XX:XX:XX:XX` 78 | 79 | Output: 80 | ``` 81 | -------------------------------------- 82 | Connected: Aranet4 00000 | v0.3.1 83 | Updated 51 s ago. Intervals: 60 s 84 | 5040 total readings 85 | -------------------------------------- 86 | CO2: 904 ppm 87 | Temperature: 19.9 C 88 | Humidity: 51 % 89 | Pressure: 997.0 hPa 90 | Battery: 96 % 91 | Status Display: GREEN 92 | -------------------------------------- 93 | ``` 94 | 95 | ### Get History Example 96 | Write full log to screen: 97 | 98 | Usage: `aranetctl XX:XX:XX:XX:XX:XX -r` 99 | 100 | ```shell 101 | ------------------------------------------------------------- 102 | Device Name : Aranet4 00000 103 | Device Version : v0.3.1 104 | ------------------------------------------------------------- 105 | id | date | co2 | temp | hum | pressure | 106 | ------------------------------------------------------------- 107 | 1 | 2022-02-18T14:15:44 | 844 | 21.8 | 50 | 985.6 | 108 | 2 | 2022-02-18T14:20:44 | 846 | 21.8 | 50 | 985.9 | 109 | 3 | 2022-02-18T14:25:44 | 843 | 22.0 | 50 | 986.4 | 110 | 4 | 2022-02-18T14:30:44 | 881 | 22.1 | 50 | 986.4 | 111 | 5 | 2022-02-18T14:35:44 | 854 | 22.1 | 50 | 987.3 | 112 | 6 | 2022-02-18T14:40:44 | 867 | 22.2 | 50 | 987.5 | 113 | 7 | 2022-02-18T14:45:44 | 883 | 22.1 | 50 | 988.1 | 114 | 8 | 2022-02-18T14:50:44 | 921 | 22.1 | 50 | 988.6 | 115 | 9 | 2022-02-18T14:55:44 | 930 | 22.0 | 50 | 989.1 | 116 | 10 | 2022-02-18T15:00:44 | 954 | 22.0 | 50 | 989.5 | 117 | ------------------------------------------------------------- 118 | ``` 119 | 120 | Usage: `aranetctl XX:XX:XX:XX:XX:XX -r -o aranet4.csv` 121 | 122 | Output file format: `Date,CO2,Temperature,Humidity,Pressure` 123 | 124 | Output file example: 125 | ``` 126 | date,co2,temperature,humidity,pressure 127 | 2022-02-18 10:05:47,1398,23.2,53,986.6 128 | 2022-02-18 10:10:47,1155,23.1,50,986.3 129 | ``` 130 | 131 | ## Usage of library 132 | 133 | ### Current Readings Example 134 | 135 | ```python 136 | import aranet4 137 | 138 | device_mac = "XX:XX:XX:XX:XX:XX" 139 | 140 | current = aranet4.client.get_current_readings(device_mac) 141 | 142 | print("co2 reading:", current.co2) 143 | print("Temperature:", current.temperature) 144 | print("Humidity:", current.humidity) 145 | print("Pressure:", current.pressure) 146 | ``` 147 | 148 | ### Logged Readings Example 149 | 150 | ```python 151 | import aranet4 152 | 153 | device_mac = "XX:XX:XX:XX:XX:XX" 154 | 155 | history = aranet4.client.get_all_records( 156 | device_mac, entry_filter={"humi": False, "pres": False} 157 | ) 158 | print(f"{'Date':^20} | {'CO2':^10} | {'Temperature':^10} ") 159 | for entry in history.value: 160 | print(f"{entry.date.isoformat():^20} | {entry.co2:^10} | {entry.temperature:^10}") 161 | 162 | ``` 163 | 164 | ## Library functions 165 | ### get_current_readings(mac_address: str) -> client.CurrentReading 166 | Get current measurements from device 167 | Returns **CurrentReading** object: 168 | ```python 169 | class CurrentReading: 170 | name: str = "" 171 | version: str = "" 172 | temperature: float = -1 173 | humidity: int = -1 174 | pressure: float = -1 175 | co2: int = -1 176 | battery: int = -1 177 | status: int = -1 178 | interval: int = -1 179 | ago: int = -1 180 | stored: int = -1 181 | ``` 182 | 183 | ### get_all_records(mac_address: str, entry_filter: dict) -> client.Record 184 | Get stored datapoints from device. Apply any filters if required 185 | 186 | `entry_filter` is a dictionary that can have the following values: 187 | - `last`: int : Get last n entries 188 | - `start`: datetime : Get entries after specified time 189 | - `end`: datetime : Get entries before specified time 190 | - `temp`: bool : Get temperature data points (default = True) 191 | - `humi`: bool : Get humidity data points (default = True) 192 | - `pres`: bool : Get pressure data points (default = True) 193 | - `co2`: bool : Get co2 data points (default = True) 194 | 195 | Returns **CurrentReading** object: 196 | ```python 197 | class Record: 198 | name: str 199 | version: str 200 | records_on_device: int 201 | filter: Filter 202 | value: List[RecordItem] = field(default_factory=list) 203 | ``` 204 | Which includes these objects: 205 | ```python 206 | class RecordItem: 207 | date: datetime 208 | temperature: float 209 | humidity: int 210 | pressure: float 211 | co2: int 212 | 213 | class Filter: 214 | begin: int 215 | end: int 216 | incl_temperature: bool 217 | incl_humidity: bool 218 | incl_pressure: bool 219 | incl_co2: bool 220 | ``` 221 | -------------------------------------------------------------------------------- /aranet4/__init__.py: -------------------------------------------------------------------------------- 1 | from aranet4.client import Aranet4, Aranet4HistoryDelegate, Aranet4Error, Aranet4Scanner 2 | 3 | name = "aranet4" 4 | __version__ = "2.5.1" 5 | -------------------------------------------------------------------------------- /aranet4/aranetctl.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import csv 3 | from dataclasses import asdict 4 | import datetime 5 | from pathlib import Path 6 | import sys 7 | import requests 8 | from time import sleep 9 | 10 | from aranet4 import client 11 | 12 | 13 | def parse_args(ctl_args): 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument( 16 | "device_mac", 17 | nargs="?", 18 | help="Aranet Bluetooth Address" 19 | ) 20 | parser.add_argument( 21 | "--scan", 22 | action="store_true", 23 | help="Scan for Aranet devices" 24 | ) 25 | 26 | current = parser.add_argument_group("Options for current reading") 27 | current.add_argument( 28 | "-u", 29 | "--url", 30 | metavar="URL", 31 | help="Remote url for current value push" 32 | ) 33 | 34 | parser.add_argument( 35 | "-r", 36 | "--records", 37 | action="store_true", 38 | help="Fetch historical log records" 39 | ) 40 | 41 | history = parser.add_argument_group("Filter History Log Records") 42 | history.add_argument( 43 | "-s", 44 | "--start", 45 | metavar="DATE", 46 | type=datetime.datetime.fromisoformat, 47 | help="Records range start (UTC time, example: 2019-09-29T14:00:00", 48 | ) 49 | history.add_argument( 50 | "-e", 51 | "--end", 52 | metavar="DATE", 53 | type=datetime.datetime.fromisoformat, 54 | help="Records range end (UTC time, example: 2019-09-30T14:00:00", 55 | ) 56 | history.add_argument( 57 | "-o", 58 | "--output", 59 | metavar="FILE", 60 | type=Path, 61 | help="Save records to a file" 62 | ) 63 | history.add_argument( 64 | "-w", 65 | "--wait", 66 | action="store_true", 67 | default=False, 68 | help="Wait until new data point available", 69 | ) 70 | history.add_argument( 71 | "-l", 72 | "--last", 73 | metavar="COUNT", 74 | type=int, 75 | help="Get last records" 76 | ) 77 | history.add_argument( 78 | "--xt", 79 | dest="temp", 80 | default=True, 81 | action="store_false", 82 | help="Don't get temperature records", 83 | ) 84 | history.add_argument( 85 | "--xh", 86 | dest="humi", 87 | default=True, 88 | action="store_false", 89 | help="Don't get humidity records", 90 | ) 91 | history.add_argument( 92 | "--xp", 93 | dest="pres", 94 | default=True, 95 | action="store_false", 96 | help="Don't get pressure records", 97 | ) 98 | history.add_argument( 99 | "--xc", 100 | dest="co2", 101 | default=True, 102 | action="store_false", 103 | help="Don't get co2 records", 104 | ) 105 | settings = parser.add_argument_group("Change device settings") 106 | settings.add_argument( 107 | "--set-interval", 108 | dest="set_interval", 109 | metavar="MINUTES", 110 | type=int, 111 | help="Change update interval" 112 | ) 113 | settings.add_argument( 114 | "--set-integrations", 115 | dest="set_integrations", 116 | type=str, 117 | choices=["on", "off"], 118 | help="Toggle Smart Home Integrations" 119 | ) 120 | settings.add_argument( 121 | "--set-range", 122 | dest="set_btrange", 123 | type=str, 124 | choices=["normal", "extended"], 125 | help="Change bluetooth range" 126 | ) 127 | 128 | return parser.parse_args(ctl_args) 129 | 130 | 131 | def print_records(records): 132 | """Format log records to be printed to screen""" 133 | char_repeat = 34 134 | if records.filter.incl_co2: 135 | char_repeat += 9 136 | if records.filter.incl_temperature: 137 | char_repeat += 7 138 | if records.filter.incl_humidity: 139 | char_repeat += 8 140 | if records.filter.incl_pressure: 141 | char_repeat += 11 142 | if records.filter.incl_rad_dose_rate: 143 | char_repeat += 11 144 | if records.filter.incl_rad_dose: 145 | char_repeat += 11 146 | if records.filter.incl_rad_dose_total: 147 | char_repeat += 12 148 | if records.filter.incl_radon_concentration: 149 | char_repeat += 8 150 | print("-" * char_repeat) 151 | print(f"{'Device Name':<15}: {records.name:>20}") 152 | print(f"{'Device Version':<15}: {records.version:>20}") 153 | print("-" * char_repeat) 154 | print(f"{'id': ^4} | {'date': ^25} |", end="") 155 | if records.filter.incl_co2: 156 | print(f" {'co2':^6} |", end="") 157 | if records.filter.incl_temperature: 158 | print(" temp |", end="") 159 | if records.filter.incl_humidity: 160 | print(" humid |", end="") 161 | if records.filter.incl_pressure: 162 | print(" pressure |", end="") 163 | if records.filter.incl_rad_dose: 164 | print(" rad_dose |", end="") 165 | if records.filter.incl_rad_dose_rate: 166 | print(" rad_rate |", end="") 167 | if records.filter.incl_rad_dose_total: 168 | print(" rad_total |", end="") 169 | if records.filter.incl_radon_concentration: 170 | print(f" {'radon':^5} |", end="") 171 | print("") 172 | print("-" * char_repeat) 173 | 174 | for record_id, line in enumerate( 175 | records.value, start=records.filter.begin 176 | ): 177 | print(f"{record_id:>4d} | {line.date.isoformat()} |", end="") 178 | if records.filter.incl_co2: 179 | print(f" {line.co2:>6d} |", end="") 180 | if records.filter.incl_temperature: 181 | print(f" {line.temperature:>4.1f} |", end="") 182 | if records.filter.incl_humidity: 183 | print(f" {line.humidity:>5.1f} |", end="") 184 | if records.filter.incl_pressure: 185 | print(f" {line.pressure:>8.1f} |", end="") 186 | if records.filter.incl_rad_dose: 187 | print(f" {line.rad_dose/1000:>8.3f} |", end="") 188 | if records.filter.incl_rad_dose_rate: 189 | print(f" {line.rad_dose_rate/1000:>8.3f} |", end="") 190 | if records.filter.incl_rad_dose_total: 191 | print(f" {line.rad_dose_total/1000000:>9.4f} |", end="") 192 | if records.filter.incl_radon_concentration: 193 | print(f" {line.radon_concentration:>5d} |", end="") 194 | print("") 195 | print("-" * char_repeat) 196 | 197 | 198 | def store_scan_result(found, advertisement): 199 | if not advertisement.device: 200 | return 201 | 202 | found[advertisement.device.address] = advertisement 203 | 204 | 205 | def write_csv(filename, log_data): 206 | """ 207 | Output `client.Record` dataclass to csv file 208 | :param filename: file name 209 | :param log_data: `client.Record` data object 210 | """ 211 | with open( 212 | file=filename, mode="w", encoding="utf-8", newline="" 213 | ) as csv_file: 214 | fieldnames = ["date"] 215 | if log_data.filter.incl_co2: 216 | fieldnames.append("co2") 217 | if log_data.filter.incl_temperature: 218 | fieldnames.append("temperature") 219 | if log_data.filter.incl_humidity: 220 | fieldnames.append("humidity") 221 | if log_data.filter.incl_pressure: 222 | fieldnames.append("pressure") 223 | if log_data.filter.incl_rad_dose: 224 | fieldnames.append("rad_dose") 225 | if log_data.filter.incl_rad_dose_rate: 226 | fieldnames.append("rad_dose_rate") 227 | if log_data.filter.incl_rad_dose_total: 228 | fieldnames.append("rad_dose_total") 229 | if log_data.filter.incl_radon_concentration: 230 | fieldnames.append("radon_concentration") 231 | writer = csv.DictWriter( 232 | csv_file, fieldnames=fieldnames, extrasaction="ignore" 233 | ) 234 | 235 | writer.writeheader() 236 | 237 | for line in log_data.value: 238 | writer.writerow(asdict(line)) 239 | 240 | 241 | def post_data(url, current): 242 | # get current measurement minute 243 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 244 | delta_ago = datetime.timedelta(seconds=current.ago) 245 | t = now - delta_ago 246 | t = t.replace(second=0) # epoch, floored to minutes 247 | data = current.toDict() 248 | data["time"] = t.timestamp() 249 | r = requests.post( 250 | url=url, 251 | data=data, 252 | timeout=300 253 | ) 254 | print(f"Pushing data: {r.text}") 255 | 256 | 257 | def wait_for_new_record(address): 258 | current_vals = client.get_current_readings(address) 259 | wait_time = current_vals.interval - current_vals.ago 260 | for secs in range(wait_time, 0, -1): 261 | sleep(1) 262 | print(f"Next data point in {secs}...", end="\r") 263 | 264 | 265 | def main(argv): 266 | found = {} 267 | args = parse_args(argv) 268 | 269 | if args.scan: 270 | print("Looking for Aranet devices...") 271 | devices = client.find_nearby(lambda ad: store_scan_result(found, ad)) 272 | print(f"Scan finished. Found {len(devices)}") 273 | print() 274 | for _, advertisement in found.items(): 275 | if advertisement.readings: 276 | print(advertisement.readings.toString(advertisement)) 277 | else: 278 | print("=======================================") 279 | print(f" Name: {advertisement.device.name}") 280 | print(f" Address: {advertisement.device.address}") 281 | print(f" RSSI: {advertisement.rssi} dBm") 282 | print() 283 | print() 284 | 285 | return 286 | 287 | if not args.device_mac: 288 | print("Device address not specified") 289 | return 290 | 291 | if args.records: 292 | if args.wait: 293 | wait_for_new_record(args.device_mac) 294 | records = client.get_all_records(args.device_mac, vars(args), True) 295 | print_records(records) 296 | if args.output: 297 | write_csv(args.output, records) 298 | else: 299 | settings = {} 300 | 301 | if args.set_interval: 302 | settings["interval"] = args.set_interval 303 | 304 | if args.set_integrations: 305 | settings["integrations"] = args.set_integrations 306 | 307 | if args.set_btrange: 308 | settings["range"] = args.set_btrange 309 | 310 | if settings: 311 | result = client.set_settings(args.device_mac, settings, True) 312 | for k in result: 313 | val = settings[k] 314 | ret = "SUCCESS" if result[k] else "FAILED" 315 | print(f"Set {k} to \"{val}\": {ret}") 316 | else: 317 | current = client.get_current_readings(args.device_mac) 318 | print(current.toString()) 319 | if args.url: 320 | post_data(args.url, current) 321 | 322 | 323 | def entry_point(): 324 | main(argv=sys.argv[1:]) 325 | 326 | 327 | if __name__ == "__main__": 328 | entry_point() 329 | -------------------------------------------------------------------------------- /aranet4/client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass, field 3 | import datetime 4 | from enum import IntEnum 5 | import re 6 | import struct 7 | import math 8 | from typing import List, NamedTuple 9 | 10 | from bleak import BleakClient 11 | from bleak import BleakScanner 12 | from bleak.backends.device import BLEDevice 13 | from bleak.uuids import normalize_uuid_16 14 | 15 | 16 | class Aranet4Error(Exception): 17 | pass 18 | 19 | 20 | class Param(IntEnum): 21 | """Enums for the different log_size available""" 22 | 23 | TEMPERATURE = 1 24 | HUMIDITY = 2 25 | PRESSURE = 3 26 | CO2 = 4 27 | HUMIDITY2 = 5 28 | PULSES = 6 29 | RADIATION_DOSE = 7 30 | RADIATION_DOSE_RATE = 8 31 | RADIATION_DOSE_INTEGRAL = 9 32 | RADON_CONCENTRATION = 10 33 | 34 | 35 | class Color(IntEnum): 36 | """Enum for the different status colors""" 37 | 38 | ERROR = 0 39 | GREEN = 1 40 | YELLOW = 2 41 | RED = 3 42 | 43 | 44 | class Status(IntEnum): 45 | """Enum for the different status alerts""" 46 | 47 | OFF = 0 48 | UNDER = 1 49 | OVER = 2 50 | DASH = 3 51 | 52 | 53 | class AranetType(IntEnum): 54 | """Enum for the different Aranet devices""" 55 | 56 | ARANET4 = 0 57 | ARANET2 = 1 58 | ARANET_RADIATION = 2 59 | ARANET_RADON = 3 60 | UNKNOWN = 255 61 | 62 | @property 63 | def model(self): 64 | description = { 65 | AranetType.ARANET4: "Aranet4", 66 | AranetType.ARANET2: "Aranet2", 67 | AranetType.ARANET_RADIATION: "Aranet Radiation", 68 | AranetType.ARANET_RADON: "Aranet Radon Plus", 69 | AranetType.UNKNOWN: "Unknown Aranet Device" 70 | } 71 | return description.get(self, "Unknown Aranet Device") 72 | 73 | 74 | @dataclass 75 | class Aranet4HistoryDelegate: 76 | """ 77 | When collecting the historical records they are sent using BLE 78 | notifications. This class takes those notifications and presents them 79 | as a Python dataclass 80 | """ 81 | 82 | handle: str 83 | param: Param 84 | size: int 85 | client: object 86 | result: list = field(default_factory=list) 87 | 88 | def __post_init__(self): 89 | self.result = _empty_reading(self.size) 90 | 91 | def handle_notification(self, sender: int, packet: bytes): 92 | """ 93 | Method to use with Bleak's `start_notify` function. 94 | Takes data returned and process it before storing 95 | """ 96 | data_type, start, count = struct.unpack(" self.size or count == 0: 98 | self.client.reading = False 99 | return 100 | 101 | if self.param != data_type: 102 | ( 103 | print(f"ERROR: invalid parameter. Got {data_type:02X}, expected {self.param:02X}") 104 | ) 105 | return 106 | pattern = " 0: 160 | ret += f" Logs: {self.stored}\n" 161 | 162 | ret += "---------------------------------------\n" 163 | 164 | if self.type == AranetType.ARANET4: 165 | ret += f" CO2: {self.co2} ppm\n" 166 | ret += f" Temperature: {self.temperature:.01f} \u00b0C\n" 167 | ret += f" Humidity: {self.humidity} %\n" 168 | ret += f" Pressure: {self.pressure:.01f} hPa\n" 169 | ret += f" Battery: {self.battery} %\n" 170 | ret += f" Status Display: {self.status.name}\n" 171 | ret += f" Age: {self.ago}/{self.interval} s\n" 172 | elif self.type == AranetType.ARANET2: 173 | ret += f" Temperature: {self.temperature:.01f} \u00b0C\n" 174 | ret += f" Humidity: {self.humidity} %\n" 175 | ret += f" Battery: {self.battery} %\n" 176 | ret += f" Status Temp.: {self.status_temperature.name}\n" 177 | ret += f" Status Humid.: {self.status_humidity.name}\n" 178 | ret += f" Age: {self.ago}/{self.interval} s\n" 179 | elif self.type == AranetType.ARANET_RADIATION: 180 | m = math.floor(self.radiation_duration / 60 % 60) 181 | h = math.floor(self.radiation_duration / 3600 % 24) 182 | d = math.floor(self.radiation_duration / 86400) 183 | 184 | dose_duration = str(m) + "m" 185 | if h > 0: 186 | dose_duration = str(h) + "h " + dose_duration 187 | if d > 0: 188 | dose_duration = str(d) + "d " + dose_duration 189 | 190 | ret += f" Dose rate: {self.radiation_rate / 1000:.02f} uSv/h\n" 191 | ret += f" Dose total: {self.radiation_total / 1000000:.04f} mSv/{dose_duration}\n" 192 | ret += f" Battery: {self.battery} %\n" 193 | ret += f" Age: {self.ago}/{self.interval} s\n" 194 | elif self.type == AranetType.ARANET_RADON: 195 | ret += f" Radon Conc.: {self.radon_concentration} Bq/m3\n" 196 | ret += f" Temperature: {self.temperature:.01f} \u00b0C\n" 197 | ret += f" Humidity: {self.humidity} %\n" 198 | ret += f" Pressure: {self.pressure:.01f} hPa\n" 199 | ret += f" Battery: {self.battery} %\n" 200 | ret += f" Status Display: {self.status.name}\n" 201 | ret += f" Age: {self.ago}/{self.interval} s\n" 202 | 203 | else: 204 | ret += " Unknown device type\n" 205 | 206 | return ret 207 | 208 | def toDict(self): 209 | data = { 210 | "battery": self.battery, 211 | "type": self.type.name 212 | } 213 | 214 | if self.type == AranetType.ARANET2: 215 | data["temperature"] = self.temperature 216 | data["humidity"] = self.humidity 217 | elif self.type == AranetType.ARANET4: 218 | data["co2"] = self.co2 219 | data["temperature"] = self.temperature 220 | data["pressure"] = self.pressure 221 | data["humidity"] = self.humidity 222 | elif self.type == AranetType.ARANET_RADIATION: 223 | data["radiation_rate"] = self.radiation_rate 224 | data["radiation_total"] = self.radiation_total 225 | data["radiation_duration"] = self.radiation_duration 226 | 227 | return data 228 | 229 | def decode(self, value: tuple, type: AranetType, gatt=False): 230 | """Process advertisement or gatt data""" 231 | 232 | self.type = type 233 | if type == AranetType.ARANET4: 234 | self._decode_aranet4(value, gatt) 235 | elif type == AranetType.ARANET2: 236 | self._decode_aranet2(value, gatt) 237 | elif type == AranetType.ARANET_RADIATION: 238 | self._decode_aranetR(value, gatt) 239 | elif type == AranetType.ARANET_RADON: 240 | self._decode_aranetRn(value, gatt) 241 | 242 | def _decode_aranet4(self, value: tuple, gatt=False): 243 | """Process Aranet4 data - CO2, Temperature, Humidity, Pressure""" 244 | 245 | self.co2 = self._set(Param.CO2, value[0]) 246 | self.temperature = self._set(Param.TEMPERATURE, value[1]) 247 | self.pressure = self._set(Param.PRESSURE, value[2]) 248 | self.humidity = self._set(Param.HUMIDITY, value[3]) 249 | self.battery = value[4] 250 | self.status = Color(value[5]) 251 | # If extended data list 252 | if len(value) > 6: 253 | self.interval = value[6] 254 | self.ago = value[7] 255 | 256 | def _decode_aranet2(self, value: tuple, gatt=False): 257 | """Process Aranet2 data - Temperature, Humidity""" 258 | 259 | # order from gatt and advertisements are different 260 | if gatt: 261 | self.temperature = self._set(Param.TEMPERATURE, value[4]) 262 | self.humidity = self._set(Param.HUMIDITY2, value[5]) 263 | self.battery = value[3] 264 | self.status_humidity = Status(value[6] & 0b0011) 265 | self.status_temperature = Status((value[6] & 0b1100) >> 2) 266 | self.interval = value[1] 267 | self.ago = value[2] 268 | else: 269 | self.temperature = self._set(Param.TEMPERATURE, value[1]) 270 | self.humidity = self._set(Param.HUMIDITY2, value[3]) 271 | self.status_humidity = Status(value[6] & 0b0011) 272 | self.status_temperature = Status((value[6] & 0b1100) >> 2) 273 | self.battery = value[5] 274 | self.interval = value[7] 275 | self.ago = value[8] 276 | self.counter = value[9] 277 | 278 | def _decode_aranetR(self, value: tuple, gatt=False): 279 | """Process Aranet Radiation data - radiation dose""" 280 | # order from gatt and advertisements are different 281 | if gatt: 282 | self.radiation_duration = value[6] 283 | self.radiation_rate = value[4] 284 | self.radiation_total = value[5] 285 | self.battery = value[3] 286 | self.interval = value[1] 287 | self.ago = value[2] 288 | else: 289 | self.radiation_total = value[0] 290 | self.radiation_duration = value[1] 291 | self.radiation_rate = value[2] 292 | self.battery = value[4] 293 | self.interval = value[6] 294 | self.ago = value[7] 295 | self.counter = value[8] 296 | 297 | def _decode_aranetRn(self, value: tuple, gatt=False): 298 | """Process Aranet Radon data""" 299 | # order from gatt and advertisements are different 300 | if gatt: 301 | self.battery = value[3] 302 | self.temperature = self._set(Param.TEMPERATURE, value[4]) 303 | self.pressure = self._set(Param.PRESSURE, value[5]) 304 | self.humidity = self._set(Param.HUMIDITY2, value[6]) 305 | self.radon_concentration = self._set(Param.RADON_CONCENTRATION, value[7]) 306 | self.status = Color(value[8]) 307 | self.radon_concentration_avg_24h = self._parse_avg_radon(value[9], value[10])["value"] 308 | self.radon_concentration_avg_7d = self._parse_avg_radon(value[11], value[12])["value"] 309 | self.radon_concentration_avg_30d = self._parse_avg_radon(value[13], value[14])["value"] 310 | else: 311 | self.radon_concentration = self._set(Param.RADON_CONCENTRATION, value[0]) 312 | self.temperature = self._set(Param.TEMPERATURE, value[1]) 313 | self.pressure = self._set(Param.PRESSURE, value[2]) 314 | self.humidity = self._set(Param.HUMIDITY2, value[3]) 315 | self.battery = value[5] 316 | self.status = Color(value[6]) 317 | self.interval = value[7] 318 | self.ago = value[8] 319 | self.counter = value[9] 320 | 321 | @staticmethod 322 | def _parse_avg_radon(time, average) -> dict: 323 | inProgress = average >= 0xff000000 324 | progress = -1 325 | value = average 326 | 327 | if inProgress: 328 | progress = average & 0x00FFFFFF 329 | value = -1 330 | 331 | return { 332 | "time": time, 333 | "value": value, 334 | "progress": progress 335 | } 336 | 337 | @staticmethod 338 | def _set(param: Param, value: int): 339 | """ 340 | While in CO2 calibration mode Aranet4 did not take new measurements and 341 | stores Magic numbers in measurement history. 342 | Here data is converted with checking for Magic numbers. 343 | """ 344 | invalid_reading_flag = True 345 | multiplier = 1 346 | if param == Param.CO2: 347 | invalid_reading_flag = value >> 15 == 1 348 | multiplier = 1 349 | elif param == Param.PRESSURE: 350 | invalid_reading_flag = value >> 15 == 1 351 | multiplier = 0.1 352 | elif param == Param.TEMPERATURE: 353 | invalid_reading_flag = value >> 14 & 1 == 1 354 | multiplier = 0.05 355 | elif param == Param.HUMIDITY: 356 | invalid_reading_flag = value >> 8 357 | multiplier = 1 358 | elif param == Param.HUMIDITY2: 359 | invalid_reading_flag = value >> 15 == 1 360 | multiplier = 0.1 361 | elif param == Param.RADIATION_DOSE: 362 | invalid_reading_flag = value >> 15 == 1 363 | multiplier = 1 # nSv 364 | elif param == Param.RADIATION_DOSE_RATE: 365 | invalid_reading_flag = value >> 15 == 1 366 | multiplier = 10 # nSv/h 367 | elif param == Param.RADIATION_DOSE_INTEGRAL: 368 | invalid_reading_flag = value >> 63 == 1 369 | multiplier = 1 # nSv 370 | elif param == Param.RADON_CONCENTRATION: 371 | # 0x1f00 general error 372 | # 0x1f01 no data 373 | # 0x1f02 Hi humidity in sensor chamber 374 | invalid_reading_flag = value >= 0x1f00 375 | multiplier = 1 # Bq/m3 376 | 377 | if invalid_reading_flag: 378 | return -1 379 | if isinstance(multiplier, float): 380 | return round(value * multiplier, 1) 381 | return value * multiplier 382 | 383 | 384 | @dataclass(order=True) 385 | class Version: 386 | major: int = -1 387 | minor: int = -1 388 | patch: int = -1 389 | 390 | def __init__(self, major, minor, patch): 391 | self.major = major 392 | self.minor = minor 393 | self.patch = patch 394 | 395 | def __str__(self): 396 | return f"v{self.major}.{self.minor}.{self.patch}" 397 | 398 | 399 | class CalibrationState(IntEnum): 400 | """Enum for calibration state""" 401 | NOT_ACTIVE = 0 402 | END_REQUEST = 1 403 | IN_PROGRESS = 2 404 | ERROR = 3 405 | 406 | 407 | @dataclass 408 | class ManufacturerData: 409 | """dataclass to store manufacturer data""" 410 | 411 | disconnected: bool = False 412 | calibration_state: CalibrationState = -1 413 | dfu_active: bool = False 414 | integrations: bool = False 415 | version: Version = None 416 | 417 | def decode(self, value: tuple): 418 | self.disconnected = self._get_b(value[0], 0) 419 | self.calibration_state = CalibrationState(self._get_uint2(value[0], 2)) 420 | self.dfu_active = self._get_b(value[0], 4) 421 | self.integrations = self._get_b(value[0], 5) 422 | self.version = Version(value[3], value[2], value[1]) 423 | 424 | def _get_b(self, value, pos): 425 | return (value & (1 << pos)) != 0 426 | 427 | def _get_uint2(self, value, pos): 428 | return (value >> pos) & 0x03 429 | 430 | 431 | @dataclass 432 | class Aranet4Advertisement: 433 | """dataclass to store the information aboud scanned aranet4 device""" 434 | 435 | device: BLEDevice = None 436 | readings: CurrentReading = None 437 | manufacturer_data: ManufacturerData = None 438 | rssi: int = None 439 | 440 | def __init__(self, device=None, ad_data=None): 441 | self.device = device 442 | 443 | if device and ad_data: 444 | has_manufacturer_data = Aranet4.MANUFACTURER_ID in ad_data.manufacturer_data 445 | self.rssi = getattr(ad_data, "rssi", None) 446 | 447 | if has_manufacturer_data: 448 | mf_data = ManufacturerData() 449 | raw_bytes = bytearray(ad_data.manufacturer_data[Aranet4.MANUFACTURER_ID]) 450 | if len(raw_bytes) < 5: 451 | # invalid manufacturer data 452 | return 453 | 454 | # Passive scan may return result with no name. 455 | valid_name = device.name and device.name.startswith(("Aranet4", "Aranet2", "Aranet\u2622", "AranetRn")) 456 | cond_name = valid_name and device.name.startswith("Aranet4") 457 | cond_len = not valid_name and len(raw_bytes) in [7, 22] 458 | 459 | if cond_name or cond_len: # Should be Aranet4 460 | raw_bytes.insert(0, 0) 461 | 462 | # Basic info 463 | value_fmt = " 0 and len(raw_bytes[:end]) == end: 492 | value = struct.unpack(value_fmt, raw_bytes[:end]) 493 | self.readings = CurrentReading() 494 | self.readings.decode(value, aranetv) 495 | self.readings.name = device.name 496 | else: 497 | mf_data.integrations = False 498 | 499 | 500 | @dataclass 501 | class RecordItem: 502 | """dataclass to store historical records""" 503 | 504 | date: datetime 505 | temperature: float 506 | humidity: int 507 | pressure: float 508 | co2: int 509 | rad_dose: float 510 | rad_dose_rate: float 511 | rad_dose_total: float 512 | radon_concentration: int 513 | 514 | 515 | @dataclass 516 | class Filter: 517 | """dataclass to store log filter information""" 518 | 519 | begin: int 520 | end: int 521 | incl_temperature: bool 522 | incl_humidity: int 523 | incl_pressure: bool 524 | incl_co2: bool 525 | incl_rad_dose: bool 526 | incl_rad_dose_rate: bool 527 | incl_rad_dose_total: bool 528 | incl_radon_concentration: bool 529 | 530 | 531 | @dataclass 532 | class Record: 533 | name: str 534 | version: str 535 | records_on_device: int 536 | filter: Filter 537 | value: List[RecordItem] = field(default_factory=list) 538 | 539 | 540 | @dataclass 541 | class SensorState: 542 | """dataclass to store sensor state values""" 543 | 544 | type: AranetType = AranetType.UNKNOWN 545 | buzzerSetting: str = "unknown" 546 | calibrationState: str = "unknown" 547 | calibrationProgress: int = 0 548 | warningPreset: str = "unknown" 549 | isLoRaEnabled: bool = False 550 | temperatureUnit: str = "unknown" 551 | isPulseBeepOn: bool = False 552 | isUsingCustomThreshold: bool = False 553 | isAutomaticCalibrationEnabled: bool = False 554 | radiationDisplayUnits: str = "unknown" 555 | radonDisplayUnits: str = "unknown" 556 | isBuzzerAvailable: bool = False 557 | bluetoothRange: str = "unknown" 558 | isOpenForIntegration: bool = False 559 | 560 | def decode(self, t): 561 | isAranet2 = t[0] == 0xF2 # Aranet2 562 | isAranet4 = t[0] == 0xF1 # Aranet4 563 | isNucleo = t[0] == 0xF4 # Aranet Radiation 564 | isRadon = t[0] == 0xF3 # Aranet Radon 565 | c = format(ord(chr(t[1])), "08b")[::-1] 566 | o = format(ord(chr(t[2])), "08b")[::-1] 567 | 568 | if isAranet4: 569 | self.type = AranetType.ARANET4 570 | elif isAranet2: 571 | self.type = AranetType.ARANET2 572 | elif isNucleo: 573 | self.type = AranetType.ARANET_RADIATION 574 | elif isRadon: 575 | self.type = AranetType.ARANET_RADON 576 | else: 577 | self.type = AranetType.UNKNOWN 578 | 579 | self.buzzerSetting = ( 580 | "none" if isAranet2 else 581 | "off" if not _eval(c[0]) else 582 | "on" if isRadon else 583 | "once" if not _eval(c[1]) else 584 | "each" 585 | ) 586 | 587 | self.calibrationState = self.cond(isAranet4, self.parseCalibrationState(t[1]), "none") 588 | self.calibrationProgress = self.cond(isAranet4, t[3], 0) 589 | self.warningPreset = self.cond(isAranet2, self.cond(t[3] == 1, "ISO", "custom"), "none") 590 | self.isLoRaEnabled = self.cond(c[4], True, False) 591 | self.temperatureUnit = self.cond(isNucleo, "none", self.cond(c[5], "C", "F")) 592 | self.isPulseBeepOn = self.cond(isNucleo, _eval(c[5]), False) 593 | self.isUsingCustomThreshold = c[6] == (isNucleo or isRadon) 594 | self.isAutomaticCalibrationEnabled = isAranet4 and c[7] 595 | self.radiationDisplayUnits = self.cond(isNucleo, self.cond(c[7], "Sv", "rem"), "none") 596 | self.radonDisplayUnits = ( 597 | "none" if not isRadon else 598 | "Bq/m³" if _eval(c[7]) else 599 | "pCi/L" 600 | ) 601 | self.isBuzzerAvailable = self.cond(isNucleo or isRadon, True, isAranet4 and _eval(o[0])) 602 | self.bluetoothRange = self.cond(o[1], "extended", "normal") 603 | self.isOpenForIntegration = _eval(o[7]) 604 | 605 | @staticmethod 606 | def parseCalibrationState(e): 607 | t = format(ord(chr(e)), "08b")[::-1] 608 | return SensorState.cond( 609 | t[3], 610 | SensorState.cond(t[2], "inErrorState", "endRequest"), 611 | SensorState.cond(t[2], "inProgress", "notActive") 612 | ) 613 | 614 | @staticmethod 615 | def cond(check, true_condition, false_condition): 616 | if _eval(check): 617 | return true_condition 618 | return false_condition 619 | 620 | 621 | class HistoryHeader(NamedTuple): 622 | param: int 623 | interval: int 624 | total_readings: int 625 | ago: int 626 | start: int 627 | count: int 628 | 629 | 630 | def _empty_reading(size): 631 | return [-1] * size 632 | 633 | 634 | class Aranet4: 635 | 636 | # Param return value if no data 637 | AR4_NO_DATA_FOR_PARAM = -1 638 | 639 | # Company Identifier (Akciju sabiedriba "SAF TEHNIKA") 640 | MANUFACTURER_ID = 0x0702 641 | 642 | # GAP Service 643 | SERVICE_GAP = normalize_uuid_16(0x1800) 644 | 645 | # GAP Service Characteristics 646 | CHARACTERISTIC_DEVICE_NAME = normalize_uuid_16(0x2a00) 647 | CHARACTERISTIC_APPEARANCE = normalize_uuid_16(0x2a01) 648 | 649 | # Device Information Service 650 | SERVICE_DIS = normalize_uuid_16(0x180a) 651 | 652 | # Device Information Service Characteristics 653 | CHARACTERISTIC_SYSTEM_ID = normalize_uuid_16(0x2a23) 654 | CHARACTERISTIC_MODEL_NUMBER = normalize_uuid_16(0x2a24) 655 | CHARACTERISTIC_SERIAL_NO = normalize_uuid_16(0x2a25) 656 | CHARACTERISTIC_SW_REV = normalize_uuid_16(0x2a26) 657 | CHARACTERISTIC_HW_REV = normalize_uuid_16(0x2a27) 658 | CHARACTERISTIC_SW_REV_FACTORY = normalize_uuid_16(0x2a28) 659 | CHARACTERISTIC_MANUFACTURER_NAME = normalize_uuid_16(0x2a29) 660 | 661 | # Battery Service 662 | SERVICE_BATTERY = normalize_uuid_16(0x180f) 663 | 664 | # Battery Service Characteristics 665 | CHARACTERISTIC_BATTERY_LEVEL = normalize_uuid_16(0x2a19) 666 | 667 | # SAF Tehnika Service 668 | SERVICE_SAF_TEHNIKA = normalize_uuid_16(0xfce0) # v1.2.0 and later 669 | SERVICE_SAF_TEHNIKA_OLD = "f0cd1400-95da-4f4b-9ac8-aa55d312af0c" # until v1.2.0 670 | 671 | # SAF Tehnika Service Characteristics (Aranet2 has different readings characteristic uuids) 672 | CHARACTERISTIC_SENSOR_STATE = "f0cd1401-95da-4f4b-9ac8-aa55d312af0c" 673 | CHARACTERISTIC_CMD = "f0cd1402-95da-4f4b-9ac8-aa55d312af0c" 674 | CHARACTERISTIC_CALIBRATION_DATA = "f0cd1502-95da-4f4b-9ac8-aa55d312af0c" 675 | CHARACTERISTIC_CURRENT_READINGS = "f0cd1503-95da-4f4b-9ac8-aa55d312af0c" 676 | CHARACTERISTIC_CURRENT_READINGS_AR2 = "f0cd1504-95da-4f4b-9ac8-aa55d312af0c" # Aranet2 Only 677 | CHARACTERISTIC_TOTAL_READINGS = "f0cd2001-95da-4f4b-9ac8-aa55d312af0c" 678 | CHARACTERISTIC_INTERVAL = "f0cd2002-95da-4f4b-9ac8-aa55d312af0c" 679 | CHARACTERISTIC_HISTORY_READINGS_V1 = "f0cd2003-95da-4f4b-9ac8-aa55d312af0c" 680 | CHARACTERISTIC_SECONDS_SINCE_UPDATE = "f0cd2004-95da-4f4b-9ac8-aa55d312af0c" 681 | CHARACTERISTIC_HISTORY_READINGS_V2 = "f0cd2005-95da-4f4b-9ac8-aa55d312af0c" 682 | CHARACTERISTIC_CURRENT_READINGS_DET = "f0cd3001-95da-4f4b-9ac8-aa55d312af0c" 683 | CHARACTERISTIC_CURRENT_READINGS_A = "f0cd3002-95da-4f4b-9ac8-aa55d312af0c" 684 | CHARACTERISTIC_CURRENT_READINGS_A_AR2 = "f0cd3003-95da-4f4b-9ac8-aa55d312af0c" # Aranet2 Only 685 | 686 | # Nordic Semiconductor ASA Service 687 | SERVICE_NORDIC_SEMICONDUCTOR = normalize_uuid_16(0xfe59) 688 | 689 | # Nordic Semiconductor ASA Service Characteristics 690 | CHARACTERISTIC_SECURE_DFU = "8ec90003-f315-4f60-9fb8-838830daea50" 691 | 692 | # Regexp 693 | REGEX_MAC = "([0-9a-f]{2}[:-]){5}([0-9a-f]{2})" 694 | REGEX_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" 695 | REGEX_ADDR = f"({REGEX_MAC})|({REGEX_UUID})" 696 | 697 | def __init__(self, address: str): 698 | if not re.match(self.REGEX_ADDR, address.lower()): 699 | raise Aranet4Error("Invalid device address") 700 | 701 | self.address = address 702 | self.device = BleakClient(address) 703 | self.reading = True 704 | 705 | def __del__(self): 706 | """Close remote""" 707 | if self.device.is_connected: 708 | asyncio.shield(self.device.disconnect()) 709 | 710 | async def connect(self): 711 | """Connect to remote device""" 712 | await self.device.connect() 713 | 714 | async def current_readings(self, details: bool = False): 715 | """Extract current readings from remote device""" 716 | readings = CurrentReading() 717 | 718 | new_aranet_char = self.device.services.get_characteristic( 719 | self.CHARACTERISTIC_CURRENT_READINGS_AR2 720 | ) 721 | 722 | if new_aranet_char: 723 | uuid = self.CHARACTERISTIC_CURRENT_READINGS_AR2 724 | raw_bytes = await self.device.read_gatt_char(uuid) 725 | 726 | isRadon = raw_bytes[0] == 3 727 | isNucleo = raw_bytes[0] == 4 728 | isAranet2 = raw_bytes[0] == 2 729 | 730 | if isRadon and len(raw_bytes) >= 47: 731 | # radon 732 | value_fmt = "= 28: 736 | # radiation 737 | value_fmt = " int: 760 | """Get the value for how often datapoints are logged on device""" 761 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_INTERVAL) 762 | return int.from_bytes(raw_bytes, byteorder="little") 763 | 764 | async def get_name(self): 765 | """Get name of remote device""" 766 | try: 767 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_DEVICE_NAME) 768 | return raw_bytes.decode("utf-8") 769 | except Exception: 770 | # fallback to serial number 771 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_SERIAL_NO) 772 | return "Aranet4 {}".format(raw_bytes.decode("utf-8")) 773 | 774 | async def get_version(self): 775 | """Get firmware version of remote device""" 776 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_SW_REV) 777 | return raw_bytes.decode("utf-8") 778 | 779 | async def get_seconds_since_update(self): 780 | """ 781 | Get the value for how long (in seconds) has passed since last 782 | datapoint was logged 783 | """ 784 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_SECONDS_SINCE_UPDATE) 785 | return int.from_bytes(raw_bytes, byteorder="little") 786 | 787 | async def get_total_readings(self): 788 | """Return the count of how many datapoints are logged on device""" 789 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_TOTAL_READINGS) 790 | return int.from_bytes(raw_bytes, byteorder="little") 791 | 792 | async def get_last_measurement_date(self, use_epoch: bool = False): 793 | """Calculate the time the last datapoint was logged""" 794 | ago = await self.get_seconds_since_update() 795 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 796 | delta_ago = datetime.timedelta(seconds=ago) 797 | last_reading = now - delta_ago 798 | if use_epoch: 799 | return last_reading.timestamp() 800 | return last_reading 801 | 802 | async def get_records( 803 | self, param: Param, log_size: int, start: int = 0x0001, end: int = 0xFFFF 804 | ): 805 | """ 806 | Return ordered list of datapoints for requested parameter. 807 | List will be length of "total datapoints". If index is outside `start` 808 | and `end` request then default of `-1` is returned for those datapoints. 809 | """ 810 | 811 | history_v2 = self.device.services.get_characteristic( 812 | self.CHARACTERISTIC_HISTORY_READINGS_V2 813 | ) 814 | 815 | if history_v2 is not None: 816 | return await self._get_records_v2(param, log_size, start, end) 817 | 818 | return await self._get_records_v1(param, log_size, start, end) 819 | 820 | async def _get_records_v2( 821 | self, param: Param, log_size: int, start: int = 0x0001, end: int = 0xFFFF 822 | ): 823 | """ 824 | Return ordered list of datapoints for requested parameter. 825 | List will be length of "total datapoints". If index is outside `start` 826 | and `end` request then default of `-1` is returned for those datapoints. 827 | """ 828 | start = max(start, 0x0001) 829 | 830 | header = 0x61 831 | val = struct.pack(" end or idx == header.start - 1 + header.count: 875 | break 876 | result[idx] = CurrentReading._set(param, value[0]) 877 | 878 | if idx >= end or (header.start - 1 + header.count) == log_size: 879 | reading = False 880 | 881 | return result 882 | 883 | async def _get_records_v1( 884 | self, param: Param, log_size: int, start: int = 0x0001, end: int = 0xFFFF 885 | ): 886 | """ 887 | Return ordered list of datapoints for requested parameter. 888 | List will be length of "total datapoints". If index is outside `start` 889 | and `end` request then default of `-1` is returned for those datapoints. 890 | """ 891 | start = max(start, 0x0001) 892 | 893 | header = 0x82 894 | unknown = 0x00 895 | val = struct.pack(" datetime: 968 | if dt and not dt.tzinfo: 969 | now = datetime.datetime.now().astimezone() 970 | dt = dt.replace(tzinfo=now.tzinfo) 971 | return dt 972 | 973 | 974 | def _calc_start_end(datapoint_times: int, entry_filter): 975 | """ 976 | Apply filters to get required start and end datapoint. 977 | `entry_filter` is a dictionary that can have the following values: 978 | `last`: int : Get last n entries 979 | `start`: datetime : Get entries after specified time 980 | `end`: datetime : Get entries before specified time 981 | """ 982 | last_n_entries = entry_filter.get("last") 983 | filter_start = _attach_tzinfo(entry_filter.get("start")) 984 | filter_end = _attach_tzinfo(entry_filter.get("end")) 985 | start = 0x0001 986 | end = len(datapoint_times) 987 | if last_n_entries: 988 | # Result is inclusive so reduce count back by 1 989 | start = max(end - last_n_entries + 1, start) 990 | if filter_start: 991 | time_start = -1 992 | for idx, timestamp in enumerate(datapoint_times, start=1): 993 | if filter_start <= timestamp: 994 | time_start = idx 995 | break 996 | if 0 < time_start <= end: 997 | start = time_start 998 | else: 999 | start = -1 # out of range 1000 | if filter_end: 1001 | time_end = -1 1002 | for idx, timestamp in enumerate(datapoint_times, start=1): 1003 | if timestamp <= filter_end: 1004 | time_end = idx 1005 | else: 1006 | break 1007 | if start <= time_end <= end: 1008 | end = time_end 1009 | else: 1010 | end = -1 # out of range 1011 | return start, end 1012 | 1013 | 1014 | async def _current_reading(address): 1015 | """Populate and return `client.CurrentReading` dataclass""" 1016 | monitor = Aranet4(address=address) 1017 | await monitor.connect() 1018 | readings = await monitor.current_readings(details=True) 1019 | readings.name = await monitor.get_name() 1020 | readings.version = await monitor.get_version() 1021 | readings.stored = await monitor.get_total_readings() 1022 | return readings 1023 | 1024 | 1025 | def _eval(val) -> bool: 1026 | falsy = ["0", "false", "disable", "disabled", "no", "off", "none"] 1027 | if isinstance(val, str): 1028 | return val.lower() not in falsy 1029 | return bool(val) 1030 | 1031 | 1032 | async def _set_settings(address, settings, verify: bool = True) -> dict: 1033 | """Change device settings. Returns changed count""" 1034 | monitor = Aranet4(address=address) 1035 | await monitor.connect() 1036 | status = {} 1037 | 1038 | if "interval" in settings: 1039 | intval = int(settings["interval"]) 1040 | status["interval"] = await monitor.set_readings_interval(intval, verify) 1041 | 1042 | if "range" in settings: 1043 | extend = ["extend", "extended", "1"] 1044 | extend = settings["range"].lower() in extend 1045 | status["range"] = await monitor.set_bluetooth_range(extend, verify) 1046 | 1047 | if "integrations" in settings: 1048 | on = ["on", "enable", "enabled", "1"] 1049 | on = settings["integrations"].lower() in on 1050 | status["integrations"] = await monitor.set_home_integration_enabled(on, verify) 1051 | 1052 | return status 1053 | 1054 | 1055 | def get_current_readings(mac_address: str) -> CurrentReading: 1056 | """Get from the device the current measurements""" 1057 | return asyncio.run(_current_reading(mac_address)) 1058 | 1059 | 1060 | def set_settings(mac_address: str, settings: dict, verify: bool = True) -> int: 1061 | """Get from the device the current measurements""" 1062 | return asyncio.run(_set_settings(mac_address, settings, verify)) 1063 | 1064 | 1065 | class Aranet4Scanner: 1066 | """Aranet4 Scanner class - scan advertisements and process data, if available""" 1067 | 1068 | def _process_advertisement(self, device, ad_data): 1069 | """Processes Aranet4 advertisement data""" 1070 | adv = Aranet4Advertisement(device, ad_data) 1071 | self.on_scan(adv) 1072 | 1073 | def __init__(self, on_scan): 1074 | uuids = [Aranet4.SERVICE_SAF_TEHNIKA, Aranet4.SERVICE_SAF_TEHNIKA_OLD] 1075 | self.on_scan = on_scan 1076 | self.scanner = BleakScanner( 1077 | detection_callback=self._process_advertisement, 1078 | service_uuids=uuids 1079 | ) 1080 | 1081 | async def start(self): 1082 | await self.scanner.start() 1083 | 1084 | async def stop(self): 1085 | await self.scanner.stop() 1086 | 1087 | 1088 | async def _find_nearby(detect_callback: callable, duration: int) -> List[BLEDevice]: 1089 | scanner = Aranet4Scanner(detect_callback) 1090 | await scanner.start() 1091 | await asyncio.sleep(duration) 1092 | await scanner.stop() 1093 | return [device 1094 | for device in scanner.scanner.discovered_devices 1095 | if "Aranet" in device.name] 1096 | 1097 | 1098 | def find_nearby(detect_callback: callable, duration: int = 5) -> List[BLEDevice]: 1099 | """ 1100 | Scans for nearby Aranet4 devices. 1101 | Will call callback on every valid Aranet4 advertisement, including duplicates 1102 | """ 1103 | 1104 | return asyncio.run(_find_nearby(detect_callback, duration)) 1105 | 1106 | 1107 | async def _all_records(address, entry_filter, remove_empty): 1108 | """ 1109 | Get stored data points from device. Apply any filters requested 1110 | `entry_filter` is a dictionary that can have the following values: 1111 | `last`: int : Get last n entries 1112 | `start`: datetime : Get entries after specified time 1113 | `end`: datetime : Get entries before specified time 1114 | `temp`: bool : Get temperature data points (default = True) 1115 | `humi`: bool : Get humidity data points (default = True) 1116 | `pres`: bool : Get pressure data points (default = True) 1117 | `co2`: bool : Get co2 data points (default = True) 1118 | """ 1119 | # Connect 1120 | monitor = Aranet4(address=address) 1121 | await monitor.connect() 1122 | # Get Basic information 1123 | dev_name = await monitor.get_name() 1124 | dev_version = await monitor.get_version() 1125 | last_log = await monitor.get_seconds_since_update() 1126 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 1127 | interval = await monitor.get_interval() 1128 | next_log = interval - last_log 1129 | # Decide if there is enough time to read all the data 1130 | # before the next datapoint is logged. 1131 | print(f"Next data point will be logged in {next_log} seconds") 1132 | if next_log < 10: 1133 | print(f"Waiting {next_log} for next datapoint to be taken...") 1134 | await asyncio.sleep(next_log) 1135 | # there was another log so update the numbers 1136 | last_log = await monitor.get_seconds_since_update() 1137 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 1138 | 1139 | if dev_name.startswith("Aranet2"): 1140 | entry_filter["pres"] = False 1141 | entry_filter["co2"] = False 1142 | if entry_filter.get("humi", False): 1143 | entry_filter["humi"] = 2 # v2 humidity 1144 | elif dev_name.startswith("Aranet\u2622"): 1145 | entry_filter["pres"] = False 1146 | entry_filter["co2"] = False 1147 | entry_filter["humi"] = False 1148 | entry_filter["temp"] = False 1149 | entry_filter["rad_dose"] = entry_filter.get("rad_dose", True) 1150 | entry_filter["rad_dose_rate"] = entry_filter.get("rad_dose_rate", True) 1151 | entry_filter["rad_dose_total"] = entry_filter.get("rad_dose_total", True) 1152 | elif dev_name.startswith("AranetRn"): 1153 | entry_filter["co2"] = False 1154 | entry_filter["radon_concentration"] = entry_filter.get("radon_concentration", True) 1155 | entry_filter["pres"] = entry_filter.get("pres", True) 1156 | entry_filter["temp"] = entry_filter.get("temp", True) 1157 | if entry_filter.get("humi", False): 1158 | entry_filter["humi"] = 2 # v2 humidity 1159 | 1160 | log_size = await monitor.get_total_readings() 1161 | log_points = _log_times(now, log_size, interval, last_log) 1162 | begin, end = _calc_start_end(log_points, entry_filter) 1163 | rec_filter = Filter( 1164 | begin, 1165 | end, 1166 | entry_filter.get("temp", True), 1167 | entry_filter.get("humi", True), 1168 | entry_filter.get("pres", True), 1169 | entry_filter.get("co2", True), 1170 | entry_filter.get("rad_dose", False), 1171 | entry_filter.get("rad_dose_rate", False), 1172 | entry_filter.get("rad_dose_total", False), 1173 | entry_filter.get("radon_concentration", False), 1174 | ) 1175 | 1176 | if begin < 0 or end < 0: 1177 | # invalid range. Most likely no points available 1178 | return Record(dev_name, dev_version, log_size, rec_filter) 1179 | 1180 | # Read datapoint history from device 1181 | if rec_filter.incl_temperature: 1182 | temperature_val = await monitor.get_records( 1183 | Param.TEMPERATURE, log_size=log_size, start=begin, end=end 1184 | ) 1185 | else: 1186 | temperature_val = _empty_reading(log_size) 1187 | if rec_filter.incl_humidity == 2: 1188 | humidity_val = await monitor.get_records( 1189 | Param.HUMIDITY2, log_size=log_size, start=begin, end=end 1190 | ) 1191 | elif rec_filter.incl_humidity: 1192 | humidity_val = await monitor.get_records( 1193 | Param.HUMIDITY, log_size=log_size, start=begin, end=end 1194 | ) 1195 | else: 1196 | humidity_val = _empty_reading(log_size) 1197 | if rec_filter.incl_pressure: 1198 | pressure_val = await monitor.get_records( 1199 | Param.PRESSURE, log_size=log_size, start=begin, end=end 1200 | ) 1201 | else: 1202 | pressure_val = _empty_reading(log_size) 1203 | if rec_filter.incl_co2: 1204 | co2_val = await monitor.get_records( 1205 | Param.CO2, log_size=log_size, start=begin, end=end 1206 | ) 1207 | else: 1208 | co2_val = _empty_reading(log_size) 1209 | if rec_filter.incl_rad_dose: 1210 | rad_dose_val = await monitor.get_records( 1211 | Param.RADIATION_DOSE, log_size=log_size, start=begin, end=end 1212 | ) 1213 | else: 1214 | rad_dose_val = _empty_reading(log_size) 1215 | if rec_filter.incl_rad_dose_rate: 1216 | rad_dose_rate_val = await monitor.get_records( 1217 | Param.RADIATION_DOSE_RATE, log_size=log_size, start=begin, end=end 1218 | ) 1219 | else: 1220 | rad_dose_rate_val = _empty_reading(log_size) 1221 | if rec_filter.incl_rad_dose_total: 1222 | rad_dose_total_val = await monitor.get_records( 1223 | Param.RADIATION_DOSE_INTEGRAL, log_size=log_size, start=begin, end=end 1224 | ) 1225 | else: 1226 | rad_dose_total_val = _empty_reading(log_size) 1227 | if rec_filter.incl_radon_concentration: 1228 | radon_concentration_val = await monitor.get_records( 1229 | Param.RADON_CONCENTRATION, log_size=log_size, start=begin, end=end 1230 | ) 1231 | else: 1232 | radon_concentration_val = _empty_reading(log_size) 1233 | #### 1234 | # Store returned data in dataclass 1235 | data = zip( 1236 | log_points, 1237 | co2_val, 1238 | temperature_val, 1239 | pressure_val, 1240 | humidity_val, 1241 | rad_dose_val, 1242 | rad_dose_rate_val, 1243 | rad_dose_total_val, 1244 | radon_concentration_val 1245 | ) 1246 | 1247 | record = Record(dev_name, dev_version, log_size, rec_filter) 1248 | 1249 | for date, co2, temp, pres, hum, rad, rad_rate, rad_integral, radon in data: 1250 | record.value.append(RecordItem(date, temp, hum, pres, co2, rad, rad_rate, rad_integral, radon)) 1251 | if remove_empty: 1252 | record.value = record.value[begin - 1:end + 1] 1253 | return record 1254 | 1255 | 1256 | def get_all_records(mac_address: str, entry_filter: dict, remove_empty: bool = False) -> Record: 1257 | """ 1258 | Get stored datapoints from device. Apply any filters requested 1259 | `entry_filter` is a dictionary that can have the following values: 1260 | `last`: int : Get last n entries 1261 | `start`: datetime : Get entries after specified time 1262 | `end`: datetime : Get entries before specified time 1263 | `temp`: bool : Get temperature data points (default = True) 1264 | `humi`: bool : Get humidity data points (default = True) 1265 | `pres`: bool : Get pressure data points (default = True) 1266 | `co2`: bool : Get co2 data points (default = True) 1267 | """ 1268 | return asyncio.run(_all_records(mac_address, entry_filter, remove_empty)) 1269 | -------------------------------------------------------------------------------- /docs/UUIDs.md: -------------------------------------------------------------------------------- 1 | # Read values 2 | ## Aranet4 info 3 | Service UUID: `0000fce0-0000-1000-8000-00805f9b34fb` 4 | Service UUID before v1.2.0: `f0cd1400-95da-4f4b-9ac8-aa55d312af0c` 5 | 6 | | Characteristic UUID | Name | Type | Return bytes | Values | 7 | |----------------------------------------|-----------------------------------|------------|----------------------------------------|----------------------------------| 8 | | `f0cd1401-95da-4f4b-9ac8-aa55d312af0c` | Sensor settings state | raw | | | 9 | | `f0cd1503-95da-4f4b-9ac8-aa55d312af0c` | Current Readings | raw | SS:SS:TT:TT:UU:UU:VV:WW:XX | See Aranet4 readings table | 10 | | `f0cd3001-95da-4f4b-9ac8-aa55d312af0c` | Current Readings + Interval + Ago | raw | SS:SS:TT:TT:UU:UU:VV:WW:XX:YY:YY:ZZ:ZZ | See Aranet4 readings table | 11 | | `f0cd1504-95da-4f4b-9ac8-aa55d312af0c` | Aranet2/Rad Current Readings | raw | See Aranet2/Rad readings table | See Aranet2/Rad readings table | 12 | | `f0cd2002-95da-4f4b-9ac8-aa55d312af0c` | Read Interval | u16LE | XX:XX | Read interval in seconds | 13 | | `f0cd1502-95da-4f4b-9ac8-aa55d312af0c` | Sensor calibration data | raw | FF:FF:FF:FF:FF:FF:FF:FF | | 14 | | `f0cd2004-95da-4f4b-9ac8-aa55d312af0c` | Seconds since update | u16LE | XX:XX | Last reading time (seconds ago) | 15 | | `f0cd2001-95da-4f4b-9ac8-aa55d312af0c` | Total readings | u16LE | XX:XX | XX:XX - Total readings in memory | 16 | 17 | ### Aranet4 readings 18 | | Parameter | Name | Type | Maths | 19 | |-----------|---------------------------------|-------|--------------| 20 | | SS:SS | CO2 | uLE16 | not required | 21 | | TT:TT | Temperature | uLE16 | /20 | 22 | | UU:UU | Pressure | uLE16 | /10 | 23 | | VV | Humidity | u8 | not required | 24 | | WW | Battery | u8 | not required | 25 | | XX | Status color (1 - green, 2 - yellow, 3 - red) | u8 | not required | 26 | | YY:YY | Interval in seconds | uLE16 | not required | 27 | | ZZ:ZZ | Age (seconds ago) | uLE16 | not required | 28 | 29 | ### Aranet2 readings (GATT) 30 | | Parameter | Name | Type | Maths | 31 | |-----------|---------------------------------|-------|--------------| 32 | | SS:SS | _Unknown_ | uLE16 | not required | 33 | | TT:TT | Interval in seconds | uLE16 | not required | 34 | | UU:UU | Age (seconds ago) | uLE16 | not required | 35 | | VV | Battery | u8 | not required | 36 | | WW:WW | Temperature | uLE16 | /20 | 37 | | XX:XX | Humidity | uLE16 | /10 | 38 | | YY | Status Flags | u8 | not required | 39 | 40 | ### Aranet2 readings (Advertisement) 41 | | Parameter | Name | Type | Maths | 42 | |-----------|---------------------------------|-------|--------------| 43 | | header | Advertisement data header | | not required | 44 | | QQ:QQ | _Unknown_ | uLE16 | not required | 45 | | RR:RR | Temperature | uLE16 | /20 | 46 | | SS:SS | _Unknown_ | uLE16 | not required | 47 | | TT:TT | Humidity | uLE16 | /10 | 48 | | UU | _Unknown_ | u8 | not required | 49 | | VV | Battery | u8 | not required | 50 | | WW | Status Flags | u8 | not required | 51 | | XX:XX | Interval in seconds | u8 | not required | 52 | | YY:YY | Age (seconds ago) | uLE16 | not required | 53 | | ZZ | Counter | u8 | not required | 54 | 55 | ### Aranet Radiation readings (GATT) 56 | | Parameter | Name | Type | Maths | 57 | |-------------|---------------------------------|-------|--------------| 58 | | RR:RR | _Unknown_ | uLE16 | not required | 59 | | SS:SS | Interval in seconds | uLE16 | not required | 60 | | TT:TT | Age (seconds ago) | uLE16 | not required | 61 | | VV | Battery | u8 | not required | 62 | | WW:WW:WW:WW | Radiation dose rate (nSv) | uLE32 | not required | 63 | | XX:XX:XX:XX:XX:XX:XX:XX | Radiation dose total (nSv) | uLE64 | not required | 64 | | YY:YY:YY:YY:YY:YY:YY:YY | Total dose duration (seconds) | uLE64 | not required | 65 | | ZZ | Status | u8 | not required | 66 | 67 | ### Aranet Radiation readings (Advertisement) 68 | | Parameter | Name | Type | Maths | 69 | |-------------|---------------------------------|-------|--------------| 70 | | header | Advertisement data header | | | 71 | | RR:RR:RR:RR | Radiation dose total (nSv) | uLE32 | not required | 72 | | SS:SS:SS:SS | Total dose duration (seconds) | uLE32 | not required | 73 | | TT:TT | Radiation dose rate (nSv) | uLE16 | *10 | 74 | | UU | _Unknown_ | u8 | | 75 | | VV | Battery | u8 | not required | 76 | | WW | _Unknown_ | u8 | | 77 | | XX:XX | Interval | u8 | not required | 78 | | YY:YY | Age (seconds ago) | u8 | not required | 79 | | ZZ | Counter | u8 | not required | 80 | 81 | ### AranetRn readings (GATT) 82 | | Parameter | Name | Type | Maths | 83 | |-----------|---------------------------------|-------|--------------| 84 | | NN:NN:NN:NN:NN:NN | _Unknown_ | | | 85 | | OO | Battery | u8 | not required | 86 | | PP:PP | Temperature | uLE16 | /20 | 87 | | QQ:QQ | Pressure | uLE16 | /10 | 88 | | RR:RR | Humidity | uLE16 | /10 | 89 | | SS:SS:SS:SS | Radon Concentration | uLE32 | not required | 90 | | TT | Status | u8 | | 91 | | VV:VV:VV:VV:VV:VV:VV:VV | Average 1 | uLE64 | | 92 | | WW:WW:WW:WW:WW:WW:WW:WW | Average 2 | uLE64 | | 93 | | XX:XX:XX:XX:XX:XX:XX:XX | Average 3 | uLE64 | | 94 | | YY:YY:YY:YY | Initial progress | uLE64 | | 95 | | ZZ | Display Type | u8 | | 96 | 97 | ### AranetRn readings (Advertisement) 98 | | Parameter | Name | Type | Maths | 99 | |-----------|---------------------------------|-------|--------------| 100 | | header | Advertisement data header | | not required | 101 | | QQ:QQ | Radon Concentration | uLE16 | not required | 102 | | RR:RR | Temperature | uLE16 | /20 | 103 | | SS:SS | Pressure | uLE16 | /10 | 104 | | TT:TT | Humidity | uLE16 | /10 | 105 | | UU | _Unknown_ | u8 | not required | 106 | | VV | Battery | u8 | not required | 107 | | WW | Status Flags | u8 | not required | 108 | | XX:XX | Interval in seconds | u8 | not required | 109 | | YY:YY | Age (seconds ago) | uLE16 | not required | 110 | | ZZ | Counter | u8 | not required | 111 | 112 | ## Generic info 113 | | Service UUID | Characteristic UUID | Name | Type | 114 | |--------------------------------------|--------------------------------------|-------------------|--------| 115 | | `00001800-0000-1000-8000-00805f9b34fb` | `00002a00-0000-1000-8000-00805f9b34fb` | Device name | String | 116 | | `0000180a-0000-1000-8000-00805f9b34fb` | `00002a19-0000-1000-8000-00805f9b34fb` | Battery level | u8 | 117 | | `0000180a-0000-1000-8000-00805f9b34fb` | `00002a24-0000-1000-8000-00805f9b34fb` | Model Number | String | 118 | | `0000180a-0000-1000-8000-00805f9b34fb` | `00002a25-0000-1000-8000-00805f9b34fb` | Serial No. | String | 119 | | `0000180a-0000-1000-8000-00805f9b34fb` | `00002a27-0000-1000-8000-00805f9b34fb` | Hardware Revision | String | 120 | | `0000180a-0000-1000-8000-00805f9b34fb` | `00002a28-0000-1000-8000-00805f9b34fb` | Software Revision | String | 121 | | `0000180a-0000-1000-8000-00805f9b34fb` | `00002a29-0000-1000-8000-00805f9b34fb` | Manufacturer Name | String | 122 | 123 | # Write values 124 | Service UUID: `f0cd1400-95da-4f4b-9ac8-aa55d312af0c` 125 | 126 | | Characteristic UUID | Name | Type | Write value | Parameters | 127 | |----------------------------------------|--------------------------------|------|-------------------------|-----------------------------------------------------------------------------------------------| 128 | | `f0cd1402-95da-4f4b-9ac8-aa55d312af0c` | Set Interval | raw | 90:XX | XX - Time in minutes (01,02,05,0A) | 129 | | `f0cd1402-95da-4f4b-9ac8-aa55d312af0c` | Toggle Smart Home integrations | raw | 91:XX | XX - 0 = disabled, 1 = enabled | 130 | | `f0cd1402-95da-4f4b-9ac8-aa55d312af0c` | Set Bluetooth range | raw | 92:XX | XX - 0 = standard, 1 = extended | 131 | | `f0cd1402-95da-4f4b-9ac8-aa55d312af0c` | Set history parameter | raw | 82:XX:00:00:YY:YY:ZZ:ZZ | XX - Property (1,2,3,4), YY:YY - First index (uLE16, starts with 1),ZZ:ZZ - Max index (u16LE) | 132 | 133 | 134 | -------------------------------------------------------------------------------- /examples/history/export_csv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | import aranet4 4 | 5 | # Aranet4 MAC address 6 | device_mac = "XX:XX:XX:XX:XX:XX" 7 | 8 | # Selection filter. Will export last 25 records 9 | entry_filter = { 10 | "last": 25 11 | } 12 | 13 | # Fetch results 14 | records = aranet4.client.get_all_records( 15 | device_mac, 16 | entry_filter, 17 | remove_empty=True # This will remove blank records, if range parameters (start,end,last) are specified 18 | ) 19 | 20 | # Write CSV file 21 | with open(file="aranet_history.csv", mode="w", encoding="utf-8") as csv_file: 22 | writer = csv.writer(csv_file) 23 | 24 | header = [ 25 | "date", 26 | "co2", 27 | "temperature", 28 | "humidity", 29 | "pressure" 30 | ] 31 | 32 | # Write CSV header 33 | writer.writerow(header) 34 | 35 | # Write CSV rows 36 | for line in records.value: 37 | row = [ 38 | line.date.isoformat(), 39 | line.co2, 40 | line.temperature, 41 | line.humidity, 42 | line.pressure 43 | ] 44 | 45 | writer.writerow(row) 46 | -------------------------------------------------------------------------------- /examples/influx/README.md: -------------------------------------------------------------------------------- 1 | # InfluxDB Examples 2 | To try out theese examples, make sure influx db login info and db name is correct. 3 | 4 | ## influx.py 5 | If you wish to store data on server, using InfuxDB, check `influx.py` 6 | 7 | ### Usage 8 | Current readings: `python influx.py DEVICE_ADDRESS DEVICE_NAME` 9 | 10 | History: `python influx.py DEVICE_ADDRESS DEVICE_NAME -h [-l ]` 11 | 12 | #### Automatic data collection 13 | 14 | To automate data collection, using crontab will be easiest way: 15 | 1. Edit crontab: `crontab -e` 16 | 17 | 2. Add job. If running Aranet4 with 1 minute intervals, run script every minute:`* * * * * python /PATH_TO_THIS_REPO/influx.py XX:XX:XX:XX:XX:XX anrijsAR4` 18 | 19 | 3. Save and close crontab. 20 | 21 | #### History upload 22 | It is also possible to upload history to InfluxDB. In this case run command: 23 | `python influx.py XX:XX:XX:XX:XX:XX anrijsAR4 -h` 24 | 25 | To limit record count, add `-l ` parameter. 26 | 27 | ## import.py 28 | This script imports history files generated by `aranet.py` 29 | 30 | ### Usage 31 | Import file to InfluxDB: `python import.py FILE_NAME DEVICE_NAME` 32 | -------------------------------------------------------------------------------- /examples/influx/import.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import sys 3 | 4 | from influxdb import InfluxDBClient 5 | 6 | def mkpt(device, key, value, timestr): 7 | return { 8 | "measurement": key, 9 | "tags": { 10 | "device": device, 11 | }, 12 | "time": timestr, 13 | "fields": { 14 | "value": value 15 | } 16 | } 17 | 18 | def main(argv): 19 | if len(argv) < 2: 20 | print("Missing file name and device name") 21 | return 22 | 23 | if "help" in argv or "?" in argv: 24 | print("Usage: python import.py DEVICE_ADDRESS DEVICE_NAME") 25 | print("") 26 | return 27 | 28 | results = [] 29 | 30 | device_name = argv[1] 31 | with open(file=argv[0], mode="r", encoding="utf-8") as file: 32 | lines = file.readlines() 33 | 34 | for ln in lines: 35 | pt = ln.strip().split(";") 36 | if len(pt) < 5: 37 | continue 38 | 39 | id = pt[0] 40 | timestr = pt[1] + ":00" 41 | dt = datetime.datetime.strptime(timestr, "%Y-%m-%d %H:%M:%S") 42 | dt = dt - datetime.timedelta(hours=1) 43 | 44 | t = float(pt[2]) 45 | h = int(pt[3]) 46 | p = float(pt[4]) 47 | c = int(pt[5]) 48 | 49 | res = { 50 | "id": id, 51 | "time": dt.strftime("%Y-%m-%dT%H:%M:%SZ"), 52 | "temperature": t, 53 | "pressure": p, 54 | "humidity": h, 55 | "co2": c 56 | } 57 | 58 | results.append(res) 59 | 60 | client = InfluxDBClient("127.0.0.1", "8086", "root", "root", "aranet4") 61 | client.create_database("aranet4") 62 | 63 | print("Sending history to InfluxDB...") 64 | pts = [] 65 | 66 | for r in results: 67 | strtim = r["time"] 68 | t = r["temperature"] 69 | p = r["pressure"] 70 | h = r["humidity"] 71 | c = r["co2"] 72 | #i = r["id"] 73 | 74 | if len(pts) > 10000: # flush 75 | client.write_points(pts) 76 | pts = [] 77 | 78 | pts.append(mkpt(device_name, "temperature", t, strtim)) 79 | pts.append(mkpt(device_name, "pressure", p, strtim)) 80 | pts.append(mkpt(device_name, "humidity", h, strtim)) 81 | pts.append(mkpt(device_name, "co2", c, strtim)) 82 | 83 | client.write_points(pts) 84 | 85 | if __name__== "__main__": 86 | main(sys.argv[1:]) 87 | -------------------------------------------------------------------------------- /examples/influx/influx.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import sys 3 | 4 | from influxdb import InfluxDBClient 5 | 6 | import aranet4 7 | 8 | def mkpt(device, key, value, timestr): 9 | return { 10 | "measurement": key, 11 | "tags": { 12 | "device": device, 13 | }, 14 | "time": timestr, 15 | "fields": { 16 | "value": value 17 | } 18 | } 19 | 20 | def readArg(argv, key, default, error="Invalid value"): 21 | if key in argv: 22 | idx = argv.index(key) + 1 23 | if idx >= len(argv): 24 | print(error) 25 | raise Exception(error) 26 | return argv[idx] 27 | return default 28 | 29 | def main(argv): 30 | if len(argv) < 2: 31 | print("Missing device address and/or name.") 32 | return 33 | 34 | if "help" in argv or "?" in argv: 35 | print("Usage: python influx.py DEVICE_ADDRESS DEVICE_NAME [OPTIONS]") 36 | print("Options:") 37 | print(" -h Fetch history") 38 | print(" -l Get last records") 39 | print("") 40 | return 41 | 42 | hist = "-h" in argv 43 | 44 | limit = int(readArg(argv, "-l", 0, "Missing limit value")) 45 | 46 | device_mac = argv[0] 47 | device_name = argv[1] 48 | 49 | client = InfluxDBClient("127.0.0.1", "8086", "root", "root", "aranet4") 50 | client.create_database("aranet4") 51 | 52 | if hist: 53 | print("Fetching sensor history...") 54 | results = aranet4.client.get_all_records(mac_address=device_mac, 55 | entry_filter={"last": limit}) 56 | else: 57 | print("Fetching current readings...") 58 | current = aranet4.client.get_current_readings(mac_address=device_mac) 59 | 60 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 61 | delta_ago = datetime.timedelta(seconds=current.ago) 62 | t = now - delta_ago 63 | t = t.replace(second=0) # epoch, floored to minutes 64 | 65 | results = [{ 66 | "time": t.timestamp(), 67 | "id": 0, 68 | "temperature": current.temperature, 69 | "pressure": current.pressure, 70 | "humidity": current.humidity, 71 | "co2": current.co2 72 | }] 73 | 74 | pts = [] 75 | 76 | print("Sending history to InfluxDB...") 77 | for r in results: 78 | strtim = datetime.datetime.utcfromtimestamp(r["time"]).strftime("%Y-%m-%dT%H:%M:%SZ") # ISO 8601 UTC 79 | t = r["temperature"] 80 | p = r["pressure"] 81 | h = r["humidity"] 82 | c = r["co2"] 83 | #i = r["id"] 84 | 85 | if len(pts) > 2500: # flush 86 | client.write_points(pts) 87 | pts = [] 88 | 89 | pts.append(mkpt(device_name, "temperature", t, strtim)) 90 | pts.append(mkpt(device_name, "pressure", p, strtim)) 91 | pts.append(mkpt(device_name, "humidity", h, strtim)) 92 | pts.append(mkpt(device_name, "co2", c, strtim)) 93 | 94 | client.write_points(pts) 95 | 96 | 97 | if __name__== "__main__": 98 | main(sys.argv[1:]) 99 | -------------------------------------------------------------------------------- /examples/mqtt/README.md: -------------------------------------------------------------------------------- 1 | # MQTT Example 2 | 3 | ## mqtt.py 4 | This example read data from aranet and then sends it to mqtt broker. 5 | 6 | ### Usage 7 | Send to host, with topic: `python publish.py DEVICE_ADDRESS HOSTNAME TOPIC_BASE [OPTIONS]` 8 | 9 | Options: 10 | ``` 11 | -P Broker port 12 | -u Auth user name 13 | -p Auth user password 14 | ``` 15 | 16 | Data will be sent to `TOPIC_BASE`/``. 17 | 18 | For example, if `TOPIC_BASE` is set to "bedroom/aranet4/, following data will be sent: 19 | ``` 20 | /bedroom/aranet4/temperature 21 | /bedroom/aranet4/pressure 22 | /bedroom/aranet4/humidity 23 | /bedroom/aranet4/co2 24 | /bedroom/aranet4/battery 25 | ``` 26 | 27 | #### Automatic data collection 28 | 29 | To automate data collection, using crontab will be easiest way: 30 | 1. Edit crontab: `crontab -e` 31 | 32 | 2. Add job. If running Aranet4 with 1 minute intervals, run script every minute:`* * * * * python /PATH_TO_THIS_FILE/publish.py XX:XX:XX:XX:XX:XX HOSTNAME bedroom/ar4/` 33 | 34 | 3. Save and close crontab. 35 | -------------------------------------------------------------------------------- /examples/mqtt/publish.py: -------------------------------------------------------------------------------- 1 | from dataclasses import asdict 2 | import sys 3 | 4 | from paho.mqtt import publish 5 | 6 | import aranet4 7 | 8 | def buildMsgs(readings, topic): 9 | return [ 10 | (topic + "temperature", readings["temperature"]), 11 | (topic + "pressure", readings["pressure"]), 12 | (topic + "humidity", readings["humidity"]), 13 | (topic + "co2", readings["co2"]), 14 | (topic + "battery", readings["battery"]) 15 | ] 16 | 17 | def readArg(argv, key, default, error="Invalid value"): 18 | if key in argv: 19 | idx = argv.index(key) + 1 20 | if idx >= len(argv): 21 | print(error) 22 | raise Exception(error) 23 | return argv[idx] 24 | return default 25 | 26 | def main(argv): 27 | if len(argv) < 3: 28 | print("Missing device address, topic base and/or hostname.") 29 | argv[0] = "?" 30 | 31 | if "help" in argv or "?" in argv: 32 | print("Usage: python publish.py DEVICE_ADDRESS HOSTNAME TOPIC_BASE [OPTIONS]") 33 | print(" -P Broker port") 34 | print(" -u Auth user name") 35 | print(" -p Auth user password") 36 | 37 | print("") 38 | return 39 | 40 | device_mac = argv[0] 41 | host = argv[1] 42 | topic = argv[2] 43 | 44 | port = readArg(argv, "-P", "1883") 45 | user = readArg(argv, "-u", "") 46 | pwd = readArg(argv, "-p", "") 47 | 48 | auth = None 49 | 50 | if len(user) > 0: 51 | auth = {"username":user} 52 | 53 | if len(pwd) > 0: 54 | auth["password"] = pwd 55 | 56 | if topic[-1] != "/": 57 | topic += "/" 58 | 59 | current = aranet4.client.get_current_readings(device_mac) 60 | 61 | print("Publishing results...") 62 | publish.multiple(buildMsgs(asdict(current), topic), hostname=host, port=int(port), auth=auth) 63 | 64 | 65 | if __name__== "__main__": 66 | main(sys.argv[1:]) 67 | -------------------------------------------------------------------------------- /examples/scanner/scanner_advanced.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | from aranet4 import Aranet4Scanner 5 | 6 | """ 7 | Custom scanner setup example. 8 | This will run scanner forever (or until interupted by Ctrl^C) 9 | """ 10 | 11 | def on_scan(advertisement): 12 | if not advertisement.readings: 13 | return 14 | 15 | print("=======================================") 16 | print(f" Name: {advertisement.device.name}") 17 | print(f" Model: {advertisement.readings.type.model}") 18 | print(f" Address: {advertisement.device.address}") 19 | 20 | if advertisement.manufacturer_data: 21 | mf_data = advertisement.manufacturer_data 22 | print(f" Version: {mf_data.version}") 23 | print(f" Integrations: {mf_data.integrations}") 24 | # print(f" Disconnected: {mf_data.disconnected}") 25 | # print(f" Calibration state: {mf_data.calibration_state.name}") 26 | # print(f" DFU Active: {mf_data.dfu_active:}") 27 | 28 | print(f" RSSI: {advertisement.rssi} dBm") 29 | 30 | if advertisement.readings: 31 | print("--------------------------------------") 32 | print(f" CO2: {advertisement.readings.co2} pm") 33 | print(f" Temperature: {advertisement.readings.temperature:.01f} \u00b0C") 34 | print(f" Humidity: {advertisement.readings.humidity} %") 35 | print(f" Pressure: {advertisement.readings.pressure:.01f} hPa") 36 | print(f" Battery: {advertisement.readings.battery} %") 37 | print(f" Status disp.: {advertisement.readings.status.name}") 38 | print(f" Ago: {advertisement.readings.ago} s") 39 | print() 40 | 41 | async def main(argv): 42 | scanner = Aranet4Scanner(on_scan) 43 | await scanner.start() 44 | while True: # Run forever 45 | await asyncio.sleep(1) 46 | await scanner.stop() 47 | 48 | if __name__== "__main__": 49 | try: 50 | asyncio.run(main(sys.argv[1:])) 51 | except KeyboardInterrupt: 52 | print("User interupted.") 53 | -------------------------------------------------------------------------------- /examples/scanner/scanner_simple.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import aranet4 4 | 5 | scanned_devices = {} 6 | 7 | def on_scan(advertisement): 8 | if advertisement.device.address not in scanned_devices: 9 | print(f"Found device: {advertisement.device.name}") 10 | 11 | scanned_devices[advertisement.device.address] = advertisement 12 | 13 | def print_advertisement(advertisement): 14 | print("=======================================") 15 | print(f" Name: {advertisement.device.name}") 16 | print(f" Model: {advertisement.readings.type.model}") 17 | print(f" Address: {advertisement.device.address}") 18 | 19 | if advertisement.manufacturer_data: 20 | mf_data = advertisement.manufacturer_data 21 | print(f" Version: {mf_data.version}") 22 | print(f" Integrations: {mf_data.integrations}") 23 | # print(f" Disconnected: {mf_data.disconnected}") 24 | # print(f" Calibration: {mf_data.calibration_state.name}") 25 | # print(f" DFU Active: {mf_data.dfu_active:}") 26 | 27 | print(f" RSSI: {advertisement.rssi} dBm") 28 | 29 | if advertisement.readings: 30 | readings = advertisement.readings 31 | print("---------------------------------------") 32 | print(f" CO2: {readings.co2} pm") 33 | print(f" Temperature: {readings.temperature:.01f} \u00b0C") 34 | print(f" Humidity: {readings.humidity} %") 35 | print(f" Pressure: {readings.pressure:.01f} hPa") 36 | print(f" Battery: {readings.battery} %") 37 | print(f" Status Display: {readings.status.name}") 38 | print(f" Age: {readings.ago}/{readings.interval} s") 39 | print() 40 | 41 | 42 | def main(argv): 43 | # Scan for 10 seconds, then print results 44 | print("Looking for Aranet devices...") 45 | print() 46 | aranet4.client.find_nearby(on_scan, 10) 47 | print(f"\nFound {len(scanned_devices)} devices:") 48 | 49 | for addr in scanned_devices: 50 | print() 51 | advertisement = scanned_devices[addr] 52 | print_advertisement(advertisement) 53 | 54 | if __name__== "__main__": 55 | main(sys.argv[1:]) 56 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = . 3 | testpaths = 4 | tests 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open(file="README.md", mode="r", encoding="utf-8") as file: 4 | long_description = file.read() 5 | 6 | setuptools.setup( 7 | name="aranet4", 8 | version="2.5.1", 9 | description="Aranet Python client", 10 | long_description=long_description, 11 | long_description_content_type="text/markdown", 12 | url="https://github.com/Anrijs/Aranet4-Python", 13 | packages=setuptools.find_packages(), 14 | classifiers=[ 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent" 19 | ], 20 | install_requires=[ 21 | "bleak>=1.0.1", 22 | "requests" 23 | ], 24 | entry_points={ 25 | "console_scripts": ["aranetctl=aranet4.aranetctl:entry_point"] 26 | } 27 | ) 28 | -------------------------------------------------------------------------------- /tests/data/aranet4_readings.csv: -------------------------------------------------------------------------------- 1 | date,co2,temperature,humidity,pressure,rad_dose,rad_dose_rate,rad_dose_total,radon_concentration 2 | 2022-02-15 05:34:28,830,17.95,54,1009.1,19,120,5422,8 3 | 2022-02-15 05:39:28,843,17.95,54,1009.0,5,120,5427,22 4 | 2022-02-15 05:44:28,900,18.1,55,1009.0,10,110,5437,11 5 | 2022-02-15 05:49:28,927,18.25,55,1008.9000000000001,14,110,5451,7 6 | 2022-02-15 05:54:28,965,18.400000000000002,55,1008.7,5,100,5456,1 7 | 2022-02-15 05:59:28,1011,18.5,55,1008.5,19,120,5475,0 8 | 2022-02-15 06:04:28,1011,18.6,55,1008.5,19,130,5494,4 9 | 2022-02-15 06:09:28,1012,18.7,55,1008.6,14,140,5509,5 10 | 2022-02-15 06:14:28,1035,18.75,55,1008.5,14,140,5523,1 11 | 2022-02-15 06:19:28,1045,18.8,55,1008.4000000000001,34,190,5556,8 12 | 2022-02-15 06:24:28,1058,18.85,55,1008.4000000000001,10,170,5566,12 13 | 2022-02-15 06:29:28,1073,18.900000000000002,55,1008.2,5,170,5571,15 14 | 2022-02-15 06:34:28,1085,19.0,56,1008.2,19,190,5590,32 15 | 2022-02-15 06:39:28,1094,19.05,55,1008.2,5,170,5595,7 16 | -------------------------------------------------------------------------------- /tests/test_advertisements.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from bleak.backends.scanner import AdvertisementData 4 | from bleak.backends.device import BLEDevice 5 | 6 | from aranet4.client import Aranet4Advertisement 7 | from aranet4.client import AranetType 8 | 9 | def fake_ad_data(name, service_uuid, manufacturer_data, address="00:11:22:33:44:55"): 10 | """Return a BluetoothServiceInfoBleak for use in testing.""" 11 | 12 | ad_data = AdvertisementData( 13 | local_name=name, 14 | manufacturer_data=manufacturer_data, 15 | service_data={}, 16 | service_uuids=[service_uuid], 17 | rssi=-60, 18 | tx_power=-127, 19 | platform_data=() 20 | ) 21 | 22 | device = BLEDevice(address=address, name=name, details=None) 23 | 24 | return { 25 | "ad_data": ad_data, 26 | "device": device 27 | } 28 | 29 | TEST_DATA_ARANET_4 = { 30 | "name": "Aranet4 12345", 31 | "uuid": "f0cd1400-95da-4f4b-9ac8-aa55d312af0c", 32 | "manufacturer_data": {1794: b"!\x05\x03\x01\x00\x05\x00\x01C\x04\x9f\x01\x8b\'5\x0c\x02<\x00\x10\x00U"}, 33 | "manufacturer_data_short": {1794: b"!\x05\x03\x01\x00\x05\x00\x01C\x04\x9f\x01\x8b\'5\x0c\x02<\x00\x10"}, 34 | "manufacturer_data_old_fw": {1794: b"\x21\x0a\x04\x00\x00\x00\x00"}, 35 | "manufacturer_data_no_integrations": {1794: b"\x01\x00\x02\x01\x00\x00\x00"}, 36 | "manufacturer_data_bad": {1794: b"\x01\x00\x02"} 37 | } 38 | 39 | TEST_DATA_ARANET_2 = { 40 | "name": "Aranet2 278F8", 41 | "uuid": "f0cd1400-95da-4f4b-9ac8-aa55d312af0c", 42 | "manufacturer_data": {1794: b"\x01!\x04\x04\x01\x00\x00\x00\x00\x00\x99\x01\x00\x00\n\x02\x00;\x09x\x00R\x00d"}, 43 | "manufacturer_data_no_integrations": {1794: b"\x01\x01\x04\x04\x01\x00\x00\x00\x00\x00\x99\x01\x00\x00\n\x02\x00;\x00x\x00R\x00d"} 44 | } 45 | 46 | TEST_DATA_ARANET_RADIATION = { 47 | "name": "Aranet\u2622 27DB3", 48 | "uuid": "f0cd1400-95da-4f4b-9ac8-aa55d312af0c", 49 | "manufacturer_data": {1794: b"\x02!&\x04\x01\x00\xd03\x00\x00l`\x06\x00\x82\x00\x00c\x00,\x01X\x00r"}, 50 | "manufacturer_data_no_integrations": {1794: b"\x02\x01&\x04\x01\x00\xd03\x00\x00l`\x06\x00\x82\x00\x00c\x00,\x01X\x00r"} 51 | } 52 | 53 | TEST_DATA_ARANET_RADON = { 54 | "name": "AranetRn 298C9", 55 | "uuid": "f0cd1400-95da-4f4b-9ac8-aa55d312af0c", 56 | "manufacturer_data": {1794: b"\x03!\x04\x06\x01\x00\x00\x00\x07\x00\xfe\x01\xc9\'\xce\x01\x00d\x01X\x02\xf6\x01\x08"} 57 | } 58 | 59 | 60 | class DataManipulation(unittest.TestCase): 61 | # --------------------------------------------- 62 | # Test data validation helpers 63 | # --------------------------------------------- 64 | 65 | def _test_aranet_4(self, data): 66 | ad = Aranet4Advertisement(data["device"], data["ad_data"]) 67 | 68 | self.assertTrue(ad.manufacturer_data.integrations) 69 | self.assertEqual("v1.3.5", str(ad.manufacturer_data.version)) 70 | 71 | self.assertEqual(AranetType.ARANET4, ad.readings.type) 72 | self.assertEqual("Aranet4", ad.readings.type.model) 73 | self.assertEqual(1091, ad.readings.co2) 74 | self.assertEqual(20.8, ad.readings.temperature) 75 | self.assertEqual(53, ad.readings.humidity) 76 | self.assertEqual(1012.3, ad.readings.pressure) 77 | self.assertEqual(12, ad.readings.battery) 78 | self.assertEqual(16, ad.readings.ago) 79 | self.assertEqual(60, ad.readings.interval) 80 | 81 | def _test_aranet_2(self, data): 82 | ad = Aranet4Advertisement(data["device"], data["ad_data"]) 83 | 84 | self.assertTrue(ad.manufacturer_data.integrations) 85 | self.assertEqual("v1.4.4", str(ad.manufacturer_data.version)) 86 | 87 | self.assertEqual(AranetType.ARANET2, ad.readings.type) 88 | self.assertEqual("Aranet2", ad.readings.type.model) 89 | self.assertEqual(20.5, ad.readings.temperature) 90 | self.assertEqual(52.2, ad.readings.humidity) 91 | self.assertEqual(59, ad.readings.battery) 92 | self.assertEqual(82, ad.readings.ago) 93 | self.assertEqual("OVER", ad.readings.status_temperature.name) 94 | self.assertEqual("UNDER", ad.readings.status_humidity.name) 95 | 96 | def _test_aranet_radiation(self, data): 97 | ad = Aranet4Advertisement(data["device"], data["ad_data"]) 98 | 99 | self.assertTrue(ad.manufacturer_data.integrations) 100 | self.assertEqual("v1.4.38", str(ad.manufacturer_data.version)) 101 | 102 | self.assertEqual(AranetType.ARANET_RADIATION, ad.readings.type) 103 | self.assertEqual("Aranet Radiation", ad.readings.type.model) 104 | self.assertEqual(130, ad.readings.radiation_rate) 105 | self.assertEqual(13264, ad.readings.radiation_total) 106 | self.assertEqual(417900, ad.readings.radiation_duration) 107 | self.assertEqual(99, ad.readings.battery) 108 | self.assertEqual(88, ad.readings.ago) 109 | self.assertEqual(300, ad.readings.interval) 110 | 111 | def _test_aranet_radon(self, data): 112 | ad = Aranet4Advertisement(data["device"], data["ad_data"]) 113 | 114 | self.assertTrue(ad.manufacturer_data.integrations) 115 | self.assertEqual("v1.6.4", str(ad.manufacturer_data.version)) 116 | 117 | self.assertEqual(AranetType.ARANET_RADON, ad.readings.type) 118 | self.assertEqual("Aranet Radon Plus", ad.readings.type.model) 119 | self.assertEqual(25.5, ad.readings.temperature) 120 | self.assertEqual(46.2, ad.readings.humidity) 121 | self.assertEqual(1018.5, ad.readings.pressure) 122 | self.assertEqual(7, ad.readings.radon_concentration) 123 | self.assertEqual(100, ad.readings.battery) 124 | self.assertEqual(502, ad.readings.ago) 125 | self.assertEqual(600, ad.readings.interval) 126 | # --------------------------------------------- 127 | # Test variations 128 | # --------------------------------------------- 129 | 130 | def test_aranet_4(self): 131 | srcdata = TEST_DATA_ARANET_4 132 | self._test_aranet_4(fake_ad_data( 133 | srcdata["name"], 134 | srcdata["uuid"], 135 | srcdata["manufacturer_data"] 136 | )) 137 | 138 | def test_aranet_4_noname(self): 139 | srcdata = TEST_DATA_ARANET_4 140 | self._test_aranet_4(fake_ad_data( 141 | None, 142 | srcdata["uuid"], 143 | srcdata["manufacturer_data"] 144 | )) 145 | 146 | def test_aranet_4_invalid(self): 147 | data_old = fake_ad_data( 148 | TEST_DATA_ARANET_4["name"], 149 | TEST_DATA_ARANET_4["uuid"], 150 | TEST_DATA_ARANET_4["manufacturer_data_old_fw"] 151 | ) 152 | 153 | data_no_integrations = fake_ad_data( 154 | TEST_DATA_ARANET_4["name"], 155 | TEST_DATA_ARANET_4["uuid"], 156 | TEST_DATA_ARANET_4["manufacturer_data_no_integrations"] 157 | ) 158 | 159 | data_short = fake_ad_data( 160 | TEST_DATA_ARANET_4["name"], 161 | TEST_DATA_ARANET_4["uuid"], 162 | TEST_DATA_ARANET_4["manufacturer_data_short"] 163 | ) 164 | 165 | data_bad = fake_ad_data( 166 | TEST_DATA_ARANET_4["name"], 167 | TEST_DATA_ARANET_4["uuid"], 168 | TEST_DATA_ARANET_4["manufacturer_data_bad"] 169 | ) 170 | 171 | ad_old = Aranet4Advertisement(data_old["device"], data_old["ad_data"]) 172 | self.assertFalse(ad_old.manufacturer_data.integrations) 173 | self.assertEqual("v0.4.10", str(ad_old.manufacturer_data.version)) 174 | self.assertFalse(ad_old.readings) 175 | 176 | ad_no_integrations = Aranet4Advertisement(data_no_integrations["device"], data_no_integrations["ad_data"]) 177 | self.assertFalse(ad_no_integrations.manufacturer_data.integrations) 178 | self.assertEqual("v1.2.0", str(ad_no_integrations.manufacturer_data.version)) 179 | self.assertFalse(ad_no_integrations.readings) 180 | 181 | ad_short = Aranet4Advertisement(data_short["device"], data_short["ad_data"]) 182 | self.assertFalse(ad_short.manufacturer_data.integrations) 183 | self.assertEqual("v1.3.5", str(ad_short.manufacturer_data.version)) 184 | self.assertFalse(ad_short.readings) 185 | 186 | ad_bad = Aranet4Advertisement(data_bad["device"], data_bad["ad_data"]) 187 | self.assertFalse(ad_bad.manufacturer_data) 188 | 189 | def test_aranet_2_invalid(self): 190 | data_no_integrations = fake_ad_data( 191 | TEST_DATA_ARANET_2["name"], 192 | TEST_DATA_ARANET_2["uuid"], 193 | TEST_DATA_ARANET_2["manufacturer_data_no_integrations"] 194 | ) 195 | 196 | ad_no_integrations = Aranet4Advertisement(data_no_integrations["device"], data_no_integrations["ad_data"]) 197 | self.assertFalse(ad_no_integrations.manufacturer_data.integrations) 198 | self.assertEqual("v1.4.4", str(ad_no_integrations.manufacturer_data.version)) 199 | self.assertFalse(ad_no_integrations.readings) 200 | 201 | def test_aranet_radiation_invalid(self): 202 | data_no_integrations = fake_ad_data( 203 | TEST_DATA_ARANET_RADIATION["name"], 204 | TEST_DATA_ARANET_RADIATION["uuid"], 205 | TEST_DATA_ARANET_RADIATION["manufacturer_data_no_integrations"] 206 | ) 207 | 208 | ad_no_integrations = Aranet4Advertisement(data_no_integrations["device"], data_no_integrations["ad_data"]) 209 | self.assertFalse(ad_no_integrations.manufacturer_data.integrations) 210 | self.assertEqual("v1.4.38", str(ad_no_integrations.manufacturer_data.version)) 211 | self.assertFalse(ad_no_integrations.readings) 212 | 213 | def test_aranet_2(self): 214 | srcdata = TEST_DATA_ARANET_2 215 | self._test_aranet_2(fake_ad_data( 216 | srcdata["name"], 217 | srcdata["uuid"], 218 | srcdata["manufacturer_data"] 219 | )) 220 | 221 | def test_aranet_2_noname(self): 222 | srcdata = TEST_DATA_ARANET_2 223 | self._test_aranet_2(fake_ad_data( 224 | srcdata["name"], 225 | srcdata["uuid"], 226 | srcdata["manufacturer_data"] 227 | )) 228 | 229 | def test_aranet_radiation(self): 230 | srcdata = TEST_DATA_ARANET_RADIATION 231 | self._test_aranet_radiation(fake_ad_data( 232 | srcdata["name"], 233 | srcdata["uuid"], 234 | srcdata["manufacturer_data"] 235 | )) 236 | 237 | def test_aranet_radiation_noname(self): 238 | srcdata = TEST_DATA_ARANET_RADIATION 239 | self._test_aranet_radiation(fake_ad_data( 240 | None, 241 | srcdata["uuid"], 242 | srcdata["manufacturer_data"] 243 | )) 244 | 245 | def test_aranet_radon(self): 246 | srcdata = TEST_DATA_ARANET_RADON 247 | self._test_aranet_radon(fake_ad_data( 248 | srcdata["name"], 249 | srcdata["uuid"], 250 | srcdata["manufacturer_data"] 251 | )) 252 | 253 | def test_aranet_radon_noname(self): 254 | srcdata = TEST_DATA_ARANET_RADON 255 | self._test_aranet_radon(fake_ad_data( 256 | None, 257 | srcdata["uuid"], 258 | srcdata["manufacturer_data"] 259 | )) 260 | 261 | if __name__ == "__main__": 262 | unittest.main() 263 | -------------------------------------------------------------------------------- /tests/test_csv.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import difflib 3 | from pathlib import Path 4 | import tempfile 5 | import unittest 6 | 7 | from aranet4 import client 8 | from aranet4 import aranetctl 9 | 10 | here = Path(__file__).parent 11 | data_file = here.joinpath("data", "aranet4_readings.csv") 12 | 13 | 14 | def build_data(): 15 | log_filter = client.Filter(1, 14, True, True, True, True, True, True, True, True) 16 | records = client.Record("mock_device", "v1234", 14, log_filter) 17 | with open(file=data_file, mode="r", encoding="utf-8") as csv_file: 18 | reader = csv.DictReader(csv_file) 19 | for row in reader: 20 | records.value.append(client.RecordItem(**row)) 21 | return records 22 | 23 | 24 | class CSVCreation(unittest.TestCase): 25 | def setUp(self): 26 | # Create data object 27 | self.records = build_data() 28 | # Create a temporary directory 29 | self.test_file = tempfile.NamedTemporaryFile(delete=True) 30 | 31 | def tearDown(self): 32 | self.test_file.close() 33 | 34 | def test_simple_write(self): 35 | aranetctl.write_csv(self.test_file.name, self.records) 36 | ref = data_file.read_text(encoding="utf-8").splitlines(keepends=False) 37 | new = Path(self.test_file.name).read_text(encoding="utf-8").splitlines(keepends=False) 38 | cmp_result = list( 39 | difflib.context_diff(ref, new, fromfile="reference", tofile="test output") 40 | ) 41 | self.assertListEqual([], cmp_result) 42 | 43 | if __name__ == "__main__": 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /tests/test_values.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import io 3 | import unittest 4 | from unittest import mock 5 | 6 | from aranet4 import client 7 | from aranet4 import aranetctl 8 | from aranet4.client import AranetType 9 | 10 | result = client.CurrentReading( 11 | name="Aranet4 1234Z", 12 | version="v0.4.4", 13 | type=AranetType.ARANET4, 14 | temperature=21.75, 15 | humidity=48, 16 | pressure=1016.5, 17 | co2=933, 18 | battery=93, 19 | status=client.Color.GREEN, 20 | interval=300, 21 | ago=44, 22 | stored=2016 23 | ) 24 | 25 | 26 | device_readings = """======================================= 27 | Name: Aranet4 1234Z 28 | Logs: 2016 29 | --------------------------------------- 30 | CO2: 933 ppm 31 | Temperature: 21.8 °C 32 | Humidity: 48 % 33 | Pressure: 1016.5 hPa 34 | Battery: 93 % 35 | Status Display: GREEN 36 | Age: 44/300 s 37 | 38 | """ 39 | 40 | base_args = dict( 41 | device_mac="11:22:33:44:55:66", 42 | end=None, 43 | last=None, 44 | output=None, 45 | records=False, 46 | scan=False, 47 | set_btrange=None, 48 | set_integrations=None, 49 | set_interval=None, 50 | start=None, 51 | url=None, 52 | wait=False, 53 | co2=True, 54 | humi=True, 55 | pres=True, 56 | temp=True 57 | ) 58 | 59 | 60 | class DataManipulation(unittest.TestCase): 61 | def test_current_values(self): 62 | client.get_current_readings = mock.MagicMock(return_value=result) 63 | with unittest.mock.patch("sys.stdout", new=io.StringIO()) as fake_out: 64 | aranetctl.main(["C7:18:1E:21:F4:87"]) 65 | self.assertEqual(device_readings, fake_out.getvalue()) 66 | 67 | def test_parse_args1(self): 68 | expected = base_args.copy() 69 | args = aranetctl.parse_args(["11:22:33:44:55:66"]) 70 | self.assertDictEqual(expected, args.__dict__) 71 | 72 | def test_parse_args2(self): 73 | expected = base_args.copy() 74 | expected["records"] = True 75 | args = aranetctl.parse_args("11:22:33:44:55:66 -r".split()) 76 | self.assertDictEqual(expected, args.__dict__) 77 | 78 | def test_parse_args3(self): 79 | expected = base_args.copy() 80 | expected["records"] = True 81 | expected["last"] = 30 82 | args = aranetctl.parse_args("11:22:33:44:55:66 -r -l 30".split()) 83 | self.assertDictEqual(expected, args.__dict__) 84 | 85 | def test_parse_args4(self): 86 | expected = base_args.copy() 87 | expected["records"] = True 88 | expected["start"] = datetime.datetime(2022, 2, 14, 15, 16) 89 | expected["end"] = datetime.datetime(2022, 2, 17, 18, 19) 90 | args = aranetctl.parse_args( 91 | "11:22:33:44:55:66 -r -s 2022-02-14T15:16 -e 2022-02-17T18:19".split() 92 | ) 93 | self.assertDictEqual(expected, args.__dict__) 94 | 95 | def test_parse_args5(self): 96 | expected = base_args.copy() 97 | expected["records"] = True 98 | expected["temp"] = False 99 | args = aranetctl.parse_args("11:22:33:44:55:66 -r --xt".split()) 100 | self.assertDictEqual(expected, args.__dict__) 101 | 102 | def test_calc_log_last_n(self): 103 | mock_points = [datetime.datetime.now(datetime.timezone.utc)] * 200 104 | start, end = client._calc_start_end(mock_points, {"last": 20}) 105 | # Requested numbers are inclusive so difference is 19 although 106 | # 20 data points have been requested 107 | self.assertEqual(181, start) 108 | self.assertEqual(200, end) 109 | self.assertEqual(19, end - start) 110 | 111 | def test_log_times_1(self): 112 | log_records = 13 113 | log_interval = 300 114 | expected = [] 115 | expected_start = datetime.datetime(2000, 10, 11, 22, 59, 10) 116 | for idx in range(log_records): 117 | expected.append( 118 | expected_start + datetime.timedelta(seconds=log_interval * idx) 119 | ) 120 | 121 | now = datetime.datetime(2000, 10, 11, 23, 59, 30) 122 | times = client._log_times(now, log_records, log_interval, 20) 123 | self.assertListEqual(expected, times) 124 | 125 | 126 | if __name__ == "__main__": 127 | unittest.main() 128 | --------------------------------------------------------------------------------