SolixBLE 3.4.0__tar.gz → 3.6.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.4.0 → solixble-3.6.0}/PKG-INFO +2 -1
- {solixble-3.4.0 → solixble-3.6.0}/README.md +1 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/__init__.py +8 -2
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/const.py +1 -1
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/device.py +197 -127
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/__init__.py +2 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/c300dc.py +135 -34
- solixble-3.6.0/SolixBLE/devices/prime_charger_160w.py +261 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/prime_charger_250w.py +20 -20
- solixble-3.6.0/SolixBLE/prime_device.py +522 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/states.py +32 -21
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/utilities.py +9 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE.egg-info/PKG-INFO +2 -1
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE.egg-info/SOURCES.txt +4 -1
- {solixble-3.4.0 → solixble-3.6.0}/pyproject.toml +1 -1
- solixble-3.6.0/tests/test_connection.py +255 -0
- {solixble-3.4.0 → solixble-3.6.0}/tests/test_devices.py +325 -129
- solixble-3.6.0/tests/test_prime.py +74 -0
- solixble-3.4.0/tests/test_reconnect.py +0 -155
- {solixble-3.4.0 → solixble-3.6.0}/LICENSE.txt +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/c1000.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/c1000g2.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/c300.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/c800.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/f2000.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/f3800.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/generic.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/solarbank2.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE/devices/solarbank3.py +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE.egg-info/requires.txt +0 -0
- {solixble-3.4.0 → solixble-3.6.0}/SolixBLE.egg-info/top_level.txt +0 -0
- {solixble-3.4.0 → solixble-3.6.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.6.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,22 +14,26 @@ 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
|
-
ChargingStatusC300DC,
|
|
24
25
|
ChargingStatusF3800,
|
|
25
26
|
DisplayTimeout,
|
|
26
27
|
LightStatus,
|
|
27
28
|
PortStatus,
|
|
29
|
+
TemperatureUnit,
|
|
30
|
+
PortOverload,
|
|
28
31
|
)
|
|
29
32
|
from .utilities import discover_devices
|
|
30
33
|
|
|
31
34
|
__all__ = [
|
|
32
35
|
"SolixBLEDevice",
|
|
36
|
+
"PrimeDevice",
|
|
33
37
|
"C300",
|
|
34
38
|
"C300DC",
|
|
35
39
|
"C800",
|
|
@@ -39,13 +43,15 @@ __all__ = [
|
|
|
39
43
|
"F3800",
|
|
40
44
|
"Solarbank2",
|
|
41
45
|
"Solarbank3",
|
|
46
|
+
"PrimeCharger160w",
|
|
42
47
|
"PrimeCharger250w",
|
|
43
48
|
"Generic",
|
|
44
49
|
"ChargingStatus",
|
|
45
|
-
"ChargingStatusC300DC",
|
|
46
50
|
"ChargingStatusF3800",
|
|
47
51
|
"DisplayTimeout",
|
|
48
52
|
"LightStatus",
|
|
49
53
|
"PortStatus",
|
|
54
|
+
"TemperatureUnit",
|
|
55
|
+
"PortOverload",
|
|
50
56
|
"discover_devices",
|
|
51
57
|
]
|
|
@@ -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.
|
|
@@ -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:
|
|
@@ -363,48 +357,88 @@ class SolixBLEDevice:
|
|
|
363
357
|
|
|
364
358
|
return packet_pattern, packet_cmd, packet_payload
|
|
365
359
|
|
|
366
|
-
def _parse_payload(self, payload: bytearray) -> dict[str, bytes]:
|
|
367
|
-
"""
|
|
360
|
+
def _parse_payload(self, payload: bytearray | bytes) -> dict[str, bytes]:
|
|
361
|
+
"""
|
|
362
|
+
Parse payload bytes into parameters.
|
|
363
|
+
|
|
364
|
+
Payloads contain a list of parameters and these parameters
|
|
365
|
+
have a format of: <id 1B> <len 1-2B> <type 1B> <data nB>.
|
|
366
|
+
|
|
367
|
+
If an error occurs when decoding a parameter it prevents all
|
|
368
|
+
further parameters from being parsed and logs an exception,
|
|
369
|
+
but the successfully parsed parameters (if any) will be returned.
|
|
370
|
+
|
|
371
|
+
:param payload: Payload to parse into parameters.
|
|
372
|
+
:returns: Dictionary mapping parameter ids (a1, a2, ...) to data.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
def _verbose_pop(data: bytearray, length: int, name: str) -> bytes:
|
|
376
|
+
"""
|
|
377
|
+
Pop specified number of bytes from bytearray and log if error.
|
|
378
|
+
|
|
379
|
+
:param data: Data to be popped.
|
|
380
|
+
:param length: Number of bytes to pop and return.
|
|
381
|
+
:param name: Name of value being popped to put in logs if error.
|
|
382
|
+
:raises IndexError: If popping fails.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
# Copy of bytes to use in error message if needed
|
|
386
|
+
data_copy = bytes(data)
|
|
387
|
+
|
|
388
|
+
# Bytes extracted so far
|
|
389
|
+
new_bytes = bytes([])
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
# Pop length bytes from data and return
|
|
393
|
+
for _ in range(length):
|
|
394
|
+
new_bytes = new_bytes + bytes([data.pop(0)])
|
|
395
|
+
return new_bytes
|
|
396
|
+
|
|
397
|
+
# Build error message
|
|
398
|
+
except IndexError as e:
|
|
399
|
+
message = (
|
|
400
|
+
f"Error extracting {name} (len={length}) from '{data_copy.hex()}'"
|
|
401
|
+
f" (len={len(data_copy)}) at index {len(new_bytes)}. We extracted:"
|
|
402
|
+
f" '{new_bytes.hex()}' but expected {length - len(data_copy)}"
|
|
403
|
+
f" more bytes!"
|
|
404
|
+
)
|
|
405
|
+
_LOGGER.exception(message)
|
|
406
|
+
raise IndexError(message) from e
|
|
368
407
|
|
|
369
408
|
parsed_data: dict[str, bytes] = {}
|
|
370
409
|
remaining_data = bytearray(payload)
|
|
371
410
|
|
|
372
|
-
#
|
|
411
|
+
# Payloads sometimes start with 00 and we must strip that
|
|
373
412
|
if remaining_data.startswith(bytes.fromhex("00")):
|
|
374
|
-
|
|
413
|
+
_LOGGER.debug("Stripped 00 from start of payload")
|
|
414
|
+
_verbose_pop(remaining_data, 1, "special 00 header")
|
|
375
415
|
|
|
376
416
|
while len(remaining_data) != 0:
|
|
377
417
|
try:
|
|
378
418
|
# Extract param id (e.g a1, a2, ...)
|
|
379
|
-
param_id =
|
|
419
|
+
param_id = _verbose_pop(remaining_data, 1, "param_id").hex()
|
|
380
420
|
|
|
381
421
|
# 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
|
-
):
|
|
422
|
+
if len(remaining_data) == 0:
|
|
391
423
|
parsed_data[param_id] = bytes()
|
|
392
424
|
break
|
|
393
425
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
426
|
+
# Extract encoded length of parameter
|
|
427
|
+
param_len = int.from_bytes(
|
|
428
|
+
_verbose_pop(remaining_data, 1, f"param_len (id={param_id})")
|
|
429
|
+
)
|
|
397
430
|
|
|
398
|
-
#
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
break
|
|
431
|
+
# Extract data/body from parameter
|
|
432
|
+
param_data = _verbose_pop(
|
|
433
|
+
remaining_data, param_len, f"param_data (id={param_id})"
|
|
434
|
+
)
|
|
435
|
+
parsed_data[param_id] = param_data
|
|
404
436
|
|
|
405
437
|
except IndexError:
|
|
406
438
|
_LOGGER.exception(
|
|
407
|
-
f"Unexpected end of packet! Data may be missing or invalid!
|
|
439
|
+
f"Unexpected end of packet! Data may be missing or invalid!"
|
|
440
|
+
f" Extracted so far: '{self._parameters_to_str(parsed_data)}'."
|
|
441
|
+
f" Payload: '{payload.hex()}'"
|
|
408
442
|
)
|
|
409
443
|
|
|
410
444
|
return parsed_data
|
|
@@ -444,12 +478,74 @@ class SolixBLEDevice:
|
|
|
444
478
|
|
|
445
479
|
def _decrypt_payload(self, payload: bytes) -> bytes:
|
|
446
480
|
"""Decrypt telemetry packet using negotiated shared secret and IV."""
|
|
447
|
-
cipher = AES.new(
|
|
448
|
-
|
|
481
|
+
cipher = AES.new(
|
|
482
|
+
self._shared_secret[:16], AES.MODE_CBC, iv=self._shared_secret[16:]
|
|
483
|
+
)
|
|
484
|
+
decrypted = cipher.decrypt(payload)
|
|
485
|
+
unpadder = PKCS7(128).unpadder()
|
|
486
|
+
unpadded_data = unpadder.update(decrypted)
|
|
487
|
+
return unpadded_data + unpadder.finalize()
|
|
449
488
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
489
|
+
def _encrypt_payload(self, payload: bytes) -> bytes:
|
|
490
|
+
"""Encrypt telemetry packet using negotiated shared secret and IV."""
|
|
491
|
+
|
|
492
|
+
# Pad and encrypt payload
|
|
493
|
+
padder = PKCS7(128).padder()
|
|
494
|
+
padded_data = padder.update(payload)
|
|
495
|
+
padded_data += padder.finalize()
|
|
496
|
+
cipher = AES.new(
|
|
497
|
+
self._shared_secret[:16], AES.MODE_CBC, iv=self._shared_secret[16:]
|
|
498
|
+
)
|
|
499
|
+
return cipher.encrypt(padded_data)
|
|
500
|
+
|
|
501
|
+
async def _process_telemetry_packet(self, payload: bytes) -> None:
|
|
502
|
+
"""Process a telemetry packet from the device.
|
|
503
|
+
|
|
504
|
+
This performs the default processing of telemetry packets in which
|
|
505
|
+
telemetry payloads are spread across multiple packets. This is
|
|
506
|
+
overridden for devices which do not use multi-packet payloads for
|
|
507
|
+
telemetry.
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
# Anker devices seem to split data across multiple
|
|
511
|
+
# packets so we need to wait until we have both
|
|
512
|
+
# packets before we can decrypt all of the data
|
|
513
|
+
if len(payload) < 50:
|
|
514
|
+
self._telemetry_payload_small = payload
|
|
515
|
+
|
|
516
|
+
# If we receive a big packet it invalidates the
|
|
517
|
+
# last small one since the big one comes before
|
|
518
|
+
# the small one
|
|
519
|
+
elif len(payload) > 230:
|
|
520
|
+
self._telemetry_payload_large = payload
|
|
521
|
+
self._telemetry_payload_small = None
|
|
522
|
+
|
|
523
|
+
else:
|
|
524
|
+
_LOGGER.warning(
|
|
525
|
+
f"Telemetry payload has an unexpected length of {len(payload)}!"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
if (
|
|
529
|
+
self._telemetry_payload_small is None
|
|
530
|
+
or self._telemetry_payload_large is None
|
|
531
|
+
):
|
|
532
|
+
_LOGGER.debug("Missing other payload!")
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
new_payload = self._telemetry_payload_large + self._telemetry_payload_small
|
|
536
|
+
|
|
537
|
+
# If we are accepting the new payload we invalidate
|
|
538
|
+
# the partial payloads
|
|
539
|
+
self._telemetry_payload_large = None
|
|
540
|
+
self._telemetry_payload_small = None
|
|
541
|
+
|
|
542
|
+
_LOGGER.debug(f"Merged payload: {new_payload.hex()}")
|
|
543
|
+
decrypted_payload = self._decrypt_payload(new_payload)
|
|
544
|
+
_LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}")
|
|
545
|
+
parameters = self._parse_payload(decrypted_payload)
|
|
546
|
+
return await self._process_telemetry(parameters)
|
|
547
|
+
|
|
548
|
+
async def _process_telemetry(self, parameters: dict[str, bytes]) -> None:
|
|
453
549
|
"""Process telemetry data from the device."""
|
|
454
550
|
|
|
455
551
|
state_changed = self._data is None or parameters != self._data
|
|
@@ -524,63 +620,31 @@ class SolixBLEDevice:
|
|
|
524
620
|
return await self._process_negotiation(cmd, payload)
|
|
525
621
|
|
|
526
622
|
# Encrypted messages
|
|
527
|
-
case "03010f":
|
|
623
|
+
case "03010f" | "030111":
|
|
528
624
|
|
|
529
625
|
match cmd.hex():
|
|
530
626
|
|
|
531
627
|
# Telemetry messages
|
|
532
|
-
case "c402":
|
|
628
|
+
case "c402" | "4300":
|
|
533
629
|
_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)
|
|
630
|
+
return await self._process_telemetry_packet(payload)
|
|
575
631
|
|
|
576
632
|
# Unknown messages
|
|
577
633
|
case _:
|
|
578
634
|
_LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
|
|
579
635
|
try:
|
|
580
636
|
|
|
581
|
-
# If the payload is one byte too short
|
|
637
|
+
# If the payload is one byte too short and we are
|
|
638
|
+
# using the default AES (CBC) then try putting the
|
|
582
639
|
# last byte of the cmd in front of it
|
|
583
|
-
if
|
|
640
|
+
if (
|
|
641
|
+
len(payload) % 16 == 15
|
|
642
|
+
and self._decrypt_payload
|
|
643
|
+
is SolixBLEDevice._decrypt_payload
|
|
644
|
+
):
|
|
645
|
+
_LOGGER.debug(
|
|
646
|
+
"Using special trick of embedded part of CMD in payload..."
|
|
647
|
+
)
|
|
584
648
|
payload = cmd[1].to_bytes() + payload
|
|
585
649
|
|
|
586
650
|
decrypted_payload = self._decrypt_payload(payload)
|
|
@@ -682,12 +746,8 @@ class SolixBLEDevice:
|
|
|
682
746
|
bytes.fromhex(PRIVATE_KEY), byteorder="big"
|
|
683
747
|
)
|
|
684
748
|
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()}")
|
|
749
|
+
self._shared_secret = private_key.exchange(ECDH(), device_public_key)
|
|
750
|
+
_LOGGER.debug(f"Shared secret: {self._shared_secret.hex()}")
|
|
691
751
|
|
|
692
752
|
_LOGGER.debug("Sending stage 5 response message...")
|
|
693
753
|
return await self._client.write_gatt_char(
|
|
@@ -740,34 +800,34 @@ class SolixBLEDevice:
|
|
|
740
800
|
new_payload = payload + bytes.fromhex("fe0503") + new_timestamp
|
|
741
801
|
await self._send_encrypted_packet(cmd, new_payload)
|
|
742
802
|
|
|
743
|
-
|
|
744
|
-
"""
|
|
745
|
-
|
|
746
|
-
f"Building packet with cmd: {cmd.hex()} and payload: {payload.hex()}"
|
|
747
|
-
)
|
|
803
|
+
def _build_packet(self, pattern: bytes, cmd: bytes, payload: bytes) -> bytes:
|
|
804
|
+
"""
|
|
805
|
+
Build a packet to be send to a device.
|
|
748
806
|
|
|
749
|
-
|
|
750
|
-
padder = PKCS7(128).padder()
|
|
751
|
-
padded_data = padder.update(payload)
|
|
752
|
-
padded_data += padder.finalize()
|
|
807
|
+
Packet format: <HEADER 2B> <LENGTH 2B> <PATTERN 3B> <CMD 2B> <PAYLOAD bB> <CHECKSUM 1B>.
|
|
753
808
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
809
|
+
:param pattern: Pattern of packet (e.g encrypted, negotiation, etc).
|
|
810
|
+
:param cmd: Command in packet (e.g telemetry, power on, etc).
|
|
811
|
+
:param payload: Payload of command (e.g a1...).
|
|
812
|
+
:returns: Packet bytes ready to be sent.
|
|
813
|
+
"""
|
|
757
814
|
|
|
758
815
|
# Calculate length of message
|
|
759
|
-
length = 2 + 2 + 3 + 2 + len(
|
|
816
|
+
length = 2 + 2 + 3 + 2 + len(payload) + 1
|
|
760
817
|
length_bytes = length.to_bytes(length=2, byteorder="little")
|
|
761
818
|
|
|
762
819
|
# Build packet
|
|
763
|
-
packet = (
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
820
|
+
packet = bytes.fromhex("ff09") + length_bytes + pattern + cmd + payload
|
|
821
|
+
return packet + self._checksum(packet)
|
|
822
|
+
|
|
823
|
+
async def _send_encrypted_packet(self, cmd: bytes, payload: bytes) -> None:
|
|
824
|
+
"""Send an encrypted packet using negotiated shared secret and IV."""
|
|
825
|
+
_LOGGER.debug(
|
|
826
|
+
f"Building packet with cmd: {cmd.hex()} and payload: {payload.hex()}"
|
|
769
827
|
)
|
|
770
|
-
|
|
828
|
+
encrypted_payload = self._encrypt_payload(payload)
|
|
829
|
+
|
|
830
|
+
packet = self._build_packet(bytes.fromhex("03000f"), cmd, encrypted_payload)
|
|
771
831
|
_LOGGER.debug(f"Sending encrypted packet: {packet.hex()}")
|
|
772
832
|
|
|
773
833
|
# Send packet
|
|
@@ -963,7 +1023,18 @@ class SolixBLEDevice:
|
|
|
963
1023
|
# Trigger disconnection event
|
|
964
1024
|
self._disconnect_event.set()
|
|
965
1025
|
|
|
966
|
-
def
|
|
1026
|
+
async def _dispose_of_client(self) -> None:
|
|
1027
|
+
"""Dispose of current bleak client."""
|
|
1028
|
+
client = self._client
|
|
1029
|
+
self._client = None
|
|
1030
|
+
try:
|
|
1031
|
+
await client.disconnect()
|
|
1032
|
+
except Exception:
|
|
1033
|
+
_LOGGER.exception(
|
|
1034
|
+
f"Exception raised when disposing of bleak client '{client}'!"
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
def _reset_session(self, reset_data: bool = True) -> None:
|
|
967
1038
|
"""Reset negotiated variables and data and futures."""
|
|
968
1039
|
|
|
969
1040
|
if reset_data:
|
|
@@ -972,8 +1043,7 @@ class SolixBLEDevice:
|
|
|
972
1043
|
|
|
973
1044
|
self._telemetry_payload_small = None
|
|
974
1045
|
self._telemetry_payload_large = None
|
|
975
|
-
self.
|
|
976
|
-
self._iv = None
|
|
1046
|
+
self._shared_secret = None
|
|
977
1047
|
self._last_packet_timestamp = None
|
|
978
1048
|
self._negotiation_timestamp = None
|
|
979
1049
|
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
|
]
|