diff --git a/README.md b/README.md index efaf047..de33bbd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/custom_components/solix_ble/__init__.py b/custom_components/solix_ble/__init__.py index 1cf9fbd..43e3487 100644 --- a/custom_components/solix_ble/__init__.py +++ b/custom_components/solix_ble/__init__.py @@ -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 @@ -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 diff --git a/custom_components/solix_ble/button.py b/custom_components/solix_ble/button.py new file mode 100644 index 0000000..015b330 --- /dev/null +++ b/custom_components/solix_ble/button.py @@ -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) diff --git a/custom_components/solix_ble/number.py b/custom_components/solix_ble/number.py new file mode 100644 index 0000000..5d89cdd --- /dev/null +++ b/custom_components/solix_ble/number.py @@ -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() diff --git a/tests/__init__.py b/tests/__init__.py index 0c76580..4a10510 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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, diff --git a/tests/test_button.py b/tests/test_button.py new file mode 100644 index 0000000..d944fec --- /dev/null +++ b/tests/test_button.py @@ -0,0 +1,210 @@ +"""Test the Apply button for SolixBLE Solarbank 2.""" + +import asyncio +from contextlib import ExitStack +from unittest.mock import patch + +import pytest +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS, +) +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.solix_ble.const import DOMAIN + +from . import ( + MOCK_SOLAR_BANK_2_DETAILS, + MockDeviceDetails, +) + + +def _enter_device_setup_patches(stack: ExitStack, class_name: str, mock_device_details): + """Enter the standard set of patches that bring the integration up.""" + stack.enter_context( + patch( + "custom_components.solix_ble.async_ble_device_from_address", + return_value=mock_device_details.get_ble_device(), + ) + ) + stack.enter_context( + patch("custom_components.solix_ble.async_scanner_count", return_value=1) + ) + stack.enter_context( + patch(f"SolixBLE.{class_name}.connect", autospec=True, return_value=True) + ) + stack.enter_context(patch(f"SolixBLE.{class_name}.connected", side_effect=[True])) + stack.enter_context(patch(f"SolixBLE.{class_name}.connected", side_effect=[True])) + stack.enter_context(patch(f"SolixBLE.{class_name}.negotiated", side_effect=[True])) + stack.enter_context( + patch("SolixBLE.SolixBLEDevice.available", side_effect=[True]) + ) + + +NUMBER_ENTITY_ID = "number.solar_bank_2_output_power_target" +BUTTON_ENTITY_ID = "button.solar_bank_2_apply_power_target" + + +@pytest.mark.parametrize( + "mock_config_entry,mock_device_details", + [pytest.param(MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, id="sb2")], + indirect=["mock_config_entry"], +) +async def test_apply_button_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_details: MockDeviceDetails, +) -> None: + """The Apply button is registered for a Solarbank 2.""" + mock_config_entry.add_to_hass(hass) + + with ExitStack() as stack: + _enter_device_setup_patches(stack, "Solarbank2", mock_device_details) + stack.enter_context(patch("SolixBLE.Solarbank2.set_schedule")) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + await asyncio.sleep(1) + + assert hass.states.get(BUTTON_ENTITY_ID) is not None + + +@pytest.mark.parametrize( + "mock_config_entry,mock_device_details", + [pytest.param(MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, id="sb2")], + indirect=["mock_config_entry"], +) +async def test_apply_button_calls_set_schedule( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_details: MockDeviceDetails, +) -> None: + """Pressing Apply sends the current Number value via set_schedule.""" + mock_config_entry.add_to_hass(hass) + + with ExitStack() as stack: + _enter_device_setup_patches(stack, "Solarbank2", mock_device_details) + mock_set_schedule = stack.enter_context( + patch("SolixBLE.Solarbank2.set_schedule") + ) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + await asyncio.sleep(1) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: NUMBER_ENTITY_ID, ATTR_VALUE: 250}, + blocking=True, + ) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: BUTTON_ENTITY_ID}, + blocking=True, + ) + + mock_set_schedule.assert_called_once_with(power_w=250) + + +@pytest.mark.parametrize( + "mock_config_entry,mock_device_details", + [pytest.param(MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, id="sb2")], + indirect=["mock_config_entry"], +) +async def test_apply_button_raises_when_number_unset( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_details: MockDeviceDetails, +) -> None: + """Pressing Apply before setting the Number raises and does not call the device.""" + mock_config_entry.add_to_hass(hass) + + with ExitStack() as stack: + _enter_device_setup_patches(stack, "Solarbank2", mock_device_details) + mock_set_schedule = stack.enter_context( + patch("SolixBLE.Solarbank2.set_schedule") + ) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + await asyncio.sleep(1) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: BUTTON_ENTITY_ID}, + blocking=True, + ) + + mock_set_schedule.assert_not_called() + + +@pytest.mark.parametrize( + "mock_config_entry,mock_device_details,raised_exception", + [ + pytest.param( + MOCK_SOLAR_BANK_2_DETAILS, + MOCK_SOLAR_BANK_2_DETAILS, + ConnectionError("not connected"), + id="connection_error", + ), + pytest.param( + MOCK_SOLAR_BANK_2_DETAILS, + MOCK_SOLAR_BANK_2_DETAILS, + ValueError("power_w must be 0-800 W"), + id="value_error", + ), + ], + indirect=["mock_config_entry"], +) +async def test_apply_button_wraps_library_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_details: MockDeviceDetails, + raised_exception: Exception, +) -> None: + """Library errors from set_schedule surface as HomeAssistantError.""" + mock_config_entry.add_to_hass(hass) + + with ExitStack() as stack: + _enter_device_setup_patches(stack, "Solarbank2", mock_device_details) + stack.enter_context( + patch( + "SolixBLE.Solarbank2.set_schedule", + side_effect=raised_exception, + ) + ) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + await asyncio.sleep(1) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: NUMBER_ENTITY_ID, ATTR_VALUE: 100}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: BUTTON_ENTITY_ID}, + blocking=True, + ) + + diff --git a/tests/test_number.py b/tests/test_number.py new file mode 100644 index 0000000..e41644f --- /dev/null +++ b/tests/test_number.py @@ -0,0 +1,207 @@ +"""Test number entities for SolixBLE integration.""" + +import asyncio +from contextlib import ExitStack +from unittest.mock import patch + +import pytest +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, + mock_restore_cache, +) + +from custom_components.solix_ble.const import DOMAIN + +from . import ( + MOCK_SOLAR_BANK_2_DETAILS, + MockDeviceDetails, +) + + +def _enter_device_setup_patches(stack: ExitStack, class_name: str, mock_device_details): + """Enter the standard set of patches that bring the integration up.""" + stack.enter_context( + patch( + "custom_components.solix_ble.async_ble_device_from_address", + return_value=mock_device_details.get_ble_device(), + ) + ) + stack.enter_context( + patch("custom_components.solix_ble.async_scanner_count", return_value=1) + ) + stack.enter_context( + patch(f"SolixBLE.{class_name}.connect", autospec=True, return_value=True) + ) + stack.enter_context(patch(f"SolixBLE.{class_name}.connected", side_effect=[True])) + stack.enter_context(patch(f"SolixBLE.{class_name}.connected", side_effect=[True])) + stack.enter_context(patch(f"SolixBLE.{class_name}.negotiated", side_effect=[True])) + stack.enter_context( + patch("SolixBLE.SolixBLEDevice.available", side_effect=[True]) + ) + + +@pytest.mark.parametrize( + "mock_config_entry,mock_device_details", + [pytest.param(MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, id="sb2")], + indirect=["mock_config_entry"], +) +async def test_number_entity_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_details: MockDeviceDetails, +) -> None: + """The Output Power Target number is registered for a Solarbank 2 and starts unknown.""" + mock_config_entry.add_to_hass(hass) + + with ExitStack() as stack: + _enter_device_setup_patches(stack, "Solarbank2", mock_device_details) + mock_set_schedule = stack.enter_context( + patch("SolixBLE.Solarbank2.set_schedule") + ) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + await asyncio.sleep(1) + + entity_id = "number.solar_bank_2_output_power_target" + state = hass.states.get(entity_id) + assert state is not None, "Expected Output Power Target entity to exist" + assert state.state == STATE_UNKNOWN + attrs = state.attributes + assert attrs["min"] == 0 + assert attrs["max"] == 800 + assert attrs["step"] == 10 + assert attrs["unit_of_measurement"] == "W" + mock_set_schedule.assert_not_called() + + +@pytest.mark.parametrize( + "mock_config_entry,mock_device_details", + [pytest.param(MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, id="sb2")], + indirect=["mock_config_entry"], +) +async def test_number_set_value_does_not_call_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_details: MockDeviceDetails, +) -> None: + """Setting the number value stores it locally without invoking set_schedule.""" + mock_config_entry.add_to_hass(hass) + + with ExitStack() as stack: + _enter_device_setup_patches(stack, "Solarbank2", mock_device_details) + mock_set_schedule = stack.enter_context( + patch("SolixBLE.Solarbank2.set_schedule") + ) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + await asyncio.sleep(1) + + entity_id = "number.solar_bank_2_output_power_target" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 250}, + blocking=True, + ) + + assert hass.states.get(entity_id).state == "250" + mock_set_schedule.assert_not_called() + + +@pytest.mark.parametrize( + "mock_config_entry,mock_device_details,input_value,expected_state", + [ + pytest.param( + MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, 257, "260", + id="snap_up", + ), + pytest.param( + MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, 254, "250", + id="snap_down", + ), + pytest.param( + MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, 799, "800", + id="snap_up_at_max_boundary", + ), + ], + indirect=["mock_config_entry"], +) +async def test_number_snaps_to_step( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_details: MockDeviceDetails, + input_value: int, + expected_state: str, +) -> None: + """SB2-specific: non-multiple-of-10 inputs are rounded to the nearest 10 W. + + HA's number platform rejects out-of-range values *before* they reach the + entity, so we only need to verify rounding behavior for in-range inputs + (including a boundary case where rounding lands on max). + + The snap policy lives in SolixSchedulePowerNumberEntity.async_set_native_value + and is tied to the SB2 Output Power Target. Future devices that add Number + entities should make their own snapping decision and have separate tests. + """ + mock_config_entry.add_to_hass(hass) + + with ExitStack() as stack: + _enter_device_setup_patches(stack, "Solarbank2", mock_device_details) + stack.enter_context(patch("SolixBLE.Solarbank2.set_schedule")) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + await asyncio.sleep(1) + + entity_id = "number.solar_bank_2_output_power_target" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: input_value}, + blocking=True, + ) + + assert hass.states.get(entity_id).state == expected_state + + +@pytest.mark.parametrize( + "mock_config_entry,mock_device_details", + [pytest.param(MOCK_SOLAR_BANK_2_DETAILS, MOCK_SOLAR_BANK_2_DETAILS, id="sb2")], + indirect=["mock_config_entry"], +) +async def test_number_restores_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_details: MockDeviceDetails, +) -> None: + """A previously-saved value is restored on startup via RestoreEntity.""" + mock_config_entry.add_to_hass(hass) + + entity_id = "number.solar_bank_2_output_power_target" + mock_restore_cache(hass, [State(entity_id, "320")]) + + with ExitStack() as stack: + _enter_device_setup_patches(stack, "Solarbank2", mock_device_details) + stack.enter_context(patch("SolixBLE.Solarbank2.set_schedule")) + + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + await asyncio.sleep(1) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "320.0" + +