Skip to content

Commit 93c2a2a

Browse files
0xAHAclaude
andcommitted
fix: SPH AC charge energy reads registers 112-115 as a pair (#390)
Protocol V1.39 gives input 112-115 two meanings selected by device class: warn and fault codes on MAX-class string inverters, AC charge energy on Storage Power models. SPH is Storage Power. The SPH profiles read BOTH interpretations at once - 112 as warning_code and 115 alone as ac_charge_energy_total - which cannot both be right on one device. And reading 115 without its high word at 114 capped the lifetime total at 6553.5 kWh. A reporter's scan: 114=1, 115=5462 -> (1<<16)|5462 = 70998 -> 7099.8 kWh. The old mapping would have said 546.2. His entity actually showed 13820.7, because the decode path copied charge_energy_total into ac_charge_energy_total for every profile despite the comment saying 'SPF uses...'. On a grid-tied hybrid those are different quantities: 1058/1059 is total battery charge (PV + grid), AC charge is grid only. Now gated to off-grid profiles, where every charge really is an AC charge. Also removes ac_discharge_energy_total from the shared BATTERY_SENSORS group. V1.39 has no AC-discharge counter anywhere - only the off-grid protocol defines one - so 21 grid-tied profiles exposed a sensor with no register behind it. It read 0.0 every poll, and _protect_energy_totals latches any non-zero value and restores it whenever the reading is 0, so one garbage frame became permanent: 21,069,824 kWh on a 12 kWh battery, unclearable. Moved to the SPF and SPE groups, which map registers 66/67. The existing blanket stale-entity rule removes it on upgrade - no per-removal block needed. Scope is SPH only. MIN/MID/MOD/MIC/TL3-S/TL-XH also map 112 as warning_code; for the MAX-class ones that is correct, and for the XH hybrids there is no evidence either way. Verified red without the fix (22 failures). NOT RELEASED - manifest still at 1.7.5 pending @Vict20 confirming 7099.8 against his portal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 64434f9 commit 93c2a2a

5 files changed

Lines changed: 102 additions & 22 deletions

File tree

custom_components/growatt_modbus/device_profiles.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,18 @@
7373
"priority_mode",
7474
# WIT: Battery SOH and BMS voltage
7575
"battery_soh", "battery_voltage_bms",
76-
# WIT/SPF/MOD: AC charge/discharge energy
77-
"ac_charge_energy_today", "ac_charge_energy_total", "ac_discharge_energy_total",
76+
# AC charge energy — SPH reads these from registers 112-115, SPF/SPE from their own
77+
# off-grid range.
78+
"ac_charge_energy_today", "ac_charge_energy_total",
79+
# NOT here: ac_discharge_energy_total. Protocol V1.39 has no AC-discharge counter at
80+
# all — there is an EACharge_Total but no EADischarge anywhere in the input table. Only
81+
# the off-grid protocol defines one (registers 66/67), so it belongs to SPF and SPE and
82+
# is listed in their own groups below.
83+
#
84+
# While it sat here, 21 grid-tied profiles created the sensor with nothing behind it.
85+
# It read 0.0 every poll, and _protect_energy_totals latched a single garbage frame
86+
# (3215 << 16) and restored it forever after — one reporter saw 21,069,824 kWh on a
87+
# 12 kWh battery, a value the integration could never clear on its own (#390).
7888
}
7989

