diff --git a/.strict-typing b/.strict-typing index fa8588e3dc5..2ea93fa6fbc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -275,6 +275,7 @@ homeassistant.components.humidifier.* homeassistant.components.husqvarna_automower.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* +homeassistant.components.hypontech.* homeassistant.components.ibeacon.* homeassistant.components.idasen_desk.* homeassistant.components.image.* diff --git a/CODEOWNERS b/CODEOWNERS index c53d65b5957..bad799a69e6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -753,6 +753,8 @@ build.json @home-assistant/supervisor /tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy +/homeassistant/components/hypontech/ @jcisio +/tests/components/hypontech/ @jcisio /homeassistant/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK /homeassistant/components/iammeter/ @lewei50 diff --git a/homeassistant/components/hypontech/__init__.py b/homeassistant/components/hypontech/__init__.py new file mode 100644 index 00000000000..ba0c0e5d459 --- /dev/null +++ b/homeassistant/components/hypontech/__init__.py @@ -0,0 +1,47 @@ +"""The Hypontech Cloud integration.""" + +from __future__ import annotations + +from hyponcloud import AuthenticationError, HyponCloud, RequestError + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import HypontechConfigEntry, HypontechDataCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: HypontechConfigEntry) -> bool: + """Set up Hypontech Cloud from a config entry.""" + session = async_get_clientsession(hass) + hypontech_cloud = HyponCloud( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + session, + ) + try: + await hypontech_cloud.connect() + except AuthenticationError as ex: + raise ConfigEntryAuthFailed("Authentication failed for Hypontech Cloud") from ex + except (RequestError, TimeoutError, ConnectionError) as ex: + raise ConfigEntryNotReady("Cannot connect to Hypontech Cloud") from ex + + assert entry.unique_id + coordinator = HypontechDataCoordinator( + hass, entry, hypontech_cloud, entry.unique_id + ) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HypontechConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/hypontech/config_flow.py b/homeassistant/components/hypontech/config_flow.py new file mode 100644 index 00000000000..a0f233b0039 --- /dev/null +++ b/homeassistant/components/hypontech/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for the Hypontech Cloud integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from hyponcloud import AuthenticationError, HyponCloud +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class HypontechConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hypontech Cloud.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + session = async_get_clientsession(self.hass) + hypon = HyponCloud( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session + ) + try: + await hypon.connect() + admin_info = await hypon.get_admin_info() + except AuthenticationError: + errors["base"] = "invalid_auth" + except TimeoutError, ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(admin_info.id) + if self.source == SOURCE_USER: + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication.""" + return await self.async_step_user() diff --git a/homeassistant/components/hypontech/const.py b/homeassistant/components/hypontech/const.py new file mode 100644 index 00000000000..4f290ee882d --- /dev/null +++ b/homeassistant/components/hypontech/const.py @@ -0,0 +1,7 @@ +"""Constants for the Hypontech Cloud integration.""" + +from logging import Logger, getLogger + +DOMAIN = "hypontech" + +LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/hypontech/coordinator.py b/homeassistant/components/hypontech/coordinator.py new file mode 100644 index 00000000000..b3cae5d6e5d --- /dev/null +++ b/homeassistant/components/hypontech/coordinator.py @@ -0,0 +1,62 @@ +"""The coordinator for Hypontech Cloud integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from hyponcloud import HyponCloud, OverviewData, PlantData, RequestError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +@dataclass +class HypontechCoordinatorData: + """Store coordinator data.""" + + overview: OverviewData + plants: dict[str, PlantData] + + +type HypontechConfigEntry = ConfigEntry[HypontechDataCoordinator] + + +class HypontechDataCoordinator(DataUpdateCoordinator[HypontechCoordinatorData]): + """Coordinator used for all sensors.""" + + config_entry: HypontechConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HypontechConfigEntry, + api: HyponCloud, + account_id: str, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="Hypontech Data", + update_interval=timedelta(seconds=60), + ) + self.api = api + self.account_id = account_id + + async def _async_update_data(self) -> HypontechCoordinatorData: + try: + overview = await self.api.get_overview() + plants = await self.api.get_list() + except RequestError as ex: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="connection_error" + ) from ex + return HypontechCoordinatorData( + overview=overview, + plants={plant.plant_id: plant for plant in plants}, + ) diff --git a/homeassistant/components/hypontech/entity.py b/homeassistant/components/hypontech/entity.py new file mode 100644 index 00000000000..a8abb23cf09 --- /dev/null +++ b/homeassistant/components/hypontech/entity.py @@ -0,0 +1,53 @@ +"""Base entity for the Hypontech Cloud integration.""" + +from __future__ import annotations + +from hyponcloud import PlantData + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HypontechDataCoordinator + + +class HypontechEntity(CoordinatorEntity[HypontechDataCoordinator]): + """Base entity for Hypontech Cloud.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HypontechDataCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.account_id)}, + name="Overview", + manufacturer="Hypontech", + ) + + +class HypontechPlantEntity(CoordinatorEntity[HypontechDataCoordinator]): + """Base entity for Hypontech Cloud plant.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HypontechDataCoordinator, plant_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.plant_id = plant_id + plant = coordinator.data.plants[plant_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, plant_id)}, + name=plant.plant_name, + manufacturer="Hypontech", + ) + + @property + def plant(self) -> PlantData: + """Return the plant data.""" + return self.coordinator.data.plants[self.plant_id] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.plant_id in self.coordinator.data.plants diff --git a/homeassistant/components/hypontech/manifest.json b/homeassistant/components/hypontech/manifest.json new file mode 100644 index 00000000000..8701e493192 --- /dev/null +++ b/homeassistant/components/hypontech/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "hypontech", + "name": "Hypontech Cloud", + "codeowners": ["@jcisio"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hypontech", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["hyponcloud==0.3.0"] +} diff --git a/homeassistant/components/hypontech/quality_scale.yaml b/homeassistant/components/hypontech/quality_scale.yaml new file mode 100644 index 00000000000..cd76f521036 --- /dev/null +++ b/homeassistant/components/hypontech/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/hypontech/sensor.py b/homeassistant/components/hypontech/sensor.py new file mode 100644 index 00000000000..a85e77174d0 --- /dev/null +++ b/homeassistant/components/hypontech/sensor.py @@ -0,0 +1,152 @@ +"""The read-only sensors for Hypontech integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from hyponcloud import OverviewData, PlantData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HypontechConfigEntry, HypontechDataCoordinator +from .entity import HypontechEntity, HypontechPlantEntity + + +@dataclass(frozen=True, kw_only=True) +class HypontechSensorDescription(SensorEntityDescription): + """Describes Hypontech overview sensor entity.""" + + value_fn: Callable[[OverviewData], float | None] + + +@dataclass(frozen=True, kw_only=True) +class HypontechPlantSensorDescription(SensorEntityDescription): + """Describes Hypontech plant sensor entity.""" + + value_fn: Callable[[PlantData], float | None] + + +OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = ( + HypontechSensorDescription( + key="pv_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.power, + ), + HypontechSensorDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_total, + ), + HypontechSensorDescription( + key="today_energy", + translation_key="today_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_today, + ), +) + +PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = ( + HypontechPlantSensorDescription( + key="pv_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.power, + ), + HypontechPlantSensorDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_total, + ), + HypontechPlantSensorDescription( + key="today_energy", + translation_key="today_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_today, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HypontechConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + entities: list[SensorEntity] = [ + HypontechOverviewSensor(coordinator, desc) for desc in OVERVIEW_SENSORS + ] + + entities.extend( + HypontechPlantSensor(coordinator, plant_id, desc) + for plant_id in coordinator.data.plants + for desc in PLANT_SENSORS + ) + + async_add_entities(entities) + + +class HypontechOverviewSensor(HypontechEntity, SensorEntity): + """Class describing Hypontech overview sensor entities.""" + + entity_description: HypontechSensorDescription + + def __init__( + self, + coordinator: HypontechDataCoordinator, + description: HypontechSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.account_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data.overview) + + +class HypontechPlantSensor(HypontechPlantEntity, SensorEntity): + """Class describing Hypontech plant sensor entities.""" + + entity_description: HypontechPlantSensorDescription + + def __init__( + self, + coordinator: HypontechDataCoordinator, + plant_id: str, + description: HypontechPlantSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, plant_id) + self.entity_description = description + self._attr_unique_id = f"{plant_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.plant) diff --git a/homeassistant/components/hypontech/strings.json b/homeassistant/components/hypontech/strings.json new file mode 100644 index 00000000000..b2d18800fe0 --- /dev/null +++ b/homeassistant/components/hypontech/strings.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "The provided credentials are for a different Hypontech Cloud account." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::hypontech::config::step::user::data_description::password%]", + "username": "[%key:component::hypontech::config::step::user::data_description::username%]" + }, + "description": "Your Hypontech Cloud credentials have expired. Please re-enter your credentials to continue using this integration." + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your Hypontech Cloud account password.", + "username": "Your Hypontech Cloud account username." + } + } + } + }, + "entity": { + "sensor": { + "lifetime_energy": { + "name": "Lifetime energy" + }, + "today_energy": { + "name": "Today energy" + } + } + }, + "exceptions": { + "connection_error": { + "message": "Failed to connect to Hypontech Cloud. Maybe you make too frequent connection from multiple devices in your network." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 04902a57f02..9bcc030329a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -314,6 +314,7 @@ FLOWS = { "hvv_departures", "hydrawise", "hyperion", + "hypontech", "ialarm", "iaqualink", "ibeacon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e111bae54b2..e7935ab7427 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2990,6 +2990,12 @@ "config_flow": true, "iot_class": "local_push" }, + "hypontech": { + "name": "Hypontech Cloud", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ialarm": { "name": "Antifurto365 iAlarm", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 5a9058326c6..6ace8e21ce4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2506,6 +2506,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.hypontech.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ibeacon.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 03c4c4b395b..55bdf620cd8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1255,6 +1255,9 @@ huum==0.8.1 # homeassistant.components.hyperion hyperion-py==0.7.6 +# homeassistant.components.hypontech +hyponcloud==0.3.0 + # homeassistant.components.iammeter iammeter==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2fd188a6df5..9c8c5ab7b1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1113,6 +1113,9 @@ huum==0.8.1 # homeassistant.components.hyperion hyperion-py==0.7.6 +# homeassistant.components.hypontech +hyponcloud==0.3.0 + # homeassistant.components.iaqualink iaqualink==0.6.0 diff --git a/tests/components/hypontech/__init__.py b/tests/components/hypontech/__init__.py new file mode 100644 index 00000000000..3dcb7d7ecba --- /dev/null +++ b/tests/components/hypontech/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Hypontech Cloud integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/hypontech/conftest.py b/tests/components/hypontech/conftest.py new file mode 100644 index 00000000000..6007696c39e --- /dev/null +++ b/tests/components/hypontech/conftest.py @@ -0,0 +1,92 @@ +"""Common fixtures for the Hypontech Cloud tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from hyponcloud import AdminInfo, InverterData, OverviewData, PlantData +import pytest + +from homeassistant.components.hypontech.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.hypontech.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="2123456789123456789", + ) + + +@pytest.fixture +def load_overview_fixture() -> OverviewData: + """Load overview fixture data.""" + data = load_json_object_fixture("overview.json", DOMAIN) + return OverviewData.from_dict(data["data"]) + + +@pytest.fixture +def load_plant_list_fixture() -> list[PlantData]: + """Load plant list fixture data.""" + data = load_json_object_fixture("plant_list.json", DOMAIN) + return [PlantData.from_dict(item) for item in data["data"]] + + +@pytest.fixture +def load_inverters_fixture() -> list[InverterData]: + """Load inverters fixture data.""" + data = load_json_object_fixture("inverters.json", DOMAIN) + return [InverterData.from_dict(item) for item in data["data"]] + + +@pytest.fixture +def load_admin_info_fixture() -> AdminInfo: + """Load admin info fixture data.""" + data = load_json_object_fixture("admin_info.json", DOMAIN) + admin_data = data["data"] + # Flatten nested "info" object into the main data dict + if "info" in admin_data and isinstance(admin_data["info"], dict): + info_data = admin_data.pop("info") + admin_data.update(info_data) + return AdminInfo.from_dict(admin_data) + + +@pytest.fixture +def mock_hyponcloud( + load_overview_fixture: OverviewData, + load_plant_list_fixture: list[PlantData], + load_inverters_fixture: list[InverterData], + load_admin_info_fixture: AdminInfo, +) -> Generator[AsyncMock]: + """Mock HyponCloud.""" + with ( + patch( + "homeassistant.components.hypontech.HyponCloud", autospec=True + ) as mock_hyponcloud, + patch( + "homeassistant.components.hypontech.config_flow.HyponCloud", + new=mock_hyponcloud, + ), + ): + mock_client = mock_hyponcloud.return_value + mock_client.get_admin_info.return_value = load_admin_info_fixture + mock_client.get_list.return_value = load_plant_list_fixture + mock_client.get_overview.return_value = load_overview_fixture + mock_client.get_inverters.return_value = load_inverters_fixture + yield mock_client diff --git a/tests/components/hypontech/fixtures/admin_info.json b/tests/components/hypontech/fixtures/admin_info.json new file mode 100644 index 00000000000..c655addc21d --- /dev/null +++ b/tests/components/hypontech/fixtures/admin_info.json @@ -0,0 +1,44 @@ +{ + "data": { + "parent_name": "admin", + "role": ["End-User"], + "info": { + "last_login_time": "2026-02-17 05:32:32", + "created_at": "2025-11-30 23:17:59", + "deleted_at": "0001-01-01 00:00:00", + "country": "United States", + "address": "", + "email": "admin@example.com", + "mobile": "", + "mobile_prefix_code": "", + "login_name": "admin@example.com", + "first_name": "First Name", + "last_name": "Last Name", + "company": "", + "city": "New York", + "city_mobile_code": "", + "username": "admin@example.com", + "country_mobile_code": "001", + "photo": "", + "address2": "", + "language": "us", + "currency": "USD", + "postal_code": "", + "timezone": "America/New_York", + "last_login_ip": "10.0.0.1", + "id": "2123456789123456789", + "eid": 0, + "manufacturer": 1, + "switch_warning": 0, + "is_internal": 2, + "status": 1, + "first_login": true, + "token": "" + }, + "parent_id": "0", + "has_lower_level": false + }, + "message": "ok", + "time": 1771277551, + "code": 20000 +} diff --git a/tests/components/hypontech/fixtures/inverters.json b/tests/components/hypontech/fixtures/inverters.json new file mode 100644 index 00000000000..34fb082f6a0 --- /dev/null +++ b/tests/components/hypontech/fixtures/inverters.json @@ -0,0 +1,78 @@ +{ + "data": [ + { + "gateway": { + "time": "2026-02-16T18:04:53+01:00", + "sn": "P16000A024500000", + "model": "COM-WF", + "status": "offline", + "push_time": 60, + "pid": "0" + }, + "plant_name": "Balcon", + "sn": "P16000A024500000", + "gateway_sn": "P16000A024500000", + "status": "offline", + "model": "HMS-1600W", + "software_version": "V1.0.0.10", + "lcd_version": "V0.0.0.0", + "afci_version": "V0.0.0.0", + "afci_version0": "", + "afci_version1": "", + "afci_version2": "", + "time": "2026-02-16T18:04:50+01:00", + "spn": "H910-07100-10", + "port": [ + { + "sn": "P16000A024500000", + "id": "0", + "x": -1, + "y": -1, + "port": 1 + }, + { + "sn": "P16000A024500000", + "id": "0", + "x": -1, + "y": -1, + "port": 2 + }, + { + "sn": "P16000A024500000", + "id": "0", + "x": -1, + "y": -1, + "port": 3 + }, + { + "sn": "P16000A024500000", + "id": "0", + "x": -1, + "y": -1, + "port": 4 + } + ], + "power": 0, + "eid": "0", + "device_type": "2", + "fault": 0, + "warning": 0, + "plant_id": "1123456789123456789", + "modbus": 1, + "e_total": 48.01, + "e_today": 1.54, + "property": 1, + "nick_name": "", + "com": 0, + "system_connect_mode": 0, + "third_active_power": 0, + "third_meter_energy": 0, + "today_generation_third": 0 + } + ], + "message": "ok", + "time": 1771277551, + "totalPage": 1, + "totalCount": 1, + "code": 20000 +} diff --git a/tests/components/hypontech/fixtures/login.json b/tests/components/hypontech/fixtures/login.json new file mode 100644 index 00000000000..97e2e62446b --- /dev/null +++ b/tests/components/hypontech/fixtures/login.json @@ -0,0 +1,8 @@ +{ + "data": { + "token": "12345678905a17049548ba99c75081e6" + }, + "message": "ok", + "time": 1771277551, + "code": 20000 +} diff --git a/tests/components/hypontech/fixtures/overview.json b/tests/components/hypontech/fixtures/overview.json new file mode 100644 index 00000000000..8a58b7bf318 --- /dev/null +++ b/tests/components/hypontech/fixtures/overview.json @@ -0,0 +1,27 @@ +{ + "data": { + "company": "W", + "capacity_company": "KW", + "e_total": 48.01, + "e_today": 1.54, + "total_co2": 0.03, + "total_tree": 1.73, + "power": 0, + "normal_dev_num": 0, + "offline_dev_num": 1, + "fault_dev_num": 0, + "wait_dev_num": 0, + "percent": 0, + "capacity": 1.6, + "earning": [ + { + "currency": "USD", + "today": 0.95, + "total": 0.95 + } + ] + }, + "message": "ok", + "time": 1771277551, + "code": 20000 +} diff --git a/tests/components/hypontech/fixtures/plant_list.json b/tests/components/hypontech/fixtures/plant_list.json new file mode 100644 index 00000000000..bc8cfb5a6b8 --- /dev/null +++ b/tests/components/hypontech/fixtures/plant_list.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "status": "offline", + "time": "2026-02-16T18:04:50+01:00", + "plant_name": "Balcon", + "plant_type": "PvGrid", + "photo": "", + "owner_name": "admin@example.com", + "country": "United States", + "city": "New York", + "e_today": 1.54, + "e_total": 48, + "power": 0, + "owner_id": "2123456789123456789", + "plant_id": "1123456789123456789", + "eid": 0, + "top": 0, + "micro": 1, + "property": 1, + "kwhimp": 0 + } + ], + "message": "ok", + "time": 1771277551, + "totalPage": 1, + "totalCount": 1, + "code": 20000 +} diff --git a/tests/components/hypontech/snapshots/test_sensor.ambr b/tests/components/hypontech/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..9feac1ad314 --- /dev/null +++ b/tests/components/hypontech/snapshots/test_sensor.ambr @@ -0,0 +1,343 @@ +# serializer version: 1 +# name: test_sensors[sensor.balcon_lifetime_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.balcon_lifetime_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lifetime energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1123456789123456789_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_lifetime_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Balcon Lifetime energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_lifetime_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.0', + }) +# --- +# name: test_sensors[sensor.balcon_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.balcon_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1123456789123456789_pv_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Balcon Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.balcon_today_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.balcon_today_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Today energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today energy', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'today_energy', + 'unique_id': '1123456789123456789_today_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_today_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Balcon Today energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_today_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.54', + }) +# --- +# name: test_sensors[sensor.overview_lifetime_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overview_lifetime_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lifetime energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '2123456789123456789_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.overview_lifetime_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Overview Lifetime energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.overview_lifetime_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.01', + }) +# --- +# name: test_sensors[sensor.overview_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overview_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2123456789123456789_pv_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.overview_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Overview Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.overview_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.overview_today_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overview_today_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Today energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today energy', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'today_energy', + 'unique_id': '2123456789123456789_today_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.overview_today_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Overview Today energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.overview_today_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.54', + }) +# --- diff --git a/tests/components/hypontech/test_config_flow.py b/tests/components/hypontech/test_config_flow.py new file mode 100644 index 00000000000..e35a71d72c1 --- /dev/null +++ b/tests/components/hypontech/test_config_flow.py @@ -0,0 +1,177 @@ +"""Test the Hypontech Cloud config flow.""" + +from unittest.mock import AsyncMock + +from hyponcloud import AuthenticationError +import pytest + +from homeassistant.components.hypontech.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_USER_INPUT = { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", +} + + +async def test_user_flow( + hass: HomeAssistant, mock_hyponcloud: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test a successful user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == TEST_USER_INPUT + assert result["result"].unique_id == "2123456789123456789" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_message"), + [ + (AuthenticationError, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_errors( + hass: HomeAssistant, + mock_hyponcloud: AsyncMock, + side_effect: Exception, + error_message: str, +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_hyponcloud.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_message} + + mock_hyponcloud.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hyponcloud: AsyncMock +) -> None: + """Test that duplicate entries are prevented based on account ID.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hyponcloud: AsyncMock +) -> None: + """Test reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_USER_INPUT, CONF_PASSWORD: "password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "password" + + +@pytest.mark.parametrize( + ("side_effect", "error_message"), + [ + (AuthenticationError, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hyponcloud: AsyncMock, + side_effect: Exception, + error_message: str, +) -> None: + """Test reauthentication flow with errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_hyponcloud.connect.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_USER_INPUT, CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_message} + + mock_hyponcloud.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_USER_INPUT, CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_flow_wrong_account( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hyponcloud: AsyncMock +) -> None: + """Test reauthentication flow with wrong account.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_hyponcloud.get_admin_info.return_value.id = "different_account_id_456" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_USER_INPUT, CONF_USERNAME: "different@example.com"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/hypontech/test_init.py b/tests/components/hypontech/test_init.py new file mode 100644 index 00000000000..b49de5954fa --- /dev/null +++ b/tests/components/hypontech/test_init.py @@ -0,0 +1,51 @@ +"""Test the Hypontech Cloud init.""" + +from unittest.mock import AsyncMock + +from hyponcloud import AuthenticationError, RequestError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (TimeoutError, ConfigEntryState.SETUP_RETRY), + (AuthenticationError, ConfigEntryState.SETUP_ERROR), + (RequestError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hyponcloud: AsyncMock, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry with timeout error.""" + mock_hyponcloud.connect.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hyponcloud: AsyncMock, +) -> None: + """Test setup and unload of config entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + 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/hypontech/test_sensor.py b/tests/components/hypontech/test_sensor.py new file mode 100644 index 00000000000..7f349fcc90c --- /dev/null +++ b/tests/components/hypontech/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for Hypontech sensors.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_hyponcloud: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Hypontech sensors.""" + with patch("homeassistant.components.hypontech._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)