From 013e580c02fdbbf8b99c4f0e818fe3d8d3594055 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Fri, 20 Oct 2023 20:05:42 -0400 Subject: [PATCH] Add support for changing Enphase battery backup modes (#102392) --- .../components/enphase_envoy/manifest.json | 2 +- .../components/enphase_envoy/number.py | 80 +++++++++++++++++- .../components/enphase_envoy/select.py | 84 ++++++++++++++++++- .../components/enphase_envoy/strings.json | 11 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 174 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 8788c95d3c6..c524a2421c3 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.12.0"], + "requirements": ["pyenphase==1.13.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 50d4de18f12..918e4002e7a 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -1,10 +1,13 @@ """Number platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Any -from pyenphase import EnvoyDryContactSettings +from pyenphase import Envoy, EnvoyDryContactSettings +from pyenphase.const import SupportedFeatures +from pyenphase.models.tariff import EnvoyStorageSettings from homeassistant.components.number import ( NumberDeviceClass, @@ -12,7 +15,7 @@ from homeassistant.components.number import ( NumberEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -36,6 +39,21 @@ class EnvoyRelayNumberEntityDescription( """Describes an Envoy Dry Contact Relay number entity.""" +@dataclass +class EnvoyStorageSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyStorageSettings], float] + update_fn: Callable[[Envoy, float], Awaitable[dict[str, Any]]] + + +@dataclass +class EnvoyStorageSettingsNumberEntityDescription( + NumberEntityDescription, EnvoyStorageSettingsRequiredKeysMixin +): + """Describes an Envoy storage mode number entity.""" + + RELAY_ENTITIES = ( EnvoyRelayNumberEntityDescription( key="soc_low", @@ -53,6 +71,15 @@ RELAY_ENTITIES = ( ), ) +STORAGE_RESERVE_SOC_ENTITY = EnvoyStorageSettingsNumberEntityDescription( + key="reserve_soc", + translation_key="reserve_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + value_fn=lambda storage_settings: storage_settings.reserved_soc, + update_fn=lambda envoy, value: envoy.set_reserve_soc(int(value)), +) + async def async_setup_entry( hass: HomeAssistant, @@ -70,6 +97,14 @@ async def async_setup_entry( for entity in RELAY_ENTITIES for relay in envoy_data.dry_contact_settings ) + if ( + envoy_data.tariff + and envoy_data.tariff.storage_settings + and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE + ): + entities.append( + EnvoyStorageSettingsNumberEntity(coordinator, STORAGE_RESERVE_SOC_ENTITY) + ) async_add_entities(entities) @@ -114,3 +149,42 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity): {"id": self._relay_id, self.entity_description.key: int(value)} ) await self.coordinator.async_request_refresh() + + +class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity): + """Representation of an Enphase storage settings number entity.""" + + entity_description: EnvoyStorageSettingsNumberEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyStorageSettingsNumberEntityDescription, + ) -> None: + """Initialize the Enphase relay number entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + assert self.data.enpower is not None + enpower = self.data.enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def native_value(self) -> float: + """Return the state of the storage setting entity.""" + assert self.data.tariff is not None + assert self.data.tariff.storage_settings is not None + return self.entity_description.value_fn(self.data.tariff.storage_settings) + + async def async_set_native_value(self, value: float) -> None: + """Update the storage setting.""" + await self.entity_description.update_fn(self.envoy, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 5ae73a315f2..331d2a999ad 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -1,12 +1,14 @@ """Select platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from typing import Any from pyenphase import Envoy, EnvoyDryContactSettings +from pyenphase.const import SupportedFeatures from pyenphase.models.dry_contacts import DryContactAction, DryContactMode +from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -36,6 +38,21 @@ class EnvoyRelaySelectEntityDescription( """Describes an Envoy Dry Contact Relay select entity.""" +@dataclass +class EnvoyStorageSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyStorageSettings], str] + update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]] + + +@dataclass +class EnvoyStorageSettingsSelectEntityDescription( + SelectEntityDescription, EnvoyStorageSettingsRequiredKeysMixin +): + """Describes an Envoy storage settings select entity.""" + + RELAY_MODE_MAP = { DryContactMode.MANUAL: "standard", DryContactMode.STATE_OF_CHARGE: "battery", @@ -51,6 +68,14 @@ REVERSE_RELAY_ACTION_MAP = {v: k for k, v in RELAY_ACTION_MAP.items()} MODE_OPTIONS = list(REVERSE_RELAY_MODE_MAP) ACTION_OPTIONS = list(REVERSE_RELAY_ACTION_MAP) +STORAGE_MODE_MAP = { + EnvoyStorageMode.BACKUP: "backup", + EnvoyStorageMode.SELF_CONSUMPTION: "self_consumption", + EnvoyStorageMode.SAVINGS: "savings", +} +REVERSE_STORAGE_MODE_MAP = {v: k for k, v in STORAGE_MODE_MAP.items()} +STORAGE_MODE_OPTIONS = list(REVERSE_STORAGE_MODE_MAP) + RELAY_ENTITIES = ( EnvoyRelaySelectEntityDescription( key="mode", @@ -101,6 +126,15 @@ RELAY_ENTITIES = ( ), ), ) +STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription( + key="storage_mode", + translation_key="storage_mode", + options=STORAGE_MODE_OPTIONS, + value_fn=lambda storage_settings: STORAGE_MODE_MAP[storage_settings.mode], + update_fn=lambda envoy, value: envoy.set_storage_mode( + REVERSE_STORAGE_MODE_MAP[value] + ), +) async def async_setup_entry( @@ -119,6 +153,14 @@ async def async_setup_entry( for entity in RELAY_ENTITIES for relay in envoy_data.dry_contact_settings ) + if ( + envoy_data.tariff + and envoy_data.tariff.storage_settings + and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE + ): + entities.append( + EnvoyStorageSettingsSelectEntity(coordinator, STORAGE_MODE_ENTITY) + ) async_add_entities(entities) @@ -164,3 +206,43 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): """Update the relay.""" await self.entity_description.update_fn(self.envoy, self.relay, option) await self.coordinator.async_request_refresh() + + +class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): + """Representation of an Enphase storage settings select entity.""" + + entity_description: EnvoyStorageSettingsSelectEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyStorageSettingsSelectEntityDescription, + ) -> None: + """Initialize the Enphase storage settings select entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + assert coordinator.envoy.data is not None + assert coordinator.envoy.data.enpower is not None + enpower = coordinator.envoy.data.enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def current_option(self) -> str: + """Return the state of the select entity.""" + assert self.data.tariff is not None + assert self.data.tariff.storage_settings is not None + return self.entity_description.value_fn(self.data.tariff.storage_settings) + + async def async_select_option(self, option: str) -> None: + """Update the relay.""" + await self.entity_description.update_fn(self.envoy, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 7c5d48edfe7..94cf9233745 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -39,6 +39,9 @@ }, "restore_battery_level": { "name": "Restore battery level" + }, + "reserve_soc": { + "name": "Reserve battery level" } }, "select": { @@ -75,6 +78,14 @@ "schedule": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::schedule%]", "none": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::none%]" } + }, + "storage_mode": { + "name": "Storage mode", + "state": { + "self_consumption": "Self consumption", + "backup": "Full backup", + "savings": "Savings mode" + } } }, "sensor": { diff --git a/requirements_all.txt b/requirements_all.txt index 58c8f7fb6ed..464fe28ef8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1691,7 +1691,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.12.0 +pyenphase==1.13.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b2c2e9d249..62bac0f7a1b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1273,7 +1273,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.12.0 +pyenphase==1.13.0 # homeassistant.components.everlights pyeverlights==0.1.0