aiobmsble 0.1.0__py3-none-any.whl → 0.2.1__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/bms/__init__.py +1 -0
- aiobmsble/bms/abc_bms.py +164 -0
- aiobmsble/bms/ant_bms.py +196 -0
- aiobmsble/bms/braunpwr_bms.py +167 -0
- aiobmsble/bms/cbtpwr_bms.py +168 -0
- aiobmsble/bms/cbtpwr_vb_bms.py +184 -0
- aiobmsble/bms/daly_bms.py +164 -0
- aiobmsble/bms/dpwrcore_bms.py +207 -0
- aiobmsble/bms/dummy_bms.py +89 -0
- aiobmsble/bms/ecoworthy_bms.py +151 -0
- aiobmsble/bms/ective_bms.py +177 -0
- aiobmsble/bms/ej_bms.py +233 -0
- aiobmsble/bms/felicity_bms.py +139 -0
- aiobmsble/bms/jbd_bms.py +203 -0
- aiobmsble/bms/jikong_bms.py +301 -0
- aiobmsble/bms/neey_bms.py +214 -0
- aiobmsble/bms/ogt_bms.py +214 -0
- aiobmsble/bms/pro_bms.py +144 -0
- aiobmsble/bms/redodo_bms.py +127 -0
- aiobmsble/bms/renogy_bms.py +149 -0
- aiobmsble/bms/renogy_pro_bms.py +105 -0
- aiobmsble/bms/roypow_bms.py +186 -0
- aiobmsble/bms/seplos_bms.py +245 -0
- aiobmsble/bms/seplos_v2_bms.py +205 -0
- aiobmsble/bms/tdt_bms.py +199 -0
- aiobmsble/bms/tianpwr_bms.py +138 -0
- aiobmsble/utils.py +96 -6
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/METADATA +23 -14
- aiobmsble-0.2.1.dist-info/RECORD +36 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/WHEEL +1 -1
- aiobmsble-0.1.0.dist-info/RECORD +0 -10
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/entry_points.txt +0 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/top_level.txt +0 -0
aiobmsble/bms/ant_bms.py
ADDED
@@ -0,0 +1,196 @@
|
|
1
|
+
"""Module to support ANT BMS."""
|
2
|
+
|
3
|
+
from typing import Final
|
4
|
+
|
5
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
6
|
+
from bleak.backends.device import BLEDevice
|
7
|
+
from bleak.uuids import normalize_uuid_str
|
8
|
+
|
9
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
10
|
+
from aiobmsble.basebms import BaseBMS, crc_modbus
|
11
|
+
|
12
|
+
|
13
|
+
class BMS(BaseBMS):
|
14
|
+
"""ANT BMS implementation."""
|
15
|
+
|
16
|
+
_HEAD: Final[bytes] = b"\x7e\xa1"
|
17
|
+
_TAIL: Final[bytes] = b"\xaa\x55"
|
18
|
+
_MIN_LEN: Final[int] = 10 # frame length without data
|
19
|
+
_CMD_STAT: Final[int] = 0x01
|
20
|
+
_CMD_DEV: Final[int] = 0x02
|
21
|
+
_TEMP_POS: Final[int] = 8
|
22
|
+
_MAX_TEMPS: Final[int] = 6
|
23
|
+
_CELL_COUNT: Final[int] = 9
|
24
|
+
_CELL_POS: Final[int] = 34
|
25
|
+
_MAX_CELLS: Final[int] = 32
|
26
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
27
|
+
BMSdp("voltage", 38, 2, False, lambda x: x / 100),
|
28
|
+
BMSdp("current", 40, 2, True, lambda x: x / 10),
|
29
|
+
BMSdp("design_capacity", 50, 4, False, lambda x: x // 1e6),
|
30
|
+
BMSdp("battery_level", 42, 2, False, lambda x: x),
|
31
|
+
BMSdp(
|
32
|
+
"problem_code",
|
33
|
+
46,
|
34
|
+
2,
|
35
|
+
False,
|
36
|
+
lambda x: ((x & 0xF00) if (x >> 8) not in (0x1, 0x4, 0xB, 0xF) else 0)
|
37
|
+
| ((x & 0xF) if (x & 0xF) not in (0x1, 0x4, 0xB, 0xC, 0xF) else 0),
|
38
|
+
),
|
39
|
+
BMSdp("cycle_charge", 54, 4, False, lambda x: x / 1e6),
|
40
|
+
BMSdp("delta_voltage", 82, 2, False, lambda x: x / 1000),
|
41
|
+
BMSdp("power", 62, 4, True, lambda x: x / 1),
|
42
|
+
)
|
43
|
+
|
44
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
45
|
+
"""Initialize BMS."""
|
46
|
+
super().__init__(ble_device, reconnect)
|
47
|
+
self._data_final: bytearray = bytearray()
|
48
|
+
self._valid_reply: int = BMS._CMD_STAT | 0x10 # valid reply mask
|
49
|
+
self._exp_len: int = BMS._MIN_LEN
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
53
|
+
"""Provide BluetoothMatcher definition."""
|
54
|
+
return [
|
55
|
+
{
|
56
|
+
"local_name": "ANT-BLE*",
|
57
|
+
"service_uuid": BMS.uuid_services()[0],
|
58
|
+
"manufacturer_id": 0x2313,
|
59
|
+
"connectable": True,
|
60
|
+
}
|
61
|
+
]
|
62
|
+
|
63
|
+
@staticmethod
|
64
|
+
def device_info() -> dict[str, str]:
|
65
|
+
"""Return device information for the battery management system."""
|
66
|
+
return {"manufacturer": "ANT", "model": "Smart BMS"}
|
67
|
+
|
68
|
+
@staticmethod
|
69
|
+
def uuid_services() -> list[str]:
|
70
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
71
|
+
return [normalize_uuid_str("ffe0")] # change service UUID here!
|
72
|
+
|
73
|
+
@staticmethod
|
74
|
+
def uuid_rx() -> str:
|
75
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
76
|
+
return "ffe1"
|
77
|
+
|
78
|
+
@staticmethod
|
79
|
+
def uuid_tx() -> str:
|
80
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
81
|
+
return "ffe1"
|
82
|
+
|
83
|
+
@staticmethod
|
84
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
85
|
+
return frozenset(
|
86
|
+
{"cycle_capacity", "temperature"}
|
87
|
+
) # calculate further values from BMS provided set ones
|
88
|
+
|
89
|
+
async def _init_connection(
|
90
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
91
|
+
) -> None:
|
92
|
+
"""Initialize RX/TX characteristics and protocol state."""
|
93
|
+
await super()._init_connection(char_notify)
|
94
|
+
self._exp_len = BMS._MIN_LEN
|
95
|
+
self._valid_reply = BMS._CMD_DEV | 0x10
|
96
|
+
await self._await_reply(BMS._cmd(BMS._CMD_DEV, 0x026C, 0x20)) # TODO: parse
|
97
|
+
self._valid_reply = BMS._CMD_STAT | 0x10
|
98
|
+
|
99
|
+
def _notification_handler(
|
100
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
101
|
+
) -> None:
|
102
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
103
|
+
|
104
|
+
if (
|
105
|
+
data.startswith(BMS._HEAD)
|
106
|
+
and len(self._data) >= self._exp_len
|
107
|
+
and len(data) >= BMS._MIN_LEN
|
108
|
+
):
|
109
|
+
self._data = bytearray()
|
110
|
+
self._exp_len = data[5] + BMS._MIN_LEN
|
111
|
+
|
112
|
+
self._data += data
|
113
|
+
self._log.debug(
|
114
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
115
|
+
)
|
116
|
+
|
117
|
+
if len(self._data) < self._exp_len:
|
118
|
+
return
|
119
|
+
|
120
|
+
if self._data[2] != self._valid_reply:
|
121
|
+
self._log.debug("unexpected response (type 0x%X)", self._data[2])
|
122
|
+
return
|
123
|
+
|
124
|
+
if len(self._data) != self._exp_len and self._data[2] != BMS._CMD_DEV | 0x10:
|
125
|
+
# length of CMD_DEV is incorrect, so we ignore the length check here
|
126
|
+
self._log.debug(
|
127
|
+
"invalid frame length %d != %d", len(self._data), self._exp_len
|
128
|
+
)
|
129
|
+
return
|
130
|
+
|
131
|
+
if not self._data.endswith(BMS._TAIL):
|
132
|
+
self._log.debug("invalid frame end")
|
133
|
+
return
|
134
|
+
|
135
|
+
if (crc := crc_modbus(self._data[1 : self._exp_len - 4])) != int.from_bytes(
|
136
|
+
self._data[self._exp_len - 4 : self._exp_len - 2], "little"
|
137
|
+
):
|
138
|
+
self._log.debug(
|
139
|
+
"invalid checksum 0x%X != 0x%X",
|
140
|
+
int.from_bytes(
|
141
|
+
self._data[self._exp_len - 4 : self._exp_len - 2], "little"
|
142
|
+
),
|
143
|
+
crc,
|
144
|
+
)
|
145
|
+
return
|
146
|
+
|
147
|
+
self._data_final = self._data.copy()
|
148
|
+
self._data_event.set()
|
149
|
+
|
150
|
+
@staticmethod
|
151
|
+
def _cmd(cmd: int, adr: int, value: int) -> bytes:
|
152
|
+
"""Assemble a ANT BMS command."""
|
153
|
+
frame: bytearray = (
|
154
|
+
bytearray([*BMS._HEAD, cmd & 0xFF])
|
155
|
+
+ adr.to_bytes(2, "little")
|
156
|
+
+ int.to_bytes(value & 0xFF, 1)
|
157
|
+
)
|
158
|
+
frame.extend(int.to_bytes(crc_modbus(frame[1:]), 2, "little"))
|
159
|
+
return bytes(frame) + BMS._TAIL
|
160
|
+
|
161
|
+
@staticmethod
|
162
|
+
def _temp_sensors(data: bytearray, sensors: int, offs: int) -> list[float]:
|
163
|
+
return [
|
164
|
+
float(int.from_bytes(data[idx : idx + 2], byteorder="little", signed=True))
|
165
|
+
for idx in range(offs, offs + sensors * 2, 2)
|
166
|
+
]
|
167
|
+
|
168
|
+
async def _async_update(self) -> BMSsample:
|
169
|
+
"""Update battery status information."""
|
170
|
+
await self._await_reply(BMS._cmd(BMS._CMD_STAT, 0, 0xBE))
|
171
|
+
|
172
|
+
result: BMSsample = {}
|
173
|
+
result["battery_charging"] = self._data_final[7] == 0x2
|
174
|
+
result["cell_count"] = min(self._data_final[BMS._CELL_COUNT], BMS._MAX_CELLS)
|
175
|
+
result["cell_voltages"] = BMS._cell_voltages(
|
176
|
+
self._data_final,
|
177
|
+
cells=result["cell_count"],
|
178
|
+
start=BMS._CELL_POS,
|
179
|
+
byteorder="little",
|
180
|
+
)
|
181
|
+
result["temp_sensors"] = min(self._data_final[BMS._TEMP_POS], BMS._MAX_TEMPS)
|
182
|
+
result["temp_values"] = BMS._temp_sensors(
|
183
|
+
self._data_final,
|
184
|
+
result["temp_sensors"] + 2, # + MOSFET, balancer temperature
|
185
|
+
BMS._CELL_POS + result["cell_count"] * 2,
|
186
|
+
)
|
187
|
+
result.update(
|
188
|
+
BMS._decode_data(
|
189
|
+
BMS._FIELDS,
|
190
|
+
self._data_final,
|
191
|
+
byteorder="little",
|
192
|
+
offset=(result["temp_sensors"] + result["cell_count"]) * 2,
|
193
|
+
)
|
194
|
+
)
|
195
|
+
|
196
|
+
return result
|
@@ -0,0 +1,167 @@
|
|
1
|
+
"""Module to support Braun Power BMS."""
|
2
|
+
|
3
|
+
from typing import Final
|
4
|
+
|
5
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
6
|
+
from bleak.backends.device import BLEDevice
|
7
|
+
from bleak.uuids import normalize_uuid_str
|
8
|
+
|
9
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
10
|
+
from aiobmsble.basebms import BaseBMS
|
11
|
+
|
12
|
+
|
13
|
+
class BMS(BaseBMS):
|
14
|
+
"""Braun Power BMS class implementation."""
|
15
|
+
|
16
|
+
_HEAD: Final[bytes] = b"\x7b" # header for responses
|
17
|
+
_TAIL: Final[int] = 0x7D # tail for command
|
18
|
+
_MIN_LEN: Final[int] = 4 # minimum frame size
|
19
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
20
|
+
BMSdp("cell_count", 3, 1, False, lambda x: x, 0x2),
|
21
|
+
BMSdp("temp_sensors", 3, 1, False, lambda x: x, 0x3),
|
22
|
+
BMSdp("voltage", 5, 2, False, lambda x: x / 100, 0x1),
|
23
|
+
BMSdp("current", 13, 2, True, lambda x: x / 100, 0x1),
|
24
|
+
BMSdp("battery_level", 4, 1, False, lambda x: x, 0x1),
|
25
|
+
BMSdp("cycle_charge", 15, 2, False, lambda x: x / 100, 0x1),
|
26
|
+
BMSdp("design_capacity", 17, 2, False, lambda x: x // 100, 0x1),
|
27
|
+
BMSdp("cycles", 23, 2, False, lambda x: x, 0x1),
|
28
|
+
BMSdp("problem_code", 31, 2, False, lambda x: x, 0x1),
|
29
|
+
)
|
30
|
+
_CMDS: Final[set[int]] = {field.idx for field in _FIELDS}
|
31
|
+
_INIT_CMDS: Final[set[int]] = {
|
32
|
+
0x74, # SW version
|
33
|
+
0xF4, # BMS program version
|
34
|
+
0xF5, # BMS boot version
|
35
|
+
}
|
36
|
+
|
37
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
38
|
+
"""Intialize private BMS members."""
|
39
|
+
super().__init__(ble_device, reconnect)
|
40
|
+
self._data_final: dict[int, bytearray] = {}
|
41
|
+
self._exp_reply: tuple[int] = (0x01,)
|
42
|
+
|
43
|
+
@staticmethod
|
44
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
45
|
+
"""Provide BluetoothMatcher definition."""
|
46
|
+
return [
|
47
|
+
MatcherPattern(
|
48
|
+
local_name=pattern,
|
49
|
+
service_uuid=BMS.uuid_services()[0],
|
50
|
+
manufacturer_id=0x7B,
|
51
|
+
connectable=True,
|
52
|
+
)
|
53
|
+
for pattern in ("HSKS-*", "BL-*")
|
54
|
+
]
|
55
|
+
|
56
|
+
@staticmethod
|
57
|
+
def device_info() -> dict[str, str]:
|
58
|
+
"""Return device information for the battery management system."""
|
59
|
+
return {"manufacturer": "Braun Power", "model": "Smart BMS"}
|
60
|
+
|
61
|
+
@staticmethod
|
62
|
+
def uuid_services() -> list[str]:
|
63
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
64
|
+
return [normalize_uuid_str("ff00")]
|
65
|
+
|
66
|
+
@staticmethod
|
67
|
+
def uuid_rx() -> str:
|
68
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
69
|
+
return "ff01"
|
70
|
+
|
71
|
+
@staticmethod
|
72
|
+
def uuid_tx() -> str:
|
73
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
74
|
+
return "ff02"
|
75
|
+
|
76
|
+
@staticmethod
|
77
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
78
|
+
return frozenset(
|
79
|
+
{
|
80
|
+
"power",
|
81
|
+
"battery_charging",
|
82
|
+
"cycle_capacity",
|
83
|
+
"runtime",
|
84
|
+
"delta_voltage",
|
85
|
+
"temperature",
|
86
|
+
}
|
87
|
+
)
|
88
|
+
|
89
|
+
def _notification_handler(
|
90
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
91
|
+
) -> None:
|
92
|
+
# check if answer is a heading of valid response type
|
93
|
+
if (
|
94
|
+
data.startswith(BMS._HEAD)
|
95
|
+
and len(self._data) >= BMS._MIN_LEN
|
96
|
+
and data[1] in {*BMS._CMDS, *BMS._INIT_CMDS}
|
97
|
+
and len(self._data) >= BMS._MIN_LEN + self._data[2]
|
98
|
+
):
|
99
|
+
self._data = bytearray()
|
100
|
+
|
101
|
+
self._data += data
|
102
|
+
self._log.debug(
|
103
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
104
|
+
)
|
105
|
+
|
106
|
+
# verify that data is long enough
|
107
|
+
if (
|
108
|
+
len(self._data) < BMS._MIN_LEN
|
109
|
+
or len(self._data) < BMS._MIN_LEN + self._data[2]
|
110
|
+
):
|
111
|
+
return
|
112
|
+
|
113
|
+
# check correct frame ending
|
114
|
+
if self._data[-1] != BMS._TAIL:
|
115
|
+
self._log.debug("incorrect frame end (length: %i).", len(self._data))
|
116
|
+
self._data.clear()
|
117
|
+
return
|
118
|
+
|
119
|
+
if self._data[1] not in self._exp_reply:
|
120
|
+
self._log.debug("unexpected command 0x%02X", self._data[1])
|
121
|
+
self._data.clear()
|
122
|
+
return
|
123
|
+
|
124
|
+
# check if response length matches expected length
|
125
|
+
if len(self._data) != BMS._MIN_LEN + self._data[2]:
|
126
|
+
self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
|
127
|
+
self._data.clear()
|
128
|
+
return
|
129
|
+
|
130
|
+
self._data_final[self._data[1]] = self._data
|
131
|
+
self._data_event.set()
|
132
|
+
|
133
|
+
@staticmethod
|
134
|
+
def _cmd(cmd: int, data: bytes = b"") -> bytes:
|
135
|
+
"""Assemble a Braun Power BMS command."""
|
136
|
+
assert len(data) <= 255, "data length must be a single byte."
|
137
|
+
return bytes([*BMS._HEAD, cmd, len(data), *data, BMS._TAIL])
|
138
|
+
|
139
|
+
async def _init_connection(
|
140
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
141
|
+
) -> None:
|
142
|
+
"""Connect to the BMS and setup notification if not connected."""
|
143
|
+
await super()._init_connection()
|
144
|
+
for cmd in BMS._INIT_CMDS:
|
145
|
+
self._exp_reply = (cmd,)
|
146
|
+
await self._await_reply(BMS._cmd(cmd))
|
147
|
+
|
148
|
+
async def _async_update(self) -> BMSsample:
|
149
|
+
"""Update battery status information."""
|
150
|
+
self._data_final.clear()
|
151
|
+
for cmd in BMS._CMDS:
|
152
|
+
self._exp_reply = (cmd,)
|
153
|
+
await self._await_reply(BMS._cmd(cmd))
|
154
|
+
|
155
|
+
data: BMSsample = BMS._decode_data(BMS._FIELDS, self._data_final)
|
156
|
+
data["cell_voltages"] = BMS._cell_voltages(
|
157
|
+
self._data_final[0x2], cells=data.get("cell_count", 0), start=4
|
158
|
+
)
|
159
|
+
data["temp_values"] = BMS._temp_values(
|
160
|
+
self._data_final[0x3],
|
161
|
+
values=data.get("temp_sensors", 0),
|
162
|
+
start=4,
|
163
|
+
offset=2731,
|
164
|
+
divider=10,
|
165
|
+
)
|
166
|
+
|
167
|
+
return data
|
@@ -0,0 +1,168 @@
|
|
1
|
+
"""Module to support CBT Power Smart BMS."""
|
2
|
+
|
3
|
+
from typing import Final
|
4
|
+
|
5
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
6
|
+
from bleak.backends.device import BLEDevice
|
7
|
+
from bleak.uuids import normalize_uuid_str
|
8
|
+
|
9
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
10
|
+
from aiobmsble.basebms import BaseBMS, crc_sum
|
11
|
+
|
12
|
+
|
13
|
+
class BMS(BaseBMS):
|
14
|
+
"""CBT Power Smart BMS class implementation."""
|
15
|
+
|
16
|
+
HEAD: Final[bytes] = bytes([0xAA, 0x55])
|
17
|
+
TAIL_RX: Final[bytes] = bytes([0x0D, 0x0A])
|
18
|
+
TAIL_TX: Final[bytes] = bytes([0x0A, 0x0D])
|
19
|
+
MIN_FRAME: Final[int] = len(HEAD) + len(TAIL_RX) + 3 # CMD, LEN, CRC, 1 Byte each
|
20
|
+
CRC_POS: Final[int] = -len(TAIL_RX) - 1
|
21
|
+
LEN_POS: Final[int] = 3
|
22
|
+
CMD_POS: Final[int] = 2
|
23
|
+
CELL_VOLTAGE_CMDS: Final[list[int]] = [0x5, 0x6, 0x7, 0x8]
|
24
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
25
|
+
BMSdp("voltage", 4, 4, False, lambda x: x / 1000, 0x0B),
|
26
|
+
BMSdp("current", 8, 4, True, lambda x: x / 1000, 0x0B),
|
27
|
+
BMSdp("temperature", 4, 2, True, lambda x: x, 0x09),
|
28
|
+
BMSdp("battery_level", 4, 1, False, lambda x: x, 0x0A),
|
29
|
+
BMSdp("design_capacity", 4, 2, False, lambda x: x, 0x15),
|
30
|
+
BMSdp("cycles", 6, 2, False, lambda x: x, 0x15),
|
31
|
+
BMSdp("runtime", 14, 2, False, lambda x: x * BMS._HRS_TO_SECS / 100, 0x0C),
|
32
|
+
BMSdp("problem_code", 4, 4, False, lambda x: x, 0x21),
|
33
|
+
)
|
34
|
+
_CMDS: Final[list[int]] = list({field.idx for field in _FIELDS})
|
35
|
+
|
36
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
37
|
+
"""Intialize private BMS members."""
|
38
|
+
super().__init__(ble_device, reconnect)
|
39
|
+
|
40
|
+
@staticmethod
|
41
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
42
|
+
"""Provide BluetoothMatcher definition."""
|
43
|
+
return [
|
44
|
+
{"service_uuid": BMS.uuid_services()[0], "connectable": True},
|
45
|
+
{ # Creabest
|
46
|
+
"service_uuid": normalize_uuid_str("fff0"),
|
47
|
+
"manufacturer_id": 0,
|
48
|
+
"connectable": True,
|
49
|
+
},
|
50
|
+
{
|
51
|
+
"service_uuid": normalize_uuid_str("03c1"),
|
52
|
+
"manufacturer_id": 0x5352,
|
53
|
+
"connectable": True,
|
54
|
+
},
|
55
|
+
]
|
56
|
+
|
57
|
+
@staticmethod
|
58
|
+
def device_info() -> dict[str, str]:
|
59
|
+
"""Return device information for the battery management system."""
|
60
|
+
return {"manufacturer": "CBT Power", "model": "Smart BMS"}
|
61
|
+
|
62
|
+
@staticmethod
|
63
|
+
def uuid_services() -> list[str]:
|
64
|
+
"""Return list of services required by BMS."""
|
65
|
+
return [normalize_uuid_str("ffe5"), normalize_uuid_str("ffe0")]
|
66
|
+
|
67
|
+
@staticmethod
|
68
|
+
def uuid_rx() -> str:
|
69
|
+
"""Return characteristic that provides notification/read property."""
|
70
|
+
return "ffe4"
|
71
|
+
|
72
|
+
@staticmethod
|
73
|
+
def uuid_tx() -> str:
|
74
|
+
"""Return characteristic that provides write property."""
|
75
|
+
return "ffe9"
|
76
|
+
|
77
|
+
@staticmethod
|
78
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
79
|
+
return frozenset(
|
80
|
+
{
|
81
|
+
"power",
|
82
|
+
"battery_charging",
|
83
|
+
"delta_voltage",
|
84
|
+
"cycle_capacity",
|
85
|
+
"temperature",
|
86
|
+
}
|
87
|
+
)
|
88
|
+
|
89
|
+
def _notification_handler(
|
90
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
91
|
+
) -> None:
|
92
|
+
"""Retrieve BMS data update."""
|
93
|
+
self._log.debug("RX BLE data: %s", data)
|
94
|
+
|
95
|
+
# verify that data is long enough
|
96
|
+
if len(data) < BMS.MIN_FRAME or len(data) != BMS.MIN_FRAME + data[BMS.LEN_POS]:
|
97
|
+
self._log.debug("incorrect frame length (%i): %s", len(data), data)
|
98
|
+
return
|
99
|
+
|
100
|
+
if not data.startswith(BMS.HEAD) or not data.endswith(BMS.TAIL_RX):
|
101
|
+
self._log.debug("incorrect frame start/end: %s", data)
|
102
|
+
return
|
103
|
+
|
104
|
+
if (crc := crc_sum(data[len(BMS.HEAD) : len(data) + BMS.CRC_POS])) != data[
|
105
|
+
BMS.CRC_POS
|
106
|
+
]:
|
107
|
+
self._log.debug(
|
108
|
+
"invalid checksum 0x%X != 0x%X",
|
109
|
+
data[len(data) + BMS.CRC_POS],
|
110
|
+
crc,
|
111
|
+
)
|
112
|
+
return
|
113
|
+
|
114
|
+
self._data = data
|
115
|
+
self._data_event.set()
|
116
|
+
|
117
|
+
@staticmethod
|
118
|
+
def _cmd(cmd: bytes, value: list[int] | None = None) -> bytes:
|
119
|
+
"""Assemble a CBT Power BMS command."""
|
120
|
+
value = [] if value is None else value
|
121
|
+
assert len(value) <= 255
|
122
|
+
frame = bytearray([*BMS.HEAD, cmd[0], len(value), *value])
|
123
|
+
frame.append(crc_sum(frame[len(BMS.HEAD) :]))
|
124
|
+
frame.extend(BMS.TAIL_TX)
|
125
|
+
return bytes(frame)
|
126
|
+
|
127
|
+
async def _async_update(self) -> BMSsample:
|
128
|
+
"""Update battery status information."""
|
129
|
+
resp_cache: dict[int, bytearray] = {} # avoid multiple queries
|
130
|
+
for cmd in BMS._CMDS:
|
131
|
+
self._log.debug("request command 0x%X.", cmd)
|
132
|
+
try:
|
133
|
+
await self._await_reply(BMS._cmd(cmd.to_bytes(1)))
|
134
|
+
except TimeoutError:
|
135
|
+
continue
|
136
|
+
if cmd != self._data[BMS.CMD_POS]:
|
137
|
+
self._log.debug(
|
138
|
+
"incorrect response 0x%X to command 0x%X",
|
139
|
+
self._data[BMS.CMD_POS],
|
140
|
+
cmd,
|
141
|
+
)
|
142
|
+
resp_cache[self._data[BMS.CMD_POS]] = self._data.copy()
|
143
|
+
|
144
|
+
voltages: list[float] = []
|
145
|
+
for cmd in BMS.CELL_VOLTAGE_CMDS:
|
146
|
+
try:
|
147
|
+
await self._await_reply(BMS._cmd(cmd.to_bytes(1)))
|
148
|
+
except TimeoutError:
|
149
|
+
break
|
150
|
+
cells: list[float] = BMS._cell_voltages(
|
151
|
+
self._data, cells=5, start=4, byteorder="little"
|
152
|
+
)
|
153
|
+
voltages.extend(cells)
|
154
|
+
if len(voltages) % 5 or len(cells) == 0:
|
155
|
+
break
|
156
|
+
|
157
|
+
data: BMSsample = BMS._decode_data(BMS._FIELDS, resp_cache, byteorder="little")
|
158
|
+
|
159
|
+
# get cycle charge from design capacity and SoC
|
160
|
+
if data.get("design_capacity") and data.get("battery_level"):
|
161
|
+
data["cycle_charge"] = (
|
162
|
+
data.get("design_capacity", 0) * data.get("battery_level", 0) / 100
|
163
|
+
)
|
164
|
+
# remove runtime if not discharging
|
165
|
+
if data.get("current", 0) >= 0:
|
166
|
+
data.pop("runtime", None)
|
167
|
+
|
168
|
+
return data | {"cell_voltages": voltages}
|