Compare commits

...

1 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
d2cb3928e9 Homevolt select
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-27 10:46:46 +01:00
6 changed files with 313 additions and 1 deletions

View File

@@ -10,7 +10,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.SWITCH,
Platform.SELECT,
Platform.NUMBER,
]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:

View File

@@ -0,0 +1,132 @@
"""Support for Homevolt number entities."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
@dataclass(frozen=True, kw_only=True)
class HomevoltNumberEntityDescription(NumberEntityDescription):
"""Custom entity description for Homevolt numbers."""
set_value_fn: Any = None
value_fn: Any = None
NUMBER_DESCRIPTIONS: tuple[HomevoltNumberEntityDescription, ...] = (
HomevoltNumberEntityDescription(
key="setpoint",
translation_key="setpoint",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_charge",
translation_key="max_charge",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_discharge",
translation_key="max_discharge",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="min_soc",
translation_key="min_soc",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_soc",
translation_key="max_soc",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="grid_import_limit",
translation_key="grid_import_limit",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="grid_export_limit",
translation_key="grid_export_limit",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt number entities."""
coordinator = entry.runtime_data
entities: list[HomevoltNumberEntity] = []
for description in NUMBER_DESCRIPTIONS:
entities.append(HomevoltNumberEntity(coordinator, description))
async_add_entities(entities)
class HomevoltNumberEntity(HomevoltEntity, NumberEntity):
"""Representation of a Homevolt number entity."""
entity_description: HomevoltNumberEntityDescription
def __init__(
self,
coordinator: HomevoltDataUpdateCoordinator,
description: HomevoltNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def native_value(self) -> float | None:
"""Return the current value."""
value = self.coordinator.client.schedule.get(self.entity_description.key)
return float(value) if value is not None else None
@homevolt_exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
key = self.entity_description.key
await self.coordinator.client.set_battery_parameters(**{key: int(value)})
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,51 @@
"""Support for Homevolt select entities."""
from __future__ import annotations
from homevolt.const import SCHEDULE_TYPE
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt select entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltModeSelect(coordinator)])
class HomevoltModeSelect(HomevoltEntity, SelectEntity):
"""Select entity for battery operational mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "battery_mode"
_attr_options = list(SCHEDULE_TYPE.values())
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the select entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_battery_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def current_option(self) -> str | None:
"""Return the current selected mode."""
mode_int = self.coordinator.client.schedule_mode
return SCHEDULE_TYPE.get(mode_int)
@homevolt_exception_handler
async def async_select_option(self, option: str) -> None:
"""Change the selected mode."""
await self.coordinator.client.set_battery_mode(mode=option)
await self.coordinator.async_request_refresh()

View File

@@ -54,6 +54,46 @@
}
},
"entity": {
"number": {
"grid_export_limit": {
"name": "Grid export limit"
},
"grid_import_limit": {
"name": "Grid import limit"
},
"max_charge": {
"name": "Maximum charge power"
},
"max_discharge": {
"name": "Maximum discharge power"
},
"max_soc": {
"name": "Maximum state of charge"
},
"min_soc": {
"name": "Minimum state of charge"
},
"setpoint": {
"name": "Power setpoint"
}
},
"select": {
"battery_mode": {
"name": "Battery mode",
"state": {
"frequency_reserve": "Frequency reserve",
"full_solar_export": "Full solar export",
"grid_charge": "Grid charge",
"grid_charge_discharge": "Grid charge/discharge",
"grid_discharge": "Grid discharge",
"idle": "Idle",
"inverter_charge": "Inverter charge",
"inverter_discharge": "Inverter discharge",
"solar_charge": "Solar charge",
"solar_charge_discharge": "Solar charge/discharge"
}
}
},
"sensor": {
"available_charging_energy": {
"name": "Available charging energy"

View File

@@ -87,6 +87,8 @@ def mock_homevolt_client() -> Generator[MagicMock]:
client.local_mode_enabled = False
client.enable_local_mode = AsyncMock()
client.disable_local_mode = AsyncMock()
# SELECT platform ability to change schedule type
client.set_schedule_type = AsyncMock()
yield client

View File

@@ -0,0 +1,82 @@
"""Tests for the Homevolt SELECT platform."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform
from homeassistant.core import HomeAssistant
# entity_registry is not required for these tests
from tests.common import MockConfigEntry
@pytest.fixture
def platforms_select() -> list[Platform]:
"""Return platforms including SELECT for this test."""
# Sensor is required for the coordinator; add SELECT as well.
return [Platform.SENSOR, Platform.SELECT]
async def test_select_entity_created(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homevolt_client,
platforms_select: list[Platform],
) -> None:
"""The select entity should be created with correct options and state."""
# Initialise integration with SELECT platform enabled.
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.homevolt.PLATFORMS", platforms_select):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = f"select.{DOMAIN}_schedule_type"
state = hass.states.get(entity_id)
assert state is not None
# The fixture schedule type is 1 → "grid_charge"
assert state.state == "grid_charge"
# Expect all defined schedule types to be present.
expected_options = {
"idle",
"grid_charge",
"grid_discharge",
"solar_charge",
"solar_discharge",
}
assert set(state.attributes["options"]) == expected_options
async def test_select_option_changes(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_homevolt_client,
platforms_select: list[Platform],
) -> None:
"""Selecting a new option calls the client and triggers a refresh."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.homevolt.PLATFORMS", platforms_select):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = f"select.{DOMAIN}_schedule_type"
# Change to a different schedule type.
await hass.services.async_call(
SELECT_DOMAIN,
"select_option",
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "solar_charge"},
blocking=True,
)
# Verify the client method was called with the correct enum value (3).
mock_homevolt_client.set_schedule_type.assert_awaited_once_with(3)
# The coordinator should have refreshed the data.
assert mock_homevolt_client.update_info.called