mirror of
https://github.com/home-assistant/core.git
synced 2026-09-13 12:10:22 +01:00
Add integration for my-PV devices (#169872)
Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
co-authored by
Robert Resch
parent
2b385bb0d7
commit
87da2fb0da
Generated
+2
@@ -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"],
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
Generated
+1
@@ -510,6 +510,7 @@ FLOWS = {
|
||||
"mullvad",
|
||||
"music_assistant",
|
||||
"mutesync",
|
||||
"my_pv",
|
||||
"myneomitis",
|
||||
"mysensors",
|
||||
"mystrom",
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
|
||||
Generated
+5
@@ -828,6 +828,11 @@ ZEROCONF = {
|
||||
"domain": "bluesound",
|
||||
},
|
||||
],
|
||||
"_mypv._tcp.local.": [
|
||||
{
|
||||
"domain": "my_pv",
|
||||
},
|
||||
],
|
||||
"_nanoleafapi._tcp.local.": [
|
||||
{
|
||||
"domain": "nanoleaf",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""The tests for the my-PV component."""
|
||||
|
||||
ELWA2_SERIAL_NUMBER = "1601500000000000"
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user