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>
This commit is contained in:
Arie Catsman 2024-01-28 22:46:47 +01:00 committed by GitHub
parent e13a34df0f
commit 2b33feb341
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 183 additions and 2 deletions

View File

@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass, replace
import datetime import datetime
import logging import logging
from typing import TYPE_CHECKING
from pyenphase import ( from pyenphase import (
EnvoyEncharge, EnvoyEncharge,
@ -15,6 +16,7 @@ from pyenphase import (
EnvoySystemConsumption, EnvoySystemConsumption,
EnvoySystemProduction, EnvoySystemProduction,
) )
from pyenphase.const import PHASENAMES, PhaseNames
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -85,6 +87,7 @@ class EnvoyProductionRequiredKeysMixin:
"""Mixin for required keys.""" """Mixin for required keys."""
value_fn: Callable[[EnvoySystemProduction], int] value_fn: Callable[[EnvoySystemProduction], int]
on_phase: PhaseNames | None
@dataclass(frozen=True) @dataclass(frozen=True)
@ -104,6 +107,7 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3, suggested_display_precision=3,
value_fn=lambda production: production.watts_now, value_fn=lambda production: production.watts_now,
on_phase=None,
), ),
EnvoyProductionSensorEntityDescription( EnvoyProductionSensorEntityDescription(
key="daily_production", key="daily_production",
@ -114,6 +118,7 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2, suggested_display_precision=2,
value_fn=lambda production: production.watt_hours_today, value_fn=lambda production: production.watt_hours_today,
on_phase=None,
), ),
EnvoyProductionSensorEntityDescription( EnvoyProductionSensorEntityDescription(
key="seven_days_production", key="seven_days_production",
@ -123,6 +128,7 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1, suggested_display_precision=1,
value_fn=lambda production: production.watt_hours_last_7_days, value_fn=lambda production: production.watt_hours_last_7_days,
on_phase=None,
), ),
EnvoyProductionSensorEntityDescription( EnvoyProductionSensorEntityDescription(
key="lifetime_production", key="lifetime_production",
@ -133,15 +139,32 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3, suggested_display_precision=3,
value_fn=lambda production: production.watt_hours_lifetime, 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) @dataclass(frozen=True)
class EnvoyConsumptionRequiredKeysMixin: class EnvoyConsumptionRequiredKeysMixin:
"""Mixin for required keys.""" """Mixin for required keys."""
value_fn: Callable[[EnvoySystemConsumption], int] value_fn: Callable[[EnvoySystemConsumption], int]
on_phase: PhaseNames | None
@dataclass(frozen=True) @dataclass(frozen=True)
@ -161,6 +184,7 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3, suggested_display_precision=3,
value_fn=lambda consumption: consumption.watts_now, value_fn=lambda consumption: consumption.watts_now,
on_phase=None,
), ),
EnvoyConsumptionSensorEntityDescription( EnvoyConsumptionSensorEntityDescription(
key="daily_consumption", key="daily_consumption",
@ -171,6 +195,7 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2, suggested_display_precision=2,
value_fn=lambda consumption: consumption.watt_hours_today, value_fn=lambda consumption: consumption.watt_hours_today,
on_phase=None,
), ),
EnvoyConsumptionSensorEntityDescription( EnvoyConsumptionSensorEntityDescription(
key="seven_days_consumption", key="seven_days_consumption",
@ -180,6 +205,7 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1, suggested_display_precision=1,
value_fn=lambda consumption: consumption.watt_hours_last_7_days, value_fn=lambda consumption: consumption.watt_hours_last_7_days,
on_phase=None,
), ),
EnvoyConsumptionSensorEntityDescription( EnvoyConsumptionSensorEntityDescription(
key="lifetime_consumption", key="lifetime_consumption",
@ -190,10 +216,26 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3, suggested_display_precision=3,
value_fn=lambda consumption: consumption.watt_hours_lifetime, 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) @dataclass(frozen=True)
class EnvoyEnchargeRequiredKeysMixin: class EnvoyEnchargeRequiredKeysMixin:
"""Mixin for required keys.""" """Mixin for required keys."""
@ -361,6 +403,23 @@ async def async_setup_entry(
EnvoyConsumptionEntity(coordinator, description) EnvoyConsumptionEntity(coordinator, description)
for description in CONSUMPTION_SENSORS 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: if envoy_data.inverters:
entities.extend( entities.extend(
EnvoyInverterEntity(coordinator, description, inverter) EnvoyInverterEntity(coordinator, description, inverter)
@ -414,9 +473,11 @@ class EnvoySystemSensorEntity(EnvoySensorBaseEntity):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.envoy_serial_num)}, identifiers={(DOMAIN, self.envoy_serial_num)},
manufacturer="Enphase", manufacturer="Enphase",
model=coordinator.envoy.part_number or "Envoy", model=coordinator.envoy.envoy_model,
name=coordinator.name, name=coordinator.name,
sw_version=str(coordinator.envoy.firmware), 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) 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): class EnvoyInverterEntity(EnvoySensorBaseEntity):
"""Envoy inverter entity.""" """Envoy inverter entity."""

View File

@ -119,6 +119,30 @@
"lifetime_consumption": { "lifetime_consumption": {
"name": "Lifetime energy 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": { "reserve_soc": {
"name": "Reserve battery level" "name": "Reserve battery level"
}, },

View File

@ -9,6 +9,8 @@ from pyenphase import (
EnvoySystemProduction, EnvoySystemProduction,
EnvoyTokenAuth, EnvoyTokenAuth,
) )
from pyenphase.const import PhaseNames, SupportedFeatures
from pyenphase.models.meters import CtType, EnvoyPhaseMode
import pytest import pytest
from homeassistant.components.enphase_envoy import DOMAIN 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.authenticate = mock_authenticate
mock_envoy.setup = mock_setup mock_envoy.setup = mock_setup
mock_envoy.auth = mock_auth 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( mock_envoy.data = EnvoyData(
system_consumption=EnvoySystemConsumption( system_consumption=EnvoySystemConsumption(
watt_hours_last_7_days=1234, 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, watt_hours_today=1234,
watts_now=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={ inverters={
"1": EnvoyInverter( "1": EnvoyInverter(
serial_number="1", serial_number="1",