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 +53 -8
- aiobmsble/__main__.py +51 -27
- aiobmsble/basebms.py +266 -50
- aiobmsble/utils.py +95 -5
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.0.dist-info}/METADATA +22 -14
- aiobmsble-0.2.0.dist-info/RECORD +10 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.0.dist-info}/WHEEL +1 -1
- aiobmsble-0.1.0.dist-info/RECORD +0 -10
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.0.dist-info}/entry_points.txt +0 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.0.dist-info}/top_level.txt +0 -0
aiobmsble/__init__.py
CHANGED
@@ -1,9 +1,12 @@
|
|
1
1
|
"""Package for battery management systems (BMS) via Bluetooth LE."""
|
2
2
|
|
3
|
-
from
|
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
|
-
#
|
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
|
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
|
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.
|
14
|
-
from aiobmsble.utils import
|
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(
|
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
|
30
|
-
"""
|
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
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
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
|
-
|
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.
|
53
|
-
|
54
|
-
|
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[
|
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:
|
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(
|
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:
|
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
|
167
|
-
|
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
|
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(
|
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
|
260
|
+
except Exception as exc:
|
215
261
|
self._log.info(
|
216
|
-
"failed to initialize BMS connection (%s)", type(
|
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:
|
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
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
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
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
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
|
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
|
-
|
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
|
313
|
-
|
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
|
12
|
+
from aiobmsble import MatcherPattern
|
9
13
|
from aiobmsble.basebms import BaseBMS
|
10
14
|
|
11
15
|
|
12
|
-
def
|
13
|
-
matcher:
|
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
|
-
|
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
|
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.
|
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~=
|
20
|
-
Requires-Dist: bleak-retry-connector~=
|
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
|
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.
|
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
|
-
"""
|
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
|
-
|
82
|
+
logger.error("Device '%s' not found.", dev_name)
|
76
83
|
return
|
77
84
|
|
78
|
-
|
85
|
+
logger.info("Found device: %s (%s)", device.name, device.address)
|
79
86
|
bms = BMS(ble_device=device, reconnect=True)
|
80
87
|
try:
|
81
|
-
|
88
|
+
logger.info("Updating BMS data...")
|
82
89
|
data: BMSsample = await bms.async_update()
|
83
|
-
|
90
|
+
logger.info("BMS data: %s", repr(data).replace(", ", ",\n\t"))
|
84
91
|
except BleakError as ex:
|
85
|
-
|
92
|
+
logger.error("Failed to update BMS: %s", type(ex).__name__)
|
86
93
|
|
87
94
|
|
88
|
-
|
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,,
|
aiobmsble-0.1.0.dist-info/RECORD
DELETED
@@ -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,,
|
File without changes
|
File without changes
|
File without changes
|