Compare commits

...

1 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
16cca904a0 homevolt tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-16 13:39:23 +01:00
8 changed files with 377 additions and 21 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]
PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:

View File

@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -30,6 +31,9 @@ class HomevoltConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
_host: str
_device_id: str
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -68,3 +72,81 @@ class HomevoltConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug("Zeroconf discovery: %s", discovery_info)
self._host = str(discovery_info.ip_address)
websession = async_get_clientsession(self.hass)
client = Homevolt(self._host, websession=websession)
try:
await client.update_info()
device = client.get_device()
self._device_id = device.device_id
except HomevoltConnectionError:
return self.async_abort(reason="cannot_connect")
except HomevoltAuthenticationError:
# Device requires authentication - proceed to discovery confirm
# where user can enter password
self._device_id = discovery_info.hostname.removesuffix(".local.")
except Exception:
_LOGGER.exception("Error occurred while connecting to the Homevolt battery")
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(self._device_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
self.context.update(
{
"title_placeholders": {
"name": "Homevolt",
}
}
)
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery and optionally get password."""
errors: dict[str, str] = {}
if user_input is not None:
password = user_input.get(CONF_PASSWORD)
websession = async_get_clientsession(self.hass)
client = Homevolt(self._host, password, websession=websession)
try:
await client.update_info()
device = client.get_device()
self._device_id = device.device_id
except HomevoltAuthenticationError:
errors["base"] = "invalid_auth"
except HomevoltConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception(
"Error occurred while connecting to the Homevolt battery"
)
errors["base"] = "unknown"
else:
await self.async_set_unique_id(self._device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Homevolt",
data={
CONF_HOST: self._host,
CONF_PASSWORD: password,
},
)
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}),
errors=errors,
description_placeholders={
"host": self._host,
},
)

View File

@@ -8,5 +8,11 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["homevolt==0.2.4"]
"requirements": ["homevolt==0.2.4"],
"zeroconf": [
{
"name": "homevolt*",
"type": "_http._tcp.local."
}
]
}

View File

@@ -21,13 +21,11 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity
PARALLEL_UPDATES = 0 # Coordinator-based updates
@@ -121,11 +119,9 @@ async def async_setup_entry(
async_add_entities(entities)
class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity):
class HomevoltSensor(HomevoltEntity, SensorEntity):
"""Representation of a Homevolt sensor."""
_attr_has_entity_name = True
def __init__(
self,
description: SensorEntityDescription,
@@ -133,23 +129,13 @@ class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEnt
sensor_key: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
device_id = coordinator.data.device_id
self._attr_unique_id = f"{device_id}_{sensor_key}"
sensor_data = coordinator.data.sensors[sensor_key]
self._attr_translation_key = sensor_data.slug
self._sensor_key = sensor_key
device_metadata = coordinator.data.device_metadata.get(
sensor_data.device_identifier
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{device_id}_{sensor_data.device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
super().__init__(coordinator, sensor_data.device_identifier)
@property
def available(self) -> bool:

View File

@@ -1,14 +1,25 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "Failed to connect"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"step": {
"discovery_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "The local password configured for your Homevolt battery, if required."
},
"description": "A Homevolt battery was discovered at {host}. Enter the password if required."
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -24,6 +35,28 @@
}
},
"entity": {
"number": {
"battery_max_charge_power": {
"name": "Battery max charge power"
},
"battery_max_discharge_power": {
"name": "Battery max discharge power"
},
"battery_max_soc": {
"name": "Battery maximum state of charge"
},
"battery_min_soc": {
"name": "Battery minimum state of charge"
},
"battery_power_setpoint": {
"name": "Battery power setpoint"
}
},
"select": {
"battery_control_mode": {
"name": "Battery control mode"
}
},
"sensor": {
"available_charging_energy": {
"name": "Available charging energy"
@@ -193,6 +226,11 @@
"tmin": {
"name": "Minimum temperature"
}
},
"switch": {
"local_mode": {
"name": "Local mode"
}
}
}
}

View File

@@ -581,6 +581,10 @@ ZEROCONF = {
"domain": "eheimdigital",
"name": "eheimdigital._http._tcp.local.",
},
{
"domain": "homevolt",
"name": "homevolt*",
},
{
"domain": "lektrico",
"name": "lektrico*",

View File

@@ -679,6 +679,7 @@ class OAuth2Session:
@property
def valid_token(self) -> bool:
"""Return if token is still valid."""
_LOGGER.error("Token: %s", self.token["expires_at"])
return (
cast(float, self.token["expires_at"])
> time.time() + CLOCK_OUT_OF_SYNC_MAX_SEC

View File

@@ -2,19 +2,31 @@
from __future__ import annotations
from ipaddress import ip_address
from unittest.mock import AsyncMock, MagicMock, patch
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError
import pytest
from homeassistant.components.homevolt.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("192.168.68.87"),
ip_addresses=[ip_address("192.168.68.87")],
hostname="homevolt-68b6b34ce824.local.",
name="Homevolt._http._tcp.local.",
port=80,
type="_http._tcp.local.",
properties={},
)
async def test_full_flow_success(
hass: HomeAssistant, mock_setup_entry: AsyncMock
@@ -168,3 +180,225 @@ async def test_duplicate_entry(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_discovery_success(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test successful zeroconf discovery flow."""
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Homevolt"
assert result["data"] == {
CONF_HOST: "192.168.68.87",
CONF_PASSWORD: None,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_discovery_with_password(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test zeroconf discovery when device requires password."""
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
side_effect=HomevoltAuthenticationError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "test-password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Homevolt"
assert result["data"] == {
CONF_HOST: "192.168.68.87",
CONF_PASSWORD: "test-password",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_discovery_connection_error(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test zeroconf discovery when device cannot be reached."""
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
side_effect=HomevoltConnectionError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_zeroconf_discovery_unknown_error(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test zeroconf discovery when an unknown error occurs."""
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
side_effect=Exception("Unknown error"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_zeroconf_discovery_already_configured(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test zeroconf discovery when device is already configured."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100", CONF_PASSWORD: "test-password"},
unique_id="40580137858664",
)
existing_entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
# Verify host was updated
assert existing_entry.data[CONF_HOST] == "192.168.68.87"
async def test_zeroconf_discovery_confirm_errors(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test error handling in discovery confirm step."""
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
# Test invalid auth error
with patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
side_effect=HomevoltAuthenticationError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "wrong-password"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["errors"] == {"base": "invalid_auth"}
# Recover with correct password
with (
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.update_info",
new_callable=AsyncMock,
),
patch(
"homeassistant.components.homevolt.config_flow.Homevolt.get_device",
) as mock_get_device,
):
mock_device = MagicMock()
mock_device.device_id = "40580137858664"
mock_get_device.return_value = mock_device
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "correct-password"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY