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