diff --git a/CODEOWNERS b/CODEOWNERS index 0675fe24e1f9c1..5547daaaf79360 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2061,6 +2061,8 @@ CLAUDE.md @home-assistant/core /homeassistant/components/zabbix/ @kruton /homeassistant/components/zamg/ @killer0071234 /tests/components/zamg/ @killer0071234 +/homeassistant/components/zendure_p1/ @NextNebula +/tests/components/zendure_p1/ @NextNebula /homeassistant/components/zengge/ @emontnemery /homeassistant/components/zeroconf/ @bdraco /tests/components/zeroconf/ @bdraco diff --git a/homeassistant/components/zendure_p1/__init__.py b/homeassistant/components/zendure_p1/__init__.py new file mode 100644 index 00000000000000..e575abdb2b3b4d --- /dev/null +++ b/homeassistant/components/zendure_p1/__init__.py @@ -0,0 +1,36 @@ +"""The Zendure Smart Meter P1 integration.""" + +from zendure_p1 import ZendureP1Client + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import PLATFORMS +from .coordinator import ZendureP1Coordinator + +type ZendureP1ConfigEntry = ConfigEntry[ZendureP1Coordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: ZendureP1ConfigEntry) -> bool: + """Set up Zendure Smart Meter P1 from a config entry.""" + api = ZendureP1Client(entry.data[CONF_HOST]) + coordinator = ZendureP1Coordinator(hass, entry, api) + entry.runtime_data = coordinator + + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + await api.close() + raise + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ZendureP1ConfigEntry) -> bool: + """Unload a config entry.""" + if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.api.close() + return unloaded diff --git a/homeassistant/components/zendure_p1/config_flow.py b/homeassistant/components/zendure_p1/config_flow.py new file mode 100644 index 00000000000000..c99bc3c6886c7f --- /dev/null +++ b/homeassistant/components/zendure_p1/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for the Zendure Smart Meter P1 integration.""" + +from typing import Any + +import voluptuous as vol +from zendure_p1 import ( + ZendureP1Client, + ZendureP1ConnectionError, + ZendureP1ResponseError, + ZendureP1TimeoutError, +) + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, LOGGER + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + } +) + + +class ZendureP1ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Zendure Smart Meter P1.""" + + VERSION = 1 + + 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: + device_id = await self._async_try_connect(user_input[CONF_HOST], errors) + if device_id is not None: + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=device_id, + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration to update the host.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + if user_input is not None: + device_id = await self._async_try_connect(user_input[CONF_HOST], errors) + if device_id is not None: + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_HOST: user_input[CONF_HOST]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + reconfigure_entry.data, + ), + errors=errors, + ) + + async def _async_try_connect(self, host: str, errors: dict[str, str]) -> str | None: + """Try connecting to the device and return the device ID, or None on failure.""" + try: + return await self._async_validate_host(host) + except ( + ZendureP1ConnectionError, + ZendureP1ResponseError, + ZendureP1TimeoutError, + ) as err: + LOGGER.debug("Cannot connect to Zendure P1: %s", err) + errors["base"] = "cannot_connect" + except Exception as err: # noqa: BLE001 + LOGGER.exception("Unexpected error while connecting to Zendure P1: %s", err) + errors["base"] = "unknown" + return None + + async def _async_validate_host(self, host: str) -> str: + """Validate host by connecting and return the device ID.""" + async with ZendureP1Client(host) as client: + report = await client.get_report() + return report.device_id diff --git a/homeassistant/components/zendure_p1/const.py b/homeassistant/components/zendure_p1/const.py new file mode 100644 index 00000000000000..ae8b34700bf9b3 --- /dev/null +++ b/homeassistant/components/zendure_p1/const.py @@ -0,0 +1,13 @@ +"""Constants for the Zendure Smart Meter P1 integration.""" + +from datetime import timedelta +import logging + +from homeassistant.const import Platform + +DOMAIN = "zendure_p1" +PLATFORMS = [Platform.SENSOR] + +LOGGER = logging.getLogger(__package__) + +UPDATE_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/zendure_p1/coordinator.py b/homeassistant/components/zendure_p1/coordinator.py new file mode 100644 index 00000000000000..e806889e19fbe4 --- /dev/null +++ b/homeassistant/components/zendure_p1/coordinator.py @@ -0,0 +1,56 @@ +"""DataUpdateCoordinator for the Zendure Smart Meter P1 integration.""" + +from typing import TYPE_CHECKING + +from zendure_p1 import ( + Report, + ZendureP1Client, + ZendureP1ConnectionError, + ZendureP1ResponseError, + ZendureP1TimeoutError, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL + +if TYPE_CHECKING: + from . import ZendureP1ConfigEntry + + +class ZendureP1Coordinator(DataUpdateCoordinator[Report]): + """Coordinator for the Zendure Smart Meter P1.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ZendureP1ConfigEntry, + api: ZendureP1Client, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=UPDATE_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> Report: + """Fetch data from api.""" + try: + return await self.api.get_report() + except ZendureP1ConnectionError as ex: + raise UpdateFailed( + f"Error communicating with the Zendure P1 device: {ex}" + ) from ex + except ZendureP1TimeoutError as ex: + raise UpdateFailed( + f"Timeout communicating with the Zendure P1 device: {ex}" + ) from ex + except ZendureP1ResponseError as ex: + raise UpdateFailed( + f"Invalid response from the Zendure P1 device: {ex}" + ) from ex diff --git a/homeassistant/components/zendure_p1/diagnostics.py b/homeassistant/components/zendure_p1/diagnostics.py new file mode 100644 index 00000000000000..92b3a49c33e185 --- /dev/null +++ b/homeassistant/components/zendure_p1/diagnostics.py @@ -0,0 +1,16 @@ +"""Diagnostics support for Zendure Smart Meter P1.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import ZendureP1ConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: ZendureP1ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return asdict(entry.runtime_data.data) diff --git a/homeassistant/components/zendure_p1/entity.py b/homeassistant/components/zendure_p1/entity.py new file mode 100644 index 00000000000000..bbf09df5740f19 --- /dev/null +++ b/homeassistant/components/zendure_p1/entity.py @@ -0,0 +1,22 @@ +"""Base entity for Zendure Smart Meter P1 integration.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ZendureP1Coordinator + + +class ZendureP1Entity(CoordinatorEntity[ZendureP1Coordinator]): + """Defines a base Zendure Smart Meter P1 entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ZendureP1Coordinator) -> None: + """Initialize the Zendure Smart Meter P1 entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.device_id)}, + manufacturer="Zendure", + name="Smart Meter P1", + ) diff --git a/homeassistant/components/zendure_p1/manifest.json b/homeassistant/components/zendure_p1/manifest.json new file mode 100644 index 00000000000000..efceebfc131b62 --- /dev/null +++ b/homeassistant/components/zendure_p1/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "zendure_p1", + "name": "Zendure Smart Meter P1", + "codeowners": ["@NextNebula"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/zendure_p1", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "silver", + "requirements": ["zendure-p1==0.1.2"] +} diff --git a/homeassistant/components/zendure_p1/quality_scale.yaml b/homeassistant/components/zendure_p1/quality_scale.yaml new file mode 100644 index 00000000000000..5319796335ab29 --- /dev/null +++ b/homeassistant/components/zendure_p1/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not provide any 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: The integration does not provide any actions. + 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: + status: exempt + comment: The integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No additional configuration parameters beyond the host address. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: The integration does not use any authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: todo + reconfiguration-flow: done + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/zendure_p1/sensor.py b/homeassistant/components/zendure_p1/sensor.py new file mode 100644 index 00000000000000..cfc77d8d9acd59 --- /dev/null +++ b/homeassistant/components/zendure_p1/sensor.py @@ -0,0 +1,99 @@ +"""Support for Zendure Smart Meter P1 sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from zendure_p1 import Report + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfApparentPower, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import ZendureP1ConfigEntry +from .coordinator import ZendureP1Coordinator +from .entity import ZendureP1Entity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class ZendureP1SensorEntityDescription(SensorEntityDescription): + """Describes a Zendure Smart Meter P1 sensor.""" + + value_fn: Callable[[Report], StateType] + + +SENSORS: tuple[ZendureP1SensorEntityDescription, ...] = ( + ZendureP1SensorEntityDescription( + key="phase_1_apparent_power", + translation_key="phase_1_apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda report: report.a_apparent_power, + ), + ZendureP1SensorEntityDescription( + key="phase_2_apparent_power", + translation_key="phase_2_apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda report: report.b_apparent_power, + ), + ZendureP1SensorEntityDescription( + key="phase_3_apparent_power", + translation_key="phase_3_apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda report: report.c_apparent_power, + ), + ZendureP1SensorEntityDescription( + key="total_power", + translation_key="total_power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda report: report.total_power, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ZendureP1ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Zendure Smart Meter P1 sensor entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + ZendureP1SensorEntity(coordinator, description) for description in SENSORS + ) + + +class ZendureP1SensorEntity(ZendureP1Entity, SensorEntity): + """Defines a Zendure Smart Meter P1 sensor entity.""" + + entity_description: ZendureP1SensorEntityDescription + + def __init__( + self, + coordinator: ZendureP1Coordinator, + description: ZendureP1SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.device_id}-{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/zendure_p1/strings.json b/homeassistant/components/zendure_p1/strings.json new file mode 100644 index 00000000000000..62c0f134a5eb3a --- /dev/null +++ b/homeassistant/components/zendure_p1/strings.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The device at the new address does not match the configured device." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "host": "[%key:component::zendure_p1::config::step::user::data_description::host%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "host": "The IP address or hostname of your Zendure Smart Meter P1 device." + } + } + } + }, + "entity": { + "sensor": { + "phase_1_apparent_power": { + "name": "Phase 1 apparent power" + }, + "phase_2_apparent_power": { + "name": "Phase 2 apparent power" + }, + "phase_3_apparent_power": { + "name": "Phase 3 apparent power" + }, + "total_power": { + "name": "Total power" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3617690047d0b2..156903fee49878 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -862,6 +862,7 @@ "youless", "youtube", "zamg", + "zendure_p1", "zerproc", "zeversolar", "zha", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 549d7a747c272a..97855a77172b92 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -8237,6 +8237,12 @@ "integration_type": "virtual", "supported_by": "fibaro" }, + "zendure_p1": { + "name": "Zendure Smart Meter P1", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "zengge": { "name": "Zengge", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 3a0df99724cd38..7124b338a2c818 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3422,6 +3422,9 @@ zamg==0.3.6 # homeassistant.components.zimi zcc-helper==3.8 +# homeassistant.components.zendure_p1 +zendure-p1==0.1.2 + # homeassistant.components.zeroconf zeroconf==0.148.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd13a3f3a76598..ffbfe7f6c6bf5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2916,6 +2916,9 @@ zamg==0.3.6 # homeassistant.components.zimi zcc-helper==3.8 +# homeassistant.components.zendure_p1 +zendure-p1==0.1.2 + # homeassistant.components.zeroconf zeroconf==0.148.0 diff --git a/tests/components/zendure_p1/__init__.py b/tests/components/zendure_p1/__init__.py new file mode 100644 index 00000000000000..eb7c95a78cfeab --- /dev/null +++ b/tests/components/zendure_p1/__init__.py @@ -0,0 +1 @@ +"""Tests for the Zendure Smart Meter P1 integration.""" diff --git a/tests/components/zendure_p1/conftest.py b/tests/components/zendure_p1/conftest.py new file mode 100644 index 00000000000000..f2b42cdd2f3ef9 --- /dev/null +++ b/tests/components/zendure_p1/conftest.py @@ -0,0 +1,79 @@ +"""Common fixtures for the Zendure Smart Meter P1 tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from zendure_p1 import Report + +from homeassistant.components.zendure_p1.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def mock_client(device_id: str = "SN123456") -> AsyncMock: + """Create a mock ZendureP1Client for config flow tests.""" + mock_report = MagicMock() + mock_report.device_id = device_id + client = AsyncMock() + client.get_report.return_value = mock_report + client.__aenter__.return_value = client + client.__aexit__.return_value = None + return client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.zendure_p1.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="SN123456", + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="SN123456", + ) + + +@pytest.fixture +def mock_zendure_p1_client() -> Generator[AsyncMock]: + """Return a mocked ZendureP1Client.""" + with patch( + "homeassistant.components.zendure_p1.ZendureP1Client", + autospec=True, + ) as client_class_mock: + client = client_class_mock.return_value + client.get_report = AsyncMock( + return_value=Report( + timestamp=1000000, + device_id="SN123456", + a_apparent_power=100, + b_apparent_power=200, + c_apparent_power=300, + total_power=600, + ) + ) + client.close = AsyncMock() + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zendure_p1_client: AsyncMock, +) -> MockConfigEntry: + """Set up the Zendure Smart Meter P1 integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/zendure_p1/snapshots/test_diagnostics.ambr b/tests/components/zendure_p1/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..a06e3a2f79b2ab --- /dev/null +++ b/tests/components/zendure_p1/snapshots/test_diagnostics.ambr @@ -0,0 +1,11 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'a_apparent_power': 100, + 'b_apparent_power': 200, + 'c_apparent_power': 300, + 'device_id': 'SN123456', + 'timestamp': 1000000, + 'total_power': 600, + }) +# --- diff --git a/tests/components/zendure_p1/snapshots/test_sensor.ambr b/tests/components/zendure_p1/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..e44afddfcb7728 --- /dev/null +++ b/tests/components/zendure_p1/snapshots/test_sensor.ambr @@ -0,0 +1,233 @@ +# serializer version: 1 +# name: test_entities[sensor.smart_meter_p1_phase_1_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.smart_meter_p1_phase_1_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Phase 1 apparent power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase 1 apparent power', + 'platform': 'zendure_p1', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_1_apparent_power', + 'unique_id': 'SN123456-phase_1_apparent_power', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smart_meter_p1_phase_1_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter P1 Phase 1 apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_p1_phase_1_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_entities[sensor.smart_meter_p1_phase_2_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.smart_meter_p1_phase_2_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Phase 2 apparent power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase 2 apparent power', + 'platform': 'zendure_p1', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_2_apparent_power', + 'unique_id': 'SN123456-phase_2_apparent_power', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smart_meter_p1_phase_2_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter P1 Phase 2 apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_p1_phase_2_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200', + }) +# --- +# name: test_entities[sensor.smart_meter_p1_phase_3_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.smart_meter_p1_phase_3_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Phase 3 apparent power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Phase 3 apparent power', + 'platform': 'zendure_p1', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_3_apparent_power', + 'unique_id': 'SN123456-phase_3_apparent_power', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smart_meter_p1_phase_3_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Smart Meter P1 Phase 3 apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_p1_phase_3_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '300', + }) +# --- +# name: test_entities[sensor.smart_meter_p1_total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + '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.smart_meter_p1_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'zendure_p1', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'SN123456-total_power', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.smart_meter_p1_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Smart Meter P1 Total power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smart_meter_p1_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '600', + }) +# --- diff --git a/tests/components/zendure_p1/test_config_flow.py b/tests/components/zendure_p1/test_config_flow.py new file mode 100644 index 00000000000000..f8ffa5f4d2e399 --- /dev/null +++ b/tests/components/zendure_p1/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Zendure Smart Meter P1 config flow.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from zendure_p1 import ( + ZendureP1ConnectionError, + ZendureP1ResponseError, + ZendureP1TimeoutError, +) + +from homeassistant.components.zendure_p1.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import mock_client as _mock_client + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form and can successfully set up the integration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=_mock_client(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "SN123456" + assert result["data"] == {CONF_HOST: "192.168.1.100"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test that the same device cannot be configured twice.""" + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=_mock_client(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=_mock_client(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.101"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + pytest.param( + ZendureP1ConnectionError(), "cannot_connect", id="connection_error" + ), + pytest.param(ZendureP1TimeoutError(), "cannot_connect", id="timeout_error"), + pytest.param(ZendureP1ResponseError(), "cannot_connect", id="response_error"), + pytest.param(Exception(), "unknown", id="unknown_error"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test we handle errors and the flow can recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + failing_client = _mock_client() + failing_client.get_report.side_effect = exception + + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=failing_client, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=_mock_client(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_zendure_p1_client: AsyncMock, +) -> None: + """Test reconfiguration updates the host and reloads.""" + result = await init_integration.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=_mock_client(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.200"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert init_integration.data[CONF_HOST] == "192.168.1.200" + + +async def test_reconfigure_wrong_device( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_zendure_p1_client: AsyncMock, +) -> None: + """Test reconfiguration aborts when the host points to a different device.""" + result = await init_integration.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=_mock_client(device_id="OTHER_DEVICE"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.200"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert init_integration.data[CONF_HOST] == "192.168.1.100" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + pytest.param( + ZendureP1ConnectionError(), "cannot_connect", id="connection_error" + ), + pytest.param(ZendureP1TimeoutError(), "cannot_connect", id="timeout_error"), + pytest.param(ZendureP1ResponseError(), "cannot_connect", id="response_error"), + pytest.param(Exception(), "unknown", id="unknown_error"), + ], +) +async def test_reconfigure_errors( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_zendure_p1_client: AsyncMock, + exception: Exception, + expected_error: str, +) -> None: + """Test reconfiguration handles errors and allows recovery.""" + result = await init_integration.start_reconfigure_flow(hass) + + failing_client = _mock_client() + failing_client.get_report.side_effect = exception + + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=failing_client, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.200"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + assert init_integration.data[CONF_HOST] == "192.168.1.100" + + with patch( + "homeassistant.components.zendure_p1.config_flow.ZendureP1Client", + return_value=_mock_client(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.200"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert init_integration.data[CONF_HOST] == "192.168.1.200" diff --git a/tests/components/zendure_p1/test_diagnostics.py b/tests/components/zendure_p1/test_diagnostics.py new file mode 100644 index 00000000000000..b4ef9528022fa0 --- /dev/null +++ b/tests/components/zendure_p1/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Tests for Zendure Smart Meter P1 diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + mock_zendure_p1_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + + assert result == snapshot diff --git a/tests/components/zendure_p1/test_entity.py b/tests/components/zendure_p1/test_entity.py new file mode 100644 index 00000000000000..80e49839eefd44 --- /dev/null +++ b/tests/components/zendure_p1/test_entity.py @@ -0,0 +1,31 @@ +"""Tests for the Zendure Smart Meter P1 base entity.""" + +import pytest + +from homeassistant.components.zendure_p1.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_device_info( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that all entities belong to a single correctly-registered device.""" + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "SN123456")}) + assert device_entry is not None + assert device_entry.manufacturer == "Zendure" + assert device_entry.name == "Smart Meter P1" + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entity_entries) == 4 + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id diff --git a/tests/components/zendure_p1/test_init.py b/tests/components/zendure_p1/test_init.py new file mode 100644 index 00000000000000..da6e9176666b90 --- /dev/null +++ b/tests/components/zendure_p1/test_init.py @@ -0,0 +1,59 @@ +"""Tests for the Zendure Smart Meter P1 init module.""" + +from unittest.mock import AsyncMock + +import pytest +from zendure_p1 import ( + ZendureP1ConnectionError, + ZendureP1ResponseError, + ZendureP1TimeoutError, +) + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zendure_p1_client: AsyncMock, +) -> None: + """Test load and unload of the config entry.""" + mock_config_entry.add_to_hass(hass) + + 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 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_zendure_p1_client.close.assert_awaited_once() + + +@pytest.mark.parametrize( + "side_effect", + [ + ZendureP1ConnectionError(), + ZendureP1TimeoutError(), + ZendureP1ResponseError(), + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zendure_p1_client: AsyncMock, + side_effect: Exception, +) -> None: + """Test that a failed first refresh puts the entry in SETUP_RETRY state.""" + mock_zendure_p1_client.get_report.side_effect = side_effect + mock_config_entry.add_to_hass(hass) + + 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 + mock_zendure_p1_client.close.assert_awaited_once() diff --git a/tests/components/zendure_p1/test_sensor.py b/tests/components/zendure_p1/test_sensor.py new file mode 100644 index 00000000000000..0eb99e619ee71d --- /dev/null +++ b/tests/components/zendure_p1/test_sensor.py @@ -0,0 +1,77 @@ +"""Tests for the Zendure Smart Meter P1 sensor platform.""" + +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +from zendure_p1 import Report + +from homeassistant.components.zendure_p1.const import UPDATE_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor entities match the snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensor_values( + hass: HomeAssistant, + mock_zendure_p1_client: AsyncMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor entities expose values from the coordinator.""" + for key, expected in ( + ("phase_1_apparent_power", "100"), + ("phase_2_apparent_power", "200"), + ("phase_3_apparent_power", "300"), + ("total_power", "600"), + ): + unique_id = f"SN123456-{key}" + entity_id = entity_registry.async_get_entity_id( + "sensor", "zendure_p1", unique_id + ) + assert entity_id is not None, f"Entity {unique_id} not found" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected + + mock_zendure_p1_client.get_report.return_value = Report( + timestamp=2000000, + device_id="SN123456", + a_apparent_power=150, + b_apparent_power=250, + c_apparent_power=350, + total_power=750, + ) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for key, expected in ( + ("phase_1_apparent_power", "150"), + ("phase_2_apparent_power", "250"), + ("phase_3_apparent_power", "350"), + ("total_power", "750"), + ): + unique_id = f"SN123456-{key}" + entity_id = entity_registry.async_get_entity_id( + "sensor", "zendure_p1", unique_id + ) + assert entity_id is not None, f"Entity {unique_id} not found" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == expected