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
@@ -0,0 +1,305 @@
|
|
1
|
+
"""Module to support Jikong Smart BMS.
|
2
|
+
|
3
|
+
Project: aiobmsble, https://pypi.org/p/aiobmsble/
|
4
|
+
License: Apache-2.0, http://www.apache.org/licenses/
|
5
|
+
"""
|
6
|
+
|
7
|
+
import asyncio
|
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, BMSmode, BMSsample, BMSvalue, MatcherPattern
|
15
|
+
from aiobmsble.basebms import BaseBMS, crc_sum
|
16
|
+
|
17
|
+
|
18
|
+
class BMS(BaseBMS):
|
19
|
+
"""Jikong Smart BMS class implementation."""
|
20
|
+
|
21
|
+
HEAD_RSP: Final = bytes([0x55, 0xAA, 0xEB, 0x90]) # header for responses
|
22
|
+
HEAD_CMD: Final = bytes([0xAA, 0x55, 0x90, 0xEB]) # header for commands (endiness!)
|
23
|
+
_READY_MSG: Final = HEAD_CMD + bytes([0xC8, 0x01, 0x01] + [0x00] * 12 + [0x44])
|
24
|
+
_BT_MODULE_MSG: Final = bytes([0x41, 0x54, 0x0D, 0x0A]) # AT\r\n from BLE module
|
25
|
+
TYPE_POS: Final[int] = 4 # frame type is right after the header
|
26
|
+
INFO_LEN: Final[int] = 300
|
27
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = ( # Protocol: JK02_32S; JK02_24S has offset -32
|
28
|
+
BMSdp("voltage", 150, 4, False, lambda x: x / 1000),
|
29
|
+
BMSdp("current", 158, 4, True, lambda x: x / 1000),
|
30
|
+
BMSdp("battery_level", 173, 1, False, lambda x: x),
|
31
|
+
BMSdp("cycle_charge", 174, 4, False, lambda x: x / 1000),
|
32
|
+
BMSdp("cycles", 182, 4, False, lambda x: x),
|
33
|
+
BMSdp("balance_current", 170, 2, True, lambda x: x / 1000),
|
34
|
+
BMSdp("temp_sensors", 214, 2, True, lambda x: x),
|
35
|
+
BMSdp("problem_code", 166, 4, False, lambda x: x),
|
36
|
+
)
|
37
|
+
|
38
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
39
|
+
"""Intialize private BMS members."""
|
40
|
+
super().__init__(ble_device, reconnect)
|
41
|
+
self._data_final: bytearray = bytearray()
|
42
|
+
self._char_write_handle: int = -1
|
43
|
+
self._bms_info: dict[str, str] = {}
|
44
|
+
self._prot_offset: int = 0
|
45
|
+
self._sw_version: int = 0
|
46
|
+
self._valid_reply: int = 0x02
|
47
|
+
self._bms_ready: bool = False
|
48
|
+
|
49
|
+
@staticmethod
|
50
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
51
|
+
"""Provide BluetoothMatcher definition."""
|
52
|
+
return [
|
53
|
+
{
|
54
|
+
"service_uuid": BMS.uuid_services()[0],
|
55
|
+
"connectable": True,
|
56
|
+
"manufacturer_id": 0x0B65,
|
57
|
+
},
|
58
|
+
]
|
59
|
+
|
60
|
+
@staticmethod
|
61
|
+
def device_info() -> dict[str, str]:
|
62
|
+
"""Return device information for the battery management system."""
|
63
|
+
return {"manufacturer": "Jikong", "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("ffe0")]
|
69
|
+
|
70
|
+
@staticmethod
|
71
|
+
def uuid_rx() -> str:
|
72
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
73
|
+
return "ffe1"
|
74
|
+
|
75
|
+
@staticmethod
|
76
|
+
def uuid_tx() -> str:
|
77
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
78
|
+
return "ffe1"
|
79
|
+
|
80
|
+
@staticmethod
|
81
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
82
|
+
return frozenset(
|
83
|
+
{
|
84
|
+
"power",
|
85
|
+
"battery_charging",
|
86
|
+
"cycle_capacity",
|
87
|
+
"runtime",
|
88
|
+
"temperature",
|
89
|
+
}
|
90
|
+
)
|
91
|
+
|
92
|
+
def _notification_handler(
|
93
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
94
|
+
) -> None:
|
95
|
+
"""Retrieve BMS data update."""
|
96
|
+
|
97
|
+
if data.startswith(BMS._BT_MODULE_MSG):
|
98
|
+
self._log.debug("filtering AT cmd")
|
99
|
+
if not (data := data.removeprefix(BMS._BT_MODULE_MSG)):
|
100
|
+
return
|
101
|
+
|
102
|
+
if (
|
103
|
+
len(self._data) >= self.INFO_LEN
|
104
|
+
and (data.startswith((BMS.HEAD_RSP, BMS.HEAD_CMD)))
|
105
|
+
) or not self._data.startswith(BMS.HEAD_RSP):
|
106
|
+
self._data = bytearray()
|
107
|
+
|
108
|
+
self._data += data
|
109
|
+
|
110
|
+
self._log.debug(
|
111
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
112
|
+
)
|
113
|
+
|
114
|
+
# verify that data is long enough
|
115
|
+
if (
|
116
|
+
len(self._data) < BMS.INFO_LEN and self._data.startswith(BMS.HEAD_RSP)
|
117
|
+
) or len(self._data) < BMS.TYPE_POS + 1:
|
118
|
+
return
|
119
|
+
|
120
|
+
# check that message type is expected
|
121
|
+
if self._data[BMS.TYPE_POS] != self._valid_reply:
|
122
|
+
self._log.debug(
|
123
|
+
"unexpected message type 0x%X (length %i): %s",
|
124
|
+
self._data[BMS.TYPE_POS],
|
125
|
+
len(self._data),
|
126
|
+
self._data,
|
127
|
+
)
|
128
|
+
return
|
129
|
+
|
130
|
+
# trim AT\r\n message from the end
|
131
|
+
if self._data.endswith(BMS._BT_MODULE_MSG):
|
132
|
+
self._log.debug("trimming AT cmd")
|
133
|
+
self._data = self._data.removesuffix(BMS._BT_MODULE_MSG)
|
134
|
+
|
135
|
+
# set BMS ready if msg is attached to last responses (v19.05)
|
136
|
+
if self._data[BMS.INFO_LEN :].startswith(BMS._READY_MSG):
|
137
|
+
self._log.debug("BMS ready.")
|
138
|
+
self._bms_ready = True
|
139
|
+
self._data = self._data[: BMS.INFO_LEN]
|
140
|
+
|
141
|
+
# trim message in case oversized
|
142
|
+
if len(self._data) > BMS.INFO_LEN:
|
143
|
+
self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
|
144
|
+
self._data = self._data[: BMS.INFO_LEN]
|
145
|
+
|
146
|
+
if (crc := crc_sum(self._data[:-1])) != self._data[-1]:
|
147
|
+
self._log.debug("invalid checksum 0x%X != 0x%X", self._data[-1], crc)
|
148
|
+
return
|
149
|
+
|
150
|
+
self._data_final = self._data.copy()
|
151
|
+
self._data_event.set()
|
152
|
+
|
153
|
+
async def _init_connection(
|
154
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
155
|
+
) -> None:
|
156
|
+
"""Initialize RX/TX characteristics and protocol state."""
|
157
|
+
char_notify_handle: int = -1
|
158
|
+
self._char_write_handle = -1
|
159
|
+
self._bms_ready = False
|
160
|
+
|
161
|
+
for service in self._client.services:
|
162
|
+
for char in service.characteristics:
|
163
|
+
self._log.debug(
|
164
|
+
"discovered %s (#%i): %s", char.uuid, char.handle, char.properties
|
165
|
+
)
|
166
|
+
if char.uuid == normalize_uuid_str(
|
167
|
+
BMS.uuid_rx()
|
168
|
+
) or char.uuid == normalize_uuid_str(BMS.uuid_tx()):
|
169
|
+
if "notify" in char.properties:
|
170
|
+
char_notify_handle = char.handle
|
171
|
+
if (
|
172
|
+
"write" in char.properties
|
173
|
+
or "write-without-response" in char.properties
|
174
|
+
):
|
175
|
+
self._char_write_handle = char.handle
|
176
|
+
if char_notify_handle == -1 or self._char_write_handle == -1:
|
177
|
+
self._log.debug("failed to detect characteristics.")
|
178
|
+
await self._client.disconnect()
|
179
|
+
raise ConnectionError(f"Failed to detect characteristics from {self.name}.")
|
180
|
+
self._log.debug(
|
181
|
+
"using characteristics handle #%i (notify), #%i (write).",
|
182
|
+
char_notify_handle,
|
183
|
+
self._char_write_handle,
|
184
|
+
)
|
185
|
+
|
186
|
+
await super()._init_connection()
|
187
|
+
|
188
|
+
# query device info frame (0x03) and wait for BMS ready (0xC8)
|
189
|
+
self._valid_reply = 0x03
|
190
|
+
await self._await_reply(self._cmd(b"\x97"), char=self._char_write_handle)
|
191
|
+
self._bms_info = BMS._dec_devinfo(self._data_final or bytearray())
|
192
|
+
self._log.debug("device information: %s", self._bms_info)
|
193
|
+
self._prot_offset = (
|
194
|
+
-32 if int(self._bms_info.get("sw_version", "")[:2]) < 11 else 0
|
195
|
+
)
|
196
|
+
if not self._bms_ready:
|
197
|
+
self._valid_reply = 0xC8 # BMS ready confirmation
|
198
|
+
await asyncio.wait_for(self._wait_event(), timeout=BMS.TIMEOUT)
|
199
|
+
self._valid_reply = 0x02 # cell information
|
200
|
+
|
201
|
+
@staticmethod
|
202
|
+
def _cmd(cmd: bytes, value: list[int] | None = None) -> bytes:
|
203
|
+
"""Assemble a Jikong BMS command."""
|
204
|
+
value = [] if value is None else value
|
205
|
+
assert len(value) <= 13
|
206
|
+
frame: bytearray = bytearray(
|
207
|
+
[*BMS.HEAD_CMD, cmd[0], len(value), *value]
|
208
|
+
) + bytearray(13 - len(value))
|
209
|
+
frame.append(crc_sum(frame))
|
210
|
+
return bytes(frame)
|
211
|
+
|
212
|
+
@staticmethod
|
213
|
+
def _dec_devinfo(data: bytearray) -> dict[str, str]:
|
214
|
+
fields: Final[dict[str, int]] = {
|
215
|
+
"hw_version": 22,
|
216
|
+
"sw_version": 30,
|
217
|
+
}
|
218
|
+
return {
|
219
|
+
key: data[idx : idx + 8].decode(errors="replace").strip("\x00")
|
220
|
+
for key, idx in fields.items()
|
221
|
+
}
|
222
|
+
|
223
|
+
def _temp_pos(self) -> list[tuple[int, int]]:
|
224
|
+
sw_majv: Final[int] = int(self._bms_info.get("sw_version", "")[:2])
|
225
|
+
if sw_majv >= 14:
|
226
|
+
return [(0, 144), (1, 162), (2, 164), (3, 254), (4, 256), (5, 258)]
|
227
|
+
if sw_majv >= 11:
|
228
|
+
return [(0, 144), (1, 162), (2, 164), (3, 254)]
|
229
|
+
return [(0, 130), (1, 132), (2, 134)]
|
230
|
+
|
231
|
+
@staticmethod
|
232
|
+
def _temp_sensors(
|
233
|
+
data: bytearray, temp_pos: list[tuple[int, int]], mask: int
|
234
|
+
) -> list[int | float]:
|
235
|
+
return [
|
236
|
+
(value / 10)
|
237
|
+
for idx, pos in temp_pos
|
238
|
+
if mask & (1 << idx)
|
239
|
+
and (
|
240
|
+
value := int.from_bytes(
|
241
|
+
data[pos : pos + 2], byteorder="little", signed=True
|
242
|
+
)
|
243
|
+
)
|
244
|
+
!= -2000
|
245
|
+
]
|
246
|
+
|
247
|
+
@staticmethod
|
248
|
+
def _conv_data(data: bytearray, offs: int, sw_majv: int) -> BMSsample:
|
249
|
+
"""Return BMS data from status message."""
|
250
|
+
|
251
|
+
result: BMSsample = BMS._decode_data(
|
252
|
+
BMS._FIELDS, data, byteorder="little", offset=offs
|
253
|
+
)
|
254
|
+
result["cell_count"] = int.from_bytes(
|
255
|
+
data[70 + (offs >> 1) : 74 + (offs >> 1)], byteorder="little"
|
256
|
+
).bit_count()
|
257
|
+
|
258
|
+
result["delta_voltage"] = (
|
259
|
+
int.from_bytes(
|
260
|
+
data[76 + (offs >> 1) : 78 + (offs >> 1)], byteorder="little"
|
261
|
+
)
|
262
|
+
/ 1000
|
263
|
+
)
|
264
|
+
|
265
|
+
if sw_majv >= 15:
|
266
|
+
result["battery_mode"] = (
|
267
|
+
BMSmode(data[280 + offs])
|
268
|
+
if data[280 + offs] in BMSmode
|
269
|
+
else BMSmode.UNKNOWN
|
270
|
+
)
|
271
|
+
|
272
|
+
return result
|
273
|
+
|
274
|
+
async def _async_update(self) -> BMSsample:
|
275
|
+
"""Update battery status information."""
|
276
|
+
if not self._data_event.is_set() or self._data_final[4] != 0x02:
|
277
|
+
# request cell info (only if data is not constantly published)
|
278
|
+
self._log.debug("requesting cell info")
|
279
|
+
await self._await_reply(
|
280
|
+
data=BMS._cmd(b"\x96"), char=self._char_write_handle
|
281
|
+
)
|
282
|
+
|
283
|
+
data: BMSsample = self._conv_data(
|
284
|
+
self._data_final,
|
285
|
+
self._prot_offset,
|
286
|
+
int(self._bms_info.get("sw_version", "")[:2]),
|
287
|
+
)
|
288
|
+
data["temp_values"] = BMS._temp_sensors(
|
289
|
+
self._data_final, self._temp_pos(), data.get("temp_sensors", 0)
|
290
|
+
)
|
291
|
+
|
292
|
+
data["problem_code"] = (
|
293
|
+
((data.get("problem_code", 0)) >> 16)
|
294
|
+
if self._prot_offset
|
295
|
+
else (data.get("problem_code", 0) & 0xFFFF)
|
296
|
+
)
|
297
|
+
|
298
|
+
data["cell_voltages"] = BMS._cell_voltages(
|
299
|
+
self._data_final,
|
300
|
+
cells=data.get("cell_count", 0),
|
301
|
+
start=6,
|
302
|
+
byteorder="little",
|
303
|
+
)
|
304
|
+
|
305
|
+
return data
|
@@ -0,0 +1,218 @@
|
|
1
|
+
"""Module to support Neey Smart BMS.
|
2
|
+
|
3
|
+
Project: aiobmsble, https://pypi.org/p/aiobmsble/
|
4
|
+
License: Apache-2.0, http://www.apache.org/licenses/
|
5
|
+
"""
|
6
|
+
|
7
|
+
from collections.abc import Callable
|
8
|
+
from struct import unpack_from
|
9
|
+
from typing import Any, Final, Literal
|
10
|
+
|
11
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
12
|
+
from bleak.backends.device import BLEDevice
|
13
|
+
from bleak.uuids import normalize_uuid_str
|
14
|
+
|
15
|
+
from aiobmsble import BMSsample, BMSvalue, MatcherPattern
|
16
|
+
from aiobmsble.basebms import BaseBMS, crc_sum
|
17
|
+
|
18
|
+
|
19
|
+
class BMS(BaseBMS):
|
20
|
+
"""Neey Smart BMS class implementation."""
|
21
|
+
|
22
|
+
_BT_MODULE_MSG: Final = bytes([0x41, 0x54, 0x0D, 0x0A]) # AT\r\n from BLE module
|
23
|
+
_HEAD_RSP: Final = bytes([0x55, 0xAA, 0x11, 0x01]) # start, dev addr, read cmd
|
24
|
+
_HEAD_CMD: Final = bytes(
|
25
|
+
[0xAA, 0x55, 0x11, 0x01]
|
26
|
+
) # header for commands (endiness!)
|
27
|
+
_TAIL: Final[int] = 0xFF # end of message
|
28
|
+
_TYPE_POS: Final[int] = 4 # frame type is right after the header
|
29
|
+
_MIN_FRAME: Final[int] = 10 # header length
|
30
|
+
_FIELDS: Final[list[tuple[BMSvalue, int, str, Callable[[int], Any]]]] = [
|
31
|
+
("voltage", 201, "<f", lambda x: round(x, 3)),
|
32
|
+
("delta_voltage", 209, "<f", lambda x: round(x, 3)),
|
33
|
+
("problem_code", 216, "B", lambda x: x if x in {1, 3, 7, 8, 9, 10, 11} else 0),
|
34
|
+
("balance_current", 217, "<f", lambda x: round(x, 3)),
|
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: bytearray = bytearray()
|
41
|
+
self._bms_info: dict[str, str] = {}
|
42
|
+
self._exp_len: int = BMS._MIN_FRAME
|
43
|
+
self._valid_reply: int = 0x02
|
44
|
+
|
45
|
+
@staticmethod
|
46
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
47
|
+
"""Provide BluetoothMatcher definition."""
|
48
|
+
return [
|
49
|
+
{
|
50
|
+
"local_name": pattern,
|
51
|
+
"service_uuid": normalize_uuid_str("fee7"),
|
52
|
+
"connectable": True,
|
53
|
+
}
|
54
|
+
for pattern in ("EK-*", "GW-*")
|
55
|
+
]
|
56
|
+
|
57
|
+
@staticmethod
|
58
|
+
def device_info() -> dict[str, str]:
|
59
|
+
"""Return device information for the battery management system."""
|
60
|
+
return {"manufacturer": "Neey", "model": "Balancer"}
|
61
|
+
|
62
|
+
@staticmethod
|
63
|
+
def uuid_services() -> list[str]:
|
64
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
65
|
+
return [normalize_uuid_str("ffe0")]
|
66
|
+
|
67
|
+
@staticmethod
|
68
|
+
def uuid_rx() -> str:
|
69
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
70
|
+
return "ffe1"
|
71
|
+
|
72
|
+
@staticmethod
|
73
|
+
def uuid_tx() -> str:
|
74
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
75
|
+
return "ffe1"
|
76
|
+
|
77
|
+
@staticmethod
|
78
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
79
|
+
return frozenset({"temperature"})
|
80
|
+
|
81
|
+
def _notification_handler(
|
82
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
83
|
+
) -> None:
|
84
|
+
"""Retrieve BMS data update."""
|
85
|
+
|
86
|
+
if (
|
87
|
+
len(self._data) >= self._exp_len or not self._data.startswith(BMS._HEAD_RSP)
|
88
|
+
) and data.startswith(BMS._HEAD_RSP):
|
89
|
+
self._data = bytearray()
|
90
|
+
self._exp_len = max(
|
91
|
+
int.from_bytes(data[6:8], byteorder="little", signed=False),
|
92
|
+
BMS._MIN_FRAME,
|
93
|
+
)
|
94
|
+
|
95
|
+
self._data += data
|
96
|
+
|
97
|
+
self._log.debug(
|
98
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
99
|
+
)
|
100
|
+
|
101
|
+
# verify that data is long enough
|
102
|
+
if len(self._data) < self._exp_len:
|
103
|
+
return
|
104
|
+
|
105
|
+
if not self._data.startswith(BMS._HEAD_RSP):
|
106
|
+
self._log.debug("incorrect frame start.")
|
107
|
+
return
|
108
|
+
|
109
|
+
# trim message in case oversized
|
110
|
+
if len(self._data) > self._exp_len:
|
111
|
+
self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
|
112
|
+
self._data = self._data[: self._exp_len]
|
113
|
+
|
114
|
+
if self._data[-1] != BMS._TAIL:
|
115
|
+
self._log.debug("incorrect frame end.")
|
116
|
+
return
|
117
|
+
|
118
|
+
# check that message type is expected
|
119
|
+
if self._data[BMS._TYPE_POS] != self._valid_reply:
|
120
|
+
self._log.debug(
|
121
|
+
"unexpected message type 0x%X (length %i): %s",
|
122
|
+
self._data[BMS._TYPE_POS],
|
123
|
+
len(self._data),
|
124
|
+
self._data,
|
125
|
+
)
|
126
|
+
return
|
127
|
+
|
128
|
+
if (crc := crc_sum(self._data[:-2])) != self._data[-2]:
|
129
|
+
self._log.debug("invalid checksum 0x%X != 0x%X", self._data[-2], crc)
|
130
|
+
return
|
131
|
+
|
132
|
+
self._data_final = self._data.copy()
|
133
|
+
self._data_event.set()
|
134
|
+
|
135
|
+
async def _init_connection(
|
136
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
137
|
+
) -> None:
|
138
|
+
"""Initialize RX/TX characteristics and protocol state."""
|
139
|
+
await super()._init_connection(char_notify)
|
140
|
+
|
141
|
+
# query device info frame (0x03) and wait for BMS ready (0xC8)
|
142
|
+
self._valid_reply = 0x01
|
143
|
+
await self._await_reply(self._cmd(b"\x01"))
|
144
|
+
self._bms_info = BMS._dec_devinfo(self._data_final or bytearray())
|
145
|
+
self._log.debug("device information: %s", self._bms_info)
|
146
|
+
|
147
|
+
self._valid_reply = 0x02 # cell information
|
148
|
+
|
149
|
+
@staticmethod
|
150
|
+
def _cmd(cmd: bytes, reg: int = 0, value: list[int] | None = None) -> bytes:
|
151
|
+
"""Assemble a Neey BMS command."""
|
152
|
+
value = [] if value is None else value
|
153
|
+
assert len(value) <= 11
|
154
|
+
frame: bytearray = bytearray( # 0x14 frame length
|
155
|
+
[*BMS._HEAD_CMD, cmd[0], reg & 0xFF, 0x14, *value]
|
156
|
+
) + bytearray(11 - len(value))
|
157
|
+
frame += bytes([crc_sum(frame), BMS._TAIL])
|
158
|
+
return bytes(frame)
|
159
|
+
|
160
|
+
@staticmethod
|
161
|
+
def _dec_devinfo(data: bytearray) -> dict[str, str]:
|
162
|
+
fields: Final[dict[str, int]] = {
|
163
|
+
"hw_version": 24,
|
164
|
+
"sw_version": 32,
|
165
|
+
}
|
166
|
+
return {
|
167
|
+
key: data[idx : idx + 8].decode(errors="replace").strip("\x00")
|
168
|
+
for key, idx in fields.items()
|
169
|
+
}
|
170
|
+
|
171
|
+
@staticmethod
|
172
|
+
def _cell_voltages(
|
173
|
+
data: bytearray,
|
174
|
+
*,
|
175
|
+
cells: int,
|
176
|
+
start: int,
|
177
|
+
size: int = 2,
|
178
|
+
byteorder: Literal["little", "big"] = "big",
|
179
|
+
divider: int = 1000,
|
180
|
+
) -> list[float]:
|
181
|
+
"""Parse cell voltages from message."""
|
182
|
+
return [
|
183
|
+
round(value, 3)
|
184
|
+
for idx in range(cells)
|
185
|
+
if (value := unpack_from("<f", data, start + idx * size)[0])
|
186
|
+
]
|
187
|
+
|
188
|
+
@staticmethod
|
189
|
+
def _temp_sensors(data: bytearray, sensors: int) -> list[int | float]:
|
190
|
+
return [
|
191
|
+
round(unpack_from("<f", data, 221 + idx * 4)[0], 2)
|
192
|
+
for idx in range(sensors)
|
193
|
+
]
|
194
|
+
|
195
|
+
@staticmethod
|
196
|
+
def _conv_data(data: bytearray) -> BMSsample:
|
197
|
+
"""Return BMS data from status message."""
|
198
|
+
result: BMSsample = {}
|
199
|
+
for key, idx, fmt, func in BMS._FIELDS:
|
200
|
+
result[key] = func(unpack_from(fmt, data, idx)[0])
|
201
|
+
|
202
|
+
return result
|
203
|
+
|
204
|
+
async def _async_update(self) -> BMSsample:
|
205
|
+
"""Update battery status information."""
|
206
|
+
if not self._data_event.is_set() or self._data_final[4] != 0x02:
|
207
|
+
# request cell info (only if data is not constantly published)
|
208
|
+
self._log.debug("requesting cell info")
|
209
|
+
await self._await_reply(data=BMS._cmd(b"\x02"))
|
210
|
+
|
211
|
+
data: BMSsample = self._conv_data(self._data_final)
|
212
|
+
data["temp_values"] = BMS._temp_sensors(self._data_final, 2)
|
213
|
+
|
214
|
+
data["cell_voltages"] = BMS._cell_voltages(
|
215
|
+
self._data_final, cells=24, start=9, byteorder="little", size=4
|
216
|
+
)
|
217
|
+
|
218
|
+
return data
|