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.
Files changed (37) hide show
  1. aiobmsble/__init__.py +53 -8
  2. aiobmsble/__main__.py +51 -27
  3. aiobmsble/basebms.py +266 -50
  4. aiobmsble/bms/__init__.py +1 -0
  5. aiobmsble/bms/abc_bms.py +164 -0
  6. aiobmsble/bms/ant_bms.py +196 -0
  7. aiobmsble/bms/braunpwr_bms.py +167 -0
  8. aiobmsble/bms/cbtpwr_bms.py +168 -0
  9. aiobmsble/bms/cbtpwr_vb_bms.py +184 -0
  10. aiobmsble/bms/daly_bms.py +164 -0
  11. aiobmsble/bms/dpwrcore_bms.py +207 -0
  12. aiobmsble/bms/dummy_bms.py +89 -0
  13. aiobmsble/bms/ecoworthy_bms.py +151 -0
  14. aiobmsble/bms/ective_bms.py +177 -0
  15. aiobmsble/bms/ej_bms.py +233 -0
  16. aiobmsble/bms/felicity_bms.py +139 -0
  17. aiobmsble/bms/jbd_bms.py +203 -0
  18. aiobmsble/bms/jikong_bms.py +301 -0
  19. aiobmsble/bms/neey_bms.py +214 -0
  20. aiobmsble/bms/ogt_bms.py +214 -0
  21. aiobmsble/bms/pro_bms.py +144 -0
  22. aiobmsble/bms/redodo_bms.py +127 -0
  23. aiobmsble/bms/renogy_bms.py +149 -0
  24. aiobmsble/bms/renogy_pro_bms.py +105 -0
  25. aiobmsble/bms/roypow_bms.py +186 -0
  26. aiobmsble/bms/seplos_bms.py +245 -0
  27. aiobmsble/bms/seplos_v2_bms.py +205 -0
  28. aiobmsble/bms/tdt_bms.py +199 -0
  29. aiobmsble/bms/tianpwr_bms.py +138 -0
  30. aiobmsble/utils.py +96 -6
  31. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/METADATA +23 -14
  32. aiobmsble-0.2.1.dist-info/RECORD +36 -0
  33. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/WHEEL +1 -1
  34. aiobmsble-0.1.0.dist-info/RECORD +0 -10
  35. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/entry_points.txt +0 -0
  36. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/licenses/LICENSE +0 -0
  37. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,203 @@
