SolixBLE 3.7.0__tar.gz → 3.9.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 (36) hide show
  1. {solixble-3.7.0 → solixble-3.9.0}/PKG-INFO +8 -1
  2. {solixble-3.7.0 → solixble-3.9.0}/README.md +7 -0
  3. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/__init__.py +7 -1
  4. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/device.py +133 -42
  5. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/__init__.py +6 -0
  6. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c1000.py +19 -0
  7. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c1000g2.py +102 -20
  8. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c300dc.py +107 -1
  9. solixble-3.9.0/SolixBLE/devices/f2600.py +501 -0
  10. solixble-3.9.0/SolixBLE/devices/maggo_3in1.py +124 -0
  11. solixble-3.9.0/SolixBLE/devices/prime_charger_250w.py +477 -0
  12. solixble-3.9.0/SolixBLE/devices/prime_power_bank_20k.py +173 -0
  13. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/prime_device.py +3 -1
  14. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/PKG-INFO +8 -1
  15. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/SOURCES.txt +3 -0
  16. {solixble-3.7.0 → solixble-3.9.0}/pyproject.toml +8 -1
  17. {solixble-3.7.0 → solixble-3.9.0}/tests/test_devices.py +807 -0
  18. solixble-3.7.0/SolixBLE/devices/prime_charger_250w.py +0 -278
  19. {solixble-3.7.0 → solixble-3.9.0}/LICENSE.txt +0 -0
  20. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/const.py +0 -0
  21. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c300.py +0 -0
  22. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/c800.py +0 -0
  23. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/f2000.py +0 -0
  24. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/f3800.py +0 -0
  25. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/generic.py +0 -0
  26. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/prime_charger_160w.py +0 -0
  27. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/solarbank2.py +0 -0
  28. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/devices/solarbank3.py +0 -0
  29. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/states.py +0 -0
  30. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE/utilities.py +0 -0
  31. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
  32. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/requires.txt +0 -0
  33. {solixble-3.7.0 → solixble-3.9.0}/SolixBLE.egg-info/top_level.txt +0 -0
  34. {solixble-3.7.0 → solixble-3.9.0}/setup.cfg +0 -0
  35. {solixble-3.7.0 → solixble-3.9.0}/tests/test_connection.py +0 -0
  36. {solixble-3.7.0 → solixble-3.9.0}/tests/test_prime.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SolixBLE
