aiobmsble 0.2.0__py3-none-any.whl → 0.2.2__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 +5 -1
- aiobmsble/__main__.py +5 -1
- aiobmsble/basebms.py +10 -1
- aiobmsble/bms/__init__.py +5 -0
- aiobmsble/bms/abc_bms.py +168 -0
- aiobmsble/bms/ant_bms.py +200 -0
- aiobmsble/bms/braunpwr_bms.py +171 -0
- aiobmsble/bms/cbtpwr_bms.py +172 -0
- aiobmsble/bms/cbtpwr_vb_bms.py +188 -0
- aiobmsble/bms/daly_bms.py +168 -0
- aiobmsble/bms/dpwrcore_bms.py +211 -0
- aiobmsble/bms/dummy_bms.py +93 -0
- aiobmsble/bms/ecoworthy_bms.py +155 -0
- aiobmsble/bms/ective_bms.py +181 -0
- aiobmsble/bms/ej_bms.py +237 -0
- aiobmsble/bms/felicity_bms.py +143 -0
- aiobmsble/bms/jbd_bms.py +207 -0
- aiobmsble/bms/jikong_bms.py +305 -0
- aiobmsble/bms/neey_bms.py +218 -0
- aiobmsble/bms/ogt_bms.py +218 -0
- aiobmsble/bms/pro_bms.py +148 -0
- aiobmsble/bms/redodo_bms.py +131 -0
- aiobmsble/bms/renogy_bms.py +152 -0
- aiobmsble/bms/renogy_pro_bms.py +109 -0
- aiobmsble/bms/roypow_bms.py +190 -0
- aiobmsble/bms/seplos_bms.py +249 -0
- aiobmsble/bms/seplos_v2_bms.py +209 -0
- aiobmsble/bms/tdt_bms.py +203 -0
- aiobmsble/bms/tianpwr_bms.py +142 -0
- aiobmsble/utils.py +16 -6
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/METADATA +3 -2
- aiobmsble-0.2.2.dist-info/RECORD +36 -0
- aiobmsble-0.2.0.dist-info/RECORD +0 -10
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/WHEEL +0 -0
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/entry_points.txt +0 -0
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/top_level.txt +0 -0
aiobmsble/__init__.py
CHANGED
@@ -1,4 +1,8 @@
|
|
1
|
-
"""Package for battery management systems (BMS) via Bluetooth LE.
|
1
|
+
"""Package for battery management systems (BMS) via Bluetooth LE (aiobmsble).
|
2
|
+
|
3
|
+
Project: aiobmsble, https://pypi.org/p/aiobmsble/
|
4
|
+
License: Apache-2.0, http://www.apache.org/licenses/
|
5
|
+
"""
|
2
6
|
|
3
7
|
from collections.abc import Callable
|
4
8
|
from enum import IntEnum
|
aiobmsble/__main__.py
CHANGED
aiobmsble/basebms.py
CHANGED
@@ -1,4 +1,8 @@
|
|
1
|
-
"""Base class defintion for battery management systems (BMS).
|
1
|
+
"""Base class defintion for battery management systems (BMS).
|
2
|
+
|
3
|
+
Project: aiobmsble, https://pypi.org/p/aiobmsble/
|
4
|
+
License: Apache-2.0, http://www.apache.org/licenses/
|
5
|
+
"""
|
2
6
|
|
3
7
|
from abc import ABC, abstractmethod
|
4
8
|
import asyncio
|
@@ -79,6 +83,11 @@ class BaseBMS(ABC):
|
|
79
83
|
self._data: bytearray = bytearray()
|
80
84
|
self._data_event: Final[asyncio.Event] = asyncio.Event()
|
81
85
|
|
86
|
+
@classmethod
|
87
|
+
def get_bms_module(cls) -> str:
|
88
|
+
"""Return BMS module name, e.g. aiobmsble.bms.dummy_bms."""
|
89
|
+
return cls.__module__
|
90
|
+
|
82
91
|
@staticmethod
|
83
92
|
@abstractmethod
|
84
93
|
def matcher_dict_list() -> list[MatcherPattern]:
|
aiobmsble/bms/abc_bms.py
ADDED
@@ -0,0 +1,168 @@
|
|
1
|
+
"""Module to support ABC BMS.
|
2
|
+
|
3
|
+
Project: aiobmsble, https://pypi.org/p/aiobmsble/
|
4
|
+
License: Apache-2.0, http://www.apache.org/licenses/
|
5
|
+
"""
|
6
|
+
|
7
|
+
import contextlib
|
8
|
+
from typing import Final
|
9
|
+
|
10
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
11
|
+
from bleak.backends.device import BLEDevice
|
12
|
+
from bleak.uuids import normalize_uuid_str
|
13
|
+
|
14
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
15
|
+
from aiobmsble.basebms import BaseBMS, crc8
|
16
|
+
|
17
|
+
|
18
|
+
class BMS(BaseBMS):
|
19
|
+
"""ABC BMS implementation."""
|
20
|
+
|
21
|
+
_HEAD_CMD: Final[int] = 0xEE
|
22
|
+
_HEAD_RESP: Final[bytes] = b"\xcc"
|
23
|
+
_INFO_LEN: Final[int] = 0x14
|
24
|
+
_EXP_REPLY: Final[dict[int, set[int]]] = { # wait for these replies
|
25
|
+
0xC0: {0xF1},
|
26
|
+
0xC1: {0xF0, 0xF2},
|
27
|
+
0xC2: {0xF0, 0xF3, 0xF4}, # 4 cells per F4 message
|
28
|
+
0xC3: {0xF5, 0xF6, 0xF7, 0xF8, 0xFA},
|
29
|
+
0xC4: {0xF9},
|
30
|
+
}
|
31
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
32
|
+
BMSdp("temp_sensors", 4, 1, False, lambda x: x, 0xF2),
|
33
|
+
BMSdp("voltage", 2, 3, False, lambda x: x / 1000, 0xF0),
|
34
|
+
BMSdp("current", 5, 3, True, lambda x: x / 1000, 0xF0),
|
35
|
+
# ("design_capacity", 8, 3, False, lambda x: x / 1000, 0xF0),
|
36
|
+
BMSdp("battery_level", 16, 1, False, lambda x: x, 0xF0),
|
37
|
+
BMSdp("cycle_charge", 11, 3, False, lambda x: x / 1000, 0xF0),
|
38
|
+
BMSdp("cycles", 14, 2, False, lambda x: x, 0xF0),
|
39
|
+
BMSdp( # only first bit per byte is used
|
40
|
+
"problem_code",
|
41
|
+
2,
|
42
|
+
16,
|
43
|
+
False,
|
44
|
+
lambda x: sum(((x >> (i * 8)) & 1) << i for i in range(16)),
|
45
|
+
0xF9,
|
46
|
+
),
|
47
|
+
)
|
48
|
+
_RESPS: Final[set[int]] = {field.idx for field in _FIELDS} | {0xF4} # cell voltages
|
49
|
+
|
50
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
51
|
+
"""Initialize BMS."""
|
52
|
+
super().__init__(ble_device, reconnect)
|
53
|
+
self._data_final: dict[int, bytearray] = {}
|
54
|
+
self._exp_reply: set[int] = set()
|
55
|
+
|
56
|
+
@staticmethod
|
57
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
58
|
+
"""Provide BluetoothMatcher definition."""
|
59
|
+
return [
|
60
|
+
{
|
61
|
+
"local_name": pattern,
|
62
|
+
"service_uuid": normalize_uuid_str("fff0"),
|
63
|
+
"connectable": True,
|
64
|
+
}
|
65
|
+
for pattern in ("ABC-*", "SOK-*") # "NB-*", "Hoover",
|
66
|
+
]
|
67
|
+
|
68
|
+
@staticmethod
|
69
|
+
def device_info() -> dict[str, str]:
|
70
|
+
"""Return device information for the battery management system."""
|
71
|
+
return {"manufacturer": "Chunguang Song", "model": "ABC BMS"}
|
72
|
+
|
73
|
+
@staticmethod
|
74
|
+
def uuid_services() -> list[str]:
|
75
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
76
|
+
return [normalize_uuid_str("ffe0")]
|
77
|
+
|
78
|
+
@staticmethod
|
79
|
+
def uuid_rx() -> str:
|
80
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
81
|
+
return "ffe1"
|
82
|
+
|
83
|
+
@staticmethod
|
84
|
+
def uuid_tx() -> str:
|
85
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
86
|
+
return "ffe2"
|
87
|
+
|
88
|
+
@staticmethod
|
89
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
90
|
+
return frozenset(
|
91
|
+
{
|
92
|
+
"battery_charging",
|
93
|
+
"cycle_capacity",
|
94
|
+
"delta_voltage",
|
95
|
+
"power",
|
96
|
+
"runtime",
|
97
|
+
"temperature",
|
98
|
+
}
|
99
|
+
) # calculate further values from BMS provided set ones
|
100
|
+
|
101
|
+
def _notification_handler(
|
102
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
103
|
+
) -> None:
|
104
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
105
|
+
self._log.debug("RX BLE data: %s", data)
|
106
|
+
|
107
|
+
if not data.startswith(BMS._HEAD_RESP):
|
108
|
+
self._log.debug("Incorrect frame start")
|
109
|
+
return
|
110
|
+
|
111
|
+
if len(data) != BMS._INFO_LEN:
|
112
|
+
self._log.debug("Incorrect frame length")
|
113
|
+
return
|
114
|
+
|
115
|
+
if (crc := crc8(data[:-1])) != data[-1]:
|
116
|
+
self._log.debug("invalid checksum 0x%X != 0x%X", data[-1], crc)
|
117
|
+
return
|
118
|
+
|
119
|
+
if data[1] == 0xF4 and 0xF4 in self._data_final:
|
120
|
+
# expand cell voltage frame with all parts
|
121
|
+
self._data_final[0xF4] = bytearray(self._data_final[0xF4][:-2] + data[2:])
|
122
|
+
else:
|
123
|
+
self._data_final[data[1]] = data.copy()
|
124
|
+
|
125
|
+
self._exp_reply.discard(data[1])
|
126
|
+
|
127
|
+
if not self._exp_reply: # check if all expected replies are received
|
128
|
+
self._data_event.set()
|
129
|
+
|
130
|
+
@staticmethod
|
131
|
+
def _cmd(cmd: bytes) -> bytes:
|
132
|
+
"""Assemble a ABC BMS command."""
|
133
|
+
frame = bytearray([BMS._HEAD_CMD, cmd[0], 0x00, 0x00, 0x00])
|
134
|
+
frame.append(crc8(frame))
|
135
|
+
return bytes(frame)
|
136
|
+
|
137
|
+
async def _async_update(self) -> BMSsample:
|
138
|
+
"""Update battery status information."""
|
139
|
+
self._data_final.clear()
|
140
|
+
for cmd in (0xC1, 0xC2, 0xC4):
|
141
|
+
self._exp_reply.update(BMS._EXP_REPLY[cmd])
|
142
|
+
with contextlib.suppress(TimeoutError):
|
143
|
+
await self._await_reply(BMS._cmd(bytes([cmd])))
|
144
|
+
|
145
|
+
# check all repsonses are here, 0xF9 is not mandatory (not all BMS report it)
|
146
|
+
self._data_final.setdefault(0xF9, bytearray())
|
147
|
+
if not BMS._RESPS.issubset(set(self._data_final.keys())):
|
148
|
+
self._log.debug("Incomplete data set %s", self._data_final.keys())
|
149
|
+
raise TimeoutError("BMS data incomplete.")
|
150
|
+
|
151
|
+
result: BMSsample = BMS._decode_data(
|
152
|
+
BMS._FIELDS, self._data_final, byteorder="little"
|
153
|
+
)
|
154
|
+
return result | {
|
155
|
+
"cell_voltages": BMS._cell_voltages( # every second value is the cell idx
|
156
|
+
self._data_final[0xF4],
|
157
|
+
cells=(len(self._data_final[0xF4]) - 4) // 2,
|
158
|
+
start=3,
|
159
|
+
byteorder="little",
|
160
|
+
size=2,
|
161
|
+
)[::2],
|
162
|
+
"temp_values": BMS._temp_values(
|
163
|
+
self._data_final[0xF2],
|
164
|
+
start=5,
|
165
|
+
values=result.get("temp_sensors", 0),
|
166
|
+
byteorder="little",
|
167
|
+
),
|
168
|
+
}
|
aiobmsble/bms/ant_bms.py
ADDED
@@ -0,0 +1,200 @@
|
|
1
|
+
"""Module to support ANT BMS.
|
2
|
+
|
3
|
+
Project: aiobmsble, https://pypi.org/p/aiobmsble/
|
4
|
+
License: Apache-2.0, http://www.apache.org/licenses/
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import Final
|
8
|
+
|
9
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
10
|
+
from bleak.backends.device import BLEDevice
|
11
|
+
from bleak.uuids import normalize_uuid_str
|
12
|
+
|
13
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
14
|
+
from aiobmsble.basebms import BaseBMS, crc_modbus
|
15
|
+
|
16
|
+
|
17
|
+
class BMS(BaseBMS):
|
18
|
+
"""ANT BMS implementation."""
|
19
|
+
|
20
|
+
_HEAD: Final[bytes] = b"\x7e\xa1"
|
21
|
+
_TAIL: Final[bytes] = b"\xaa\x55"
|
22
|
+
_MIN_LEN: Final[int] = 10 # frame length without data
|
23
|
+
_CMD_STAT: Final[int] = 0x01
|
24
|
+
_CMD_DEV: Final[int] = 0x02
|
25
|
+
_TEMP_POS: Final[int] = 8
|
26
|
+
_MAX_TEMPS: Final[int] = 6
|
27
|
+
_CELL_COUNT: Final[int] = 9
|
28
|
+
_CELL_POS: Final[int] = 34
|
29
|
+
_MAX_CELLS: Final[int] = 32
|
30
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
31
|
+
BMSdp("voltage", 38, 2, False, lambda x: x / 100),
|
32
|
+
BMSdp("current", 40, 2, True, lambda x: x / 10),
|
33
|
+
BMSdp("design_capacity", 50, 4, False, lambda x: x // 1e6),
|
34
|
+
BMSdp("battery_level", 42, 2, False, lambda x: x),
|
35
|
+
BMSdp(
|
36
|
+
"problem_code",
|
37
|
+
46,
|
38
|
+
2,
|
39
|
+
False,
|
40
|
+
lambda x: ((x & 0xF00) if (x >> 8) not in (0x1, 0x4, 0xB, 0xF) else 0)
|
41
|
+
| ((x & 0xF) if (x & 0xF) not in (0x1, 0x4, 0xB, 0xC, 0xF) else 0),
|
42
|
+
),
|
43
|
+
BMSdp("cycle_charge", 54, 4, False, lambda x: x / 1e6),
|
44
|
+
BMSdp("delta_voltage", 82, 2, False, lambda x: x / 1000),
|
45
|
+
BMSdp("power", 62, 4, True, lambda x: x / 1),
|
46
|
+
)
|
47
|
+
|
48
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
49
|
+
"""Initialize BMS."""
|
50
|
+
super().__init__(ble_device, reconnect)
|
51
|
+
self._data_final: bytearray = bytearray()
|
52
|
+
self._valid_reply: int = BMS._CMD_STAT | 0x10 # valid reply mask
|
53
|
+
self._exp_len: int = BMS._MIN_LEN
|
54
|
+
|
55
|
+
@staticmethod
|
56
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
57
|
+
"""Provide BluetoothMatcher definition."""
|
58
|
+
return [
|
59
|
+
{
|
60
|
+
"local_name": "ANT-BLE*",
|
61
|
+
"service_uuid": BMS.uuid_services()[0],
|
62
|
+
"manufacturer_id": 0x2313,
|
63
|
+
"connectable": True,
|
64
|
+
}
|
65
|
+
]
|
66
|
+
|
67
|
+
@staticmethod
|
68
|
+
def device_info() -> dict[str, str]:
|
69
|
+
"""Return device information for the battery management system."""
|
70
|
+
return {"manufacturer": "ANT", "model": "Smart BMS"}
|
71
|
+
|
72
|
+
@staticmethod
|
73
|
+
def uuid_services() -> list[str]:
|
74
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
75
|
+
return [normalize_uuid_str("ffe0")] # change service UUID here!
|
76
|
+
|
77
|
+
@staticmethod
|
78
|
+
def uuid_rx() -> str:
|
79
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
80
|
+
return "ffe1"
|
81
|
+
|
82
|
+
@staticmethod
|
83
|
+
def uuid_tx() -> str:
|
84
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
85
|
+
return "ffe1"
|
86
|
+
|
87
|
+
@staticmethod
|
88
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
89
|
+
return frozenset(
|
90
|
+
{"cycle_capacity", "temperature"}
|
91
|
+
) # calculate further values from BMS provided set ones
|
92
|
+
|
93
|
+
async def _init_connection(
|
94
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
95
|
+
) -> None:
|
96
|
+
"""Initialize RX/TX characteristics and protocol state."""
|
97
|
+
await super()._init_connection(char_notify)
|
98
|
+
self._exp_len = BMS._MIN_LEN
|
99
|
+
self._valid_reply = BMS._CMD_DEV | 0x10
|
100
|
+
await self._await_reply(BMS._cmd(BMS._CMD_DEV, 0x026C, 0x20)) # TODO: parse
|
101
|
+
self._valid_reply = BMS._CMD_STAT | 0x10
|
102
|
+
|
103
|
+
def _notification_handler(
|
104
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
105
|
+
) -> None:
|
106
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
107
|
+
|
108
|
+
if (
|
109
|
+
data.startswith(BMS._HEAD)
|
110
|
+
and len(self._data) >= self._exp_len
|
111
|
+
and len(data) >= BMS._MIN_LEN
|
112
|
+
):
|
113
|
+
self._data = bytearray()
|
114
|
+
self._exp_len = data[5] + BMS._MIN_LEN
|
115
|
+
|
116
|
+
self._data += data
|
117
|
+
self._log.debug(
|
118
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
119
|
+
)
|
120
|
+
|
121
|
+
if len(self._data) < self._exp_len:
|
122
|
+
return
|
123
|
+
|
124
|
+
if self._data[2] != self._valid_reply:
|
125
|
+
self._log.debug("unexpected response (type 0x%X)", self._data[2])
|
126
|
+
return
|
127
|
+
|
128
|
+
if len(self._data) != self._exp_len and self._data[2] != BMS._CMD_DEV | 0x10:
|
129
|
+
# length of CMD_DEV is incorrect, so we ignore the length check here
|
130
|
+
self._log.debug(
|
131
|
+
"invalid frame length %d != %d", len(self._data), self._exp_len
|
132
|
+
)
|
133
|
+
return
|
134
|
+
|
135
|
+
if not self._data.endswith(BMS._TAIL):
|
136
|
+
self._log.debug("invalid frame end")
|
137
|
+
return
|
138
|
+
|
139
|
+
if (crc := crc_modbus(self._data[1 : self._exp_len - 4])) != int.from_bytes(
|
140
|
+
self._data[self._exp_len - 4 : self._exp_len - 2], "little"
|
141
|
+
):
|
142
|
+
self._log.debug(
|
143
|
+
"invalid checksum 0x%X != 0x%X",
|
144
|
+
int.from_bytes(
|
145
|
+
self._data[self._exp_len - 4 : self._exp_len - 2], "little"
|
146
|
+
),
|
147
|
+
crc,
|
148
|
+
)
|
149
|
+
return
|
150
|
+
|
151
|
+
self._data_final = self._data.copy()
|
152
|
+
self._data_event.set()
|
153
|
+
|
154
|
+
@staticmethod
|
155
|
+
def _cmd(cmd: int, adr: int, value: int) -> bytes:
|
156
|
+
"""Assemble a ANT BMS command."""
|
157
|
+
frame: bytearray = (
|
158
|
+
bytearray([*BMS._HEAD, cmd & 0xFF])
|
159
|
+
+ adr.to_bytes(2, "little")
|
160
|
+
+ int.to_bytes(value & 0xFF, 1)
|
161
|
+
)
|
162
|
+
frame.extend(int.to_bytes(crc_modbus(frame[1:]), 2, "little"))
|
163
|
+
return bytes(frame) + BMS._TAIL
|
164
|
+
|
165
|
+
@staticmethod
|
166
|
+
def _temp_sensors(data: bytearray, sensors: int, offs: int) -> list[float]:
|
167
|
+
return [
|
168
|
+
float(int.from_bytes(data[idx : idx + 2], byteorder="little", signed=True))
|
169
|
+
for idx in range(offs, offs + sensors * 2, 2)
|
170
|
+
]
|
171
|
+
|
172
|
+
async def _async_update(self) -> BMSsample:
|
173
|
+
"""Update battery status information."""
|
174
|
+
await self._await_reply(BMS._cmd(BMS._CMD_STAT, 0, 0xBE))
|
175
|
+
|
176
|
+
result: BMSsample = {}
|
177
|
+
result["battery_charging"] = self._data_final[7] == 0x2
|
178
|
+
result["cell_count"] = min(self._data_final[BMS._CELL_COUNT], BMS._MAX_CELLS)
|
179
|
+
result["cell_voltages"] = BMS._cell_voltages(
|
180
|
+
self._data_final,
|
181
|
+
cells=result["cell_count"],
|
182
|
+
start=BMS._CELL_POS,
|
183
|
+
byteorder="little",
|
184
|
+
)
|
185
|
+
result["temp_sensors"] = min(self._data_final[BMS._TEMP_POS], BMS._MAX_TEMPS)
|
186
|
+
result["temp_values"] = BMS._temp_sensors(
|
187
|
+
self._data_final,
|
188
|
+
result["temp_sensors"] + 2, # + MOSFET, balancer temperature
|
189
|
+
BMS._CELL_POS + result["cell_count"] * 2,
|
190
|
+
)
|
191
|
+
result.update(
|
192
|
+
BMS._decode_data(
|
193
|
+
BMS._FIELDS,
|
194
|
+
self._data_final,
|
195
|
+
byteorder="little",
|
196
|
+
offset=(result["temp_sensors"] + result["cell_count"]) * 2,
|
197
|
+
)
|
198
|
+
)
|
199
|
+
|
200
|
+
return result
|
@@ -0,0 +1,171 @@
|
|
1
|
+
"""Module to support Braun Power BMS.
|
2
|
+
|
3
|
+
Project: aiobmsble, https://pypi.org/p/aiobmsble/
|
4
|
+
License: Apache-2.0, http://www.apache.org/licenses/
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import Final
|
8
|
+
|
9
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
10
|
+
from bleak.backends.device import BLEDevice
|
11
|
+
from bleak.uuids import normalize_uuid_str
|
12
|
+
|
13
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
14
|
+
from aiobmsble.basebms import BaseBMS
|
15
|
+
|
16
|
+
|
17
|
+
class BMS(BaseBMS):
|
18
|
+
"""Braun Power BMS class implementation."""
|
19
|
+
|
20
|
+
_HEAD: Final[bytes] = b"\x7b" # header for responses
|
21
|
+
_TAIL: Final[int] = 0x7D # tail for command
|
22
|
+
_MIN_LEN: Final[int] = 4 # minimum frame size
|
23
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
24
|
+
BMSdp("cell_count", 3, 1, False, lambda x: x, 0x2),
|
25
|
+
BMSdp("temp_sensors", 3, 1, False, lambda x: x, 0x3),
|
26
|
+
BMSdp("voltage", 5, 2, False, lambda x: x / 100, 0x1),
|
27
|
+
BMSdp("current", 13, 2, True, lambda x: x / 100, 0x1),
|
28
|
+
BMSdp("battery_level", 4, 1, False, lambda x: x, 0x1),
|
29
|
+
BMSdp("cycle_charge", 15, 2, False, lambda x: x / 100, 0x1),
|
30
|
+
BMSdp("design_capacity", 17, 2, False, lambda x: x // 100, 0x1),
|
31
|
+
BMSdp("cycles", 23, 2, False, lambda x: x, 0x1),
|
32
|
+
BMSdp("problem_code", 31, 2, False, lambda x: x, 0x1),
|
33
|
+
)
|
34
|
+
_CMDS: Final[set[int]] = {field.idx for field in _FIELDS}
|
35
|
+
_INIT_CMDS: Final[set[int]] = {
|
36
|
+
0x74, # SW version
|
37
|
+
0xF4, # BMS program version
|
38
|
+
0xF5, # BMS boot version
|
39
|
+
}
|
40
|
+
|
41
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
42
|
+
"""Intialize private BMS members."""
|
43
|
+
super().__init__(ble_device, reconnect)
|
44
|
+
self._data_final: dict[int, bytearray] = {}
|
45
|
+
self._exp_reply: tuple[int] = (0x01,)
|
46
|
+
|
47
|
+
@staticmethod
|
48
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
49
|
+
"""Provide BluetoothMatcher definition."""
|
50
|
+
return [
|
51
|
+
MatcherPattern(
|
52
|
+
local_name=pattern,
|
53
|
+
service_uuid=BMS.uuid_services()[0],
|
54
|
+
manufacturer_id=0x7B,
|
55
|
+
connectable=True,
|
56
|
+
)
|
57
|
+
for pattern in ("HSKS-*", "BL-*")
|
58
|
+
]
|
59
|
+
|
60
|
+
@staticmethod
|
61
|
+
def device_info() -> dict[str, str]:
|
62
|
+
"""Return device information for the battery management system."""
|
63
|
+
return {"manufacturer": "Braun Power", "model": "Smart BMS"}
|
64
|
+
|
65
|
+
@staticmethod
|
66
|
+
def uuid_services() -> list[str]:
|
67
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
68
|
+
return [normalize_uuid_str("ff00")]
|
69
|
+
|
70
|
+
@staticmethod
|
71
|
+
def uuid_rx() -> str:
|
72
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
73
|
+
return "ff01"
|
74
|
+
|
75
|
+
@staticmethod
|
76
|
+
def uuid_tx() -> str:
|
77
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
78
|
+
return "ff02"
|
79
|
+
|
80
|
+
@staticmethod
|
81
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
82
|
+
return frozenset(
|
83
|
+
{
|
84
|
+
"power",
|
85
|
+
"battery_charging",
|
86
|
+
"cycle_capacity",
|
87
|
+
"runtime",
|
88
|
+
"delta_voltage",
|
89
|
+
"temperature",
|
90
|
+
}
|
91
|
+
)
|
92
|
+
|
93
|
+
def _notification_handler(
|
94
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
95
|
+
) -> None:
|
96
|
+
# check if answer is a heading of valid response type
|
97
|
+
if (
|
98
|
+
data.startswith(BMS._HEAD)
|
99
|
+
and len(self._data) >= BMS._MIN_LEN
|
100
|
+
and data[1] in {*BMS._CMDS, *BMS._INIT_CMDS}
|
101
|
+
and len(self._data) >= BMS._MIN_LEN + self._data[2]
|
102
|
+
):
|
103
|
+
self._data = bytearray()
|
104
|
+
|
105
|
+
self._data += data
|
106
|
+
self._log.debug(
|
107
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
108
|
+
)
|
109
|
+
|
110
|
+
# verify that data is long enough
|
111
|
+
if (
|
112
|
+
len(self._data) < BMS._MIN_LEN
|
113
|
+
or len(self._data) < BMS._MIN_LEN + self._data[2]
|
114
|
+
):
|
115
|
+
return
|
116
|
+
|
117
|
+
# check correct frame ending
|
118
|
+
if self._data[-1] != BMS._TAIL:
|
119
|
+
self._log.debug("incorrect frame end (length: %i).", len(self._data))
|
120
|
+
self._data.clear()
|
121
|
+
return
|
122
|
+
|
123
|
+
if self._data[1] not in self._exp_reply:
|
124
|
+
self._log.debug("unexpected command 0x%02X", self._data[1])
|
125
|
+
self._data.clear()
|
126
|
+
return
|
127
|
+
|
128
|
+
# check if response length matches expected length
|
129
|
+
if len(self._data) != BMS._MIN_LEN + self._data[2]:
|
130
|
+
self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
|
131
|
+
self._data.clear()
|
132
|
+
return
|
133
|
+
|
134
|
+
self._data_final[self._data[1]] = self._data
|
135
|
+
self._data_event.set()
|
136
|
+
|
137
|
+
@staticmethod
|
138
|
+
def _cmd(cmd: int, data: bytes = b"") -> bytes:
|
139
|
+
"""Assemble a Braun Power BMS command."""
|
140
|
+
assert len(data) <= 255, "data length must be a single byte."
|
141
|
+
return bytes([*BMS._HEAD, cmd, len(data), *data, BMS._TAIL])
|
142
|
+
|
143
|
+
async def _init_connection(
|
144
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
145
|
+
) -> None:
|
146
|
+
"""Connect to the BMS and setup notification if not connected."""
|
147
|
+
await super()._init_connection()
|
148
|
+
for cmd in BMS._INIT_CMDS:
|
149
|
+
self._exp_reply = (cmd,)
|
150
|
+
await self._await_reply(BMS._cmd(cmd))
|
151
|
+
|
152
|
+
async def _async_update(self) -> BMSsample:
|
153
|
+
"""Update battery status information."""
|
154
|
+
self._data_final.clear()
|
155
|
+
for cmd in BMS._CMDS:
|
156
|
+
self._exp_reply = (cmd,)
|
157
|
+
await self._await_reply(BMS._cmd(cmd))
|
158
|
+
|
159
|
+
data: BMSsample = BMS._decode_data(BMS._FIELDS, self._data_final)
|
160
|
+
data["cell_voltages"] = BMS._cell_voltages(
|
161
|
+
self._data_final[0x2], cells=data.get("cell_count", 0), start=4
|
162
|
+
)
|
163
|
+
data["temp_values"] = BMS._temp_values(
|
164
|
+
self._data_final[0x3],
|
165
|
+
values=data.get("temp_sensors", 0),
|
166
|
+
start=4,
|
167
|
+
offset=2731,
|
168
|
+
divider=10,
|
169
|
+
)
|
170
|
+
|
171
|
+
return data
|