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,149 @@
|
|
1
|
+
"""Module to support Renogy 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
|
+
"""Renogy battery class implementation."""
|
15
|
+
|
16
|
+
HEAD: bytes = b"\x30\x03" # SOP, read fct (x03)
|
17
|
+
_CRC_POS: Final[int] = -2
|
18
|
+
_TEMP_POS: Final[int] = 37
|
19
|
+
_CELL_POS: Final[int] = 3
|
20
|
+
FIELDS: tuple[BMSdp, ...] = (
|
21
|
+
BMSdp("voltage", 5, 2, False, lambda x: x / 10),
|
22
|
+
BMSdp("current", 3, 2, True, lambda x: x / 100),
|
23
|
+
BMSdp("design_capacity", 11, 4, False, lambda x: x // 1000),
|
24
|
+
BMSdp("cycle_charge", 7, 4, False, lambda x: x / 1000),
|
25
|
+
BMSdp("cycles", 15, 2, 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
|
+
{
|
37
|
+
"service_uuid": BMS.uuid_services()[0],
|
38
|
+
"manufacturer_id": 0x9860,
|
39
|
+
"connectable": True,
|
40
|
+
},
|
41
|
+
]
|
42
|
+
|
43
|
+
@staticmethod
|
44
|
+
def device_info() -> dict[str, str]:
|
45
|
+
"""Return device information for the battery management system."""
|
46
|
+
return {"manufacturer": "Renogy", "model": "Bluetooth battery"}
|
47
|
+
|
48
|
+
@staticmethod
|
49
|
+
def uuid_services() -> list[str]:
|
50
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
51
|
+
return [normalize_uuid_str("ffd0"), normalize_uuid_str("fff0")]
|
52
|
+
|
53
|
+
@staticmethod
|
54
|
+
def uuid_rx() -> str:
|
55
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
56
|
+
return "fff1"
|
57
|
+
|
58
|
+
@staticmethod
|
59
|
+
def uuid_tx() -> str:
|
60
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
61
|
+
return "ffd1"
|
62
|
+
|
63
|
+
@staticmethod
|
64
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
65
|
+
return frozenset(
|
66
|
+
{
|
67
|
+
"power",
|
68
|
+
"battery_charging",
|
69
|
+
"temperature",
|
70
|
+
"cycle_capacity",
|
71
|
+
"battery_level",
|
72
|
+
"runtime",
|
73
|
+
"delta_voltage",
|
74
|
+
}
|
75
|
+
) # calculate further values from BMS provided set ones
|
76
|
+
|
77
|
+
def _notification_handler(
|
78
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
79
|
+
) -> None:
|
80
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
81
|
+
self._log.debug("RX BLE data: %s", data)
|
82
|
+
|
83
|
+
if not data.startswith(BMS.HEAD) or len(data) < 3:
|
84
|
+
self._log.debug("incorrect SOF")
|
85
|
+
return
|
86
|
+
|
87
|
+
if data[2] + 5 != len(data):
|
88
|
+
self._log.debug("incorrect frame length: %i != %i", len(data), data[2] + 5)
|
89
|
+
return
|
90
|
+
|
91
|
+
if (crc := crc_modbus(data[: BMS._CRC_POS])) != int.from_bytes(
|
92
|
+
data[BMS._CRC_POS :], "little"
|
93
|
+
):
|
94
|
+
self._log.debug(
|
95
|
+
"invalid checksum 0x%X != 0x%X",
|
96
|
+
crc,
|
97
|
+
int.from_bytes(data[BMS._CRC_POS :], "little"),
|
98
|
+
)
|
99
|
+
return
|
100
|
+
|
101
|
+
self._data = data.copy()
|
102
|
+
self._data_event.set()
|
103
|
+
|
104
|
+
@staticmethod
|
105
|
+
def _read_int16(data: bytearray, pos: int, signed: bool = False) -> int:
|
106
|
+
return int.from_bytes(data[pos : pos + 2], byteorder="big", signed=signed)
|
107
|
+
|
108
|
+
@staticmethod
|
109
|
+
def _cmd(addr: int, words: int) -> bytes:
|
110
|
+
"""Assemble a Renogy BMS command (MODBUS)."""
|
111
|
+
frame: bytearray = (
|
112
|
+
bytearray(BMS.HEAD)
|
113
|
+
+ int.to_bytes(addr, 2, byteorder="big")
|
114
|
+
+ int.to_bytes(words, 2, byteorder="big")
|
115
|
+
)
|
116
|
+
|
117
|
+
frame.extend(int.to_bytes(crc_modbus(frame), 2, byteorder="little"))
|
118
|
+
return bytes(frame)
|
119
|
+
|
120
|
+
async def _async_update(self) -> BMSsample:
|
121
|
+
"""Update battery status information."""
|
122
|
+
|
123
|
+
await self._await_reply(self._cmd(0x13B2, 0x7))
|
124
|
+
result: BMSsample = BMS._decode_data(type(self).FIELDS, self._data)
|
125
|
+
|
126
|
+
await self._await_reply(self._cmd(0x1388, 0x22))
|
127
|
+
result["cell_count"] = BMS._read_int16(self._data, BMS._CELL_POS)
|
128
|
+
result["cell_voltages"] = BMS._cell_voltages(
|
129
|
+
self._data,
|
130
|
+
cells=min(16, result.get("cell_count", 0)),
|
131
|
+
start=BMS._CELL_POS + 2,
|
132
|
+
byteorder="big",
|
133
|
+
divider=10,
|
134
|
+
)
|
135
|
+
|
136
|
+
result["temp_sensors"] = BMS._read_int16(self._data, BMS._TEMP_POS)
|
137
|
+
result["temp_values"] = BMS._temp_values(
|
138
|
+
self._data,
|
139
|
+
values=min(16, result.get("temp_sensors", 0)),
|
140
|
+
start=BMS._TEMP_POS + 2,
|
141
|
+
divider=10,
|
142
|
+
)
|
143
|
+
|
144
|
+
await self._await_reply(self._cmd(0x13EC, 0x7))
|
145
|
+
result["problem_code"] = int.from_bytes(self._data[3:-2], byteorder="big") & (
|
146
|
+
~0xE
|
147
|
+
)
|
148
|
+
|
149
|
+
return result
|
@@ -0,0 +1,105 @@
|
|
1
|
+
"""Module to support Renogy Pro BMS."""
|
2
|
+
|
3
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
4
|
+
from bleak.backends.device import BLEDevice
|
5
|
+
from bleak.uuids import normalize_uuid_str
|
6
|
+
|
7
|
+
from aiobmsble import BMSdp, MatcherPattern
|
8
|
+
from aiobmsble.bms.renogy_bms import BMS as RenogyBMS
|
9
|
+
|
10
|
+
|
11
|
+
class BMS(RenogyBMS):
|
12
|
+
"""Renogy Pro battery class implementation."""
|
13
|
+
|
14
|
+
HEAD: bytes = b"\xff\x03" # SOP, read fct (x03)
|
15
|
+
FIELDS: tuple[BMSdp, ...] = (
|
16
|
+
BMSdp("voltage", 5, 2, False, lambda x: x / 10),
|
17
|
+
BMSdp("current", 3, 2, True, lambda x: x / 10),
|
18
|
+
BMSdp("design_capacity", 11, 4, False, lambda x: x // 1000),
|
19
|
+
BMSdp("cycle_charge", 7, 4, False, lambda x: x / 1000),
|
20
|
+
BMSdp("cycles", 15, 2, False, lambda x: x),
|
21
|
+
)
|
22
|
+
|
23
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
24
|
+
"""Intialize private BMS members."""
|
25
|
+
super().__init__(ble_device, reconnect)
|
26
|
+
self._char_write_handle: int = -1
|
27
|
+
|
28
|
+
@staticmethod
|
29
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
30
|
+
"""Provide BluetoothMatcher definition."""
|
31
|
+
return [
|
32
|
+
{
|
33
|
+
"local_name": "RNGRBP*",
|
34
|
+
"manufacturer_id": 0xE14C,
|
35
|
+
"connectable": True,
|
36
|
+
},
|
37
|
+
]
|
38
|
+
|
39
|
+
@staticmethod
|
40
|
+
def device_info() -> dict[str, str]:
|
41
|
+
"""Return device information for the battery management system."""
|
42
|
+
return {"manufacturer": "Renogy", "model": "Bluetooth battery pro"}
|
43
|
+
|
44
|
+
async def _init_connection(
|
45
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
46
|
+
) -> None:
|
47
|
+
"""Initialize RX/TX characteristics and protocol state."""
|
48
|
+
char_notify_handle: int = -1
|
49
|
+
self._char_write_handle = -1
|
50
|
+
assert char_notify is None, "char_notify not used for Renogy Pro BMS"
|
51
|
+
|
52
|
+
for service in self._client.services:
|
53
|
+
self._log.debug(
|
54
|
+
"service %s (#%i): %s",
|
55
|
+
service.uuid,
|
56
|
+
service.handle,
|
57
|
+
service.description,
|
58
|
+
)
|
59
|
+
for char in service.characteristics:
|
60
|
+
self._log.debug(
|
61
|
+
"characteristic %s (#%i): %s",
|
62
|
+
char.uuid,
|
63
|
+
char.handle,
|
64
|
+
char.properties,
|
65
|
+
)
|
66
|
+
if (
|
67
|
+
service.uuid == BMS.uuid_services()[0]
|
68
|
+
and char.uuid == normalize_uuid_str(BMS.uuid_tx())
|
69
|
+
and any(
|
70
|
+
prop in char.properties
|
71
|
+
for prop in ("write", "write-without-response")
|
72
|
+
)
|
73
|
+
):
|
74
|
+
self._char_write_handle = char.handle
|
75
|
+
if (
|
76
|
+
service.uuid == BMS.uuid_services()[1]
|
77
|
+
and char.uuid == normalize_uuid_str(BMS.uuid_rx())
|
78
|
+
and "notify" in char.properties
|
79
|
+
):
|
80
|
+
char_notify_handle = char.handle
|
81
|
+
|
82
|
+
if char_notify_handle == -1 or self._char_write_handle == -1:
|
83
|
+
self._log.debug("failed to detect characteristics.")
|
84
|
+
await self._client.disconnect()
|
85
|
+
raise ConnectionError(f"Failed to detect characteristics from {self.name}.")
|
86
|
+
self._log.debug(
|
87
|
+
"using characteristics handle #%i (notify), #%i (write).",
|
88
|
+
char_notify_handle,
|
89
|
+
self._char_write_handle,
|
90
|
+
)
|
91
|
+
|
92
|
+
await super()._init_connection(char_notify_handle)
|
93
|
+
|
94
|
+
async def _await_reply(
|
95
|
+
self,
|
96
|
+
data: bytes,
|
97
|
+
char: int | str | None = None,
|
98
|
+
wait_for_notify: bool = True,
|
99
|
+
max_size: int = 0,
|
100
|
+
) -> None:
|
101
|
+
"""Send data to the BMS and wait for valid reply notification."""
|
102
|
+
|
103
|
+
await super()._await_reply(
|
104
|
+
data, self._char_write_handle, wait_for_notify, max_size
|
105
|
+
)
|
@@ -0,0 +1,186 @@
|
|
1
|
+
"""Module to support RoyPow 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
|
+
"""RoyPow BMS implementation."""
|
15
|
+
|
16
|
+
_HEAD: Final[bytes] = b"\xea\xd1\x01"
|
17
|
+
_TAIL: Final[int] = 0xF5
|
18
|
+
_BT_MODULE_MSG: Final[bytes] = b"AT+STAT\r\n" # AT cmd from BLE module
|
19
|
+
_MIN_LEN: Final[int] = len(_HEAD) + 1
|
20
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
21
|
+
BMSdp("battery_level", 7, 1, False, lambda x: x, 0x4),
|
22
|
+
BMSdp("voltage", 47, 2, False, lambda x: x / 100, 0x4),
|
23
|
+
BMSdp(
|
24
|
+
"current",
|
25
|
+
6,
|
26
|
+
3,
|
27
|
+
False,
|
28
|
+
lambda x: (x & 0xFFFF) * (-1 if (x >> 16) & 0x1 else 1) / 100,
|
29
|
+
0x3,
|
30
|
+
),
|
31
|
+
BMSdp("problem_code", 9, 3, False, lambda x: x, 0x3),
|
32
|
+
BMSdp(
|
33
|
+
"cycle_charge",
|
34
|
+
24,
|
35
|
+
4,
|
36
|
+
False,
|
37
|
+
lambda x: ((x & 0xFFFF0000) | (x & 0xFF00) >> 8 | (x & 0xFF) << 8) / 1000,
|
38
|
+
0x4,
|
39
|
+
),
|
40
|
+
BMSdp("runtime", 30, 2, False, lambda x: x * 60, 0x4),
|
41
|
+
BMSdp("temp_sensors", 13, 1, False, lambda x: x, 0x3),
|
42
|
+
BMSdp("cycles", 9, 2, False, lambda x: x, 0x4),
|
43
|
+
)
|
44
|
+
_CMDS: Final[set[int]] = set({field.idx for field in _FIELDS})
|
45
|
+
|
46
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
47
|
+
"""Initialize BMS."""
|
48
|
+
super().__init__(ble_device, reconnect)
|
49
|
+
self._data_final: dict[int, bytearray] = {}
|
50
|
+
self._exp_len: int = 0
|
51
|
+
|
52
|
+
@staticmethod
|
53
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
54
|
+
"""Provide BluetoothMatcher definition."""
|
55
|
+
return [
|
56
|
+
{
|
57
|
+
"service_uuid": BMS.uuid_services()[0],
|
58
|
+
"manufacturer_id": manufacturer_id,
|
59
|
+
"connectable": True,
|
60
|
+
}
|
61
|
+
for manufacturer_id in (0x01A8, 0x0B31, 0x8AFB)
|
62
|
+
]
|
63
|
+
|
64
|
+
@staticmethod
|
65
|
+
def device_info() -> dict[str, str]:
|
66
|
+
"""Return device information for the battery management system."""
|
67
|
+
return {"manufacturer": "RoyPow", "model": "SmartBMS"}
|
68
|
+
|
69
|
+
@staticmethod
|
70
|
+
def uuid_services() -> list[str]:
|
71
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
72
|
+
return [normalize_uuid_str("ffe0")]
|
73
|
+
|
74
|
+
@staticmethod
|
75
|
+
def uuid_rx() -> str:
|
76
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
77
|
+
return "ffe1"
|
78
|
+
|
79
|
+
@staticmethod
|
80
|
+
def uuid_tx() -> str:
|
81
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
82
|
+
return "ffe1"
|
83
|
+
|
84
|
+
@staticmethod
|
85
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
86
|
+
return frozenset(
|
87
|
+
{
|
88
|
+
"battery_charging",
|
89
|
+
"cycle_capacity",
|
90
|
+
"delta_voltage",
|
91
|
+
"power",
|
92
|
+
"temperature",
|
93
|
+
}
|
94
|
+
) # calculate further values from BMS provided set ones
|
95
|
+
|
96
|
+
def _notification_handler(
|
97
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
98
|
+
) -> None:
|
99
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
100
|
+
if not (data := data.removeprefix(BMS._BT_MODULE_MSG)):
|
101
|
+
self._log.debug("filtering AT cmd")
|
102
|
+
return
|
103
|
+
|
104
|
+
if (
|
105
|
+
data.startswith(BMS._HEAD)
|
106
|
+
and not self._data.startswith(BMS._HEAD)
|
107
|
+
and len(data) > len(BMS._HEAD)
|
108
|
+
):
|
109
|
+
self._exp_len = data[len(BMS._HEAD)]
|
110
|
+
self._data.clear()
|
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 not self._data.startswith(BMS._HEAD):
|
118
|
+
self._data.clear()
|
119
|
+
return
|
120
|
+
|
121
|
+
# verify that data is long enough
|
122
|
+
if len(self._data) < BMS._MIN_LEN + self._exp_len:
|
123
|
+
return
|
124
|
+
|
125
|
+
end_idx: Final[int] = BMS._MIN_LEN + self._exp_len - 1
|
126
|
+
if self._data[end_idx] != BMS._TAIL:
|
127
|
+
self._log.debug("incorrect EOF: %s", self._data)
|
128
|
+
self._data.clear()
|
129
|
+
return
|
130
|
+
|
131
|
+
if (crc := BMS._crc(self._data[len(BMS._HEAD) : end_idx - 1])) != self._data[
|
132
|
+
end_idx - 1
|
133
|
+
]:
|
134
|
+
self._log.debug(
|
135
|
+
"invalid checksum 0x%X != 0x%X", self._data[end_idx - 1], crc
|
136
|
+
)
|
137
|
+
self._data.clear()
|
138
|
+
return
|
139
|
+
|
140
|
+
self._data_final[self._data[5]] = self._data.copy()
|
141
|
+
self._data.clear()
|
142
|
+
self._data_event.set()
|
143
|
+
|
144
|
+
@staticmethod
|
145
|
+
def _crc(frame: bytearray) -> int:
|
146
|
+
"""Calculate XOR of all frame bytes."""
|
147
|
+
crc: int = 0
|
148
|
+
for b in frame:
|
149
|
+
crc ^= b
|
150
|
+
return crc
|
151
|
+
|
152
|
+
@staticmethod
|
153
|
+
def _cmd(cmd: bytes) -> bytes:
|
154
|
+
"""Assemble a RoyPow BMS command."""
|
155
|
+
data: Final[bytearray] = bytearray([len(cmd) + 2, *cmd])
|
156
|
+
return bytes([*BMS._HEAD, *data, BMS._crc(data), BMS._TAIL])
|
157
|
+
|
158
|
+
async def _async_update(self) -> BMSsample:
|
159
|
+
"""Update battery status information."""
|
160
|
+
|
161
|
+
self._data.clear()
|
162
|
+
self._data_final.clear()
|
163
|
+
for cmd in range(2, 5):
|
164
|
+
await self._await_reply(BMS._cmd(bytes([0xFF, cmd])))
|
165
|
+
|
166
|
+
result: BMSsample = BMS._decode_data(BMS._FIELDS, self._data_final)
|
167
|
+
|
168
|
+
# remove remaining runtime if battery is charging
|
169
|
+
if result.get("runtime") == 0xFFFF * 60:
|
170
|
+
result.pop("runtime", None)
|
171
|
+
|
172
|
+
result["cell_voltages"] = BMS._cell_voltages(
|
173
|
+
self._data_final.get(0x2, bytearray()),
|
174
|
+
cells=max(0, (len(self._data_final.get(0x2, bytearray())) - 11) // 2),
|
175
|
+
start=9,
|
176
|
+
)
|
177
|
+
result["temp_values"] = BMS._temp_values(
|
178
|
+
self._data_final.get(0x3, bytearray()),
|
179
|
+
values=result.get("temp_sensors", 0),
|
180
|
+
start=14,
|
181
|
+
size=1,
|
182
|
+
signed=False,
|
183
|
+
offset=40,
|
184
|
+
)
|
185
|
+
|
186
|
+
return result
|
@@ -0,0 +1,245 @@
|
|
1
|
+
"""Module to support Seplos V3 Smart BMS."""
|
2
|
+
|
3
|
+
from collections.abc import Callable
|
4
|
+
from typing import Any, 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, BMSpackvalue, BMSsample, BMSvalue, MatcherPattern
|
11
|
+
from aiobmsble.basebms import BaseBMS, crc_modbus
|
12
|
+
|
13
|
+
|
14
|
+
class BMS(BaseBMS):
|
15
|
+
"""Seplos V3 Smart BMS class implementation."""
|
16
|
+
|
17
|
+
CMD_READ: Final[list[int]] = [0x01, 0x04]
|
18
|
+
HEAD_LEN: Final[int] = 3
|
19
|
+
CRC_LEN: Final[int] = 2
|
20
|
+
PIA_LEN: Final[int] = 0x11
|
21
|
+
PIB_LEN: Final[int] = 0x1A
|
22
|
+
EIA_LEN: Final[int] = PIB_LEN
|
23
|
+
EIB_LEN: Final[int] = 0x16
|
24
|
+
EIC_LEN: Final[int] = 0x5
|
25
|
+
_TEMP_START: Final[int] = HEAD_LEN + 32
|
26
|
+
QUERY: Final[dict[str, tuple[int, int, int]]] = {
|
27
|
+
# name: cmd, reg start, length
|
28
|
+
"EIA": (0x4, 0x2000, EIA_LEN),
|
29
|
+
"EIB": (0x4, 0x2100, EIB_LEN),
|
30
|
+
"EIC": (0x1, 0x2200, EIC_LEN),
|
31
|
+
}
|
32
|
+
PQUERY: Final[dict[str, tuple[int, int, int]]] = {
|
33
|
+
"PIA": (0x4, 0x1000, PIA_LEN),
|
34
|
+
"PIB": (0x4, 0x1100, PIB_LEN),
|
35
|
+
}
|
36
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
37
|
+
BMSdp("temperature", 20, 2, True, lambda x: x / 10, EIB_LEN), # avg. ctemp
|
38
|
+
BMSdp("voltage", 0, 4, False, lambda x: BMS._swap32(x) / 100, EIA_LEN),
|
39
|
+
BMSdp("current", 4, 4, True, lambda x: BMS._swap32(x, True) / 10, EIA_LEN),
|
40
|
+
BMSdp("cycle_charge", 8, 4, False, lambda x: BMS._swap32(x) / 100, EIA_LEN),
|
41
|
+
BMSdp("pack_count", 44, 2, False, lambda x: x, EIA_LEN),
|
42
|
+
BMSdp("cycles", 46, 2, False, lambda x: x, EIA_LEN),
|
43
|
+
BMSdp("battery_level", 48, 2, False, lambda x: x / 10, EIA_LEN),
|
44
|
+
BMSdp("problem_code", 1, 9, False, lambda x: x & 0xFFFF00FF00FF0000FF, EIC_LEN),
|
45
|
+
) # Protocol Seplos V3
|
46
|
+
_PFIELDS: Final[list[tuple[BMSpackvalue, int, bool, Callable[[int], Any]]]] = [
|
47
|
+
("pack_voltages", 0, False, lambda x: x / 100),
|
48
|
+
("pack_currents", 2, True, lambda x: x / 100),
|
49
|
+
("pack_battery_levels", 10, False, lambda x: x / 10),
|
50
|
+
("pack_cycles", 14, False, lambda x: x),
|
51
|
+
] # Protocol Seplos V3
|
52
|
+
_CMDS: Final[set[int]] = {field[2] for field in QUERY.values()} | {
|
53
|
+
field[2] for field in PQUERY.values()
|
54
|
+
}
|
55
|
+
|
56
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
57
|
+
"""Intialize private BMS members."""
|
58
|
+
super().__init__(ble_device, reconnect)
|
59
|
+
self._data_final: dict[int, bytearray] = {}
|
60
|
+
self._pack_count: int = 0 # number of battery packs
|
61
|
+
self._pkglen: int = 0 # expected packet length
|
62
|
+
|
63
|
+
@staticmethod
|
64
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
65
|
+
"""Provide BluetoothMatcher definition."""
|
66
|
+
return [
|
67
|
+
{
|
68
|
+
"local_name": pattern,
|
69
|
+
"service_uuid": BMS.uuid_services()[0],
|
70
|
+
"connectable": True,
|
71
|
+
}
|
72
|
+
for pattern in {f"SP{num}?B*" for num in range(10)} | {"CSY*"}
|
73
|
+
]
|
74
|
+
|
75
|
+
@staticmethod
|
76
|
+
def device_info() -> dict[str, str]:
|
77
|
+
"""Return device information for the battery management system."""
|
78
|
+
return {"manufacturer": "Seplos", "model": "Smart BMS V3"}
|
79
|
+
|
80
|
+
# setup UUIDs
|
81
|
+
# serv 0000fff0-0000-1000-8000-00805f9b34fb
|
82
|
+
# char 0000fff1-0000-1000-8000-00805f9b34fb (#16): ['read', 'notify']
|
83
|
+
# char 0000fff2-0000-1000-8000-00805f9b34fb (#20): ['read', 'write-without-response', 'write']
|
84
|
+
@staticmethod
|
85
|
+
def uuid_services() -> list[str]:
|
86
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
87
|
+
return [normalize_uuid_str("fff0")]
|
88
|
+
|
89
|
+
@staticmethod
|
90
|
+
def uuid_rx() -> str:
|
91
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
92
|
+
return "fff1"
|
93
|
+
|
94
|
+
@staticmethod
|
95
|
+
def uuid_tx() -> str:
|
96
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
97
|
+
return "fff2"
|
98
|
+
|
99
|
+
@staticmethod
|
100
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
101
|
+
return frozenset({"power", "battery_charging", "cycle_capacity", "runtime"})
|
102
|
+
|
103
|
+
def _notification_handler(
|
104
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
105
|
+
) -> None:
|
106
|
+
"""Retrieve BMS data update."""
|
107
|
+
|
108
|
+
if (
|
109
|
+
len(data) > BMS.HEAD_LEN + BMS.CRC_LEN
|
110
|
+
and data[0] <= self._pack_count
|
111
|
+
and data[1] & 0x7F in BMS.CMD_READ # include read errors
|
112
|
+
and data[2] >= BMS.HEAD_LEN + BMS.CRC_LEN
|
113
|
+
):
|
114
|
+
self._data = bytearray()
|
115
|
+
self._pkglen = data[2] + BMS.HEAD_LEN + BMS.CRC_LEN
|
116
|
+
elif ( # error message
|
117
|
+
len(data) == BMS.HEAD_LEN + BMS.CRC_LEN
|
118
|
+
and data[0] <= self._pack_count
|
119
|
+
and data[1] & 0x80
|
120
|
+
):
|
121
|
+
self._log.debug("RX error: %X", data[2])
|
122
|
+
self._data = bytearray()
|
123
|
+
self._pkglen = BMS.HEAD_LEN + BMS.CRC_LEN
|
124
|
+
|
125
|
+
self._data += data
|
126
|
+
self._log.debug(
|
127
|
+
"RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
|
128
|
+
)
|
129
|
+
|
130
|
+
# verify that data is long enough
|
131
|
+
if len(self._data) < self._pkglen:
|
132
|
+
return
|
133
|
+
|
134
|
+
if (crc := crc_modbus(self._data[: self._pkglen - 2])) != int.from_bytes(
|
135
|
+
self._data[self._pkglen - 2 : self._pkglen], "little"
|
136
|
+
):
|
137
|
+
self._log.debug(
|
138
|
+
"invalid checksum 0x%X != 0x%X",
|
139
|
+
int.from_bytes(self._data[self._pkglen - 2 : self._pkglen], "little"),
|
140
|
+
crc,
|
141
|
+
)
|
142
|
+
self._data = bytearray()
|
143
|
+
return
|
144
|
+
|
145
|
+
if self._data[2] >> 1 not in BMS._CMDS or self._data[1] & 0x80:
|
146
|
+
self._log.debug(
|
147
|
+
"unknown message: %s, length: %s", self._data[0:2], self._data[2]
|
148
|
+
)
|
149
|
+
self._data = bytearray()
|
150
|
+
return
|
151
|
+
|
152
|
+
if len(self._data) != self._pkglen:
|
153
|
+
self._log.debug(
|
154
|
+
"wrong data length (%i!=%s): %s",
|
155
|
+
len(self._data),
|
156
|
+
self._pkglen,
|
157
|
+
self._data,
|
158
|
+
)
|
159
|
+
|
160
|
+
self._data_final[self._data[0] << 8 | self._data[2] >> 1] = self._data
|
161
|
+
self._data = bytearray()
|
162
|
+
self._data_event.set()
|
163
|
+
|
164
|
+
async def _init_connection(
|
165
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
166
|
+
) -> None:
|
167
|
+
"""Initialize RX/TX characteristics."""
|
168
|
+
await super()._init_connection()
|
169
|
+
self._pack_count = 0
|
170
|
+
self._pkglen = 0
|
171
|
+
|
172
|
+
@staticmethod
|
173
|
+
def _swap32(value: int, signed: bool = False) -> int:
|
174
|
+
"""Swap high and low 16bit in 32bit integer."""
|
175
|
+
|
176
|
+
value = ((value >> 16) & 0xFFFF) | (value & 0xFFFF) << 16
|
177
|
+
if signed and value & 0x80000000:
|
178
|
+
value = -0x100000000 + value
|
179
|
+
return value
|
180
|
+
|
181
|
+
@staticmethod
|
182
|
+
def _cmd(device: int, cmd: int, start: int, count: int) -> bytes:
|
183
|
+
"""Assemble a Seplos BMS command."""
|
184
|
+
assert device >= 0x00 and (device <= 0x10 or device in (0xC0, 0xE0))
|
185
|
+
assert cmd in (0x01, 0x04) # allow only read commands
|
186
|
+
assert start >= 0 and count > 0 and start + count <= 0xFFFF
|
187
|
+
frame: bytearray = bytearray([device, cmd])
|
188
|
+
frame += int.to_bytes(start, 2, byteorder="big")
|
189
|
+
frame += int.to_bytes(count * (0x10 if cmd == 0x1 else 0x1), 2, byteorder="big")
|
190
|
+
frame += int.to_bytes(crc_modbus(frame), 2, byteorder="little")
|
191
|
+
return bytes(frame)
|
192
|
+
|
193
|
+
async def _async_update(self) -> BMSsample:
|
194
|
+
"""Update battery status information."""
|
195
|
+
for block in BMS.QUERY.values():
|
196
|
+
await self._await_reply(BMS._cmd(0x0, *block))
|
197
|
+
|
198
|
+
data: BMSsample = BMS._decode_data(
|
199
|
+
BMS._FIELDS, self._data_final, offset=BMS.HEAD_LEN
|
200
|
+
)
|
201
|
+
|
202
|
+
self._pack_count = min(data.get("pack_count", 0), 0x10)
|
203
|
+
|
204
|
+
for pack in range(1, 1 + self._pack_count):
|
205
|
+
for block in BMS.PQUERY.values():
|
206
|
+
await self._await_reply(self._cmd(pack, *block))
|
207
|
+
|
208
|
+
for key, idx, sign, func in BMS._PFIELDS:
|
209
|
+
data.setdefault(key, []).append(
|
210
|
+
func(
|
211
|
+
int.from_bytes(
|
212
|
+
self._data_final[pack << 8 | BMS.PIA_LEN][
|
213
|
+
BMS.HEAD_LEN + idx : BMS.HEAD_LEN + idx + 2
|
214
|
+
],
|
215
|
+
byteorder="big",
|
216
|
+
signed=sign,
|
217
|
+
)
|
218
|
+
)
|
219
|
+
)
|
220
|
+
|
221
|
+
pack_cells: list[float] = BMS._cell_voltages(
|
222
|
+
self._data_final[pack << 8 | BMS.PIB_LEN], cells=16, start=BMS.HEAD_LEN
|
223
|
+
)
|
224
|
+
# update per pack delta voltage
|
225
|
+
data["delta_voltage"] = max(
|
226
|
+
data.get("delta_voltage", 0),
|
227
|
+
round(max(pack_cells) - min(pack_cells), 3),
|
228
|
+
)
|
229
|
+
# add individual cell voltages
|
230
|
+
data.setdefault("cell_voltages", []).extend(pack_cells)
|
231
|
+
# add temperature sensors (4x cell temperature + 4 reserved)
|
232
|
+
data.setdefault("temp_values", []).extend(
|
233
|
+
BMS._temp_values(
|
234
|
+
self._data_final[pack << 8 | BMS.PIB_LEN],
|
235
|
+
values=4,
|
236
|
+
start=BMS._TEMP_START,
|
237
|
+
signed=False,
|
238
|
+
offset=2731,
|
239
|
+
divider=10,
|
240
|
+
)
|
241
|
+
)
|
242
|
+
|
243
|
+
self._data_final.clear()
|
244
|
+
|
245
|
+
return data
|