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.
@@ -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