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.
Files changed (33) hide show
  1. {solixble-3.5.0 → solixble-3.7.0}/PKG-INFO +2 -1
  2. {solixble-3.5.0 → solixble-3.7.0}/README.md +1 -0
  3. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/__init__.py +4 -0
  4. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/const.py +1 -1
  5. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/device.py +208 -137
  6. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/__init__.py +2 -0
  7. solixble-3.7.0/SolixBLE/devices/prime_charger_160w.py +261 -0
  8. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/prime_charger_250w.py +20 -20
  9. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/solarbank2.py +151 -11
  10. solixble-3.7.0/SolixBLE/prime_device.py +522 -0
  11. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/states.py +75 -0
  12. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/utilities.py +9 -0
  13. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/PKG-INFO +2 -1
  14. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/SOURCES.txt +4 -1
  15. {solixble-3.5.0 → solixble-3.7.0}/pyproject.toml +1 -1
  16. solixble-3.7.0/tests/test_connection.py +255 -0
  17. {solixble-3.5.0 → solixble-3.7.0}/tests/test_devices.py +250 -131
  18. solixble-3.7.0/tests/test_prime.py +74 -0
  19. solixble-3.5.0/tests/test_reconnect.py +0 -155
  20. {solixble-3.5.0 → solixble-3.7.0}/LICENSE.txt +0 -0
  21. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c1000.py +0 -0
  22. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c1000g2.py +0 -0
  23. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c300.py +0 -0
  24. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c300dc.py +0 -0
  25. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/c800.py +0 -0
  26. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/f2000.py +0 -0
  27. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/f3800.py +0 -0
  28. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/generic.py +0 -0
  29. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE/devices/solarbank3.py +0 -0
  30. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
  31. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/requires.txt +0 -0
  32. {solixble-3.5.0 → solixble-3.7.0}/SolixBLE.egg-info/top_level.txt +0 -0
  33. {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.5.0
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
 
@@ -51,6 +51,7 @@ See the [support table](https://solixble.readthedocs.io/en/latest) in the docume
51
51
  - F3800
52
52
  - Solarbank 2
53
53
  - Solarbank 3
54
+ - Prime Charger 160w
54
55
  - Prime Charger 250w
55
56
  - Potentially more!
56
57
 
@@ -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 C1000).
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._telemetry_payload_small: bytes | None = None
66
- self._telemetry_payload_large: bytes | None = None
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._shared_key: bytes | None = None
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 and self._client.is_connected:
112
- _LOGGER.debug(
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._client.write_gatt_char(
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
- """Parse payload bytes into parameters."""
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
- # Packets sometimes start with 00 and we must strip that
406
+ # Payloads sometimes start with 00 and we must strip that
373
407
  if remaining_data.startswith(bytes.fromhex("00")):
374
- remaining_data.pop(0)
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 = bytes([remaining_data.pop(0)]).hex()
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
- # and then padding after that. This has been observed during
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
- param_len = remaining_data.pop(0)
395
- param_data = bytes([remaining_data.pop(0) for _ in range(0, param_len)])
396
- parsed_data[param_id] = param_data
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
- # If we have reached PKCS7 padding then we have
399
- # reached the end of the payload
400
- if len(remaining_data) < 16 and remaining_data == bytearray(
401
- len(remaining_data) * len(remaining_data).to_bytes(1)
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! Payload: '{payload.hex()}'"
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(self._shared_key, AES.MODE_CBC, iv=self._iv)
448
- return cipher.decrypt(payload)
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
- async def _process_telemetry(
451
- self, cmd: bytes, parameters: dict[str, bytes]
452
- ) -> None:
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 is: {client}")
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 try putting the
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 len(payload) % 16 == 15:
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
- shared_secret = private_key.exchange(ECDH(), device_public_key)
686
- self._shared_key = shared_secret[:16]
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
- async def _send_encrypted_packet(self, cmd: bytes, payload: bytes) -> None:
744
- """Send an encrypted packet using negotiated shared secret and IV."""
745
- _LOGGER.debug(
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
- # Pad payload
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
- # Encrypt payload
755
- cipher = AES.new(self._shared_key, AES.MODE_CBC, iv=self._iv)
756
- encrypted_payload = cipher.encrypt(padded_data)
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(encrypted_payload) + 1
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
- bytes.fromhex("ff09")
765
- + length_bytes
766
- + bytes.fromhex("03000f")
767
- + cmd
768
- + encrypted_payload
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
- packet = packet + self._checksum(packet)
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 _reset_session(self, reset_data: bool = True):
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._telemetry_payload_small = None
974
- self._telemetry_payload_large = None
975
- self._shared_key = None
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
  ]