ruuvitag-sensor 2.3.1__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,12 +1,9 @@
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, too-many-return-statements
9
-
10
7
 
11
8
  def get_ble_adapter():
12
9
  forced_ble_adapter = os.environ.get("RUUVI_BLE_ADAPTER", "").lower()
@@ -36,21 +33,15 @@ def get_ble_adapter():
36
33
  return BleCommunicationNixFile()
37
34
 
38
35
  if is_ci_env:
39
- # Use BleCommunicationDummy for CI as it can't use BlueZ
36
+ # Use BleCommunicationDummy for CI as it can't use Bleak/BlueZ
40
37
  from ruuvitag_sensor.adapters.dummy import BleCommunicationDummy
41
38
 
42
39
  return BleCommunicationDummy()
43
40
 
44
- # Use default adapter for platform
45
- if sys.platform.startswith("win") or sys.platform.startswith("darwin"):
46
- from ruuvitag_sensor.adapters.bleak_ble import BleCommunicationBleak
47
-
48
- return BleCommunicationBleak()
49
-
50
- # BlueZ is default for Linux
51
- 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
52
43
 
53
- return BleCommunicationNix()
44
+ return BleCommunicationBleak()
54
45
 
55
46
 
56
47
  def is_async_adapter(ble: object):
@@ -105,5 +96,5 @@ class BleCommunicationAsync:
105
96
  # if False: yield is a mypy fix for
106
97
  # error: Return type "AsyncGenerator[Tuple[str, str], None]" of "get_data" incompatible with return type
107
98
  # "Coroutine[Any, Any, AsyncGenerator[Tuple[str, str], None]]" in supertype "BleCommunicationAsync"
108
- if False: # pylint: disable=unreachable,using-constant-test
99
+ if False:
109
100
  yield 0
@@ -3,9 +3,10 @@ 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 import BleakClient, BleakGATTCharacteristic, BleakScanner
9
10
  from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback, BLEDevice
10
11
 
11
12
  from ruuvitag_sensor.adapters import BleCommunicationAsync
@@ -13,6 +14,9 @@ from ruuvitag_sensor.adapters.utils import rssi_to_hex
13
14
  from ruuvitag_sensor.ruuvi_types import MacAndRawData, RawData
14
15
 
15
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
16
20
 
17
21
 
18
22
  def _get_scanner(detection_callback: AdvertisementDataCallback, bt_device: str = ""):
@@ -21,22 +25,21 @@ def _get_scanner(detection_callback: AdvertisementDataCallback, bt_device: str =
21
25
  scanning_mode = "passive" if sys.platform.startswith("win") else "active"
22
26
 
23
27
  if "bleak_dev" in os.environ.get("RUUVI_BLE_ADAPTER", "").lower():
24
- # pylint: disable=import-outside-toplevel
25
28
  from ruuvitag_sensor.adapters.development.dev_bleak_scanner import DevBleakScanner
26
29
 
27
30
  return DevBleakScanner(detection_callback, scanning_mode)
28
31
 
29
32
  if bt_device:
30
33
  return BleakScanner(
31
- detection_callback=detection_callback, scanning_mode=scanning_mode, adapter=bt_device
32
- ) # type: ignore[arg-type]
34
+ detection_callback=detection_callback,
35
+ scanning_mode=scanning_mode, # type: ignore[arg-type]
36
+ adapter=bt_device,
37
+ )
33
38
 
34
39
  return BleakScanner(detection_callback=detection_callback, scanning_mode=scanning_mode) # type: ignore[arg-type]
35
40
 
36
41
 
37
- # TODO: Python 3.7 - TypeError: 'type' object is not subscriptable
38
- # queue = asyncio.Queue[Tuple[str, str]]()
39
- queue = asyncio.Queue() # type: ignore
42
+ queue = asyncio.Queue[Tuple[str, str]]()
40
43
 
41
44
  log = logging.getLogger(__name__)
42
45
 
@@ -121,3 +124,139 @@ class BleCommunicationBleak(BleCommunicationAsync):
121
124
  await data_iter.aclose()
122
125
 
123
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
@@ -13,8 +13,6 @@ from ruuvitag_sensor.ruuvi_types import MacAndRawData, RawData
13
13
 
14
14
  log = logging.getLogger(__name__)
15
15
 
16
- # pylint: disable=duplicate-code
17
-
18
16
 
19
17
  class BleCommunicationBleson(BleCommunication):
20
18
  """Bluetooth LE communication with Bleson"""
@@ -76,11 +74,8 @@ class BleCommunicationBleson(BleCommunication):
76
74
  device (string): BLE device (default 0)
77
75
  """
78
76
 
79
- if not bt_device:
80
- bt_device = 0
81
- else:
82
- # Old communication used hci0 etc.
83
- 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", "")
84
79
 
85
80
  log.info("Start receiving broadcasts (device %s)", bt_device)
86
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
 
@@ -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
 
@@ -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)