1
+ """Module to support JBD Smart 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
+ """JBD Smart BMS class implementation."""
15
+
16
+ HEAD_RSP: Final[bytes] = bytes([0xDD]) # header for responses
17
+ HEAD_CMD: Final[bytes] = bytes([0xDD, 0xA5]) # read header for commands
18
+ TAIL: Final[int] = 0x77 # tail for command
19
+ INFO_LEN: Final[int] = 7 # minimum frame size
20
+ BASIC_INFO: Final[int] = 23 # basic info data length
21
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
22
+ BMSdp("temp_sensors", 26, 1, False, lambda x: x), # count is not limited
23
+ BMSdp("voltage", 4, 2, False, lambda x: x / 100),
24
+ BMSdp("current", 6, 2, True, lambda x: x / 100),
25
+ BMSdp("battery_level", 23, 1, False, lambda x: x),
26
+ BMSdp("cycle_charge", 8, 2, False, lambda x: x / 100),
27
+ BMSdp("cycles", 12, 2, False, lambda x: x),
28
+ BMSdp("problem_code", 20, 2, False, lambda x: x),
29
+ ) # general protocol v4
30
+
31
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
32
+ """Intialize private BMS members."""
33
+ super().__init__(ble_device, reconnect)
34
+ self._valid_reply: int = 0x00
35
+ self._data_final: bytearray = bytearray()
36
+
37
+ @staticmethod
38
+ def matcher_dict_list() -> list[MatcherPattern]:
39
+ """Provide BluetoothMatcher definition."""
40
+ return [
41
+ MatcherPattern(
42
+ local_name=pattern,
43
+ service_uuid=BMS.uuid_services()[0],
44
+ connectable=True,
45
+ )
46
+ for pattern in (
47
+ "JBD-*",
48
+ "SP0?S*",
49
+ "SP1?S*",
50
+ "SP2?S*",
51
+ "AP2?S*",
52
+ "GJ-*", # accurat batteries
53
+ "SX1*", # Supervolt v3
54
+ "DP04S*", # ECO-WORTHY, DCHOUSE
55
+ "ECO-LFP*", # ECO-WORTHY rack (use m_id?)
56
+ "121?0*", # Eleksol, Ultimatron
57
+ "12200*",
58
+ "12300*",
59
+ "SBL-*", # SBL
60
+ "LT40AH", # LionTron
61
+ "PKT*", # Perfektium
62
+ "gokwh*",
63
+ "OGR-*", # OGRPHY
64
+ "DWC*", # Vatrer
65
+ "DXD*", # Vatrer
66
+ "xiaoxiang*", # xiaoxiang BMS
67
+ "AL12-*", # Aolithium
68
+ "BS20*", # BasenGreen
69
+ "BT LP*", # LANPWR
70
+ )
71
+ ] + [
72
+ MatcherPattern(
73
+ service_uuid=BMS.uuid_services()[0],
74
+ manufacturer_id=m_id,
75
+ connectable=True,
76
+ )
77
+ for m_id in (0x0211, 0x3E70, 0xC1A4)
78
+ # Liontron, LISMART1240LX/LISMART1255LX,
79
+ # LionTron XL19110253 / EPOCH batteries 12.8V 460Ah - 12460A-H
80
+ ]
81
+
82
+ @staticmethod
83
+ def device_info() -> dict[str, str]:
84
+ """Return device information for the battery management system."""
85
+ return {"manufacturer": "Jiabaida", "model": "Smart BMS"}
86
+
87
+ @staticmethod
88
+ def uuid_services() -> list[str]:
89
+ """Return list of 128-bit UUIDs of services required by BMS."""
90
+ return [normalize_uuid_str("ff00")]
91
+
92
+ @staticmethod
93
+ def uuid_rx() -> str:
94
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
95
+ return "ff01"
96
+
97
+ @staticmethod
98
+ def uuid_tx() -> str:
99
+ """Return 16-bit UUID of characteristic that provides write property."""
100
+ return "ff02"
101
+
102
+ @staticmethod
103
+ def _calc_values() -> frozenset[BMSvalue]:
104
+ return frozenset(
105
+ {
106
+ "power",
107
+ "battery_charging",
108
+ "cycle_capacity",
109
+ "runtime",
110
+ "delta_voltage",
111
+ "temperature",
112
+ }
113
+ )
114
+
115
+ def _notification_handler(
116
+ self, _sender: BleakGATTCharacteristic, data: bytearray
117
+ ) -> None:
118
+ # check if answer is a heading of basic info (0x3) or cell block info (0x4)
119
+ if (
120
+ data.startswith(self.HEAD_RSP)
121
+ and len(self._data) > self.INFO_LEN
122
+ and data[1] in (0x03, 0x04)
123
+ and data[2] == 0x00
124
+ and len(self._data) >= self.INFO_LEN + self._data[3]
125
+ ):
126
+ self._data = bytearray()
127
+
128
+ self._data += data
129
+ self._log.debug(
130
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
131
+ )
132
+
133
+ # verify that data is long enough
134
+ if (
135
+ len(self._data) < BMS.INFO_LEN
136
+ or len(self._data) < BMS.INFO_LEN + self._data[3]
137
+ ):
138
+ return
139
+
140
+ # check correct frame ending
141
+ frame_end: Final[int] = BMS.INFO_LEN + self._data[3] - 1
142
+ if self._data[frame_end] != BMS.TAIL:
143
+ self._log.debug("incorrect frame end (length: %i).", len(self._data))
144
+ return
145
+
146
+ if (crc := BMS._crc(self._data[2 : frame_end - 2])) != int.from_bytes(
147
+ self._data[frame_end - 2 : frame_end], "big"
148
+ ):
149
+ self._log.debug(
150
+ "invalid checksum 0x%X != 0x%X",
151
+ int.from_bytes(self._data[frame_end - 2 : frame_end], "big"),
152
+ crc,
153
+ )
154
+ return
155
+
156
+ if len(self._data) != BMS.INFO_LEN + self._data[3]:
157
+ self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
158
+
159
+ if self._data[1] != self._valid_reply:
160
+ self._log.debug("unexpected response (type 0x%X)", self._data[1])
161
+ return
162
+
163
+ self._data_final = self._data
164
+ self._data_event.set()
165
+
166
+ @staticmethod
167
+ def _crc(frame: bytearray) -> int:
168
+ """Calculate JBD frame CRC."""
169
+ return 0x10000 - sum(frame)
170
+
171
+ @staticmethod
172
+ def _cmd(cmd: bytes) -> bytes:
173
+ """Assemble a JBD BMS command."""
174
+ frame = bytearray([*BMS.HEAD_CMD, cmd[0], 0x00])
175
+ frame.extend([*BMS._crc(frame[2:4]).to_bytes(2, "big"), BMS.TAIL])
176
+ return bytes(frame)
177
+
178
+ async def _await_cmd_resp(self, cmd: int) -> None:
179
+ msg: Final[bytes] = BMS._cmd(bytes([cmd]))
180
+ self._valid_reply = msg[2]
181
+ await self._await_reply(msg)
182
+ self._valid_reply = 0x00
183
+
184
+ async def _async_update(self) -> BMSsample:
185
+ """Update battery status information."""
186
+ data: BMSsample = {}
187
+ await self._await_cmd_resp(0x03)
188
+ data = BMS._decode_data(BMS._FIELDS, self._data_final)
189
+ data["temp_values"] = BMS._temp_values(
190
+ self._data_final,
191
+ values=data.get("temp_sensors", 0),
192
+ start=27,
193
+ signed=False,
194
+ offset=2731,
195
+ divider=10,
196
+ )
197
+
198
+ await self._await_cmd_resp(0x04)
199
+ data["cell_voltages"] = BMS._cell_voltages(
200
+ self._data_final, cells=self._data_final[3] // 2, start=4, byteorder="big"
201
+ )
202
+
203
+ return data
@@ -0,0 +1,301 @@
1
+ """Module to support Jikong Smart 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, BMSmode, BMSsample, BMSvalue, MatcherPattern
11
+ from aiobmsble.basebms import BaseBMS, crc_sum
12
+
13
+
14
+ class BMS(BaseBMS):
15
+ """Jikong Smart BMS class implementation."""
16
+
17
+ HEAD_RSP: Final = bytes([0x55, 0xAA, 0xEB, 0x90]) # header for responses
18
+ HEAD_CMD: Final = bytes([0xAA, 0x55, 0x90, 0xEB]) # header for commands (endiness!)
19
+ _READY_MSG: Final = HEAD_CMD + bytes([0xC8, 0x01, 0x01] + [0x00] * 12 + [0x44])
20
+ _BT_MODULE_MSG: Final = bytes([0x41, 0x54, 0x0D, 0x0A]) # AT\r\n from BLE module
21
+ TYPE_POS: Final[int] = 4 # frame type is right after the header
22
+ INFO_LEN: Final[int] = 300
23
+ _FIELDS: Final[tuple[BMSdp, ...]] = ( # Protocol: JK02_32S; JK02_24S has offset -32
24
+ BMSdp("voltage", 150, 4, False, lambda x: x / 1000),
25
+ BMSdp("current", 158, 4, True, lambda x: x / 1000),
26
+ BMSdp("battery_level", 173, 1, False, lambda x: x),
27
+ BMSdp("cycle_charge", 174, 4, False, lambda x: x / 1000),
28
+ BMSdp("cycles", 182, 4, False, lambda x: x),
29
+ BMSdp("balance_current", 170, 2, True, lambda x: x / 1000),
30
+ BMSdp("temp_sensors", 214, 2, True, lambda x: x),
31
+ BMSdp("problem_code", 166, 4, False, lambda x: x),
32
+ )
33
+
34
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
35
+ """Intialize private BMS members."""
36
+ super().__init__(ble_device, reconnect)
37
+ self._data_final: bytearray = bytearray()
38
+ self._char_write_handle: int = -1
39
+ self._bms_info: dict[str, str] = {}
40
+ self._prot_offset: int = 0
41
+ self._sw_version: int = 0
42
+ self._valid_reply: int = 0x02
43
+ self._bms_ready: bool = False
44
+
45
+ @staticmethod
46
+ def matcher_dict_list() -> list[MatcherPattern]:
47
+ """Provide BluetoothMatcher definition."""
48
+ return [
49
+ {
50
+ "service_uuid": BMS.uuid_services()[0],
51
+ "connectable": True,
52
+ "manufacturer_id": 0x0B65,
53
+ },
54
+ ]
55
+
56
+ @staticmethod
57
+ def device_info() -> dict[str, str]:
58
+ """Return device information for the battery management system."""
59
+ return {"manufacturer": "Jikong", "model": "Smart BMS"}
60
+
61
+ @staticmethod
62
+ def uuid_services() -> list[str]:
63
+ """Return list of 128-bit UUIDs of services required by BMS."""
64
+ return [normalize_uuid_str("ffe0")]
65
+
66
+ @staticmethod
67
+ def uuid_rx() -> str:
68
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
69
+ return "ffe1"
70
+
71
+ @staticmethod
72
+ def uuid_tx() -> str:
73
+ """Return 16-bit UUID of characteristic that provides write property."""
74
+ return "ffe1"
75
+
76
+ @staticmethod
77
+ def _calc_values() -> frozenset[BMSvalue]:
78
+ return frozenset(
79
+ {
80
+ "power",
81
+ "battery_charging",
82
+ "cycle_capacity",
83
+ "runtime",
84
+ "temperature",
85
+ }
86
+ )
87
+
88
+ def _notification_handler(
89
+ self, _sender: BleakGATTCharacteristic, data: bytearray
90
+ ) -> None:
91
+ """Retrieve BMS data update."""
92
+
93
+ if data.startswith(BMS._BT_MODULE_MSG):
94
+ self._log.debug("filtering AT cmd")
95
+ if not (data := data.removeprefix(BMS._BT_MODULE_MSG)):
96
+ return
97
+
98
+ if (
99
+ len(self._data) >= self.INFO_LEN
100
+ and (data.startswith((BMS.HEAD_RSP, BMS.HEAD_CMD)))
101
+ ) or not self._data.startswith(BMS.HEAD_RSP):
102
+ self._data = bytearray()
103
+
104
+ self._data += data
105
+
106
+ self._log.debug(
107
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
108
+ )
109
+
110
+ # verify that data is long enough
111
+ if (
112
+ len(self._data) < BMS.INFO_LEN and self._data.startswith(BMS.HEAD_RSP)
113
+ ) or len(self._data) < BMS.TYPE_POS + 1:
114
+ return
115
+
116
+ # check that message type is expected
117
+ if self._data[BMS.TYPE_POS] != self._valid_reply:
118
+ self._log.debug(
119
+ "unexpected message type 0x%X (length %i): %s",
120
+ self._data[BMS.TYPE_POS],
121
+ len(self._data),
122
+ self._data,
123
+ )
124
+ return
125
+
126
+ # trim AT\r\n message from the end
127
+ if self._data.endswith(BMS._BT_MODULE_MSG):
128
+ self._log.debug("trimming AT cmd")
129
+ self._data = self._data.removesuffix(BMS._BT_MODULE_MSG)
130
+
131
+ # set BMS ready if msg is attached to last responses (v19.05)
132
+ if self._data[BMS.INFO_LEN :].startswith(BMS._READY_MSG):
133
+ self._log.debug("BMS ready.")
134
+ self._bms_ready = True
135
+ self._data = self._data[: BMS.INFO_LEN]
136
+
137
+ # trim message in case oversized
138
+ if len(self._data) > BMS.INFO_LEN:
139
+ self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
140
+ self._data = self._data[: BMS.INFO_LEN]
141
+
142
+ if (crc := crc_sum(self._data[:-1])) != self._data[-1]:
143
+ self._log.debug("invalid checksum 0x%X != 0x%X", self._data[-1], crc)
144
+ return
145
+
146
+ self._data_final = self._data.copy()
147
+ self._data_event.set()
148
+
149
+ async def _init_connection(
150
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
151
+ ) -> None:
152
+ """Initialize RX/TX characteristics and protocol state."""
153
+ char_notify_handle: int = -1
154
+ self._char_write_handle = -1
155
+ self._bms_ready = False
156
+
157
+ for service in self._client.services:
158
+ for char in service.characteristics:
159
+ self._log.debug(
160
+ "discovered %s (#%i): %s", char.uuid, char.handle, char.properties
161
+ )
162
+ if char.uuid == normalize_uuid_str(
163
+ BMS.uuid_rx()
164
+ ) or char.uuid == normalize_uuid_str(BMS.uuid_tx()):
165
+ if "notify" in char.properties:
166
+ char_notify_handle = char.handle
167
+ if (
168
+ "write" in char.properties
169
+ or "write-without-response" in char.properties
170
+ ):
171
+ self._char_write_handle = char.handle
172
+ if char_notify_handle == -1 or self._char_write_handle == -1:
173
+ self._log.debug("failed to detect characteristics.")
174
+ await self._client.disconnect()
175
+ raise ConnectionError(f"Failed to detect characteristics from {self.name}.")
176
+ self._log.debug(
177
+ "using characteristics handle #%i (notify), #%i (write).",
178
+ char_notify_handle,
179
+ self._char_write_handle,
180
+ )
181
+
182
+ await super()._init_connection()
183
+
184
+ # query device info frame (0x03) and wait for BMS ready (0xC8)
185
+ self._valid_reply = 0x03
186
+ await self._await_reply(self._cmd(b"\x97"), char=self._char_write_handle)
187
+ self._bms_info = BMS._dec_devinfo(self._data_final or bytearray())
188
+ self._log.debug("device information: %s", self._bms_info)
189
+ self._prot_offset = (
190
+ -32 if int(self._bms_info.get("sw_version", "")[:2]) < 11 else 0
191
+ )
192
+ if not self._bms_ready:
193
+ self._valid_reply = 0xC8 # BMS ready confirmation
194
+ await asyncio.wait_for(self._wait_event(), timeout=BMS.TIMEOUT)
195
+ self._valid_reply = 0x02 # cell information
196
+
197
+ @staticmethod
198
+ def _cmd(cmd: bytes, value: list[int] | None = None) -> bytes:
199
+ """Assemble a Jikong BMS command."""
200
+ value = [] if value is None else value
201
+ assert len(value) <= 13
202
+ frame: bytearray = bytearray(
203
+ [*BMS.HEAD_CMD, cmd[0], len(value), *value]
204
+ ) + bytearray(13 - len(value))
205
+ frame.append(crc_sum(frame))
206
+ return bytes(frame)
207
+
208
+ @staticmethod
209
+ def _dec_devinfo(data: bytearray) -> dict[str, str]:
210
+ fields: Final[dict[str, int]] = {
211
+ "hw_version": 22,
212
+ "sw_version": 30,
213
+ }
214
+ return {
215
+ key: data[idx : idx + 8].decode(errors="replace").strip("\x00")
216
+ for key, idx in fields.items()
217
+ }
218
+
219
+ def _temp_pos(self) -> list[tuple[int, int]]:
220
+ sw_majv: Final[int] = int(self._bms_info.get("sw_version", "")[:2])
221
+ if sw_majv >= 14:
222
+ return [(0, 144), (1, 162), (2, 164), (3, 254), (4, 256), (5, 258)]
223
+ if sw_majv >= 11:
224
+ return [(0, 144), (1, 162), (2, 164), (3, 254)]
225
+ return [(0, 130), (1, 132), (2, 134)]
226
+
227
+ @staticmethod
228
+ def _temp_sensors(
229
+ data: bytearray, temp_pos: list[tuple[int, int]], mask: int
230
+ ) -> list[int | float]:
231
+ return [
232
+ (value / 10)
233
+ for idx, pos in temp_pos
234
+ if mask & (1 << idx)
235
+ and (
236
+ value := int.from_bytes(
237
+ data[pos : pos + 2], byteorder="little", signed=True
238
+ )
239
+ )
240
+ != -2000
241
+ ]
242
+
243
+ @staticmethod
244
+ def _conv_data(data: bytearray, offs: int, sw_majv: int) -> BMSsample:
245
+ """Return BMS data from status message."""
246
+
247
+ result: BMSsample = BMS._decode_data(
248
+ BMS._FIELDS, data, byteorder="little", offset=offs
249
+ )
250
+ result["cell_count"] = int.from_bytes(
251
+ data[70 + (offs >> 1) : 74 + (offs >> 1)], byteorder="little"
252
+ ).bit_count()
253
+
254
+ result["delta_voltage"] = (
255
+ int.from_bytes(
256
+ data[76 + (offs >> 1) : 78 + (offs >> 1)], byteorder="little"
257
+ )
258
+ / 1000
259
+ )
260
+
261
+ if sw_majv >= 15:
262
+ result["battery_mode"] = (
263
+ BMSmode(data[280 + offs])
264
+ if data[280 + offs] in BMSmode
265
+ else BMSmode.UNKNOWN
266
+ )
267
+
268
+ return result
269
+
270
+ async def _async_update(self) -> BMSsample:
271
+ """Update battery status information."""
272
+ if not self._data_event.is_set() or self._data_final[4] != 0x02:
273
+ # request cell info (only if data is not constantly published)
274
+ self._log.debug("requesting cell info")
275
+ await self._await_reply(
276
+ data=BMS._cmd(b"\x96"), char=self._char_write_handle
277
+ )
278
+
279
+ data: BMSsample = self._conv_data(
280
+ self._data_final,
281
+ self._prot_offset,
282
+ int(self._bms_info.get("sw_version", "")[:2]),
283
+ )
284
+ data["temp_values"] = BMS._temp_sensors(
285
+ self._data_final, self._temp_pos(), data.get("temp_sensors", 0)
286
+ )
287
+
288
+ data["problem_code"] = (
289
+ ((data.get("problem_code", 0)) >> 16)
290
+ if self._prot_offset
291
+ else (data.get("problem_code", 0) & 0xFFFF)
292
+ )
293
+
294
+ data["cell_voltages"] = BMS._cell_voltages(
295
+ self._data_final,
296
+ cells=data.get("cell_count", 0),
297
+ start=6,
298
+ byteorder="little",
299
+ )
300
+
301
+ return data