Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion custom_components/solix_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from SolixBLE import (
AS220,
C300,
C300DC,
C800,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
66 changes: 65 additions & 1 deletion custom_components/solix_ble/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
import uuid as _uuid
from typing import Any

import voluptuous as vol
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""

Expand Down
1 change: 1 addition & 0 deletions custom_components/solix_ble/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
38 changes: 19 additions & 19 deletions custom_components/solix_ble/manifest.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
{
"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==4.0.0b1"],
"loggers": ["bleak", "SolixBLE"],
"version": "3.7.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==4.0.0b1"],
"loggers": ["bleak", "SolixBLE"],
"version": "3.7.0"
}
79 changes: 67 additions & 12 deletions custom_components/solix_ble/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,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(
Expand All @@ -109,6 +124,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,
Expand All @@ -120,8 +141,7 @@ async def async_setup_entry(
F2600,
F3800,
Solarbank2,
PrimePowerBank20k,
]:
PrimePowerBank20k, AS220]:
sensors.append(
SolixSensorEntity(
device,
Expand Down Expand Up @@ -157,7 +177,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,
Expand Down Expand Up @@ -233,8 +253,7 @@ async def async_setup_entry(
F2600,
F3800,
Solarbank2,
PrimePowerBank20k,
]:
PrimePowerBank20k, AS220]:
sensors.append(
SolixSensorEntity(
device,
Expand Down Expand Up @@ -264,16 +283,15 @@ async def async_setup_entry(
F3800,
Solarbank2,
PrimePowerBank20k,
MagGo3in1,
]:
MagGo3in1, AS220]:
sensors.append(
SolixSensorEntity(
device, "Total Power Out", "W", "power_out", SensorDeviceClass.POWER
)
)

# 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,
Expand All @@ -285,7 +303,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,
Expand All @@ -297,7 +315,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,
Expand All @@ -310,6 +328,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(
Expand All @@ -324,7 +356,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,
Expand Down Expand Up @@ -564,6 +596,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,
Expand Down Expand Up @@ -870,7 +925,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,
Expand Down
35 changes: 20 additions & 15 deletions custom_components/solix_ble/strings.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
Loading