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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ This lists the supported devices, more information on what features are supporte
- F2000
- F3800
- Anker Prime 250w Charger
- Solarbank 2
- Potentially more!

## Installation (HACS)
Expand Down
11 changes: 4 additions & 7 deletions custom_components/solix_ble/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolixBLEConfigEntry) ->
entry.runtime_data = device

await hass.config_entries.async_forward_entry_setups(
entry, [Platform.SENSOR, Platform.SWITCH]
entry, [Platform.SENSOR, Platform.SWITCH, Platform.NUMBER, Platform.BUTTON]
)

return True
Expand All @@ -116,15 +116,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolixBLEConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: SolixBLEConfigEntry) -> bool:
"""Unload a config entry."""

unload_ok_sensor = await hass.config_entries.async_forward_entry_unload(
entry, Platform.SENSOR
)
unload_ok_switch = await hass.config_entries.async_forward_entry_unload(
entry, Platform.SWITCH
unload_ok = await hass.config_entries.async_unload_platforms(
entry, [Platform.SENSOR, Platform.SWITCH, Platform.NUMBER, Platform.BUTTON]
)

await entry.runtime_data.disconnect()

entry.runtime_data = None

return unload_ok_sensor and unload_ok_switch
return unload_ok
103 changes: 103 additions & 0 deletions custom_components/solix_ble/button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Button platform."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from homeassistant.components.button import ButtonEntity
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from SolixBLE import Solarbank2, SolixBLEDevice

from .const import DOMAIN
from .number import SCHEDULE_POWER_UNIQUE_SUFFIX

_LOGGER = logging.getLogger(__name__)


if TYPE_CHECKING:
from . import SolixBLEConfigEntry


async def async_setup_entry(
hass: HomeAssistant,
config_entry: SolixBLEConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the buttons."""

device = config_entry.runtime_data
buttons: list[ButtonEntity] = []

if type(device) is Solarbank2:
buttons.append(SolixApplyScheduleButtonEntity(device))

async_add_entities(buttons)


class SolixApplyScheduleButtonEntity(ButtonEntity):
"""Apply the staged Output Power Target to a Solarbank 2 via set_schedule()."""

_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG

def __init__(self, device: SolixBLEDevice) -> None:
"""Initialize the entity."""
self._device = device
self._number_unique_id = f"{device.address}_{SCHEDULE_POWER_UNIQUE_SUFFIX}"
self._attr_name = "Apply Power Target"
self._attr_unique_id = f"{device.address}_apply_schedule"
self._attr_device_info = DeviceInfo(
name=device.name,
connections={(CONNECTION_BLUETOOTH, device.address)},
)
self._attr_available = device.available

async def async_added_to_hass(self) -> None:
"""Subscribe to availability updates."""
await super().async_added_to_hass()
self._device.add_callback(self._availability_callback)

async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from device callbacks."""
self._device.remove_callback(self._availability_callback)

def _availability_callback(self) -> None:
"""Update availability from device state."""
self._attr_available = self._device.available
self.async_write_ha_state()

async def async_press(self) -> None:
"""Send the current Output Power Target value to the device."""
registry = er.async_get(self.hass)
number_entity_id = registry.async_get_entity_id(
NUMBER_DOMAIN, DOMAIN, self._number_unique_id
)
if number_entity_id is None:
raise HomeAssistantError(
"Output Power Target entity is not registered yet"
)

state = self.hass.states.get(number_entity_id)
if state is None or state.state in ("unknown", "unavailable"):
raise HomeAssistantError("Output Power Target is not set")

try:
power_w = int(round(float(state.state)))
except (TypeError, ValueError) as err:
raise HomeAssistantError(
f"Output Power Target has an invalid value: {state.state!r}"
) from err

try:
await self._device.set_schedule(power_w=power_w)
except (ConnectionError, ValueError) as err:
raise HomeAssistantError(str(err)) from err

_LOGGER.debug("set_schedule(%d W) sent to %s", power_w, self._device.name)
114 changes: 114 additions & 0 deletions custom_components/solix_ble/number.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Number platform."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from SolixBLE import Solarbank2, SolixBLEDevice

_LOGGER = logging.getLogger(__name__)


if TYPE_CHECKING:
from . import SolixBLEConfigEntry


SCHEDULE_POWER_UNIQUE_SUFFIX = "output_power_target"


async def async_setup_entry(
hass: HomeAssistant,
config_entry: SolixBLEConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the numbers."""

device = config_entry.runtime_data
numbers: list[NumberEntity] = []

if type(device) is Solarbank2:
numbers.append(SolixSchedulePowerNumberEntity(device))

async_add_entities(numbers)


class SolixSchedulePowerNumberEntity(NumberEntity, RestoreEntity):
"""Output power target for the Solarbank 2.

Stores the value the user wants to apply; the actual BLE write is
performed by the matching Apply button. The device does not report
the current schedule back, so the value is restored from HA state
on startup.
"""

_attr_has_entity_name = True
_attr_native_min_value = 0
_attr_native_max_value = 800
_attr_native_step = 10
_attr_native_unit_of_measurement = UnitOfPower.WATT
_attr_mode = NumberMode.SLIDER
_attr_device_class = NumberDeviceClass.POWER
_attr_entity_category = EntityCategory.CONFIG

def __init__(self, device: SolixBLEDevice) -> None:
"""Initialize the entity."""
self._device = device
self._attr_name = "Output Power Target"
self._attr_unique_id = f"{device.address}_{SCHEDULE_POWER_UNIQUE_SUFFIX}"
self._attr_device_info = DeviceInfo(
name=device.name,
connections={(CONNECTION_BLUETOOTH, device.address)},
)
self._attr_native_value = None
self._attr_available = device.available

async def async_added_to_hass(self) -> None:
"""Restore last value and subscribe to availability updates."""
await super().async_added_to_hass()

last_state = await self.async_get_last_state()
if last_state is not None and last_state.state not in ("unknown", "unavailable"):
try:
restored = float(last_state.state)
except (TypeError, ValueError):
restored = None
if restored is not None:
self._attr_native_value = max(
self._attr_native_min_value,
min(self._attr_native_max_value, restored),
)

self._device.add_callback(self._availability_callback)

async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from device callbacks."""
self._device.remove_callback(self._availability_callback)

def _availability_callback(self) -> None:
"""Update availability from device state."""
self._attr_available = self._device.available
self.async_write_ha_state()

async def async_set_native_value(self, value: float) -> None:
"""Store the new value; do not write to the device until Apply is pressed."""
# Solarbank 2-specific: We snap the native step (10 W) of the slider in the
# original Anker app to avoid trouble.
# If future devices add Number entities, they should decide their own snapping policy.
step = self._attr_native_step
snapped = round(value / step) * step
self._attr_native_value = max(
self._attr_native_min_value,
min(self._attr_native_max_value, snapped),
)
self.async_write_ha_state()
2 changes: 1 addition & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ def get_service_info(self) -> BluetoothServiceInfoBleak:
"battery_charge_power": 0,
"pv_yield": 1053.2,
"charged_energy": ("battery_input_energy", 124.3),
"output_energy": ("battery_output_energy", 12.7),
"output_energy": ("total_output_energy", 12.7),
"output_cutoff_data": ("output_cutoff_threshold", SBPowerCutoff.P5, "5%"),
"input_cutoff_data": ("input_cutoff_threshold", SBPowerCutoff.P10, "10%"),
"battery_discharge_power": 12.4,
Expand Down
Loading