8090
_EXTRA_BATTERY_FIELDS = (
@@ -166,8 +176,10 @@
166176
# Generator sensors (SPF with generator input)
167177
"generator_power", "generator_voltage",
168178
"generator_discharge_today", "generator_discharge_total",
169-
# AC charge/discharge energy (from grid/generator)
170-
"ac_charge_energy_today", "ac_discharge_energy_today",
179+
# AC charge/discharge energy (from grid/generator). ac_discharge_energy_total is
180+
# here rather than in BATTERY_SENSORS because only the off-grid protocol defines it
181+
# (registers 66/67) — see the note there (#390).
182+
"ac_charge_energy_today", "ac_discharge_energy_today", "ac_discharge_energy_total",
171183
# Operational discharge energy
172184
"op_discharge_energy_today", "op_discharge_energy_total",
173185
# Fan speeds
@@ -184,6 +196,7 @@
184196
"grid_voltage", # reg 20
185197
"grid_frequency", # reg 21
186198
"ac_discharge_energy_today", # regs 64/65 (= grid import today on SPE)
199+
"ac_discharge_energy_total", # regs 66/67 (= grid import total on SPE)
187200
"mppt_fan_speed", # reg 81
188201
"inverter_fan_speed", # reg 82
189202
"dcdc_temp", # reg 26

custom_components/growatt_modbus/growatt_modbus.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3496,14 +3496,18 @@ def _read_battery_data(self, data: GrowattData) -> None:
34963496
value = self._get_register_value_with_fallback('battery_charge_today_low')
34973497
if value is not None:
34983498
data.charge_energy_today = value
3499-
# SPF uses charge_energy_* registers for AC charging - populate both fields
3500-
data.ac_charge_energy_today = data.charge_energy_today
3499+
# On an off-grid inverter every charge is an AC charge, so the same counter
3500+
# serves both. On a grid-tied hybrid it does not: this register is total
3501+
# battery charge (PV + grid), while AC charge is grid-only and lives at
3502+
# 112-115. Copying it here made AC Charge Energy report battery charge —
3503+
# 13820.7 kWh against a true 7099.8 on the reporter's SPH (#390).
3504+
if self.register_map.get('offgrid_protocol', False):
3505+
data.ac_charge_energy_today = data.charge_energy_today
35013506
# Log which register was used
35023507
addr = self._find_register_by_name('charge_energy_today_low') or self._find_register_by_name('battery_charge_today_low')
3503-
logger.debug(f"Charge energy today from register {addr}: {data.charge_energy_today} kWh (also populating ac_charge_energy_today for SPF)")
3508+
logger.debug(f"Charge energy today from register {addr}: {data.charge_energy_today} kWh")
35043509
else:
35053510
data.charge_energy_today = 0.0
3506-
data.ac_charge_energy_today = 0.0
35073511

35083512
# Discharge energy today
35093513
# Try both naming conventions with smart fallback: "discharge_energy_today" and "battery_discharge_today"
@@ -3525,14 +3529,14 @@ def _read_battery_data(self, data: GrowattData) -> None:
35253529
value = self._get_register_value_with_fallback('battery_charge_total_low')
35263530
if value is not None:
35273531
data.charge_energy_total = value
3528-
# SPF uses charge_energy_* registers for AC charging - populate both fields
3529-
data.ac_charge_energy_total = data.charge_energy_total
3532+
# Off-grid only — see the matching note on charge_energy_today above.
3533+
if self.register_map.get('offgrid_protocol', False):
3534+
data.ac_charge_energy_total = data.charge_energy_total
35303535
# Log which register was used
35313536
addr = self._find_register_by_name('charge_energy_total_low') or self._find_register_by_name('battery_charge_total_low')
3532-
logger.debug(f"Charge energy total from register {addr}: {data.charge_energy_total} kWh (also populating ac_charge_energy_total for SPF)")
3537+
logger.debug(f"Charge energy total from register {addr}: {data.charge_energy_total} kWh")
35333538
else:
35343539
data.charge_energy_total = 0.0
3535-
data.ac_charge_energy_total = 0.0
35363540

35373541
# Discharge energy total
35383542
# Try both naming conventions with smart fallback: "discharge_energy_total" and "battery_discharge_total"

custom_components/growatt_modbus/profiles/sph.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,42 @@
44
VPP_V201_BATTERY2, VPP_V201_HOLDING_1P,
55
)
66

