Add balanced grid import/export to enphase_envoy (#123154)

* Add balanced grid import/export to enphase_envoy

* rebuild sensor snapshot after dev merge

* Cleanup snapshot file
pull/125006/head
Arie Catsman 2024-09-08 12:15:00 +02:00 committed by GitHub
parent c0492d4af4
commit 74b78307ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1423 additions and 0 deletions

View File

@ -228,6 +228,50 @@ CONSUMPTION_PHASE_SENSORS = {
}
NET_CONSUMPTION_SENSORS = (
EnvoyConsumptionSensorEntityDescription(
key="balanced_net_consumption",
translation_key="balanced_net_consumption",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("watts_now"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="lifetime_balanced_net_consumption",
translation_key="lifetime_balanced_net_consumption",
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("watt_hours_lifetime"),
on_phase=None,
),
)
NET_CONSUMPTION_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [
replace(
sensor,
key=f"{sensor.key}_l{phase + 1}",
translation_key=f"{sensor.translation_key}_phase",
entity_registry_enabled_default=False,
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
)
for sensor in list(NET_CONSUMPTION_SENSORS)
]
for phase in range(3)
}
@dataclass(frozen=True, kw_only=True)
class EnvoyCTSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy CT sensor entity."""
@ -697,6 +741,11 @@ async def async_setup_entry(
EnvoyConsumptionEntity(coordinator, description)
for description in CONSUMPTION_SENSORS
)
if envoy_data.system_net_consumption:
entities.extend(
EnvoyNetConsumptionEntity(coordinator, description)
for description in NET_CONSUMPTION_SENSORS
)
# For each production phase reported add production entities
if envoy_data.system_production_phases:
entities.extend(
@ -713,6 +762,14 @@ async def async_setup_entry(
for description in CONSUMPTION_PHASE_SENSORS[use_phase]
if phase is not None
)
# For each net_consumption phase reported add consumption entities
if envoy_data.system_net_consumption_phases:
entities.extend(
EnvoyNetConsumptionPhaseEntity(coordinator, description)
for use_phase, phase in envoy_data.system_net_consumption_phases.items()
for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase]
if phase is not None
)
# Add net consumption CT entities
if ctmeter := envoy_data.ctmeter_consumption:
entities.extend(
@ -846,6 +903,19 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity):
return self.entity_description.value_fn(system_consumption)
class EnvoyNetConsumptionEntity(EnvoySystemSensorEntity):
"""Envoy consumption entity."""
entity_description: EnvoyConsumptionSensorEntityDescription
@property
def native_value(self) -> int | None:
"""Return the state of the sensor."""
system_net_consumption = self.data.system_net_consumption
assert system_net_consumption is not None
return self.entity_description.value_fn(system_net_consumption)
class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity):
"""Envoy phase production entity."""
@ -888,6 +958,27 @@ class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity):
return self.entity_description.value_fn(system_consumption)
class EnvoyNetConsumptionPhaseEntity(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_net_consumption_phases
if (
system_net_consumption := self.data.system_net_consumption_phases[
self.entity_description.on_phase
]
) is None:
return None
return self.entity_description.value_fn(system_net_consumption)
class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity):
"""Envoy net consumption CT entity."""

View File

@ -165,6 +165,18 @@
"lifetime_consumption_phase": {
"name": "Lifetime energy consumption {phase_name}"
},
"balanced_net_consumption": {
"name": "balanced net power consumption"
},
"lifetime_balanced_net_consumption": {
"name": "Lifetime balanced net energy consumption"
},
"balanced_net_consumption_phase": {
"name": "balanced net power consumption {phase_name}"
},
"lifetime_balanced_net_consumption_phase": {
"name": "Lifetime balanced net energy consumption {phase_name}"
},
"lifetime_net_consumption": {
"name": "Lifetime net energy consumption"
},

View File

@ -150,6 +150,8 @@ def _load_json_2_production_data(
"""Fill envoy production data from fixture."""
if item := json_fixture["data"].get("system_consumption"):
mocked_data.system_consumption = EnvoySystemConsumption(**item)
if item := json_fixture["data"].get("system_net_consumption"):
mocked_data.system_net_consumption = EnvoySystemConsumption(**item)
if item := json_fixture["data"].get("system_production"):
mocked_data.system_production = EnvoySystemProduction(**item)
if item := json_fixture["data"].get("system_consumption_phases"):
@ -158,6 +160,12 @@ def _load_json_2_production_data(
mocked_data.system_consumption_phases[sub_item] = EnvoySystemConsumption(
**item_data
)
if item := json_fixture["data"].get("system_net_consumption_phases"):
mocked_data.system_net_consumption_phases = {}
for sub_item, item_data in item.items():
mocked_data.system_net_consumption_phases[sub_item] = (
EnvoySystemConsumption(**item_data)
)
if item := json_fixture["data"].get("system_production_phases"):
mocked_data.system_production_phases = {}
for sub_item, item_data in item.items():

View File

@ -17,6 +17,7 @@
"encharge_aggregate": null,
"enpower": null,
"system_consumption": null,
"system_net_consumption": null,
"system_production": {
"watt_hours_lifetime": 1234,
"watt_hours_last_7_days": 1234,
@ -24,6 +25,7 @@
"watts_now": 1234
},
"system_consumption_phases": null,
"system_net_consumption_phases": null,
"system_production_phases": null,
"ctmeter_production": null,
"ctmeter_consumption": null,

View File

@ -22,6 +22,12 @@
"watt_hours_today": 1234,
"watts_now": 1234
},
"system_net_consumption": {
"watt_hours_lifetime": 4321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 2341
},
"system_production": {
"watt_hours_lifetime": 1234,
"watt_hours_last_7_days": 1234,
@ -29,6 +35,7 @@
"watts_now": 1234
},
"system_consumption_phases": null,
"system_net_consumption_phases": null,
"system_production_phases": null,
"ctmeter_production": {
"eid": "100000010",

View File

@ -79,6 +79,12 @@
"watt_hours_today": 1234,
"watts_now": 1234
},
"system_net_consumption": {
"watt_hours_lifetime": 4321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 2341
},
"system_production": {
"watt_hours_lifetime": 1234,
"watt_hours_last_7_days": 1234,
@ -105,6 +111,26 @@
"watts_now": 3324
}
},
"system_net_consumption_phases": {
"L1": {
"watt_hours_lifetime": 1321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 12341
},
"L2": {
"watt_hours_lifetime": 2321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 22341
},
"L3": {
"watt_hours_lifetime": 3321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 32341
}
},
"system_production_phases": {
"L1": {
"watt_hours_lifetime": 1232,

View File

@ -22,6 +22,12 @@
"watt_hours_today": 1234,
"watts_now": 1234
},
"system_net_consumption": {
"watt_hours_lifetime": 4321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 2341
},
"system_production": {
"watt_hours_lifetime": 1234,
"watt_hours_last_7_days": 1234,
@ -48,6 +54,26 @@
"watts_now": 3324
}
},
"system_net_consumption_phases": {
"L1": {
"watt_hours_lifetime": 1321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 12341
},
"L2": {
"watt_hours_lifetime": 2321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 22341
},
"L3": {
"watt_hours_lifetime": 3321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 32341
}
},
"system_production_phases": {
"L1": {
"watt_hours_lifetime": 1232,

View File

@ -17,6 +17,12 @@
"encharge_aggregate": null,
"enpower": null,
"system_consumption": null,
"system_net_consumption": {
"watt_hours_lifetime": 4321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 2341
},
"system_production": {
"watt_hours_lifetime": 1234,
"watt_hours_last_7_days": 1234,
@ -24,6 +30,7 @@
"watts_now": 1234
},
"system_consumption_phases": null,
"system_net_consumption_phases": null,
"system_production_phases": null,
"ctmeter_production": {
"eid": "100000010",

File diff suppressed because it is too large Load Diff

View File

@ -179,6 +179,47 @@ async def test_sensor_consumption_data(
assert float(entity_state.state) == target
NET_CONSUMPTION_NAMES: tuple[str, ...] = (
"balanced_net_power_consumption",
"lifetime_balanced_net_energy_consumption",
)
@pytest.mark.parametrize(
("mock_envoy"),
[
"envoy_1p_metered",
"envoy_metered_batt_relay",
"envoy_nobatt_metered_3p",
"envoy_tot_cons_metered",
],
indirect=["mock_envoy"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_net_consumption_data(
hass: HomeAssistant,
mock_envoy: AsyncMock,
config_entry: MockConfigEntry,
) -> None:
"""Test net consumption entities values."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, config_entry)
sn = mock_envoy.serial_number
ENTITY_BASE: str = f"{Platform.SENSOR}.envoy_{sn}"
data = mock_envoy.data.system_net_consumption
NET_CONSUMPTION_TARGETS = (
data.watts_now / 1000.0,
data.watt_hours_lifetime / 1000.0,
)
for name, target in list(
zip(NET_CONSUMPTION_NAMES, NET_CONSUMPTION_TARGETS, strict=False)
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}"))
assert float(entity_state.state) == target
CONSUMPTION_PHASE_NAMES: list[str] = [
f"{name}_{phase.lower()}" for phase in PHASENAMES for name in CONSUMPTION_NAMES
]
@ -224,6 +265,48 @@ async def test_sensor_consumption_phase_data(
assert float(entity_state.state) == target
NET_CONSUMPTION_PHASE_NAMES: list[str] = [
f"{name}_{phase.lower()}" for phase in PHASENAMES for name in NET_CONSUMPTION_NAMES
]
@pytest.mark.parametrize(
("mock_envoy"),
[
"envoy_metered_batt_relay",
"envoy_nobatt_metered_3p",
],
indirect=["mock_envoy"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_net_consumption_phase_data(
hass: HomeAssistant,
mock_envoy: AsyncMock,
config_entry: MockConfigEntry,
) -> None:
"""Test consumption phase entities values."""
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, config_entry)
sn = mock_envoy.serial_number
ENTITY_BASE: str = f"{Platform.SENSOR}.envoy_{sn}"
NET_CONSUMPTION_PHASE_TARGET = chain(
*[
(
phase_data.watts_now / 1000.0,
phase_data.watt_hours_lifetime / 1000.0,
)
for phase_data in mock_envoy.data.system_net_consumption_phases.values()
]
)
for name, target in list(
zip(NET_CONSUMPTION_PHASE_NAMES, NET_CONSUMPTION_PHASE_TARGET, strict=False)
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}"))
assert float(entity_state.state) == target
CT_PRODUCTION_NAMES_INT = ("meter_status_flags_active_production_ct",)
CT_PRODUCTION_NAMES_STR = ("metering_status_production_ct",)
@ -877,6 +960,7 @@ async def test_sensor_missing_data(
# force missing data to test 'if == none' code sections
mock_envoy.data.system_production_phases["L2"] = None
mock_envoy.data.system_consumption_phases["L2"] = None
mock_envoy.data.system_net_consumption_phases["L2"] = None
mock_envoy.data.ctmeter_production = None
mock_envoy.data.ctmeter_consumption = None
mock_envoy.data.ctmeter_storage = None