diff --git a/CODEOWNERS b/CODEOWNERS index f25b6c4f2726..206c31810051 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/my_pv/__init__.py b/homeassistant/components/my_pv/__init__.py new file mode 100644 index 000000000000..46c705e601d9 --- /dev/null +++ b/homeassistant/components/my_pv/__init__.py @@ -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 diff --git a/homeassistant/components/my_pv/config_flow.py b/homeassistant/components/my_pv/config_flow.py new file mode 100644 index 000000000000..0ee97e66aed8 --- /dev/null +++ b/homeassistant/components/my_pv/config_flow.py @@ -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"], + ) diff --git a/homeassistant/components/my_pv/const.py b/homeassistant/components/my_pv/const.py new file mode 100644 index 000000000000..dfaf54b2dea0 --- /dev/null +++ b/homeassistant/components/my_pv/const.py @@ -0,0 +1,5 @@ +"""Constants for the my-PV integration.""" + +from typing import Final + +DOMAIN: Final = "my_pv" diff --git a/homeassistant/components/my_pv/coordinator.py b/homeassistant/components/my_pv/coordinator.py new file mode 100644 index 000000000000..2c1413edd424 --- /dev/null +++ b/homeassistant/components/my_pv/coordinator.py @@ -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 diff --git a/homeassistant/components/my_pv/entity.py b/homeassistant/components/my_pv/entity.py new file mode 100644 index 000000000000..39a1784d00a2 --- /dev/null +++ b/homeassistant/components/my_pv/entity.py @@ -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 + ) diff --git a/homeassistant/components/my_pv/manifest.json b/homeassistant/components/my_pv/manifest.json new file mode 100644 index 000000000000..30213320a9af --- /dev/null +++ b/homeassistant/components/my_pv/manifest.json @@ -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."] +} diff --git a/homeassistant/components/my_pv/quality_scale.yaml b/homeassistant/components/my_pv/quality_scale.yaml new file mode 100644 index 000000000000..4c73587b9000 --- /dev/null +++ b/homeassistant/components/my_pv/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/my_pv/strings.json b/homeassistant/components/my_pv/strings.json new file mode 100644 index 000000000000..26662bfd3060 --- /dev/null +++ b/homeassistant/components/my_pv/strings.json @@ -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." + } + } +} diff --git a/homeassistant/components/my_pv/water_heater.py b/homeassistant/components/my_pv/water_heater.py new file mode 100644 index 000000000000..f9010c286176 --- /dev/null +++ b/homeassistant/components/my_pv/water_heater.py @@ -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" + ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 022efbd91edf..0270b20fb6bd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -510,6 +510,7 @@ FLOWS = { "mullvad", "music_assistant", "mutesync", + "my_pv", "myneomitis", "mysensors", "mystrom", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index bb1289afb01f..254c202ae48b 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -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-*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f82d9e89870a..588d6edbda3f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index f6dc0f2dfa19..7b08acf0ad40 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -828,6 +828,11 @@ ZEROCONF = { "domain": "bluesound", }, ], + "_mypv._tcp.local.": [ + { + "domain": "my_pv", + }, + ], "_nanoleafapi._tcp.local.": [ { "domain": "nanoleaf", diff --git a/requirements_all.txt b/requirements_all.txt index 35cc725a734b..ec509d97bfa8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/tests/components/my_pv/__init__.py b/tests/components/my_pv/__init__.py new file mode 100644 index 000000000000..45816e003bd6 --- /dev/null +++ b/tests/components/my_pv/__init__.py @@ -0,0 +1,3 @@ +"""The tests for the my-PV component.""" + +ELWA2_SERIAL_NUMBER = "1601500000000000" diff --git a/tests/components/my_pv/conftest.py b/tests/components/my_pv/conftest.py new file mode 100644 index 000000000000..7826330dd482 --- /dev/null +++ b/tests/components/my_pv/conftest.py @@ -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 diff --git a/tests/components/my_pv/snapshots/test_water_heater.ambr b/tests/components/my_pv/snapshots/test_water_heater.ambr new file mode 100644 index 000000000000..b77479b35b35 --- /dev/null +++ b/tests/components/my_pv/snapshots/test_water_heater.ambr @@ -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({ + : 95.0, + : 5.0, + : list([ + 'off', + 'electric', + ]), + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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': , + '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({ + : 54.3, + : 'my-PV AC ELWA 2', + : 95.0, + : 5.0, + : list([ + 'off', + 'electric', + ]), + : 'electric', + : , + : None, + : None, + : 0.1, + : 62.1, + }), + 'context': , + 'entity_id': 'water_heater.my_pv_ac_elwa_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'electric', + }) +# --- diff --git a/tests/components/my_pv/test_config_flow.py b/tests/components/my_pv/test_config_flow.py new file mode 100644 index 000000000000..88d88da1ff01 --- /dev/null +++ b/tests/components/my_pv/test_config_flow.py @@ -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 diff --git a/tests/components/my_pv/test_coordinator.py b/tests/components/my_pv/test_coordinator.py new file mode 100644 index 000000000000..44b481a19434 --- /dev/null +++ b/tests/components/my_pv/test_coordinator.py @@ -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] diff --git a/tests/components/my_pv/test_init.py b/tests/components/my_pv/test_init.py new file mode 100644 index 000000000000..0ef74efc0411 --- /dev/null +++ b/tests/components/my_pv/test_init.py @@ -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 diff --git a/tests/components/my_pv/test_water_heater.py b/tests/components/my_pv/test_water_heater.py new file mode 100644 index 000000000000..f3e0c5e2818d --- /dev/null +++ b/tests/components/my_pv/test_water_heater.py @@ -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