├── .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 | from time import sleep 8 | 9 | import requests 10 | 11 | from aranet4 import client 12 | 13 | def parse_args(ctl_args): 14 | parser = argparse.ArgumentParser() 15 | parser.add_argument("device_mac", nargs="?", help="Aranet Bluetooth Address") 16 | parser.add_argument( 17 | "--scan", action="store_true", help="Scan for Aranet devices" 18 | ) 19 | current = parser.add_argument_group("Options for current reading") 20 | current.add_argument( 21 | "-u", "--url", metavar="URL", help="Remote url for current value push" 22 | ) 23 | parser.add_argument( 24 | "-r", "--records", action="store_true", help="Fetch historical log records" 25 | ) 26 | history = parser.add_argument_group("Filter History Log Records") 27 | history.add_argument( 28 | "-s", 29 | "--start", 30 | metavar="DATE", 31 | type=datetime.datetime.fromisoformat, 32 | help="Records range start (UTC time, example: 2019-09-29T14:00:00", 33 | ) 34 | history.add_argument( 35 | "-e", 36 | "--end", 37 | metavar="DATE", 38 | type=datetime.datetime.fromisoformat, 39 | help="Records range end (UTC time, example: 2019-09-30T14:00:00", 40 | ) 41 | history.add_argument( 42 | "-o", "--output", metavar="FILE", type=Path, help="Save records to a file" 43 | ) 44 | history.add_argument( 45 | "-w", 46 | "--wait", 47 | action="store_true", 48 | default=False, 49 | help="Wait until new data point available", 50 | ) 51 | history.add_argument( 52 | "-l", "--last", metavar="COUNT", type=int, help="Get last records" 53 | ) 54 | history.add_argument( 55 | "--xt", 56 | dest="temp", 57 | default=True, 58 | action="store_false", 59 | help="Don't get temperature records", 60 | ) 61 | history.add_argument( 62 | "--xh", 63 | dest="humi", 64 | default=True, 65 | action="store_false", 66 | help="Don't get humidity records", 67 | ) 68 | history.add_argument( 69 | "--xp", 70 | dest="pres", 71 | default=True, 72 | action="store_false", 73 | help="Don't get pressure records", 74 | ) 75 | history.add_argument( 76 | "--xc", 77 | dest="co2", 78 | default=True, 79 | action="store_false", 80 | help="Don't get co2 records", 81 | ) 82 | settings = parser.add_argument_group("Change device settings") 83 | settings.add_argument( 84 | "--set-interval", 85 | dest="set_interval", 86 | metavar="MINUTES", 87 | type=int, 88 | help="Change update interval" 89 | ) 90 | settings.add_argument( 91 | "--set-integrations", 92 | dest="set_integrations", 93 | type=str, 94 | choices=["on", "off"], 95 | help="Toggle Smart Home Integrations" 96 | ) 97 | settings.add_argument( 98 | "--set-range", 99 | dest="set_btrange", 100 | type=str, 101 | choices=["normal", "extended"], 102 | help="Change bluetooth range" 103 | ) 104 | 105 | return parser.parse_args(ctl_args) 106 | 107 | 108 | def print_records(records): 109 | """Format log records to be printed to screen""" 110 | char_repeat = 34 111 | if records.filter.incl_co2: 112 | char_repeat += 9 113 | if records.filter.incl_temperature: 114 | char_repeat += 7 115 | if records.filter.incl_humidity: 116 | char_repeat += 8 117 | if records.filter.incl_pressure: 118 | char_repeat += 11 119 | if records.filter.incl_rad_dose_rate: 120 | char_repeat += 11 121 | if records.filter.incl_rad_dose: 122 | char_repeat += 11 123 | if records.filter.incl_rad_dose_total: 124 | char_repeat += 12 125 | if records.filter.incl_radon_concentration: 126 | char_repeat += 8 127 | print("-" * char_repeat) 128 | print(f"{'Device Name':<15}: {records.name:>20}") 129 | print(f"{'Device Version':<15}: {records.version:>20}") 130 | print("-" * char_repeat) 131 | print(f"{'id': ^4} | {'date': ^25} |", end="") 132 | if records.filter.incl_co2: 133 | print(f" {'co2':^6} |", end="") 134 | if records.filter.incl_temperature: 135 | print(" temp |", end="") 136 | if records.filter.incl_humidity: 137 | print(" humid |", end="") 138 | if records.filter.incl_pressure: 139 | print(" pressure |", end="") 140 | if records.filter.incl_rad_dose: 141 | print(" rad_dose |", end="") 142 | if records.filter.incl_rad_dose_rate: 143 | print(" rad_rate |", end="") 144 | if records.filter.incl_rad_dose_total: 145 | print(" rad_total |", end="") 146 | if records.filter.incl_radon_concentration: 147 | print(f" {'radon':^5} |", end="") 148 | print("") 149 | print("-" * char_repeat) 150 | 151 | for record_id, line in enumerate(records.value, start=records.filter.begin): 152 | print(f"{record_id:>4d} | {line.date.isoformat()} |", end="") 153 | if records.filter.incl_co2: 154 | print(f" {line.co2:>6d} |", end="") 155 | if records.filter.incl_temperature: 156 | print(f" {line.temperature:>4.1f} |", end="") 157 | if records.filter.incl_humidity: 158 | print(f" {line.humidity:>5.1f} |", end="") 159 | if records.filter.incl_pressure: 160 | print(f" {line.pressure:>8.1f} |", end="") 161 | if records.filter.incl_rad_dose: 162 | print(f" {line.rad_dose/1000:>8.3f} |", end="") 163 | if records.filter.incl_rad_dose_rate: 164 | print(f" {line.rad_dose_rate/1000:>8.3f} |", end="") 165 | if records.filter.incl_rad_dose_total: 166 | print(f" {line.rad_dose_total/1000000:>9.4f} |", end="") 167 | if records.filter.incl_radon_concentration: 168 | print(f" {line.radon_concentration:>5d} |", end="") 169 | print("") 170 | print("-" * char_repeat) 171 | 172 | 173 | def store_scan_result(advertisement): 174 | global found 175 | if not advertisement.device: 176 | return 177 | 178 | found[advertisement.device.address] = advertisement 179 | 180 | def write_csv(filename, log_data): 181 | """ 182 | Output `client.Record` dataclass to csv file 183 | :param filename: file name 184 | :param log_data: `client.Record` data object 185 | """ 186 | with open(file=filename, mode="w", encoding="utf-8", newline="") as csv_file: 187 | fieldnames = ["date"] 188 | if log_data.filter.incl_co2: 189 | fieldnames.append("co2") 190 | if log_data.filter.incl_temperature: 191 | fieldnames.append("temperature") 192 | if log_data.filter.incl_humidity: 193 | fieldnames.append("humidity") 194 | if log_data.filter.incl_pressure: 195 | fieldnames.append("pressure") 196 | if log_data.filter.incl_rad_dose: 197 | fieldnames.append("rad_dose") 198 | if log_data.filter.incl_rad_dose_rate: 199 | fieldnames.append("rad_dose_rate") 200 | if log_data.filter.incl_rad_dose_total: 201 | fieldnames.append("rad_dose_total") 202 | if log_data.filter.incl_radon_concentration: 203 | fieldnames.append("radon_concentration") 204 | writer = csv.DictWriter(csv_file, fieldnames=fieldnames, extrasaction="ignore") 205 | 206 | writer.writeheader() 207 | 208 | for line in log_data.value: 209 | writer.writerow(asdict(line)) 210 | 211 | 212 | def post_data(url, current): 213 | # get current measurement minute 214 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 215 | delta_ago = datetime.timedelta(seconds=current.ago) 216 | t = now - delta_ago 217 | t = t.replace(second=0) # epoch, floored to minutes 218 | data = current.toDict() 219 | data["time"] = t.timestamp() 220 | r = requests.post( 221 | url=url, 222 | data=data, 223 | timeout=300 224 | ) 225 | print(f"Pushing data: {r.text}") 226 | 227 | 228 | def wait_for_new_record(address): 229 | current_vals = client.get_current_readings(address) 230 | wait_time = current_vals.interval - current_vals.ago 231 | for secs in range(wait_time, 0, -1): 232 | sleep(1) 233 | print(f"Next data point in {secs}...", end="\r") 234 | 235 | 236 | def main(argv): 237 | global found 238 | found = {} 239 | args = parse_args(argv) 240 | 241 | if args.scan: 242 | print("Looking for Aranet devices...") 243 | devices = client.find_nearby(store_scan_result) 244 | print(f"Scan finished. Found {len(devices)}") 245 | print() 246 | for _, advertisement in found.items(): 247 | if advertisement.readings: 248 | print(advertisement.readings.toString(advertisement)) 249 | else: 250 | print("=======================================") 251 | print(f" Name: {advertisement.device.name}") 252 | print(f" Address: {advertisement.device.address}") 253 | print(f" RSSI: {advertisement.rssi} dBm") 254 | print() 255 | print() 256 | 257 | return 258 | 259 | if not args.device_mac: 260 | print("Device address not specified") 261 | return 262 | 263 | if args.records: 264 | if args.wait: 265 | wait_for_new_record(args.device_mac) 266 | records = client.get_all_records(args.device_mac, vars(args), True) 267 | print_records(records) 268 | if args.output: 269 | write_csv(args.output, records) 270 | else: 271 | settings = {} 272 | 273 | if args.set_interval: 274 | settings["interval"] = args.set_interval 275 | 276 | if args.set_integrations: 277 | settings["integrations"] = args.set_integrations 278 | 279 | if args.set_btrange: 280 | settings["range"] = args.set_btrange 281 | 282 | if settings: 283 | result = client.set_settings(args.device_mac, settings, True) 284 | for k in result: 285 | val = settings[k] 286 | ret = "SUCCESS" if result[k] else "FAILED" 287 | print(f"Set {k} to \"{val}\": {ret}") 288 | else: 289 | current = client.get_current_readings(args.device_mac) 290 | print(current.toString()) 291 | if args.url: 292 | post_data(args.url, current) 293 | 294 | 295 | def entry_point(): 296 | main(argv=sys.argv[1:]) 297 | 298 | 299 | if __name__ == "__main__": 300 | entry_point() 301 | -------------------------------------------------------------------------------- /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 | class Color(IntEnum): 35 | """Enum for the different status colors""" 36 | 37 | ERROR = 0 38 | GREEN = 1 39 | YELLOW = 2 40 | RED = 3 41 | 42 | class Status(IntEnum): 43 | """Enum for the different status alerts""" 44 | 45 | OFF = 0 46 | UNDER = 1 47 | OVER = 2 48 | DASH = 3 49 | 50 | class AranetType(IntEnum): 51 | """Enum for the different Aranet devices""" 52 | 53 | ARANET4 = 0 54 | ARANET2 = 1 55 | ARANET_RADIATION = 2 56 | ARANET_RADON = 3 57 | UNKNOWN = 255 58 | 59 | @property 60 | def model(self): 61 | description = { 62 | AranetType.ARANET4: "Aranet4", 63 | AranetType.ARANET2: "Aranet2", 64 | AranetType.ARANET_RADIATION: "Aranet Radiation", 65 | AranetType.ARANET_RADON: "Aranet Radon Plus", 66 | AranetType.UNKNOWN: "Unknown Aranet Device" 67 | } 68 | return description.get(self, "Unknown Aranet Device") 69 | 70 | @dataclass 71 | class Aranet4HistoryDelegate: 72 | """ 73 | When collecting the historical records they are sent using BLE 74 | notifications. This class takes those notifications and presents them 75 | as a Python dataclass 76 | """ 77 | 78 | handle: str 79 | param: Param 80 | size: int 81 | client: object 82 | result: list = field(default_factory=list) 83 | 84 | def __post_init__(self): 85 | self.result = _empty_reading(self.size) 86 | 87 | def handle_notification(self, sender: int, packet: bytes): 88 | """ 89 | Method to use with Bleak's `start_notify` function. 90 | Takes data returned and process it before storing 91 | """ 92 | data_type, start, count = struct.unpack(" self.size or count == 0: 94 | self.client.reading = False 95 | return 96 | 97 | if self.param != data_type: 98 | ( 99 | print(f"ERROR: invalid parameter. Got {data_type:02X}, expected {self.param:02X}") 100 | ) 101 | return 102 | pattern = " 0: 156 | ret += f" Logs: {self.stored}\n" 157 | 158 | ret += "---------------------------------------\n" 159 | 160 | if self.type == AranetType.ARANET4: 161 | ret += f" CO2: {self.co2} ppm\n" 162 | ret += f" Temperature: {self.temperature:.01f} \u00b0C\n" 163 | ret += f" Humidity: {self.humidity} %\n" 164 | ret += f" Pressure: {self.pressure:.01f} hPa\n" 165 | ret += f" Battery: {self.battery} %\n" 166 | ret += f" Status Display: {self.status.name}\n" 167 | ret += f" Age: {self.ago}/{self.interval} s\n" 168 | elif self.type == AranetType.ARANET2: 169 | ret += f" Temperature: {self.temperature:.01f} \u00b0C\n" 170 | ret += f" Humidity: {self.humidity} %\n" 171 | ret += f" Battery: {self.battery} %\n" 172 | ret += f" Status Temp.: {self.status_temperature.name}\n" 173 | ret += f" Status Humid.: {self.status_humidity.name}\n" 174 | ret += f" Age: {self.ago}/{self.interval} s\n" 175 | elif self.type == AranetType.ARANET_RADIATION: 176 | m = math.floor(self.radiation_duration / 60 % 60) 177 | h = math.floor(self.radiation_duration / 3600 % 24) 178 | d = math.floor(self.radiation_duration / 86400) 179 | 180 | dose_duration = str(m) + "m" 181 | if h > 0: 182 | dose_duration = str(h) + "h " + dose_duration 183 | if d > 0: 184 | dose_duration = str(d) + "d " + dose_duration 185 | 186 | ret += f" Dose rate: {self.radiation_rate/1000:.02f} uSv/h\n" 187 | ret += f" Dose total: {self.radiation_total/1000000:.04f} mSv/{dose_duration}\n" 188 | ret += f" Battery: {self.battery} %\n" 189 | ret += f" Age: {self.ago}/{self.interval} s\n" 190 | elif self.type == AranetType.ARANET_RADON: 191 | ret += f" Radon Conc.: {self.radon_concentration} Bq/m3\n" 192 | ret += f" Temperature: {self.temperature:.01f} \u00b0C\n" 193 | ret += f" Humidity: {self.humidity} %\n" 194 | ret += f" Pressure: {self.pressure:.01f} hPa\n" 195 | ret += f" Battery: {self.battery} %\n" 196 | ret += f" Status Display: {self.status.name}\n" 197 | ret += f" Age: {self.ago}/{self.interval} s\n" 198 | 199 | else: 200 | ret += " Unknown device type\n" 201 | 202 | return ret 203 | 204 | def toDict(self): 205 | data = { 206 | "battery": self.battery, 207 | "type": self.type.name 208 | } 209 | 210 | if self.type == AranetType.ARANET2: 211 | data["temperature"] = self.temperature 212 | data["humidity"] = self.humidity 213 | elif self.type == AranetType.ARANET4: 214 | data["co2"] = self.co2 215 | data["temperature"] = self.temperature 216 | data["pressure"] = self.pressure 217 | data["humidity"] = self.humidity 218 | elif self.type == AranetType.ARANET_RADIATION: 219 | data["radiation_rate"] = self.radiation_rate 220 | data["radiation_total"] = self.radiation_total 221 | data["radiation_duration"] = self.radiation_duration 222 | 223 | return data 224 | 225 | def decode(self, value: tuple, type: AranetType, gatt=False): 226 | """Process advertisement or gatt data""" 227 | 228 | self.type = type 229 | if type == AranetType.ARANET4: 230 | self._decode_aranet4(value, gatt) 231 | elif type == AranetType.ARANET2: 232 | self._decode_aranet2(value, gatt) 233 | elif type == AranetType.ARANET_RADIATION: 234 | self._decode_aranetR(value, gatt) 235 | elif type == AranetType.ARANET_RADON: 236 | self._decode_aranetRn(value, gatt) 237 | 238 | def _decode_aranet4(self, value: tuple, gatt=False): 239 | """Process Aranet4 data - CO2, Temperature, Humidity, Pressure""" 240 | 241 | self.co2 = self._set(Param.CO2, value[0]) 242 | self.temperature = self._set(Param.TEMPERATURE, value[1]) 243 | self.pressure = self._set(Param.PRESSURE, value[2]) 244 | self.humidity = self._set(Param.HUMIDITY, value[3]) 245 | self.battery = value[4] 246 | self.status = Color(value[5]) 247 | # If extended data list 248 | if len(value) > 6: 249 | self.interval = value[6] 250 | self.ago = value[7] 251 | 252 | def _decode_aranet2(self, value: tuple, gatt=False): 253 | """Process Aranet2 data - Temperature, Humidity""" 254 | 255 | # order from gatt and advertisements are different 256 | if gatt: 257 | self.temperature = self._set(Param.TEMPERATURE, value[4]) 258 | self.humidity = self._set(Param.HUMIDITY2, value[5]) 259 | self.battery = value[3] 260 | self.status_humidity = Status(value[6] & 0b0011) 261 | self.status_temperature = Status((value[6] & 0b1100) >> 2) 262 | self.interval = value[1] 263 | self.ago = value[2] 264 | else: 265 | self.temperature = self._set(Param.TEMPERATURE, value[1]) 266 | self.humidity = self._set(Param.HUMIDITY2, value[3]) 267 | self.status_humidity = Status(value[6] & 0b0011) 268 | self.status_temperature = Status((value[6] & 0b1100) >> 2) 269 | self.battery = value[5] 270 | self.interval = value[7] 271 | self.ago = value[8] 272 | self.counter = value[9] 273 | 274 | def _decode_aranetR(self, value: tuple, gatt=False): 275 | """Process Aranet Radiation data - radiation dose""" 276 | # order from gatt and advertisements are different 277 | if gatt: 278 | self.radiation_duration = value[6] 279 | self.radiation_rate = value[4] 280 | self.radiation_total = value[5] 281 | self.battery = value[3] 282 | self.interval = value[1] 283 | self.ago = value[2] 284 | else: 285 | self.radiation_total = value[0] 286 | self.radiation_duration = value[1] 287 | self.radiation_rate = value[2] 288 | self.battery = value[4] 289 | self.interval = value[6] 290 | self.ago = value[7] 291 | self.counter = value[8] 292 | 293 | def _decode_aranetRn(self, value: tuple, gatt=False): 294 | """Process Aranet Radon data""" 295 | # order from gatt and advertisements are different 296 | if gatt: 297 | self.battery = value[3] 298 | self.temperature = self._set(Param.TEMPERATURE, value[4]) 299 | self.pressure = self._set(Param.PRESSURE, value[5]) 300 | self.humidity = self._set(Param.HUMIDITY2, value[6]) 301 | self.radon_concentration = self._set(Param.RADON_CONCENTRATION, value[7]) 302 | self.status = Color(value[8]) 303 | self.radon_concentration_avg_24h = self._parse_avg_radon(value[9], value[10])["value"] 304 | self.radon_concentration_avg_7d = self._parse_avg_radon(value[11], value[12])["value"] 305 | self.radon_concentration_avg_30d = self._parse_avg_radon(value[13], value[14])["value"] 306 | else: 307 | self.radon_concentration = self._set(Param.RADON_CONCENTRATION, value[0]) 308 | self.temperature = self._set(Param.TEMPERATURE, value[1]) 309 | self.pressure = self._set(Param.PRESSURE, value[2]) 310 | self.humidity = self._set(Param.HUMIDITY2, value[3]) 311 | self.battery = value[5] 312 | self.status = Color(value[6]) 313 | self.interval = value[7] 314 | self.ago = value[8] 315 | self.counter = value[9] 316 | 317 | @staticmethod 318 | def _parse_avg_radon(time, average) -> dict: 319 | inProgress = average >= 0xff000000 320 | progress = -1 321 | value = average 322 | 323 | if inProgress: 324 | progress = average & 0x00FFFFFF 325 | value = -1 326 | 327 | return { 328 | "time": time, 329 | "value": value, 330 | "progress": progress 331 | } 332 | 333 | @staticmethod 334 | def _set(param: Param, value: int): 335 | """ 336 | While in CO2 calibration mode Aranet4 did not take new measurements and 337 | stores Magic numbers in measurement history. 338 | Here data is converted with checking for Magic numbers. 339 | """ 340 | invalid_reading_flag = True 341 | multiplier = 1 342 | if param == Param.CO2: 343 | invalid_reading_flag = value >> 15 == 1 344 | multiplier = 1 345 | elif param == Param.PRESSURE: 346 | invalid_reading_flag = value >> 15 == 1 347 | multiplier = 0.1 348 | elif param == Param.TEMPERATURE: 349 | invalid_reading_flag = value >> 14 & 1 == 1 350 | multiplier = 0.05 351 | elif param == Param.HUMIDITY: 352 | invalid_reading_flag = value >> 8 353 | multiplier = 1 354 | elif param == Param.HUMIDITY2: 355 | invalid_reading_flag = value >> 15 == 1 356 | multiplier = 0.1 357 | elif param == Param.RADIATION_DOSE: 358 | invalid_reading_flag = value >> 15 == 1 359 | multiplier = 1 # nSv 360 | elif param == Param.RADIATION_DOSE_RATE: 361 | invalid_reading_flag = value >> 15 == 1 362 | multiplier = 10 # nSv/h 363 | elif param == Param.RADIATION_DOSE_INTEGRAL: 364 | invalid_reading_flag = value >> 63 == 1 365 | multiplier = 1 # nSv 366 | elif param == Param.RADON_CONCENTRATION: 367 | # 0x1f00 general error 368 | # 0x1f01 no data 369 | # 0x1f02 Hi humidity in sensor chamber 370 | invalid_reading_flag = value >= 0x1f00 371 | multiplier = 1 # Bq/m3 372 | 373 | if invalid_reading_flag: 374 | return -1 375 | if isinstance(multiplier, float): 376 | return round(value * multiplier, 1) 377 | return value * multiplier 378 | 379 | 380 | @dataclass(order=True) 381 | class Version: 382 | major: int = -1 383 | minor: int = -1 384 | patch: int = -1 385 | 386 | def __init__(self, major, minor, patch): 387 | self.major = major 388 | self.minor = minor 389 | self.patch = patch 390 | 391 | def __str__(self): 392 | return f"v{self.major}.{self.minor}.{self.patch}" 393 | 394 | 395 | class CalibrationState(IntEnum): 396 | """Enum for calibration state""" 397 | NOT_ACTIVE = 0 398 | END_REQUEST = 1 399 | IN_PROGRESS = 2 400 | ERROR = 3 401 | 402 | 403 | @dataclass 404 | class ManufacturerData: 405 | """dataclass to store manufacturer data""" 406 | 407 | disconnected: bool = False 408 | calibration_state: CalibrationState = -1 409 | dfu_active: bool = False 410 | integrations: bool = False 411 | version: Version = None 412 | 413 | def decode(self, value: tuple): 414 | self.disconnected = self._get_b(value[0], 0) 415 | self.calibration_state = CalibrationState(self._get_uint2(value[0], 2)) 416 | self.dfu_active = self._get_b(value[0], 4) 417 | self.integrations = self._get_b(value[0], 5) 418 | self.version = Version(value[3], value[2], value[1]) 419 | 420 | def _get_b(self, value, pos): 421 | return (value & (1 << pos)) != 0 422 | 423 | def _get_uint2(self, value, pos): 424 | return (value >> pos) & 0x03 425 | 426 | 427 | @dataclass 428 | class Aranet4Advertisement: 429 | """dataclass to store the information aboud scanned aranet4 device""" 430 | 431 | device: BLEDevice = None 432 | readings: CurrentReading = None 433 | manufacturer_data: ManufacturerData = None 434 | rssi: int = None 435 | 436 | def __init__(self, device = None, ad_data = None): 437 | self.device = device 438 | 439 | if device and ad_data: 440 | has_manufacturer_data = Aranet4.MANUFACTURER_ID in ad_data.manufacturer_data 441 | self.rssi = getattr(ad_data, "rssi", None) 442 | 443 | if has_manufacturer_data: 444 | mf_data = ManufacturerData() 445 | raw_bytes = bytearray(ad_data.manufacturer_data[Aranet4.MANUFACTURER_ID]) 446 | if len(raw_bytes) < 5: 447 | # invalid manufacturer data 448 | return 449 | 450 | # Passive scan may return result with no name. 451 | valid_name = device.name and device.name.startswith(("Aranet4", "Aranet2", "Aranet\u2622", "AranetRn")) 452 | cond_name = valid_name and device.name.startswith("Aranet4") 453 | cond_len = not valid_name and len(raw_bytes) in [7,22] 454 | 455 | if cond_name or cond_len: # Should be Aranet4 456 | raw_bytes.insert(0,0) 457 | 458 | # Basic info 459 | value_fmt = " 0 and len(raw_bytes[:end]) == end: 488 | value = struct.unpack(value_fmt, raw_bytes[:end]) 489 | self.readings = CurrentReading() 490 | self.readings.decode(value, aranetv) 491 | self.readings.name = device.name 492 | else: 493 | mf_data.integrations = False 494 | 495 | 496 | @dataclass 497 | class RecordItem: 498 | """dataclass to store historical records""" 499 | 500 | date: datetime 501 | temperature: float 502 | humidity: int 503 | pressure: float 504 | co2: int 505 | rad_dose: float 506 | rad_dose_rate: float 507 | rad_dose_total: float 508 | radon_concentration: int 509 | 510 | 511 | @dataclass 512 | class Filter: 513 | """dataclass to store log filter information""" 514 | 515 | begin: int 516 | end: int 517 | incl_temperature: bool 518 | incl_humidity: int 519 | incl_pressure: bool 520 | incl_co2: bool 521 | incl_rad_dose: bool 522 | incl_rad_dose_rate: bool 523 | incl_rad_dose_total: bool 524 | incl_radon_concentration: bool 525 | 526 | 527 | @dataclass 528 | class Record: 529 | name: str 530 | version: str 531 | records_on_device: int 532 | filter: Filter 533 | value: List[RecordItem] = field(default_factory=list) 534 | 535 | @dataclass 536 | class SensorState: 537 | """dataclass to store sensor state values""" 538 | 539 | type: AranetType = AranetType.UNKNOWN 540 | buzzerSetting: str = "unknown" 541 | calibrationState: str = "unknown" 542 | calibrationProgress: int = 0 543 | warningPreset: str = "unknown" 544 | isLoRaEnabled: bool = False 545 | temperatureUnit: str = "unknown" 546 | isPulseBeepOn: bool = False 547 | isUsingCustomThreshold: bool = False 548 | isAutomaticCalibrationEnabled: bool = False 549 | radiationDisplayUnits: str = "unknown" 550 | radonDisplayUnits: str = "unknown" 551 | isBuzzerAvailable: bool = False 552 | bluetoothRange: str = "unknown" 553 | isOpenForIntegration: bool = False 554 | 555 | def decode(self, t): 556 | isAranet2 = t[0] == 0xF2 # Aranet2 557 | isAranet4 = t[0] == 0xF1 # Aranet4 558 | isNucleo = t[0] == 0xF4 # Aranet Radiation 559 | isRadon = t[0] == 0xF3 # Aranet Radon 560 | c = format(ord(chr(t[1])), "08b")[::-1] 561 | o = format(ord(chr(t[2])), "08b")[::-1] 562 | 563 | if isAranet4: 564 | self.type = AranetType.ARANET4 565 | elif isAranet2: 566 | self.type = AranetType.ARANET2 567 | elif isNucleo: 568 | self.type = AranetType.ARANET_RADIATION 569 | elif isRadon: 570 | self.type = AranetType.ARANET_RADON 571 | else: 572 | self.type = AranetType.UNKNOWN 573 | 574 | self.buzzerSetting = ( 575 | "none" if isAranet2 else 576 | "off" if not _eval(c[0]) else 577 | "on" if isRadon else 578 | "once" if not _eval(c[1]) else 579 | "each" 580 | ) 581 | 582 | self.calibrationState = self.cond(isAranet4, self.parseCalibrationState(t[1]), "none") 583 | self.calibrationProgress = self.cond(isAranet4, t[3], 0) 584 | self.warningPreset = self.cond(isAranet2, self.cond(t[3] == 1, "ISO", "custom"), "none") 585 | self.isLoRaEnabled = self.cond(c[4], True, False) 586 | self.temperatureUnit = self.cond(isNucleo, "none", self.cond(c[5], "C", "F")) 587 | self.isPulseBeepOn = self.cond(isNucleo, _eval(c[5]), False) 588 | self.isUsingCustomThreshold = c[6] == (isNucleo or isRadon) 589 | self.isAutomaticCalibrationEnabled = isAranet4 and c[7] 590 | self.radiationDisplayUnits = self.cond(isNucleo, self.cond(c[7], "Sv", "rem"), "none") 591 | self.radonDisplayUnits = ( 592 | "none" if not isRadon else 593 | "Bq/m³" if _eval(c[7]) else 594 | "pCi/L" 595 | ) 596 | self.isBuzzerAvailable = self.cond(isNucleo or isRadon, True, isAranet4 and _eval(o[0])) 597 | self.bluetoothRange = self.cond(o[1], "extended", "normal") 598 | self.isOpenForIntegration = _eval(o[7]) 599 | 600 | @staticmethod 601 | def parseCalibrationState(e): 602 | t = format(ord(chr(e)), "08b")[::-1] 603 | return SensorState.cond( 604 | t[3], 605 | SensorState.cond(t[2], "inErrorState", "endRequest"), 606 | SensorState.cond(t[2], "inProgress", "notActive") 607 | ) 608 | 609 | @staticmethod 610 | def cond(check, true_condition, false_condition): 611 | if _eval(check): 612 | return true_condition 613 | return false_condition 614 | 615 | 616 | class HistoryHeader(NamedTuple): 617 | param: int 618 | interval: int 619 | total_readings: int 620 | ago: int 621 | start: int 622 | count: int 623 | 624 | 625 | def _empty_reading(size): 626 | return [-1] * size 627 | 628 | 629 | class Aranet4: 630 | 631 | # Param return value if no data 632 | AR4_NO_DATA_FOR_PARAM = -1 633 | 634 | # Company Identifier (Akciju sabiedriba "SAF TEHNIKA") 635 | MANUFACTURER_ID = 0x0702 636 | 637 | # GAP Service 638 | SERVICE_GAP = normalize_uuid_16(0x1800) 639 | 640 | # GAP Service Characteristics 641 | CHARACTERISTIC_DEVICE_NAME = normalize_uuid_16(0x2a00) 642 | CHARACTERISTIC_APPEARANCE = normalize_uuid_16(0x2a01) 643 | 644 | # Device Information Service 645 | SERVICE_DIS = normalize_uuid_16(0x180a) 646 | 647 | # Device Information Service Characteristics 648 | CHARACTERISTIC_SYSTEM_ID = normalize_uuid_16(0x2a23) 649 | CHARACTERISTIC_MODEL_NUMBER = normalize_uuid_16(0x2a24) 650 | CHARACTERISTIC_SERIAL_NO = normalize_uuid_16(0x2a25) 651 | CHARACTERISTIC_SW_REV = normalize_uuid_16(0x2a26) 652 | CHARACTERISTIC_HW_REV = normalize_uuid_16(0x2a27) 653 | CHARACTERISTIC_SW_REV_FACTORY = normalize_uuid_16(0x2a28) 654 | CHARACTERISTIC_MANUFACTURER_NAME = normalize_uuid_16(0x2a29) 655 | 656 | # Battery Service 657 | SERVICE_BATTERY = normalize_uuid_16(0x180f) 658 | 659 | # Battery Service Characteristics 660 | CHARACTERISTIC_BATTERY_LEVEL = normalize_uuid_16(0x2a19) 661 | 662 | # SAF Tehnika Service 663 | SERVICE_SAF_TEHNIKA = normalize_uuid_16(0xfce0) # v1.2.0 and later 664 | SERVICE_SAF_TEHNIKA_OLD = "f0cd1400-95da-4f4b-9ac8-aa55d312af0c" # until v1.2.0 665 | 666 | # SAF Tehnika Service Characteristics (Aranet2 has different readings characteristic uuids) 667 | CHARACTERISTIC_SENSOR_STATE = "f0cd1401-95da-4f4b-9ac8-aa55d312af0c" 668 | CHARACTERISTIC_CMD = "f0cd1402-95da-4f4b-9ac8-aa55d312af0c" 669 | CHARACTERISTIC_CALIBRATION_DATA = "f0cd1502-95da-4f4b-9ac8-aa55d312af0c" 670 | CHARACTERISTIC_CURRENT_READINGS = "f0cd1503-95da-4f4b-9ac8-aa55d312af0c" 671 | CHARACTERISTIC_CURRENT_READINGS_AR2 = "f0cd1504-95da-4f4b-9ac8-aa55d312af0c" # Aranet2 Only 672 | CHARACTERISTIC_TOTAL_READINGS = "f0cd2001-95da-4f4b-9ac8-aa55d312af0c" 673 | CHARACTERISTIC_INTERVAL = "f0cd2002-95da-4f4b-9ac8-aa55d312af0c" 674 | CHARACTERISTIC_HISTORY_READINGS_V1 = "f0cd2003-95da-4f4b-9ac8-aa55d312af0c" 675 | CHARACTERISTIC_SECONDS_SINCE_UPDATE = "f0cd2004-95da-4f4b-9ac8-aa55d312af0c" 676 | CHARACTERISTIC_HISTORY_READINGS_V2 = "f0cd2005-95da-4f4b-9ac8-aa55d312af0c" 677 | CHARACTERISTIC_CURRENT_READINGS_DET = "f0cd3001-95da-4f4b-9ac8-aa55d312af0c" 678 | CHARACTERISTIC_CURRENT_READINGS_A = "f0cd3002-95da-4f4b-9ac8-aa55d312af0c" 679 | CHARACTERISTIC_CURRENT_READINGS_A_AR2 = "f0cd3003-95da-4f4b-9ac8-aa55d312af0c" # Aranet2 Only 680 | 681 | # Nordic Semiconductor ASA Service 682 | SERVICE_NORDIC_SEMICONDUCTOR = normalize_uuid_16(0xfe59) 683 | 684 | # Nordic Semiconductor ASA Service Characteristics 685 | CHARACTERISTIC_SECURE_DFU = "8ec90003-f315-4f60-9fb8-838830daea50" 686 | 687 | # Regexp 688 | REGEX_MAC = "([0-9a-f]{2}[:-]){5}([0-9a-f]{2})" 689 | REGEX_UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" 690 | REGEX_ADDR = f"({REGEX_MAC})|({REGEX_UUID})" 691 | 692 | def __init__(self, address: str): 693 | if not re.match(self.REGEX_ADDR, address.lower()): 694 | raise Aranet4Error("Invalid device address") 695 | 696 | self.address = address 697 | self.device = BleakClient(address) 698 | self.reading = True 699 | 700 | def __del__(self): 701 | """Close remote""" 702 | if self.device.is_connected: 703 | asyncio.shield(self.device.disconnect()) 704 | 705 | async def connect(self): 706 | """Connect to remote device""" 707 | await self.device.connect() 708 | 709 | async def current_readings(self, details: bool = False): 710 | """Extract current readings from remote device""" 711 | readings = CurrentReading() 712 | 713 | new_aranet_char = self.device.services.get_characteristic( 714 | self.CHARACTERISTIC_CURRENT_READINGS_AR2 715 | ) 716 | 717 | if new_aranet_char: 718 | uuid = self.CHARACTERISTIC_CURRENT_READINGS_AR2 719 | raw_bytes = await self.device.read_gatt_char(uuid) 720 | 721 | isRadon = raw_bytes[0] == 3 722 | isNucleo = raw_bytes[0] == 4 723 | isAranet2 = raw_bytes[0] == 2 724 | 725 | if isRadon and len(raw_bytes) >= 47: 726 | # radon 727 | value_fmt = "= 28: 731 | # radiation 732 | value_fmt = " int: 755 | """Get the value for how often datapoints are logged on device""" 756 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_INTERVAL) 757 | return int.from_bytes(raw_bytes, byteorder="little") 758 | 759 | async def get_name(self): 760 | """Get name of remote device""" 761 | try: 762 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_DEVICE_NAME) 763 | return raw_bytes.decode("utf-8") 764 | except: 765 | # fallback to serial number 766 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_SERIAL_NO) 767 | return "Aranet4 {}".format(raw_bytes.decode("utf-8")) 768 | 769 | async def get_version(self): 770 | """Get firmware version of remote device""" 771 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_SW_REV) 772 | return raw_bytes.decode("utf-8") 773 | 774 | async def get_seconds_since_update(self): 775 | """ 776 | Get the value for how long (in seconds) has passed since last 777 | datapoint was logged 778 | """ 779 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_SECONDS_SINCE_UPDATE) 780 | return int.from_bytes(raw_bytes, byteorder="little") 781 | 782 | async def get_total_readings(self): 783 | """Return the count of how many datapoints are logged on device""" 784 | raw_bytes = await self.device.read_gatt_char(self.CHARACTERISTIC_TOTAL_READINGS) 785 | return int.from_bytes(raw_bytes, byteorder="little") 786 | 787 | async def get_last_measurement_date(self, use_epoch: bool = False): 788 | """Calculate the time the last datapoint was logged""" 789 | ago = await self.get_seconds_since_update() 790 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 791 | delta_ago = datetime.timedelta(seconds=ago) 792 | last_reading = now - delta_ago 793 | if use_epoch: 794 | return last_reading.timestamp() 795 | return last_reading 796 | 797 | async def get_records( 798 | self, param: Param, log_size: int, start: int = 0x0001, end: int = 0xFFFF 799 | ): 800 | """ 801 | Return ordered list of datapoints for requested parameter. 802 | List will be length of "total datapoints". If index is outside `start` 803 | and `end` request then default of `-1` is returned for those datapoints. 804 | """ 805 | 806 | history_v2 = self.device.services.get_characteristic( 807 | self.CHARACTERISTIC_HISTORY_READINGS_V2 808 | ) 809 | 810 | if history_v2 is not None: 811 | return await self._get_records_v2(param, log_size, start, end) 812 | 813 | return await self._get_records_v1(param, log_size, start, end) 814 | 815 | async def _get_records_v2( 816 | self, param: Param, log_size: int, start: int = 0x0001, end: int = 0xFFFF 817 | ): 818 | """ 819 | Return ordered list of datapoints for requested parameter. 820 | List will be length of "total datapoints". If index is outside `start` 821 | and `end` request then default of `-1` is returned for those datapoints. 822 | """ 823 | start = max(start, 0x0001) 824 | 825 | header = 0x61 826 | val = struct.pack(" end or idx == header.start - 1 + header.count: 870 | break 871 | result[idx] = CurrentReading._set(param, value[0]) 872 | 873 | if idx >= end or (header.start - 1 + header.count) == log_size: 874 | reading = False 875 | 876 | return result 877 | 878 | async def _get_records_v1( 879 | self, param: Param, log_size: int, start: int = 0x0001, end: int = 0xFFFF 880 | ): 881 | """ 882 | Return ordered list of datapoints for requested parameter. 883 | List will be length of "total datapoints". If index is outside `start` 884 | and `end` request then default of `-1` is returned for those datapoints. 885 | """ 886 | start = max(start, 0x0001) 887 | 888 | header = 0x82 889 | unknown = 0x00 890 | val = struct.pack(" datetime: 963 | if dt and not dt.tzinfo: 964 | now = datetime.datetime.now().astimezone() 965 | dt = dt.replace(tzinfo=now.tzinfo) 966 | return dt 967 | 968 | 969 | def _calc_start_end(datapoint_times: int, entry_filter): 970 | """ 971 | Apply filters to get required start and end datapoint. 972 | `entry_filter` is a dictionary that can have the following values: 973 | `last`: int : Get last n entries 974 | `start`: datetime : Get entries after specified time 975 | `end`: datetime : Get entries before specified time 976 | """ 977 | last_n_entries = entry_filter.get("last") 978 | filter_start = _attach_tzinfo(entry_filter.get("start")) 979 | filter_end = _attach_tzinfo(entry_filter.get("end")) 980 | start = 0x0001 981 | end = len(datapoint_times) 982 | if last_n_entries: 983 | # Result is inclusive so reduce count back by 1 984 | start = max(end - last_n_entries + 1, start) 985 | if filter_start: 986 | time_start = -1 987 | for idx, timestamp in enumerate(datapoint_times, start=1): 988 | if filter_start <= timestamp: 989 | time_start = idx 990 | break 991 | if 0 < time_start <= end: 992 | start = time_start 993 | else: 994 | start = -1 # out of range 995 | if filter_end: 996 | time_end = -1 997 | for idx, timestamp in enumerate(datapoint_times, start=1): 998 | if timestamp <= filter_end: 999 | time_end = idx 1000 | else: 1001 | break 1002 | if start <= time_end <= end: 1003 | end = time_end 1004 | else: 1005 | end = -1 # out of range 1006 | return start, end 1007 | 1008 | 1009 | async def _current_reading(address): 1010 | """Populate and return `client.CurrentReading` dataclass""" 1011 | monitor = Aranet4(address=address) 1012 | await monitor.connect() 1013 | readings = await monitor.current_readings(details=True) 1014 | readings.name = await monitor.get_name() 1015 | readings.version = await monitor.get_version() 1016 | readings.stored = await monitor.get_total_readings() 1017 | return readings 1018 | 1019 | def _eval(val) -> bool: 1020 | falsy = ["0", "false", "disable", "disabled", "no", "off", "none"] 1021 | if isinstance(val, str): 1022 | return val.lower() not in falsy 1023 | return bool(val) 1024 | 1025 | async def _set_settings(address, settings, verify: bool=True) -> dict: 1026 | """Change device settings. Returns changed count""" 1027 | monitor = Aranet4(address=address) 1028 | await monitor.connect() 1029 | status = {} 1030 | 1031 | if "interval" in settings: 1032 | intval = int(settings["interval"]) 1033 | status["interval"] = await monitor.set_readings_interval(intval, verify) 1034 | 1035 | if "range" in settings: 1036 | extend = ["extend", "extended", "1"] 1037 | extend = settings["range"].lower() in extend 1038 | status["range"] = await monitor.set_bluetooth_range(extend, verify) 1039 | 1040 | if "integrations" in settings: 1041 | on = ["on", "enable", "enabled", "1"] 1042 | on = settings["integrations"].lower() in on 1043 | status["integrations"] = await monitor.set_home_integration_enabled(on, verify) 1044 | 1045 | return status 1046 | 1047 | def get_current_readings(mac_address: str) -> CurrentReading: 1048 | """Get from the device the current measurements""" 1049 | return asyncio.run(_current_reading(mac_address)) 1050 | 1051 | def set_settings(mac_address: str, settings: dict, verify: bool=True) -> int: 1052 | """Get from the device the current measurements""" 1053 | return asyncio.run(_set_settings(mac_address, settings, verify)) 1054 | 1055 | 1056 | class Aranet4Scanner: 1057 | """Aranet4 Scanner class - scan advertisements and process data, if available""" 1058 | 1059 | def _process_advertisement(self, device, ad_data): 1060 | """Processes Aranet4 advertisement data""" 1061 | adv = Aranet4Advertisement(device, ad_data) 1062 | self.on_scan(adv) 1063 | 1064 | def __init__(self, on_scan): 1065 | uuids = [Aranet4.SERVICE_SAF_TEHNIKA, Aranet4.SERVICE_SAF_TEHNIKA_OLD] 1066 | self.on_scan = on_scan 1067 | self.scanner = BleakScanner( 1068 | detection_callback=self._process_advertisement, 1069 | service_uuids=uuids 1070 | ) 1071 | 1072 | async def start(self): 1073 | await self.scanner.start() 1074 | 1075 | async def stop(self): 1076 | await self.scanner.stop() 1077 | 1078 | async def _find_nearby(detect_callback: callable, duration: int) -> List[BLEDevice]: 1079 | scanner = Aranet4Scanner(detect_callback) 1080 | await scanner.start() 1081 | await asyncio.sleep(duration) 1082 | await scanner.stop() 1083 | return [device 1084 | for device in scanner.scanner.discovered_devices 1085 | if "Aranet" in device.name] 1086 | 1087 | 1088 | def find_nearby(detect_callback: callable, duration: int = 5) -> List[BLEDevice]: 1089 | """ 1090 | Scans for nearby Aranet4 devices. 1091 | Will call callback on every valid Aranet4 advertisement, including duplicates 1092 | """ 1093 | 1094 | return asyncio.run(_find_nearby(detect_callback, duration)) 1095 | 1096 | async def _all_records(address, entry_filter, remove_empty): 1097 | """ 1098 | Get stored data points from device. Apply any filters requested 1099 | `entry_filter` is a dictionary that can have the following values: 1100 | `last`: int : Get last n entries 1101 | `start`: datetime : Get entries after specified time 1102 | `end`: datetime : Get entries before specified time 1103 | `temp`: bool : Get temperature data points (default = True) 1104 | `humi`: bool : Get humidity data points (default = True) 1105 | `pres`: bool : Get pressure data points (default = True) 1106 | `co2`: bool : Get co2 data points (default = True) 1107 | """ 1108 | # Connect 1109 | monitor = Aranet4(address=address) 1110 | await monitor.connect() 1111 | # Get Basic information 1112 | dev_name = await monitor.get_name() 1113 | dev_version = await monitor.get_version() 1114 | last_log = await monitor.get_seconds_since_update() 1115 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 1116 | interval = await monitor.get_interval() 1117 | next_log = interval - last_log 1118 | # Decide if there is enough time to read all the data 1119 | # before the next datapoint is logged. 1120 | print(f"Next data point will be logged in {next_log} seconds") 1121 | if next_log < 10: 1122 | print(f"Waiting {next_log} for next datapoint to be taken...") 1123 | await asyncio.sleep(next_log) 1124 | # there was another log so update the numbers 1125 | last_log = await monitor.get_seconds_since_update() 1126 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 1127 | 1128 | if dev_name.startswith("Aranet2"): 1129 | entry_filter["pres"] = False 1130 | entry_filter["co2"] = False 1131 | if entry_filter.get("humi", False): 1132 | entry_filter["humi"] = 2 #v2 humidity 1133 | elif dev_name.startswith("Aranet\u2622"): 1134 | entry_filter["pres"] = False 1135 | entry_filter["co2"] = False 1136 | entry_filter["humi"] = False 1137 | entry_filter["temp"] = False 1138 | entry_filter["rad_dose"] = entry_filter.get("rad_dose", True) 1139 | entry_filter["rad_dose_rate"] = entry_filter.get("rad_dose_rate", True) 1140 | entry_filter["rad_dose_total"] = entry_filter.get("rad_dose_total", True) 1141 | elif dev_name.startswith("AranetRn"): 1142 | entry_filter["co2"] = False 1143 | entry_filter["radon_concentration"] = entry_filter.get("radon_concentration", True) 1144 | entry_filter["pres"] = entry_filter.get("pres", True) 1145 | entry_filter["temp"] = entry_filter.get("temp", True) 1146 | if entry_filter.get("humi", False): 1147 | entry_filter["humi"] = 2 #v2 humidity 1148 | 1149 | log_size = await monitor.get_total_readings() 1150 | log_points = _log_times(now, log_size, interval, last_log) 1151 | begin, end = _calc_start_end(log_points, entry_filter) 1152 | rec_filter = Filter( 1153 | begin, 1154 | end, 1155 | entry_filter.get("temp", True), 1156 | entry_filter.get("humi", True), 1157 | entry_filter.get("pres", True), 1158 | entry_filter.get("co2", True), 1159 | entry_filter.get("rad_dose", False), 1160 | entry_filter.get("rad_dose_rate", False), 1161 | entry_filter.get("rad_dose_total", False), 1162 | entry_filter.get("radon_concentration", False), 1163 | ) 1164 | 1165 | if begin < 0 or end < 0: 1166 | # invalid range. Most likely no points available 1167 | return Record(dev_name, dev_version, log_size, rec_filter) 1168 | 1169 | # Read datapoint history from device 1170 | if rec_filter.incl_temperature: 1171 | temperature_val = await monitor.get_records( 1172 | Param.TEMPERATURE, log_size=log_size, start=begin, end=end 1173 | ) 1174 | else: 1175 | temperature_val = _empty_reading(log_size) 1176 | if rec_filter.incl_humidity == 2: 1177 | humidity_val = await monitor.get_records( 1178 | Param.HUMIDITY2, log_size=log_size, start=begin, end=end 1179 | ) 1180 | elif rec_filter.incl_humidity: 1181 | humidity_val = await monitor.get_records( 1182 | Param.HUMIDITY, log_size=log_size, start=begin, end=end 1183 | ) 1184 | else: 1185 | humidity_val = _empty_reading(log_size) 1186 | if rec_filter.incl_pressure: 1187 | pressure_val = await monitor.get_records( 1188 | Param.PRESSURE, log_size=log_size, start=begin, end=end 1189 | ) 1190 | else: 1191 | pressure_val = _empty_reading(log_size) 1192 | if rec_filter.incl_co2: 1193 | co2_val = await monitor.get_records( 1194 | Param.CO2, log_size=log_size, start=begin, end=end 1195 | ) 1196 | else: 1197 | co2_val = _empty_reading(log_size) 1198 | if rec_filter.incl_rad_dose: 1199 | rad_dose_val = await monitor.get_records( 1200 | Param.RADIATION_DOSE, log_size=log_size, start=begin, end=end 1201 | ) 1202 | else: 1203 | rad_dose_val = _empty_reading(log_size) 1204 | if rec_filter.incl_rad_dose_rate: 1205 | rad_dose_rate_val = await monitor.get_records( 1206 | Param.RADIATION_DOSE_RATE, log_size=log_size, start=begin, end=end 1207 | ) 1208 | else: 1209 | rad_dose_rate_val = _empty_reading(log_size) 1210 | if rec_filter.incl_rad_dose_total: 1211 | rad_dose_total_val = await monitor.get_records( 1212 | Param.RADIATION_DOSE_INTEGRAL, log_size=log_size, start=begin, end=end 1213 | ) 1214 | else: 1215 | rad_dose_total_val = _empty_reading(log_size) 1216 | if rec_filter.incl_radon_concentration: 1217 | radon_concentration_val = await monitor.get_records( 1218 | Param.RADON_CONCENTRATION, log_size=log_size, start=begin, end=end 1219 | ) 1220 | else: 1221 | radon_concentration_val = _empty_reading(log_size) 1222 | #### 1223 | # Store returned data in dataclass 1224 | data = zip( 1225 | log_points, 1226 | co2_val, 1227 | temperature_val, 1228 | pressure_val, 1229 | humidity_val, 1230 | rad_dose_val, 1231 | rad_dose_rate_val, 1232 | rad_dose_total_val, 1233 | radon_concentration_val 1234 | ) 1235 | 1236 | record = Record(dev_name, dev_version, log_size, rec_filter) 1237 | 1238 | for date, co2, temp, pres, hum, rad, rad_rate, rad_integral, radon in data: 1239 | record.value.append(RecordItem(date, temp, hum, pres, co2, rad, rad_rate, rad_integral, radon)) 1240 | if remove_empty: 1241 | record.value = record.value[begin-1:end+1] 1242 | return record 1243 | 1244 | 1245 | def get_all_records(mac_address: str, entry_filter: dict, remove_empty: bool = False) -> Record: 1246 | """ 1247 | Get stored datapoints from device. Apply any filters requested 1248 | `entry_filter` is a dictionary that can have the following values: 1249 | `last`: int : Get last n entries 1250 | `start`: datetime : Get entries after specified time 1251 | `end`: datetime : Get entries before specified time 1252 | `temp`: bool : Get temperature data points (default = True) 1253 | `humi`: bool : Get humidity data points (default = True) 1254 | `pres`: bool : Get pressure data points (default = True) 1255 | `co2`: bool : Get co2 data points (default = True) 1256 | """ 1257 | return asyncio.run(_all_records(mac_address, entry_filter, remove_empty)) 1258 | -------------------------------------------------------------------------------- /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", 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 import BleakClient 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 = BleakClient(address) 23 | device.name = name 24 | 25 | return { 26 | "ad_data": ad_data, 27 | "device": device 28 | } 29 | 30 | TEST_DATA_ARANET_4 = { 31 | "name": "Aranet4 12345", 32 | "uuid": "f0cd1400-95da-4f4b-9ac8-aa55d312af0c", 33 | "manufacturer_data": {1794: b"!\x05\x03\x01\x00\x05\x00\x01C\x04\x9f\x01\x8b\'5\x0c\x02<\x00\x10\x00U"}, 34 | "manufacturer_data_short": {1794: b"!\x05\x03\x01\x00\x05\x00\x01C\x04\x9f\x01\x8b\'5\x0c\x02<\x00\x10"}, 35 | "manufacturer_data_old_fw": {1794: b"\x21\x0a\x04\x00\x00\x00\x00"}, 36 | "manufacturer_data_no_integrations": {1794: b"\x01\x00\x02\x01\x00\x00\x00"}, 37 | "manufacturer_data_bad": {1794: b"\x01\x00\x02"} 38 | } 39 | 40 | TEST_DATA_ARANET_2 = { 41 | "name": "Aranet2 278F8", 42 | "uuid": "f0cd1400-95da-4f4b-9ac8-aa55d312af0c", 43 | "manufacturer_data": {1794: b"\x01!\x04\x04\x01\x00\x00\x00\x00\x00\x99\x01\x00\x00\n\x02\x00;\x09x\x00R\x00d"}, 44 | "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"} 45 | } 46 | 47 | TEST_DATA_ARANET_RADIATION = { 48 | "name": "Aranet\u2622 27DB3", 49 | "uuid": "f0cd1400-95da-4f4b-9ac8-aa55d312af0c", 50 | "manufacturer_data": {1794: b"\x02!&\x04\x01\x00\xd03\x00\x00l`\x06\x00\x82\x00\x00c\x00,\x01X\x00r"}, 51 | "manufacturer_data_no_integrations": {1794: b"\x02\x01&\x04\x01\x00\xd03\x00\x00l`\x06\x00\x82\x00\x00c\x00,\x01X\x00r"} 52 | } 53 | 54 | TEST_DATA_ARANET_RADON = { 55 | "name": "AranetRn 298C9", 56 | "uuid": "f0cd1400-95da-4f4b-9ac8-aa55d312af0c", 57 | "manufacturer_data": {1794: b"\x03!\x04\x06\x01\x00\x00\x00\x07\x00\xfe\x01\xc9\'\xce\x01\x00d\x01X\x02\xf6\x01\x08"} 58 | } 59 | 60 | 61 | class DataManipulation(unittest.TestCase): 62 | # --------------------------------------------- 63 | # Test data validation helpers 64 | # --------------------------------------------- 65 | 66 | def _test_aranet_4(self, data): 67 | ad = Aranet4Advertisement(data["device"], data["ad_data"]) 68 | 69 | self.assertTrue(ad.manufacturer_data.integrations) 70 | self.assertEqual("v1.3.5", str(ad.manufacturer_data.version)) 71 | 72 | self.assertEqual(AranetType.ARANET4, ad.readings.type) 73 | self.assertEqual("Aranet4", ad.readings.type.model) 74 | self.assertEqual(1091, ad.readings.co2) 75 | self.assertEqual(20.8, ad.readings.temperature) 76 | self.assertEqual(53, ad.readings.humidity) 77 | self.assertEqual(1012.3, ad.readings.pressure) 78 | self.assertEqual(12, ad.readings.battery) 79 | self.assertEqual(16, ad.readings.ago) 80 | self.assertEqual(60, ad.readings.interval) 81 | 82 | def _test_aranet_2(self, data): 83 | ad = Aranet4Advertisement(data["device"], data["ad_data"]) 84 | 85 | self.assertTrue(ad.manufacturer_data.integrations) 86 | self.assertEqual("v1.4.4", str(ad.manufacturer_data.version)) 87 | 88 | self.assertEqual(AranetType.ARANET2, ad.readings.type) 89 | self.assertEqual("Aranet2", ad.readings.type.model) 90 | self.assertEqual(20.5, ad.readings.temperature) 91 | self.assertEqual(52.2, ad.readings.humidity) 92 | self.assertEqual(59, ad.readings.battery) 93 | self.assertEqual(82, ad.readings.ago) 94 | self.assertEqual("OVER", ad.readings.status_temperature.name) 95 | self.assertEqual("UNDER", ad.readings.status_humidity.name) 96 | 97 | def _test_aranet_radiation(self, data): 98 | ad = Aranet4Advertisement(data["device"], data["ad_data"]) 99 | 100 | self.assertTrue(ad.manufacturer_data.integrations) 101 | self.assertEqual("v1.4.38", str(ad.manufacturer_data.version)) 102 | 103 | self.assertEqual(AranetType.ARANET_RADIATION, ad.readings.type) 104 | self.assertEqual("Aranet Radiation", ad.readings.type.model) 105 | self.assertEqual(130, ad.readings.radiation_rate) 106 | self.assertEqual(13264, ad.readings.radiation_total) 107 | self.assertEqual(417900, ad.readings.radiation_duration) 108 | self.assertEqual(99, ad.readings.battery) 109 | self.assertEqual(88, ad.readings.ago) 110 | self.assertEqual(300, ad.readings.interval) 111 | 112 | def _test_aranet_radon(self, data): 113 | ad = Aranet4Advertisement(data["device"], data["ad_data"]) 114 | 115 | self.assertTrue(ad.manufacturer_data.integrations) 116 | self.assertEqual("v1.6.4", str(ad.manufacturer_data.version)) 117 | 118 | self.assertEqual(AranetType.ARANET_RADON, ad.readings.type) 119 | self.assertEqual("Aranet Radon Plus", ad.readings.type.model) 120 | self.assertEqual(25.5, ad.readings.temperature) 121 | self.assertEqual(46.2, ad.readings.humidity) 122 | self.assertEqual(1018.5, ad.readings.pressure) 123 | self.assertEqual(7, ad.readings.radon_concentration) 124 | self.assertEqual(100, ad.readings.battery) 125 | self.assertEqual(502, ad.readings.ago) 126 | self.assertEqual(600, ad.readings.interval) 127 | # --------------------------------------------- 128 | # Test variations 129 | # --------------------------------------------- 130 | 131 | def test_aranet_4(self): 132 | srcdata = TEST_DATA_ARANET_4 133 | self._test_aranet_4(fake_ad_data( 134 | srcdata["name"], 135 | srcdata["uuid"], 136 | srcdata["manufacturer_data"] 137 | )) 138 | 139 | def test_aranet_4_noname(self): 140 | srcdata = TEST_DATA_ARANET_4 141 | self._test_aranet_4(fake_ad_data( 142 | None, 143 | srcdata["uuid"], 144 | srcdata["manufacturer_data"] 145 | )) 146 | 147 | def test_aranet_4_invalid(self): 148 | data_old = fake_ad_data( 149 | TEST_DATA_ARANET_4["name"], 150 | TEST_DATA_ARANET_4["uuid"], 151 | TEST_DATA_ARANET_4["manufacturer_data_old_fw"] 152 | ) 153 | 154 | data_no_integrations = fake_ad_data( 155 | TEST_DATA_ARANET_4["name"], 156 | TEST_DATA_ARANET_4["uuid"], 157 | TEST_DATA_ARANET_4["manufacturer_data_no_integrations"] 158 | ) 159 | 160 | data_short = fake_ad_data( 161 | TEST_DATA_ARANET_4["name"], 162 | TEST_DATA_ARANET_4["uuid"], 163 | TEST_DATA_ARANET_4["manufacturer_data_short"] 164 | ) 165 | 166 | data_bad = fake_ad_data( 167 | TEST_DATA_ARANET_4["name"], 168 | TEST_DATA_ARANET_4["uuid"], 169 | TEST_DATA_ARANET_4["manufacturer_data_bad"] 170 | ) 171 | 172 | ad_old = Aranet4Advertisement(data_old["device"], data_old["ad_data"]) 173 | self.assertFalse(ad_old.manufacturer_data.integrations) 174 | self.assertEqual("v0.4.10", str(ad_old.manufacturer_data.version)) 175 | self.assertFalse(ad_old.readings) 176 | 177 | ad_no_integrations = Aranet4Advertisement(data_no_integrations["device"], data_no_integrations["ad_data"]) 178 | self.assertFalse(ad_no_integrations.manufacturer_data.integrations) 179 | self.assertEqual("v1.2.0", str(ad_no_integrations.manufacturer_data.version)) 180 | self.assertFalse(ad_no_integrations.readings) 181 | 182 | ad_short = Aranet4Advertisement(data_short["device"], data_short["ad_data"]) 183 | self.assertFalse(ad_short.manufacturer_data.integrations) 184 | self.assertEqual("v1.3.5", str(ad_short.manufacturer_data.version)) 185 | self.assertFalse(ad_short.readings) 186 | 187 | ad_bad = Aranet4Advertisement(data_bad["device"], data_bad["ad_data"]) 188 | self.assertFalse(ad_bad.manufacturer_data) 189 | 190 | def test_aranet_2_invalid(self): 191 | data_no_integrations = fake_ad_data( 192 | TEST_DATA_ARANET_2["name"], 193 | TEST_DATA_ARANET_2["uuid"], 194 | TEST_DATA_ARANET_2["manufacturer_data_no_integrations"] 195 | ) 196 | 197 | ad_no_integrations = Aranet4Advertisement(data_no_integrations["device"], data_no_integrations["ad_data"]) 198 | self.assertFalse(ad_no_integrations.manufacturer_data.integrations) 199 | self.assertEqual("v1.4.4", str(ad_no_integrations.manufacturer_data.version)) 200 | self.assertFalse(ad_no_integrations.readings) 201 | 202 | def test_aranet_radiation_invalid(self): 203 | data_no_integrations = fake_ad_data( 204 | TEST_DATA_ARANET_RADIATION["name"], 205 | TEST_DATA_ARANET_RADIATION["uuid"], 206 | TEST_DATA_ARANET_RADIATION["manufacturer_data_no_integrations"] 207 | ) 208 | 209 | ad_no_integrations = Aranet4Advertisement(data_no_integrations["device"], data_no_integrations["ad_data"]) 210 | self.assertFalse(ad_no_integrations.manufacturer_data.integrations) 211 | self.assertEqual("v1.4.38", str(ad_no_integrations.manufacturer_data.version)) 212 | self.assertFalse(ad_no_integrations.readings) 213 | 214 | def test_aranet_2(self): 215 | srcdata = TEST_DATA_ARANET_2 216 | self._test_aranet_2(fake_ad_data( 217 | srcdata["name"], 218 | srcdata["uuid"], 219 | srcdata["manufacturer_data"] 220 | )) 221 | 222 | def test_aranet_2_noname(self): 223 | srcdata = TEST_DATA_ARANET_2 224 | self._test_aranet_2(fake_ad_data( 225 | srcdata["name"], 226 | srcdata["uuid"], 227 | srcdata["manufacturer_data"] 228 | )) 229 | 230 | def test_aranet_radiation(self): 231 | srcdata = TEST_DATA_ARANET_RADIATION 232 | self._test_aranet_radiation(fake_ad_data( 233 | srcdata["name"], 234 | srcdata["uuid"], 235 | srcdata["manufacturer_data"] 236 | )) 237 | 238 | def test_aranet_radiation_noname(self): 239 | srcdata = TEST_DATA_ARANET_RADIATION 240 | self._test_aranet_radiation(fake_ad_data( 241 | None, 242 | srcdata["uuid"], 243 | srcdata["manufacturer_data"] 244 | )) 245 | 246 | def test_aranet_radon(self): 247 | srcdata = TEST_DATA_ARANET_RADON 248 | self._test_aranet_radon(fake_ad_data( 249 | srcdata["name"], 250 | srcdata["uuid"], 251 | srcdata["manufacturer_data"] 252 | )) 253 | 254 | def test_aranet_radon_noname(self): 255 | srcdata = TEST_DATA_ARANET_RADON 256 | self._test_aranet_radon(fake_ad_data( 257 | None, 258 | srcdata["uuid"], 259 | srcdata["manufacturer_data"] 260 | )) 261 | 262 | if __name__ == "__main__": 263 | unittest.main() 264 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------