Skip to content

Commit 83e9fca

Browse files
Adds support for controlling Growatt MIN/TLX inverters through number platform and entities (home-assistant#153886)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent fc9313f commit 83e9fca

File tree

6 files changed

+790
-7
lines changed

6 files changed

+790
-7
lines changed

homeassistant/components/growatt_server/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
DOMAIN = "growatt_server"
3838

39-
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
39+
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
4040

4141
LOGIN_INVALID_AUTH_CODE = "502"
4242

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Number platform for Growatt."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
import logging
7+
8+
from growattServer import GrowattV1ApiError
9+
10+
from homeassistant.components.number import NumberEntity, NumberEntityDescription
11+
from homeassistant.const import PERCENTAGE, EntityCategory
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.exceptions import HomeAssistantError
14+
from homeassistant.helpers.device_registry import DeviceInfo
15+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
16+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
17+
18+
from .const import DOMAIN
19+
from .coordinator import GrowattConfigEntry, GrowattCoordinator
20+
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
24+
PARALLEL_UPDATES = (
25+
1 # Serialize updates as inverter does not handle concurrent requests
26+
)
27+
28+
29+
@dataclass(frozen=True, kw_only=True)
30+
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin):
31+
"""Describes Growatt number entity."""
32+
33+
write_key: str | None = None # Parameter ID for writing (if different from api_key)
34+
35+
36+
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
37+
# Reading values returns camelCase keys, while writing requires snake_case keys.
38+
39+
MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = (
40+
GrowattNumberEntityDescription(
41+
key="battery_charge_power_limit",
42+
translation_key="battery_charge_power_limit",
43+
api_key="chargePowerCommand", # Key returned by V1 API
44+
write_key="charge_power", # Key used to write parameter
45+
native_step=1,
46+
native_min_value=0,
47+
native_max_value=100,
48+
native_unit_of_measurement=PERCENTAGE,
49+
),
50+
GrowattNumberEntityDescription(
51+
key="battery_charge_soc_limit",
52+
translation_key="battery_charge_soc_limit",
53+
api_key="wchargeSOCLowLimit", # Key returned by V1 API
54+
write_key="charge_stop_soc", # Key used to write parameter
55+
native_step=1,
56+
native_min_value=0,
57+
native_max_value=100,
58+
native_unit_of_measurement=PERCENTAGE,
59+
),
60+
GrowattNumberEntityDescription(
61+
key="battery_discharge_power_limit",
62+
translation_key="battery_discharge_power_limit",
63+
api_key="disChargePowerCommand", # Key returned by V1 API
64+
write_key="discharge_power", # Key used to write parameter
65+
native_step=1,
66+
native_min_value=0,
67+
native_max_value=100,
68+
native_unit_of_measurement=PERCENTAGE,
69+
),
70+
GrowattNumberEntityDescription(
71+
key="battery_discharge_soc_limit",
72+
translation_key="battery_discharge_soc_limit",
73+
api_key="wdisChargeSOCLowLimit", # Key returned by V1 API
74+
write_key="discharge_stop_soc", # Key used to write parameter
75+
native_step=1,
76+
native_min_value=0,
77+
native_max_value=100,
78+
native_unit_of_measurement=PERCENTAGE,
79+
),
80+
)
81+
82+
83+
async def async_setup_entry(
84+
hass: HomeAssistant,
85+
entry: GrowattConfigEntry,
86+
async_add_entities: AddConfigEntryEntitiesCallback,
87+
) -> None:
88+
"""Set up Growatt number entities."""
89+
runtime_data = entry.runtime_data
90+
91+
# Add number entities for each MIN device (only supported with V1 API)
92+
async_add_entities(
93+
GrowattNumber(device_coordinator, description)
94+
for device_coordinator in runtime_data.devices.values()
95+
if (
96+
device_coordinator.device_type == "min"
97+
and device_coordinator.api_version == "v1"
98+
)
99+
for description in MIN_NUMBER_TYPES
100+
)
101+
102+
103+
class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
104+
"""Representation of a Growatt number."""
105+
106+
_attr_has_entity_name = True
107+
_attr_entity_category = EntityCategory.CONFIG
108+
entity_description: GrowattNumberEntityDescription
109+
110+
def __init__(
111+
self,
112+
coordinator: GrowattCoordinator,
113+
description: GrowattNumberEntityDescription,
114+
) -> None:
115+
"""Initialize the number."""
116+
super().__init__(coordinator)
117+
self.entity_description = description
118+
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
119+
self._attr_device_info = DeviceInfo(
120+
identifiers={(DOMAIN, coordinator.device_id)},
121+
manufacturer="Growatt",
122+
name=coordinator.device_id,
123+
)
124+
125+
@property
126+
def native_value(self) -> int | None:
127+
"""Return the current value of the number."""
128+
value = self.coordinator.data.get(self.entity_description.api_key)
129+
if value is None:
130+
return None
131+
return int(value)
132+
133+
async def async_set_native_value(self, value: float) -> None:
134+
"""Set the value of the number."""
135+
# Use write_key if specified, otherwise fall back to api_key
136+
parameter_id = (
137+
self.entity_description.write_key or self.entity_description.api_key
138+
)
139+
int_value = int(value)
140+
141+
try:
142+
# Use V1 API to write parameter
143+
await self.hass.async_add_executor_job(
144+
self.coordinator.api.min_write_parameter,
145+
self.coordinator.device_id,
146+
parameter_id,
147+
int_value,
148+
)
149+
except GrowattV1ApiError as e:
150+
raise HomeAssistantError(f"Error while setting parameter: {e}") from e
151+
152+
# If no exception was raised, the write was successful
153+
_LOGGER.debug(
154+
"Set parameter %s to %s",
155+
parameter_id,
156+
value,
157+
)
158+
159+
# Update the value in coordinator data to avoid triggering an immediate
160+
# refresh that would hit the API rate limit (5-minute polling interval)
161+
self.coordinator.data[self.entity_description.api_key] = int_value
162+
self.async_write_ha_state()

