Skip to content

Commit dd6bc71

Browse files
Add switch platform and grid charge enable for Growatt Server integration (home-assistant#153960)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 1452aec commit dd6bc71

File tree

6 files changed

+703
-1
lines changed

6 files changed

+703
-1
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]
39+
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
4040

4141
LOGIN_INVALID_AUTH_CODE = "502"
4242

homeassistant/components/growatt_server/strings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,11 @@
461461
"total_maximum_output": {
462462
"name": "Maximum power"
463463
}
464+
},
465+
"switch": {
466+
"ac_charge": {
467+
"name": "Charge from grid"
468+
}
464469
}
465470
}
466471
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Switch platform for Growatt."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
import logging
7+
from typing import Any
8+
9+
from growattServer import GrowattV1ApiError
10+
11+
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
12+
from homeassistant.const import EntityCategory
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import HomeAssistantError
15+
from homeassistant.helpers.device_registry import DeviceInfo
16+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
17+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
18+
19+
from .const import DOMAIN
20+
from .coordinator import GrowattConfigEntry, GrowattCoordinator
21+
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
PARALLEL_UPDATES = (
26+
1 # Serialize updates as inverter does not handle concurrent requests
27+
)
28+
29+
30+
@dataclass(frozen=True, kw_only=True)
31+
class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKeysMixin):
32+
"""Describes Growatt switch entity."""
33+
34+
write_key: str | None = None # Parameter ID for writing (if different from api_key)
35+
36+
37+
# Note that the Growatt V1 API uses different keys for reading and writing parameters.
38+
# Reading values returns camelCase keys, while writing requires snake_case keys.
39+
40+
MIN_SWITCH_TYPES: tuple[GrowattSwitchEntityDescription, ...] = (
41+
GrowattSwitchEntityDescription(
42+
key="ac_charge",
43+
translation_key="ac_charge",
44+
api_key="acChargeEnable", # Key returned by V1 API
45+
write_key="ac_charge", # Key used to write parameter
46+
),
47+
)
48+
49+
50+
async def async_setup_entry(
51+
hass: HomeAssistant,
52+
entry: GrowattConfigEntry,
53+
async_add_entities: AddConfigEntryEntitiesCallback,
54+
) -> None:
55+
"""Set up Growatt switch entities."""
56+
runtime_data = entry.runtime_data
57+
58+
# Add switch entities for each MIN device (only supported with V1 API)
59+
async_add_entities(
60+
GrowattSwitch(device_coordinator, description)
61+
for device_coordinator in runtime_data.devices.values()
62+
if (
63+
device_coordinator.device_type == "min"
64+
and device_coordinator.api_version == "v1"
65+
)
66+
for description in MIN_SWITCH_TYPES
67+
)
68+
69+
70+
class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
71+
"""Representation of a Growatt switch."""
72+
73+
_attr_has_entity_name = True
74+
_attr_entity_category = EntityCategory.CONFIG
75+
entity_description: GrowattSwitchEntityDescription
76+
77+
def __init__(
78+
self,
79+
coordinator: GrowattCoordinator,
80+
description: GrowattSwitchEntityDescription,
81+
) -> None:
82+
"""Initialize the switch."""
83+
super().__init__(coordinator)
84+
self.entity_description = description
85+
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
86+
self._attr_device_info = DeviceInfo(
87+
identifiers={(DOMAIN, coordinator.device_id)},
88+
manufacturer="Growatt",
89+
name=coordinator.device_id,
90+
)
91+
92+
@property
93+
def is_on(self) -> bool | None:
94+
"""Return true if the switch is on."""
95+
value = self.coordinator.data.get(self.entity_description.api_key)
96+
if value is None:
97+
return None
98+
99+
# API returns integer 1 for enabled, 0 for disabled
100+
return bool(value)
101+
102+
async def async_turn_on(self, **kwargs: Any) -> None:
103+
"""Turn the switch on."""
104+
await self._async_set_state(True)
105+
106+
async def async_turn_off(self, **kwargs: Any) -> None:
107+
"""Turn the switch off."""
108+
await self._async_set_state(False)
109+
110+
async def _async_set_state(self, state: bool) -> None:
111+
"""Set the switch state."""
112+
# Use write_key if specified, otherwise fall back to api_key
113+
parameter_id = (
114+
self.entity_description.write_key or self.entity_description.api_key
115+
)
116+
api_value = int(state)
117+
118+
try:
119+
# Use V1 API to write parameter
120+
await self.hass.async_add_executor_job(
121+
self.coordinator.api.min_write_parameter,
122+
self.coordinator.device_id,
123+
parameter_id,
124+
api_value,
125+
)
126+
except GrowattV1ApiError as e:
127+
raise HomeAssistantError(f"Error while setting switch state: {e}") from e
128+
129+
# If no exception was raised, the write was successful
130+
_LOGGER.debug(
131+
"Set switch %s to %s",
132+
parameter_id,
133+
api_value,
134+
)
135+
136+
# Update the value in coordinator data (keep as integer like API returns)
137+
self.coordinator.data[self.entity_description.api_key] = api_value
138+
self.async_write_ha_state()
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Common fixtures for the Growatt server tests."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
7+
from homeassistant.components.growatt_server.const import (
8+
AUTH_API_TOKEN,
9+
AUTH_PASSWORD,
10+
CONF_AUTH_TYPE,
11+
CONF_PLANT_ID,
12+
DEFAULT_URL,
13+
DOMAIN,
14+
)
15+
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
16+
from homeassistant.core import HomeAssistant
17+
18+
from tests.common import MockConfigEntry
19+
20+
21+
@pytest.fixture
22+
def mock_growatt_v1_api():
23+
"""Return a mocked Growatt V1 API.
24+
25+
This fixture provides the happy path for integration setup and basic operations.
26+
Individual tests can override specific return values to test error conditions.
27+
28+
Methods mocked for integration setup:
29+
- device_list: Called during async_setup_entry to discover devices
30+
- plant_energy_overview: Called by total coordinator during first refresh
31+
32+
Methods mocked for MIN device coordinator refresh:
33+
- min_detail: Provides device state (e.g., acChargeEnable for switches)
34+
- min_settings: Provides settings (e.g. TOU periods)
35+
- min_energy: Provides energy data (empty for switch tests, sensors need real data)
36+
37+
Methods mocked for switch operations:
38+
- min_write_parameter: Called by switch entities to change settings
39+
"""
40+
with patch("growattServer.OpenApiV1", autospec=True) as mock_v1_api_class:
41+
mock_v1_api = mock_v1_api_class.return_value
42+
43+
# Called during setup to discover devices
44+
mock_v1_api.device_list.return_value = {
45+
"devices": [
46+
{
47+
"device_sn": "MIN123456",
48+
"type": 7, # MIN device type
49+
}
50+
]
51+
}
52+
53+
# Called by MIN device coordinator during refresh
54+
mock_v1_api.min_detail.return_value = {
55+
"deviceSn": "MIN123456",
56+
"acChargeEnable": 1, # AC charge enabled - read by switch entity
57+
}
58+
59+
# Called by MIN device coordinator during refresh
60+
mock_v1_api.min_settings.return_value = {
61+
# Forced charge time segments (not used by switch, but coordinator fetches it)
62+
"forcedTimeStart1": "06:00",
63+
"forcedTimeStop1": "08:00",
64+
"forcedChargeBatMode1": 1,
65+
"forcedChargeFlag1": 1,
66+
"forcedTimeStart2": "22:00",
67+
"forcedTimeStop2": "24:00",
68+
"forcedChargeBatMode2": 0,
69+
"forcedChargeFlag2": 0,
70+
}
71+
72+
# Called by MIN device coordinator during refresh
73+
# Empty dict is sufficient for switch tests (sensor tests would need real energy data)
74+
mock_v1_api.min_energy.return_value = {}
75+
76+
# Called by total coordinator during refresh
77+
mock_v1_api.plant_energy_overview.return_value = {
78+
"today_energy": 12.5,
79+
"total_energy": 1250.0,
80+
"current_power": 2500,
81+
}
82+
83+
# Called by switch entities during turn_on/turn_off
84+
mock_v1_api.min_write_parameter.return_value = None
85+
86+
yield mock_v1_api
87+
88+
89+
@pytest.fixture
90+
def mock_growatt_classic_api():
91+
"""Return a mocked Growatt Classic API.
92+
93+
This fixture provides the happy path for Classic API integration setup.
94+
Individual tests can override specific return values to test error conditions.
95+
96+
Methods mocked for integration setup:
97+
- login: Called during get_device_list_classic to authenticate
98+
- plant_list: Called during setup if plant_id is default (to auto-select plant)
99+
- device_list: Called during async_setup_entry to discover devices
100+
101+
Methods mocked for total coordinator refresh:
102+
- plant_info: Provides plant totals (energy, power, money) for Classic API
103+
104+
Methods mocked for device-specific tests:
105+
- tlx_detail: Provides TLX device data (kept for potential future tests)
106+
"""
107+
with patch("growattServer.GrowattApi", autospec=True) as mock_classic_api_class:
108+
# Use the autospec'd mock instance instead of creating a new Mock()
109+
mock_classic_api = mock_classic_api_class.return_value
110+
111+
# Called during setup to authenticate with Classic API
112+
mock_classic_api.login.return_value = {"success": True, "user": {"id": 12345}}
113+
114+
# Called during setup if plant_id is default (auto-select first plant)
115+
mock_classic_api.plant_list.return_value = {"data": [{"plantId": "12345"}]}
116+
117+
# Called during setup to discover devices
118+
mock_classic_api.device_list.return_value = [
119+
{"deviceSn": "MIN123456", "deviceType": "min"}
120+
]
121+
122+
# Called by total coordinator during refresh for Classic API
123+
mock_classic_api.plant_info.return_value = {
124+
"deviceList": [],
125+
"totalEnergy": 1250.0,
126+
"todayEnergy": 12.5,
127+
"invTodayPpv": 2500,
128+
"plantMoneyText": "123.45/USD",
129+
}
130+
131+
# Called for TLX device coordinator (kept for potential future tests)
132+
mock_classic_api.tlx_detail.return_value = {
133+
"data": {
134+
"deviceSn": "TLX123456",
135+
}
136+
}
137+
138+
yield mock_classic_api
139+
140+
141+
@pytest.fixture
142+
def mock_config_entry() -> MockConfigEntry:
143+
"""Return the default mocked config entry (V1 API with token auth).
144+
145+
This is the primary config entry used by most tests. For Classic API tests,
146+
use mock_config_entry_classic instead.
147+
"""
148+
return MockConfigEntry(
149+
domain=DOMAIN,
150+
data={
151+
CONF_AUTH_TYPE: AUTH_API_TOKEN,
152+
CONF_TOKEN: "test_token_123",
153+
CONF_URL: DEFAULT_URL,
154+
"user_id": "12345",
155+
CONF_PLANT_ID: "plant_123",
156+
"name": "Test Plant",
157+
},
158+
unique_id="plant_123",
159+
)
160+
161+
162+
@pytest.fixture
163+
def mock_config_entry_classic() -> MockConfigEntry:
164+
"""Return a mocked config entry for Classic API (password auth).
165+
166+
Use this for tests that specifically need to test Classic API behavior.
167+
Most tests use the default mock_config_entry (V1 API) instead.
168+
"""
169+
return MockConfigEntry(
170+
domain=DOMAIN,
171+
data={
172+
CONF_AUTH_TYPE: AUTH_PASSWORD,
173+
CONF_USERNAME: "test_user",
174+
CONF_PASSWORD: "test_password",
175+
CONF_URL: DEFAULT_URL,
176+
CONF_PLANT_ID: "12345",
177+
"name": "Test Plant",
178+
},
179+
unique_id="12345",
180+
)
181+
182+
183+
@pytest.fixture
184+
async def init_integration(
185+
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_growatt_v1_api
186+
) -> MockConfigEntry:
187+
"""Set up the Growatt Server integration for testing (V1 API).
188+
189+
This combines mock_config_entry and mock_growatt_v1_api to provide a fully
190+
initialized integration ready for testing. Use @pytest.mark.usefixtures("init_integration")
191+
to automatically set up the integration before your test runs.
192+
193+
For Classic API tests, manually set up using mock_config_entry_classic and
194+
mock_growatt_classic_api instead.
195+
"""
196+
# The mock_growatt_v1_api fixture is required for patches to be active
197+
assert mock_growatt_v1_api is not None
198+
199+
mock_config_entry.add_to_hass(hass)
200+
await hass.config_entries.async_setup(mock_config_entry.entry_id)
201+
await hass.async_block_till_done()
202+
return mock_config_entry

0 commit comments

Comments
 (0)