aiobmsble 0.1.0__py3-none-any.whl → 0.2.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.
aiobmsble/__init__.py CHANGED
@@ -1,9 +1,12 @@
1
1
  """Package for battery management systems (BMS) via Bluetooth LE."""
2
2
 
3
- from typing import Literal, TypedDict
3
+ from collections.abc import Callable
4
+ from enum import IntEnum
5
+ from typing import Any, Literal, NamedTuple, TypedDict
4
6
 
5
7
  type BMSvalue = Literal[
6
8
  "battery_charging",
9
+ "battery_mode",
7
10
  "battery_level",
8
11
  "current",
9
12
  "power",
@@ -15,36 +18,77 @@ type BMSvalue = Literal[
15
18
  "delta_voltage",
16
19
  "problem",
17
20
  "runtime",
21
+ "balance_current",
22
+ "cell_count",
18
23
  "cell_voltages",
19
24
  "design_capacity",
25
+ "pack_count",
26
+ "temp_sensors",
20
27
  "temp_values",
21
28
  "problem_code",
22
29
  ]
23
30
 
31
+ type BMSpackvalue = Literal[
32
+ "pack_voltages",
33
+ "pack_currents",
34
+ "pack_battery_levels",
35
+ "pack_cycles",
36
+ ]
37
+
38
+
39
+ class BMSmode(IntEnum):
40
+ """Enumeration of BMS modes."""
41
+
42
+ UNKNOWN = -1
43
+ BULK = 0x00
44
+ ABSORPTION = 0x01
45
+ FLOAT = 0x02
46
+
24
47
 
25
48
  class BMSsample(TypedDict, total=False):
26
49
  """Dictionary representing a sample of battery management system (BMS) data."""
27
50
 
28
- battery_charging: bool
51
+ battery_charging: bool # True: battery charging
52
+ battery_mode: BMSmode # BMS charging mode
29
53
  battery_level: int | float # [%]
30
- current: float # [A]
31
- power: float # [W]
54
+ current: float # [A] (positive: charging)
55
+ power: float # [W] (positive: charging)
32
56
  temperature: int | float # [°C]
33
57
  voltage: float # [V]
34
58
  cycle_capacity: int | float # [Wh]
35
59
  cycles: int # [#]
36
60
  delta_voltage: float # [V]
37
- problem: bool
61
+ problem: bool # True: problem detected
38
62
  runtime: int # [s]
39
- # internal
63
+ # detailed information
64
+ balance_current: float # [A]
65
+ cell_count: int # [#]
40
66
  cell_voltages: list[float] # [V]
41
67
  cycle_charge: int | float # [Ah]
42
68
  design_capacity: int # [Ah]
69
+ pack_count: int # [#]
70
+ temp_sensors: int # [#]
43
71
  temp_values: list[int | float] # [°C]
44
- problem_code: int
72
+ problem_code: int # BMS specific code, 0 no problem
73
+ # battery pack data
74
+ pack_voltages: list[float] # [V]
75
+ pack_currents: list[float] # [A]
76
+ pack_battery_levels: list[int | float] # [%]
77
+ pack_cycles: list[int] # [#]
78
+
79
+
80
+ class BMSdp(NamedTuple):
81
+ """Representation of one BMS data point."""
82
+
83
+ key: BMSvalue # the key of the value to be parsed
84
+ pos: int # position within the message
85
+ size: int # size in bytes
86
+ signed: bool # signed value
87
+ fct: Callable[[int], Any] = lambda x: x # conversion function (default do nothing)
88
+ idx: int = -1 # array index containing the message to be parsed
45
89
 
46
90
 
47
- class AdvertisementPattern(TypedDict, total=False):
91
+ class MatcherPattern(TypedDict, total=False):
48
92
  """Optional patterns that can match Bleak advertisement data."""
49
93
 
50
94
  local_name: str # name pattern that supports Unix shell-style wildcards
@@ -52,3 +96,4 @@ class AdvertisementPattern(TypedDict, total=False):
52
96
  service_data_uuid: str # service data for the service UUID
53
97
  manufacturer_id: int # required manufacturer ID
54
98
  manufacturer_data_start: list[int] # required starting bytes of manufacturer data
99
+ connectable: bool # True if active connections to the device are required
aiobmsble/__main__.py CHANGED
@@ -1,8 +1,9 @@
1
1
  """Example function for package usage."""
2
2
 
3
+ import argparse
3
4
  import asyncio
4
5
  import logging
5
- from types import ModuleType
6
+ from typing import Final
6
7
 
7
8
  from bleak import BleakScanner
8
9
  from bleak.backends.device import BLEDevice
@@ -10,31 +11,31 @@ from bleak.backends.scanner import AdvertisementData
10
11
  from bleak.exc import BleakError
11
12
 
12
13
  from aiobmsble import BMSsample
13
- from aiobmsble.bms import ogt_bms
14
- from aiobmsble.utils import bms_supported
15
-
16
- bms_plugins: list[ModuleType] = [ogt_bms]
14
+ from aiobmsble.basebms import BaseBMS
15
+ from aiobmsble.utils import bms_identify
17
16
 
18
17
  logging.basicConfig(
19
18
  format="%(levelname)s: %(message)s",
20
19
  level=logging.INFO,
21
20
  )
22
- logger: logging.Logger = logging.getLogger(__name__)
23
-
24
- logger.info(
25
- "loaded BMS types: %s", [key.__name__.rsplit(".", 1)[-1] for key in bms_plugins]
26
- )
21
+ logger: logging.Logger = logging.getLogger(__package__)
27
22
 
28
23
 
29
- async def detect_bms() -> None:
30
- """Query a Bluetooth device based on the provided arguments."""
31
-
24
+ async def scan_devices() -> dict[str, tuple[BLEDevice, AdvertisementData]]:
25
+ """Scan for BLE devices and return results."""
32
26
  logger.info("starting scan...")
33
27
  scan_result: dict[str, tuple[BLEDevice, AdvertisementData]] = (
34
28
  await BleakScanner.discover(return_adv=True)
35
29
  )
30
+ logger.info(scan_result)
36
31
  logger.info("%i BT devices in range.", len(scan_result))
32
+ return scan_result
37
33
 
34
+
35
+ async def detect_bms() -> None:
36
+ """Query a Bluetooth device based on the provided arguments."""
37
+
38
+ scan_result: dict[str, tuple[BLEDevice, AdvertisementData]] = await scan_devices()
38
39
  for ble_dev, advertisement in scan_result.values():
39
40
  logger.info(
40
41
  "%s\nBT device '%s' (%s)\n\t%s",
@@ -43,27 +44,50 @@ async def detect_bms() -> None:
43
44
  ble_dev.address,
44
45
  repr(advertisement).replace(", ", ",\n\t"),
45
46
  )
46
- for bms_module in bms_plugins:
47
- if bms_supported(bms_module.BMS, advertisement):
48
- logger.info(
49
- "Found matching BMS type: %s",
50
- bms_module.__name__.rsplit(".", maxsplit=1)[-1],
51
- )
52
- bms = bms_module.BMS(ble_device=ble_dev, reconnect=True)
53
- try:
54
- logger.info("Updating BMS data...")
55
- data: BMSsample = await bms.async_update()
56
- logger.info("BMS data: %s", repr(data).replace(", ", ",\n\t"))
57
- except BleakError as ex:
58
- logger.error("Failed to update BMS: %s", ex)
47
+
48
+ if bms_cls := bms_identify(advertisement):
49
+ logger.info("Found matching BMS type: %s", bms_cls.device_id())
50
+ bms: BaseBMS = bms_cls(ble_device=ble_dev, reconnect=True)
51
+
52
+ try:
53
+ logger.info("Updating BMS data...")
54
+ data: BMSsample = await bms.async_update()
55
+ logger.info("BMS data: %s", repr(data).replace(", '", ",\n\t'"))
56
+ except (BleakError, TimeoutError) as exc:
57
+ logger.error("Failed to update BMS: %s", type(exc).__name__)
59
58
 
60
59
  logger.info("done.")
61
60
 
62
61
 
62
+ def setup_logging(args: argparse.Namespace) -> None:
63
+ """Configure logging based on command line arguments."""
64
+ loglevel: Final[int] = logging.DEBUG if args.verbose else logging.INFO
65
+
66
+ if args.logfile:
67
+ file_handler = logging.FileHandler(args.logfile)
68
+ file_handler.setLevel(loglevel)
69
+ file_handler.setFormatter(
70
+ logging.Formatter("%(asctime)s - %(levelname)s: %(message)s")
71
+ )
72
+ logger.addHandler(file_handler)
73
+
74
+ logger.setLevel(loglevel)
75
+
76
+
63
77
  def main() -> None:
64
78
  """Entry point for the script to run the BMS detection."""
79
+ parser = argparse.ArgumentParser(
80
+ description="Reference script for 'aiobmsble' to show all recognized BMS in range."
81
+ )
82
+ parser.add_argument("-l", "--logfile", type=str, help="Path to the log file")
83
+ parser.add_argument(
84
+ "-v", "--verbose", action="store_true", help="Enable debug logging"
85
+ )
86
+
87
+ setup_logging(parser.parse_args())
88
+
65
89
  asyncio.run(detect_bms())
66
90
 
67
91
 
68
92
  if __name__ == "__main__":
69
- main()
93
+ main() # pragma: no cover
aiobmsble/basebms.py CHANGED
@@ -2,34 +2,46 @@
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
  import asyncio
5
- from collections.abc import Callable
5
+ from collections.abc import Callable, MutableMapping
6
6
  import logging
7
7
  from statistics import fmean
8
- from typing import Any, Final
8
+ from typing import Any, Final, Literal
9
9
 
10
10
  from bleak import BleakClient
11
11
  from bleak.backends.characteristic import BleakGATTCharacteristic
12
12
  from bleak.backends.device import BLEDevice
13
13
  from bleak.exc import BleakError
14
- from bleak_retry_connector import establish_connection
14
+ from bleak_retry_connector import BLEAK_TIMEOUT, establish_connection
15
15
 
16
- from aiobmsble import AdvertisementPattern, BMSsample, BMSvalue
17
-
18
- KEY_CELL_VOLTAGE: Final[str] = "cell#" # [V]
16
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
19
17
 
20
18
 
21
19
  class BaseBMS(ABC):
22
20
  """Abstract base class for battery management system."""
23
21
 
24
- TIMEOUT = 5.0
22
+ MAX_RETRY: Final[int] = 3 # max number of retries for data requests
23
+ TIMEOUT: Final[float] = BLEAK_TIMEOUT / 4 # default timeout for BMS operations
24
+ # calculate time between retries to complete all retries (2 modes) in TIMEOUT seconds
25
+ _RETRY_TIMEOUT: Final[float] = TIMEOUT / (2**MAX_RETRY - 1)
26
+ _MAX_TIMEOUT_FACTOR: Final[int] = 8 # limit timout increase to 8x
25
27
  _MAX_CELL_VOLT: Final[float] = 5.906 # max cell potential
26
28
  _HRS_TO_SECS: Final[int] = 60 * 60 # seconds in an hour
27
29
 
30
+ class PrefixAdapter(logging.LoggerAdapter):
31
+ """Logging adpater to add instance ID to each log message."""
32
+
33
+ def process(
34
+ self, msg: str, kwargs: MutableMapping[str, Any]
35
+ ) -> tuple[str, MutableMapping[str, Any]]:
36
+ """Process the logging message."""
37
+ prefix: str = str(self.extra.get("prefix") if self.extra else "")
38
+ return (f"{prefix} {msg}", kwargs)
39
+
28
40
  def __init__(
29
41
  self,
30
- logger_name: str,
31
42
  ble_device: BLEDevice,
32
43
  reconnect: bool = False,
44
+ logger_name: str = "",
33
45
  ) -> None:
34
46
  """Intialize the BMS.
35
47
 
@@ -49,9 +61,11 @@ class BaseBMS(ABC):
49
61
  self._ble_device: Final[BLEDevice] = ble_device
50
62
  self._reconnect: Final[bool] = reconnect
51
63
  self.name: Final[str] = self._ble_device.name or "undefined"
52
- self._log: Final[logging.Logger] = logging.getLogger(
53
- f"{logger_name.replace('.plugins', '')}::{self.name}:"
54
- f"{self._ble_device.address[-5:].replace(':','')})"
64
+ self._inv_wr_mode: bool | None = None # invert write mode (WNR <-> W)
65
+ logger_name = logger_name or self.__class__.__module__
66
+ self._log: Final[BaseBMS.PrefixAdapter] = BaseBMS.PrefixAdapter(
67
+ logging.getLogger(f"{logger_name}"),
68
+ {"prefix": f"{self.name}|{self._ble_device.address[-5:].replace(':','')}:"},
55
69
  )
56
70
 
57
71
  self._log.debug(
@@ -67,7 +81,7 @@ class BaseBMS(ABC):
67
81
 
68
82
  @staticmethod
69
83
  @abstractmethod
70
- def matcher_dict_list() -> list[AdvertisementPattern]:
84
+ def matcher_dict_list() -> list[MatcherPattern]:
71
85
  """Return a list of Bluetooth advertisement matchers."""
72
86
 
73
87
  @staticmethod
@@ -126,55 +140,78 @@ class BaseBMS(ABC):
126
140
  return (value in values) and (value not in data) and using.issubset(data)
127
141
 
128
142
  cell_voltages: Final[list[float]] = data.get("cell_voltages", [])
129
- design_capacity: Final[int | float] = data.get("design_capacity", 0)
130
143
  battery_level: Final[int | float] = data.get("battery_level", 0)
131
- voltage: Final[float] = data.get("voltage", 0)
132
- cycle_charge: Final[int | float] = data.get("cycle_charge", 0)
133
144
  current: Final[float] = data.get("current", 0)
134
145
 
135
146
  calculations: dict[BMSvalue, tuple[set[BMSvalue], Callable[[], Any]]] = {
136
147
  "voltage": ({"cell_voltages"}, lambda: round(sum(cell_voltages), 3)),
137
148
  "delta_voltage": (
138
149
  {"cell_voltages"},
139
- lambda: round(max(cell_voltages) - min(cell_voltages), 3),
150
+ lambda: (
151
+ round(max(cell_voltages) - min(cell_voltages), 3)
152
+ if len(cell_voltages)
153
+ else None
154
+ ),
140
155
  ),
141
156
  "cycle_charge": (
142
157
  {"design_capacity", "battery_level"},
143
- lambda: (design_capacity * battery_level) / 100,
158
+ lambda: (data.get("design_capacity", 0) * battery_level) / 100,
159
+ ),
160
+ "battery_level": (
161
+ {"design_capacity", "cycle_charge"},
162
+ lambda: round(
163
+ data.get("cycle_charge", 0) / data.get("design_capacity", 0) * 100,
164
+ 1,
165
+ ),
144
166
  ),
145
167
  "cycle_capacity": (
146
168
  {"voltage", "cycle_charge"},
147
- lambda: voltage * cycle_charge,
169
+ lambda: round(data.get("voltage", 0) * data.get("cycle_charge", 0), 3),
170
+ ),
171
+ "power": (
172
+ {"voltage", "current"},
173
+ lambda: round(data.get("voltage", 0) * current, 3),
148
174
  ),
149
- "power": ({"voltage", "current"}, lambda: round(voltage * current, 3)),
150
175
  "battery_charging": ({"current"}, lambda: current > 0),
151
176
  "runtime": (
152
177
  {"current", "cycle_charge"},
153
178
  lambda: (
154
- int(cycle_charge / abs(current) * BaseBMS._HRS_TO_SECS)
179
+ int(
180
+ data.get("cycle_charge", 0)
181
+ / abs(current)
182
+ * BaseBMS._HRS_TO_SECS
183
+ )
155
184
  if current < 0
156
185
  else None
157
186
  ),
158
187
  ),
159
188
  "temperature": (
160
189
  {"temp_values"},
161
- lambda: round(fmean(data.get("temp_values", [])), 3),
190
+ lambda: (
191
+ round(fmean(data.get("temp_values", [])), 3)
192
+ if data.get("temp_values")
193
+ else None
194
+ ),
162
195
  ),
163
196
  }
164
197
 
165
198
  for attr, (required, calc_func) in calculations.items():
166
- if can_calc(attr, frozenset(required)):
167
- data[attr] = calc_func()
199
+ if (
200
+ can_calc(attr, frozenset(required))
201
+ and (value := calc_func()) is not None
202
+ ):
203
+ data[attr] = value
168
204
 
169
205
  # do sanity check on values to set problem state
170
206
  data["problem"] = any(
171
207
  [
172
208
  data.get("problem", False),
173
209
  data.get("problem_code", False),
174
- voltage <= 0,
210
+ data.get("voltage") is not None and data.get("voltage", 0) <= 0,
175
211
  any(v <= 0 or v > BaseBMS._MAX_CELL_VOLT for v in cell_voltages),
176
212
  data.get("delta_voltage", 0) > BaseBMS._MAX_CELL_VOLT,
177
- cycle_charge <= 0,
213
+ data.get("cycle_charge") is not None
214
+ and data.get("cycle_charge", 0.0) <= 0.0,
178
215
  battery_level > 100,
179
216
  ]
180
217
  )
@@ -184,13 +221,15 @@ class BaseBMS(ABC):
184
221
 
185
222
  self._log.debug("disconnected from BMS")
186
223
 
187
- async def _init_connection(self) -> None:
224
+ async def _init_connection(
225
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
226
+ ) -> None:
188
227
  # reset any stale data from BMS
189
228
  self._data.clear()
190
229
  self._data_event.clear()
191
230
 
192
231
  await self._client.start_notify(
193
- self.uuid_rx(), getattr(self, "_notification_handler")
232
+ char_notify or self.uuid_rx(), getattr(self, "_notification_handler")
194
233
  )
195
234
 
196
235
  async def _connect(self) -> None:
@@ -200,6 +239,13 @@ class BaseBMS(ABC):
200
239
  self._log.debug("BMS already connected")
201
240
  return
202
241
 
242
+ try:
243
+ await self._client.disconnect() # ensure no stale connection exists
244
+ except (BleakError, TimeoutError) as exc:
245
+ self._log.debug(
246
+ "failed to disconnect stale connection (%s)", type(exc).__name__
247
+ )
248
+
203
249
  self._log.debug("connecting BMS")
204
250
  self._client = await establish_connection(
205
251
  client_class=BleakClient,
@@ -211,37 +257,98 @@ class BaseBMS(ABC):
211
257
 
212
258
  try:
213
259
  await self._init_connection()
214
- except Exception as err:
260
+ except Exception as exc:
215
261
  self._log.info(
216
- "failed to initialize BMS connection (%s)", type(err).__name__
262
+ "failed to initialize BMS connection (%s)", type(exc).__name__
217
263
  )
218
264
  await self.disconnect()
219
265
  raise
220
266
 
267
+ def _wr_response(self, char: int | str) -> bool:
268
+ char_tx: Final[BleakGATTCharacteristic | None] = (
269
+ self._client.services.get_characteristic(char)
270
+ )
271
+ return bool(char_tx and "write" in getattr(char_tx, "properties", []))
272
+
273
+ async def _send_msg(
274
+ self,
275
+ data: bytes,
276
+ max_size: int,
277
+ char: int | str,
278
+ attempt: int,
279
+ inv_wr_mode: bool = False,
280
+ ) -> None:
281
+ """Send message to the bms in chunks if needed."""
282
+ chunk_size: Final[int] = max_size or len(data)
283
+
284
+ for i in range(0, len(data), chunk_size):
285
+ chunk: bytes = data[i : i + chunk_size]
286
+ self._log.debug(
287
+ "TX BLE req #%i (%s%s%s): %s",
288
+ attempt + 1,
289
+ "!" if inv_wr_mode else "",
290
+ "W" if self._wr_response(char) else "WNR",
291
+ "." if self._inv_wr_mode is not None else "",
292
+ chunk.hex(" "),
293
+ )
294
+ await self._client.write_gatt_char(
295
+ char,
296
+ chunk,
297
+ response=(self._wr_response(char) != inv_wr_mode),
298
+ )
299
+
221
300
  async def _await_reply(
222
301
  self,
223
302
  data: bytes,
224
- char: BleakGATTCharacteristic | int | str | None = None,
303
+ char: int | str | None = None,
225
304
  wait_for_notify: bool = True,
305
+ max_size: int = 0,
226
306
  ) -> None:
227
307
  """Send data to the BMS and wait for valid reply notification."""
228
308
 
229
- self._log.debug("TX BLE data: %s", data.hex(" "))
230
- self._data_event.clear() # clear event before requesting new data
231
- await self._client.write_gatt_char(char or self.uuid_tx(), data, response=False)
232
- if wait_for_notify:
233
- await asyncio.wait_for(self._wait_event(), timeout=self.TIMEOUT)
234
-
235
- async def disconnect(self) -> None:
309
+ for inv_wr_mode in (
310
+ [False, True] if self._inv_wr_mode is None else [self._inv_wr_mode]
311
+ ):
312
+ try:
313
+ self._data_event.clear() # clear event before requesting new data
314
+ for attempt in range(BaseBMS.MAX_RETRY):
315
+ await self._send_msg(
316
+ data, max_size, char or self.uuid_tx(), attempt, inv_wr_mode
317
+ )
318
+ if not wait_for_notify:
319
+ return # write without wait for response selected
320
+ try:
321
+ await asyncio.wait_for(
322
+ self._wait_event(),
323
+ BaseBMS._RETRY_TIMEOUT
324
+ * min(2**attempt, BaseBMS._MAX_TIMEOUT_FACTOR),
325
+ )
326
+ except TimeoutError:
327
+ self._log.debug("TX BLE request timed out.")
328
+ continue # retry sending data
329
+
330
+ self._inv_wr_mode = inv_wr_mode
331
+ return # leave loop if no exception
332
+ except BleakError as exc:
333
+ # reconnect on communication errors
334
+ self._log.warning(
335
+ "TX BLE request error, retrying connection (%s)", type(exc).__name__
336
+ )
337
+ await self.disconnect()
338
+ await self._connect()
339
+ raise TimeoutError
340
+
341
+ async def disconnect(self, reset: bool = False) -> None:
236
342
  """Disconnect the BMS, includes stoping notifications."""
237
343
 
238
- if self._client.is_connected:
239
- self._log.debug("disconnecting BMS")
240
- try:
241
- self._data_event.clear()
242
- await self._client.disconnect()
243
- except BleakError:
244
- self._log.warning("disconnect failed!")
344
+ self._log.debug("disconnecting BMS (%s)", str(self._client.is_connected))
345
+ try:
346
+ self._data_event.clear()
347
+ if reset:
348
+ self._inv_wr_mode = None # reset write mode
349
+ await self._client.disconnect()
350
+ except BleakError:
351
+ self._log.warning("disconnect failed!")
245
352
 
246
353
  async def _wait_event(self) -> None:
247
354
  """Wait for data event and clear it."""
@@ -252,7 +359,7 @@ class BaseBMS(ABC):
252
359
  async def _async_update(self) -> BMSsample:
253
360
  """Return a dictionary of BMS values (keys need to come from the SENSOR_TYPES list)."""
254
361
 
255
- async def async_update(self, raw: bool = False) -> BMSsample:
362
+ async def async_update(self) -> BMSsample:
256
363
  """Retrieve updated values from the BMS using method of the subclass.
257
364
 
258
365
  Args:
@@ -266,8 +373,7 @@ class BaseBMS(ABC):
266
373
  await self._connect()
267
374
 
268
375
  data: BMSsample = await self._async_update()
269
- if not raw:
270
- self._add_missing_values(data, self._calc_values())
376
+ self._add_missing_values(data, self._calc_values())
271
377
 
272
378
  if self._reconnect:
273
379
  # disconnect after data update to force reconnect next time (slow!)
@@ -275,6 +381,107 @@ class BaseBMS(ABC):
275
381
 
276
382
  return data
277
383
 
384
+ @staticmethod
385
+ def _decode_data(
386
+ fields: tuple[BMSdp, ...],
387
+ data: bytearray | dict[int, bytearray],
388
+ *,
389
+ byteorder: Literal["little", "big"] = "big",
390
+ offset: int = 0,
391
+ ) -> BMSsample:
392
+ result: BMSsample = {}
393
+ for field in fields:
394
+ if isinstance(data, dict) and field.idx not in data:
395
+ continue
396
+ msg: bytearray = data[field.idx] if isinstance(data, dict) else data
397
+ result[field.key] = field.fct(
398
+ int.from_bytes(
399
+ msg[offset + field.pos : offset + field.pos + field.size],
400
+ byteorder=byteorder,
401
+ signed=field.signed,
402
+ )
403
+ )
404
+ return result
405
+
406
+ @staticmethod
407
+ def _cell_voltages(
408
+ data: bytearray,
409
+ *,
410
+ cells: int,
411
+ start: int,
412
+ size: int = 2,
413
+ byteorder: Literal["little", "big"] = "big",
414
+ divider: int = 1000,
415
+ ) -> list[float]:
416
+ """Return cell voltages from BMS message.
417
+
418
+ Args:
419
+ data: Raw data from BMS
420
+ cells: Number of cells to read
421
+ start: Start position in data array
422
+ size: Number of bytes per cell value (defaults 2)
423
+ byteorder: Byte order ("big"/"little" endian)
424
+ divider: Value to divide raw value by, defaults to 1000 (mv to V)
425
+
426
+ Returns:
427
+ list[float]: List of cell voltages in volts
428
+
429
+ """
430
+ return [
431
+ value / divider
432
+ for idx in range(cells)
433
+ if (len(data) >= start + (idx + 1) * size)
434
+ and (
435
+ value := int.from_bytes(
436
+ data[start + idx * size : start + (idx + 1) * size],
437
+ byteorder=byteorder,
438
+ signed=False,
439
+ )
440
+ )
441
+ ]
442
+
443
+ @staticmethod
444
+ def _temp_values(
445
+ data: bytearray,
446
+ *,
447
+ values: int,
448
+ start: int,
449
+ size: int = 2,
450
+ byteorder: Literal["little", "big"] = "big",
451
+ signed: bool = True,
452
+ offset: float = 0,
453
+ divider: int = 1,
454
+ ) -> list[int | float]:
455
+ """Return temperature values from BMS message.
456
+
457
+ Args:
458
+ data: Raw data from BMS
459
+ values: Number of values to read
460
+ start: Start position in data array
461
+ size: Number of bytes per cell value (defaults 2)
462
+ byteorder: Byte order ("big"/"little" endian)
463
+ signed: Indicates whether two's complement is used to represent the integer.
464
+ offset: The offset read values are shifted by (for Kelvin use 273.15)
465
+ divider: Value to divide raw value by, defaults to 1000 (mv to V)
466
+
467
+ Returns:
468
+ list[int | float]: List of temperature values
469
+
470
+ """
471
+ return [
472
+ value / divider if divider != 1 else value
473
+ for idx in range(values)
474
+ if (len(data) >= start + (idx + 1) * size)
475
+ and (
476
+ value := int.from_bytes(
477
+ data[start + idx * size : start + (idx + 1) * size],
478
+ byteorder=byteorder,
479
+ signed=signed,
480
+ )
481
+ - offset
482
+ )
483
+ ]
484
+
278
485
 
279
486
  def crc_modbus(data: bytearray) -> int:
280
487
  """Calculate CRC-16-CCITT MODBUS."""
@@ -286,6 +493,11 @@ def crc_modbus(data: bytearray) -> int:
286
493
  return crc & 0xFFFF
287
494
 
288
495
 
496
+ def lrc_modbus(data: bytearray) -> int:
497
+ """Calculate MODBUS LRC."""
498
+ return ((sum(data) ^ 0xFFFF) + 1) & 0xFFFF
499
+
500
+
289
501
  def crc_xmodem(data: bytearray) -> int:
290
502
  """Calculate CRC-16-CCITT XMODEM."""
291
503
  crc: int = 0x0000
@@ -308,6 +520,10 @@ def crc8(data: bytearray) -> int:
308
520
  return crc & 0xFF
309
521
 
310
522
 
311
- def crc_sum(frame: bytearray) -> int:
312
- """Calculate frame CRC."""
313
- return sum(frame) & 0xFF
523
+ def crc_sum(frame: bytearray, size: int = 1) -> int:
524
+ """Calculate the checksum of a frame using a specified size.
525
+
526
+ size : int, optional
527
+ The size of the checksum in bytes (default is 1).
528
+ """
529
+ return sum(frame) & ((1 << (8 * size)) - 1)
aiobmsble/utils.py CHANGED
@@ -1,16 +1,20 @@
1
1
  """Utilitiy/Support functions for aiobmsble."""
2
2
 
3
3
  from fnmatch import translate
4
+ from functools import lru_cache
5
+ import importlib
6
+ import pkgutil
4
7
  import re
8
+ from types import ModuleType
5
9
 
6
10
  from bleak.backends.scanner import AdvertisementData
7
11
 
8
- from aiobmsble import AdvertisementPattern
12
+ from aiobmsble import MatcherPattern
9
13
  from aiobmsble.basebms import BaseBMS
10
14
 
11
15
 
12
- def advertisement_matches(
13
- matcher: AdvertisementPattern,
16
+ def _advertisement_matches(
17
+ matcher: MatcherPattern,
14
18
  adv_data: AdvertisementData,
15
19
  ) -> bool:
16
20
  """Determine whether the given advertisement data matches the specified pattern.
@@ -56,18 +60,104 @@ def advertisement_matches(
56
60
  )
57
61
 
58
62
 
59
- def bms_supported(bms: BaseBMS, adv_data: AdvertisementData) -> bool:
63
+ @lru_cache
64
+ def load_bms_plugins() -> set[ModuleType]:
65
+ """Discover and load all available Battery Management System (BMS) plugin modules.
66
+
67
+ This function scans the 'aiobmsble/bms' directory for all Python modules,
68
+ dynamically imports each discovered module, and returns a set containing
69
+ the imported module objects required to end with "_bms".
70
+
71
+ Returns:
72
+ set[ModuleType]: A set of imported BMS plugin modules.
73
+
74
+ Raises:
75
+ ImportError: If a module cannot be imported.
76
+ OSError: If the plugin directory cannot be accessed.
77
+
78
+ """
79
+ return {
80
+ importlib.import_module(f"aiobmsble.bms.{module_name}")
81
+ for _, module_name, _ in pkgutil.iter_modules(["aiobmsble/bms"])
82
+ if module_name.endswith("_bms")
83
+ }
84
+
85
+
86
+ def bms_cls(name: str) -> type[BaseBMS] | None:
87
+ """Return the BMS class that is defined by the name argument.
88
+
89
+ Args:
90
+ name (str): The name of the BMS type
91
+
92
+ Returns:
93
+ type[BaseBMS] | None: If the BMS class defined by name is found, None otherwise.
94
+
95
+ """
96
+ try:
97
+ bms_module: ModuleType = importlib.import_module(f"aiobmsble.bms.{name}_bms")
98
+ except ModuleNotFoundError:
99
+ return None
100
+ return bms_module.BMS
101
+
102
+
103
+ def bms_matching(
104
+ adv_data: AdvertisementData, mac_addr: str | None = None
105
+ ) -> list[type[BaseBMS]]:
106
+ """Return the BMS classes that match the given advertisement data.
107
+
108
+ Currently the function returns at most one match, but this behaviour might change
109
+ in the future to multiple entries, if BMSs cannot be distinguished uniquely using
110
+ their Bluetooth advertisement / OUI (Organizationally Unique Identifier)
111
+
112
+ Args:
113
+ adv_data (AdvertisementData): The advertisement data to match against available BMS plugins.
114
+ mac_addr (str | None): Optional MAC address to check OUI against
115
+
116
+ Returns:
117
+ list[type[BaseBMS]]: A list of matching BMS class(es) if found, an empty list otherwhise.
118
+
119
+ """
120
+ for bms_module in load_bms_plugins():
121
+ if bms_supported(bms_module.BMS, adv_data, mac_addr):
122
+ return [bms_module.BMS]
123
+ return []
124
+
125
+
126
+ def bms_identify(
127
+ adv_data: AdvertisementData, mac_addr: str | None = None
128
+ ) -> type[BaseBMS] | None:
129
+ """Return the BMS classes that best matches the given advertisement data.
130
+
131
+ Args:
132
+ adv_data (AdvertisementData): The advertisement data to match against available BMS plugins.
133
+ mac_addr (str | None): Optional MAC address to check OUI against
134
+
135
+ Returns:
136
+ type[BaseBMS] | None: The identified BMS class if a match is found, None otherwhise
137
+
138
+ """
139
+
140
+ matching_bms: list[type[BaseBMS]] = bms_matching(adv_data, mac_addr)
141
+ return matching_bms[0] if matching_bms else None
142
+
143
+
144
+ def bms_supported(
145
+ bms: BaseBMS, adv_data: AdvertisementData, mac_addr: str | None = None
146
+ ) -> bool:
60
147
  """Determine if the given BMS is supported based on advertisement data.
61
148
 
62
149
  Args:
63
150
  bms (BaseBMS): The BMS class to check.
64
151
  adv_data (AdvertisementData): The advertisement data to match against.
152
+ mac_addr (str | None): Optional MAC address to check OUI against
65
153
 
66
154
  Returns:
67
155
  bool: True if the BMS is supported, False otherwise.
68
156
 
69
157
  """
158
+ if mac_addr:
159
+ raise NotImplementedError # pragma: no cover
70
160
  for matcher in bms.matcher_dict_list():
71
- if advertisement_matches(matcher, adv_data):
161
+ if _advertisement_matches(matcher, adv_data):
72
162
  return True
73
163
  return False
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiobmsble
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Asynchronous Python library to query battery management systems via Bluetooth Low Energy.
5
5
  Author: Patrick Loschmidt
6
6
  Maintainer: Patrick Loschmidt
7
7
  License-Expression: Apache-2.0
8
8
  Project-URL: Homepage, https://github.com/patman15/aiobmsble/
9
- Project-URL: Documentation, https://github.com/patman15/aiobmsble/
9
+ Project-URL: Documentation, https://github.com/patman15/aiobmsble/README
10
+ Project-URL: Source Code, https://github.com/patman15/aiobmsble/
11
+ Project-URL: Bug Reports, https://github.com/patman15/aiobmsble/issues
10
12
  Keywords: BMS,BLE,battery,bluetooth
11
13
  Classifier: Programming Language :: Python :: 3
12
14
  Classifier: Operating System :: OS Independent
@@ -16,8 +18,8 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
18
  Requires-Python: >=3.12
17
19
  Description-Content-Type: text/markdown
18
20
  License-File: LICENSE
19
- Requires-Dist: bleak~=0.22.3
20
- Requires-Dist: bleak-retry-connector~=3.8.1
21
+ Requires-Dist: bleak~=1.1.0
22
+ Requires-Dist: bleak-retry-connector~=4.0.1
21
23
  Requires-Dist: asyncio
22
24
  Requires-Dist: logging
23
25
  Requires-Dist: statistics
@@ -26,6 +28,7 @@ Provides-Extra: dev
26
28
  Requires-Dist: pytest; extra == "dev"
27
29
  Requires-Dist: pytest-asyncio; extra == "dev"
28
30
  Requires-Dist: pytest-cov; extra == "dev"
31
+ Requires-Dist: pytest-xdist; extra == "dev"
29
32
  Requires-Dist: mypy; extra == "dev"
30
33
  Requires-Dist: ruff; extra == "dev"
31
34
  Dynamic: license-file
@@ -35,9 +38,8 @@ Dynamic: license-file
35
38
  # Aiobmsble
36
39
  Requires Python 3 and uses [asyncio](https://pypi.org/project/asyncio/) and [bleak](https://pypi.org/project/bleak/)
37
40
  > [!IMPORTANT]
38
- > At the moment the library is under development and not all BMS classes have been ported over from the [BMS_BLE-HA integration](https://github.com/patman15/BMS_BLE-HA/)!
41
+ > At the moment the library is under development and there might be missing functionality compared to the [BMS_BLE-HA integration](https://github.com/patman15/BMS_BLE-HA/)!
39
42
  > Please do not (yet) report missing BMS support or bugs here. Instead please raise an issue at the integration till the library reached at least development status *beta*.
40
- > Plan is to support all BMSs that are listed [here](https://github.com/patman15/BMS_BLE-HA/edit/main/README.md#supported-devices).
41
43
 
42
44
  ## Asynchronous Library to Query Battery Management Systems via Bluetooth LE
43
45
  This library is intended to query data from battery management systems that use Bluetooth LE. It is developed to support [BMS_BLE-HA integration](https://github.com/patman15/BMS_BLE-HA/) that was written to make BMS data available to Home Assistant. While the integration depends on Home Assistant, this library can be used stand-alone in any Python environment (with necessary dependencies installed).
@@ -55,6 +57,7 @@ This example can also be found as an [example](/examples/minimal.py) in the resp
55
57
  """Example of using the aiobmsble library to find a BLE device by name and print its senosr data."""
56
58
 
57
59
  import asyncio
60
+ import logging
58
61
  from typing import Final
59
62
 
60
63
  from bleak import BleakScanner
@@ -62,30 +65,35 @@ from bleak.backends.device import BLEDevice
62
65
  from bleak.exc import BleakError
63
66
 
64
67
  from aiobmsble import BMSsample
65
- from aiobmsble.bms.ogt_bms import BMS # use the right BMS class for your device
68
+ from aiobmsble.bms.dummy_bms import BMS # use the right BMS class for your device
66
69
 
67
70
  NAME: Final[str] = "BT Device Name" # Replace with the name of your BLE device
68
71
 
72
+ # Configure logging
73
+ logging.basicConfig(level=logging.INFO)
74
+ logger: logging.Logger = logging.getLogger(__name__)
75
+
69
76
 
70
77
  async def main(dev_name) -> None:
71
- """Main function to find a BLE device by name and update its sensor data."""
78
+ """Find a BLE device by name and update its sensor data."""
72
79
 
73
80
  device: BLEDevice | None = await BleakScanner.find_device_by_name(dev_name)
74
81
  if device is None:
75
- print(f"Device '{dev_name}' not found.")
82
+ logger.error("Device '%s' not found.", dev_name)
76
83
  return
77
84
 
78
- print(f"Found device: {device.name} ({device.address})")
85
+ logger.info("Found device: %s (%s)", device.name, device.address)
79
86
  bms = BMS(ble_device=device, reconnect=True)
80
87
  try:
81
- print("Updating BMS data...")
88
+ logger.info("Updating BMS data...")
82
89
  data: BMSsample = await bms.async_update()
83
- print("BMS data: ", repr(data).replace(", ", ",\n\t"))
90
+ logger.info("BMS data: %s", repr(data).replace(", ", ",\n\t"))
84
91
  except BleakError as ex:
85
- print(f"Failed to update BMS: {ex}")
92
+ logger.error("Failed to update BMS: %s", type(ex).__name__)
86
93
 
87
94
 
88
- asyncio.run(main(NAME))
95
+ if __name__ == "__main__":
96
+ asyncio.run(main(NAME)) # pragma: no cover
89
97
  ```
90
98
 
91
99
  ## Installation
@@ -0,0 +1,10 @@
1
+ aiobmsble/__init__.py,sha256=gwWO0ojesAR4aYsu5P4LZYlaQgkJpMkzg4Q27V3VOrg,2932
2
+ aiobmsble/__main__.py,sha256=7h8ZnUlQ67IMjnRMJJt091ndGIrYuvd5i12OEMxy2j0,3000
3
+ aiobmsble/basebms.py,sha256=bNik9TsHTlAqAR-RPxFFXy6qD1E7l7f53u8wffMgQyU,18556
4
+ aiobmsble/utils.py,sha256=9DQT4y7VGlSgCEjfTyu3fqKxOBu97cT9V8y8Ce1MTgA,5524
5
+ aiobmsble-0.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
6
+ aiobmsble-0.2.0.dist-info/METADATA,sha256=6_PNHNaXNX5GuU_HXB-PWZdrJB5fgKzMoWbYBt0Cj1w,4669
7
+ aiobmsble-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ aiobmsble-0.2.0.dist-info/entry_points.txt,sha256=HSC_C3nQikc3nk0a6mcG92RuIM7wAzozjBVfDojJceo,54
9
+ aiobmsble-0.2.0.dist-info/top_level.txt,sha256=YHBVzg45mJ3vPz0sl_TpMB0edMqqhD61kwJj4EPAk9g,10
10
+ aiobmsble-0.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.4.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,10 +0,0 @@
1
- aiobmsble/__init__.py,sha256=qEmiX9i7aKK7lYl7zrTgNFblb3-WJYTG22tTmAO_uC4,1541
2
- aiobmsble/__main__.py,sha256=ycvwDkcph4M1UzsBGJzkQWkLS6CW5sPfEG9QK-Q691I,2102
3
- aiobmsble/basebms.py,sha256=ZHG34fmDQ5wz58GpS7yI09ICdot7iyvdhPg6zt9U6vw,10699
4
- aiobmsble/utils.py,sha256=BGcwcfOpS5KW942J7uHRrBkmh9rdMW64OvDRyHIP9lQ,2535
5
- aiobmsble-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
6
- aiobmsble-0.1.0.dist-info/METADATA,sha256=THqATDPH5bBlgIb363ZpqCkOh5S4FPYkQSPnPDW4xdE,4395
7
- aiobmsble-0.1.0.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
8
- aiobmsble-0.1.0.dist-info/entry_points.txt,sha256=HSC_C3nQikc3nk0a6mcG92RuIM7wAzozjBVfDojJceo,54
9
- aiobmsble-0.1.0.dist-info/top_level.txt,sha256=YHBVzg45mJ3vPz0sl_TpMB0edMqqhD61kwJj4EPAk9g,10
10
- aiobmsble-0.1.0.dist-info/RECORD,,