diff --git a/SolixBLE/__init__.py b/SolixBLE/__init__.py index fd7e1b2..413572c 100644 --- a/SolixBLE/__init__.py +++ b/SolixBLE/__init__.py @@ -16,6 +16,7 @@ Generic, PrimeCharger160w, PrimeCharger250w, + PrimePowerBank20k, Solarbank2, Solarbank3, ) @@ -25,9 +26,9 @@ ChargingStatusF3800, DisplayTimeout, LightStatus, + PortOverload, PortStatus, TemperatureUnit, - PortOverload, ) from .utilities import discover_devices @@ -45,6 +46,7 @@ "Solarbank3", "PrimeCharger160w", "PrimeCharger250w", + "PrimePowerBank20k", "Generic", "ChargingStatus", "ChargingStatusF3800", diff --git a/SolixBLE/devices/__init__.py b/SolixBLE/devices/__init__.py index 030b7ea..78ae2c2 100644 --- a/SolixBLE/devices/__init__.py +++ b/SolixBLE/devices/__init__.py @@ -14,6 +14,7 @@ from .generic import Generic from .prime_charger_160w import PrimeCharger160w from .prime_charger_250w import PrimeCharger250w +from .prime_power_bank_20k import PrimePowerBank20k from .solarbank2 import Solarbank2 from .solarbank3 import Solarbank3 @@ -29,5 +30,6 @@ "Solarbank3", "PrimeCharger160w", "PrimeCharger250w", + "PrimePowerBank20k", "Generic", ] diff --git a/SolixBLE/devices/prime_power_bank_20k.py b/SolixBLE/devices/prime_power_bank_20k.py new file mode 100644 index 0000000..dc7f98b --- /dev/null +++ b/SolixBLE/devices/prime_power_bank_20k.py @@ -0,0 +1,173 @@ +"""Anker Prime Power Bank 20k (220w) model. + +.. moduleauthor:: Harvey Lelliott (flip-dots) + +""" + +from ..const import DEFAULT_METADATA_FLOAT +from ..prime_device import PrimeDevice +from ..states import PortStatus + + +class PrimePowerBank20k(PrimeDevice): + """ + Anker Prime Power Bank 20k (220w) model. + + Use this class to connect and monitor the 220w power bank. + This model is also known as the A110B. + """ + + @property + def battery_percentage(self) -> int: + """Battery Percentage. + + :returns: Percentage charge of battery or default int value. + """ + return self._parse_int("a2", begin=1, end=2) + + @property + def power_out(self) -> int: + """Total Power Out. + + :returns: Total power out or default int value. + """ + return self._parse_int("a6", begin=2, end=4) / 10.0 + + @property + def temperature(self) -> int: + """Temperature of the unit (C). + + :returns: Temperature of the unit in degrees C. + """ + return self._parse_int("af", begin=1, signed=True) + + @property + def usb_port_c1(self) -> PortStatus: + """USB C1 Port Status. + + :returns: Status of the USB C1 port. + """ + return PortStatus(self._parse_int("a8", begin=1, end=2)) + + @property + def usb_c1_voltage(self) -> float: + """USB C1 Port voltage (V). + + :returns: Voltage of the USB C1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a8", begin=2, end=4) / 10.0 + + @property + def usb_c1_current(self) -> float: + """USB C1 Port current (A). + + :returns: Current of the USB C1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a8", begin=4, end=6) / 10.0 + + @property + def usb_c1_power(self) -> float: + """USB C1 Port power (W). + + .. important:: + + There appears to be a firmware bug in the power bank which + causes the value of USB C1 power to latch to whatever its + last value was when unplugged, this does not happen with + USB C2 power for some reason. This has been observed on + version v1.6.0.5. + + :returns: Power of the USB C1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a8", begin=6, end=8) / 10.0 + + @property + def usb_port_c2(self) -> PortStatus: + """USB C2 Port Status. + + :returns: Status of the USB C2 port. + """ + return PortStatus(self._parse_int("a9", begin=1, end=2)) + + @property + def usb_c2_voltage(self) -> float: + """USB C2 Port voltage (V). + + :returns: Voltage of the USB C2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a9", begin=2, end=4) / 10.0 + + @property + def usb_c2_current(self) -> float: + """USB C2 Port current (A). + + :returns: Current of the USB C2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a9", begin=4, end=6) / 10.0 + + @property + def usb_c2_power(self) -> float: + """USB C2 Port power (W). + + :returns: Power of the USB C2 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("a9", begin=6, end=8) / 10.0 + + @property + def usb_port_a1(self) -> PortStatus: + """USB A1 Port Status. + + :returns: Status of the USB A1 port. + """ + return PortStatus(self._parse_int("ac", begin=1, end=2)) + + @property + def usb_a1_voltage(self) -> float: + """USB A1 Port voltage (V). + + :returns: Voltage of the USB A1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("ac", begin=2, end=4) / 10.0 + + @property + def usb_a1_current(self) -> float: + """USB A1 Port current (A). + + :returns: Current of the USB A1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("ac", begin=4, end=6) / 10.0 + + @property + def usb_a1_power(self) -> float: + """USB A1 Port power (W). + + :returns: Power of the USB A1 port or default float value. + """ + if self._data is None: + return DEFAULT_METADATA_FLOAT + + return self._parse_int("ac", begin=6, end=8) / 10.0 diff --git a/docs/source/api.rst b/docs/source/api.rst index 8bcc67c..e5e2cb7 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -23,6 +23,7 @@ the list of properties for that class. solarbank3 prime_charger_160w prime_charger_250w + prime_power_bank_20k generic enums helpers diff --git a/docs/source/index.rst b/docs/source/index.rst index 47d9998..bdc3786 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -137,6 +137,7 @@ Parameter 250w (A2345) 160w (A2687) Display status ❌ ❌ Total power out ❌ ❌ Port on/off control ❌ ✅ +Port protocol control ❌ ❌ Timer control ❌ ✅ Individual port status ✅ ✅ Individual port voltage ✅ ✅ @@ -148,6 +149,26 @@ Serial number ❌ ❌ ======================= ============= ============= +Prime power bank support +------------------------ + +======================= ================= +Parameter 20k/220w (A110B) +======================= ================= +Battery percentage ✅ +Display status ❌ +Total power out ✅ +Port on/off control ❌ +Port protocol control ❌ +Individual port status ✅ +Individual port voltage ✅ +Individual port current ✅ +Individual port power ✅ +Temperature ✅ +Firmware version ❌ +Serial number ❌ +======================= ================= + Contents -------- diff --git a/docs/source/prime_power_bank_20k.rst b/docs/source/prime_power_bank_20k.rst new file mode 100644 index 0000000..ed6bcf9 --- /dev/null +++ b/docs/source/prime_power_bank_20k.rst @@ -0,0 +1,9 @@ +Prime Power Bank 20k 220w +========================= + +.. autoclass:: SolixBLE.PrimePowerBank20k + :members: + :inherited-members: connect, disconnect, add_callback, remove_callback, connected, available, address, name, supports_telemetry, last_update + :special-members: __init__ + :member-order: groupwise + :no-index: diff --git a/examples/demo.py b/examples/demo.py index 7972f0b..7a96559 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -24,6 +24,7 @@ Generic, PrimeCharger160w, PrimeCharger250w, + PrimePowerBank20k, Solarbank2, Solarbank3, SolixBLEDevice, @@ -42,6 +43,7 @@ "Solarbank 3": Solarbank3, "PrimeCharger160w": PrimeCharger160w, "PrimeCharger250w": PrimeCharger250w, + "Prime Power Bank 20k/220w": PrimePowerBank20k, "Unknown": Generic, } diff --git a/tests/test_devices.py b/tests/test_devices.py index f003124..ac4db63 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -23,6 +23,7 @@ PortStatus, PrimeCharger160w, PrimeDevice, + PrimePowerBank20k, Solarbank2, SolixBLEDevice, TemperatureUnit, @@ -548,6 +549,94 @@ }, id="prime_160w_all_three_charging", ), + pytest.param( + PrimePowerBank20k, + "a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000", + { + "battery_percentage": 77, + "temperature": 29, + "power_out": 0.0, + "usb_port_c1": PortStatus.NOT_CONNECTED, + "usb_c1_current": 0.0, + "usb_c1_power": 15.0, + "usb_c1_voltage": 0.0, + "usb_port_c2": PortStatus.NOT_CONNECTED, + "usb_c2_current": 0.0, + "usb_c2_power": 0.0, + "usb_c2_voltage": 0.0, + "usb_port_a1": PortStatus.NOT_CONNECTED, + "usb_a1_current": 0.0, + "usb_a1_power": 0.0, + "usb_a1_voltage": 0.0, + }, + id="prime_power_bank_20k_idle", + ), + pytest.param( + PrimePowerBank20k, + "a10131a20304515ca30404010000a4020101a50404000000a60404013601a7080400000000000000a80f04019500140036010107ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011ab002011bb103020900fe050300000000", + { + "battery_percentage": 81, + "temperature": 26, + "power_out": 31.0, + "usb_port_c1": PortStatus.OUTPUT, + "usb_c1_current": 2.0, + "usb_c1_power": 31.0, + "usb_c1_voltage": 14.9, + "usb_port_c2": PortStatus.NOT_CONNECTED, + "usb_c2_current": 0.0, + "usb_c2_power": 0.0, + "usb_c2_voltage": 0.0, + "usb_port_a1": PortStatus.NOT_CONNECTED, + "usb_a1_current": 0.0, + "usb_a1_power": 0.0, + "usb_a1_voltage": 0.0, + }, + id="prime_power_bank_20k_discharge_c1", + ), + pytest.param( + PrimePowerBank20k, + "a10131a20304505ca30404010000a4020101a50404000000a60404013a01a7080400000000000000a80f0400000000003d01ff00ffffffff00a90f0401950014002a010107ffffffff00ac09040133000300100000af02011bb002011cb103020900fe050300000000", + { + "battery_percentage": 80, + "temperature": 27, + "power_out": 31.4, + "usb_port_c1": PortStatus.NOT_CONNECTED, + "usb_c1_current": 0.0, + "usb_c1_power": 31.7, + "usb_c1_voltage": 0.0, + "usb_port_c2": PortStatus.OUTPUT, + "usb_c2_current": 2.0, + "usb_c2_power": 29.8, + "usb_c2_voltage": 14.9, + "usb_port_a1": PortStatus.OUTPUT, + "usb_a1_current": 0.3, + "usb_a1_power": 1.6, + "usb_a1_voltage": 5.1, + }, + id="prime_power_bank_20k_discharge_c2_a1", + ), + pytest.param( + PrimePowerBank20k, + "a10131a203044b5da30404010018a4020101a50404014102a6040401a300a7080400000000000000a80f04015900100096000107ffffffff00a90f0402c9001c004102ff07ffffffff00ac090401330002000d0000af02011cb002011db103020900fe050300000000", + { + "battery_percentage": 75, + "temperature": 28, + "power_out": 16.3, + "usb_port_c1": PortStatus.OUTPUT, + "usb_c1_current": 1.6, + "usb_c1_power": 15.0, + "usb_c1_voltage": 8.9, + "usb_port_c2": PortStatus.INPUT, + "usb_c2_current": 2.8, + "usb_c2_power": 57.7, + "usb_c2_voltage": 20.1, + "usb_port_a1": PortStatus.OUTPUT, + "usb_a1_current": 0.2, + "usb_a1_power": 1.3, + "usb_a1_voltage": 5.1, + }, + id="prime_power_bank_20k_discharge_c1_a1_charge_c2", + ), pytest.param( C300DC, "a10131a2050300000000a303020000a403020000a503020000a603020000a703020000a803020000a903020000aa03020000ab03020000ac03020000ad03020000ae03020000af03020000b003020000b103020000b203020000b303020000b403020000b5020180b6020100b7020100b8020100b9020100ba020100bb020100bc020100bd020100be020100bf020100c0020100c1020100c2020100c3110020202020202020202020202020202020c403020000c503020000c603020000c7020100c8020100c9020100ca020100cb03020000cc020100cd020100f7050300000000f815040000000000000000000000000000000000000000", @@ -843,6 +932,22 @@ async def test_c1000g2_dc_control() -> None: "6a2c89888de58cce1e15d98eb22669898ec29bcb1519ce19f950439aac9dbcb5", id="solarbank2_1", ), + pytest.param( + PrimePowerBank20k, + [ + "ff091e000300014801ab273ed3e27270c3f4d676ac7d69a00572793732a6", + "ff092b000300014803ab273ed0443800b35db54c6d4a6ec3d48171a04ea7ebce8bf749e5e48c5d991a5e67", + "ff0958000300014829ab273ed144326ada9fc66fa02508c5ddf549ade014d1eeb352fea11c0315b70b8aaa8a734ca5830f8d5827acbaa1224f05ad300b38d27bac9862a768d95c29daed0a89e92feb1d09163a094aa700ff", + "ff091b000300014805abab709a595a803dd04246b78a927453cf65", + "ff095d000300014821ab277f4e77c3b9e1f44367539f64f85d19969d0273c2c0ca93a06f3a010cf636e3b2df75d10791adf1e3c706a3238bcf0a858cd1e2d55d4cf1164a1b7db3b0058c47dfb24c71f11f8a96209d9f0924d420f03120", + "ff091b000300014822e520695552c2745a608fd21cf84bc6e3ccb9", + "ff091b000300014827e520695552c2745a608fd21cf84bc6e3ccbc", + "ff09df000301114a00e5a17fe3ebb89758b89ffb0e7d35a36ffeaeba3e991d79323680049a018c8e719bb706b6d00a142199a6cdc7f05bb5489f1ebb093fe3d134caf7ae5ad7b456867d9a58885cee8479bc10ea2d42d5b94d3b5a929cf4f4fd25f987e5a4922ae6fa744e22289080676583f390c1351a4b68ac5c1dabdcbf8e5e23416e47a0cea7a6062326dd8505464f821ba881f0f6f2c8ea050a7c978962980a539e90879aa1499b5be92fdceb53de533fc2bdd78b7998aec24493fdcfe3d2bc7e95b383744f92a4168819350e89d0d3142d1dbedcb779e45cfad12008", + "ff098300030111430044014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83bb9", + ], + "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", + id="prime_power_bank_20k", + ), ], ) async def test_negotiation( @@ -926,6 +1031,13 @@ async def test_negotiation( "a10131a20302e805a303020000a4020100a5080400000000000000a6080401d84e00000000a7080400000000000000a8020100a9020150aa020100ab090400001c50343b3b3bac0d0401002c0100002c0100000300ad0d0401002c0100002c0100000100ae0d0401002c0100002c0100000300af020101b0020101b1020100b2020101b30201ffb40d04fafffbff00000000fafffbffb50d04ffffffffffffffffffffffffe0050408000000e10b0400000000000000000000fe050300000000", id="prime_160w_telemetry_alt", ), + pytest.param( + PrimePowerBank20k, + "44014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83b", + "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", + "a10131a203044d60a30404010000a4020101a50404000000a60404000000a7080400000000000000a80f0400000000009600ff00ffffffff00a90f0400000000000000ff00ffffffff00ac09040000000000000000af02011db002011eb103020900fe050300000000", + id="prime_power_bank_telemetry", + ), ], ) def test_payload_decryption( @@ -1079,6 +1191,16 @@ def test_payload_decryption( """{'a1': '31', 'a2': '02e805', 'a3': '020000', 'a4': '0100', 'a5': '0401a824fe0b3f0b', 'a6': '0400000000000000', 'a7': '0400000000000000', 'a8': '0103', 'a9': '0150', 'aa': '0100', 'ab': '0400000f0f0f000000', 'ac': '0401002c0100002c0100000203', 'ad': '0401002c0100002c0100000300', 'ae': '0401002c0100002c0100000300', 'af': '0100', 'b0': '0100', 'b1': '0101', 'b2': '0101', 'b3': '0101', 'b4': '04e8040000fafffbfffafffbff', 'b5': '04ffffffffffffffffffffffff', 'e0': '0408000000', 'e1': '0480034b53000000000000', 'fe': '0300000000'}""", id="prime_telemetry_packet", ), + # Test an Anker Prime power bank (single payload device) with a single telemetry packet. + pytest.param( + PrimePowerBank20k, + [ + "ff098300030111430044014f704abfd87d1d38fc0d7a35a36efdaf1f9f9f1c799493804dfaa6882d789fb7aeb4d117bd2330cd63c5f13f1e4a089ce80ac2442c66c85fa1f0dcb0d6867d9a58f7a3ee8479ec124724f6d7b84d8a58939c465ffb24e43754a1889be5f8c946d82d93806765835569e75bd67cbd3ac71071159c13a83bb9" + ], + "5609bc39f79166da75139feb7c335fb7524b3bf0d730db96bf6ebf450d3e165b", + """{'a1': '31', 'a2': '044d60', 'a3': '04010000', 'a4': '0101', 'a5': '04000000', 'a6': '04000000', 'a7': '0400000000000000', 'a8': '0400000000009600ff00ffffffff00', 'a9': '0400000000000000ff00ffffffff00', 'ac': '040000000000000000', 'af': '011d', 'b0': '011e', 'b1': '020900', 'fe': '0300000000'}""", + id="prime_power_bank_telemetry_packet", + ), # Test an Anker Prime device (single payload device) with a single telemetry packet # from the logs of someone elses unit which for some reason transmits telemetry # unencrypted