Add integration for my-PV devices (#169872)

Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
rrooggiieerr
2026-09-08 17:46:18 +02:00
committed by GitHub
co-authored by Robert Resch
parent 2b385bb0d7
commit 87da2fb0da
22 changed files with 2012 additions and 0 deletions
Generated
+2
View File
@@ -1219,6 +1219,8 @@ CLAUDE.md @home-assistant/core
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
/tests/components/my/ @home-assistant/core
/homeassistant/components/my_pv/ @my-pv @rrooggiieerr
/tests/components/my_pv/ @my-pv @rrooggiieerr
/homeassistant/components/myneomitis/ @Epyes
/tests/components/myneomitis/ @Epyes
/homeassistant/components/mysensors/ @MartinHjelmare @functionpointer
@@ -0,0 +1,55 @@
"""The my-PV integration for Home Assistant."""
from my_pv import MyPVLocalDevice
from my_pv.exceptions import MyPVAuthenticationError
from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import MyPVConfigEntry, MyPVCoordinator
PLATFORMS: list[Platform] = [
Platform.WATER_HEATER,
]
async def async_setup_entry(hass: HomeAssistant, entry: MyPVConfigEntry) -> bool:
"""Set up my-PV from a config entry."""
device = MyPVLocalDevice(entry.data[CONF_HOST], entry.data.get(CONF_PASSWORD))
try:
if not await device.connect():
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
)
except MyPVAuthenticationError as exc:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from exc
coordinator = MyPVCoordinator(hass, entry, device)
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady, ConfigEntryAuthFailed:
await coordinator.async_disconnect()
raise
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MyPVConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.async_disconnect()
return unload_ok
@@ -0,0 +1,215 @@
"""Config flow for the my-PV integration."""
import logging
from typing import Any, Final, override
from my_pv import MyPVLocalDevice
from my_pv.exceptions import MyPVAuthenticationError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_BASE, CONF_HOST, CONF_PASSWORD
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
_LOGGER: Final = logging.getLogger(__name__)
HOST_SCHEMA: Final = vol.Schema(
{
vol.Required(CONF_HOST): TextSelector(),
}
)
AUTH_SCHEMA: Final = vol.Schema(
{
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class MyPVConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for my-PV."""
_host: str
_device_model: str
_device_serial_number: str
@override
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug(
"Zeroconf discovery detected my-PV on %s",
discovery_info.ip_address,
)
self._host = str(discovery_info.ip_address)
return await self.async_step_discovery_confirm()
@override
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
_LOGGER.debug(
"DHCP discovery detected my-PV on %s (%s)",
discovery_info.ip,
format_mac(discovery_info.macaddress),
)
self._host = discovery_info.ip
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle discovery confirmation."""
if user_input is not None:
title = f"my-PV {self._device_model} {self._device_serial_number[6:]}"
data = {
CONF_HOST: self._host,
}
return self.async_create_entry(title=title, data=data)
password_needed = False
device = MyPVLocalDevice(self._host)
try:
if not await device.connect():
return self.async_abort(reason="cannot_connect")
except MyPVAuthenticationError:
password_needed = True
finally:
await device.disconnect()
self._device_serial_number = device.serial_number
await self.async_set_unique_id(device.serial_number)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
self._device_model = device.model
if password_needed:
return await self.async_step_discovery_auth()
_LOGGER.debug("my-PV on %s is not yet configured", self._host)
self.context.update(
{
"title_placeholders": {
"name": f"my-PV {device.model}",
}
}
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders=self.context["title_placeholders"],
)
async def async_step_discovery_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle discovery password authentication."""
return await self.async_step_auth(user_input, "discovery_auth")
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the local setup."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
password_needed = False
device = MyPVLocalDevice(host)
try:
if not await device.connect():
errors[CONF_BASE] = "cannot_connect"
except MyPVAuthenticationError:
password_needed = True
finally:
await device.disconnect()
if not errors and password_needed:
self._host = host
self._device_model = device.model
return await self.async_step_auth()
if not errors:
await self.async_set_unique_id(device.serial_number)
self._abort_if_unique_id_configured()
title = f"my-PV {device.model} {device.serial_number[6:]}"
data = {
CONF_HOST: host,
}
return self.async_create_entry(title=title, data=data)
data_schema = self.add_suggested_values_to_schema(HOST_SCHEMA, user_input or {})
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None, step_id: str = "auth"
) -> ConfigFlowResult:
"""Handle password authentication."""
errors: dict[str, str] = {}
if user_input is not None:
host = self._host
password = user_input[CONF_PASSWORD]
device = MyPVLocalDevice(host, password)
try:
if not await device.connect():
errors[CONF_BASE] = "cannot_connect"
except MyPVAuthenticationError:
errors[CONF_PASSWORD] = "invalid_password"
finally:
await device.disconnect()
if not errors:
await self.async_set_unique_id(device.serial_number)
self._abort_if_unique_id_configured()
title = f"my-PV {device.model} {device.serial_number[6:]}"
data = {
CONF_HOST: host,
CONF_PASSWORD: password,
}
return self.async_create_entry(title=title, data=data)
data_schema = self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input or {})
self.context.update(
{
"title_placeholders": {
"name": f"my-PV {self._device_model}",
}
}
)
return self.async_show_form(
step_id=step_id,
data_schema=data_schema,
errors=errors,
description_placeholders=self.context["title_placeholders"],
)
+5
View File
@@ -0,0 +1,5 @@
"""Constants for the my-PV integration."""
from typing import Final
DOMAIN: Final = "my_pv"
@@ -0,0 +1,159 @@
"""Data update coordinator for the my-PV integration."""
from collections.abc import Callable, Coroutine
from datetime import timedelta
import functools
import logging
from typing import Any, Final, override
from my_pv import MyPVDevice
from my_pv.exceptions import (
MyPVAuthenticationError,
MyPVConnectionError,
MyPVTooManyRequestsError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=5)
def _my_pv_connection[T](
func: Callable[..., Coroutine[Any, Any, T]],
) -> Callable[..., Coroutine[Any, Any, T]]:
@functools.wraps(func)
async def wrapper(self, *args: Any, **kwargs: Any) -> T:
try:
if not self.device.connected and not await self.device.connect():
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
)
return await func(self, *args, **kwargs)
except MyPVAuthenticationError as exc:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from exc
except MyPVConnectionError as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_unavailable",
translation_placeholders={"uri": self.device.uri},
) from exc
return wrapper
type MyPVConfigEntry = ConfigEntry[MyPVCoordinator]
class MyPVCoordinator(DataUpdateCoordinator[None]):
"""my-PV Data Update Coordinator."""
config_entry: MyPVConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: MyPVConfigEntry,
device: MyPVDevice,
) -> None:
"""Initialize my-PV Data Update Coordinator."""
assert device.serial_number
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
config_entry=config_entry,
update_interval=UPDATE_INTERVAL,
always_update=True,
)
self.device: Final[MyPVDevice] = device
connections = set()
if device.mac_address:
connections.add((CONNECTION_NETWORK_MAC, device.mac_address))
name = f"my-PV {device.model}"
self.device_info: Final[DeviceInfo] = DeviceInfo(
configuration_url=device.setup_uri,
connections=connections,
identifiers={(DOMAIN, device.serial_number)},
manufacturer="my-PV",
model=device.model,
name=name,
serial_number=device.serial_number,
sw_version=device.firmware_version,
hw_version=device.hardware_version,
)
async def async_disconnect(self) -> bool:
"""Disconnect from my-PV."""
return await self.device.disconnect()
@override
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
try:
if not self.device.connected and not await self.device.connect():
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
)
if not await self.device.fetch_data():
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_failed",
)
except MyPVTooManyRequestsError:
# Keep using the old data when the device is rate limiting.
# Don't raise an UpdateFailed error since this will make the device unavailable but
# reduce the update interval instead.
_LOGGER.info("Device is rate limiting, reducing update interval")
self.update_interval = 2 * UPDATE_INTERVAL
except MyPVAuthenticationError as exc:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from exc
except MyPVConnectionError as exc:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="device_unavailable",
translation_placeholders={"uri": self.device.uri},
) from exc
@_my_pv_connection
async def set_target_temperature(self, temperature: float) -> bool:
"""Set target temperature."""
result = await self.device.set_target_temperature(temperature)
self.async_update_listeners()
return result
@_my_pv_connection
async def turn_on(self) -> bool:
"""Turn on the device."""
result = await self.device.turn_on()
self.async_update_listeners()
return result
@_my_pv_connection
async def turn_off(self) -> bool:
"""Turn off the device."""
result = await self.device.turn_off()
self.async_update_listeners()
return result
+40
View File
@@ -0,0 +1,40 @@
"""Base entity for the my-PV integration."""
from typing import override
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import MyPVCoordinator
class MyPVDataEntity(CoordinatorEntity[MyPVCoordinator]):
"""The my-PV data entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: MyPVCoordinator,
entity_description: EntityDescription,
serial_number: str,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{serial_number}-{entity_description.key}"
self.entity_description = entity_description
@property
@override
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.device.connected
and self.coordinator.device.is_on is not None
and self.coordinator.device.get_data_value(self.entity_description.key)
is not None
)
@@ -0,0 +1,21 @@
{
"domain": "my_pv",
"name": "my-PV",
"codeowners": ["@my-pv", "@rrooggiieerr"],
"config_flow": true,
"dhcp": [
{
"registered_devices": true
},
{
"macaddress": "986D35C*"
}
],
"documentation": "https://www.home-assistant.io/integrations/my_pv",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["my_pv"],
"quality_scale": "bronze",
"requirements": ["my-pv==0.0.8"],
"zeroconf": ["_mypv._tcp.local."]
}
@@ -0,0 +1,84 @@
rules:
# Bronze tier rules
action-setup:
status: exempt
comment: Integration does not provide service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not provide service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not subscribe to external events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
docs-conditions:
status: exempt
comment: This integration does not implement conditions
docs-triggers:
status: exempt
comment: This integration does not implement triggers
# Silver tier rules
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not provide configuration parameters.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold tier rules
devices: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class:
status: exempt
comment: The water heater entity does not provide a device class.
entity-disabled-by-default: todo
entity-translations:
status: exempt
comment: The only entity uses the device name.
exception-translations: done
icon-translations:
status: exempt
comment: There are no entities that require icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not have any known issues that require repair.
stale-devices:
status: exempt
comment: Each config entry represents a single device; the integration does not manage a dynamic set of devices.
# Platinum tier rules
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,67 @@
{
"common": {
"discovery_description": "Do you want to set up {name}?",
"password_description": "The password of your my-PV device. If no custom password is set you have to use the **devicekey** which can be found under the ⓘ info menu of your my-PV device. For the HEA•THOR IoT you can find the **devicekey** on the device label."
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_password": "Invalid password"
},
"step": {
"auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::my_pv::common::password_description%]"
},
"title": "{name} password"
},
"discovery_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::my_pv::common::password_description%]"
},
"description": "[%key:component::my_pv::common::discovery_description%]",
"title": "{name}"
},
"discovery_confirm": {
"description": "[%key:component::my_pv::common::discovery_description%]",
"title": "{name}"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The IP address or hostname of your my-PV device."
},
"title": "my-PV Device"
}
}
},
"exceptions": {
"auth_error": {
"message": "Authentication failed. Remove and add the integration again with the current device password."
},
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"device_unavailable": {
"message": "Device on {uri} is unavailable"
},
"fetch_failed": {
"message": "Failed to fetch data."
},
"unknown_error": {
"message": "The device could not complete the requested action."
}
}
}
@@ -0,0 +1,126 @@
"""Creates Water Heater entities for the my-PV Home Assistant integration."""
from typing import Any, override
from my_pv import MyPVDeviceMainMode
from homeassistant.components.water_heater import (
STATE_ELECTRIC,
WaterHeaterEntity,
WaterHeaterEntityDescription,
WaterHeaterEntityFeature,
)
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MyPVConfigEntry, MyPVCoordinator
from .entity import MyPVDataEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MyPVConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the my-PV water heater."""
coordinator = config_entry.runtime_data
entities = []
if coordinator.device.supports_main_mode(MyPVDeviceMainMode.HOT_WATER) and (
configuration := coordinator.device.get_setup_configuration("ww1target")
):
entity_description = WaterHeaterEntityDescription(
key="temp1",
)
entities.append(
MyPVWaterHeater(
coordinator,
entity_description,
coordinator.device.serial_number,
configuration=configuration,
)
)
async_add_entities(entities)
class MyPVWaterHeater(MyPVDataEntity, WaterHeaterEntity):
"""my-PV water heater."""
_attr_name = None
_attr_operation_list = [STATE_OFF, STATE_ELECTRIC]
_attr_supported_features = (
WaterHeaterEntityFeature.ON_OFF
| WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
)
def __init__(
self,
coordinator: MyPVCoordinator,
entity_description: WaterHeaterEntityDescription,
serial_number: str,
configuration: dict[str, Any],
) -> None:
"""Initialize the water_heater."""
super().__init__(coordinator, entity_description, serial_number)
self._attr_target_temperature_step = configuration["step"]
self._attr_temperature_unit = configuration["unit"]
self._attr_min_temp = configuration["min"]
self._attr_max_temp = configuration["max"]
@property
@override
def current_operation(self) -> str | None:
"""Return current operation."""
return STATE_ELECTRIC if self.coordinator.device.is_on else STATE_OFF
@property
@override
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self.coordinator.device.current_temperature
@property
@override
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self.coordinator.device.target_temperature
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if not await self.coordinator.set_target_temperature(
float(kwargs[ATTR_TEMPERATURE])
):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unknown_error"
)
@override
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
if operation_mode == STATE_OFF:
await self.async_turn_off()
else:
await self.async_turn_on()
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the water heater on."""
if not await self.coordinator.turn_on():
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unknown_error"
)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the water heater off."""
if not await self.coordinator.turn_off():
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unknown_error"
)
+1
View File
@@ -510,6 +510,7 @@ FLOWS = {
"mullvad",
"music_assistant",
"mutesync",
"my_pv",
"myneomitis",
"mysensors",
"mystrom",
+8
View File
@@ -675,6 +675,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "motion_blinds",
"hostname": "connector_*",
},
{
"domain": "my_pv",
"registered_devices": True,
},
{
"domain": "my_pv",
"macaddress": "986D35C*",
},
{
"domain": "mystrom",
"hostname": "mystrom-*",
@@ -4724,6 +4724,12 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"my_pv": {
"name": "my-PV",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"myneomitis": {
"name": "MyNeomitis",
"integration_type": "hub",
+5
View File
@@ -828,6 +828,11 @@ ZEROCONF = {
"domain": "bluesound",
},
],
"_mypv._tcp.local.": [
{
"domain": "my_pv",
},
],
"_nanoleafapi._tcp.local.": [
{
"domain": "nanoleaf",
+3
View File
@@ -1682,6 +1682,9 @@ mutesync==0.0.1
# homeassistant.components.mvglive
mvg==1.6.0
# homeassistant.components.my_pv
my-pv==0.0.8
# homeassistant.components.myuplink
myuplink==0.7.0
+3
View File
@@ -0,0 +1,3 @@
"""The tests for the my-PV component."""
ELWA2_SERIAL_NUMBER = "1601500000000000"
+78
View File
@@ -0,0 +1,78 @@
"""Common fixtures for the my-PV tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
import pytest
from homeassistant.components.my_pv.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from . import ELWA2_SERIAL_NUMBER
from tests.common import MockConfigEntry
SETUP_CONFIGURATION = {
"ww1target": {"step": 0.1, "unit": "°C", "min": 5.0, "max": 95.0}
}
def _setup_configuration_lookup(key):
return SETUP_CONFIGURATION.get(key)
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the my-PV mocked config entry for local devices."""
return MockConfigEntry(
title="my-PV AC ELWA 2 0000000000",
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "test-password",
},
unique_id=ELWA2_SERIAL_NUMBER,
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Prevent running the real integration setup during tests."""
with patch(
"homeassistant.components.my_pv.async_setup_entry",
return_value=True,
) as mock_setup:
yield mock_setup
@pytest.fixture
def mock_my_pv_client() -> Generator[AsyncMock]:
"""Mock the my-PV client across the integration."""
with (
patch(
"homeassistant.components.my_pv.MyPVLocalDevice",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.my_pv.coordinator.MyPVDevice",
new=mock_client,
) as mock_client,
patch(
"homeassistant.components.my_pv.config_flow.MyPVLocalDevice",
new=mock_client,
),
):
client = mock_client.return_value
client.connect = AsyncMock(return_value=True)
client.disconnect = AsyncMock(return_value=True)
client.serial_number = ELWA2_SERIAL_NUMBER
client.model = "AC ELWA 2"
client.mac_address = "98:6d:35:c0:00:00"
client.setup_uri = "http://127.0.0.1/"
client.hardware_version = "v1.5A"
client.firmware_version = "e0002200"
client.current_temperature = 54.3
client.target_temperature = 62.1
client.get_setup_configuration = Mock(side_effect=_setup_configuration_lookup)
yield client
@@ -0,0 +1,72 @@
# serializer version: 1
# name: test_water_heater[water_heater.my_pv_ac_elwa_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<WaterHeaterCapabilityAttribute.MAX_TEMP: 'max_temp'>: 95.0,
<WaterHeaterCapabilityAttribute.MIN_TEMP: 'min_temp'>: 5.0,
<WaterHeaterCapabilityAttribute.OPERATION_LIST: 'operation_list'>: list([
'off',
'electric',
]),
<WaterHeaterCapabilityAttribute.TARGET_TEMP_STEP: 'target_temp_step'>: 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'water_heater',
'entity_category': None,
'entity_id': 'water_heater.my_pv_ac_elwa_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'my_pv',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <WaterHeaterEntityFeature: 11>,
'translation_key': None,
'unique_id': '1601500000000000-temp1',
'unit_of_measurement': None,
})
# ---
# name: test_water_heater[water_heater.my_pv_ac_elwa_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<WaterHeaterStateAttribute.CURRENT_TEMPERATURE: 'current_temperature'>: 54.3,
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'my-PV AC ELWA 2',
<WaterHeaterCapabilityAttribute.MAX_TEMP: 'max_temp'>: 95.0,
<WaterHeaterCapabilityAttribute.MIN_TEMP: 'min_temp'>: 5.0,
<WaterHeaterCapabilityAttribute.OPERATION_LIST: 'operation_list'>: list([
'off',
'electric',
]),
<WaterHeaterStateAttribute.OPERATION_MODE: 'operation_mode'>: 'electric',
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <WaterHeaterEntityFeature: 11>,
<WaterHeaterStateAttribute.TARGET_TEMP_HIGH: 'target_temp_high'>: None,
<WaterHeaterStateAttribute.TARGET_TEMP_LOW: 'target_temp_low'>: None,
<WaterHeaterCapabilityAttribute.TARGET_TEMP_STEP: 'target_temp_step'>: 0.1,
<WaterHeaterStateAttribute.TARGET_TEMPERATURE: 'temperature'>: 62.1,
}),
'context': <ANY>,
'entity_id': 'water_heater.my_pv_ac_elwa_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'electric',
})
# ---
+444
View File
@@ -0,0 +1,444 @@
"""Test the my-PV config flow."""
from ipaddress import ip_address
from unittest.mock import AsyncMock
from my_pv.exceptions import MyPVAuthenticationError
import pytest
from homeassistant import config_entries
from homeassistant.components.my_pv.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import BaseServiceInfo, FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import ELWA2_SERIAL_NUMBER
from tests.common import MockConfigEntry
DHCP_DISCOVERY = DhcpServiceInfo(
"127.0.0.1",
macaddress="986d35cabcde",
hostname=f"my-pv-ac-elwa-2-{ELWA2_SERIAL_NUMBER}.local.",
)
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname=f"my-pv-ac-elwa-2-{ELWA2_SERIAL_NUMBER}.local.",
name=f"my-pv-ac-elwa-2-{ELWA2_SERIAL_NUMBER}._mypv._tcp.local.",
port=443,
type="_mypv._tcp.local.",
properties={"": None},
)
@pytest.mark.usefixtures("mock_my_pv_client", "mock_setup_entry")
async def test_step_user(
hass: HomeAssistant,
) -> None:
"""Test if we get the local setup form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "127.0.0.1",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "my-PV AC ELWA 2 0000000000"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
}
assert result["result"].unique_id == ELWA2_SERIAL_NUMBER
@pytest.mark.usefixtures("mock_my_pv_client")
async def test_step_user_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test for user configuration that is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "127.0.0.1",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_step_user_cannot_connect(
hass: HomeAssistant,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test if we get the local setup form with error if we can not connect to device."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
mock_my_pv_client.connect.return_value = False
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "127.0.0.1",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "cannot_connect"
mock_my_pv_client.connect.return_value = True
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "127.0.0.1",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "my-PV AC ELWA 2 0000000000"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
}
assert result["result"].unique_id == ELWA2_SERIAL_NUMBER
@pytest.mark.usefixtures("mock_setup_entry")
async def test_step_auth(
hass: HomeAssistant,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test we get the authentication form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
mock_my_pv_client.connect.side_effect = MyPVAuthenticationError()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "127.0.0.1",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert not result["errors"]
mock_my_pv_client.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PASSWORD: "test-password",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "my-PV AC ELWA 2 0000000000"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "test-password",
}
assert result["result"].unique_id == ELWA2_SERIAL_NUMBER
async def test_step_auth_cannot_connect(
hass: HomeAssistant,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test we get the authentication form with error if we can not connect to device."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
mock_my_pv_client.connect.side_effect = MyPVAuthenticationError()
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "127.0.0.1",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert not result["errors"]
mock_my_pv_client.connect.return_value = False
mock_my_pv_client.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PASSWORD: "test-password",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"]["base"] == "cannot_connect"
mock_my_pv_client.connect.return_value = True
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PASSWORD: "test-password",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "my-PV AC ELWA 2 0000000000"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "test-password",
}
assert result["result"].unique_id == ELWA2_SERIAL_NUMBER
@pytest.mark.usefixtures("mock_setup_entry", "mock_my_pv_client")
async def test_step_dhcp(
hass: HomeAssistant,
) -> None:
"""Test for DHCP discovery that does not require a password."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_DHCP,
},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "my-PV AC ELWA 2 0000000000"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
}
assert result["result"].unique_id == ELWA2_SERIAL_NUMBER
@pytest.mark.parametrize(
("source", "data"),
[
(
config_entries.SOURCE_DHCP,
DHCP_DISCOVERY,
),
(
config_entries.SOURCE_ZEROCONF,
ZEROCONF_DISCOVERY,
),
],
)
@pytest.mark.usefixtures("mock_my_pv_client")
async def test_step_discovery_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
source: str,
data: BaseServiceInfo,
) -> None:
"""Test for discovery that is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": source,
},
data=data,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("source", "data"),
[
(
config_entries.SOURCE_DHCP,
DHCP_DISCOVERY,
),
(
config_entries.SOURCE_ZEROCONF,
ZEROCONF_DISCOVERY,
),
],
)
async def test_step_discovery_cannot_connect(
hass: HomeAssistant,
mock_my_pv_client: AsyncMock,
source: str,
data: BaseServiceInfo,
) -> None:
"""Test for discovery that can not connect."""
mock_my_pv_client.connect.return_value = False
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": source,
},
data=data,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@pytest.mark.parametrize(
("source", "data"),
[
(
config_entries.SOURCE_DHCP,
DHCP_DISCOVERY,
),
(
config_entries.SOURCE_ZEROCONF,
ZEROCONF_DISCOVERY,
),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_step_discovery_auth(
hass: HomeAssistant,
mock_my_pv_client: AsyncMock,
source: str,
data: BaseServiceInfo,
) -> None:
"""Test for discovery that requires a password."""
mock_my_pv_client.connect.side_effect = MyPVAuthenticationError()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": source,
},
data=data,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_auth"
assert not result["errors"]
mock_my_pv_client.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "test-password"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "my-PV AC ELWA 2 0000000000"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "test-password",
}
assert result["result"].unique_id == ELWA2_SERIAL_NUMBER
@pytest.mark.parametrize(
("source", "data"),
[
(
config_entries.SOURCE_DHCP,
DHCP_DISCOVERY,
),
(
config_entries.SOURCE_ZEROCONF,
ZEROCONF_DISCOVERY,
),
],
)
async def test_step_discovery_auth_wrong_password(
hass: HomeAssistant,
mock_my_pv_client: AsyncMock,
source: str,
data: BaseServiceInfo,
) -> None:
"""Test for discovery with an incorrect password."""
mock_my_pv_client.connect.side_effect = MyPVAuthenticationError()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": source,
},
data=data,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_auth"
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "wrong-password"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_auth"
assert result["errors"]["password"] == "invalid_password"
mock_my_pv_client.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "test-password"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "my-PV AC ELWA 2 0000000000"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "test-password",
}
assert result["result"].unique_id == ELWA2_SERIAL_NUMBER
+116
View File
@@ -0,0 +1,116 @@
"""Test the my-PV coordinator."""
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from my_pv.exceptions import MyPVTooManyRequestsError
import pytest
from homeassistant.components.my_pv.coordinator import UPDATE_INTERVAL
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.usefixtures("mock_my_pv_client")
async def test_coordinator_update_data(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a coordinator update."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
# Test successful setup and first data fetch
await hass.async_block_till_done()
states = hass.states.async_all()
assert False not in [state.state != STATE_UNAVAILABLE for state in states]
# Test successful data fetch
freezer.tick(UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
states = hass.states.async_all()
assert False not in [state.state != STATE_UNAVAILABLE for state in states]
async def test_coordinator_update_data_not_connected(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test coordinator update when client not connected."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
# Test successful setup and first data fetch
await hass.async_block_till_done()
states = hass.states.async_all()
assert False not in [state.state != STATE_UNAVAILABLE for state in states]
# Test states get unavailable when not connected
freezer.tick(UPDATE_INTERVAL)
mock_my_pv_client.connected = False
mock_my_pv_client.connect = AsyncMock(return_value=False)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_my_pv_client.connect.assert_awaited_once_with()
states = hass.states.async_all()
assert False not in [state.state == STATE_UNAVAILABLE for state in states]
# Test successful data fetch
freezer.tick(UPDATE_INTERVAL)
mock_my_pv_client.connected = False
mock_my_pv_client.connect.reset_mock()
async def reconnect() -> bool:
mock_my_pv_client.connected = True
return True
mock_my_pv_client.connect.side_effect = reconnect
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_my_pv_client.connect.assert_awaited_once_with()
states = hass.states.async_all()
assert False not in [state.state != STATE_UNAVAILABLE for state in states]
async def test_coordinator_update_data_rate_limiting(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test coordinator update when client is rate limiting."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
# Test successful setup and first data fetch
await hass.async_block_till_done()
states = hass.states.async_all()
assert False not in [state.state != STATE_UNAVAILABLE for state in states]
# Test states stay available when rate limiting
freezer.tick(UPDATE_INTERVAL)
mock_my_pv_client.fetch_data = AsyncMock(side_effect=MyPVTooManyRequestsError)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_my_pv_client.fetch_data.assert_awaited_once_with()
states = hass.states.async_all()
assert False not in [state.state != STATE_UNAVAILABLE for state in states]
# Test successful data fetch
freezer.tick(2 * UPDATE_INTERVAL)
mock_my_pv_client.fetch_data.reset_mock(side_effect=True)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_my_pv_client.fetch_data.assert_awaited_once_with()
states = hass.states.async_all()
assert False not in [state.state != STATE_UNAVAILABLE for state in states]
+91
View File
@@ -0,0 +1,91 @@
"""Test the my-PV init."""
from unittest.mock import AsyncMock
from my_pv.exceptions import MyPVAuthenticationError, MyPVConnectionError
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_my_pv_client")
async def test_setup_entry_success(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful setup of a config entry."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_setup_entry_cannot_connect(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test setup of a config entry when unable to connect."""
mock_config_entry.add_to_hass(hass)
mock_my_pv_client.connect.return_value = False
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_entry_auth_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test setup of a config entry when authentication fails."""
mock_config_entry.add_to_hass(hass)
mock_my_pv_client.connect.side_effect = MyPVAuthenticationError()
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_setup_entry_failed_first_refresh(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test setup of a config entry when first refresh fails."""
mock_config_entry.add_to_hass(hass)
mock_my_pv_client.fetch_data.side_effect = MyPVConnectionError()
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("mock_my_pv_client")
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test unloading a config entry."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
+411
View File
@@ -0,0 +1,411 @@
"""Test the my-PV water heater."""
from unittest.mock import AsyncMock, patch
from my_pv.exceptions import MyPVAuthenticationError, MyPVConnectionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.water_heater import (
ATTR_OPERATION_MODE,
ATTR_TEMPERATURE,
DOMAIN as WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
SERVICE_SET_TEMPERATURE,
STATE_ELECTRIC,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("mock_my_pv_client")
async def test_water_heater(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
) -> None:
"""Test successful setup of a water heater."""
with patch("homeassistant.components.my_pv.PLATFORMS", [Platform.WATER_HEATER]):
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_water_heater_unavailable_not_connected(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test if a water_heater is unavailable when not connected."""
mock_config_entry.add_to_hass(hass)
mock_my_pv_client.connected = False
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_UNAVAILABLE
async def test_water_heater_unavailable_data_value_none(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test if a water_heater is unavailable when data value is None."""
mock_config_entry.add_to_hass(hass)
mock_my_pv_client.get_data_value.return_value = None
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_UNAVAILABLE
async def test_water_heater_turn_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test turning the water heater off."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_ELECTRIC
mock_my_pv_client.is_on = False
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_TURN_OFF,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
},
blocking=True,
)
mock_my_pv_client.turn_off.assert_awaited_once_with()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_OFF
async def test_water_heater_turn_off_false(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test turning off returns false."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_ELECTRIC
mock_my_pv_client.turn_off = AsyncMock(return_value=False)
with (
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_TURN_OFF,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
},
blocking=True,
)
mock_my_pv_client.turn_off.assert_awaited_once_with()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_ELECTRIC
async def test_water_heater_turn_on(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test turning the water heater on."""
mock_config_entry.add_to_hass(hass)
mock_my_pv_client.is_on = False
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_OFF
mock_my_pv_client.is_on = True
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
},
blocking=True,
)
mock_my_pv_client.turn_on.assert_awaited_once_with()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_ELECTRIC
async def test_water_heater_turn_on_false(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test turning on returns false."""
mock_config_entry.add_to_hass(hass)
mock_my_pv_client.is_on = False
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_OFF
mock_my_pv_client.turn_on = AsyncMock(return_value=False)
with (
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
},
blocking=True,
)
mock_my_pv_client.turn_on.assert_awaited_once_with()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_OFF
async def test_water_heater_set_operation_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test setting the operation mode to off."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_ELECTRIC
mock_my_pv_client.is_on = False
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
ATTR_OPERATION_MODE: STATE_OFF,
},
blocking=True,
)
mock_my_pv_client.turn_off.assert_awaited_once_with()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_OFF
async def test_water_heater_set_operation_electric(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test setting the operation mode to electric."""
mock_config_entry.add_to_hass(hass)
mock_my_pv_client.is_on = False
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_OFF
mock_my_pv_client.is_on = True
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_OPERATION_MODE,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
ATTR_OPERATION_MODE: STATE_ELECTRIC,
},
blocking=True,
)
mock_my_pv_client.turn_on.assert_awaited_once_with()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.state == STATE_ELECTRIC
async def test_water_heater_set_temp(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test setting the target temperature."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.attributes[ATTR_TEMPERATURE] == 62.1
mock_my_pv_client.target_temperature = 35
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
ATTR_TEMPERATURE: 35,
},
blocking=True,
)
mock_my_pv_client.set_target_temperature.assert_awaited_once_with(35)
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.attributes[ATTR_TEMPERATURE] == 35
async def test_water_heater_set_temp_false(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test setting the target temperature returns false."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_my_pv_client.set_target_temperature = AsyncMock(return_value=False)
with (
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
ATTR_TEMPERATURE: 35,
},
blocking=True,
)
mock_my_pv_client.set_target_temperature.assert_awaited_once_with(35)
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.attributes[ATTR_TEMPERATURE] == 62.1
async def test_water_heater_set_temp_connection_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test connection error when setting the target temperature."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_my_pv_client.set_target_temperature.side_effect = MyPVConnectionError()
with (
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
ATTR_TEMPERATURE: 35,
},
blocking=True,
)
mock_my_pv_client.set_target_temperature.assert_awaited_once_with(35)
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.attributes[ATTR_TEMPERATURE] == 62.1
async def test_water_heater_set_temp_authentication_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_my_pv_client: AsyncMock,
) -> None:
"""Test authentication error when setting the target temperature."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_my_pv_client.set_target_temperature.side_effect = MyPVAuthenticationError()
with (
pytest.raises(ConfigEntryAuthFailed),
):
await hass.services.async_call(
WATER_HEATER_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: "water_heater.my_pv_ac_elwa_2",
ATTR_TEMPERATURE: 35,
},
blocking=True,
)
mock_my_pv_client.set_target_temperature.assert_awaited_once_with(35)
state = hass.states.get("water_heater.my_pv_ac_elwa_2")
assert state.attributes[ATTR_TEMPERATURE] == 62.1