3
- Version: 3.7.0
3
+ Version: 3.9.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
@@ -97,11 +97,13 @@ See the [support table](https://solixble.readthedocs.io/en/latest) in the docume
97
97
  - C1000(X)
98
98
  - C1000 Gen 2
99
99
  - F2000 (767 PowerHouse)
100
+ - F2600
100
101
  - F3800
101
102
  - Solarbank 2
102
103
  - Solarbank 3
103
104
  - Prime Charger 160w
104
105
  - Prime Charger 250w
106
+ - Prime MagGo 3in1 (A25X7)
105
107
  - Potentially more!
106
108
 
107
109
 
@@ -138,3 +140,8 @@ pip install SolixBLE
138
140
  See the `Generic` class inside `SolixBLE/devices/generic.py` and the
139
141
  [documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
140
142
  for guidance on how to add support for new devices.
143
+
144
+
145
+ ## Disclaimer
146
+
147
+ 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.
@@ -48,11 +48,13 @@ See the [support table](https://solixble.readthedocs.io/en/latest) in the docume
48
48
  - C1000(X)
49
49
  - C1000 Gen 2
50
50
  - F2000 (767 PowerHouse)
51
+ - F2600
51
52
  - F3800
52
53
  - Solarbank 2
53
54
  - Solarbank 3
54
55
  - Prime Charger 160w
55
56
  - Prime Charger 250w
57
+ - Prime MagGo 3in1 (A25X7)
56
58
  - Potentially more!
57
59
 
58
60
 
@@ -89,3 +91,8 @@ pip install SolixBLE
89
91
  See the `Generic` class inside `SolixBLE/devices/generic.py` and the
90
92
  [documentation](https://solixble.readthedocs.io/en/latest/new_devices.html)
91
93
  for guidance on how to add support for new devices.
94
+
95
+
96
+ ## Disclaimer
97
+
98
+ 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.
@@ -12,10 +12,13 @@ from .devices import (
12
12
  C1000,
13
13
  C1000G2,
14
14
  F2000,
15
+ F2600,
15
16
  F3800,
16
17
  Generic,
18
+ MagGo3in1,
17
19
  PrimeCharger160w,
18
20
  PrimeCharger250w,
21
+ PrimePowerBank20k,
19
22
  Solarbank2,
20
23
  Solarbank3,
21
24
  )
@@ -25,9 +28,9 @@ from .states import (
25
28
  ChargingStatusF3800,
26
29
  DisplayTimeout,
27
30
  LightStatus,
31
+ PortOverload,
28
32
  PortStatus,
29
33
  TemperatureUnit,
30
- PortOverload,
31
34
  )
32
35
  from .utilities import discover_devices
33
36
 
@@ -40,11 +43,14 @@ __all__ = [
40
43
  "C1000",
41
44
  "C1000G2",
42
45
  "F2000",
46
+ "F2600",
43
47
  "F3800",
44
48
  "Solarbank2",
45
49
  "Solarbank3",
46
50
  "PrimeCharger160w",
47
51
  "PrimeCharger250w",
52
+ "PrimePowerBank20k",
53
+ "MagGo3in1",
48
54
  "Generic",
49
55
  "ChargingStatus",
50
56
  "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
 
@@ -71,6 +76,7 @@ class SolixBLEDevice:
71
76
  self._state_changed_callbacks: list[Callable[[], None]] = []
72
77
  self._packet_futures: dict[bytes, list[asyncio.Future]] = {}
73
78
  self._auto_reconnect_task: asyncio.Task | None = None
79
+ self._keep_alive_task: asyncio.Task | None = None
74
80
  self._disconnect_event: asyncio.Event = asyncio.Event()
75
81
  self._connection_attempts: int = 0
76
82
  self._shared_secret: bytes | None = None
@@ -195,16 +201,60 @@ class SolixBLEDevice:
195
201
  if self._disconnect_event.is_set():
196
202
  self._disconnect_event.clear()
197
203
 
204
+ # Run any device-specific post-connect setup (e.g sending a subscribe
205
+ # command to start telemetry). This runs on every (re)connection. Errors
206
+ # are logged but do not abort the connection; the automatic reconnect
207
+ # task will retry.
208
+ try:
209
+ await self._post_connect()
210
+ except Exception:
211
+ _LOGGER.exception(f"Error running post-connect setup for '{self.name}'!")
212
+
198
213
  # Start an automatic reconnect task if its not running already
199
214
  if self._auto_reconnect_task is None:
200
215
  self._auto_reconnect_task = asyncio.create_task(self._auto_reconnect())
201
216
 
217
+ # Start a keep-alive task if its not running already
218
+ if self._keep_alive_task is None:
219
+ self._keep_alive_task = asyncio.create_task(self._keep_alive_fn())
220
+
202
221
  # Execute callbacks if enabled
203
222
  if run_callbacks:
204
223
  self._run_state_changed_callbacks()
205
224
 
206
225
  return True
207
226
 
227
+ async def _post_connect(self) -> None:
228
+ """Run device-specific setup after a negotiated connection is established.
229
+
230
+ Called by :meth:`connect` once the encrypted session has been negotiated
231
+ (so :meth:`_send_command` may be used) and on every automatic reconnect.
232
+ The default implementation does nothing; subclasses can override it to,
233
+ for example, send a subscribe command to start a telemetry stream (see
234
+ :class:`~SolixBLE.devices.c1000g2.C1000G2`).
235
+ """
236
+ pass
237
+
238
+ async def _keep_alive(self) -> int | None:
239
+ """Execute designated keep-alive command periodically after good negotiation.
240
+
241
+ Use this to execute code periodically that is needed to keep the
242
+ connection. For example some devices need a special keep alive command
243
+ to keep receiving telemetry updates that must be sent every ~10 seconds.
244
+
245
+ This is automatically executed in a task created by :meth:`connect` once
246
+ the encrypted session has been negotiated (so :meth:`_send_command`
247
+ may be used) and on every automatic reconnect, with it not being
248
+ executed when not connected or negotiated.
249
+
250
+ The default implementation does nothing; subclasses can override it to,
251
+ for example, send a subscribe command to keep a telemetry stream (see
252
+ :class:`~SolixBLE.devices.prime_charger_250w.PrimeCharger250w`).
253
+
254
+ :returns: Seconds to wait before calling again or None for not implemented.
255
+ """
256
+ return None
257
+
208
258
  async def disconnect(self) -> None:
209
259
  """Disconnect from device and reset internal state.
210
260
 
@@ -217,6 +267,10 @@ class SolixBLEDevice:
217
267
  if self._auto_reconnect_task is not None:
218
268
  self._auto_reconnect_task.cancel()
219
269
 
270
+ # Cancel the keep-alive task
271
+ if self._keep_alive_task is not None:
272
+ self._keep_alive_task.cancel()
273
+
220
274
  # If there is a client disconnect and throw it away
221
275
  if self._client is not None:
222
276
  await self._dispose_of_client()
@@ -493,7 +547,9 @@ class SolixBLEDevice:
493
547
  )
494
548
  return cipher.encrypt(padded_data)
495
549
 
496
- async def _process_telemetry_packet(self, payload: bytes, cmd: bytes = None) -> None:
550
+ async def _process_telemetry_packet(
551
+ self, payload: bytes, cmd: bytes = None
552
+ ) -> None:
497
553
  """Process a telemetry packet from the device.
498
554
 
499
555
  This performs the default processing of telemetry packets in which
@@ -533,14 +589,12 @@ class SolixBLEDevice:
533
589
  )
534
590
  del self._fragment_buffers[cmd_key]
535
591
  del self._fragment_totals[cmd_key]
536
- _LOGGER.debug(
537
- f"Reassembled payload: {len(payload)} bytes"
538
- )
592
+ _LOGGER.debug(f"Reassembled payload: {len(payload)} bytes")
539
593
 
540
594
  else:
541
595
  # Strip fragment info
542
596
  payload = payload[1:]
543
-
597
+
544
598
  decrypted_payload = self._decrypt_payload(payload)
545
599
  _LOGGER.debug(f"Decrypted payload: {decrypted_payload.hex()}")
546
600
  parameters = self._parse_payload(decrypted_payload)
@@ -615,51 +669,55 @@ class SolixBLEDevice:
615
669
  # Match against common message types
616
670
  match pattern.hex():
617
671
 
618
- # Encryption negotiation
672
+ # Negotiation messages
619
673
  case "030001":
620
- _LOGGER.debug("Received encryption negotiation message!")
674
+ _LOGGER.debug("Received negotiation message!")
621
675
  return await self._process_negotiation(cmd, payload)
622
676
 
623
- # Encrypted messages
677
+ # Session messages
624
678
  case "03010f" | "030111":
625
679
 
626
- match cmd.hex():
627
-
628
- # Telemetry messages
629
- case "c402" | "4300" | "c405":
630
- _LOGGER.debug("Received telemetry message!")
631
- return await self._process_telemetry_packet(payload, cmd)
632
-
633
- # Unknown messages
634
- case _:
635
- _LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
636
- try:
637
-
638
- # If the payload is one byte too short and we are
639
- # using the default AES (CBC) then try putting the
640
- # last byte of the cmd in front of it
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
- )
649
- payload = cmd[1].to_bytes() + payload
680
+ # Non-encrypted telemetry messages
681
+ if cmd.hex() == "0300":
682
+ _LOGGER.debug("Received non-encrypted telemetry message!")
683
+ parameters = self._parse_payload(payload)
684
+ return await self._process_telemetry(parameters)
650
685
 
651
- decrypted_payload = self._decrypt_payload(payload)
652
- _LOGGER.debug(
653
- f"Decrypted payload: {decrypted_payload.hex()}"
654
- )
655
- parameters = self._parse_payload(decrypted_payload)
686
+ # Encrypted telemetry messages
687
+ elif cmd.hex() in self._TELEMETRY_COMMANDS:
688
+ _LOGGER.debug("Received encrypted telemetry message!")
689
+ return await self._process_telemetry_packet(payload, cmd)
690
+
691
+ # Unknown messages
692
+ else:
693
+ _LOGGER.debug(f"Received unknown message of type: {cmd.hex()}")
694
+ try:
695
+
696
+ # If the payload is one byte too short and we are
697
+ # using the default AES (CBC) then try putting the
698
+ # last byte of the cmd in front of it
699
+ if (
700
+ len(payload) % 16 == 15
701
+ and self._decrypt_payload
702
+ is SolixBLEDevice._decrypt_payload
703
+ ):
656
704
  _LOGGER.debug(
657
- f"Parameters: {self._parameters_to_str(parameters, types=True)}"
658
- )
659
- except Exception:
660
- _LOGGER.exception(
661
- "Exception decrypting unknown message type"
705
+ "Using special trick of embedded part of CMD in payload..."
662
706
  )
707
+ payload = cmd[1].to_bytes() + payload
708
+
709
+ decrypted_payload = self._decrypt_payload(payload)
710
+ _LOGGER.debug(
711
+ f"Decrypted payload: {decrypted_payload.hex()}"
712
+ )
713
+ parameters = self._parse_payload(decrypted_payload)
714
+ _LOGGER.debug(
715
+ f"Parameters: {self._parameters_to_str(parameters, types=True)}"
716
+ )
717
+ except Exception:
718
+ _LOGGER.exception(
719
+ "Exception decrypting unknown message type"
720
+ )
663
721
 
664
722
  case _:
665
723
  _LOGGER.warning(
@@ -996,6 +1054,39 @@ class SolixBLEDevice:
996
1054
  except Exception:
997
1055
  _LOGGER.exception("Unexpected exception in automatic reconnect task!")
998
1056
 
1057
+
1058
+ async def _keep_alive_fn(self) -> None:
1059
+ """Task designed to be run in background to execute keep-alive.
1060
+
1061
+ This task is executed automatically when a successful connection
1062
+ is made and will periodically execute the keep alive function if
1063
+ one exists.
1064
+
1065
+ This background task is cancelled when the connection is lost.
1066
+ """
1067
+ try:
1068
+ while self.negotiated:
1069
+
1070
+ try:
1071
+ _LOGGER.debug("Executing keep-alive...")
1072
+ result = await self._keep_alive()
1073
+
1074
+ if result is None:
1075
+ _LOGGER.debug("No keep-alive task registered, stopping task...")
1076
+ return
1077
+
1078
+ _LOGGER.debug(f"Executing keep-alive in {result}s")
1079
+ await asyncio.sleep(result)
1080
+
1081
+ except Exception:
1082
+ _LOGGER.exception("Exception raised executing keep-alive function!")
1083
+
1084
+ except asyncio.CancelledError:
1085
+ _LOGGER.debug("Keep-alive task has been canceled/stopped")
1086
+
1087
+ except Exception:
1088
+ _LOGGER.exception("Unexpected exception in keep-alive task!")
1089
+
999
1090
  def _disconnect_callback(self, client: BaseBleakClient) -> None:
1000
1091
  """Callback executed by bleak when the connection is lost.
1001
1092
 
@@ -10,10 +10,13 @@ from .c800 import C800
10
10
  from .c1000 import C1000
11
11
  from .c1000g2 import C1000G2
12
12
  from .f2000 import F2000
13
+ from .f2600 import F2600
13
14
  from .f3800 import F3800
14
15
  from .generic import Generic
16
+ from .maggo_3in1 import MagGo3in1
15
17
  from .prime_charger_160w import PrimeCharger160w
16
18
  from .prime_charger_250w import PrimeCharger250w
19
+ from .prime_power_bank_20k import PrimePowerBank20k
17
20
  from .solarbank2 import Solarbank2
18
21
  from .solarbank3 import Solarbank3
19
22
 
@@ -24,10 +27,13 @@ __all__ = [
24
27
  "C1000",
25
28
  "C1000G2",
26
29
  "F2000",
30
+ "F2600",
27
31
  "F3800",
28
32
  "Solarbank2",
29
33
  "Solarbank3",
30
34
  "PrimeCharger160w",
31
35
  "PrimeCharger250w",
36
+ "PrimePowerBank20k",
37
+ "MagGo3in1",
32
38
  "Generic",
33
39
  ]
@@ -186,6 +186,25 @@ class C1000(SolixBLEDevice):
186
186
  """
187
187
  return self._parse_int("b0", begin=1)
188
188
 
189
+ @property
190
+ def dc_output(self) -> PortStatus:
191
+ """DC Port Status.
192
+
193
+ PortStatus.NOT_CONNECTED signifies off.
194
+ PortStatus.OUTPUT signifies on.
195
+
196
+ :returns: Status of the DC output port.
197
+ """
198
+ return PortStatus(self._parse_int("b2", begin=1, end=2))
199
+
200
+ @property
201
+ def dc_power_out(self) -> int:
202
+ """DC Power Out.
203
+
204
+ :returns: DC power out or default int value.
205
+ """
206
+ return self._parse_int("b2", begin=2)
207
+
189
208
  @property
190
209
  def software_version(self) -> str:
191
210
  """Main software version.
@@ -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 monitor a Gen 2 C1000(X) power station.
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
  """