From 0281e023a00678065a945f847dafc65188680853 Mon Sep 17 00:00:00 2001 From: Chris Moore Date: Tue, 25 Aug 2026 20:28:37 -0400 Subject: [PATCH 1/4] Add S2000 (AS220) support with local UUID binding Wires up the new SolixBLE AS220 (SOLIX S2000) device: - const/__init__/sensor: register the model, map it to SolixBLE.AS220, and expose its sensors (battery %, temperature, total/AC power in/out, solar in, serial). - config_flow: the S2000 only streams telemetry to a client UUID bound to the unit, so selecting it adds a bind step. The integration generates a random UUID and asks the user to press the unit's Power button once to confirm the (local, account-free) binding; the UUID is stored in the config entry and reused on every connect. The device is single-owner, so this replaces the phone app's pairing. - manifest: requires SolixBLE>=3.10.0 (the release that adds AS220). Depends on flip-dots/SolixBLE#65. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph --- custom_components/solix_ble/__init__.py | 8 +- custom_components/solix_ble/config_flow.py | 66 +++++++++++++- custom_components/solix_ble/const.py | 1 + custom_components/solix_ble/manifest.json | 45 ++++++---- custom_components/solix_ble/sensor.py | 18 ++-- custom_components/solix_ble/strings.json | 35 ++++---- .../solix_ble/translations/en.json | 39 +++++---- tests/__init__.py | 7 ++ tests/test_config_flow.py | 86 +++++++++++++++++++ 9 files changed, 243 insertions(+), 62 deletions(-) diff --git a/custom_components/solix_ble/__init__.py b/custom_components/solix_ble/__init__.py index f0a5445..1b7a194 100644 --- a/custom_components/solix_ble/__init__.py +++ b/custom_components/solix_ble/__init__.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from SolixBLE import ( + AS220, C300, C300DC, C800, @@ -64,6 +65,8 @@ def get_power_station_class(model: Models) -> SolixBLEDevice: return MagGo3in1 elif model is Models.SOLARBANK_2: return Solarbank2 + elif model is Models.AS220: + return AS220 elif model is Models.UNKNOWN: return Generic else: @@ -97,7 +100,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolixBLEConfigEntry) -> f"updates will be printed in the log and this can be used to aid in adding support for new devices." ) - device = DeviceClass(ble_device) + if model is Models.AS220: + device = DeviceClass(ble_device, client_uuid=entry.data.get("client_uuid")) + else: + device = DeviceClass(ble_device) try: await device.connect() except Exception as e: diff --git a/custom_components/solix_ble/config_flow.py b/custom_components/solix_ble/config_flow.py index 073baf4..a42cdb0 100644 --- a/custom_components/solix_ble/config_flow.py +++ b/custom_components/solix_ble/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import uuid as _uuid from typing import Any import voluptuous as vol @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry, selector -from SolixBLE import Generic +from SolixBLE import AS220, Generic from . import get_power_station_class from .const import DOMAIN, Models @@ -66,6 +67,8 @@ class SolixBLEConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None + self._model: Models | None = None + self._client_uuid: str | None = None async def async_step_bluetooth( self, discovery_info: bluetooth.BluetoothServiceInfoBleak @@ -102,6 +105,11 @@ async def async_step_confirm( await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() model = Models(user_input["device_model"]) + self._model = model + if model is Models.AS220: + # The S2000 needs a client UUID bound to the unit; hand off to + # the bind step (generate a UUID + press-the-Power-button flow). + return await self.async_step_bind() await validate_input(self.hass, unique_id, model) except CannotConnect: @@ -140,6 +148,62 @@ async def async_step_confirm( ) + async def async_step_bind( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Bind a generated client UUID to an S2000 (AS220). + + The S2000 only streams telemetry to a UUID that has been registered with + the unit. We generate one and ask the user to press the Power button once + to confirm the (local, account-free) binding. + """ + assert self._discovery_info is not None + errors: dict[str, str] = {} + + # Generate the UUID once and reuse it across retries. + if self._client_uuid is None: + self._client_uuid = str(_uuid.uuid4()) + + if user_input is not None: + address = self._discovery_info.address + ble_device = async_ble_device_from_address( + self.hass, address.upper(), connectable=True + ) + if ble_device is None: + errors["base"] = "not_found" + else: + device = AS220(ble_device, client_uuid=self._client_uuid) + try: + bound = await device.bind() + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception during S2000 bind") + errors["base"] = "unknown" + bound = False + finally: + await device.disconnect() + + if bound: + return self.async_create_entry( + title=self._discovery_info.name, + data={ + "model": Models.AS220.value, + "client_uuid": self._client_uuid, + }, + ) + if not errors: + errors["base"] = "bind_failed" + + return self.async_show_form( + step_id="bind", + data_schema=vol.Schema({}), + errors=errors, + description_placeholders={ + CONF_NAME: self._discovery_info.name, + CONF_MAC: self._discovery_info.address, + }, + ) + + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/custom_components/solix_ble/const.py b/custom_components/solix_ble/const.py index 03426e2..371e737 100644 --- a/custom_components/solix_ble/const.py +++ b/custom_components/solix_ble/const.py @@ -99,4 +99,5 @@ class Models(Enum): PRIME_POWER_BANK_20K = "Prime Power Bank 20k (220w)" SOLARBANK_2 = "Solarbank 2" MAGGO_3IN1 = "MagGo 3-in-1 Wireless Charger (A25x7)" + AS220 = "S2000 (AS220)" UNKNOWN = "Unknown" diff --git a/custom_components/solix_ble/manifest.json b/custom_components/solix_ble/manifest.json index 38a60ea..1f0cc67 100644 --- a/custom_components/solix_ble/manifest.json +++ b/custom_components/solix_ble/manifest.json @@ -1,20 +1,29 @@ { - "domain": "solix_ble", - "name": "Solix BLE", - "bluetooth": [ - { - "connectable": true, - "service_uuid": "0000ff09-0000-1000-8000-00805f9b34fb" - } - ], - "codeowners": ["@flip-dots"], - "config_flow": true, - "dependencies": ["bluetooth_adapters"], - "documentation": "https://github.com/flip-dots/HaSolixBLE", - "integration_type": "device", - "iot_class": "local_push", - "issue_tracker": "https://github.com/flip-dots/HaSolixBLE/issues", - "requirements": ["SolixBLE==3.9.0"], - "loggers": ["bleak", "SolixBLE"], - "version": "3.6.0" + "domain": "solix_ble", + "name": "Solix BLE", + "bluetooth": [ + { + "connectable": true, + "service_uuid": "0000ff09-0000-1000-8000-00805f9b34fb" + } + ], + "codeowners": [ + "@flip-dots" + ], + "config_flow": true, + "dependencies": [ + "bluetooth_adapters" + ], + "documentation": "https://github.com/flip-dots/HaSolixBLE", + "integration_type": "device", + "iot_class": "local_push", + "issue_tracker": "https://github.com/flip-dots/HaSolixBLE/issues", + "requirements": [ + "SolixBLE>=3.10.0" + ], + "loggers": [ + "bleak", + "SolixBLE" + ], + "version": "3.6.0" } \ No newline at end of file diff --git a/custom_components/solix_ble/sensor.py b/custom_components/solix_ble/sensor.py index e46e3ff..18cac12 100644 --- a/custom_components/solix_ble/sensor.py +++ b/custom_components/solix_ble/sensor.py @@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local from SolixBLE import ( + AS220, C300, C300DC, C800, @@ -120,8 +121,7 @@ async def async_setup_entry( F2600, F3800, Solarbank2, - PrimePowerBank20k, - ]: + PrimePowerBank20k, AS220]: sensors.append( SolixSensorEntity( device, @@ -233,8 +233,7 @@ async def async_setup_entry( F2600, F3800, Solarbank2, - PrimePowerBank20k, - ]: + PrimePowerBank20k, AS220]: sensors.append( SolixSensorEntity( device, @@ -264,8 +263,7 @@ async def async_setup_entry( F3800, Solarbank2, PrimePowerBank20k, - MagGo3in1, - ]: + MagGo3in1, AS220]: sensors.append( SolixSensorEntity( device, "Total Power Out", "W", "power_out", SensorDeviceClass.POWER @@ -273,7 +271,7 @@ async def async_setup_entry( ) # AC power in sensor - if type(device) in [C300, C800, C1000, C1000G2, F2000, F2600, F3800]: + if type(device) in [C300, C800, C1000, C1000G2, F2000, F2600, F3800, AS220]: sensors.append( SolixSensorEntity( device, @@ -285,7 +283,7 @@ async def async_setup_entry( ) # AC power out sensor - if type(device) in [C300, C800, C1000, C1000G2, F2000, F2600, F3800, Solarbank2]: + if type(device) in [C300, C800, C1000, C1000G2, F2000, F2600, F3800, Solarbank2, AS220]: sensors.append( SolixSensorEntity( device, @@ -324,7 +322,7 @@ async def async_setup_entry( ) # Solar power in - if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F2600, F3800, Solarbank2]: + if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F2600, F3800, Solarbank2, AS220]: sensors.append( SolixSensorEntity( device, @@ -870,7 +868,7 @@ async def async_setup_entry( ) # Serial number - if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F2600, F3800, Solarbank2]: + if type(device) in [C300, C300DC, C800, C1000, C1000G2, F2000, F2600, F3800, Solarbank2, AS220]: sensors.append( SolixSensorEntity( device, diff --git a/custom_components/solix_ble/strings.json b/custom_components/solix_ble/strings.json index a73172a..25b6097 100644 --- a/custom_components/solix_ble/strings.json +++ b/custom_components/solix_ble/strings.json @@ -1,20 +1,25 @@ { - "config": { - "step": { - "confirm": { - "description": "Select the correct model for {name} ({mac}). Make sure the connection light is blinking!" - } + "config": { + "step": { + "confirm": { + "description": "Select the correct model for {name} ({mac}). Make sure the connection light is blinking!" }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_negotiate": "Failed to negotiate encryption, is the device too far?.", - "no_scanners": "No Bluetooth scanners are available to search for the device.", - "not_found": "The device was not found.", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "not_implemented": "This integration can only be setup via Bluetooth discovery. No devices have been found." + "bind": { + "title": "Bind Home Assistant to your S2000", + "description": "The S2000 ({name}) needs a one-time local pairing (no Anker account). Press the Power button once on the unit, then submit to register Home Assistant. Note: the S2000 is single-owner, so this replaces the phone app's pairing." } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_negotiate": "Failed to negotiate encryption, is the device too far?.", + "no_scanners": "No Bluetooth scanners are available to search for the device.", + "not_found": "The device was not found.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "bind_failed": "Binding was not confirmed. Press the Power button on the S2000 once during the pairing window, then try again." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "not_implemented": "This integration can only be setup via Bluetooth discovery. No devices have been found." } + } } \ No newline at end of file diff --git a/custom_components/solix_ble/translations/en.json b/custom_components/solix_ble/translations/en.json index 4e95857..b37976d 100644 --- a/custom_components/solix_ble/translations/en.json +++ b/custom_components/solix_ble/translations/en.json @@ -1,20 +1,25 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured", - "not_implemented": "This integration can only be setup via Bluetooth discovery. No devices have been found." - }, - "error": { - "cannot_connect": "Failed to connect", - "cannot_negotiate": "Failed to negotiate encryption, is the device too far?.", - "no_scanners": "No Bluetooth scanners are available to search for the device.", - "not_found": "The device was not found.", - "unknown": "Unexpected error" - }, - "step": { - "confirm": { - "description": "Select the correct model for {name} ({mac}). Make sure the connection light is blinking!" - } - } + "config": { + "abort": { + "already_configured": "Device is already configured", + "not_implemented": "This integration can only be setup via Bluetooth discovery. No devices have been found." + }, + "error": { + "cannot_connect": "Failed to connect", + "cannot_negotiate": "Failed to negotiate encryption, is the device too far?.", + "no_scanners": "No Bluetooth scanners are available to search for the device.", + "not_found": "The device was not found.", + "unknown": "Unexpected error", + "bind_failed": "Binding was not confirmed. Press the Power button on the S2000 once during the pairing window, then try again." + }, + "step": { + "confirm": { + "description": "Select the correct model for {name} ({mac}). Make sure the connection light is blinking!" + }, + "bind": { + "title": "Bind Home Assistant to your S2000", + "description": "The S2000 ({name}) needs a one-time local pairing (no Anker account). Press the Power button once on the unit, then submit to register Home Assistant. Note: the S2000 is single-owner, so this replaces the phone app's pairing." + } } + } } \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py index a8d659c..ed08f4f 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -147,6 +147,13 @@ def get_service_info(self) -> BluetoothServiceInfoBleak: model_class=Models.C1000G2, ) +MOCK_S2000_DETAILS = MockDeviceDetails( + name="Anker SOLIX S2000", + addr="AA:BB:CC:DD:EE:20", + model_string="S2000 (AS220)", + model_class=Models.AS220, +) + MOCK_F2000_DETAILS = MockDeviceDetails( name="Anker SOLIX F2000", addr="AA:BB:CC:DD:EE:03", diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0ec3e6d..286dc94 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -23,6 +23,7 @@ MOCK_F2000_DETAILS, MOCK_F2600_DETAILS, MOCK_F3800_DETAILS, + MOCK_S2000_DETAILS, MOCK_MAGGO_3IN1_DETAILS, MOCK_PRIME_160_DETAILS, MOCK_PRIME_250_DETAILS, @@ -347,3 +348,88 @@ async def test_bluetooth_form_multiple_set_up( CONF_NAME: mock_device_details.name, CONF_MAC: mock_device_details.addr, } + + +async def test_bluetooth_form_as220_bind( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Selecting the S2000 routes to the bind step and stores a generated UUID.""" + details = MOCK_S2000_DETAILS + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=details.get_service_info(), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + # Selecting AS220 hands off to the bind step (not create_entry). + with patch( + "custom_components.solix_ble.config_flow.async_ble_device_from_address", + return_value=details.get_ble_device(), + ): + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={"device_model": details.model_string}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bind" + + # A confirmed bind creates the entry with a generated client_uuid. + with ( + patch( + "custom_components.solix_ble.config_flow.async_ble_device_from_address", + return_value=details.get_ble_device(), + ), + patch("SolixBLE.AS220.bind", side_effect=[True]), + patch("SolixBLE.AS220.disconnect", side_effect=[None]), + ): + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].data["model"] == details.model_string + assert len(result["result"].data["client_uuid"]) == 36 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_bluetooth_form_as220_bind_failed( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """An unconfirmed bind (no button press) re-shows the bind form with an error.""" + details = MOCK_S2000_DETAILS + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=details.get_service_info(), + ) + with patch( + "custom_components.solix_ble.config_flow.async_ble_device_from_address", + return_value=details.get_ble_device(), + ): + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={"device_model": details.model_string}, + ) + assert result["step_id"] == "bind" + + with ( + patch( + "custom_components.solix_ble.config_flow.async_ble_device_from_address", + return_value=details.get_ble_device(), + ), + patch("SolixBLE.AS220.bind", side_effect=[False]), + patch("SolixBLE.AS220.disconnect", side_effect=[None]), + ): + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bind" + assert result["errors"] == {"base": "bind_failed"} From eb66fc15769e844d5affcd40ce5d50da55c327c4 Mon Sep 17 00:00:00 2001 From: Chris Moore Date: Tue, 25 Aug 2026 21:15:40 -0400 Subject: [PATCH 2/4] Add AS220 telemetry sensors Wire the newly-mapped S2000 fields to sensors: battery health, AC input status (charger connected), AC output status, and USB-C1 power/status. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph --- custom_components/solix_ble/sensor.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/custom_components/solix_ble/sensor.py b/custom_components/solix_ble/sensor.py index 18cac12..449a382 100644 --- a/custom_components/solix_ble/sensor.py +++ b/custom_components/solix_ble/sensor.py @@ -157,7 +157,7 @@ async def async_setup_entry( ) # Battery health sensor - if type(device) in [C300DC, C800, C1000, C1000G2, F2000, F2600]: + if type(device) in [C300DC, C800, C1000, C1000G2, F2000, F2600, AS220]: sensors.append( SolixSensorEntity( device, @@ -295,7 +295,7 @@ async def async_setup_entry( ) # AC output on/off sensor - if type(device) in [C300, C800, C1000, C1000G2, F2600, F3800]: + if type(device) in [C300, C800, C1000, C1000G2, F2600, F3800, AS220]: sensors.append( SolixSensorEntity( device, @@ -308,6 +308,20 @@ async def async_setup_entry( ) ) + # AC input on/off sensor (wall charger connected) + if type(device) in [AS220]: + sensors.append( + SolixSensorEntity( + device, + "Status AC In", + None, + "ac_input", + SensorDeviceClass.ENUM, + PORT_STATUS_STRINGS, + None, + ) + ) + # AC output timer if type(device) in [C300, C800, C1000, F2600]: sensors.append( @@ -436,6 +450,7 @@ async def async_setup_entry( PrimeCharger160w, PrimeCharger250w, PrimePowerBank20k, + AS220, ]: sensors.append( SolixSensorEntity( @@ -549,6 +564,7 @@ async def async_setup_entry( PrimeCharger160w, PrimeCharger250w, PrimePowerBank20k, + AS220, ]: sensors.append( SolixSensorEntity( From f541cc250e000d35a41736beb24c55c3cf6e086c Mon Sep 17 00:00:00 2001 From: Chris Moore Date: Tue, 25 Aug 2026 23:24:11 -0400 Subject: [PATCH 3/4] AS220: add Remaining Time + aggregate USB sensors Wire the newly-mapped fields: Remaining Time (time_remaining, hours) and a single aggregate USB Power + Status USB (the S2000 reports total USB, not per-port). Drops AS220 from the per-port USB C1 sensor blocks. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph --- custom_components/solix_ble/sensor.py | 31 +++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/custom_components/solix_ble/sensor.py b/custom_components/solix_ble/sensor.py index 449a382..74bf530 100644 --- a/custom_components/solix_ble/sensor.py +++ b/custom_components/solix_ble/sensor.py @@ -110,6 +110,12 @@ async def async_setup_entry( ) ), + # Remaining time (S2000 reports a single tenths-of-hour estimate) + if type(device) in [AS220]: + sensors.append( + SolixSensorEntity(device, "Remaining Time", "hours", "time_remaining"), + ) + # Battery percentage sensor if type(device) in [ C300, @@ -450,7 +456,6 @@ async def async_setup_entry( PrimeCharger160w, PrimeCharger250w, PrimePowerBank20k, - AS220, ]: sensors.append( SolixSensorEntity( @@ -564,7 +569,6 @@ async def async_setup_entry( PrimeCharger160w, PrimeCharger250w, PrimePowerBank20k, - AS220, ]: sensors.append( SolixSensorEntity( @@ -578,6 +582,29 @@ async def async_setup_entry( ) ) + # USB total power + status (S2000 reports one aggregate figure for all USB ports) + if type(device) in [AS220]: + sensors.append( + SolixSensorEntity( + device, + "USB Power", + "W", + "usb_power", + SensorDeviceClass.POWER, + ) + ) + sensors.append( + SolixSensorEntity( + device, + "Status USB", + None, + "usb_output", + SensorDeviceClass.ENUM, + PORT_STATUS_STRINGS, + None, + ) + ) + # USB C2 status if type(device) in [ C300, From 29c191cfabc767c5f6f4d28ce4ffc6842cf2501b Mon Sep 17 00:00:00 2001 From: Chris Moore Date: Wed, 26 Aug 2026 01:12:10 -0400 Subject: [PATCH 4/4] AS220: add Charging Status sensor Wire charging_status (battery power-flow: idle/discharging/charging) using the existing CHARGING_STATUS_C300_STRINGS enum labels. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011WbLpvDpPK2ykge7M5V5ph --- custom_components/solix_ble/sensor.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/custom_components/solix_ble/sensor.py b/custom_components/solix_ble/sensor.py index 74bf530..60b5d48 100644 --- a/custom_components/solix_ble/sensor.py +++ b/custom_components/solix_ble/sensor.py @@ -88,6 +88,20 @@ async def async_setup_entry( ) ) + # Charging status sensor (S2000 battery power-flow: idle/discharging/charging) + if type(device) in [AS220]: + sensors.append( + SolixSensorEntity( + device, + "Charging Status", + None, + "charging_status", + SensorDeviceClass.ENUM, + CHARGING_STATUS_C300_STRINGS, + None, + ) + ) + # Time remaining sensor if type(device) in [C300, C300DC, C800, C1000, F2000, F2600, F3800]: sensors.append(