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
@@ -0,0 +1,205 @@
|
|
1
|
+
"""Module to support Seplos v2 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_xmodem
|
11
|
+
|
12
|
+
|
13
|
+
class BMS(BaseBMS):
|
14
|
+
"""Seplos v2 BMS implementation."""
|
15
|
+
|
16
|
+
_HEAD: Final[bytes] = b"\x7e"
|
17
|
+
_TAIL: Final[bytes] = b"\x0d"
|
18
|
+
_CMD_VER: Final[int] = 0x10 # TX protocol version
|
19
|
+
_RSP_VER: Final[int] = 0x14 # RX protocol version
|
20
|
+
_MIN_LEN: Final[int] = 10
|
21
|
+
_MAX_SUBS: Final[int] = 0xF
|
22
|
+
_CELL_POS: Final[int] = 9
|
23
|
+
_PRB_MAX: Final[int] = 8 # max number of alarm event bytes
|
24
|
+
_PRB_MASK: Final[int] = ~0x82FFFF # ignore byte 7-8 + byte 6 (bit 7,2)
|
25
|
+
_PFIELDS: Final[tuple[BMSdp, ...]] = ( # Seplos V2: single machine data
|
26
|
+
BMSdp("voltage", 2, 2, False, lambda x: x / 100),
|
27
|
+
BMSdp("current", 0, 2, True, lambda x: x / 100), # /10 for 0x62
|
28
|
+
BMSdp("cycle_charge", 4, 2, False, lambda x: x / 100), # /10 for 0x62
|
29
|
+
BMSdp("cycles", 13, 2, False, lambda x: x),
|
30
|
+
BMSdp("battery_level", 9, 2, False, lambda x: x / 10),
|
31
|
+
)
|
32
|
+
_GSMD_LEN: Final[int] = _CELL_POS + max((dp.pos + dp.size) for dp in _PFIELDS) + 3
|
33
|
+
_CMDS: Final[list[tuple[int, bytes]]] = [(0x51, b""), (0x61, b"\x00"), (0x62, b"")]
|
34
|
+
|
35
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
36
|
+
"""Initialize BMS."""
|
37
|
+
super().__init__(ble_device, reconnect)
|
38
|
+
self._data_final: dict[int, bytearray] = {}
|
39
|
+
self._exp_len: int = BMS._MIN_LEN
|
40
|
+
self._exp_reply: set[int] = set()
|
41
|
+
|
42
|
+
@staticmethod
|
43
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
44
|
+
"""Provide BluetoothMatcher definition."""
|
45
|
+
return [
|
46
|
+
{
|
47
|
+
"local_name": pattern,
|
48
|
+
"service_uuid": BMS.uuid_services()[0],
|
49
|
+
"connectable": True,
|
50
|
+
}
|
51
|
+
for pattern in ("BP0?", "BP1?", "BP2?")
|
52
|
+
]
|
53
|
+
|
54
|
+
@staticmethod
|
55
|
+
def device_info() -> dict[str, str]:
|
56
|
+
"""Return device information for the battery management system."""
|
57
|
+
return {"manufacturer": "Seplos", "model": "Smart BMS V2"}
|
58
|
+
|
59
|
+
@staticmethod
|
60
|
+
def uuid_services() -> list[str]:
|
61
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
62
|
+
return [normalize_uuid_str("ff00")]
|
63
|
+
|
64
|
+
@staticmethod
|
65
|
+
def uuid_rx() -> str:
|
66
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
67
|
+
return "ff01"
|
68
|
+
|
69
|
+
@staticmethod
|
70
|
+
def uuid_tx() -> str:
|
71
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
72
|
+
return "ff02"
|
73
|
+
|
74
|
+
@staticmethod
|
75
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
76
|
+
return frozenset(
|
77
|
+
{
|
78
|
+
"battery_charging",
|
79
|
+
"cycle_capacity",
|
80
|
+
"delta_voltage",
|
81
|
+
"power",
|
82
|
+
"runtime",
|
83
|
+
"temperature",
|
84
|
+
}
|
85
|
+
) # calculate further values from BMS provided set ones
|
86
|
+
|
87
|
+
def _notification_handler(
|
88
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
89
|
+
) -> None:
|
90
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
91
|
+
if (
|
92
|
+
len(data) > BMS._MIN_LEN
|
93
|
+
and data.startswith(BMS._HEAD)
|
94
|
+
and len(self._data) >= self._exp_len
|
95
|
+
):
|
96
|
+
self._exp_len = BMS._MIN_LEN + int.from_bytes(data[5:7])
|
97
|
+
self._data = bytearray()
|
98
|
+
|
99
|
+
self._data += data
|
100
|
+
self._log.debug(
|
101
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
102
|
+
)
|
103
|
+
|
104
|
+
# verify that data is long enough
|
105
|
+
if len(self._data) < self._exp_len:
|
106
|
+
return
|
107
|
+
|
108
|
+
if not self._data.endswith(BMS._TAIL):
|
109
|
+
self._log.debug("incorrect frame end: %s", self._data)
|
110
|
+
return
|
111
|
+
|
112
|
+
if self._data[1] != BMS._RSP_VER:
|
113
|
+
self._log.debug("unknown frame version: V%.1f", self._data[1] / 10)
|
114
|
+
return
|
115
|
+
|
116
|
+
if self._data[4]:
|
117
|
+
self._log.debug("BMS reported error code: 0x%X", self._data[4])
|
118
|
+
return
|
119
|
+
|
120
|
+
if (crc := crc_xmodem(self._data[1:-3])) != int.from_bytes(self._data[-3:-1]):
|
121
|
+
self._log.debug(
|
122
|
+
"invalid checksum 0x%X != 0x%X",
|
123
|
+
crc,
|
124
|
+
int.from_bytes(self._data[-3:-1]),
|
125
|
+
)
|
126
|
+
return
|
127
|
+
|
128
|
+
self._log.debug(
|
129
|
+
"address: 0x%X, function: 0x%X, return: 0x%X",
|
130
|
+
self._data[2],
|
131
|
+
self._data[3],
|
132
|
+
self._data[4],
|
133
|
+
)
|
134
|
+
|
135
|
+
self._data_final[self._data[3]] = self._data
|
136
|
+
try:
|
137
|
+
self._exp_reply.remove(self._data[3])
|
138
|
+
self._data_event.set()
|
139
|
+
except KeyError:
|
140
|
+
self._log.debug("unexpected reply: 0x%X", self._data[3])
|
141
|
+
|
142
|
+
async def _init_connection(
|
143
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
144
|
+
) -> None:
|
145
|
+
"""Initialize protocol state."""
|
146
|
+
await super()._init_connection()
|
147
|
+
self._exp_len = BMS._MIN_LEN
|
148
|
+
|
149
|
+
@staticmethod
|
150
|
+
def _cmd(cmd: int, address: int = 0, data: bytearray = bytearray()) -> bytes:
|
151
|
+
"""Assemble a Seplos V2 BMS command."""
|
152
|
+
assert cmd in (0x47, 0x51, 0x61, 0x62, 0x04) # allow only read commands
|
153
|
+
frame = bytearray([*BMS._HEAD, BMS._CMD_VER, address, 0x46, cmd])
|
154
|
+
frame += len(data).to_bytes(2, "big", signed=False) + data
|
155
|
+
frame += int.to_bytes(crc_xmodem(frame[1:]), 2, byteorder="big") + BMS._TAIL
|
156
|
+
return bytes(frame)
|
157
|
+
|
158
|
+
async def _async_update(self) -> BMSsample:
|
159
|
+
"""Update battery status information."""
|
160
|
+
|
161
|
+
for cmd, data in BMS._CMDS:
|
162
|
+
self._exp_reply.add(cmd)
|
163
|
+
await self._await_reply(BMS._cmd(cmd, data=bytearray(data)))
|
164
|
+
|
165
|
+
result: BMSsample = {}
|
166
|
+
result["cell_count"] = self._data_final[0x61][BMS._CELL_POS]
|
167
|
+
result["temp_sensors"] = self._data_final[0x61][
|
168
|
+
BMS._CELL_POS + result["cell_count"] * 2 + 1
|
169
|
+
]
|
170
|
+
ct_blk_len: Final[int] = (result["cell_count"] + result["temp_sensors"]) * 2 + 2
|
171
|
+
|
172
|
+
if (BMS._GSMD_LEN + ct_blk_len) > len(self._data_final[0x61]):
|
173
|
+
raise ValueError("message too short to decode data")
|
174
|
+
|
175
|
+
result |= BMS._decode_data(
|
176
|
+
BMS._PFIELDS, self._data_final[0x61], offset=BMS._CELL_POS + ct_blk_len
|
177
|
+
)
|
178
|
+
|
179
|
+
# get extention pack count from parallel data (main pack)
|
180
|
+
result["pack_count"] = self._data_final[0x51][42]
|
181
|
+
|
182
|
+
# get alarms from parallel data (main pack)
|
183
|
+
alarm_evt: Final[int] = min(self._data_final[0x62][46], BMS._PRB_MAX)
|
184
|
+
result["problem_code"] = (
|
185
|
+
int.from_bytes(self._data_final[0x62][47 : 47 + alarm_evt], byteorder="big")
|
186
|
+
& BMS._PRB_MASK
|
187
|
+
)
|
188
|
+
|
189
|
+
result["cell_voltages"] = BMS._cell_voltages(
|
190
|
+
self._data_final[0x61],
|
191
|
+
cells=self._data_final[0x61][BMS._CELL_POS],
|
192
|
+
start=10,
|
193
|
+
)
|
194
|
+
result["temp_values"] = BMS._temp_values(
|
195
|
+
self._data_final[0x61],
|
196
|
+
values=result["temp_sensors"],
|
197
|
+
start=BMS._CELL_POS + result.get("cell_count", 0) * 2 + 2,
|
198
|
+
signed=False,
|
199
|
+
offset=2731,
|
200
|
+
divider=10,
|
201
|
+
)
|
202
|
+
|
203
|
+
self._data_final.clear()
|
204
|
+
|
205
|
+
return result
|
aiobmsble/bms/tdt_bms.py
ADDED
@@ -0,0 +1,199 @@
|
|
1
|
+
"""Module to support TDT 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
|
+
"""TDT BMS implementation."""
|
15
|
+
|
16
|
+
_UUID_CFG: Final[str] = "fffa"
|
17
|
+
_HEAD: Final[int] = 0x7E
|
18
|
+
_CMD_HEADS: list[int] = [0x7E, 0x1E] # alternative command head
|
19
|
+
_TAIL: Final[int] = 0x0D
|
20
|
+
_CMD_VER: Final[int] = 0x00
|
21
|
+
_RSP_VER: Final[frozenset[int]] = frozenset({0x00, 0x04})
|
22
|
+
_CELL_POS: Final[int] = 0x8
|
23
|
+
_INFO_LEN: Final[int] = 10 # minimal frame length
|
24
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
25
|
+
BMSdp("voltage", 2, 2, False, lambda x: x / 100, 0x8C),
|
26
|
+
BMSdp(
|
27
|
+
"current",
|
28
|
+
0,
|
29
|
+
2,
|
30
|
+
False,
|
31
|
+
lambda x: (x & 0x3FFF) / 10 * (-1 if x >> 15 else 1),
|
32
|
+
0x8C,
|
33
|
+
),
|
34
|
+
BMSdp("cycle_charge", 4, 2, False, lambda x: x / 10, 0x8C),
|
35
|
+
BMSdp("battery_level", 13, 1, False, lambda x: x, 0x8C),
|
36
|
+
BMSdp("cycles", 8, 2, False, lambda x: x, 0x8C),
|
37
|
+
) # problem code is not included in the list, but extra
|
38
|
+
_CMDS: Final[list[int]] = [*list({field.idx for field in _FIELDS}), 0x8D]
|
39
|
+
|
40
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
41
|
+
"""Initialize BMS."""
|
42
|
+
super().__init__(ble_device, reconnect)
|
43
|
+
self._data_final: dict[int, bytearray] = {}
|
44
|
+
self._cmd_heads: list[int] = BMS._CMD_HEADS
|
45
|
+
self._exp_len: int = 0
|
46
|
+
|
47
|
+
@staticmethod
|
48
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
49
|
+
"""Provide BluetoothMatcher definition."""
|
50
|
+
return [{"manufacturer_id": 54976, "connectable": True}]
|
51
|
+
|
52
|
+
@staticmethod
|
53
|
+
def device_info() -> dict[str, str]:
|
54
|
+
"""Return device information for the battery management system."""
|
55
|
+
return {"manufacturer": "TDT", "model": "Smart BMS"}
|
56
|
+
|
57
|
+
@staticmethod
|
58
|
+
def uuid_services() -> list[str]:
|
59
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
60
|
+
return [normalize_uuid_str("fff0")]
|
61
|
+
|
62
|
+
@staticmethod
|
63
|
+
def uuid_rx() -> str:
|
64
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
65
|
+
return "fff1"
|
66
|
+
|
67
|
+
@staticmethod
|
68
|
+
def uuid_tx() -> str:
|
69
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
70
|
+
return "fff2"
|
71
|
+
|
72
|
+
@staticmethod
|
73
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
74
|
+
return frozenset(
|
75
|
+
{
|
76
|
+
"battery_charging",
|
77
|
+
"cycle_capacity",
|
78
|
+
"delta_voltage",
|
79
|
+
"power",
|
80
|
+
"runtime",
|
81
|
+
"temperature",
|
82
|
+
}
|
83
|
+
) # calculate further values from BMS provided set ones
|
84
|
+
|
85
|
+
async def _init_connection(
|
86
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
87
|
+
) -> None:
|
88
|
+
await self._await_reply(
|
89
|
+
data=b"HiLink", char=BMS._UUID_CFG, wait_for_notify=False
|
90
|
+
)
|
91
|
+
if (
|
92
|
+
ret := int.from_bytes(await self._client.read_gatt_char(BMS._UUID_CFG))
|
93
|
+
) != 0x1:
|
94
|
+
self._log.debug("error unlocking BMS: %X", ret)
|
95
|
+
|
96
|
+
await super()._init_connection()
|
97
|
+
|
98
|
+
def _notification_handler(
|
99
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
100
|
+
) -> None:
|
101
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
102
|
+
self._log.debug("RX BLE data: %s", data)
|
103
|
+
|
104
|
+
if (
|
105
|
+
len(data) > BMS._INFO_LEN
|
106
|
+
and data[0] == BMS._HEAD
|
107
|
+
and len(self._data) >= self._exp_len
|
108
|
+
):
|
109
|
+
self._exp_len = BMS._INFO_LEN + int.from_bytes(data[6:8])
|
110
|
+
self._data = bytearray()
|
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
|
+
# verify that data is long enough
|
118
|
+
if len(self._data) < max(BMS._INFO_LEN, self._exp_len):
|
119
|
+
return
|
120
|
+
|
121
|
+
if self._data[-1] != BMS._TAIL:
|
122
|
+
self._log.debug("frame end incorrect: %s", self._data)
|
123
|
+
return
|
124
|
+
|
125
|
+
if self._data[1] not in BMS._RSP_VER:
|
126
|
+
self._log.debug("unknown frame version: V%.1f", self._data[1] / 10)
|
127
|
+
return
|
128
|
+
|
129
|
+
if self._data[4]:
|
130
|
+
self._log.debug("BMS reported error code: 0x%X", self._data[4])
|
131
|
+
return
|
132
|
+
|
133
|
+
if (crc := crc_modbus(self._data[:-3])) != int.from_bytes(
|
134
|
+
self._data[-3:-1], "big"
|
135
|
+
):
|
136
|
+
self._log.debug(
|
137
|
+
"invalid checksum 0x%X != 0x%X",
|
138
|
+
int.from_bytes(self._data[-3:-1], "big"),
|
139
|
+
crc,
|
140
|
+
)
|
141
|
+
return
|
142
|
+
self._data_final[self._data[5]] = self._data
|
143
|
+
self._data_event.set()
|
144
|
+
|
145
|
+
@staticmethod
|
146
|
+
def _cmd(cmd: int, data: bytearray = bytearray(), cmd_head: int = _HEAD) -> bytes:
|
147
|
+
"""Assemble a TDT BMS command."""
|
148
|
+
assert cmd in (0x8C, 0x8D, 0x92) # allow only read commands
|
149
|
+
|
150
|
+
frame = bytearray([cmd_head, BMS._CMD_VER, 0x1, 0x3, 0x0, cmd])
|
151
|
+
frame += len(data).to_bytes(2, "big", signed=False) + data
|
152
|
+
frame += crc_modbus(frame).to_bytes(2, "big") + bytes([BMS._TAIL])
|
153
|
+
|
154
|
+
return bytes(frame)
|
155
|
+
|
156
|
+
async def _async_update(self) -> BMSsample:
|
157
|
+
"""Update battery status information."""
|
158
|
+
|
159
|
+
for head in self._cmd_heads:
|
160
|
+
try:
|
161
|
+
for cmd in BMS._CMDS:
|
162
|
+
await self._await_reply(BMS._cmd(cmd, cmd_head=head))
|
163
|
+
self._cmd_heads = [head] # set to single head for further commands
|
164
|
+
break
|
165
|
+
except TimeoutError:
|
166
|
+
... # try next command head
|
167
|
+
else:
|
168
|
+
raise TimeoutError
|
169
|
+
|
170
|
+
result: BMSsample = {"cell_count": self._data_final[0x8C][BMS._CELL_POS]}
|
171
|
+
result["temp_sensors"] = self._data_final[0x8C][
|
172
|
+
BMS._CELL_POS + result["cell_count"] * 2 + 1
|
173
|
+
]
|
174
|
+
|
175
|
+
result["cell_voltages"] = BMS._cell_voltages(
|
176
|
+
self._data_final[0x8C],
|
177
|
+
cells=result.get("cell_count", 0),
|
178
|
+
start=BMS._CELL_POS + 1,
|
179
|
+
)
|
180
|
+
result["temp_values"] = BMS._temp_values(
|
181
|
+
self._data_final[0x8C],
|
182
|
+
values=result["temp_sensors"],
|
183
|
+
start=BMS._CELL_POS + result.get("cell_count", 0) * 2 + 2,
|
184
|
+
signed=False,
|
185
|
+
offset=2731,
|
186
|
+
divider=10,
|
187
|
+
)
|
188
|
+
idx: Final[int] = result.get("cell_count", 0) + result.get("temp_sensors", 0)
|
189
|
+
|
190
|
+
result |= BMS._decode_data(
|
191
|
+
BMS._FIELDS, self._data_final, offset=BMS._CELL_POS + idx * 2 + 2
|
192
|
+
)
|
193
|
+
result["problem_code"] = int.from_bytes(
|
194
|
+
self._data_final[0x8D][BMS._CELL_POS + idx + 6 : BMS._CELL_POS + idx + 8]
|
195
|
+
)
|
196
|
+
|
197
|
+
self._data_final.clear()
|
198
|
+
|
199
|
+
return result
|
@@ -0,0 +1,138 @@
|
|
1
|
+
"""Module to support TianPwr 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
|
+
"""TianPwr BMS implementation."""
|
15
|
+
|
16
|
+
_HEAD: Final[bytes] = b"\x55"
|
17
|
+
_TAIL: Final[bytes] = b"\xaa"
|
18
|
+
_RDCMD: Final[bytes] = b"\x04"
|
19
|
+
_MAX_CELLS: Final[int] = 16
|
20
|
+
_MAX_TEMP: Final[int] = 6
|
21
|
+
_MIN_LEN: Final[int] = 4
|
22
|
+
_DEF_LEN: Final[int] = 20
|
23
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
24
|
+
BMSdp("battery_level", 3, 2, False, lambda x: x, 0x83),
|
25
|
+
BMSdp("voltage", 5, 2, False, lambda x: x / 100, 0x83),
|
26
|
+
BMSdp("current", 13, 2, True, lambda x: x / 100, 0x83),
|
27
|
+
BMSdp("problem_code", 11, 8, False, lambda x: x, 0x84),
|
28
|
+
BMSdp("cell_count", 3, 1, False, lambda x: x, 0x84),
|
29
|
+
BMSdp("temp_sensors", 4, 1, False, lambda x: x, 0x84),
|
30
|
+
BMSdp("design_capacity", 5, 2, False, lambda x: x // 100, 0x84),
|
31
|
+
BMSdp("cycle_charge", 7, 2, False, lambda x: x / 100, 0x84),
|
32
|
+
BMSdp("cycles", 9, 2, False, lambda x: x, 0x84),
|
33
|
+
)
|
34
|
+
_CMDS: Final[set[int]] = set({field.idx for field in _FIELDS}) | set({0x87})
|
35
|
+
|
36
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
37
|
+
"""Initialize BMS."""
|
38
|
+
super().__init__(ble_device, reconnect)
|
39
|
+
self._data_final: dict[int, bytearray] = {}
|
40
|
+
|
41
|
+
@staticmethod
|
42
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
43
|
+
"""Provide BluetoothMatcher definition."""
|
44
|
+
return [{"local_name": "TP_*", "connectable": True}]
|
45
|
+
|
46
|
+
@staticmethod
|
47
|
+
def device_info() -> dict[str, str]:
|
48
|
+
"""Return device information for the battery management system."""
|
49
|
+
return {"manufacturer": "TianPwr", "model": "SmartBMS"}
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def uuid_services() -> list[str]:
|
53
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
54
|
+
return [normalize_uuid_str("ff00")]
|
55
|
+
|
56
|
+
@staticmethod
|
57
|
+
def uuid_rx() -> str:
|
58
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
59
|
+
return "ff01"
|
60
|
+
|
61
|
+
@staticmethod
|
62
|
+
def uuid_tx() -> str:
|
63
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
64
|
+
return "ff02"
|
65
|
+
|
66
|
+
@staticmethod
|
67
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
68
|
+
return frozenset(
|
69
|
+
{
|
70
|
+
"battery_charging",
|
71
|
+
"cycle_capacity",
|
72
|
+
"delta_voltage",
|
73
|
+
"power",
|
74
|
+
"temperature",
|
75
|
+
}
|
76
|
+
) # calculate further values from BMS provided set ones
|
77
|
+
|
78
|
+
def _notification_handler(
|
79
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
80
|
+
) -> None:
|
81
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
82
|
+
self._log.debug("RX BLE data: %s", data)
|
83
|
+
|
84
|
+
# verify that data is long enough
|
85
|
+
if len(data) != BMS._DEF_LEN:
|
86
|
+
self._log.debug("incorrect frame length")
|
87
|
+
return
|
88
|
+
|
89
|
+
if not data.startswith(BMS._HEAD):
|
90
|
+
self._log.debug("incorrect SOF.")
|
91
|
+
return
|
92
|
+
|
93
|
+
if not data.endswith(BMS._TAIL):
|
94
|
+
self._log.debug("incorrect EOF.")
|
95
|
+
return
|
96
|
+
|
97
|
+
self._data_final[data[2]] = data.copy()
|
98
|
+
self._data_event.set()
|
99
|
+
|
100
|
+
@staticmethod
|
101
|
+
def _cmd(addr: int) -> bytes:
|
102
|
+
"""Assemble a TianPwr BMS command."""
|
103
|
+
return BMS._HEAD + BMS._RDCMD + addr.to_bytes(1) + BMS._TAIL
|
104
|
+
|
105
|
+
async def _async_update(self) -> BMSsample:
|
106
|
+
"""Update battery status information."""
|
107
|
+
|
108
|
+
self._data_final.clear()
|
109
|
+
for cmd in BMS._CMDS:
|
110
|
+
await self._await_reply(BMS._cmd(cmd))
|
111
|
+
|
112
|
+
result: BMSsample = BMS._decode_data(BMS._FIELDS, self._data_final)
|
113
|
+
|
114
|
+
for cmd in range(
|
115
|
+
0x88, 0x89 + min(result.get("cell_count", 0), BMS._MAX_CELLS) // 8
|
116
|
+
):
|
117
|
+
await self._await_reply(BMS._cmd(cmd))
|
118
|
+
result["cell_voltages"] = result.setdefault(
|
119
|
+
"cell_voltages", []
|
120
|
+
) + BMS._cell_voltages(
|
121
|
+
self._data_final.get(cmd, bytearray()), cells=8, start=3
|
122
|
+
)
|
123
|
+
|
124
|
+
if {0x83, 0x87}.issubset(self._data_final):
|
125
|
+
result["temp_values"] = [
|
126
|
+
int.from_bytes(
|
127
|
+
self._data_final[0x83][idx : idx + 2], byteorder="big", signed=True
|
128
|
+
)
|
129
|
+
/ 10
|
130
|
+
for idx in (7, 11) # take ambient and mosfet temperature
|
131
|
+
] + BMS._temp_values(
|
132
|
+
self._data_final.get(0x87, bytearray()),
|
133
|
+
values=min(BMS._MAX_TEMP, result.get("temp_sensors", 0)),
|
134
|
+
start=3,
|
135
|
+
divider=10,
|
136
|
+
)
|
137
|
+
|
138
|
+
return result
|
aiobmsble/utils.py
CHANGED
@@ -1,22 +1,26 @@
|
|
1
1
|
"""Utilitiy/Support functions for aiobmsble."""
|
2
2
|
|
3
3
|
from fnmatch import translate
|
4
|
+
from functools import lru_cache
|
5
|
+
import importlib
|
6
|
+
import pkgutil
|
4
7
|
import re
|
8
|
+
from types import ModuleType
|
5
9
|
|
6
10
|
from bleak.backends.scanner import AdvertisementData
|
7
11
|
|
8
|
-
from aiobmsble import
|
12
|
+
from aiobmsble import MatcherPattern
|
9
13
|
from aiobmsble.basebms import BaseBMS
|
10
14
|
|
11
15
|
|
12
|
-
def
|
13
|
-
matcher:
|
16
|
+
def _advertisement_matches(
|
17
|
+
matcher: MatcherPattern,
|
14
18
|
adv_data: AdvertisementData,
|
15
19
|
) -> bool:
|
16
20
|
"""Determine whether the given advertisement data matches the specified pattern.
|
17
21
|
|
18
22
|
Args:
|
19
|
-
matcher (
|
23
|
+
matcher (MatcherPattern): A dictionary containing the matching criteria.
|
20
24
|
Possible keys include:
|
21
25
|
- "service_uuid" (str): A specific service 128-bit UUID to match.
|
22
26
|
- "service_data_uuid" (str): A specific service data UUID to match.
|
@@ -56,18 +60,104 @@ def advertisement_matches(
|
|
56
60
|
)
|
57
61
|
|
58
62
|
|
59
|
-
|
63
|
+
@lru_cache
|
64
|
+
def load_bms_plugins() -> set[ModuleType]:
|
65
|
+
"""Discover and load all available Battery Management System (BMS) plugin modules.
|
66
|
+
|
67
|
+
This function scans the 'aiobmsble/bms' directory for all Python modules,
|
68
|
+
dynamically imports each discovered module, and returns a set containing
|
69
|
+
the imported module objects required to end with "_bms".
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
set[ModuleType]: A set of imported BMS plugin modules.
|
73
|
+
|
74
|
+
Raises:
|
75
|
+
ImportError: If a module cannot be imported.
|
76
|
+
OSError: If the plugin directory cannot be accessed.
|
77
|
+
|
78
|
+
"""
|
79
|
+
return {
|
80
|
+
importlib.import_module(f"aiobmsble.bms.{module_name}")
|
81
|
+
for _, module_name, _ in pkgutil.iter_modules(["aiobmsble/bms"])
|
82
|
+
if module_name.endswith("_bms")
|
83
|
+
}
|
84
|
+
|
85
|
+
|
86
|
+
def bms_cls(name: str) -> type[BaseBMS] | None:
|
87
|
+
"""Return the BMS class that is defined by the name argument.
|
88
|
+
|
89
|
+
Args:
|
90
|
+
name (str): The name of the BMS type
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
type[BaseBMS] | None: If the BMS class defined by name is found, None otherwise.
|
94
|
+
|
95
|
+
"""
|
96
|
+
try:
|
97
|
+
bms_module: ModuleType = importlib.import_module(f"aiobmsble.bms.{name}_bms")
|
98
|
+
except ModuleNotFoundError:
|
99
|
+
return None
|
100
|
+
return bms_module.BMS
|
101
|
+
|
102
|
+
|
103
|
+
def bms_matching(
|
104
|
+
adv_data: AdvertisementData, mac_addr: str | None = None
|
105
|
+
) -> list[type[BaseBMS]]:
|
106
|
+
"""Return the BMS classes that match the given advertisement data.
|
107
|
+
|
108
|
+
Currently the function returns at most one match, but this behaviour might change
|
109
|
+
in the future to multiple entries, if BMSs cannot be distinguished uniquely using
|
110
|
+
their Bluetooth advertisement / OUI (Organizationally Unique Identifier)
|
111
|
+
|
112
|
+
Args:
|
113
|
+
adv_data (AdvertisementData): The advertisement data to match against available BMS plugins.
|
114
|
+
mac_addr (str | None): Optional MAC address to check OUI against
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
list[type[BaseBMS]]: A list of matching BMS class(es) if found, an empty list otherwhise.
|
118
|
+
|
119
|
+
"""
|
120
|
+
for bms_module in load_bms_plugins():
|
121
|
+
if bms_supported(bms_module.BMS, adv_data, mac_addr):
|
122
|
+
return [bms_module.BMS]
|
123
|
+
return []
|
124
|
+
|
125
|
+
|
126
|
+
def bms_identify(
|
127
|
+
adv_data: AdvertisementData, mac_addr: str | None = None
|
128
|
+
) -> type[BaseBMS] | None:
|
129
|
+
"""Return the BMS classes that best matches the given advertisement data.
|
130
|
+
|
131
|
+
Args:
|
132
|
+
adv_data (AdvertisementData): The advertisement data to match against available BMS plugins.
|
133
|
+
mac_addr (str | None): Optional MAC address to check OUI against
|
134
|
+
|
135
|
+
Returns:
|
136
|
+
type[BaseBMS] | None: The identified BMS class if a match is found, None otherwhise
|
137
|
+
|
138
|
+
"""
|
139
|
+
|
140
|
+
matching_bms: list[type[BaseBMS]] = bms_matching(adv_data, mac_addr)
|
141
|
+
return matching_bms[0] if matching_bms else None
|
142
|
+
|
143
|
+
|
144
|
+
def bms_supported(
|
145
|
+
bms: BaseBMS, adv_data: AdvertisementData, mac_addr: str | None = None
|
146
|
+
) -> bool:
|
60
147
|
"""Determine if the given BMS is supported based on advertisement data.
|
61
148
|
|
62
149
|
Args:
|
63
150
|
bms (BaseBMS): The BMS class to check.
|
64
151
|
adv_data (AdvertisementData): The advertisement data to match against.
|
152
|
+
mac_addr (str | None): Optional MAC address to check OUI against
|
65
153
|
|
66
154
|
Returns:
|
67
155
|
bool: True if the BMS is supported, False otherwise.
|
68
156
|
|
69
157
|
"""
|
158
|
+
if mac_addr:
|
159
|
+
raise NotImplementedError # pragma: no cover
|
70
160
|
for matcher in bms.matcher_dict_list():
|
71
|
-
if
|
161
|
+
if _advertisement_matches(matcher, adv_data):
|
72
162
|
return True
|
73
163
|
return False
|