From 2b33feb34158d1d87a97bc1f77ebf2d9656b07ee Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sun, 28 Jan 2024 22:46:47 +0100 Subject: [PATCH] Add phase entities to Enphase Envoy (#108725) * add phase entities to Enphase Envoy * Implement review feedback for translation strings * Enphase Envoy multiphase review changes Move device name logic to separate function. Refactor native value for phases Use dataclasses.replace for phase entities, add on-phase to base class as well, no need for phase entity descriptions anymore * Enphase Envoy reviewe feedback Move model determination to library. Revert states test for future split to sensor test. * Enphase_Envoy use model description from pyenphase library * Enphase_Envoy refactor Phase Sensors * Enphase_Envoy use walrus in phase sensor --------- Co-authored-by: J. Nick Koston <nick@koston.org> --- .../components/enphase_envoy/sensor.py | 107 +++++++++++++++++- .../components/enphase_envoy/strings.json | 24 ++++ tests/components/enphase_envoy/conftest.py | 54 +++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 2ae9dca63ba..c2ecf8e8a13 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace import datetime import logging +from typing import TYPE_CHECKING from pyenphase import ( EnvoyEncharge, @@ -15,6 +16,7 @@ from pyenphase import ( EnvoySystemConsumption, EnvoySystemProduction, ) +from pyenphase.const import PHASENAMES, PhaseNames from homeassistant.components.sensor import ( SensorDeviceClass, @@ -85,6 +87,7 @@ class EnvoyProductionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemProduction], int] + on_phase: PhaseNames | None @dataclass(frozen=True) @@ -104,6 +107,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_display_precision=3, value_fn=lambda production: production.watts_now, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="daily_production", @@ -114,6 +118,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, value_fn=lambda production: production.watt_hours_today, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="seven_days_production", @@ -123,6 +128,7 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=1, value_fn=lambda production: production.watt_hours_last_7_days, + on_phase=None, ), EnvoyProductionSensorEntityDescription( key="lifetime_production", @@ -133,15 +139,32 @@ PRODUCTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, suggested_display_precision=3, value_fn=lambda production: production.watt_hours_lifetime, + on_phase=None, ), ) +PRODUCTION_PHASE_SENSORS = { + (on_phase := PhaseNames(PHASENAMES[phase])): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(PRODUCTION_SENSORS) + ] + for phase in range(0, 3) +} + + @dataclass(frozen=True) class EnvoyConsumptionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemConsumption], int] + on_phase: PhaseNames | None @dataclass(frozen=True) @@ -161,6 +184,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_display_precision=3, value_fn=lambda consumption: consumption.watts_now, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="daily_consumption", @@ -171,6 +195,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=2, value_fn=lambda consumption: consumption.watt_hours_today, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="seven_days_consumption", @@ -180,6 +205,7 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_display_precision=1, value_fn=lambda consumption: consumption.watt_hours_last_7_days, + on_phase=None, ), EnvoyConsumptionSensorEntityDescription( key="lifetime_consumption", @@ -190,10 +216,26 @@ CONSUMPTION_SENSORS = ( suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, suggested_display_precision=3, value_fn=lambda consumption: consumption.watt_hours_lifetime, + on_phase=None, ), ) +CONSUMPTION_PHASE_SENSORS = { + (on_phase := PhaseNames(PHASENAMES[phase])): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(CONSUMPTION_SENSORS) + ] + for phase in range(0, 3) +} + + @dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" @@ -361,6 +403,23 @@ async def async_setup_entry( EnvoyConsumptionEntity(coordinator, description) for description in CONSUMPTION_SENSORS ) + # For each production phase reported add production entities + if envoy_data.system_production_phases: + entities.extend( + EnvoyProductionPhaseEntity(coordinator, description) + for use_phase, phase in envoy_data.system_production_phases.items() + for description in PRODUCTION_PHASE_SENSORS[PhaseNames(use_phase)] + if phase is not None + ) + # For each consumption phase reported add consumption entities + if envoy_data.system_consumption_phases: + entities.extend( + EnvoyConsumptionPhaseEntity(coordinator, description) + for use_phase, phase in envoy_data.system_consumption_phases.items() + for description in CONSUMPTION_PHASE_SENSORS[PhaseNames(use_phase)] + if phase is not None + ) + if envoy_data.inverters: entities.extend( EnvoyInverterEntity(coordinator, description, inverter) @@ -414,9 +473,11 @@ class EnvoySystemSensorEntity(EnvoySensorBaseEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.envoy_serial_num)}, manufacturer="Enphase", - model=coordinator.envoy.part_number or "Envoy", + model=coordinator.envoy.envoy_model, name=coordinator.name, sw_version=str(coordinator.envoy.firmware), + hw_version=coordinator.envoy.part_number, + serial_number=self.envoy_serial_num, ) @@ -446,6 +507,48 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity): return self.entity_description.value_fn(system_consumption) +class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity): + """Envoy phase production entity.""" + + entity_description: EnvoyProductionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + assert self.data.system_production_phases + + if ( + system_production := self.data.system_production_phases[ + self.entity_description.on_phase + ] + ) is None: + return None + return self.entity_description.value_fn(system_production) + + +class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity): + """Envoy phase consumption entity.""" + + entity_description: EnvoyConsumptionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + assert self.data.system_consumption_phases + + if ( + system_consumption := self.data.system_consumption_phases[ + self.entity_description.on_phase + ] + ) is None: + return None + return self.entity_description.value_fn(system_consumption) + + class EnvoyInverterEntity(EnvoySensorBaseEntity): """Envoy inverter entity.""" diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index fe32002e6b2..f3e78432f90 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -119,6 +119,30 @@ "lifetime_consumption": { "name": "Lifetime energy consumption" }, + "current_power_production_phase": { + "name": "Current power production {phase_name}" + }, + "daily_production_phase": { + "name": "Energy production today {phase_name}" + }, + "seven_days_production_phase": { + "name": "Energy production last seven days {phase_name}" + }, + "lifetime_production_phase": { + "name": "Lifetime energy production {phase_name}" + }, + "current_power_consumption_phase": { + "name": "Current power consumption {phase_name}" + }, + "daily_consumption_phase": { + "name": "Energy consumption today {phase_name}" + }, + "seven_days_consumption_phase": { + "name": "Energy consumption last seven days {phase_name}" + }, + "lifetime_consumption_phase": { + "name": "Lifetime energy consumption {phase_name}" + }, "reserve_soc": { "name": "Reserve battery level" }, diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 185f65aa892..ed0a60dfe94 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -9,6 +9,8 @@ from pyenphase import ( EnvoySystemProduction, EnvoyTokenAuth, ) +from pyenphase.const import PhaseNames, SupportedFeatures +from pyenphase.models.meters import CtType, EnvoyPhaseMode import pytest from homeassistant.components.enphase_envoy import DOMAIN @@ -53,6 +55,18 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): mock_envoy.authenticate = mock_authenticate mock_envoy.setup = mock_setup mock_envoy.auth = mock_auth + mock_envoy.supported_features = SupportedFeatures( + SupportedFeatures.INVERTERS + | SupportedFeatures.PRODUCTION + | SupportedFeatures.PRODUCTION + | SupportedFeatures.METERING + | SupportedFeatures.THREEPHASE + ) + mock_envoy.phase_mode = EnvoyPhaseMode.THREE + mock_envoy.phase_count = 3 + mock_envoy.active_phase_count = 3 + mock_envoy.ct_meter_count = 2 + mock_envoy.consumption_meter_type = CtType.NET_CONSUMPTION mock_envoy.data = EnvoyData( system_consumption=EnvoySystemConsumption( watt_hours_last_7_days=1234, @@ -66,6 +80,46 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): watt_hours_today=1234, watts_now=1234, ), + system_consumption_phases={ + PhaseNames.PHASE_1: EnvoySystemConsumption( + watt_hours_last_7_days=1321, + watt_hours_lifetime=1322, + watt_hours_today=1323, + watts_now=1324, + ), + PhaseNames.PHASE_2: EnvoySystemConsumption( + watt_hours_last_7_days=2321, + watt_hours_lifetime=2322, + watt_hours_today=2323, + watts_now=2324, + ), + PhaseNames.PHASE_3: EnvoySystemConsumption( + watt_hours_last_7_days=3321, + watt_hours_lifetime=3322, + watt_hours_today=3323, + watts_now=3324, + ), + }, + system_production_phases={ + PhaseNames.PHASE_1: EnvoySystemProduction( + watt_hours_last_7_days=1231, + watt_hours_lifetime=1232, + watt_hours_today=1233, + watts_now=1234, + ), + PhaseNames.PHASE_2: EnvoySystemProduction( + watt_hours_last_7_days=2231, + watt_hours_lifetime=2232, + watt_hours_today=2233, + watts_now=2234, + ), + PhaseNames.PHASE_3: EnvoySystemProduction( + watt_hours_last_7_days=3231, + watt_hours_lifetime=3232, + watt_hours_today=3233, + watts_now=3234, + ), + }, inverters={ "1": EnvoyInverter( serial_number="1", -- GitLab