7+
# AC charge energy — input registers 112-115, Storage Power interpretation.
8+
#
9+
# Protocol V1.39 gives this block two meanings, selected by device class. The rightmost
10+
# column of the spec table is the selector:
11+
#
12+
# | Reg | MAX-class string inverter | Storage Power (SPH, SPA) |
13+
# |-----|---------------------------|--------------------------|
14+
# | 112 | Warn Maincode | EACharge_Today_H |
15+
# | 113 | real Power Percent | EACharge_Today_L |
16+
# | 114 | inv start delay time | EACharge_Total_H |
17+
# | 115 | bINVAllFaultCode | EACharge_Total_L |
18+
#
19+
# SPH is a Storage Power model, so the energy reading applies to every profile in this
20+
# file. Until this was corrected these profiles mapped 112 as `warning_code` (the MAX
21+
# meaning) *and* 115 alone as `ac_charge_energy_total` (the Storage meaning) — two
22+
# interpretations of one block, which cannot both be right on one device (#390).
23+
#
24+
# Reading 115 without its high word at 114 also capped the lifetime total at 6553.5 kWh.
25+
# A reporter's scan read 114=1, 115=5462 → (1<<16)|5462 = 70998 → 7099.8 kWh, where 115
26+
# alone would have said 546.2.
27+
#
28+
# NOT applied to MIN/MID/MOD/MIC/TL3-S/TL-XH. Those also map 112 as warning_code, and for
29+
# the MAX-class ones that is correct; for the XH hybrids there is no evidence either way
30+
# and guessing which side of the column they fall on is how wrong-but-plausible values
31+
# get shipped.
32+
STORAGE_AC_CHARGE_ENERGY = {
33+
112: {'name': 'ac_charge_energy_today_high', 'scale': 1, 'unit': '', 'pair': 113,
34+
'desc': 'AC charge energy today HIGH (EACharge_Today_H)'},
35+
113: {'name': 'ac_charge_energy_today_low', 'scale': 1, 'unit': '', 'pair': 112,
36+
'combined_scale': 0.1, 'combined_unit': 'kWh'},
37+
114: {'name': 'ac_charge_energy_total_high', 'scale': 1, 'unit': '', 'pair': 115,
38+
'desc': 'AC charge energy total HIGH (EACharge_Total_H)'},
39+
115: {'name': 'ac_charge_energy_total_low', 'scale': 1, 'unit': '', 'pair': 114,
40+
'combined_scale': 0.1, 'combined_unit': 'kWh'},
41+
}
42+
743
# SPH 3000-6000 (Single-phase hybrid with battery)
844
SPH_3000_6000 = {
945
'name': 'SPH Series 3-6kW',
@@ -146,10 +182,9 @@
146182
# Diagnostics
147183
104: {'name': 'derating_mode', 'scale': 1, 'unit': ''},
148184
105: {'name': 'fault_code', 'scale': 1, 'unit': ''},
149-
112: {'name': 'warning_code', 'scale': 1, 'unit': ''},
150185

151-
# Battery AC Charge Energy (SPH 3-6kW V201 with newer firmware)
152-
115: {'name': 'ac_charge_energy_total', 'scale': 0.1, 'unit': 'kWh', 'desc': 'Total energy charged from AC/grid to battery'},
186+
# AC charge energy today/total — see STORAGE_AC_CHARGE_ENERGY above.
187+
**STORAGE_AC_CHARGE_ENERGY,
153188
},
154189
'holding_registers': {
155190
# Basic Control
@@ -445,7 +480,9 @@
445480
# Diagnostics
446481
104: {'name': 'derating_mode', 'scale': 1, 'unit': ''},
447482
105: {'name': 'fault_code', 'scale': 1, 'unit': ''},
448-
112: {'name': 'warning_code', 'scale': 1, 'unit': ''},
483+
484+
# AC charge energy today/total — see STORAGE_AC_CHARGE_ENERGY above.
485+
**STORAGE_AC_CHARGE_ENERGY,
449486
},
450487
'holding_registers': {
451488
# Basic Control
@@ -1007,12 +1044,10 @@
10071044
31217: {'name': 'battery_soc_vpp', 'scale': 1, 'unit': '%', 'maps_to': 'battery_soc'},
10081045
31218: {'name': 'battery_soh', 'scale': 1, 'unit': '%', 'desc': 'Battery state of health'},
10091046
# Note: Registers 31220-31221 appear to contain incorrect data when paired as 32-bit
1010-
# AC charge energy total is available in register 115 (legacy range) instead
1047+
# AC charge energy total is available in registers 114/115 (legacy range) instead,
1048+
# inherited from SPH_7000_10000 above.
10111049
31222: {'name': 'battery_temp_vpp', 'scale': 0.1, 'unit': '°C', 'maps_to': 'battery_temp', 'signed': True},
10121050

1013-
# Battery AC Charge Energy (SPH 7-10kW V201 with newer firmware)
1014-
115: {'name': 'ac_charge_energy_total', 'scale': 0.1, 'unit': 'kWh', 'desc': 'Total energy charged from AC/grid to battery'},
1015-
10161051
# Battery Cluster 2 State
10171052
**VPP_V201_BATTERY2,
10181053
},

