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.

@@ -1,8 +1,3 @@
1
- try:
2
- import importlib.metadata # >=3.8
1
+ import importlib.metadata
3
2
 
4
- __version__ = importlib.metadata.version(__package__ or __name__) # pylint: disable=no-member
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__)
@@ -75,6 +75,6 @@ if __name__ == "__main__":
75
75
  sys.exit(0)
76
76
 
77
77
  if is_async_adapter(ruuvitag_sensor.ruuvi.ble):
78
- asyncio.get_event_loop().run_until_complete(_async_main_handle(args))
78
+ asyncio.run(_async_main_handle(args))
79
79
  else:
80
80
  _sync_main_handle(args)
@@ -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
- if "bleak" in os.environ.get("RUUVI_BLE_ADAPTER", "").lower():
13
- from ruuvitag_sensor.adapters.bleak_ble import BleCommunicationBleak
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
- return BleCommunicationBleak()
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
- return BleCommunicationBleson()
20
- if "RUUVI_NIX_FROMFILE" in os.environ:
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
- if "CI" in os.environ:
26
- # Use BleCommunicationDummy for CI as it can't use bluez
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
- # BlueZ is default for Linux
36
- from ruuvitag_sensor.adapters.nix_hci import BleCommunicationNix
41
+ # Bleak is default adapter for all platforms
42
+ from ruuvitag_sensor.adapters.bleak_ble import BleCommunicationBleak
37
43
 
38
- return BleCommunicationNix()
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: # pylint: disable=unreachable,using-constant-test
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 typing import AsyncGenerator, List, Tuple
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
- return BleakScanner(detection_callback=detection_callback, scanning_mode=scanning_mode)
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
- # TODO: Python 3.7 - TypeError: 'type' object is not subscriptable
32
- # queue = asyncio.Queue[Tuple[str, str]]()
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 += hex((advertisement_data.rssi + (1 << 8)) % (1 << 8)).replace("0x", "")
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
- if not bt_device:
76
- bt_device = 0
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
 
@@ -33,11 +33,9 @@ class DevBleakScanner:
33
33
  async def start(self):
34
34
  self.running = True
35
35
  asyncio.create_task(self.run())
36
- return None
37
36
 
38
37
  async def stop(self):
39
38
  self.running = False
40
- return None
41
39
 
42
40
  async def run(self):
43
41
  while self.running:
@@ -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 # pylint: disable=import-outside-toplevel
25
+ import ptyprocess
28
26
 
29
27
  if not bt_device:
30
28
  bt_device = "hci0"
31
29
 
32
- is_root = os.getuid() == 0 # pylint: disable=no-member
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
- else:
99
- if data:
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") # pylint: disable=consider-using-with
24
+ handle = Path.open(bt_device, "rb")
23
25
 
24
26
  return (None, handle)
25
27
 
@@ -0,0 +1,2 @@
1
+ def rssi_to_hex(rssi: int) -> str:
2
+ return f"{(rssi + (1 << 8)) % (1 << 8):x}"
@@ -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 possile that bluetooth stack received only partial data
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: # pylint: disable=unused-argument
120
+ def _parse_raw(raw: str, data_format: int) -> str:
122
121
  return raw
123
122
 
124
123
  @staticmethod
@@ -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
- ruuvitag_sensor module level logging
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
- # create a file handler
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
- # create a logging format
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(logging.INFO)
43
+ console_handler.setLevel(level)
27
44
  log.addHandler(console_handler)