ruuvitag-sensor 2.3.0__py3-none-any.whl → 3.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ruuvitag-sensor might be problematic. Click here for more details.
- ruuvitag_sensor/__init__.py +2 -7
- ruuvitag_sensor/__main__.py +1 -1
- ruuvitag_sensor/adapters/__init__.py +26 -20
- ruuvitag_sensor/adapters/bleak_ble.py +162 -11
- ruuvitag_sensor/adapters/bleson.py +6 -7
- ruuvitag_sensor/adapters/development/dev_bleak_scanner.py +0 -2
- ruuvitag_sensor/adapters/nix_hci.py +5 -8
- ruuvitag_sensor/adapters/nix_hci_file.py +3 -1
- ruuvitag_sensor/adapters/utils.py +2 -0
- ruuvitag_sensor/data_formats.py +3 -4
- ruuvitag_sensor/decoder.py +133 -1
- ruuvitag_sensor/log.py +25 -8
- ruuvitag_sensor/ruuvi.py +131 -34
- ruuvitag_sensor/ruuvi_rx.py +13 -9
- ruuvitag_sensor/ruuvi_types.py +8 -6
- {ruuvitag_sensor-2.3.0.dist-info → ruuvitag_sensor-3.1.0.dist-info}/METADATA +211 -122
- ruuvitag_sensor-3.1.0.dist-info/RECORD +23 -0
- {ruuvitag_sensor-2.3.0.dist-info → ruuvitag_sensor-3.1.0.dist-info}/WHEEL +1 -1
- ruuvitag_sensor-2.3.0.dist-info/RECORD +0 -22
- {ruuvitag_sensor-2.3.0.dist-info → ruuvitag_sensor-3.1.0.dist-info}/LICENSE +0 -0
- {ruuvitag_sensor-2.3.0.dist-info → ruuvitag_sensor-3.1.0.dist-info}/top_level.txt +0 -0
ruuvitag_sensor/__init__.py
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
import importlib.metadata # >=3.8
|
|
1
|
+
import importlib.metadata
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
except ImportError:
|
|
6
|
-
import importlib_metadata # <=3.7
|
|
7
|
-
|
|
8
|
-
__version__ = importlib_metadata.version(__package__ or __name__)
|
|
3
|
+
__version__ = importlib.metadata.version(__package__ or __name__)
|
ruuvitag_sensor/__main__.py
CHANGED
|
@@ -1,41 +1,47 @@
|
|
|
1
1
|
import abc
|
|
2
2
|
import os
|
|
3
|
-
import sys
|
|
4
3
|
from typing import AsyncGenerator, Generator, List
|
|
5
4
|
|
|
6
5
|
from ruuvitag_sensor.ruuvi_types import MacAndRawData, RawData
|
|
7
6
|
|
|
8
|
-
# pylint: disable=import-outside-toplevel, cyclic-import
|
|
9
|
-
|
|
10
7
|
|
|
11
8
|
def get_ble_adapter():
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
forced_ble_adapter = os.environ.get("RUUVI_BLE_ADAPTER", "").lower()
|
|
10
|
+
use_ruuvi_nix_from_file = "RUUVI_NIX_FROMFILE" in os.environ
|
|
11
|
+
is_ci_env = "CI" in os.environ
|
|
12
|
+
|
|
13
|
+
if forced_ble_adapter:
|
|
14
|
+
if "bleak" in forced_ble_adapter:
|
|
15
|
+
from ruuvitag_sensor.adapters.bleak_ble import BleCommunicationBleak
|
|
16
|
+
|
|
17
|
+
return BleCommunicationBleak()
|
|
18
|
+
if "bleson" in forced_ble_adapter:
|
|
19
|
+
from ruuvitag_sensor.adapters.bleson import BleCommunicationBleson
|
|
20
|
+
|
|
21
|
+
return BleCommunicationBleson()
|
|
22
|
+
if "bluez" in forced_ble_adapter:
|
|
23
|
+
from ruuvitag_sensor.adapters.nix_hci import BleCommunicationNix
|
|
14
24
|
|
|
15
|
-
|
|
16
|
-
if "bleson" in os.environ.get("RUUVI_BLE_ADAPTER", "").lower():
|
|
17
|
-
from ruuvitag_sensor.adapters.bleson import BleCommunicationBleson
|
|
25
|
+
return BleCommunicationNix()
|
|
18
26
|
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
raise RuntimeError(f"Unknown BLE adapter: {forced_ble_adapter}")
|
|
28
|
+
|
|
29
|
+
if use_ruuvi_nix_from_file:
|
|
21
30
|
# Emulate BleCommunicationNix by reading hcidump data from a file
|
|
22
31
|
from ruuvitag_sensor.adapters.nix_hci_file import BleCommunicationNixFile
|
|
23
32
|
|
|
24
33
|
return BleCommunicationNixFile()
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
|
|
35
|
+
if is_ci_env:
|
|
36
|
+
# Use BleCommunicationDummy for CI as it can't use Bleak/BlueZ
|
|
27
37
|
from ruuvitag_sensor.adapters.dummy import BleCommunicationDummy
|
|
28
38
|
|
|
29
39
|
return BleCommunicationDummy()
|
|
30
|
-
if sys.platform.startswith("win") or sys.platform.startswith("darwin"):
|
|
31
|
-
from ruuvitag_sensor.adapters.bleak_ble import BleCommunicationBleak
|
|
32
|
-
|
|
33
|
-
return BleCommunicationBleak()
|
|
34
40
|
|
|
35
|
-
#
|
|
36
|
-
from ruuvitag_sensor.adapters.
|
|
41
|
+
# Bleak is default adapter for all platforms
|
|
42
|
+
from ruuvitag_sensor.adapters.bleak_ble import BleCommunicationBleak
|
|
37
43
|
|
|
38
|
-
return
|
|
44
|
+
return BleCommunicationBleak()
|
|
39
45
|
|
|
40
46
|
|
|
41
47
|
def is_async_adapter(ble: object):
|
|
@@ -90,5 +96,5 @@ class BleCommunicationAsync:
|
|
|
90
96
|
# if False: yield is a mypy fix for
|
|
91
97
|
# error: Return type "AsyncGenerator[Tuple[str, str], None]" of "get_data" incompatible with return type
|
|
92
98
|
# "Coroutine[Any, Any, AsyncGenerator[Tuple[str, str], None]]" in supertype "BleCommunicationAsync"
|
|
93
|
-
if False:
|
|
99
|
+
if False:
|
|
94
100
|
yield 0
|
|
@@ -3,34 +3,43 @@ import logging
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
import sys
|
|
6
|
-
from
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import AsyncGenerator, List, Optional, Tuple
|
|
7
8
|
|
|
8
|
-
from bleak import BleakScanner
|
|
9
|
-
from bleak.backends.scanner import AdvertisementData, BLEDevice
|
|
9
|
+
from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner
|
|
10
|
+
from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback, BLEDevice
|
|
10
11
|
|
|
11
12
|
from ruuvitag_sensor.adapters import BleCommunicationAsync
|
|
13
|
+
from ruuvitag_sensor.adapters.utils import rssi_to_hex
|
|
12
14
|
from ruuvitag_sensor.ruuvi_types import MacAndRawData, RawData
|
|
13
15
|
|
|
14
16
|
MAC_REGEX = "[0-9a-f]{2}([:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$"
|
|
17
|
+
RUUVI_HISTORY_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
|
|
18
|
+
RUUVI_HISTORY_RX_CHAR_UUID = "6E400002-B5A3-F393-E0A9-E50E24DCCA9E" # Write
|
|
19
|
+
RUUVI_HISTORY_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" # Read and notify
|
|
15
20
|
|
|
16
21
|
|
|
17
|
-
def _get_scanner(detection_callback):
|
|
22
|
+
def _get_scanner(detection_callback: AdvertisementDataCallback, bt_device: str = ""):
|
|
18
23
|
# NOTE: On Linux - bleak.exc.BleakError: passive scanning mode requires bluez or_patterns
|
|
19
24
|
# NOTE: On macOS - bleak.exc.BleakError: macOS does not support passive scanning
|
|
20
25
|
scanning_mode = "passive" if sys.platform.startswith("win") else "active"
|
|
21
26
|
|
|
22
27
|
if "bleak_dev" in os.environ.get("RUUVI_BLE_ADAPTER", "").lower():
|
|
23
|
-
# pylint: disable=import-outside-toplevel
|
|
24
28
|
from ruuvitag_sensor.adapters.development.dev_bleak_scanner import DevBleakScanner
|
|
25
29
|
|
|
26
30
|
return DevBleakScanner(detection_callback, scanning_mode)
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
if bt_device:
|
|
33
|
+
return BleakScanner(
|
|
34
|
+
detection_callback=detection_callback,
|
|
35
|
+
scanning_mode=scanning_mode, # type: ignore[arg-type]
|
|
36
|
+
adapter=bt_device,
|
|
37
|
+
)
|
|
29
38
|
|
|
39
|
+
return BleakScanner(detection_callback=detection_callback, scanning_mode=scanning_mode) # type: ignore[arg-type]
|
|
30
40
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
queue = asyncio.Queue() # type: ignore
|
|
41
|
+
|
|
42
|
+
queue = asyncio.Queue[Tuple[str, str]]()
|
|
34
43
|
|
|
35
44
|
log = logging.getLogger(__name__)
|
|
36
45
|
|
|
@@ -71,15 +80,19 @@ class BleCommunicationBleak(BleCommunicationAsync):
|
|
|
71
80
|
if 1177 not in advertisement_data.manufacturer_data:
|
|
72
81
|
return
|
|
73
82
|
|
|
83
|
+
log.debug("Received data: %s", advertisement_data)
|
|
84
|
+
|
|
74
85
|
data = BleCommunicationBleak._parse_data(advertisement_data.manufacturer_data[1177])
|
|
75
86
|
|
|
76
87
|
# Add RSSI to encoded data as hex. All adapters use a common decoder.
|
|
77
|
-
data +=
|
|
88
|
+
data += rssi_to_hex(advertisement_data.rssi)
|
|
78
89
|
await queue.put((mac, data))
|
|
79
90
|
|
|
80
|
-
scanner = _get_scanner(detection_callback)
|
|
91
|
+
scanner = _get_scanner(detection_callback, bt_device)
|
|
81
92
|
await scanner.start()
|
|
82
93
|
|
|
94
|
+
log.debug("Bleak scanner started")
|
|
95
|
+
|
|
83
96
|
try:
|
|
84
97
|
while True:
|
|
85
98
|
next_item: Tuple[str, str] = await queue.get()
|
|
@@ -93,6 +106,8 @@ class BleCommunicationBleak(BleCommunicationAsync):
|
|
|
93
106
|
|
|
94
107
|
await scanner.stop()
|
|
95
108
|
|
|
109
|
+
log.debug("Bleak scanner stopped")
|
|
110
|
+
|
|
96
111
|
@staticmethod
|
|
97
112
|
async def get_first_data(mac: str, bt_device: str = "") -> RawData:
|
|
98
113
|
"""
|
|
@@ -109,3 +124,139 @@ class BleCommunicationBleak(BleCommunicationAsync):
|
|
|
109
124
|
await data_iter.aclose()
|
|
110
125
|
|
|
111
126
|
return data or ""
|
|
127
|
+
|
|
128
|
+
async def get_history_data(
|
|
129
|
+
self, mac: str, start_time: Optional[datetime] = None, max_items: Optional[int] = None
|
|
130
|
+
) -> AsyncGenerator[bytearray, None]:
|
|
131
|
+
"""
|
|
132
|
+
Get history data from a RuuviTag using GATT connection.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
mac (str): MAC address of the RuuviTag
|
|
136
|
+
start_time (datetime, optional): Start time for history data
|
|
137
|
+
max_items (int, optional): Maximum number of history entries to fetch
|
|
138
|
+
|
|
139
|
+
Yields:
|
|
140
|
+
bytearray: Raw history data entries
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
RuntimeError: If connection fails or required services not found
|
|
144
|
+
"""
|
|
145
|
+
client = None
|
|
146
|
+
try:
|
|
147
|
+
log.debug("Connecting to device %s", mac)
|
|
148
|
+
client = await self._connect_gatt(mac)
|
|
149
|
+
log.debug("Connected to device %s", mac)
|
|
150
|
+
|
|
151
|
+
tx_char, rx_char = self._get_history_service_characteristics(client)
|
|
152
|
+
|
|
153
|
+
data_queue: asyncio.Queue[Optional[bytearray]] = asyncio.Queue()
|
|
154
|
+
|
|
155
|
+
def notification_handler(_, data: bytearray):
|
|
156
|
+
# Ignore heartbeat data that starts with 0x05
|
|
157
|
+
if data and data[0] == 0x05:
|
|
158
|
+
log.debug("Ignoring heartbeat data")
|
|
159
|
+
return
|
|
160
|
+
log.debug("Received data: %s", data)
|
|
161
|
+
# Check for end-of-logs marker (0x3A 0x3A 0x10 0xFF ...)
|
|
162
|
+
if len(data) >= 3 and all(b == 0xFF for b in data[3:]):
|
|
163
|
+
log.debug("Received end-of-logs marker")
|
|
164
|
+
data_queue.put_nowait(data)
|
|
165
|
+
data_queue.put_nowait(None)
|
|
166
|
+
return
|
|
167
|
+
# Check for error message. Header is 0xF0 (0x30 30 F0 FF FF FF FF FF FF FF FF)
|
|
168
|
+
if len(data) >= 11 and data[2] == 0xF0:
|
|
169
|
+
log.debug("Device reported error in log reading")
|
|
170
|
+
data_queue.put_nowait(data)
|
|
171
|
+
data_queue.put_nowait(None)
|
|
172
|
+
return
|
|
173
|
+
data_queue.put_nowait(data)
|
|
174
|
+
|
|
175
|
+
await client.start_notify(tx_char, notification_handler)
|
|
176
|
+
|
|
177
|
+
command = self._create_send_history_command(start_time)
|
|
178
|
+
|
|
179
|
+
log.debug("Sending command: %s", command)
|
|
180
|
+
await client.write_gatt_char(rx_char, command)
|
|
181
|
+
log.debug("Sent history command to device")
|
|
182
|
+
|
|
183
|
+
items_received = 0
|
|
184
|
+
while True:
|
|
185
|
+
try:
|
|
186
|
+
data = await asyncio.wait_for(data_queue.get(), timeout=10.0)
|
|
187
|
+
if data is None:
|
|
188
|
+
break
|
|
189
|
+
yield data
|
|
190
|
+
items_received += 1
|
|
191
|
+
if max_items and items_received >= max_items:
|
|
192
|
+
break
|
|
193
|
+
except asyncio.TimeoutError:
|
|
194
|
+
log.error("Timeout waiting for history data")
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
log.error("Failed to get history data from device %s: %r", mac, e)
|
|
199
|
+
raise
|
|
200
|
+
finally:
|
|
201
|
+
if client:
|
|
202
|
+
await client.disconnect()
|
|
203
|
+
log.debug("Disconnected from device %s", mac)
|
|
204
|
+
|
|
205
|
+
async def _connect_gatt(self, mac: str, max_retries: int = 3) -> BleakClient:
|
|
206
|
+
# Connect to a BLE device using GATT.
|
|
207
|
+
# NOTE: On macOS, the device address is not a MAC address, but a system specific ID
|
|
208
|
+
client = BleakClient(mac)
|
|
209
|
+
|
|
210
|
+
for attempt in range(max_retries):
|
|
211
|
+
try:
|
|
212
|
+
await client.connect()
|
|
213
|
+
return client
|
|
214
|
+
except Exception as e: # noqa: PERF203
|
|
215
|
+
if attempt == max_retries - 1:
|
|
216
|
+
raise
|
|
217
|
+
log.debug("Connection attempt %s failed: %s - Retrying...", attempt + 1, str(e))
|
|
218
|
+
await asyncio.sleep(1)
|
|
219
|
+
|
|
220
|
+
return client # Satisfy linter - this line will never be reached
|
|
221
|
+
|
|
222
|
+
def _get_history_service_characteristics(
|
|
223
|
+
self, client: BleakClient
|
|
224
|
+
) -> Tuple[BleakGATTCharacteristic, BleakGATTCharacteristic]:
|
|
225
|
+
# Get the history service
|
|
226
|
+
# https://docs.ruuvi.com/communication/bluetooth-connection/nordic-uart-service-nus
|
|
227
|
+
history_service = next(
|
|
228
|
+
(service for service in client.services if service.uuid.lower() == RUUVI_HISTORY_SERVICE_UUID.lower()),
|
|
229
|
+
None,
|
|
230
|
+
)
|
|
231
|
+
if not history_service:
|
|
232
|
+
raise RuntimeError("History service not found - device may not support history")
|
|
233
|
+
|
|
234
|
+
tx_char = history_service.get_characteristic(RUUVI_HISTORY_TX_CHAR_UUID)
|
|
235
|
+
rx_char = history_service.get_characteristic(RUUVI_HISTORY_RX_CHAR_UUID)
|
|
236
|
+
|
|
237
|
+
if not tx_char or not rx_char:
|
|
238
|
+
raise RuntimeError("Required characteristics not found")
|
|
239
|
+
|
|
240
|
+
return tx_char, rx_char
|
|
241
|
+
|
|
242
|
+
def _create_send_history_command(self, start_time):
|
|
243
|
+
end_time = int(datetime.now().timestamp())
|
|
244
|
+
start_time_to_use = int(start_time.timestamp()) if start_time else 0
|
|
245
|
+
|
|
246
|
+
command = bytearray(
|
|
247
|
+
[
|
|
248
|
+
0x3A,
|
|
249
|
+
0x3A,
|
|
250
|
+
0x11, # Header for temperature query
|
|
251
|
+
(end_time >> 24) & 0xFF, # End timestamp byte 1 (most significant)
|
|
252
|
+
(end_time >> 16) & 0xFF, # End timestamp byte 2
|
|
253
|
+
(end_time >> 8) & 0xFF, # End timestamp byte 3
|
|
254
|
+
end_time & 0xFF, # End timestamp byte 4
|
|
255
|
+
(start_time_to_use >> 24) & 0xFF, # Start timestamp byte 1 (most significant)
|
|
256
|
+
(start_time_to_use >> 16) & 0xFF, # Start timestamp byte 2
|
|
257
|
+
(start_time_to_use >> 8) & 0xFF, # Start timestamp byte 3
|
|
258
|
+
start_time_to_use & 0xFF, # Start timestamp byte 4
|
|
259
|
+
]
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return command
|
|
@@ -8,12 +8,11 @@ from typing import Generator, List
|
|
|
8
8
|
from bleson import Observer, get_provider
|
|
9
9
|
|
|
10
10
|
from ruuvitag_sensor.adapters import BleCommunication
|
|
11
|
+
from ruuvitag_sensor.adapters.utils import rssi_to_hex
|
|
11
12
|
from ruuvitag_sensor.ruuvi_types import MacAndRawData, RawData
|
|
12
13
|
|
|
13
14
|
log = logging.getLogger(__name__)
|
|
14
15
|
|
|
15
|
-
# pylint: disable=duplicate-code
|
|
16
|
-
|
|
17
16
|
|
|
18
17
|
class BleCommunicationBleson(BleCommunication):
|
|
19
18
|
"""Bluetooth LE communication with Bleson"""
|
|
@@ -56,6 +55,9 @@ class BleCommunicationBleson(BleCommunication):
|
|
|
56
55
|
data = f"FF{data.hex()}"
|
|
57
56
|
data = f"{(len(data) >> 1):02x}{data}"
|
|
58
57
|
data = f"{(len(data) >> 1):02x}{data}"
|
|
58
|
+
|
|
59
|
+
# Add RSSI to encoded data as hex. All adapters use a common decoder.
|
|
60
|
+
data += rssi_to_hex(advertisement.rssi)
|
|
59
61
|
queue.put((mac, data.upper()))
|
|
60
62
|
except GeneratorExit:
|
|
61
63
|
break
|
|
@@ -72,11 +74,8 @@ class BleCommunicationBleson(BleCommunication):
|
|
|
72
74
|
device (string): BLE device (default 0)
|
|
73
75
|
"""
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
else:
|
|
78
|
-
# Old communication used hci0 etc.
|
|
79
|
-
bt_device = bt_device.replace("hci", "")
|
|
77
|
+
# Old communication used hci0 etc.
|
|
78
|
+
bt_device = 0 if not bt_device else bt_device.replace("hci", "")
|
|
80
79
|
|
|
81
80
|
log.info("Start receiving broadcasts (device %s)", bt_device)
|
|
82
81
|
|
|
@@ -10,8 +10,6 @@ from ruuvitag_sensor.ruuvi_types import MacAndRawData, RawData
|
|
|
10
10
|
|
|
11
11
|
log = logging.getLogger(__name__)
|
|
12
12
|
|
|
13
|
-
# pylint: disable=duplicate-code
|
|
14
|
-
|
|
15
13
|
|
|
16
14
|
class BleCommunicationNix(BleCommunication):
|
|
17
15
|
"""Bluetooth LE communication for Linux"""
|
|
@@ -24,12 +22,12 @@ class BleCommunicationNix(BleCommunication):
|
|
|
24
22
|
"""
|
|
25
23
|
# import ptyprocess here so as long as all implementations are in
|
|
26
24
|
# the same file, all will work
|
|
27
|
-
import ptyprocess
|
|
25
|
+
import ptyprocess
|
|
28
26
|
|
|
29
27
|
if not bt_device:
|
|
30
28
|
bt_device = "hci0"
|
|
31
29
|
|
|
32
|
-
is_root = os.getuid() == 0
|
|
30
|
+
is_root = os.getuid() == 0
|
|
33
31
|
|
|
34
32
|
log.info("Start receiving broadcasts (device %s)", bt_device)
|
|
35
33
|
DEVNULL = subprocess.DEVNULL
|
|
@@ -95,9 +93,8 @@ class BleCommunicationNix(BleCommunication):
|
|
|
95
93
|
data = line[2:].replace(" ", "")
|
|
96
94
|
elif line.startswith("< "):
|
|
97
95
|
data = None
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
data += line.replace(" ", "")
|
|
96
|
+
elif data:
|
|
97
|
+
data += line.replace(" ", "")
|
|
101
98
|
except KeyboardInterrupt:
|
|
102
99
|
return
|
|
103
100
|
except Exception as ex:
|
|
@@ -112,7 +109,7 @@ class BleCommunicationNix(BleCommunication):
|
|
|
112
109
|
log.debug("Parsing line %s", line)
|
|
113
110
|
try:
|
|
114
111
|
# Make sure we're in upper case
|
|
115
|
-
line = line.upper()
|
|
112
|
+
line = line.upper() # noqa: PLW2901
|
|
116
113
|
# We're interested in LE meta events, sent by Ruuvitags.
|
|
117
114
|
# Those start with "043E", followed by a length byte.
|
|
118
115
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
+
import Path
|
|
4
|
+
|
|
3
5
|
from ruuvitag_sensor.adapters.nix_hci import BleCommunicationNix
|
|
4
6
|
|
|
5
7
|
log = logging.getLogger(__name__)
|
|
@@ -19,7 +21,7 @@ class BleCommunicationNixFile(BleCommunicationNix):
|
|
|
19
21
|
This is interpreted as a file to open
|
|
20
22
|
"""
|
|
21
23
|
log.info("Start reading from file %s", bt_device)
|
|
22
|
-
handle = open(bt_device, "rb")
|
|
24
|
+
handle = Path.open(bt_device, "rb")
|
|
23
25
|
|
|
24
26
|
return (None, handle)
|
|
25
27
|
|
ruuvitag_sensor/data_formats.py
CHANGED
|
@@ -35,9 +35,8 @@ class DataFormats:
|
|
|
35
35
|
RuuviTag broadcasted raw data handling for each data format
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
|
-
# pylint: disable=too-many-return-statements
|
|
39
38
|
@staticmethod
|
|
40
|
-
def convert_data(raw: str) -> DataFormatAndRawSensorData:
|
|
39
|
+
def convert_data(raw: str) -> DataFormatAndRawSensorData: # noqa: PLR0911
|
|
41
40
|
"""
|
|
42
41
|
Validate that data is from RuuviTag and get correct data part.
|
|
43
42
|
|
|
@@ -78,7 +77,7 @@ class DataFormats:
|
|
|
78
77
|
break
|
|
79
78
|
except ShortDataError as ex:
|
|
80
79
|
# Data might be from RuuviTag, but received data was invalid
|
|
81
|
-
# e.g. it's
|
|
80
|
+
# e.g. it's possible that Bluetooth stack received only partial data
|
|
82
81
|
# Set the format to None, and data to '', this allows the
|
|
83
82
|
# caller to determine that we did indeed see a Ruuvitag.
|
|
84
83
|
log.debug("Error parsing advertisement data: %s", ex)
|
|
@@ -118,7 +117,7 @@ class DataFormats:
|
|
|
118
117
|
return (None, None)
|
|
119
118
|
|
|
120
119
|
@staticmethod
|
|
121
|
-
def _parse_raw(raw: str, data_format: int) -> str:
|
|
120
|
+
def _parse_raw(raw: str, data_format: int) -> str:
|
|
122
121
|
return raw
|
|
123
122
|
|
|
124
123
|
@staticmethod
|
ruuvitag_sensor/decoder.py
CHANGED
|
@@ -4,7 +4,7 @@ import math
|
|
|
4
4
|
import struct
|
|
5
5
|
from typing import Optional, Tuple, Union
|
|
6
6
|
|
|
7
|
-
from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3, SensorData5, SensorDataUrl
|
|
7
|
+
from ruuvitag_sensor.ruuvi_types import ByteData, SensorData3, SensorData5, SensorDataUrl, SensorHistoryData
|
|
8
8
|
|
|
9
9
|
log = logging.getLogger(__name__)
|
|
10
10
|
|
|
@@ -282,3 +282,135 @@ class Df5Decoder:
|
|
|
282
282
|
except Exception:
|
|
283
283
|
log.exception("Value: %s not valid", data)
|
|
284
284
|
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class HistoryDecoder:
|
|
288
|
+
"""
|
|
289
|
+
Decodes history data from RuuviTag
|
|
290
|
+
Protocol specification:
|
|
291
|
+
https://github.com/ruuvi/docs/blob/master/communication/bluetooth-connection/nordic-uart-service-nus/log-read.md
|
|
292
|
+
|
|
293
|
+
Data format:
|
|
294
|
+
- First byte: Command byte (0x3A)
|
|
295
|
+
- Second byte: Packet type (0x30 = temperature, 0x31 = humidity, 0x32 = pressure)
|
|
296
|
+
- Third byte: Header byte (skipped or error)
|
|
297
|
+
- Next 4 bytes: Clock time (seconds since unix epoch)
|
|
298
|
+
- Next 2 bytes: Reserved (always 0x00)
|
|
299
|
+
- Next 2 bytes: Sensor data (uint16, little-endian)
|
|
300
|
+
Temperature: 0.01°C units
|
|
301
|
+
Humidity: 0.01% units
|
|
302
|
+
Pressure: Raw value in hPa
|
|
303
|
+
|
|
304
|
+
Special case:
|
|
305
|
+
- End marker packet has command byte 0x3A followed by 0x3A
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def _is_error_packet(self, data: list[str]) -> bool:
|
|
309
|
+
"""Check if this is an error packet"""
|
|
310
|
+
return data[2] == "F0" and all(b == "ff" for b in data[3:])
|
|
311
|
+
|
|
312
|
+
def _is_end_marker(self, data: list[str]) -> bool:
|
|
313
|
+
"""Check if this is an end marker packet"""
|
|
314
|
+
# Check for command byte 0x3A, type 0x3A, and remaining bytes are 0xFF
|
|
315
|
+
return data[0] == "3a" and data[1] == "3a" and all(b == "ff" for b in data[3:])
|
|
316
|
+
|
|
317
|
+
def _get_timestamp(self, data: list[str]) -> int:
|
|
318
|
+
"""Return timestamp"""
|
|
319
|
+
# The timestamp is a 4-byte value after the header byte, in seconds since Unix epoch
|
|
320
|
+
timestamp_bytes = bytes.fromhex("".join(data[3:7]))
|
|
321
|
+
timestamp = int.from_bytes(timestamp_bytes, "big")
|
|
322
|
+
return timestamp
|
|
323
|
+
# return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
324
|
+
|
|
325
|
+
def _get_temperature(self, data: list[str]) -> Optional[float]:
|
|
326
|
+
"""Return temperature in celsius"""
|
|
327
|
+
if data[1] != "30": # '0' for temperature
|
|
328
|
+
return None
|
|
329
|
+
# Temperature is in 0.01°C units, little-endian
|
|
330
|
+
temp_bytes = bytes.fromhex("".join(data[9:11]))
|
|
331
|
+
temp_raw = int.from_bytes(temp_bytes, "big")
|
|
332
|
+
return round(temp_raw * 0.01, 2)
|
|
333
|
+
|
|
334
|
+
def _get_humidity(self, data: list[str]) -> Optional[float]:
|
|
335
|
+
"""Return humidity %"""
|
|
336
|
+
if data[1] != "31": # '1' for humidity
|
|
337
|
+
return None
|
|
338
|
+
# Humidity is in 0.01% units, little-endian
|
|
339
|
+
humidity_bytes = bytes.fromhex("".join(data[9:11]))
|
|
340
|
+
humidity_raw = int.from_bytes(humidity_bytes, "big")
|
|
341
|
+
return round(humidity_raw * 0.01, 2)
|
|
342
|
+
|
|
343
|
+
def _get_pressure(self, data: list[str]) -> Optional[float]:
|
|
344
|
+
"""Return air pressure hPa"""
|
|
345
|
+
if data[1] != "32": # '2' for pressure
|
|
346
|
+
return None
|
|
347
|
+
# Pressure is in hPa units, little-endian
|
|
348
|
+
pressure_bytes = bytes.fromhex("".join(data[9:11]))
|
|
349
|
+
pressure_raw = int.from_bytes(pressure_bytes, "big")
|
|
350
|
+
return float(pressure_raw)
|
|
351
|
+
|
|
352
|
+
def decode_data(self, data: bytearray) -> Optional[SensorHistoryData]: # noqa: PLR0911
|
|
353
|
+
"""
|
|
354
|
+
Decode history data from RuuviTag.
|
|
355
|
+
|
|
356
|
+
The data format follows the NUS log format.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
data: Raw history data bytearray
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
SensorDataHistory: Decoded sensor values with timestamp, or None if decoding fails
|
|
363
|
+
Returns None for both invalid data and end marker packets
|
|
364
|
+
"""
|
|
365
|
+
try:
|
|
366
|
+
hex_values = [format(x, "02x") for x in data]
|
|
367
|
+
|
|
368
|
+
if len(hex_values) != 11:
|
|
369
|
+
log.info("History data too short: %d bytes", len(hex_values))
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
# Verify this is a history log entry
|
|
373
|
+
if hex_values[0] != "3a": # ':'
|
|
374
|
+
log.info("Invalid command byte: %d", data[0])
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
# Check for error header
|
|
378
|
+
if self._is_error_packet(hex_values):
|
|
379
|
+
log.info("Device reported error in log reading")
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
# Check for end marker packet
|
|
383
|
+
if self._is_end_marker(hex_values):
|
|
384
|
+
log.debug("End marker packet received")
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
# Each packet type contains one measurement
|
|
388
|
+
packet_type = hex_values[1]
|
|
389
|
+
if packet_type == "30": # '0' temperature
|
|
390
|
+
return {
|
|
391
|
+
"temperature": self._get_temperature(hex_values),
|
|
392
|
+
"humidity": None,
|
|
393
|
+
"pressure": None,
|
|
394
|
+
"timestamp": self._get_timestamp(hex_values),
|
|
395
|
+
}
|
|
396
|
+
elif packet_type == "31": # '1' humidity
|
|
397
|
+
return {
|
|
398
|
+
"temperature": None,
|
|
399
|
+
"humidity": self._get_humidity(hex_values),
|
|
400
|
+
"pressure": None,
|
|
401
|
+
"timestamp": self._get_timestamp(hex_values),
|
|
402
|
+
}
|
|
403
|
+
elif packet_type == "32": # '2' pressure
|
|
404
|
+
return {
|
|
405
|
+
"temperature": None,
|
|
406
|
+
"humidity": None,
|
|
407
|
+
"pressure": self._get_pressure(hex_values),
|
|
408
|
+
"timestamp": self._get_timestamp(hex_values),
|
|
409
|
+
}
|
|
410
|
+
else:
|
|
411
|
+
log.info("Invalid packet type: %d - %s", packet_type, data)
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
except Exception:
|
|
415
|
+
log.exception("Value not valid: %s", data)
|
|
416
|
+
return None
|
ruuvitag_sensor/log.py
CHANGED
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Module level logging configuration for ruuvitag_sensor package.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
1. A root logger for the package with default ERROR level file logging
|
|
6
|
+
2. A function to enable console output, primarily for CLI usage
|
|
7
|
+
|
|
8
|
+
Note: Applications using this package as a library should configure their own logging
|
|
9
|
+
rather than relying on this module's configuration.
|
|
3
10
|
"""
|
|
4
11
|
|
|
5
12
|
import logging
|
|
6
13
|
|
|
14
|
+
# Create the package's root logger
|
|
7
15
|
log = logging.getLogger("ruuvitag_sensor")
|
|
8
16
|
log.setLevel(logging.INFO)
|
|
9
17
|
|
|
10
|
-
#
|
|
18
|
+
# Configure file logging for errors
|
|
11
19
|
file_handler = logging.FileHandler("ruuvitag_sensor.log")
|
|
12
20
|
file_handler.setLevel(logging.ERROR)
|
|
13
21
|
|
|
14
|
-
#
|
|
22
|
+
# Set up a standard logging format with timestamp, logger name, level and message
|
|
15
23
|
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
16
|
-
|
|
17
24
|
file_handler.setFormatter(formatter)
|
|
18
|
-
|
|
19
|
-
# add the handlers to the logger
|
|
20
25
|
log.addHandler(file_handler)
|
|
21
26
|
|
|
22
27
|
|
|
23
|
-
def enable_console():
|
|
28
|
+
def enable_console(level: int = logging.INFO) -> None:
|
|
29
|
+
"""Enable console logging for the package.
|
|
30
|
+
|
|
31
|
+
This function is primarily intended for command-line usage of the package.
|
|
32
|
+
If the requested level is DEBUG, it will also set the root logger's level to DEBUG.
|
|
33
|
+
The function ensures only one console handler is added.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
level: The logging level for console output. Defaults to INFO.
|
|
37
|
+
"""
|
|
38
|
+
if level < logging.INFO:
|
|
39
|
+
log.setLevel(level)
|
|
40
|
+
|
|
24
41
|
if len(log.handlers) != 2:
|
|
25
42
|
console_handler = logging.StreamHandler()
|
|
26
|
-
console_handler.setLevel(
|
|
43
|
+
console_handler.setLevel(level)
|
|
27
44
|
log.addHandler(console_handler)
|