1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-21 10:27:52 +00:00

Hypontech micro invertors support via Hyponcloud (#159442)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Hai-Nam Nguyen
2026-02-16 23:38:44 +01:00
committed by GitHub
parent e8f2493ed6
commit 52d645e4bf
27 changed files with 1435 additions and 0 deletions

View File

@@ -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.*

2
CODEOWNERS generated
View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -0,0 +1,7 @@
"""Constants for the Hypontech Cloud integration."""
from logging import Logger, getLogger
DOMAIN = "hypontech"
LOGGER: Logger = getLogger(__package__)

View File

@@ -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},
)

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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."
}
}
}

View File

@@ -314,6 +314,7 @@ FLOWS = {
"hvv_departures",
"hydrawise",
"hyperion",
"hypontech",
"ialarm",
"iaqualink",
"ibeacon",

View File

@@ -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",

10
mypy.ini generated
View File

@@ -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

3
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,8 @@
{
"data": {
"token": "12345678905a17049548ba99c75081e6"
},
"message": "ok",
"time": 1771277551,
"code": 20000
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.balcon_lifetime_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Lifetime energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'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': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensors[sensor.balcon_lifetime_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Balcon Lifetime energy',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.balcon_lifetime_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '48.0',
})
# ---
# name: test_sensors[sensor.balcon_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.balcon_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'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': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_sensors[sensor.balcon_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Balcon Power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.balcon_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.balcon_today_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.balcon_today_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Today energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'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': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensors[sensor.balcon_today_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Balcon Today energy',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.balcon_today_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.54',
})
# ---
# name: test_sensors[sensor.overview_lifetime_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overview_lifetime_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Lifetime energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'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': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensors[sensor.overview_lifetime_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Overview Lifetime energy',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.overview_lifetime_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '48.01',
})
# ---
# name: test_sensors[sensor.overview_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overview_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Power',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'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': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_sensors[sensor.overview_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Overview Power',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.overview_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.overview_today_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.overview_today_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Today energy',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'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': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensors[sensor.overview_today_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Overview Today energy',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.overview_today_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.54',
})
# ---

View File

@@ -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"

View File

@@ -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

View File

@@ -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)