homeassistant/components/growatt_server/strings.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,20 @@
504504
"name": "Maximum power"
505505
}
506506
},
507+
"number": {
508+
"battery_charge_power_limit": {
509+
"name": "Battery charge power limit"
510+
},
511+
"battery_charge_soc_limit": {
512+
"name": "Battery charge SOC limit"
513+
},
514+
"battery_discharge_power_limit": {
515+
"name": "Battery discharge power limit"
516+
},
517+
"battery_discharge_soc_limit": {
518+
"name": "Battery discharge SOC limit"
519+
}
520+
},
507521
"switch": {
508522
"ac_charge": {
509523
"name": "Charge from grid"

tests/components/growatt_server/conftest.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ def mock_growatt_v1_api():
3030
- plant_energy_overview: Called by total coordinator during first refresh
3131
3232
Methods mocked for MIN device coordinator refresh:
33-
- min_detail: Provides device state (e.g., acChargeEnable for switches)
33+
- min_detail: Provides device state (e.g., acChargeEnable, chargePowerCommand)
3434
- min_settings: Provides settings (e.g. TOU periods)
35-
- min_energy: Provides energy data (empty for switch tests, sensors need real data)
35+
- min_energy: Provides energy data (empty for switch/number tests, sensors need real data)
3636
37-
Methods mocked for switch operations:
38-
- min_write_parameter: Called by switch entities to change settings
37+
Methods mocked for switch and number operations:
38+
- min_write_parameter: Called by switch/number entities to change settings
3939
"""
4040
with patch("growattServer.OpenApiV1", autospec=True) as mock_v1_api_class:
4141
mock_v1_api = mock_v1_api_class.return_value
@@ -54,11 +54,15 @@ def mock_growatt_v1_api():
5454
mock_v1_api.min_detail.return_value = {
5555
"deviceSn": "MIN123456",
5656
"acChargeEnable": 1, # AC charge enabled - read by switch entity
57+
"chargePowerCommand": 50, # 50% charge power - read by number entity
58+
"wchargeSOCLowLimit": 10, # 10% charge stop SOC - read by number entity
59+
"disChargePowerCommand": 80, # 80% discharge power - read by number entity
60+
"wdisChargeSOCLowLimit": 20, # 20% discharge stop SOC - read by number entity
5761
}
5862

5963
# Called by MIN device coordinator during refresh
6064
mock_v1_api.min_settings.return_value = {
61-
# Forced charge time segments (not used by switch, but coordinator fetches it)
65+
# Forced charge time segments (not used by switch/number, but coordinator fetches it)
6266
"forcedTimeStart1": "06:00",
6367
"forcedTimeStop1": "08:00",
6468
"forcedChargeBatMode1": 1,
@@ -91,7 +95,7 @@ def mock_growatt_v1_api():
9195
"current_power": 2500,
9296
}
9397

94-
# Called by switch entities during turn_on/turn_off
98+
# Called by switch/number entities during turn_on/turn_off/set_value
9599
mock_v1_api.min_write_parameter.return_value = None
96100

97101
yield mock_v1_api

0 commit comments

Comments
 (0)