SolixBLE 3.5.0__tar.gz → 3.7.0__tar.gz
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.
- {solixble-3.5.0 → solixble-3.7.0}/PKG-INFO +2 -1
- {solixble-3.5.0 → solixble-3.7.0}/README.md +1 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/__init__.py +4 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/const.py +1 -1
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/device.py +208 -137
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/__init__.py +2 -0
- solixble-3.7.0/SolixBLE/devices/prime_charger_160w.py +261 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/prime_charger_250w.py +20 -20
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/solarbank2.py +151 -11
- solixble-3.7.0/SolixBLE/prime_device.py +522 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/states.py +75 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/utilities.py +9 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/PKG-INFO +2 -1
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/SOURCES.txt +4 -1
- {solixble-3.5.0 → solixble-3.7.0}/pyproject.toml +1 -1
- solixble-3.7.0/tests/test_connection.py +255 -0
- {solixble-3.5.0 → solixble-3.7.0}/tests/test_devices.py +250 -131
- solixble-3.7.0/tests/test_prime.py +74 -0
- solixble-3.5.0/tests/test_reconnect.py +0 -155
- {solixble-3.5.0 → solixble-3.7.0}/LICENSE.txt +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c1000.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c1000g2.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c300.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c300dc.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c800.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/f2000.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/f3800.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/generic.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/solarbank3.py +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/requires.txt +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/top_level.txt +0 -0
- {solixble-3.5.0 → solixble-3.7.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SolixBLE
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.7.0
|
|
4
4
|
Summary: Python module for monitoring & controlling Bluetooth Anker Solix devices
|
|
5
5
|
Author-email: Harvey Lelliott <harveylelliott@duck.com>
|
|
6
6
|
License: MIT License
|
|
@@ -100,6 +100,7 @@ See the [support table](https://solixble.readthedocs.io/en/latest) in the docume
|
|
|
100
100
|
- F3800
|
|
101
101
|
- Solarbank 2
|
|
102
102
|
- Solarbank 3
|
|
103
|
+
- Prime Charger 160w
|
|
103
104
|
- Prime Charger 250w
|
|
104
105
|
- Potentially more!
|
|
105
106
|
|
|
@@ -14,10 +14,12 @@ from .devices import (
|
|
|
14
14
|
F2000,
|
|
15
15
|
F3800,
|
|
16
16
|
Generic,
|
|
17
|
+
PrimeCharger160w,
|
|
17
18
|
PrimeCharger250w,
|
|
18
19
|
Solarbank2,
|
|
19
20
|
Solarbank3,
|
|
20
21
|
)
|
|
22
|
+
from .prime_device import PrimeDevice
|
|
21
23
|
from .states import (
|
|
22
24
|
ChargingStatus,
|
|
23
25
|
ChargingStatusF3800,
|
|
@@ -31,6 +33,7 @@ from .utilities import discover_devices
|
|
|
31
33
|
|
|
32
34
|
__all__ = [
|
|
33
35
|
"SolixBLEDevice",
|
|
36
|
+
"PrimeDevice",
|
|
34
37
|
"C300",
|
|
35
38
|
"C300DC",
|
|
36
39
|
"C800",
|
|
@@ -40,6 +43,7 @@ __all__ = [
|
|
|
40
43
|
"F3800",
|
|
41
44
|
"Solarbank2",
|
|
42
45
|
"Solarbank3",
|
|
46
|
+
"PrimeCharger160w",
|
|
43
47
|
"PrimeCharger250w",
|
|
44
48
|
"Generic",
|
|
45
49
|
"ChargingStatus",
|
|
@@ -10,7 +10,7 @@ UUID_TELEMETRY = "8c850003-0302-41c5-b46e-cf057c562025"
|
|
|
10
10
|
#: GATT Service UUID for sending commands / negotiating.
|
|
11
11
|
UUID_COMMAND = "8c850002-0302-41c5-b46e-cf057c562025"
|
|
12
12
|
|
|
13
|
-
#: GATT Service UUID for identifying Solix devices (Tested on C300X and
|
|
13
|
+
#: GATT Service UUID for identifying Solix/Prime devices (Tested on C300X, C1000, and Prime 160w Charger).
|
|
14
14
|
UUID_IDENTIFIER = "0000ff09-0000-1000-8000-00805f9b34fb"
|
|
15
15
|
|
|
16
16
|
#: Time to wait before re-connecting on an unexpected disconnect.
|
|
@@ -62,8 +62,8 @@ class SolixBLEDevice:
|
|
|
62
62
|
|
|
63
63
|
self._ble_device: BLEDevice = ble_device
|
|
64
64
|
self._client: BleakClient | None = None
|
|
65
|
-
self.
|
|
66
|
-
self.
|
|
65
|
+
self._fragment_buffers: dict[bytes, dict[int, bytes]] = {}
|
|
66
|
+
self._fragment_totals: dict[bytes, int] = {}
|
|
67
67
|
self._data: dict[str, bytes] | None = None
|
|
68
68
|
self._last_data_timestamp: datetime | None = None
|
|
69
69
|
self._last_packet_timestamp: datetime | None = None
|
|
@@ -73,8 +73,7 @@ class SolixBLEDevice:
|
|
|
73
73
|
self._auto_reconnect_task: asyncio.Task | None = None
|
|
74
74
|
self._disconnect_event: asyncio.Event = asyncio.Event()
|
|
75
75
|
self._connection_attempts: int = 0
|
|
76
|
-
self.
|
|
77
|
-
self._iv: bytes | None = None
|
|
76
|
+
self._shared_secret: bytes | None = None
|
|
78
77
|
|
|
79
78
|
def add_callback(self, function: Callable[[], None]) -> None:
|
|
80
79
|
"""Register a callback to be run on state updates.
|
|
@@ -94,6 +93,14 @@ class SolixBLEDevice:
|
|
|
94
93
|
"""
|
|
95
94
|
self._state_changed_callbacks.remove(function)
|
|
96
95
|
|
|
96
|
+
async def _initiate_negotiations(self) -> None:
|
|
97
|
+
"""Send the negotiation initiation command."""
|
|
98
|
+
await self._client.write_gatt_char(
|
|
99
|
+
UUID_COMMAND,
|
|
100
|
+
bytes.fromhex(NEGOTIATION_COMMAND_0),
|
|
101
|
+
response=True,
|
|
102
|
+
)
|
|
103
|
+
|
|
97
104
|
async def connect(self, max_attempts: int = 3, run_callbacks: bool = True) -> bool:
|
|
98
105
|
"""Connect to device.
|
|
99
106
|
|
|
@@ -108,12 +115,8 @@ class SolixBLEDevice:
|
|
|
108
115
|
try:
|
|
109
116
|
|
|
110
117
|
# If we have an old client get rid of it
|
|
111
|
-
if self._client is not None
|
|
112
|
-
|
|
113
|
-
f"Disposing of old client '{self._client}' in order to connect to '{self.name}'!"
|
|
114
|
-
)
|
|
115
|
-
await self._client.disconnect()
|
|
116
|
-
self._client = None
|
|
118
|
+
if self._client is not None:
|
|
119
|
+
await self._dispose_of_client()
|
|
117
120
|
|
|
118
121
|
# Reset negotiated details but keep any data
|
|
119
122
|
self._reset_session(reset_data=False)
|
|
@@ -170,11 +173,7 @@ class SolixBLEDevice:
|
|
|
170
173
|
_LOGGER.debug(
|
|
171
174
|
f"Sending negotiation initiation request to '{self.name}'..."
|
|
172
175
|
)
|
|
173
|
-
await self.
|
|
174
|
-
UUID_COMMAND,
|
|
175
|
-
bytes.fromhex(NEGOTIATION_COMMAND_0),
|
|
176
|
-
response=True,
|
|
177
|
-
)
|
|
176
|
+
await self._initiate_negotiations()
|
|
178
177
|
|
|
179
178
|
# Wait at this long to see if we get any response to
|
|
180
179
|
# our initial request in stage 0. This weird layout
|
|
@@ -218,14 +217,14 @@ class SolixBLEDevice:
|
|
|
218
217
|
if self._auto_reconnect_task is not None:
|
|
219
218
|
self._auto_reconnect_task.cancel()
|
|
220
219
|
|
|
220
|
+
# If there is a client disconnect and throw it away
|
|
221
|
+
if self._client is not None:
|
|
222
|
+
await self._dispose_of_client()
|
|
223
|
+
|
|
224
|
+
# Reset session
|
|
221
225
|
self._connection_attempts = 0
|
|
222
226
|
self._reset_session()
|
|
223
227
|
|
|
224
|
-
# If there is a client disconnect and throw it away
|
|
225
|
-
if self._client:
|
|
226
|
-
await self._client.disconnect()
|
|
227
|
-
self._client = None
|
|
228
|
-
|
|
229
228
|
@property
|
|
230
229
|
def connected(self) -> bool:
|
|
231
230
|
"""Connected to device.
|
|
@@ -247,12 +246,7 @@ class SolixBLEDevice:
|
|
|
247
246
|
|
|
248
247
|
:returns: True/False if session has been negotiated and connected.
|
|
249
248
|
"""
|
|
250
|
-
return
|
|
251
|
-
self.connected
|
|
252
|
-
and self._shared_key is not None
|
|
253
|
-
and self._iv is not None
|
|
254
|
-
and self._negotiation_timestamp is not None
|
|
255
|
-
)
|
|
249
|
+
return self.connected and self._shared_secret is not None
|
|
256
250
|
|
|
257
251
|
@property
|
|
258
252
|
def available(self) -> bool:
|
|
@@ -353,58 +347,93 @@ class SolixBLEDevice:
|
|
|
353
347
|
# Extract command
|
|
354
348
|
packet_cmd = bytes([packet_copy.pop(0), packet_copy.pop(0)])
|
|
355
349
|
|
|
356
|
-
# Telemetry packets have an extra field which must be popped
|
|
357
|
-
if packet_pattern.hex() == "03010f" and packet_cmd.hex() == "c402":
|
|
358
|
-
special_value = bytes([packet_copy.pop(0)])
|
|
359
|
-
_LOGGER.debug(f"Special value: {special_value.hex()}")
|
|
360
|
-
|
|
361
350
|
# Extract payload
|
|
362
351
|
packet_payload = bytes(packet_copy)
|
|
363
352
|
|
|
364
353
|
return packet_pattern, packet_cmd, packet_payload
|
|
365
354
|
|
|
366
|
-
def _parse_payload(self, payload: bytearray) -> dict[str, bytes]:
|
|
367
|
-
"""
|
|
355
|
+
def _parse_payload(self, payload: bytearray | bytes) -> dict[str, bytes]:
|
|
356
|
+
"""
|
|
357
|
+
Parse payload bytes into parameters.
|
|
358
|
+
|
|
359
|
+
Payloads contain a list of parameters and these parameters
|
|
360
|
+
have a format of: <id 1B> <len 1-2B> <type 1B> <data nB>.
|
|
361
|
+
|
|
362
|
+
If an error occurs when decoding a parameter it prevents all
|
|
363
|
+
further parameters from being parsed and logs an exception,
|
|
364
|
+
but the successfully parsed parameters (if any) will be returned.
|
|
365
|
+
|
|
366
|
+
:param payload: Payload to parse into parameters.
|
|
367
|
+
:returns: Dictionary mapping parameter ids (a1, a2, ...) to data.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
def _verbose_pop(data: bytearray, length: int, name: str) -> bytes:
|
|
371
|
+
"""
|
|
372
|
+
Pop specified number of bytes from bytearray and log if error.
|
|
373
|
+
|
|
374
|
+
:param data: Data to be popped.
|
|
375
|
+
:param length: Number of bytes to pop and return.
|
|
376
|
+
:param name: Name of value being popped to put in logs if error.
|
|
377
|
+
:raises IndexError: If popping fails.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
# Copy of bytes to use in error message if needed
|
|
381
|
+
data_copy = bytes(data)
|
|
382
|
+
|
|
383
|
+
# Bytes extracted so far
|
|
384
|
+
new_bytes = bytes([])
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
# Pop length bytes from data and return
|
|
388
|
+
for _ in range(length):
|
|
389
|
+
new_bytes = new_bytes + bytes([data.pop(0)])
|
|
390
|
+
return new_bytes
|
|
391
|
+
|
|
392
|
+
# Build error message
|
|
393
|
+
except IndexError as e:
|
|
394
|
+
message = (
|
|
395
|
+
f"Error extracting {name} (len={length}) from '{data_copy.hex()}'"
|
|
396
|
+
f" (len={len(data_copy)}) at index {len(new_bytes)}. We extracted:"
|
|
397
|
+
f" '{new_bytes.hex()}' but expected {length - len(data_copy)}"
|
|
398
|
+
f" more bytes!"
|
|
399
|
+
)
|
|
400
|
+
_LOGGER.exception(message)
|
|
401
|
+
raise IndexError(message) from e
|
|
368
402
|
|
|
369
403
|
parsed_data: dict[str, bytes] = {}
|
|
370
404
|
remaining_data = bytearray(payload)
|
|
371
405
|
|
|
372
|
-
#
|
|
406
|
+
# Payloads sometimes start with 00 and we must strip that
|
|
373
407
|
if remaining_data.startswith(bytes.fromhex("00")):
|
|
374
|
-
|
|
408
|
+
_LOGGER.debug("Stripped 00 from start of payload")
|
|
409
|
+
_verbose_pop(remaining_data, 1, "special 00 header")
|
|
375
410
|
|
|
376
411
|
while len(remaining_data) != 0:
|
|
377
412
|
try:
|
|
378
413
|
# Extract param id (e.g a1, a2, ...)
|
|
379
|
-
param_id =
|
|
414
|
+
param_id = _verbose_pop(remaining_data, 1, "param_id").hex()
|
|
380
415
|
|
|
381
416
|
# Sometimes there is just a param_id with no length or values
|
|
382
|
-
|
|
383
|
-
# the optional stage 6 negotiation stage that only sometimes
|
|
384
|
-
# seems to happen with the C300X (~ 1/20 chance).
|
|
385
|
-
#
|
|
386
|
-
# If we have reached PKCS7 padding then we have
|
|
387
|
-
# reached the end of the payload
|
|
388
|
-
if len(remaining_data) < 16 and remaining_data == bytearray(
|
|
389
|
-
len(remaining_data) * len(remaining_data).to_bytes(1)
|
|
390
|
-
):
|
|
417
|
+
if len(remaining_data) == 0:
|
|
391
418
|
parsed_data[param_id] = bytes()
|
|
392
419
|
break
|
|
393
420
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
421
|
+
# Extract encoded length of parameter
|
|
422
|
+
param_len = int.from_bytes(
|
|
423
|
+
_verbose_pop(remaining_data, 1, f"param_len (id={param_id})")
|
|
424
|
+
)
|
|
397
425
|
|
|
398
|
-
#
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
break
|
|
426
|
+
# Extract data/body from parameter
|
|
427
|
+
param_data = _verbose_pop(
|
|
428
|
+
remaining_data, param_len, f"param_data (id={param_id})"
|
|
429
|
+
)
|
|
430
|
+
parsed_data[param_id] = param_data
|
|
404
431
|
|
|
405
432
|
except IndexError:
|
|
406
433
|
_LOGGER.exception(
|
|
407
|
-
f"Unexpected end of packet! Data may be missing or invalid!
|
|
434
|
+
f"Unexpected end of packet! Data may be missing or invalid!"
|
|
435
|
+
f" Extracted so far: '{self._parameters_to_str(parsed_data)}'."
|
|
436
|
+
f" Payload: '{payload.hex()}'"
|
|
408
437
|
)
|
|
409
438
|
|
|
410
439
|
return parsed_data
|
|
@@ -444,12 +473,80 @@ class SolixBLEDevice:
|
|
|
444
473
|
|
|
445
474
|
def _decrypt_payload(self, payload: bytes) -> bytes:
|
|
446
475
|
"""Decrypt telemetry packet using negotiated shared secret and IV."""
|
|
447
|
-
cipher = AES.new(
|
|
448
|
-
|
|
476
|
+
cipher = AES.new(
|
|
477
|
+
self._shared_secret[:16], AES.MODE_CBC, iv=self._shared_secret[16:]
|
|
478
|
+
)
|
|
479
|
+
decrypted = cipher.decrypt(payload)
|
|
480
|
+
unpadder = PKCS7(128).unpadder()
|
|
481
|
+
unpadded_data = unpadder.update(decrypted)
|
|
482
|
+
return unpadded_data + unpadder.finalize()
|
|
449
483
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
484
|
+
def _encrypt_payload(self, payload: bytes) -> bytes:
|
|
485
|
+
"""Encrypt telemetry packet using negotiated shared secret and IV."""
|
|
486
|
+
|
|
487
|
+
# Pad and encrypt payload
|
|
488
|
+
padder = PKCS7(128).padder()
|
|
489
|
+
padded_data = padder.update(payload)
|
|
490
|
+
padded_data += padder.finalize()
|
|
491
|
+
cipher = AES.new(
|
|
492
|
+
self._shared_secret[:16], AES.MODE_CBC, iv=self._shared_secret[16:]
|
|
493
|
+
)
|
|
494
|
+
return cipher.encrypt(padded_data)
|
|
495
|
+
|
|
496
|
+
async def _process_telemetry_packet(self, payload: bytes, cmd: bytes = None) -> None:
|
|
497
|
+
"""Process a telemetry packet from the device.
|
|
498
|
+
|
|
499
|
+
This performs the default processing of telemetry packets in which
|
|
500
|
+
telemetry payloads are spread across multiple packets. This is
|
|
501
|
+
overridden for devices which do not use multi-packet payloads for
|
|
502
|
+
telemetry.
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
# First byte encodes fragment info (high nibble = index, low = total)
|
|
506
|
+
fragment_index = (payload[0] >> 4) & 0x0F
|
|
507
|
+
fragment_total = payload[0] & 0x0F
|
|
508
|
+
|
|
509
|
+
# Multi-part message
|
|
510
|
+
if fragment_total > 1:
|
|
511
|
+
fragment_data = payload[1:]
|
|
512
|
+
cmd_key = bytes(cmd)
|
|
513
|
+
_LOGGER.debug(
|
|
514
|
+
f"Fragment {fragment_index}/{fragment_total} for cmd {cmd.hex()}, {len(fragment_data)} bytes"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Store fragment
|
|
518
|
+
if cmd_key not in self._fragment_buffers or fragment_index == 1:
|
|
519
|
+
self._fragment_buffers[cmd_key] = {}
|
|
520
|
+
self._fragment_totals[cmd_key] = fragment_total
|
|
521
|
+
|
|
522
|
+
self._fragment_buffers[cmd_key][fragment_index] = fragment_data
|
|
523
|
+
|
|
524
|
+
# Wait until all fragments have arrived
|
|
525
|
+
if len(self._fragment_buffers[cmd_key]) < fragment_total:
|
|
526
|
+
_LOGGER.debug("Waiting for remaining fragments...")
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
# Reassemble in order
|
|
530
|
+
payload = b"".join(
|
|
531
|
+
self._fragment_buffers[cmd_key][i]
|
|
532
|
+
for i in sorted(self._fragment_buffers[cmd_key])
|
|
533
|
+
)
|
|
534
|
+
del self._fragment_buffers[cmd_key]
|
|
535
|
+
del self._fragment_totals[cmd_key]
|
|
536
|
+
_LOGGER.debug(
|
|
537
|
+
f"Reassembled payload: {len(payload)} bytes"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
else:
|
|
541
|
+
# Strip fragment info
|
|
542
|
+
payload = payload[1:]
|
|
543
|
+
|
|
544
|
+
decrypted_payload = self._decrypt_payload(payload)
|
|
545
|
+
_LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}")
|
|
546
|
+
parameters = self._parse_payload(decrypted_payload)
|
|
547
|
+
return await self._process_telemetry(parameters)
|
|
548
|
+
|
|
549
|
+
async def _process_telemetry(self, parameters: dict[str, bytes]) -> None:
|
|
453
550
|
"""Process telemetry data from the device."""
|
|
454
551
|
|
|
455
552
|
state_changed = self._data is None or parameters != self._data
|
|
@@ -488,7 +585,7 @@ class SolixBLEDevice:
|
|
|
488
585
|
) -> None:
|
|
489
586
|
"""Process a notification from the device."""
|
|
490
587
|
|
|
491
|
-
_LOGGER.debug(f"The client the notification is from
|
|
588
|
+
_LOGGER.debug(f"The client the notification is from: {client}")
|
|
492
589
|
|
|
493
590
|
if self._client is not client:
|
|
494
591
|
_LOGGER.debug("Ignoring notification from old client")
|
|
@@ -524,63 +621,31 @@ class SolixBLEDevice:
|
|
|
524
621
|
return await self._process_negotiation(cmd, payload)
|
|
525
622
|
|
|
526
623
|
# Encrypted messages
|
|
527
|
-
case "03010f":
|
|
624
|
+
case "03010f" | "030111":
|
|
528
625
|
|
|
529
626
|
match cmd.hex():
|
|
530
627
|
|
|
531
628
|
# Telemetry messages
|
|
532
|
-
case "c402":
|
|
629
|
+
case "c402" | "4300" | "c405":
|
|
533
630
|
_LOGGER.debug("Received telemetry message!")
|
|
534
|
-
|
|
535
|
-
# Anker devices seem to split data across multiple
|
|
536
|
-
# packets so we need to wait until we have both
|
|
537
|
-
# packets before we can decrypt all of the data
|
|
538
|
-
if len(payload) < 50:
|
|
539
|
-
self._telemetry_payload_small = payload
|
|
540
|
-
|
|
541
|
-
# If we receive a big packet it invalidates the
|
|
542
|
-
# last small one since the big one comes before
|
|
543
|
-
# the small one
|
|
544
|
-
elif len(payload) > 230:
|
|
545
|
-
self._telemetry_payload_large = payload
|
|
546
|
-
self._telemetry_payload_small = None
|
|
547
|
-
|
|
548
|
-
else:
|
|
549
|
-
_LOGGER.warning(
|
|
550
|
-
f"Telemetry payload has an unexpected length of {len(payload)}!"
|
|
551
|
-
)
|
|
552
|
-
|
|
553
|
-
if (
|
|
554
|
-
self._telemetry_payload_small is None
|
|
555
|
-
or self._telemetry_payload_large is None
|
|
556
|
-
):
|
|
557
|
-
_LOGGER.debug("Missing other payload!")
|
|
558
|
-
return
|
|
559
|
-
|
|
560
|
-
new_payload = (
|
|
561
|
-
self._telemetry_payload_large
|
|
562
|
-
+ self._telemetry_payload_small
|
|
563
|
-
)
|
|
564
|
-
|
|
565
|
-
# If we are accepting the new payload we invalidate
|
|
566
|
-
# the partial payloads
|
|
567
|
-
self._telemetry_payload_large = None
|
|
568
|
-
self._telemetry_payload_small = None
|
|
569
|
-
|
|
570
|
-
_LOGGER.debug(f"Merged payload: {new_payload.hex()}")
|
|
571
|
-
decrypted_payload = self._decrypt_payload(new_payload)
|
|
572
|
-
_LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}")
|
|
573
|
-
parameters = self._parse_payload(decrypted_payload)
|
|
574
|
-
return await self._process_telemetry(cmd, parameters)
|
|
631
|
+
return await self._process_telemetry_packet(payload, cmd)
|
|
575
632
|
|
|
576
633
|
# Unknown messages
|
|
577
634
|
case _:
|
|
578
635
|
_LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
|
|
579
636
|
try:
|
|
580
637
|
|
|
581
|
-
# If the payload is one byte too short
|
|
638
|
+
# If the payload is one byte too short and we are
|
|
639
|
+
# using the default AES (CBC) then try putting the
|
|
582
640
|
# last byte of the cmd in front of it
|
|
583
|
-
if
|
|
641
|
+
if (
|
|
642
|
+
len(payload) % 16 == 15
|
|
643
|
+
and self._decrypt_payload
|
|
644
|
+
is SolixBLEDevice._decrypt_payload
|
|
645
|
+
):
|
|
646
|
+
_LOGGER.debug(
|
|
647
|
+
"Using special trick of embedded part of CMD in payload..."
|
|
648
|
+
)
|
|
584
649
|
payload = cmd[1].to_bytes() + payload
|
|
585
650
|
|
|
586
651
|
decrypted_payload = self._decrypt_payload(payload)
|
|
@@ -682,12 +747,8 @@ class SolixBLEDevice:
|
|
|
682
747
|
bytes.fromhex(PRIVATE_KEY), byteorder="big"
|
|
683
748
|
)
|
|
684
749
|
private_key = derive_private_key(private_value, SECP256R1())
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
self._iv = shared_secret[16:]
|
|
688
|
-
_LOGGER.debug(f"Shared secret: {shared_secret.hex()}")
|
|
689
|
-
_LOGGER.debug(f"AES key: {self._shared_key.hex()}")
|
|
690
|
-
_LOGGER.debug(f"AES IV: {self._iv.hex()}")
|
|
750
|
+
self._shared_secret = private_key.exchange(ECDH(), device_public_key)
|
|
751
|
+
_LOGGER.debug(f"Shared secret: {self._shared_secret.hex()}")
|
|
691
752
|
|
|
692
753
|
_LOGGER.debug("Sending stage 5 response message...")
|
|
693
754
|
return await self._client.write_gatt_char(
|
|
@@ -740,34 +801,34 @@ class SolixBLEDevice:
|
|
|
740
801
|
new_payload = payload + bytes.fromhex("fe0503") + new_timestamp
|
|
741
802
|
await self._send_encrypted_packet(cmd, new_payload)
|
|
742
803
|
|
|
743
|
-
|
|
744
|
-
"""
|
|
745
|
-
|
|
746
|
-
f"Building packet with cmd: {cmd.hex()} and payload: {payload.hex()}"
|
|
747
|
-
)
|
|
804
|
+
def _build_packet(self, pattern: bytes, cmd: bytes, payload: bytes) -> bytes:
|
|
805
|
+
"""
|
|
806
|
+
Build a packet to be send to a device.
|
|
748
807
|
|
|
749
|
-
|
|
750
|
-
padder = PKCS7(128).padder()
|
|
751
|
-
padded_data = padder.update(payload)
|
|
752
|
-
padded_data += padder.finalize()
|
|
808
|
+
Packet format: <HEADER 2B> <LENGTH 2B> <PATTERN 3B> <CMD 2B> <PAYLOAD bB> <CHECKSUM 1B>.
|
|
753
809
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
810
|
+
:param pattern: Pattern of packet (e.g encrypted, negotiation, etc).
|
|
811
|
+
:param cmd: Command in packet (e.g telemetry, power on, etc).
|
|
812
|
+
:param payload: Payload of command (e.g a1...).
|
|
813
|
+
:returns: Packet bytes ready to be sent.
|
|
814
|
+
"""
|
|
757
815
|
|
|
758
816
|
# Calculate length of message
|
|
759
|
-
length = 2 + 2 + 3 + 2 + len(
|
|
817
|
+
length = 2 + 2 + 3 + 2 + len(payload) + 1
|
|
760
818
|
length_bytes = length.to_bytes(length=2, byteorder="little")
|
|
761
819
|
|
|
762
820
|
# Build packet
|
|
763
|
-
packet = (
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
821
|
+
packet = bytes.fromhex("ff09") + length_bytes + pattern + cmd + payload
|
|
822
|
+
return packet + self._checksum(packet)
|
|
823
|
+
|
|
824
|
+
async def _send_encrypted_packet(self, cmd: bytes, payload: bytes) -> None:
|
|
825
|
+
"""Send an encrypted packet using negotiated shared secret and IV."""
|
|
826
|
+
_LOGGER.debug(
|
|
827
|
+
f"Building packet with cmd: {cmd.hex()} and payload: {payload.hex()}"
|
|
769
828
|
)
|
|
770
|
-
|
|
829
|
+
encrypted_payload = self._encrypt_payload(payload)
|
|
830
|
+
|
|
831
|
+
packet = self._build_packet(bytes.fromhex("03000f"), cmd, encrypted_payload)
|
|
771
832
|
_LOGGER.debug(f"Sending encrypted packet: {packet.hex()}")
|
|
772
833
|
|
|
773
834
|
# Send packet
|
|
@@ -963,17 +1024,27 @@ class SolixBLEDevice:
|
|
|
963
1024
|
# Trigger disconnection event
|
|
964
1025
|
self._disconnect_event.set()
|
|
965
1026
|
|
|
966
|
-
def
|
|
1027
|
+
async def _dispose_of_client(self) -> None:
|
|
1028
|
+
"""Dispose of current bleak client."""
|
|
1029
|
+
client = self._client
|
|
1030
|
+
self._client = None
|
|
1031
|
+
try:
|
|
1032
|
+
await client.disconnect()
|
|
1033
|
+
except Exception:
|
|
1034
|
+
_LOGGER.exception(
|
|
1035
|
+
f"Exception raised when disposing of bleak client '{client}'!"
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
def _reset_session(self, reset_data: bool = True) -> None:
|
|
967
1039
|
"""Reset negotiated variables and data and futures."""
|
|
968
1040
|
|
|
969
1041
|
if reset_data:
|
|
970
1042
|
self._data = None
|
|
971
1043
|
self._last_data_timestamp = None
|
|
972
1044
|
|
|
973
|
-
self.
|
|
974
|
-
self.
|
|
975
|
-
self.
|
|
976
|
-
self._iv = None
|
|
1045
|
+
self._fragment_buffers = {}
|
|
1046
|
+
self._fragment_totals = {}
|
|
1047
|
+
self._shared_secret = None
|
|
977
1048
|
self._last_packet_timestamp = None
|
|
978
1049
|
self._negotiation_timestamp = None
|
|
979
1050
|
self._packet_futures: dict[bytes, list[asyncio.Future]] = {}
|
|
@@ -12,6 +12,7 @@ from .c1000g2 import C1000G2
|
|
|
12
12
|
from .f2000 import F2000
|
|
13
13
|
from .f3800 import F3800
|
|
14
14
|
from .generic import Generic
|
|
15
|
+
from .prime_charger_160w import PrimeCharger160w
|
|
15
16
|
from .prime_charger_250w import PrimeCharger250w
|
|
16
17
|
from .solarbank2 import Solarbank2
|
|
17
18
|
from .solarbank3 import Solarbank3
|
|
@@ -26,6 +27,7 @@ __all__ = [
|
|
|
26
27
|
"F3800",
|
|
27
28
|
"Solarbank2",
|
|
28
29
|
"Solarbank3",
|
|
30
|
+
"PrimeCharger160w",
|
|
29
31
|
"PrimeCharger250w",
|
|
30
32
|
"Generic",
|
|
31
33
|
]
|