aiobmsble 0.2.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/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 +1 -1
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.1.dist-info}/METADATA +3 -2
- aiobmsble-0.2.1.dist-info/RECORD +36 -0
- aiobmsble-0.2.0.dist-info/RECORD +0 -10
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.1.dist-info}/WHEEL +0 -0
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.1.dist-info}/entry_points.txt +0 -0
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,214 @@
|
|
1
|
+
"""Module to support Neey Smart BMS."""
|
2
|
+
|
3
|
+
from collections.abc import Callable
|
4
|
+
from struct import unpack_from
|
5
|
+
from typing import Any, Final, Literal
|
6
|
+
|
7
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
8
|
+
from bleak.backends.device import BLEDevice
|
9
|
+
from bleak.uuids import normalize_uuid_str
|
10
|
+
|
11
|
+
from aiobmsble import BMSsample, BMSvalue, MatcherPattern
|
12
|
+
from aiobmsble.basebms import BaseBMS, crc_sum
|
13
|
+
|
14
|
+
|
15
|
+
class BMS(BaseBMS):
|
16
|
+
"""Neey Smart BMS class implementation."""
|
17
|
+
|
18
|
+
_BT_MODULE_MSG: Final = bytes([0x41, 0x54, 0x0D, 0x0A]) # AT\r\n from BLE module
|
19
|
+
_HEAD_RSP: Final = bytes([0x55, 0xAA, 0x11, 0x01]) # start, dev addr, read cmd
|
20
|
+
_HEAD_CMD: Final = bytes(
|
21
|
+
[0xAA, 0x55, 0x11, 0x01]
|
22
|
+
) # header for commands (endiness!)
|
23
|
+
_TAIL: Final[int] = 0xFF # end of message
|
24
|
+
_TYPE_POS: Final[int] = 4 # frame type is right after the header
|
25
|
+
_MIN_FRAME: Final[int] = 10 # header length
|
26
|
+
_FIELDS: Final[list[tuple[BMSvalue, int, str, Callable[[int], Any]]]] = [
|
27
|
+
("voltage", 201, "<f", lambda x: round(x, 3)),
|
28
|
+
("delta_voltage", 209, "<f", lambda x: round(x, 3)),
|
29
|
+
("problem_code", 216, "B", lambda x: x if x in {1, 3, 7, 8, 9, 10, 11} else 0),
|
30
|
+
("balance_current", 217, "<f", lambda x: round(x, 3)),
|
31
|
+
]
|
32
|
+
|
33
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
34
|
+
"""Intialize private BMS members."""
|
35
|
+
super().__init__(ble_device, reconnect)
|
36
|
+
self._data_final: bytearray = bytearray()
|
37
|
+
self._bms_info: dict[str, str] = {}
|
38
|
+
self._exp_len: int = BMS._MIN_FRAME
|
39
|
+
self._valid_reply: int = 0x02
|
40
|
+
|
41
|
+
@staticmethod
|
42
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
43
|
+
"""Provide BluetoothMatcher definition."""
|
44
|
+
return [
|
45
|
+
{
|
46
|
+
"local_name": pattern,
|
47
|
+
"service_uuid": normalize_uuid_str("fee7"),
|
48
|
+
"connectable": True,
|
49
|
+
}
|
50
|
+
for pattern in ("EK-*", "GW-*")
|
51
|
+
]
|
52
|
+
|
53
|
+
@staticmethod
|
54
|
+
def device_info() -> dict[str, str]:
|
55
|
+
"""Return device information for the battery management system."""
|
56
|
+
return {"manufacturer": "Neey", "model": "Balancer"}
|
57
|
+
|
58
|
+
@staticmethod
|
59
|
+
def uuid_services() -> list[str]:
|
60
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
61
|
+
return [normalize_uuid_str("ffe0")]
|
62
|
+
|
63
|
+
@staticmethod
|
64
|
+
def uuid_rx() -> str:
|
65
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
66
|
+
return "ffe1"
|
67
|
+
|
68
|
+
@staticmethod
|
69
|
+
def uuid_tx() -> str:
|
70
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
71
|
+
return "ffe1"
|
72
|
+
|
73
|
+
@staticmethod
|
74
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
75
|
+
return frozenset({"temperature"})
|
76
|
+
|
77
|
+
def _notification_handler(
|
78
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
79
|
+
) -> None:
|
80
|
+
"""Retrieve BMS data update."""
|
81
|
+
|
82
|
+
if (
|
83
|
+
len(self._data) >= self._exp_len or not self._data.startswith(BMS._HEAD_RSP)
|
84
|
+
) and data.startswith(BMS._HEAD_RSP):
|
85
|
+
self._data = bytearray()
|
86
|
+
self._exp_len = max(
|
87
|
+
int.from_bytes(data[6:8], byteorder="little", signed=False),
|
88
|
+
BMS._MIN_FRAME,
|
89
|
+
)
|
90
|
+
|
91
|
+
self._data += data
|
92
|
+
|
93
|
+
self._log.debug(
|
94
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
95
|
+
)
|
96
|
+
|
97
|
+
# verify that data is long enough
|
98
|
+
if len(self._data) < self._exp_len:
|
99
|
+
return
|
100
|
+
|
101
|
+
if not self._data.startswith(BMS._HEAD_RSP):
|
102
|
+
self._log.debug("incorrect frame start.")
|
103
|
+
return
|
104
|
+
|
105
|
+
# trim message in case oversized
|
106
|
+
if len(self._data) > self._exp_len:
|
107
|
+
self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
|
108
|
+
self._data = self._data[: self._exp_len]
|
109
|
+
|
110
|
+
if self._data[-1] != BMS._TAIL:
|
111
|
+
self._log.debug("incorrect frame end.")
|
112
|
+
return
|
113
|
+
|
114
|
+
# check that message type is expected
|
115
|
+
if self._data[BMS._TYPE_POS] != self._valid_reply:
|
116
|
+
self._log.debug(
|
117
|
+
"unexpected message type 0x%X (length %i): %s",
|
118
|
+
self._data[BMS._TYPE_POS],
|
119
|
+
len(self._data),
|
120
|
+
self._data,
|
121
|
+
)
|
122
|
+
return
|
123
|
+
|
124
|
+
if (crc := crc_sum(self._data[:-2])) != self._data[-2]:
|
125
|
+
self._log.debug("invalid checksum 0x%X != 0x%X", self._data[-2], crc)
|
126
|
+
return
|
127
|
+
|
128
|
+
self._data_final = self._data.copy()
|
129
|
+
self._data_event.set()
|
130
|
+
|
131
|
+
async def _init_connection(
|
132
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
133
|
+
) -> None:
|
134
|
+
"""Initialize RX/TX characteristics and protocol state."""
|
135
|
+
await super()._init_connection(char_notify)
|
136
|
+
|
137
|
+
# query device info frame (0x03) and wait for BMS ready (0xC8)
|
138
|
+
self._valid_reply = 0x01
|
139
|
+
await self._await_reply(self._cmd(b"\x01"))
|
140
|
+
self._bms_info = BMS._dec_devinfo(self._data_final or bytearray())
|
141
|
+
self._log.debug("device information: %s", self._bms_info)
|
142
|
+
|
143
|
+
self._valid_reply = 0x02 # cell information
|
144
|
+
|
145
|
+
@staticmethod
|
146
|
+
def _cmd(cmd: bytes, reg: int = 0, value: list[int] | None = None) -> bytes:
|
147
|
+
"""Assemble a Neey BMS command."""
|
148
|
+
value = [] if value is None else value
|
149
|
+
assert len(value) <= 11
|
150
|
+
frame: bytearray = bytearray( # 0x14 frame length
|
151
|
+
[*BMS._HEAD_CMD, cmd[0], reg & 0xFF, 0x14, *value]
|
152
|
+
) + bytearray(11 - len(value))
|
153
|
+
frame += bytes([crc_sum(frame), BMS._TAIL])
|
154
|
+
return bytes(frame)
|
155
|
+
|
156
|
+
@staticmethod
|
157
|
+
def _dec_devinfo(data: bytearray) -> dict[str, str]:
|
158
|
+
fields: Final[dict[str, int]] = {
|
159
|
+
"hw_version": 24,
|
160
|
+
"sw_version": 32,
|
161
|
+
}
|
162
|
+
return {
|
163
|
+
key: data[idx : idx + 8].decode(errors="replace").strip("\x00")
|
164
|
+
for key, idx in fields.items()
|
165
|
+
}
|
166
|
+
|
167
|
+
@staticmethod
|
168
|
+
def _cell_voltages(
|
169
|
+
data: bytearray,
|
170
|
+
*,
|
171
|
+
cells: int,
|
172
|
+
start: int,
|
173
|
+
size: int = 2,
|
174
|
+
byteorder: Literal["little", "big"] = "big",
|
175
|
+
divider: int = 1000,
|
176
|
+
) -> list[float]:
|
177
|
+
"""Parse cell voltages from message."""
|
178
|
+
return [
|
179
|
+
round(value, 3)
|
180
|
+
for idx in range(cells)
|
181
|
+
if (value := unpack_from("<f", data, start + idx * size)[0])
|
182
|
+
]
|
183
|
+
|
184
|
+
@staticmethod
|
185
|
+
def _temp_sensors(data: bytearray, sensors: int) -> list[int | float]:
|
186
|
+
return [
|
187
|
+
round(unpack_from("<f", data, 221 + idx * 4)[0], 2)
|
188
|
+
for idx in range(sensors)
|
189
|
+
]
|
190
|
+
|
191
|
+
@staticmethod
|
192
|
+
def _conv_data(data: bytearray) -> BMSsample:
|
193
|
+
"""Return BMS data from status message."""
|
194
|
+
result: BMSsample = {}
|
195
|
+
for key, idx, fmt, func in BMS._FIELDS:
|
196
|
+
result[key] = func(unpack_from(fmt, data, idx)[0])
|
197
|
+
|
198
|
+
return result
|
199
|
+
|
200
|
+
async def _async_update(self) -> BMSsample:
|
201
|
+
"""Update battery status information."""
|
202
|
+
if not self._data_event.is_set() or self._data_final[4] != 0x02:
|
203
|
+
# request cell info (only if data is not constantly published)
|
204
|
+
self._log.debug("requesting cell info")
|
205
|
+
await self._await_reply(data=BMS._cmd(b"\x02"))
|
206
|
+
|
207
|
+
data: BMSsample = self._conv_data(self._data_final)
|
208
|
+
data["temp_values"] = BMS._temp_sensors(self._data_final, 2)
|
209
|
+
|
210
|
+
data["cell_voltages"] = BMS._cell_voltages(
|
211
|
+
self._data_final, cells=24, start=9, byteorder="little", size=4
|
212
|
+
)
|
213
|
+
|
214
|
+
return data
|
aiobmsble/bms/ogt_bms.py
ADDED
@@ -0,0 +1,214 @@
|
|
1
|
+
"""Module to support Offgridtec Smart Pro BMS."""
|
2
|
+
|
3
|
+
from collections.abc import Callable
|
4
|
+
from string import digits, hexdigits
|
5
|
+
from typing import Any, Final, NamedTuple
|
6
|
+
|
7
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
8
|
+
from bleak.backends.device import BLEDevice
|
9
|
+
from bleak.uuids import normalize_uuid_str
|
10
|
+
|
11
|
+
from aiobmsble import BMSsample, BMSvalue, MatcherPattern
|
12
|
+
from aiobmsble.basebms import BaseBMS
|
13
|
+
|
14
|
+
|
15
|
+
class BMS(BaseBMS):
|
16
|
+
"""Offgridtec LiFePO4 Smart Pro type A and type B BMS implementation."""
|
17
|
+
|
18
|
+
_IDX_NAME: Final = 0
|
19
|
+
_IDX_LEN: Final = 1
|
20
|
+
_IDX_FCT: Final = 2
|
21
|
+
# magic crypt sequence of length 16
|
22
|
+
_CRYPT_SEQ: Final[list[int]] = [2, 5, 4, 3, 1, 4, 1, 6, 8, 3, 7, 2, 5, 8, 9, 3]
|
23
|
+
|
24
|
+
class _Response(NamedTuple):
|
25
|
+
valid: bool
|
26
|
+
reg: int
|
27
|
+
value: int
|
28
|
+
|
29
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
30
|
+
"""Intialize private BMS members."""
|
31
|
+
super().__init__(ble_device, reconnect)
|
32
|
+
self._type: str = (
|
33
|
+
self.name[9]
|
34
|
+
if len(self.name) >= 10 and set(self.name[10:]).issubset(digits)
|
35
|
+
else "?"
|
36
|
+
)
|
37
|
+
self._key: int = (
|
38
|
+
sum(BMS._CRYPT_SEQ[int(c, 16)] for c in (f"{int(self.name[10:]):0>4X}"))
|
39
|
+
if self._type in "AB"
|
40
|
+
else 0
|
41
|
+
) + (5 if (self._type == "A") else 8)
|
42
|
+
self._log.info(
|
43
|
+
"%s type: %c, ID: %s, key: 0x%X",
|
44
|
+
self.device_id(),
|
45
|
+
self._type,
|
46
|
+
self.name[10:],
|
47
|
+
self._key,
|
48
|
+
)
|
49
|
+
self._exp_reply: int = 0x0
|
50
|
+
self._response: BMS._Response = BMS._Response(False, 0, 0)
|
51
|
+
self._REGISTERS: dict[int, tuple[BMSvalue, int, Callable[[int], Any]]]
|
52
|
+
if self._type == "A":
|
53
|
+
self._REGISTERS = {
|
54
|
+
# SOC (State of Charge)
|
55
|
+
2: ("battery_level", 1, lambda x: x),
|
56
|
+
4: ("cycle_charge", 3, lambda x: x / 1000),
|
57
|
+
8: ("voltage", 2, lambda x: x / 1000),
|
58
|
+
# MOS temperature
|
59
|
+
12: ("temperature", 2, lambda x: round(x * 0.1 - 273.15, 1)),
|
60
|
+
# 3rd byte of current is 0 (should be 1 as for B version)
|
61
|
+
16: ("current", 3, lambda x: x / 100),
|
62
|
+
24: ("runtime", 2, lambda x: x * 60),
|
63
|
+
44: ("cycles", 2, lambda x: x),
|
64
|
+
# Type A batteries have no cell voltage registers
|
65
|
+
}
|
66
|
+
self._HEADER = "+RAA"
|
67
|
+
elif self._type == "B":
|
68
|
+
self._REGISTERS = {
|
69
|
+
# MOS temperature
|
70
|
+
8: ("temperature", 2, lambda x: round(x * 0.1 - 273.15, 1)),
|
71
|
+
9: ("voltage", 2, lambda x: x / 1000),
|
72
|
+
10: ("current", 3, lambda x: x / 1000),
|
73
|
+
# SOC (State of Charge)
|
74
|
+
13: ("battery_level", 1, lambda x: x),
|
75
|
+
15: ("cycle_charge", 3, lambda x: x / 1000),
|
76
|
+
18: ("runtime", 2, lambda x: x * 60),
|
77
|
+
23: ("cycles", 2, lambda x: x),
|
78
|
+
}
|
79
|
+
# add cell voltage registers, note: need to be last!
|
80
|
+
self._HEADER = "+R16"
|
81
|
+
else:
|
82
|
+
self._REGISTERS = {}
|
83
|
+
self._log.exception("unkown device type '%c'", self._type)
|
84
|
+
|
85
|
+
@staticmethod
|
86
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
87
|
+
"""Return a list of Bluetooth matchers."""
|
88
|
+
return [
|
89
|
+
{
|
90
|
+
"local_name": "SmartBat-[AB]*",
|
91
|
+
"service_uuid": BMS.uuid_services()[0],
|
92
|
+
"connectable": True,
|
93
|
+
}
|
94
|
+
]
|
95
|
+
|
96
|
+
@staticmethod
|
97
|
+
def device_info() -> dict[str, str]:
|
98
|
+
"""Return a dictionary of device information."""
|
99
|
+
return {"manufacturer": "Offgridtec", "model": "LiFePo4 Smart Pro"}
|
100
|
+
|
101
|
+
@staticmethod
|
102
|
+
def uuid_services() -> list[str]:
|
103
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
104
|
+
return [normalize_uuid_str("fff0")]
|
105
|
+
|
106
|
+
@staticmethod
|
107
|
+
def uuid_rx() -> str:
|
108
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
109
|
+
return "fff4"
|
110
|
+
|
111
|
+
@staticmethod
|
112
|
+
def uuid_tx() -> str:
|
113
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
114
|
+
return "fff6"
|
115
|
+
|
116
|
+
@staticmethod
|
117
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
118
|
+
return frozenset(
|
119
|
+
{"cycle_capacity", "power", "battery_charging", "delta_voltage"}
|
120
|
+
)
|
121
|
+
|
122
|
+
def _notification_handler(
|
123
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
124
|
+
) -> None:
|
125
|
+
self._log.debug("RX BLE data: %s", data)
|
126
|
+
|
127
|
+
self._response = self._ogt_response(data)
|
128
|
+
|
129
|
+
# check that descrambled message is valid
|
130
|
+
if not self._response.valid:
|
131
|
+
self._log.debug("response data is invalid")
|
132
|
+
return
|
133
|
+
|
134
|
+
if self._response.reg not in (-1, self._exp_reply):
|
135
|
+
self._log.debug("wrong register response")
|
136
|
+
return
|
137
|
+
|
138
|
+
self._exp_reply = -1
|
139
|
+
self._data_event.set()
|
140
|
+
|
141
|
+
def _ogt_response(self, resp: bytearray) -> _Response:
|
142
|
+
"""Descramble a response from the BMS."""
|
143
|
+
|
144
|
+
try:
|
145
|
+
msg: Final[str] = bytearray(
|
146
|
+
(resp[x] ^ self._key) for x in range(len(resp))
|
147
|
+
).decode(encoding="ascii")
|
148
|
+
except UnicodeDecodeError:
|
149
|
+
return BMS._Response(False, -1, 0)
|
150
|
+
|
151
|
+
self._log.debug("response: %s", msg.rstrip("\r\n"))
|
152
|
+
# verify correct response
|
153
|
+
if len(msg) < 8 or not msg.startswith("+RD,"):
|
154
|
+
return BMS._Response(False, -1, 0)
|
155
|
+
if msg[4:7] == "Err":
|
156
|
+
return BMS._Response(True, -1, 0)
|
157
|
+
if not msg.endswith("\r\n") or not all(c in hexdigits for c in msg[4:-2]):
|
158
|
+
return BMS._Response(False, -1, 0)
|
159
|
+
|
160
|
+
# 16-bit value in network order (plus optional multiplier for 24-bit values)
|
161
|
+
# multiplier has 1 as minimum due to current value in A type battery
|
162
|
+
signed: bool = len(msg) > 12
|
163
|
+
value: int = int.from_bytes(
|
164
|
+
bytes.fromhex(msg[6:10]), byteorder="little", signed=signed
|
165
|
+
) * (max(int(msg[10:12], 16), 1) if signed else 1)
|
166
|
+
return BMS._Response(True, int(msg[4:6], 16), value)
|
167
|
+
|
168
|
+
def _ogt_command(self, reg: int, length: int) -> bytes:
|
169
|
+
"""Put together an scambled query to the BMS."""
|
170
|
+
|
171
|
+
cmd: Final[str] = f"{self._HEADER}{reg:0>2X}{length:0>2X}"
|
172
|
+
self._log.debug("command: %s", cmd)
|
173
|
+
|
174
|
+
return bytes(ord(cmd[i]) ^ self._key for i in range(len(cmd)))
|
175
|
+
|
176
|
+
async def _async_update(self) -> BMSsample:
|
177
|
+
"""Update battery status information."""
|
178
|
+
result: BMSsample = {}
|
179
|
+
|
180
|
+
for reg in list(self._REGISTERS):
|
181
|
+
self._exp_reply = reg
|
182
|
+
await self._await_reply(
|
183
|
+
data=self._ogt_command(reg, self._REGISTERS[reg][BMS._IDX_LEN])
|
184
|
+
)
|
185
|
+
if self._response.reg < 0:
|
186
|
+
raise TimeoutError
|
187
|
+
|
188
|
+
name, _length, func = self._REGISTERS[self._response.reg]
|
189
|
+
result[name] = func(self._response.value)
|
190
|
+
self._log.debug(
|
191
|
+
"decoded data: reg: %s (#%i), raw: %i, value: %f",
|
192
|
+
name,
|
193
|
+
reg,
|
194
|
+
self._response.value,
|
195
|
+
result.get(name),
|
196
|
+
)
|
197
|
+
|
198
|
+
# read cell voltages for type B battery
|
199
|
+
if self._type == "B":
|
200
|
+
for cell_reg in range(16):
|
201
|
+
self._exp_reply = 63 - cell_reg
|
202
|
+
await self._await_reply(data=self._ogt_command(63 - cell_reg, 2))
|
203
|
+
if self._response.reg < 0:
|
204
|
+
self._log.debug("cell count: %i", cell_reg)
|
205
|
+
break
|
206
|
+
result.setdefault("cell_voltages", []).append(
|
207
|
+
self._response.value / 1000
|
208
|
+
)
|
209
|
+
|
210
|
+
# remove remaining runtime if battery is charging
|
211
|
+
if result.get("runtime") == 0xFFFF * 60:
|
212
|
+
del result["runtime"]
|
213
|
+
|
214
|
+
return result
|
aiobmsble/bms/pro_bms.py
ADDED
@@ -0,0 +1,144 @@
|
|
1
|
+
"""Module to support Pro BMS."""
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
from typing import Final
|
5
|
+
|
6
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
7
|
+
from bleak.backends.device import BLEDevice
|
8
|
+
from bleak.uuids import normalize_uuid_str
|
9
|
+
|
10
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
11
|
+
from aiobmsble.basebms import BaseBMS
|
12
|
+
|
13
|
+
|
14
|
+
class BMS(BaseBMS):
|
15
|
+
"""Pro BMS Smart Shunt class implementation."""
|
16
|
+
|
17
|
+
_HEAD: Final[bytes] = bytes([0x55, 0xAA])
|
18
|
+
_MIN_LEN: Final[int] = 5
|
19
|
+
_INIT_RESP: Final[int] = 0x03
|
20
|
+
_RT_DATA: Final[int] = 0x04
|
21
|
+
|
22
|
+
# Commands from btsnoop capture
|
23
|
+
_CMD_INIT: Final[bytes] = bytes.fromhex("55aa0a0101558004077f648e682b")
|
24
|
+
_CMD_ACK: Final[bytes] = bytes.fromhex("55aa070101558040000095")
|
25
|
+
_CMD_DATA_STREAM: Final[bytes] = bytes.fromhex("55aa070101558042000097")
|
26
|
+
# command that triggers data streaming (Function 0x43)
|
27
|
+
_CMD_TRIGGER_DATA: Final[bytes] = bytes.fromhex("55aa0901015580430000120084")
|
28
|
+
|
29
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
30
|
+
BMSdp("voltage", 8, 2, False, lambda x: x / 100),
|
31
|
+
BMSdp(
|
32
|
+
"current",
|
33
|
+
12,
|
34
|
+
4,
|
35
|
+
False,
|
36
|
+
lambda x: ((x & 0xFFFF) / 1000) * (-1 if (x >> 24) & 0x80 else 1),
|
37
|
+
),
|
38
|
+
BMSdp("problem_code", 15, 4, False, lambda x: x & 0x7F),
|
39
|
+
BMSdp(
|
40
|
+
"temperature",
|
41
|
+
16,
|
42
|
+
3,
|
43
|
+
False,
|
44
|
+
lambda x: ((x & 0xFFFF) / 10) * (-1 if x >> 16 else 1),
|
45
|
+
),
|
46
|
+
BMSdp("cycle_charge", 20, 4, False, lambda x: x / 100),
|
47
|
+
BMSdp("battery_level", 24, 1, False, lambda x: x),
|
48
|
+
BMSdp("power", 32, 4, False, lambda x: x / 100),
|
49
|
+
)
|
50
|
+
|
51
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
52
|
+
"""Initialize private BMS members."""
|
53
|
+
super().__init__(ble_device, reconnect)
|
54
|
+
self._valid_reply: int = BMS._RT_DATA
|
55
|
+
|
56
|
+
@staticmethod
|
57
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
58
|
+
"""Provide BluetoothMatcher definition."""
|
59
|
+
return [
|
60
|
+
MatcherPattern(
|
61
|
+
local_name="Pro BMS",
|
62
|
+
service_uuid=BMS.uuid_services()[0],
|
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": "Pro BMS", "model": "Smart Shunt"}
|
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("fff0")]
|
76
|
+
|
77
|
+
@staticmethod
|
78
|
+
def uuid_rx() -> str:
|
79
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
80
|
+
return "fff4"
|
81
|
+
|
82
|
+
@staticmethod
|
83
|
+
def uuid_tx() -> str:
|
84
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
85
|
+
return "fff3"
|
86
|
+
|
87
|
+
@staticmethod
|
88
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
89
|
+
return frozenset({"battery_charging", "cycle_capacity", "runtime"})
|
90
|
+
|
91
|
+
def _notification_handler(
|
92
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
93
|
+
) -> None:
|
94
|
+
self._log.debug("RX BLE data: %s", data)
|
95
|
+
|
96
|
+
if len(data) < BMS._MIN_LEN or not data.startswith(BMS._HEAD):
|
97
|
+
self._log.debug("Invalid packet header")
|
98
|
+
return
|
99
|
+
|
100
|
+
if data[3] != self._valid_reply:
|
101
|
+
self._log.debug("unexpected response (type 0x%X)", data[3])
|
102
|
+
return
|
103
|
+
|
104
|
+
if len(data) != data[2] + BMS._MIN_LEN:
|
105
|
+
self._log.debug("incorrect frame length: %i).", len(self._data))
|
106
|
+
return
|
107
|
+
|
108
|
+
self._data = data
|
109
|
+
self._data_event.set()
|
110
|
+
|
111
|
+
async def _init_connection(
|
112
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
113
|
+
) -> None:
|
114
|
+
"""Initialize RX/TX characteristics and protocol state."""
|
115
|
+
await super()._init_connection()
|
116
|
+
self._valid_reply = BMS._INIT_RESP
|
117
|
+
|
118
|
+
# Step 1: Send initialization command and await response
|
119
|
+
await self._await_reply(BMS._CMD_INIT)
|
120
|
+
|
121
|
+
# Step 2: Send ACK command
|
122
|
+
# Step 3: Send data stream command
|
123
|
+
# Step 4: Send trigger data command 0x43 - start RT data stream
|
124
|
+
for cmd in (BMS._CMD_ACK, BMS._CMD_DATA_STREAM, BMS._CMD_TRIGGER_DATA):
|
125
|
+
await self._await_reply(cmd, wait_for_notify=False)
|
126
|
+
|
127
|
+
self._valid_reply = BMS._RT_DATA
|
128
|
+
|
129
|
+
async def _async_update(self) -> BMSsample:
|
130
|
+
"""Update battery status information."""
|
131
|
+
|
132
|
+
self._data_event.clear() # Clear the event to ensure fresh data on each update
|
133
|
+
try:
|
134
|
+
# Wait for new data packet
|
135
|
+
await asyncio.wait_for(self._wait_event(), timeout=BMS.TIMEOUT)
|
136
|
+
except TimeoutError:
|
137
|
+
await self.disconnect()
|
138
|
+
raise
|
139
|
+
|
140
|
+
result: BMSsample = BMS._decode_data(
|
141
|
+
BMS._FIELDS, self._data, byteorder="little"
|
142
|
+
)
|
143
|
+
result["power"] *= -1 if result["current"] < 0 else 1
|
144
|
+
return result
|
@@ -0,0 +1,127 @@
|
|
1
|
+
"""Module to support Redodo 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
|
+
"""Redodo BMS implementation."""
|
15
|
+
|
16
|
+
_HEAD_LEN: Final[int] = 3
|
17
|
+
_MAX_CELLS: Final[int] = 16
|
18
|
+
_MAX_TEMP: Final[int] = 3
|
19
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
20
|
+
BMSdp("voltage", 12, 2, False, lambda x: x / 1000),
|
21
|
+
BMSdp("current", 48, 4, True, lambda x: x / 1000),
|
22
|
+
BMSdp("battery_level", 90, 2, False, lambda x: x),
|
23
|
+
BMSdp("cycle_charge", 62, 2, False, lambda x: x / 100),
|
24
|
+
BMSdp("cycles", 96, 4, False, lambda x: x),
|
25
|
+
BMSdp("problem_code", 76, 4, False, lambda x: x),
|
26
|
+
)
|
27
|
+
|
28
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
29
|
+
"""Initialize BMS."""
|
30
|
+
super().__init__(ble_device, reconnect)
|
31
|
+
|
32
|
+
@staticmethod
|
33
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
34
|
+
"""Provide BluetoothMatcher definition."""
|
35
|
+
return [
|
36
|
+
{ # patterns required to exclude "BT-ROCC2440"
|
37
|
+
"local_name": pattern,
|
38
|
+
"service_uuid": BMS.uuid_services()[0],
|
39
|
+
"manufacturer_id": 0x585A,
|
40
|
+
"connectable": True,
|
41
|
+
}
|
42
|
+
for pattern in (
|
43
|
+
"R-12*",
|
44
|
+
"R-24*",
|
45
|
+
"RO-12*",
|
46
|
+
"RO-24*",
|
47
|
+
"P-12*",
|
48
|
+
"P-24*",
|
49
|
+
"PQ-12*",
|
50
|
+
"PQ-24*",
|
51
|
+
"L-12*", # vv *** LiTime *** vv
|
52
|
+
"L-24*",
|
53
|
+
"L-51*",
|
54
|
+
"LT-12???BG-A0[7-9]*", # LiTime based on ser#
|
55
|
+
"LT-51*",
|
56
|
+
)
|
57
|
+
]
|
58
|
+
|
59
|
+
@staticmethod
|
60
|
+
def device_info() -> dict[str, str]:
|
61
|
+
"""Return device information for the battery management system."""
|
62
|
+
return {"manufacturer": "Redodo", "model": "Bluetooth battery"}
|
63
|
+
|
64
|
+
@staticmethod
|
65
|
+
def uuid_services() -> list[str]:
|
66
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
67
|
+
return [normalize_uuid_str("ffe0")]
|
68
|
+
|
69
|
+
@staticmethod
|
70
|
+
def uuid_rx() -> str:
|
71
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
72
|
+
return "ffe1"
|
73
|
+
|
74
|
+
@staticmethod
|
75
|
+
def uuid_tx() -> str:
|
76
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
77
|
+
return "ffe2"
|
78
|
+
|
79
|
+
@staticmethod
|
80
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
81
|
+
return frozenset(
|
82
|
+
{
|
83
|
+
"battery_charging",
|
84
|
+
"delta_voltage",
|
85
|
+
"cycle_capacity",
|
86
|
+
"power",
|
87
|
+
"runtime",
|
88
|
+
"temperature",
|
89
|
+
}
|
90
|
+
) # calculate further values from BMS provided set ones
|
91
|
+
|
92
|
+
def _notification_handler(
|
93
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
94
|
+
) -> None:
|
95
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
96
|
+
self._log.debug("RX BLE data: %s", data)
|
97
|
+
|
98
|
+
if len(data) < 3 or not data.startswith(b"\x00\x00"):
|
99
|
+
self._log.debug("incorrect SOF.")
|
100
|
+
return
|
101
|
+
|
102
|
+
if len(data) != data[2] + BMS._HEAD_LEN + 1: # add header length and CRC
|
103
|
+
self._log.debug("incorrect frame length (%i)", len(data))
|
104
|
+
return
|
105
|
+
|
106
|
+
if (crc := crc_sum(data[:-1])) != data[-1]:
|
107
|
+
self._log.debug("invalid checksum 0x%X != 0x%X", data[len(data) - 1], crc)
|
108
|
+
return
|
109
|
+
|
110
|
+
self._data = data
|
111
|
+
self._data_event.set()
|
112
|
+
|
113
|
+
async def _async_update(self) -> BMSsample:
|
114
|
+
"""Update battery status information."""
|
115
|
+
await self._await_reply(b"\x00\x00\x04\x01\x13\x55\xaa\x17")
|
116
|
+
|
117
|
+
result: BMSsample = BMS._decode_data(
|
118
|
+
BMS._FIELDS, self._data, byteorder="little"
|
119
|
+
)
|
120
|
+
result["cell_voltages"] = BMS._cell_voltages(
|
121
|
+
self._data, cells=BMS._MAX_CELLS, start=16, byteorder="little"
|
122
|
+
)
|
123
|
+
result["temp_values"] = BMS._temp_values(
|
124
|
+
self._data, values=BMS._MAX_TEMP, start=52, byteorder="little"
|
125
|
+
)
|
126
|
+
|
127
|
+
return result
|