Skip to content

Commit 2d96a14

Browse files
committed
Merge dev branch with SPH profile fixes
2 parents dcb47b5 + 884ded7 commit 2d96a14

25 files changed

Lines changed: 2404 additions & 983 deletions

custom_components/growatt_modbus/__init__.py

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

1818
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
1919

20-
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR]
20+
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.SELECT, Platform.NUMBER]
2121

2222

2323
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
"""
2+
Auto-Detection System for Growatt Inverters
3+
4+
This module implements automatic inverter type detection similar to the
5+
solax-modbus plugin's async_determineInverterType function.
6+
7+
It reads the inverter's serial number and model information to automatically
8+
select the correct profile.
9+
"""
10+
11+
import logging
12+
from typing import Optional, Tuple
13+
14+
from homeassistant.core import HomeAssistant
15+
16+
from .device_profiles import INVERTER_PROFILES, get_profile
17+
from .growatt_modbus import GrowattModbus
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
22+
def detect_profile_from_model_name(model_name: str) -> Optional[str]:
23+
"""
24+
Match a model name string to a profile key.
25+
26+
Args:
27+
model_name: Model name read from inverter (e.g., "MIN 10000TL-X")
28+
29+
Returns:
30+
Profile key or None if no match
31+
"""
32+
if not model_name:
33+
return None
34+
35+
# Normalize model name for comparison
36+
model_upper = model_name.upper().replace("-", "").replace(" ", "")
37+
38+
# Model name patterns to profile mappings
39+
patterns = {
40+
# MIN series
41+
'MIN3000': 'min_3000_6000_tl_x',
42+
'MIN4000': 'min_3000_6000_tl_x',
43+
'MIN5000': 'min_3000_6000_tl_x',
44+
'MIN6000': 'min_3000_6000_tl_x',
45+
'MIN7000': 'min_7000_10000_tl_x',
46+
'MIN8000': 'min_7000_10000_tl_x',
47+
'MIN9000': 'min_7000_10000_tl_x',
48+
'MIN10000': 'min_7000_10000_tl_x',
49+
50+
# TL-XH series
51+
'TLXH3000': 'tl_xh_3000_10000',
52+
'TLXH5000': 'tl_xh_3000_10000',
53+
'TLXH6000': 'tl_xh_3000_10000',
54+
'TLXH8000': 'tl_xh_3000_10000',
55+
'TLXH10000': 'tl_xh_3000_10000',
56+
'TLXHUS': 'tl_xh_us_3000_10000',
57+
58+
# MID series
59+
'MID15000': 'mid_15000_25000tl3_x',
60+
'MID17000': 'mid_15000_25000tl3_x',
61+
'MID20000': 'mid_15000_25000tl3_x',
62+
'MID22000': 'mid_15000_25000tl3_x',
63+
'MID25000': 'mid_15000_25000tl3_x',
64+
65+
# MAC series
66+
'MAC20000': 'mac_20000_40000tl3_x',
67+
'MAC25000': 'mac_20000_40000tl3_x',
68+
'MAC30000': 'mac_20000_40000tl3_x',
69+
'MAC36000': 'mac_20000_40000tl3_x',
70+
'MAC40000': 'mac_20000_40000tl3_x',
71+
72+
# MAX series
73+
'MAX50': 'max_50000_125000tl3_x',
74+
'MAX60': 'max_50000_125000tl3_x',
75+
'MAX75': 'max_50000_125000tl3_x',
76+
'MAX100': 'max_50000_125000tl3_x',
77+
'MAX110': 'max_50000_125000tl3_x',
78+
'MAX125': 'max_50000_125000tl3_x',
79+
'MAX1500V': 'max_1500v_series',
80+
'MAXXLV': 'max_x_lv_series',
81+
82+
# SPH series
83+
'SPH3000': 'sph_3000_10000',
84+
'SPH3600': 'sph_3000_10000',
85+
'SPH4000': 'sph_3000_10000',
86+
'SPH5000': 'sph_3000_10000',
87+
'SPH6000': 'sph_3000_10000',
88+
'SPH8000': 'sph_3000_10000',
89+
'SPH10000': 'sph_3000_10000',
90+
91+
# MOD series
92+
'MOD6000': 'mod_6000_15000tl3_xh',
93+
'MOD8000': 'mod_6000_15000tl3_xh',
94+
'MOD10000': 'mod_6000_15000tl3_xh',
95+
'MOD12000': 'mod_6000_15000tl3_xh',
96+
'MOD15000': 'mod_6000_15000tl3_xh',
97+
98+
# MIX series
99+
'MIX': 'mix_series',
100+
101+
# SPA series
102+
'SPA': 'spa_series',
103+
104+
# WIT series
105+
'WIT': 'wit_tl3_series',
106+
}
107+
108+
# Try to find a match
109+
for pattern, profile_key in patterns.items():
110+
if pattern in model_upper:
111+
_LOGGER.info(f"Matched model '{model_name}' to profile '{profile_key}'")
112+
return profile_key
113+
114+
_LOGGER.warning(f"No profile match found for model name: {model_name}")
115+
return None
116+
117+
118+
async def async_read_serial_number(
119+
hass: HomeAssistant,
120+
client: GrowattModbus,
121+
device_id: int = 1
122+
) -> Optional[str]:
123+
"""
124+
Read inverter serial number from holding registers.
125+
126+
Args:
127+
hass: HomeAssistant instance
128+
client: GrowattModbus client
129+
device_id: Modbus device ID (default 1)
130+
131+
Returns:
132+
Serial number string or None
133+
"""
134+
try:
135+
# Read 10 registers starting at address 23
136+
result = await hass.async_add_executor_job(
137+
client.client.read_holding_registers,
138+
23, 10
139+
)
140+
141+
if result.isError():
142+
_LOGGER.debug(f"Error reading serial number: {result}")
143+
return None
144+
145+
# Convert registers to string
146+
serial_bytes = []
147+
for register in result.registers:
148+
high_byte = (register >> 8) & 0xFF
149+
low_byte = register & 0xFF
150+
serial_bytes.extend([high_byte, low_byte])
151+
152+
# Convert bytes to string and strip null characters
153+
serial_number = bytes(serial_bytes).decode('ascii', errors='ignore').strip('\x00').strip()
154+
155+
if serial_number:
156+
_LOGGER.info(f"Read serial number: {serial_number}")
157+
return serial_number
158+
159+
return None
160+
161+
except Exception as e:
162+
_LOGGER.debug(f"Exception reading serial number: {str(e)}")
163+
return None
164+
165+
166+
async def async_read_model_name(
167+
hass: HomeAssistant,
168+
client: GrowattModbus,
169+
device_id: int = 1
170+
) -> Optional[str]:
171+
"""
172+
Read inverter model name from holding registers.
173+
174+
Args:
175+
hass: HomeAssistant instance
176+
client: GrowattModbus client
177+
device_id: Modbus device ID (default 1)
178+
179+
Returns:
180+
Model name string or None
181+
"""
182+
try:
183+
# Read 5 registers starting at address 0
184+
result = await hass.async_add_executor_job(
185+
client.client.read_holding_registers,
186+
0, 5
187+
)
188+
189+
if result.isError():
190+
_LOGGER.debug(f"Error reading model name: {result}")
191+
return None
192+
193+
# Convert registers to string
194+
model_bytes = []
195+
for register in result.registers:
196+
high_byte = (register >> 8) & 0xFF
197+
low_byte = register & 0xFF
198+
model_bytes.extend([high_byte, low_byte])
199+
200+
# Convert bytes to string and strip null characters
201+
model_name = bytes(model_bytes).decode('ascii', errors='ignore').strip('\x00').strip()
202+
203+
if model_name:
204+
_LOGGER.info(f"Read model name: {model_name}")
205+
return model_name
206+
207+
return None
208+
209+
except Exception as e:
210+
_LOGGER.debug(f"Exception reading model name: {str(e)}")
211+
return None
212+
213+
214+
async def async_detect_inverter_series(
215+
hass: HomeAssistant,
216+
client: GrowattModbus,
217+
device_id: int = 1
218+
) -> Optional[str]:
219+
"""
220+
Detect inverter series by probing different register ranges.
221+
222+
Args:
223+
hass: HomeAssistant instance
224+
client: GrowattModbus client
225+
device_id: Modbus device ID
226+
227+
Returns:
228+
Profile key or None
229+
"""
230+
try:
231+
# Test for battery registers at 3169 (SPH/TL-XH/MOD specific)
232+
result = await hass.async_add_executor_job(
233+
client.client.read_input_registers,
234+
3169, 1,
235+
)
236+
if not result.isError() and result.registers[0] > 0:
237+
_LOGGER.info("Detected battery voltage register - hybrid inverter")
238+
239+
# Check for 3-phase at register 42 (MOD uses R/S/T phases)
240+
phase_test = await hass.async_add_executor_job(
241+
client.client.read_input_registers,
242+
42, 1
243+
)
244+
if not phase_test.isError():
245+
_LOGGER.info("Detected 3-phase hybrid - MOD series")
246+
return 'mod_6000_15000tl3_xh'
247+
else:
248+
_LOGGER.info("Detected single-phase hybrid - SPH/TL-XH series")
249+
return 'sph_3000_10000' # Default to SPH
250+
251+
# Test for 3-phase at register 38 (MID/MAC/MAX)
252+
result = await hass.async_add_executor_job(
253+
client.client.read_input_registers,
254+
38, 1
255+
)
256+
if not result.isError():
257+
# Check register 42 for second phase
258+
phase2 = await hass.async_add_executor_job(
259+
client.client.read_input_registers,
260+
42, 1
261+
)
262+
if not phase2.isError():
263+
_LOGGER.info("Detected 3-phase grid-tied inverter - MID/MAX series")
264+
return 'mid_15000_25000tl3_x' # Default to MID
265+
266+
# Test for PV3 at register 11 (MIN 7-10k has 3 strings)
267+
result = await hass.async_add_executor_job(
268+
client.client.read_input_registers,
269+
11, 1
270+
)
271+
if not result.isError() and result.registers[0] > 0:
272+
_LOGGER.info("Detected 3 PV strings - MIN 7000-10000TL-X")
273+
return 'min_7000_10000_tl_x'
274+
275+
# Default to MIN 3-6k if nothing else detected
276+
_LOGGER.info("Detected single-phase with 2 PV strings - MIN 3000-6000TL-X")
277+
return 'min_3000_6000_tl_x'
278+
279+
except Exception as e:
280+
_LOGGER.error(f"Exception detecting inverter series: {str(e)}")
281+
return None
282+
283+
284+
async def async_determine_inverter_type(
285+
hass: HomeAssistant,
286+
client: GrowattModbus,
287+
device_id: int = 1
288+
) -> Tuple[Optional[str], Optional[dict]]:
289+
"""
290+
Automatically determine the inverter type and return appropriate profile.
291+
292+
Process:
293+
1. Read model name from holding registers
294+
2. Attempt to match model name to known profiles
295+
3. If no match, detect series by probing registers
296+
4. Return the appropriate profile
297+
298+
Args:
299+
hass: HomeAssistant instance
300+
client: GrowattModbus client
301+
device_id: Modbus device ID (default 1)
302+
303+
Returns:
304+
Tuple of (profile_key, profile_dict) or (None, None) if detection fails
305+
"""
306+
_LOGGER.info("Starting automatic inverter type detection")
307+
308+
# Step 1: Try to read model name
309+
model_name = await async_read_model_name(hass, client, device_id)
310+
311+
if model_name:
312+
# Step 2: Try to match model name to profile
313+
profile_key = detect_profile_from_model_name(model_name)
314+
315+
if profile_key:
316+
profile = get_profile(profile_key)
317+
if profile:
318+
_LOGGER.info(f"✓ Auto-detected from model name: {profile['name']}")
319+
return profile_key, profile
320+
321+
# Step 3: Model name didn't work, try series detection
322+
_LOGGER.info("Model name detection failed, trying register-based detection...")
323+
profile_key = await async_detect_inverter_series(hass, client, device_id)
324+
325+
if profile_key:
326+
profile = get_profile(profile_key)
327+
if profile:
328+
_LOGGER.warning(
329+
f"⚠ Auto-detected by probing registers: {profile['name']}. "
330+
"Consider manually verifying the exact model for best accuracy."
331+
)
332+
return profile_key, profile
333+
334+
# Step 4: Everything failed
335+
_LOGGER.error("❌ Could not auto-detect inverter type")
336+
return None, None

0 commit comments

Comments
 (0)