Add support for ACB batteries to Enphase Envoy (#131298)

* Add support for ACB batteries to Enphase Envoy

* Add tests for ACB battery support in ENphase Envoy

* make acb state sensordeviceclass ENUM

* Capitalize strings and use common idle
This commit is contained in:
Arie Catsman 2024-12-18 08:48:37 +01:00 committed by GitHub
parent fab92d1cf8
commit 4c91d1b402
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 5404 additions and 0 deletions

View File

@ -10,6 +10,8 @@ from operator import attrgetter
from typing import TYPE_CHECKING
from pyenphase import (
EnvoyACBPower,
EnvoyBatteryAggregate,
EnvoyEncharge,
EnvoyEnchargeAggregate,
EnvoyEnchargePower,
@ -723,6 +725,78 @@ ENCHARGE_AGGREGATE_SENSORS = (
)
@dataclass(frozen=True, kw_only=True)
class EnvoyAcbBatterySensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy ACB Battery sensor entity."""
value_fn: Callable[[EnvoyACBPower], int | str]
ACB_BATTERY_POWER_SENSORS = (
EnvoyAcbBatterySensorEntityDescription(
key="acb_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
value_fn=attrgetter("power"),
),
EnvoyAcbBatterySensorEntityDescription(
key="acb_soc",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
value_fn=attrgetter("state_of_charge"),
),
EnvoyAcbBatterySensorEntityDescription(
key="acb_battery_state",
translation_key="acb_battery_state",
device_class=SensorDeviceClass.ENUM,
options=["discharging", "idle", "charging", "full"],
value_fn=attrgetter("state"),
),
)
ACB_BATTERY_ENERGY_SENSORS = (
EnvoyAcbBatterySensorEntityDescription(
key="acb_available_energy",
translation_key="acb_available_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
value_fn=attrgetter("charge_wh"),
),
)
@dataclass(frozen=True, kw_only=True)
class EnvoyAggregateBatterySensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy aggregate Ensemble and ACB Battery sensor entity."""
value_fn: Callable[[EnvoyBatteryAggregate], int]
AGGREGATE_BATTERY_SENSORS = (
EnvoyAggregateBatterySensorEntityDescription(
key="aggregated_soc",
translation_key="aggregated_soc",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
value_fn=attrgetter("state_of_charge"),
),
EnvoyAggregateBatterySensorEntityDescription(
key="aggregated_available_energy",
translation_key="aggregated_available_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
value_fn=attrgetter("available_energy"),
),
EnvoyAggregateBatterySensorEntityDescription(
key="aggregated_max_battery_capacity",
translation_key="aggregated_max_capacity",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
value_fn=attrgetter("max_available_capacity"),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
@ -847,6 +921,20 @@ async def async_setup_entry(
EnvoyEnpowerEntity(coordinator, description)
for description in ENPOWER_SENSORS
)
if envoy_data.acb_power:
entities.extend(
EnvoyAcbBatteryPowerEntity(coordinator, description)
for description in ACB_BATTERY_POWER_SENSORS
)
entities.extend(
EnvoyAcbBatteryEnergyEntity(coordinator, description)
for description in ACB_BATTERY_ENERGY_SENSORS
)
if envoy_data.battery_aggregate:
entities.extend(
AggregateBatteryEntity(coordinator, description)
for description in AGGREGATE_BATTERY_SENSORS
)
async_add_entities(entities)
@ -1228,3 +1316,60 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity):
enpower = self.data.enpower
assert enpower is not None
return self.entity_description.value_fn(enpower)
class EnvoyAcbBatteryPowerEntity(EnvoySensorBaseEntity):
"""Envoy ACB Battery power sensor entity."""
entity_description: EnvoyAcbBatterySensorEntityDescription
def __init__(
self,
coordinator: EnphaseUpdateCoordinator,
description: EnvoyAcbBatterySensorEntityDescription,
) -> None:
"""Initialize ACB Battery entity."""
super().__init__(coordinator, description)
acb_data = self.data.acb_power
assert acb_data is not None
self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self.envoy_serial_num}_acb")},
manufacturer="Enphase",
model="ACB",
name=f"ACB {self.envoy_serial_num}",
via_device=(DOMAIN, self.envoy_serial_num),
)
@property
def native_value(self) -> int | str | None:
"""Return the state of the ACB Battery power sensors."""
acb = self.data.acb_power
assert acb is not None
return self.entity_description.value_fn(acb)
class EnvoyAcbBatteryEnergyEntity(EnvoySystemSensorEntity):
"""Envoy combined ACB and Ensemble Battery Aggregate energy sensor entity."""
entity_description: EnvoyAcbBatterySensorEntityDescription
@property
def native_value(self) -> int | str:
"""Return the state of the aggregate energy sensors."""
acb = self.data.acb_power
assert acb is not None
return self.entity_description.value_fn(acb)
class AggregateBatteryEntity(EnvoySystemSensorEntity):
"""Envoy combined ACB and Ensemble Battery Aggregate sensor entity."""
entity_description: EnvoyAggregateBatterySensorEntityDescription
@property
def native_value(self) -> int:
"""Return the state of the aggregate sensors."""
battery_aggregate = self.data.battery_aggregate
assert battery_aggregate is not None
return self.entity_description.value_fn(battery_aggregate)

View File

@ -337,6 +337,30 @@
},
"configured_reserve_soc": {
"name": "Configured reserve battery level"
},
"acb_battery_state": {
"name": "Battery state",
"state": {
"discharging": "Discharging",
"idle": "[%key:common::state::idle%]",
"charging": "Charging",
"full": "Full"
}
},
"acb_available_energy": {
"name": "Available ACB battery energy"
},
"acb_max_capacity": {
"name": "ACB Battery capacity"
},
"aggregated_available_energy": {
"name": "Aggregated available battery energy"
},
"aggregated_max_capacity": {
"name": "Aggregated Battery capacity"
},
"aggregated_soc": {
"name": "Aggregated battery soc"
}
},
"switch": {

View File

@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, Mock, patch
import jwt
from pyenphase import (
EnvoyACBPower,
EnvoyBatteryAggregate,
EnvoyData,
EnvoyEncharge,
EnvoyEnchargeAggregate,
@ -172,6 +174,8 @@ def _load_json_2_production_data(
mocked_data.system_production_phases[sub_item] = EnvoySystemProduction(
**item_data
)
if item := json_fixture["data"].get("acb_power"):
mocked_data.acb_power = EnvoyACBPower(**item)
def _load_json_2_meter_data(
@ -245,6 +249,8 @@ def _load_json_2_encharge_enpower_data(
mocked_data.dry_contact_settings[sub_item] = EnvoyDryContactSettings(
**item_data
)
if item := json_fixture["data"].get("battery_aggregate"):
mocked_data.battery_aggregate = EnvoyBatteryAggregate(**item)
def _load_json_2_raw_data(mocked_data: EnvoyData, json_fixture: dict[str, Any]) -> None:

View File

@ -0,0 +1,274 @@
{
"serial_number": "1234",
"firmware": "7.6.358",
"part_number": "800-00654-r08",
"envoy_model": "Envoy, phases: 3, phase mode: three, net-consumption CT, production CT",
"supported_features": 1759,
"phase_mode": "three",
"phase_count": 3,
"active_phase_count": 0,
"ct_meter_count": 2,
"consumption_meter_type": "net-consumption",
"production_meter_type": "production",
"storage_meter_type": null,
"data": {
"encharge_inventory": {
"123456": {
"admin_state": 6,
"admin_state_str": "ENCHG_STATE_READY",
"bmu_firmware_version": "2.1.16",
"comm_level_2_4_ghz": 4,
"comm_level_sub_ghz": 4,
"communicating": true,
"dc_switch_off": false,
"encharge_capacity": 3500,
"encharge_revision": 2,
"firmware_loaded_date": 1714736645,
"firmware_version": "2.6.6618_rel/22.11",
"installed_date": 1714736645,
"last_report_date": 1714804173,
"led_status": 17,
"max_cell_temp": 16,
"operating": true,
"part_number": "830-01760-r46",
"percent_full": 54,
"serial_number": "122327081322",
"temperature": 16,
"temperature_unit": "C",
"zigbee_dongle_fw_version": "100F"
}
},
"encharge_power": {
"123456": {
"apparent_power_mva": 105,
"real_power_mw": 105,
"soc": 54
}
},
"encharge_aggregate": {
"available_energy": 1890,
"backup_reserve": 0,
"state_of_charge": 54,
"reserve_state_of_charge": 0,
"configured_reserve_state_of_charge": 0,
"max_available_capacity": 3500
},
"enpower": null,
"acb_power": {
"power": 260,
"charge_wh": 930,
"state_of_charge": 25,
"state": "discharging",
"batteries": 3
},
"battery_aggregate": {
"available_energy": 2820,
"state_of_charge": 39,
"max_available_capacity": 7220
},
"system_consumption": {
"watt_hours_lifetime": 1234,
"watt_hours_last_7_days": 1234,
"watt_hours_today": 1234,
"watts_now": 1234
},
"system_production": {
"watt_hours_lifetime": 1234,
"watt_hours_last_7_days": 1234,
"watt_hours_today": 1234,
"watts_now": 1234
},
"system_consumption_phases": null,
"system_production_phases": null,
"system_net_consumption": {
"watt_hours_lifetime": 4321,
"watt_hours_last_7_days": -1,
"watt_hours_today": -1,
"watts_now": 2341
},
"system_net_consumption_phases": null,
"ctmeter_production": {
"eid": "100000010",
"timestamp": 1708006110,
"energy_delivered": 11234,
"energy_received": 12345,
"active_power": 100,
"power_factor": 0.11,
"voltage": 111,
"current": 0.2,
"frequency": 50.1,
"state": "enabled",
"measurement_type": "production",
"metering_status": "normal",
"status_flags": ["production-imbalance", "power-on-unused-phase"]
},
"ctmeter_consumption": {
"eid": "100000020",
"timestamp": 1708006120,
"energy_delivered": 21234,
"energy_received": 22345,
"active_power": 101,
"power_factor": 0.21,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "net-consumption",
"metering_status": "normal",
"status_flags": []
},
"ctmeter_storage": null,
"ctmeter_production_phases": {
"L1": {
"eid": "100000011",
"timestamp": 1708006111,
"energy_delivered": 112341,
"energy_received": 123451,
"active_power": 20,
"power_factor": 0.12,
"voltage": 111,
"current": 0.2,
"frequency": 50.1,
"state": "enabled",
"measurement_type": "production",
"metering_status": "normal",
"status_flags": ["production-imbalance"]
},
"L2": {
"eid": "100000012",
"timestamp": 1708006112,
"energy_delivered": 112342,
"energy_received": 123452,
"active_power": 30,
"power_factor": 0.13,
"voltage": 111,
"current": 0.2,
"frequency": 50.1,
"state": "enabled",
"measurement_type": "production",
"metering_status": "normal",
"status_flags": ["power-on-unused-phase"]
},
"L3": {
"eid": "100000013",
"timestamp": 1708006113,
"energy_delivered": 112343,
"energy_received": 123453,
"active_power": 50,
"power_factor": 0.14,
"voltage": 111,
"current": 0.2,
"frequency": 50.1,
"state": "enabled",
"measurement_type": "production",
"metering_status": "normal",
"status_flags": []
}
},
"ctmeter_consumption_phases": {
"L1": {
"eid": "100000021",
"timestamp": 1708006121,
"energy_delivered": 212341,
"energy_received": 223451,
"active_power": 21,
"power_factor": 0.22,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "net-consumption",
"metering_status": "normal",
"status_flags": []
},
"L2": {
"eid": "100000022",
"timestamp": 1708006122,
"energy_delivered": 212342,
"energy_received": 223452,
"active_power": 31,
"power_factor": 0.23,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "net-consumption",
"metering_status": "normal",
"status_flags": []
},
"L3": {
"eid": "100000023",
"timestamp": 1708006123,
"energy_delivered": 212343,
"energy_received": 223453,
"active_power": 51,
"power_factor": 0.24,
"voltage": 112,
"current": 0.3,
"frequency": 50.2,
"state": "enabled",
"measurement_type": "net-consumption",
"metering_status": "normal",
"status_flags": []
}
},
"ctmeter_storage_phases": null,
"dry_contact_status": {},
"dry_contact_settings": {},
"inverters": {
"1": {
"serial_number": "1",
"last_report_date": 1,
"last_report_watts": 1,
"max_report_watts": 1
}
},
"tariff": {
"currency": {
"code": "EUR"
},
"logger": "mylogger",
"date": "1714749724",
"storage_settings": {
"mode": "self-consumption",
"operation_mode_sub_type": "",
"reserved_soc": 0.0,
"very_low_soc": 5,
"charge_from_grid": true,
"date": "1714749724"
},
"single_rate": {
"rate": 0.0,
"sell": 0.0
},
"seasons": [
{
"id": "all_year_long",
"start": "1/1",
"days": [
{
"id": "all_days",
"days": "Mon,Tue,Wed,Thu,Fri,Sat,Sun",
"must_charge_start": 0,
"must_charge_duration": 0,
"must_charge_mode": "CP",
"enable_discharge_to_grid": false,
"periods": [
{
"id": "period_1",
"start": 0,
"rate": 0.0
}
]
}
],
"tiers": []
}
],
"seasons_sell": []
},
"raw": {
"varies_by": "firmware_version"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat
"envoy_metered_batt_relay",
"envoy_nobatt_metered_3p",
"envoy_tot_cons_metered",
"envoy_acb_batt",
],
indirect=["mock_envoy"],
)
@ -65,6 +66,7 @@ PRODUCTION_NAMES: tuple[str, ...] = (
"envoy_metered_batt_relay",
"envoy_nobatt_metered_3p",
"envoy_tot_cons_metered",
"envoy_acb_batt",
],
indirect=["mock_envoy"],
)
@ -154,6 +156,7 @@ CONSUMPTION_NAMES: tuple[str, ...] = (
"envoy_eu_batt",
"envoy_metered_batt_relay",
"envoy_nobatt_metered_3p",
"envoy_acb_batt",
],
indirect=["mock_envoy"],
)
@ -197,6 +200,7 @@ NET_CONSUMPTION_NAMES: tuple[str, ...] = (
"envoy_metered_batt_relay",
"envoy_nobatt_metered_3p",
"envoy_tot_cons_metered",
"envoy_acb_batt",
],
indirect=["mock_envoy"],
)
@ -803,6 +807,7 @@ async def test_sensor_inverter_disabled_by_integration(
("mock_envoy"),
[
"envoy_metered_batt_relay",
"envoy_acb_batt",
],
indirect=["mock_envoy"],
)
@ -873,6 +878,7 @@ async def test_sensor_encharge_enpower_data(
("mock_envoy"),
[
"envoy_metered_batt_relay",
"envoy_acb_batt",
],
indirect=["mock_envoy"],
)
@ -930,6 +936,101 @@ async def test_sensor_encharge_power_data(
)
ACB_POWER_INT_NAMES: tuple[str, ...] = (
"power",
"battery",
)
ACB_POWER_STR_NAMES: tuple[str, ...] = ("battery_state",)
@pytest.mark.parametrize(
("mock_envoy"),
[
"envoy_acb_batt",
],
indirect=["mock_envoy"],
)
async def test_sensor_acb_power_data(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_envoy: AsyncMock,
) -> None:
"""Test enphase_envoy acb battery power 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}.acb_{sn}"
data = mock_envoy.data.acb_power
ACB_POWER_INT_TARGETS: tuple[int, ...] = (
data.power,
data.state_of_charge,
)
ACB_POWER_STR_TARGETS: tuple[int, ...] = (data.state,)
for name, target in list(
zip(ACB_POWER_INT_NAMES, ACB_POWER_INT_TARGETS, strict=False)
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}"))
assert int(entity_state.state) == target
for name, target in list(
zip(ACB_POWER_STR_NAMES, ACB_POWER_STR_TARGETS, strict=False)
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}"))
assert entity_state.state == target
AGGREGATED_BATTERY_NAMES: tuple[str, ...] = (
"aggregated_battery_soc",
"aggregated_available_battery_energy",
"aggregated_battery_capacity",
)
AGGREGATED_ACB_BATTERY_NAMES: tuple[str, ...] = ("available_acb_battery_energy",)
@pytest.mark.parametrize(
("mock_envoy"),
[
"envoy_acb_batt",
],
indirect=["mock_envoy"],
)
async def test_sensor_aggegated_battery_data(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_envoy: AsyncMock,
) -> None:
"""Test enphase_envoy aggregated batteries 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.battery_aggregate
AGGREGATED_TARGETS: tuple[int, ...] = (
data.state_of_charge,
data.available_energy,
data.max_available_capacity,
)
for name, target in list(
zip(AGGREGATED_BATTERY_NAMES, AGGREGATED_TARGETS, strict=False)
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}"))
assert int(entity_state.state) == target
data = mock_envoy.data.acb_power
AGGREGATED_ACB_TARGETS: tuple[int, ...] = (data.charge_wh,)
for name, target in list(
zip(AGGREGATED_ACB_BATTERY_NAMES, AGGREGATED_ACB_TARGETS, strict=False)
):
assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}"))
assert int(entity_state.state) == target
def integration_disabled_entities(
entity_registry: er.EntityRegistry, config_entry: MockConfigEntry
) -> list[str]: