SolixBLE 3.6.0__tar.gz → 3.8.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.6.0 → solixble-3.8.0}/PKG-INFO +6 -1
- {solixble-3.6.0 → solixble-3.8.0}/README.md +5 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/__init__.py +3 -1
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/device.py +106 -76
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/__init__.py +2 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c1000g2.py +102 -20
- solixble-3.8.0/SolixBLE/devices/prime_power_bank_20k.py +173 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/solarbank2.py +151 -11
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/prime_device.py +3 -1
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/states.py +75 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/PKG-INFO +6 -1
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/SOURCES.txt +1 -0
- {solixble-3.6.0 → solixble-3.8.0}/pyproject.toml +8 -1
- {solixble-3.6.0 → solixble-3.8.0}/tests/test_devices.py +360 -0
- {solixble-3.6.0 → solixble-3.8.0}/LICENSE.txt +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/const.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c1000.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c300.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c300dc.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c800.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/f2000.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/f3800.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/generic.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/prime_charger_160w.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/prime_charger_250w.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/solarbank3.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/utilities.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/requires.txt +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/top_level.txt +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/setup.cfg +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/tests/test_connection.py +0 -0
- {solixble-3.6.0 → solixble-3.8.0}/tests/test_prime.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: SolixBLE
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.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
|
|
@@ -138,3 +138,8 @@ pip install SolixBLE
|
|
|
138
138
|
See the `Generic` class inside `SolixBLE/devices/generic.py` and the
|
|
139
139
|
[documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
|
|
140
140
|
for guidance on how to add support for new devices.
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
## Disclaimer
|
|
144
|
+
|
|
145
|
+
SolixBLE is a software library designed to work with Anker Solix/Prime devices. ANKER is a registered trademark of Anker Innovations Limited. This project is not affiliated with, endorsed by, or sponsored by Anker Innovations Limited (Though I wouldn't mind being sponsored 😉). All other trademarks cited herein are the property of their respective owners.
|
|
@@ -89,3 +89,8 @@ pip install SolixBLE
|
|
|
89
89
|
See the `Generic` class inside `SolixBLE/devices/generic.py` and the
|
|
90
90
|
[documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
|
|
91
91
|
for guidance on how to add support for new devices.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## Disclaimer
|
|
95
|
+
|
|
96
|
+
SolixBLE is a software library designed to work with Anker Solix/Prime devices. ANKER is a registered trademark of Anker Innovations Limited. This project is not affiliated with, endorsed by, or sponsored by Anker Innovations Limited (Though I wouldn't mind being sponsored 😉). All other trademarks cited herein are the property of their respective owners.
|
|
@@ -16,6 +16,7 @@ from .devices import (
|
|
|
16
16
|
Generic,
|
|
17
17
|
PrimeCharger160w,
|
|
18
18
|
PrimeCharger250w,
|
|
19
|
+
PrimePowerBank20k,
|
|
19
20
|
Solarbank2,
|
|
20
21
|
Solarbank3,
|
|
21
22
|
)
|
|
@@ -25,9 +26,9 @@ from .states import (
|
|
|
25
26
|
ChargingStatusF3800,
|
|
26
27
|
DisplayTimeout,
|
|
27
28
|
LightStatus,
|
|
29
|
+
PortOverload,
|
|
28
30
|
PortStatus,
|
|
29
31
|
TemperatureUnit,
|
|
30
|
-
PortOverload,
|
|
31
32
|
)
|
|
32
33
|
from .utilities import discover_devices
|
|
33
34
|
|
|
@@ -45,6 +46,7 @@ __all__ = [
|
|
|
45
46
|
"Solarbank3",
|
|
46
47
|
"PrimeCharger160w",
|
|
47
48
|
"PrimeCharger250w",
|
|
49
|
+
"PrimePowerBank20k",
|
|
48
50
|
"Generic",
|
|
49
51
|
"ChargingStatus",
|
|
50
52
|
"ChargingStatusF3800",
|
|
@@ -52,6 +52,11 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
52
52
|
class SolixBLEDevice:
|
|
53
53
|
"""Solix BLE device object."""
|
|
54
54
|
|
|
55
|
+
#: Command codes (hex) that carry telemetry for this device. Subclasses can
|
|
56
|
+
#: override this if their model uses different telemetry command codes
|
|
57
|
+
#: (e.g the C1000 Gen 2 uses ``c421``/``c900`` instead of ``c402``/``c405``).
|
|
58
|
+
_TELEMETRY_COMMANDS: tuple[str, ...] = ("c402", "4300", "c405")
|
|
59
|
+
|
|
55
60
|
def __init__(self, ble_device: BLEDevice) -> None:
|
|
56
61
|
"""Initialise device object. Does not connect automatically."""
|
|
57
62
|
|
|
@@ -62,8 +67,8 @@ class SolixBLEDevice:
|
|
|
62
67
|
|
|
63
68
|
self._ble_device: BLEDevice = ble_device
|
|
64
69
|
self._client: BleakClient | None = None
|
|
65
|
-
self.
|
|
66
|
-
self.
|
|
70
|
+
self._fragment_buffers: dict[bytes, dict[int, bytes]] = {}
|
|
71
|
+
self._fragment_totals: dict[bytes, int] = {}
|
|
67
72
|
self._data: dict[str, bytes] | None = None
|
|
68
73
|
self._last_data_timestamp: datetime | None = None
|
|
69
74
|
self._last_packet_timestamp: datetime | None = None
|
|
@@ -195,6 +200,15 @@ class SolixBLEDevice:
|
|
|
195
200
|
if self._disconnect_event.is_set():
|
|
196
201
|
self._disconnect_event.clear()
|
|
197
202
|
|
|
203
|
+
# Run any device-specific post-connect setup (e.g sending a subscribe
|
|
204
|
+
# command to start telemetry). This runs on every (re)connection. Errors
|
|
205
|
+
# are logged but do not abort the connection; the automatic reconnect
|
|
206
|
+
# task will retry.
|
|
207
|
+
try:
|
|
208
|
+
await self._post_connect()
|
|
209
|
+
except Exception:
|
|
210
|
+
_LOGGER.exception(f"Error running post-connect setup for '{self.name}'!")
|
|
211
|
+
|
|
198
212
|
# Start an automatic reconnect task if its not running already
|
|
199
213
|
if self._auto_reconnect_task is None:
|
|
200
214
|
self._auto_reconnect_task = asyncio.create_task(self._auto_reconnect())
|
|
@@ -205,6 +219,17 @@ class SolixBLEDevice:
|
|
|
205
219
|
|
|
206
220
|
return True
|
|
207
221
|
|
|
222
|
+
async def _post_connect(self) -> None:
|
|
223
|
+
"""Run device-specific setup after a negotiated connection is established.
|
|
224
|
+
|
|
225
|
+
Called by :meth:`connect` once the encrypted session has been negotiated
|
|
226
|
+
(so :meth:`_send_command` may be used) and on every automatic reconnect.
|
|
227
|
+
The default implementation does nothing; subclasses can override it to,
|
|
228
|
+
for example, send a subscribe command to start a telemetry stream (see
|
|
229
|
+
:class:`~SolixBLE.devices.c1000g2.C1000G2`).
|
|
230
|
+
"""
|
|
231
|
+
pass
|
|
232
|
+
|
|
208
233
|
async def disconnect(self) -> None:
|
|
209
234
|
"""Disconnect from device and reset internal state.
|
|
210
235
|
|
|
@@ -347,11 +372,6 @@ class SolixBLEDevice:
|
|
|
347
372
|
# Extract command
|
|
348
373
|
packet_cmd = bytes([packet_copy.pop(0), packet_copy.pop(0)])
|
|
349
374
|
|
|
350
|
-
# Telemetry packets have an extra field which must be popped
|
|
351
|
-
if packet_pattern.hex() == "03010f" and packet_cmd.hex() == "c402":
|
|
352
|
-
special_value = bytes([packet_copy.pop(0)])
|
|
353
|
-
_LOGGER.debug(f"Special value: {special_value.hex()}")
|
|
354
|
-
|
|
355
375
|
# Extract payload
|
|
356
376
|
packet_payload = bytes(packet_copy)
|
|
357
377
|
|
|
@@ -498,7 +518,9 @@ class SolixBLEDevice:
|
|
|
498
518
|
)
|
|
499
519
|
return cipher.encrypt(padded_data)
|
|
500
520
|
|
|
501
|
-
async def _process_telemetry_packet(
|
|
521
|
+
async def _process_telemetry_packet(
|
|
522
|
+
self, payload: bytes, cmd: bytes = None
|
|
523
|
+
) -> None:
|
|
502
524
|
"""Process a telemetry packet from the device.
|
|
503
525
|
|
|
504
526
|
This performs the default processing of telemetry packets in which
|
|
@@ -507,40 +529,44 @@ class SolixBLEDevice:
|
|
|
507
529
|
telemetry.
|
|
508
530
|
"""
|
|
509
531
|
|
|
510
|
-
#
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
532
|
+
# First byte encodes fragment info (high nibble = index, low = total)
|
|
533
|
+
fragment_index = (payload[0] >> 4) & 0x0F
|
|
534
|
+
fragment_total = payload[0] & 0x0F
|
|
522
535
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
536
|
+
# Multi-part message
|
|
537
|
+
if fragment_total > 1:
|
|
538
|
+
fragment_data = payload[1:]
|
|
539
|
+
cmd_key = bytes(cmd)
|
|
540
|
+
_LOGGER.debug(
|
|
541
|
+
f"Fragment {fragment_index}/{fragment_total} for cmd {cmd.hex()}, {len(fragment_data)} bytes"
|
|
526
542
|
)
|
|
527
543
|
|
|
528
|
-
|
|
529
|
-
self.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
544
|
+
# Store fragment
|
|
545
|
+
if cmd_key not in self._fragment_buffers or fragment_index == 1:
|
|
546
|
+
self._fragment_buffers[cmd_key] = {}
|
|
547
|
+
self._fragment_totals[cmd_key] = fragment_total
|
|
548
|
+
|
|
549
|
+
self._fragment_buffers[cmd_key][fragment_index] = fragment_data
|
|
534
550
|
|
|
535
|
-
|
|
551
|
+
# Wait until all fragments have arrived
|
|
552
|
+
if len(self._fragment_buffers[cmd_key]) < fragment_total:
|
|
553
|
+
_LOGGER.debug("Waiting for remaining fragments...")
|
|
554
|
+
return
|
|
536
555
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
556
|
+
# Reassemble in order
|
|
557
|
+
payload = b"".join(
|
|
558
|
+
self._fragment_buffers[cmd_key][i]
|
|
559
|
+
for i in sorted(self._fragment_buffers[cmd_key])
|
|
560
|
+
)
|
|
561
|
+
del self._fragment_buffers[cmd_key]
|
|
562
|
+
del self._fragment_totals[cmd_key]
|
|
563
|
+
_LOGGER.debug(f"Reassembled payload: {len(payload)} bytes")
|
|
541
564
|
|
|
542
|
-
|
|
543
|
-
|
|
565
|
+
else:
|
|
566
|
+
# Strip fragment info
|
|
567
|
+
payload = payload[1:]
|
|
568
|
+
|
|
569
|
+
decrypted_payload = self._decrypt_payload(payload)
|
|
544
570
|
_LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}")
|
|
545
571
|
parameters = self._parse_payload(decrypted_payload)
|
|
546
572
|
return await self._process_telemetry(parameters)
|
|
@@ -584,7 +610,7 @@ class SolixBLEDevice:
|
|
|
584
610
|
) -> None:
|
|
585
611
|
"""Process a notification from the device."""
|
|
586
612
|
|
|
587
|
-
_LOGGER.debug(f"The client the notification is from
|
|
613
|
+
_LOGGER.debug(f"The client the notification is from: {client}")
|
|
588
614
|
|
|
589
615
|
if self._client is not client:
|
|
590
616
|
_LOGGER.debug("Ignoring notification from old client")
|
|
@@ -614,51 +640,55 @@ class SolixBLEDevice:
|
|
|
614
640
|
# Match against common message types
|
|
615
641
|
match pattern.hex():
|
|
616
642
|
|
|
617
|
-
#
|
|
643
|
+
# Negotiation messages
|
|
618
644
|
case "030001":
|
|
619
|
-
_LOGGER.debug("Received
|
|
645
|
+
_LOGGER.debug("Received negotiation message!")
|
|
620
646
|
return await self._process_negotiation(cmd, payload)
|
|
621
647
|
|
|
622
|
-
#
|
|
648
|
+
# Session messages
|
|
623
649
|
case "03010f" | "030111":
|
|
624
650
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
return await self._process_telemetry_packet(payload)
|
|
631
|
-
|
|
632
|
-
# Unknown messages
|
|
633
|
-
case _:
|
|
634
|
-
_LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
|
|
635
|
-
try:
|
|
636
|
-
|
|
637
|
-
# If the payload is one byte too short and we are
|
|
638
|
-
# using the default AES (CBC) then try putting the
|
|
639
|
-
# last byte of the cmd in front of it
|
|
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
|
-
)
|
|
648
|
-
payload = cmd[1].to_bytes() + payload
|
|
651
|
+
# Non-encrypted telemetry messages
|
|
652
|
+
if cmd.hex() == "0300":
|
|
653
|
+
_LOGGER.debug("Received non-encrypted telemetry message!")
|
|
654
|
+
parameters = self._parse_payload(payload)
|
|
655
|
+
return await self._process_telemetry(parameters)
|
|
649
656
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
657
|
+
# Encrypted telemetry messages
|
|
658
|
+
elif cmd.hex() in self._TELEMETRY_COMMANDS:
|
|
659
|
+
_LOGGER.debug("Received encrypted telemetry message!")
|
|
660
|
+
return await self._process_telemetry_packet(payload, cmd)
|
|
661
|
+
|
|
662
|
+
# Unknown messages
|
|
663
|
+
else:
|
|
664
|
+
_LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
|
|
665
|
+
try:
|
|
666
|
+
|
|
667
|
+
# If the payload is one byte too short and we are
|
|
668
|
+
# using the default AES (CBC) then try putting the
|
|
669
|
+
# last byte of the cmd in front of it
|
|
670
|
+
if (
|
|
671
|
+
len(payload) % 16 == 15
|
|
672
|
+
and self._decrypt_payload
|
|
673
|
+
is SolixBLEDevice._decrypt_payload
|
|
674
|
+
):
|
|
655
675
|
_LOGGER.debug(
|
|
656
|
-
|
|
657
|
-
)
|
|
658
|
-
except Exception:
|
|
659
|
-
_LOGGER.exception(
|
|
660
|
-
"Exception decrypting unknown message type"
|
|
676
|
+
"Using special trick of embedded part of CMD in payload..."
|
|
661
677
|
)
|
|
678
|
+
payload = cmd[1].to_bytes() + payload
|
|
679
|
+
|
|
680
|
+
decrypted_payload = self._decrypt_payload(payload)
|
|
681
|
+
_LOGGER.debug(
|
|
682
|
+
f"Decrypted payload: {decrypted_payload.hex()}"
|
|
683
|
+
)
|
|
684
|
+
parameters = self._parse_payload(decrypted_payload)
|
|
685
|
+
_LOGGER.debug(
|
|
686
|
+
f"Parameters: {self._parameters_to_str(parameters, types=True)}"
|
|
687
|
+
)
|
|
688
|
+
except Exception:
|
|
689
|
+
_LOGGER.exception(
|
|
690
|
+
"Exception decrypting unknown message type"
|
|
691
|
+
)
|
|
662
692
|
|
|
663
693
|
case _:
|
|
664
694
|
_LOGGER.warning(
|
|
@@ -1041,8 +1071,8 @@ class SolixBLEDevice:
|
|
|
1041
1071
|
self._data = None
|
|
1042
1072
|
self._last_data_timestamp = None
|
|
1043
1073
|
|
|
1044
|
-
self.
|
|
1045
|
-
self.
|
|
1074
|
+
self._fragment_buffers = {}
|
|
1075
|
+
self._fragment_totals = {}
|
|
1046
1076
|
self._shared_secret = None
|
|
1047
1077
|
self._last_packet_timestamp = None
|
|
1048
1078
|
self._negotiation_timestamp = None
|
|
@@ -14,6 +14,7 @@ from .f3800 import F3800
|
|
|
14
14
|
from .generic import Generic
|
|
15
15
|
from .prime_charger_160w import PrimeCharger160w
|
|
16
16
|
from .prime_charger_250w import PrimeCharger250w
|
|
17
|
+
from .prime_power_bank_20k import PrimePowerBank20k
|
|
17
18
|
from .solarbank2 import Solarbank2
|
|
18
19
|
from .solarbank3 import Solarbank3
|
|
19
20
|
|
|
@@ -29,5 +30,6 @@ __all__ = [
|
|
|
29
30
|
"Solarbank3",
|
|
30
31
|
"PrimeCharger160w",
|
|
31
32
|
"PrimeCharger250w",
|
|
33
|
+
"PrimePowerBank20k",
|
|
32
34
|
"Generic",
|
|
33
35
|
]
|
|
@@ -4,33 +4,96 @@
|
|
|
4
4
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from ..const import DEFAULT_METADATA_BOOL
|
|
8
7
|
from ..device import SolixBLEDevice
|
|
9
8
|
from ..states import PortStatus
|
|
10
9
|
|
|
10
|
+
#: Command sent after connecting to start the telemetry stream. Unlike the gen-1
|
|
11
|
+
#: models, the Gen 2 streams nothing until it receives this subscribe command.
|
|
12
|
+
CMD_SUBSCRIBE = "4100"
|
|
13
|
+
SUBSCRIBE_PAYLOAD = "a10121"
|
|
14
|
+
|
|
15
|
+
CMD_AC_OUTPUT = "4101"
|
|
16
|
+
CMD_DC_OUTPUT = "4102"
|
|
17
|
+
|
|
18
|
+
PAYLOAD_ON = "a10121a2020101"
|
|
19
|
+
PAYLOAD_OFF = "a10121a2020100"
|
|
20
|
+
|
|
11
21
|
|
|
12
22
|
class C1000G2(SolixBLEDevice):
|
|
13
23
|
"""
|
|
14
24
|
C1000(X) Gen 2 Power Station.
|
|
15
25
|
|
|
16
|
-
Use this class to connect and
|
|
17
|
-
This model is also known as the A1763.
|
|
18
|
-
|
|
19
|
-
.. note::
|
|
20
|
-
This model was added using data from anker-solix-api. It has not been
|
|
21
|
-
tested!
|
|
22
|
-
|
|
23
|
-
.. note::
|
|
24
|
-
It should be possible to add more sensors. I think devices with lots of
|
|
25
|
-
telemetry values split them up into multiple messages but I have not
|
|
26
|
-
played around with this yet. That and I am being a bit conservative with
|
|
27
|
-
these initial implementations, if you want more sensors and are willing
|
|
28
|
-
to help with testing feel free to raise a GitHub issue.
|
|
26
|
+
Use this class to connect, monitor and control a Gen 2 C1000(X) power
|
|
27
|
+
station. This model is also known as the A1763.
|
|
29
28
|
|
|
29
|
+
The Gen 2 uses the same encryption and telemetry framing as the gen-1
|
|
30
|
+
models but with different command codes: it must be sent a subscribe command
|
|
31
|
+
(``4100``) after connecting before it streams any telemetry, its telemetry
|
|
32
|
+
arrives on commands ``c421``/``c900``, its AC output is controlled with
|
|
33
|
+
command ``4101`` and its DC output with command ``4102``. Telemetry and
|
|
34
|
+
AC/DC on/off control have been confirmed on real hardware.
|
|
30
35
|
"""
|
|
31
36
|
|
|
32
37
|
_EXPECTED_TELEMETRY_LENGTH: int = 253
|
|
33
38
|
|
|
39
|
+
#: The Gen 2 pushes telemetry on different command codes to the gen-1 models.
|
|
40
|
+
_TELEMETRY_COMMANDS: tuple[str, ...] = ("c421", "c900")
|
|
41
|
+
|
|
42
|
+
async def _post_connect(self) -> None:
|
|
43
|
+
"""Subscribe to telemetry once connected.
|
|
44
|
+
|
|
45
|
+
The Gen 2 streams no telemetry until it receives this command, so we send
|
|
46
|
+
it after every (re)connection.
|
|
47
|
+
"""
|
|
48
|
+
await self._send_command(
|
|
49
|
+
cmd=bytes.fromhex(CMD_SUBSCRIBE),
|
|
50
|
+
payload=bytes.fromhex(SUBSCRIBE_PAYLOAD),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def turn_ac_on(self) -> None:
|
|
54
|
+
"""Turn the AC output on.
|
|
55
|
+
|
|
56
|
+
:raises ConnectionError: If not connected to device.
|
|
57
|
+
:raises BleakError: If command transmission fails.
|
|
58
|
+
"""
|
|
59
|
+
await self._send_command(
|
|
60
|
+
cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def turn_ac_off(self) -> None:
|
|
64
|
+
"""Turn the AC output off.
|
|
65
|
+
|
|
66
|
+
:raises ConnectionError: If not connected to device.
|
|
67
|
+
:raises BleakError: If command transmission fails.
|
|
68
|
+
"""
|
|
69
|
+
await self._send_command(
|
|
70
|
+
cmd=bytes.fromhex(CMD_AC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def turn_dc_on(self) -> None:
|
|
74
|
+
"""Turn the DC (12 V) output on.
|
|
75
|
+
|
|
76
|
+
Confirmed on real hardware: the 12 V port physically switched and the
|
|
77
|
+
``b2`` status byte latched on. The Gen 2 reuses the AC on/off payload on
|
|
78
|
+
a different command code (``4102``).
|
|
79
|
+
|
|
80
|
+
:raises ConnectionError: If not connected to device.
|
|
81
|
+
:raises BleakError: If command transmission fails.
|
|
82
|
+
"""
|
|
83
|
+
await self._send_command(
|
|
84
|
+
cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_ON)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def turn_dc_off(self) -> None:
|
|
88
|
+
"""Turn the DC (12 V) output off.
|
|
89
|
+
|
|
90
|
+
:raises ConnectionError: If not connected to device.
|
|
91
|
+
:raises BleakError: If command transmission fails.
|
|
92
|
+
"""
|
|
93
|
+
await self._send_command(
|
|
94
|
+
cmd=bytes.fromhex(CMD_DC_OUTPUT), payload=bytes.fromhex(PAYLOAD_OFF)
|
|
95
|
+
)
|
|
96
|
+
|
|
34
97
|
@property
|
|
35
98
|
def serial_number(self) -> str:
|
|
36
99
|
"""Device serial number.
|
|
@@ -73,7 +136,7 @@ class C1000G2(SolixBLEDevice):
|
|
|
73
136
|
|
|
74
137
|
@property
|
|
75
138
|
def power_out(self) -> int:
|
|
76
|
-
"""Total Power Out.
|
|
139
|
+
"""Total Power Out (watts).
|
|
77
140
|
|
|
78
141
|
:returns: Total power out or default int value.
|
|
79
142
|
"""
|
|
@@ -81,7 +144,7 @@ class C1000G2(SolixBLEDevice):
|
|
|
81
144
|
|
|
82
145
|
@property
|
|
83
146
|
def ac_power_in(self) -> int:
|
|
84
|
-
"""AC Power In.
|
|
147
|
+
"""AC Power In (watts).
|
|
85
148
|
|
|
86
149
|
:returns: Total AC power in or default int value.
|
|
87
150
|
"""
|
|
@@ -94,13 +157,24 @@ class C1000G2(SolixBLEDevice):
|
|
|
94
157
|
PortStatus.NOT_CONNECTED signifies off.
|
|
95
158
|
PortStatus.OUTPUT signifies on.
|
|
96
159
|
|
|
160
|
+
.. note::
|
|
161
|
+
:collapsible: closed
|
|
162
|
+
|
|
163
|
+
The AC port status is the first byte of the ``a7`` parameter,
|
|
164
|
+
mirroring the ``04 <status> <watts LE>`` per-port shape used by the
|
|
165
|
+
DC port (``b2``) and the USB ports; ``ac_power_out`` reads the watts
|
|
166
|
+
from this same ``a7`` TLV. Confirmed on hardware: ``a7[1]`` latches
|
|
167
|
+
``01`` (OUTPUT) when AC is on and ``00`` when off, tracking the relay.
|
|
168
|
+
(The ``a4`` parameter is constant at the previously-used offset and
|
|
169
|
+
does NOT reflect the AC state.)
|
|
170
|
+
|
|
97
171
|
:returns: Status of the AC port.
|
|
98
172
|
"""
|
|
99
173
|
return PortStatus(self._parse_int("a7", begin=1, end=2))
|
|
100
174
|
|
|
101
175
|
@property
|
|
102
176
|
def ac_power_out(self) -> int:
|
|
103
|
-
"""AC Power Out.
|
|
177
|
+
"""AC Power Out (watts).
|
|
104
178
|
|
|
105
179
|
:returns: Total AC power out or default int value.
|
|
106
180
|
"""
|
|
@@ -116,7 +190,9 @@ class C1000G2(SolixBLEDevice):
|
|
|
116
190
|
|
|
117
191
|
@property
|
|
118
192
|
def solar_power_in(self) -> int:
|
|
119
|
-
"""Solar/DC Power In.
|
|
193
|
+
"""Solar/DC Power In (watts).
|
|
194
|
+
|
|
195
|
+
.. note:: Offset inferred, not yet confirmed on hardware (no solar/DC-in capture taken).
|
|
120
196
|
|
|
121
197
|
:returns: Solar/DC power in or default int value.
|
|
122
198
|
"""
|
|
@@ -164,7 +240,7 @@ class C1000G2(SolixBLEDevice):
|
|
|
164
240
|
|
|
165
241
|
@property
|
|
166
242
|
def usb_c3_power(self) -> int:
|
|
167
|
-
"""USB C3 Power.
|
|
243
|
+
"""USB C3 Power (watts).
|
|
168
244
|
|
|
169
245
|
:returns: USB port C3 power or default int value.
|
|
170
246
|
"""
|
|
@@ -190,13 +266,19 @@ class C1000G2(SolixBLEDevice):
|
|
|
190
266
|
def dc_output(self) -> PortStatus:
|
|
191
267
|
"""DC Port Status.
|
|
192
268
|
|
|
269
|
+
Confirmed on hardware: ``b2[1]`` latched ``01`` (OUTPUT) when the 12 V
|
|
270
|
+
port was switched on and ``00`` (NOT_CONNECTED) when off.
|
|
271
|
+
|
|
193
272
|
:returns: Status of the DC output port.
|
|
194
273
|
"""
|
|
195
274
|
return PortStatus(self._parse_int("b2", begin=1, end=2))
|
|
196
275
|
|
|
197
276
|
@property
|
|
198
277
|
def dc_power_out(self) -> int:
|
|
199
|
-
"""DC Power Out.
|
|
278
|
+
"""DC Power Out (watts).
|
|
279
|
+
|
|
280
|
+
Confirmed on hardware: ``b2`` [2:4] read 6 W with a 12 V load on the DC
|
|
281
|
+
output, matching the ``04 <status> <watts LE>`` per-port shape.
|
|
200
282
|
|
|
201
283
|
:returns: DC power out or default int value.
|
|
202
284
|
"""
|