Skip to content

Commit 1709a9d

Browse files
Add services for managing Time-of-Use (TOU) schedule for Growatt integration (#154703)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent bcf46f0 commit 1709a9d

File tree

10 files changed

+1141
-7
lines changed

10 files changed

+1141
-7
lines changed

homeassistant/components/growatt_server/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
1111
from homeassistant.core import HomeAssistant
1212
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
13+
from homeassistant.helpers import config_validation as cv
14+
from homeassistant.helpers.typing import ConfigType
1315

1416
from .const import (
1517
AUTH_API_TOKEN,
@@ -19,14 +21,25 @@
1921
DEFAULT_PLANT_ID,
2022
DEFAULT_URL,
2123
DEPRECATED_URLS,
24+
DOMAIN,
2225
LOGIN_INVALID_AUTH_CODE,
2326
PLATFORMS,
2427
)
2528
from .coordinator import GrowattConfigEntry, GrowattCoordinator
2629
from .models import GrowattRuntimeData
30+
from .services import async_register_services
2731

2832
_LOGGER = logging.getLogger(__name__)
2933

34+
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
35+
36+
37+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
38+
"""Set up the Growatt Server component."""
39+
# Register services
40+
await async_register_services(hass)
41+
return True
42+
3043

3144
def get_device_list_classic(
3245
api: growattServer.GrowattApi, config: Mapping[str, str]

homeassistant/components/growatt_server/const.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,8 @@
4646

4747
# Config flow abort reasons
4848
ABORT_NO_PLANTS = "no_plants"
49+
50+
# Battery modes for TOU (Time of Use) settings
51+
BATT_MODE_LOAD_FIRST = 0
52+
BATT_MODE_BATTERY_FIRST = 1
53+
BATT_MODE_GRID_FIRST = 2

homeassistant/components/growatt_server/coordinator.py

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@
1212
from homeassistant.config_entries import ConfigEntry
1313
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
1414
from homeassistant.core import HomeAssistant
15+
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
1516
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1617
from homeassistant.util import dt as dt_util
1718

18-
from .const import DEFAULT_URL, DOMAIN
19+
from .const import (
20+
BATT_MODE_BATTERY_FIRST,
21+
BATT_MODE_GRID_FIRST,
22+
BATT_MODE_LOAD_FIRST,
23+
DEFAULT_URL,
24+
DOMAIN,
25+
)
1926
from .models import GrowattRuntimeData
2027

2128
if TYPE_CHECKING:
@@ -247,3 +254,134 @@ def get_data(
247254
self.previous_values[variable] = return_value
248255

249256
return return_value
257+
258+
async def update_time_segment(
259+
self, segment_id: int, batt_mode: int, start_time, end_time, enabled: bool
260+
) -> None:
261+
"""Update an inverter time segment.
262+
263+
Args:
264+
segment_id: Time segment ID (1-9)
265+
batt_mode: Battery mode (0=load first, 1=battery first, 2=grid first)
266+
start_time: Start time (datetime.time object)
267+
end_time: End time (datetime.time object)
268+
enabled: Whether the segment is enabled
269+
"""
270+
_LOGGER.debug(
271+
"Updating time segment %d for device %s (mode=%d, %s-%s, enabled=%s)",
272+
segment_id,
273+
self.device_id,
274+
batt_mode,
275+
start_time,
276+
end_time,
277+
enabled,
278+
)
279+
280+
if self.api_version != "v1":
281+
raise ServiceValidationError(
282+
"Updating time segments requires token authentication"
283+
)
284+
285+
try:
286+
# Use V1 API for token authentication
287+
# The library's _process_response will raise GrowattV1ApiError if error_code != 0
288+
await self.hass.async_add_executor_job(
289+
self.api.min_write_time_segment,
290+
self.device_id,
291+
segment_id,
292+
batt_mode,
293+
start_time,
294+
end_time,
295+
enabled,
296+
)
297+
except growattServer.GrowattV1ApiError as err:
298+
raise HomeAssistantError(f"API error updating time segment: {err}") from err
299+
300+
# Update coordinator's cached data without making an API call (avoids rate limit)
301+
if self.data:
302+
# Update the time segment data in the cache
303+
self.data[f"forcedTimeStart{segment_id}"] = start_time.strftime("%H:%M")
304+
self.data[f"forcedTimeStop{segment_id}"] = end_time.strftime("%H:%M")
305+
self.data[f"time{segment_id}Mode"] = batt_mode
306+
self.data[f"forcedStopSwitch{segment_id}"] = 1 if enabled else 0
307+
308+
# Notify entities of the updated data (no API call)
309+
self.async_set_updated_data(self.data)
310+
311+
async def read_time_segments(self) -> list[dict]:
312+
"""Read time segments from an inverter.
313+
314+
Returns:
315+
List of dictionaries containing segment information
316+
"""
317+
_LOGGER.debug("Reading time segments for device %s", self.device_id)
318+
319+
if self.api_version != "v1":
320+
raise ServiceValidationError(
321+
"Reading time segments requires token authentication"
322+
)
323+
324+
# Ensure we have current data
325+
if not self.data:
326+
_LOGGER.debug("Coordinator data not available, triggering refresh")
327+
await self.async_refresh()
328+
329+
time_segments = []
330+
331+
# Extract time segments from coordinator data
332+
for i in range(1, 10): # Segments 1-9
333+
segment = self._parse_time_segment(i)
334+
time_segments.append(segment)
335+
336+
return time_segments
337+
338+
def _parse_time_segment(self, segment_id: int) -> dict:
339+
"""Parse a single time segment from coordinator data."""
340+
# Get raw time values - these should always be present from the API
341+
start_time_raw = self.data.get(f"forcedTimeStart{segment_id}")
342+
end_time_raw = self.data.get(f"forcedTimeStop{segment_id}")
343+
344+
# Handle 'null' or empty values from API
345+
if start_time_raw in ("null", None, ""):
346+
start_time_raw = "0:0"
347+
if end_time_raw in ("null", None, ""):
348+
end_time_raw = "0:0"
349+
350+
# Format times with leading zeros (HH:MM)
351+
start_time = self._format_time(str(start_time_raw))
352+
end_time = self._format_time(str(end_time_raw))
353+
354+
# Get battery mode
355+
batt_mode_int = int(
356+
self.data.get(f"time{segment_id}Mode", BATT_MODE_LOAD_FIRST)
357+
)
358+
359+
# Map numeric mode to string key (matches update_time_segment input format)
360+
mode_map = {
361+
BATT_MODE_LOAD_FIRST: "load_first",
362+
BATT_MODE_BATTERY_FIRST: "battery_first",
363+
BATT_MODE_GRID_FIRST: "grid_first",
364+
}
365+
batt_mode = mode_map.get(batt_mode_int, "load_first")
366+
367+
# Get enabled status
368+
enabled = bool(int(self.data.get(f"forcedStopSwitch{segment_id}", 0)))
369+
370+
return {
371+
"segment_id": segment_id,
372+
"start_time": start_time,
373+
"end_time": end_time,
374+
"batt_mode": batt_mode,
375+
"enabled": enabled,
376+
}
377+
378+
def _format_time(self, time_raw: str) -> str:
379+
"""Format time string to HH:MM format."""
380+
try:
381+
parts = str(time_raw).split(":")
382+
hour = int(parts[0])
383+
minute = int(parts[1])
384+
except (ValueError, IndexError):
385+
return "00:00"
386+
else:
387+
return f"{hour:02d}:{minute:02d}"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"services": {
3+
"read_time_segments": {
4+
"service": "mdi:clock-outline"
5+
},
6+
"update_time_segment": {
7+
"service": "mdi:clock-edit"
8+
}
9+
}
10+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Service handlers for Growatt Server integration."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
from typing import TYPE_CHECKING, Any
7+
8+
from homeassistant.config_entries import ConfigEntryState
9+
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
10+
from homeassistant.exceptions import ServiceValidationError
11+
from homeassistant.helpers import device_registry as dr
12+
13+
from .const import (
14+
BATT_MODE_BATTERY_FIRST,
15+
BATT_MODE_GRID_FIRST,
16+
BATT_MODE_LOAD_FIRST,
17+
DOMAIN,
18+
)
19+
20+
if TYPE_CHECKING:
21+
from .coordinator import GrowattCoordinator
22+
23+
24+
async def async_register_services(hass: HomeAssistant) -> None:
25+
"""Register services for Growatt Server integration."""
26+
27+
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
28+
"""Get all MIN coordinators with V1 API from loaded config entries."""
29+
min_coordinators: dict[str, GrowattCoordinator] = {}
30+
31+
for entry in hass.config_entries.async_entries(DOMAIN):
32+
if entry.state != ConfigEntryState.LOADED:
33+
continue
34+
35+
# Add MIN coordinators from this entry
36+
for coord in entry.runtime_data.devices.values():
37+
if coord.device_type == "min" and coord.api_version == "v1":
38+
min_coordinators[coord.device_id] = coord
39+
40+
return min_coordinators
41+
42+
def get_coordinator(device_id: str) -> GrowattCoordinator:
43+
"""Get coordinator by device_id.
44+
45+
Args:
46+
device_id: Device registry ID (not serial number)
47+
"""
48+
# Get current coordinators (they may have changed since service registration)
49+
min_coordinators = get_min_coordinators()
50+
51+
if not min_coordinators:
52+
raise ServiceValidationError(
53+
"No MIN devices with token authentication are configured. "
54+
"Services require MIN devices with V1 API access."
55+
)
56+
57+
# Device registry ID provided - map to serial number
58+
device_registry = dr.async_get(hass)
59+
device_entry = device_registry.async_get(device_id)
60+
61+
if not device_entry:
62+
raise ServiceValidationError(f"Device '{device_id}' not found")
63+
64+
# Extract serial number from device identifiers
65+
serial_number = None
66+
for identifier in device_entry.identifiers:
67+
if identifier[0] == DOMAIN:
68+
serial_number = identifier[1]
69+
break
70+
71+
if not serial_number:
72+
raise ServiceValidationError(
73+
f"Device '{device_id}' is not a Growatt device"
74+
)
75+
76+
# Find coordinator by serial number
77+
if serial_number not in min_coordinators:
78+
raise ServiceValidationError(
79+
f"MIN device '{serial_number}' not found or not configured for services"
80+
)
81+
82+
return min_coordinators[serial_number]
83+
84+
async def handle_update_time_segment(call: ServiceCall) -> None:
85+
"""Handle update_time_segment service call."""
86+
segment_id: int = int(call.data["segment_id"])
87+
batt_mode_str: str = call.data["batt_mode"]
88+
start_time_str: str = call.data["start_time"]
89+
end_time_str: str = call.data["end_time"]
90+
enabled: bool = call.data["enabled"]
91+
device_id: str = call.data["device_id"]
92+
93+
# Validate segment_id range
94+
if not 1 <= segment_id <= 9:
95+
raise ServiceValidationError(
96+
f"segment_id must be between 1 and 9, got {segment_id}"
97+
)
98+
99+
# Validate and convert batt_mode string to integer
100+
valid_modes = {
101+
"load_first": BATT_MODE_LOAD_FIRST,
102+
"battery_first": BATT_MODE_BATTERY_FIRST,
103+
"grid_first": BATT_MODE_GRID_FIRST,
104+
}
105+
if batt_mode_str not in valid_modes:
106+
raise ServiceValidationError(
107+
f"batt_mode must be one of {list(valid_modes.keys())}, got '{batt_mode_str}'"
108+
)
109+
batt_mode: int = valid_modes[batt_mode_str]
110+
111+
# Convert time strings to datetime.time objects
112+
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
113+
try:
114+
# Take only HH:MM part (ignore seconds if present)
115+
start_parts = start_time_str.split(":")
116+
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
117+
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
118+
except (ValueError, IndexError) as err:
119+
raise ServiceValidationError(
120+
"start_time must be in HH:MM or HH:MM:SS format"
121+
) from err
122+
123+
try:
124+
# Take only HH:MM part (ignore seconds if present)
125+
end_parts = end_time_str.split(":")
126+
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
127+
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
128+
except (ValueError, IndexError) as err:
129+
raise ServiceValidationError(
130+
"end_time must be in HH:MM or HH:MM:SS format"
131+
) from err
132+
133+
# Get the appropriate MIN coordinator
134+
coordinator: GrowattCoordinator = get_coordinator(device_id)
135+
136+
await coordinator.update_time_segment(
137+
segment_id,
138+
batt_mode,
139+
start_time,
140+
end_time,
141+
enabled,
142+
)
143+
144+
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
145+
"""Handle read_time_segments service call."""
146+
device_id: str = call.data["device_id"]
147+
148+
# Get the appropriate MIN coordinator
149+
coordinator: GrowattCoordinator = get_coordinator(device_id)
150+
151+
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
152+
153+
return {"time_segments": time_segments}
154+
155+
# Register services without schema - services.yaml will provide UI definition
156+
# Schema validation happens in the handler functions
157+
hass.services.async_register(
158+
DOMAIN,
159+
"update_time_segment",
160+
handle_update_time_segment,
161+
supports_response=SupportsResponse.NONE,
162+
)
163+
164+
hass.services.async_register(
165+
DOMAIN,
166+
"read_time_segments",
167+
handle_read_time_segments,
168+
supports_response=SupportsResponse.ONLY,
169+
)

0 commit comments

Comments
 (0)