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.
Files changed (33) hide show
  1. {solixble-3.6.0 → solixble-3.8.0}/PKG-INFO +6 -1
  2. {solixble-3.6.0 → solixble-3.8.0}/README.md +5 -0
  3. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/__init__.py +3 -1
  4. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/device.py +106 -76
  5. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/__init__.py +2 -0
  6. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c1000g2.py +102 -20
  7. solixble-3.8.0/SolixBLE/devices/prime_power_bank_20k.py +173 -0
  8. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/solarbank2.py +151 -11
  9. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/prime_device.py +3 -1
  10. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/states.py +75 -0
  11. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/PKG-INFO +6 -1
  12. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/SOURCES.txt +1 -0
  13. {solixble-3.6.0 → solixble-3.8.0}/pyproject.toml +8 -1
  14. {solixble-3.6.0 → solixble-3.8.0}/tests/test_devices.py +360 -0
  15. {solixble-3.6.0 → solixble-3.8.0}/LICENSE.txt +0 -0
  16. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/const.py +0 -0
  17. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c1000.py +0 -0
  18. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c300.py +0 -0
  19. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c300dc.py +0 -0
  20. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/c800.py +0 -0
  21. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/f2000.py +0 -0
  22. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/f3800.py +0 -0
  23. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/generic.py +0 -0
  24. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/prime_charger_160w.py +0 -0
  25. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/prime_charger_250w.py +0 -0
  26. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/devices/solarbank3.py +0 -0
  27. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE/utilities.py +0 -0
  28. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/dependency_links.txt +0 -0
  29. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/requires.txt +0 -0
  30. {solixble-3.6.0 → solixble-3.8.0}/SolixBLE.egg-info/top_level.txt +0 -0
  31. {solixble-3.6.0 → solixble-3.8.0}/setup.cfg +0 -0
  32. {solixble-3.6.0 → solixble-3.8.0}/tests/test_connection.py +0 -0
  33. {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.6.0
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._telemetry_payload_small: bytes | None = None
66
- self._telemetry_payload_large: bytes | None = None
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(self, payload: bytes) -> None:
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
- # 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
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
- else:
524
- _LOGGER.warning(
525
- f"Telemetry payload has an unexpected length of {len(payload)}!"
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
- 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
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
- new_payload = self._telemetry_payload_large + self._telemetry_payload_small
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
- # 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
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
- _LOGGER.debug(f"Merged payload: {new_payload.hex()}")
543
- decrypted_payload = self._decrypt_payload(new_payload)
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 is: {client}")
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
- # Encryption negotiation
643
+ # Negotiation messages
618
644
  case "030001":
619
- _LOGGER.debug("Received encryption negotiation message!")
645
+ _LOGGER.debug("Received negotiation message!")
620
646
  return await self._process_negotiation(cmd, payload)
621
647
 
622
- # Encrypted messages
648
+ # Session messages
623
649
  case "03010f" | "030111":
624
650
 
625
- match cmd.hex():
626
-
627
- # Telemetry messages
628
- case "c402" | "4300":
629
- _LOGGER.debug("Received telemetry message!")
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
- decrypted_payload = self._decrypt_payload(payload)
651
- _LOGGER.debug(
652
- f"Decrypted payload: {decrypted_payload.hex()}"
653
- )
654
- parameters = self._parse_payload(decrypted_payload)
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
- f"Parameters: {self._parameters_to_str(parameters, types=True)}"
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._telemetry_payload_small = None
1045
- self._telemetry_payload_large = None
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 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
  """