custom_components/growatt_modbus/profiles/sph_tl3.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
VPP_V201_STATUS, VPP_V201_PV2_INPUT, VPP_V201_PV2_TOTAL,
77
VPP_V201_TEMPERATURE_1P, VPP_V201_BATTERY2, VPP_V201_HOLDING_1P,
88
)
9+
from .sph import STORAGE_AC_CHARGE_ENERGY
910

1011
SPH_TL3_3000_10000 = {
1112
'name': 'SPH-TL3 Series 3-10kW',
@@ -107,8 +108,12 @@
107108

108109
# Status
109110
105: {'name': 'fault_code', 'scale': 1, 'unit': ''},
110-
112: {'name': 'warning_code', 'scale': 1, 'unit': ''},
111-
111+
112+
# AC charge energy today/total. SPH-TL3 is a Storage Power model, so registers
113+
# 112-115 carry energy rather than the warn/fault codes a MAX-class inverter puts
114+
# there — see STORAGE_AC_CHARGE_ENERGY in sph.py for the protocol detail (#390).
115+
**STORAGE_AC_CHARGE_ENERGY,
116+
112117
# ============================================================================
113118
# STORAGE RANGE 1000-1124: Battery and Power Flow
114119
# ============================================================================

docs/controls/entity-reference.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,29 @@ firmware.** They are mapped because the protocol defines them, but at least one
7070
(RAAA191904/ZCBA-0004) accepts the write and immediately reverts the register. If yours does
7171
that, disable the entities - other firmware may well support them.
7272

73+
### Energy sensors worth distinguishing
74+
75+
Three lifetime counters look similar and mean different things:
76+
77+
| Sensor | Registers | Measures |
78+
|---|---|---|
79+
| Battery Charge Total | 1058/1059 | **All** energy into the battery — PV and grid |
80+
| AC Charge Energy Total | 114/115 | Grid→battery only |
81+
| Battery Discharge Total | 1054/1055 | All energy out of the battery |
82+
83+
So AC Charge Energy Total is normally *lower* than Battery Charge Total; the difference is
84+
what came from your panels. On one reporter's system the figures were 7,099.8 kWh against
85+
13,820.8 kWh.
86+
87+
Registers 112–115 carry AC charge energy on SPH because it is a "Storage Power" model. The
88+
same addresses hold warning and fault codes on MAX-class string inverters — the protocol
89+
lists both meanings side by side, selected by device class.
90+
91+
> **There is no AC Discharge Energy Total on SPH.** Protocol V1.39 defines no AC-discharge
92+
> counter at all; only off-grid models (SPF, SPE) have one. If you had this entity before
93+
> v1.7.6 it was never populated by a register, and any value it showed was a stale artefact.
94+
> It is removed automatically on upgrade. Use **Battery Discharge Total** instead.
95+
7396
**Notes:**
7497
- All SPH variants share the same 1000+ register range — controls apply across 3–6kW, 7–10kW, and HU variants automatically.
7598
- Time periods use HHMM format: `530` = 05:30, `2300` = 23:00.

0 commit comments

Comments
 (0)