Skip to content

Commit 1b4bee5

Browse files
0xAHAclaude
andcommitted
fix: SPH time slots are labelled as the inverter numbers them (#386)
Protocol V1.39 and the Growatt app agree on what these registers are. The control names did not: 1080-1088 Grid First 1/2/3 displayed as "Grid First Period 7/8/9" 1100-1108 Bat First 1/2/3 displayed as "AC Charge Time Period N" The reporter had to work the mapping out by experiment - setting a slot in Home Assistant and watching which one moved in the app - and then reasonably concluded the 7/8/9 entities were MOD leftovers wrongly carried into the SPH profile. They are not. They are Grid First 1-3 under a wrong number, and he withdrew that theory himself after finding they work. The second block was true but unhelpful. "AC Charge Time Period" describes what Battery First does rather than naming the group the app shows, so there was no way to tell which of the app's schedules an entity belonged to. Display names only. The control names stay as they are, because entity IDs derive from them and renaming would break every automation referencing one - too high a price for a labelling error. Same remedy as #362. select.py now honours the 'label' key that time.py already read. Start and end are time entities while enable is a select, so a label read by one platform and not the other would have left every third entity still mislabelled. Not changed, per the maintainer: slots 4-6 at 1017-1034. This reporter's firmware accepts writes there and immediately reverts them, but the registers are properly documented and other firmware may implement them - and a user who does not have them can disable the entities. Recorded in the entity reference with the firmware version that rejects them. Also documented: Battery First and Grid First are two independent schedules that run concurrently without conflict, confirmed by running one of each for several hours. That was not written down anywhere. 1053 tests pass. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 88be59a commit 1b4bee5

5 files changed

Lines changed: 156 additions & 29 deletions

File tree

RELEASENOTES.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@
44

55
---
66

7+
## Unreleased
8+
9+
Merged to `main`, not yet in any release.
10+
11+
- **SPH time-slot entities are now named after the slot the inverter actually uses.** The
12+
three Grid First slots displayed as "Grid First Period 7/8/9" but are slots **1, 2 and 3**
13+
in the Growatt app and in the protocol, and the Battery First slots displayed as "AC Charge
14+
Time Period" with no indication of which app group they belonged to. Entity IDs are
15+
unchanged, so automations keep working - only the displayed names are corrected. Reported
16+
by @Vict20. (#386)
17+
18+
---
19+
720
## v1.6.6 (pre-release)
821

922
Issues: #361, #384, #385

custom_components/growatt_modbus/const.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,16 @@
595595
'desc': 'System enable control (SPH HU models)'
596596
},
597597

598+
# Battery First time slots 1-3, registers 1100-1108 (#386).
599+
#
600+
# Protocol V1.39 calls these "Bat First Start/Stop Time 1..3" and the Growatt app shows
601+
# them under Battery First, so that is what the labels say. They were previously
602+
# displayed as "AC Charge Time Period N", which is true in effect - Battery First is the
603+
# charge schedule - but gave no clue which of the app's groups they correspond to.
604+
#
605+
# The control names keep their existing form. Renaming them would change entity IDs and
606+
# break automations, which is too high a price for a labelling error; only the display
607+
# name is corrected. Same remedy as #362.
598608
# AC Charge Time Period Controls (hex-packed: hours*256 + minutes, e.g. 06:00 = 0x0600 = 1536)
599609
# These are SPH AC-charge scheduling slots (registers 1100-1108), distinct from
600610
# the Battery First / Grid First extended slots at 1017-1088.
@@ -603,15 +613,15 @@
603613
'scale': 1,
604614
'valid_range': (0, 5947),
605615
'unit': '',
606-
'label': 'AC Charge Time Period 1 Start',
616+
'label': 'Battery First Period 1 Start',
607617
'desc': 'AC charge period 1 start time (hex-packed: hours*256+minutes, e.g. 06:00 = 0x0600 = 1536)'
608618
},
609619
'time_period_1_end': {
610620
'register': 1101,
611621
'scale': 1,
612622
'valid_range': (0, 5947),
613623
'unit': '',
614-
'label': 'AC Charge Time Period 1 End',
624+
'label': 'Battery First Period 1 End',
615625
'desc': 'AC charge period 1 end time (hex-packed: hours*256+minutes, e.g. 22:00 = 0x1600 = 5632)'
616626
},
617627
'time_period_1_enable': {
@@ -622,23 +632,23 @@
622632
0: 'Disabled',
623633
1: 'Enabled'
624634
},
625-
'label': 'AC Charge Time Period 1 Enable',
635+
'label': 'Battery First Period 1 Enable',
626636
'desc': 'Enable AC charge time period 1'
627637
},
628638
'time_period_2_start': {
629639
'register': 1103,
630640
'scale': 1,
631641
'valid_range': (0, 5947),
632642
'unit': '',
633-
'label': 'AC Charge Time Period 2 Start',
643+
'label': 'Battery First Period 2 Start',
634644
'desc': 'AC charge period 2 start time (hex-packed: hours*256+minutes)'
635645
},
636646
'time_period_2_end': {
637647
'register': 1104,
638648
'scale': 1,
639649
'valid_range': (0, 5947),
640650
'unit': '',
641-
'label': 'AC Charge Time Period 2 End',
651+
'label': 'Battery First Period 2 End',
642652
'desc': 'AC charge period 2 end time (hex-packed: hours*256+minutes)'
643653
},
644654
'time_period_2_enable': {
@@ -649,23 +659,23 @@
649659
0: 'Disabled',
650660
1: 'Enabled'
651661
},
652-
'label': 'AC Charge Time Period 2 Enable',
662+
'label': 'Battery First Period 2 Enable',
653663
'desc': 'Enable AC charge time period 2'
654664
},
655665
'time_period_3_start': {
656666
'register': 1106,
657667
'scale': 1,
658668
'valid_range': (0, 5947),
659669
'unit': '',
660-
'label': 'AC Charge Time Period 3 Start',
670+
'label': 'Battery First Period 3 Start',
661671
'desc': 'AC charge period 3 start time (hex-packed: hours*256+minutes)'
662672
},
663673
'time_period_3_end': {
664674
'register': 1107,
665675
'scale': 1,
666676
'valid_range': (0, 5947),
667677
'unit': '',
668-
'label': 'AC Charge Time Period 3 End',
678+
'label': 'Battery First Period 3 End',
669679
'desc': 'AC charge period 3 end time (hex-packed: hours*256+minutes)'
670680
},
671681
'time_period_3_enable': {
@@ -676,7 +686,7 @@
676686
0: 'Disabled',
677687
1: 'Enabled'
678688
},
679-
'desc': 'Enable time period 3'
689+
'label': 'Battery First Period 3 Enable', 'desc': 'Enable time period 3'
680690
},
681691

682692
# SPH GEN3 Battery First extended time slots 4-6 (registers 1017-1025)
@@ -702,15 +712,15 @@
702712
'grid_first_time_period_6_enable': {'register': 1034, 'scale': 1, 'valid_range': (0, 1), 'options': {0: 'Disabled', 1: 'Enabled'}, 'desc': 'Enable Grid First period 6'},
703713

704714
# SPH GEN3 Grid First extended time slots 7-9 (registers 1080-1088)
705-
'grid_first_time_period_7_start': {'register': 1080, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'desc': 'Grid First period 7 start (hex-packed: hours*256+minutes)'},
706-
'grid_first_time_period_7_end': {'register': 1081, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'desc': 'Grid First period 7 end (hex-packed: hours*256+minutes)'},
707-
'grid_first_time_period_7_enable': {'register': 1082, 'scale': 1, 'valid_range': (0, 1), 'options': {0: 'Disabled', 1: 'Enabled'}, 'desc': 'Enable Grid First period 7'},
708-
'grid_first_time_period_8_start': {'register': 1083, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'desc': 'Grid First period 8 start (hex-packed: hours*256+minutes)'},
709-
'grid_first_time_period_8_end': {'register': 1084, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'desc': 'Grid First period 8 end (hex-packed: hours*256+minutes)'},
710-
'grid_first_time_period_8_enable': {'register': 1085, 'scale': 1, 'valid_range': (0, 1), 'options': {0: 'Disabled', 1: 'Enabled'}, 'desc': 'Enable Grid First period 8'},
711-
'grid_first_time_period_9_start': {'register': 1086, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'desc': 'Grid First period 9 start (hex-packed: hours*256+minutes)'},
712-
'grid_first_time_period_9_end': {'register': 1087, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'desc': 'Grid First period 9 end (hex-packed: hours*256+minutes)'},
713-
'grid_first_time_period_9_enable': {'register': 1088, 'scale': 1, 'valid_range': (0, 1), 'options': {0: 'Disabled', 1: 'Enabled'}, 'desc': 'Enable Grid First period 9'},
715+
'grid_first_time_period_7_start': {'register': 1080, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'label': 'Grid First Period 1 Start', 'desc': 'Grid First period 1 start (hex-packed: hours*256+minutes)'},
716+
'grid_first_time_period_7_end': {'register': 1081, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'label': 'Grid First Period 1 End', 'desc': 'Grid First period 1 end (hex-packed: hours*256+minutes)'},
717+
'grid_first_time_period_7_enable': {'register': 1082, 'scale': 1, 'valid_range': (0, 1), 'options': {0: 'Disabled', 1: 'Enabled'}, 'label': 'Grid First Period 1 Enable', 'desc': 'Enable Grid First period 7'},
718+
'grid_first_time_period_8_start': {'register': 1083, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'label': 'Grid First Period 2 Start', 'desc': 'Grid First period 2 start (hex-packed: hours*256+minutes)'},
719+
'grid_first_time_period_8_end': {'register': 1084, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'label': 'Grid First Period 2 End', 'desc': 'Grid First period 2 end (hex-packed: hours*256+minutes)'},
720+
'grid_first_time_period_8_enable': {'register': 1085, 'scale': 1, 'valid_range': (0, 1), 'options': {0: 'Disabled', 1: 'Enabled'}, 'label': 'Grid First Period 2 Enable', 'desc': 'Enable Grid First period 8'},
721+
'grid_first_time_period_9_start': {'register': 1086, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'label': 'Grid First Period 3 Start', 'desc': 'Grid First period 3 start (hex-packed: hours*256+minutes)'},
722+
'grid_first_time_period_9_end': {'register': 1087, 'scale': 1, 'valid_range': (0, 5947), 'unit': '', 'label': 'Grid First Period 3 End', 'desc': 'Grid First period 3 end (hex-packed: hours*256+minutes)'},
723+
'grid_first_time_period_9_enable': {'register': 1088, 'scale': 1, 'valid_range': (0, 1), 'options': {0: 'Disabled', 1: 'Enabled'}, 'label': 'Grid First Period 3 Enable', 'desc': 'Enable Grid First period 9'},
714724

715725
# MIN TL-X / TL-XH / MIC: fallback output power cap when export limitation control fails
716726
'export_limit_failed_power_rate': {

custom_components/growatt_modbus/select.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,17 @@ def __init__(
240240
self._control_name = control_name
241241
self._control_config = control_config
242242

243-
# Generate friendly name (e.g., "output_config" -> "Output Config")
244-
friendly_name = control_name.replace('_', ' ').title()
243+
# Generate friendly name (e.g., "output_config" -> "Output Config"), unless the
244+
# control carries an explicit label.
245+
#
246+
# The label exists because a few control names do not describe what the register
247+
# actually is. The SPH time-slot blocks are the clearest case (#386): the registers
248+
# at 1080-1088 are named ..._7/8/9 here but Protocol V1.39 calls them Grid First
249+
# 1/2/3, which is also what the Growatt app shows. A reporter had to work that out by
250+
# experiment. The names cannot be changed without changing entity IDs and breaking
251+
# everyone's automations, so the display name is corrected instead - the same remedy
252+
# used for two SPH controls in #362.
253+
friendly_name = control_config.get('label') or control_name.replace('_', ' ').title()
245254
self._attr_name = friendly_name
246255

247256
# Set icon based on control type

docs/controls/entity-reference.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,29 @@ All writes use **read-back verification** — after writing, the integration rea
4646
| Charge Power Rate | Number | 1090 | 0–100 % | Maximum battery charge power rate |
4747
| Charge Stop SOC | Number | 1091 | 0–100 % | SOC level at which charging stops |
4848
| System Enable | Select | 1008 | Disabled (0), Enabled (1) | System enable control (HU models only) |
49-
| Time Period 1 Start | Number | 1100 | 0–2359 (HHMM) | Charge/discharge period 1 start time |
50-
| Time Period 1 End | Number | 1101 | 0–2359 (HHMM) | Charge/discharge period 1 end time |
51-
| Time Period 1 Enable | Select | 1102 | Disabled (0), Enabled (1) | Enable/disable period 1 |
52-
| Time Period 2 Start | Number | 1103 | 0–2359 (HHMM) | Charge/discharge period 2 start time |
53-
| Time Period 2 End | Number | 1104 | 0–2359 (HHMM) | Charge/discharge period 2 end time |
54-
| Time Period 2 Enable | Select | 1105 | Disabled (0), Enabled (1) | Enable/disable period 2 |
55-
| Time Period 3 Start | Number | 1106 | 0–2359 (HHMM) | Charge/discharge period 3 start time |
56-
| Time Period 3 End | Number | 1107 | 0–2359 (HHMM) | Charge/discharge period 3 end time |
57-
| Time Period 3 Enable | Select | 1108 | Disabled (0), Enabled (1) | Enable/disable period 3 |
49+
| Battery First Period 1 Start | Time | 1100 | HH:MM | Charge schedule slot 1 start |
50+
| Battery First Period 1 End | Time | 1101 | HH:MM | Charge schedule slot 1 end |
51+
| Battery First Period 1 Enable | Select | 1102 | Disabled (0), Enabled (1) | Enable charge slot 1 |
52+
| Battery First Period 2/3 | Time / Select | 1103-1108 | as above | Charge schedule slots 2 and 3 |
53+
| Grid First Period 1 Start | Time | 1080 | HH:MM | Discharge/export schedule slot 1 start |
54+
| Grid First Period 1 End | Time | 1081 | HH:MM | Discharge/export schedule slot 1 end |
55+
| Grid First Period 1 Enable | Select | 1082 | Disabled (0), Enabled (1) | Enable discharge slot 1 |
56+
| Grid First Period 2/3 | Time / Select | 1083-1088 | as above | Discharge schedule slots 2 and 3 |
57+
| Battery First / Grid First 4-6 | Time / Select | 1017-1034 | as above | Extra slots - see the note below |
58+
59+
**Two independent schedules.** Battery First (1100-1108) is the charge schedule and Grid
60+
First (1080-1088) is the discharge/export schedule. They run concurrently and do not
61+
conflict - confirmed on an SPH 3600 running one of each simultaneously for several hours
62+
([#386](https://github.com/0xAHA/Growatt_ModbusTCP/issues/386)).
63+
64+
The slot numbers shown match the Growatt app and Protocol V1.39. The underlying entity IDs
65+
use an older numbering (`grid_first_time_period_7/8/9` for Grid First 1-3, `time_period_*`
66+
for Battery First), which is kept so existing automations continue to work.
67+
68+
**Slots 4-6 (registers 1017-1034) are documented but may not be implemented on your
69+
firmware.** They are mapped because the protocol defines them, but at least one SPH 3600
70+
(RAAA191904/ZCBA-0004) accepts the write and immediately reverts the register. If yours does
71+
that, disable the entities - other firmware may well support them.
5872

5973
**Notes:**
6074
- All SPH variants share the same 1000+ register range — controls apply across 3–6kW, 7–10kW, and HU variants automatically.

tests/test_sph_time_slot_labels.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""SPH time-slot entities must be named after the slot the inverter actually uses (#386).
2+
3+
Protocol V1.39 and the Growatt app agree on what these registers are. The control names did
4+
not, and a reporter had to determine the mapping by experiment - setting a slot in Home
5+
Assistant and seeing which one moved in the app.
6+
7+
| Registers | Protocol V1.39 | Control name |
8+
|---|---|---|
9+
| 1080-1088 | Grid First 1 / 2 / 3 | grid_first_time_period_7/8/9 |
10+
| 1100-1108 | Bat First 1 / 2 / 3 | time_period_1/2/3 |
11+
12+
The names are left alone deliberately. Renaming would change entity IDs and break existing
13+
automations, which is too high a price for a labelling error - the same reasoning as #362,
14+
where two SPH controls were relabelled rather than renamed. Only the display name is fixed.
15+
"""
16+
from __future__ import annotations
17+
18+
import importlib
19+
20+
import pytest
21+
22+
_const = importlib.import_module("growatt_under_test.const")
23+
W = _const.WRITABLE_REGISTERS
24+
25+
# register -> what the protocol and the app both call it
26+
EXPECTED = {
27+
1080: "Grid First Period 1 Start", 1081: "Grid First Period 1 End",
28+
1082: "Grid First Period 1 Enable",
29+
1083: "Grid First Period 2 Start", 1084: "Grid First Period 2 End",
30+
1085: "Grid First Period 2 Enable",
31+
1086: "Grid First Period 3 Start", 1087: "Grid First Period 3 End",
32+
1088: "Grid First Period 3 Enable",
33+
1100: "Battery First Period 1 Start", 1101: "Battery First Period 1 End",
34+
1102: "Battery First Period 1 Enable",
35+
1103: "Battery First Period 2 Start", 1104: "Battery First Period 2 End",
36+
1105: "Battery First Period 2 Enable",
37+
1106: "Battery First Period 3 Start", 1107: "Battery First Period 3 End",
38+
1108: "Battery First Period 3 Enable",
39+
}
40+
41+
BY_REGISTER = {cfg["register"]: (name, cfg) for name, cfg in W.items() if "register" in cfg}
42+
43+
44+
@pytest.mark.parametrize("register,label", sorted(EXPECTED.items()))
45+
def test_the_slot_is_labelled_as_the_inverter_numbers_it(register, label):
46+
name, cfg = BY_REGISTER[register]
47+
assert cfg.get("label") == label, (
48+
f"register {register} ({name}) displays as {cfg.get('label') or name!r}, but the "
49+
f"protocol and the Growatt app both call it {label!r}"
50+
)
51+
52+
53+
@pytest.mark.parametrize("register", sorted(EXPECTED))
54+
def test_control_names_are_not_renamed(register):
55+
"""The other half of the decision. Correcting the label must not become a rename -
56+
entity IDs derive from the control name and automations reference them."""
57+
name, _ = BY_REGISTER[register]
58+
if 1080 <= register <= 1088:
59+
assert name.startswith("grid_first_time_period_"), f"{register} was renamed to {name}"
60+
else:
61+
assert name.startswith("time_period_"), f"{register} was renamed to {name}"
62+
63+
64+
def test_both_platforms_honour_the_label():
65+
"""Start/end are time entities and enable is a select. A label read by one and not the
66+
other would leave every third entity still mislabelled."""
67+
from pathlib import Path
68+
base = Path(__file__).parent.parent / "custom_components" / "growatt_modbus"
69+
for platform in ("time.py", "select.py"):
70+
source = (base / platform).read_text(encoding="utf-8")
71+
assert "control_config.get('label')" in source, (
72+
f"{platform} ignores the label, so its entities keep the misleading name"
73+
)
74+
75+
76+
def test_the_4_to_6_slots_are_left_alone():
77+
"""Those registers are documented and correctly named; one firmware rejects writes to
78+
them. Other firmware may not, and a user can disable the entities - so they stay."""
79+
for n in (4, 5, 6):
80+
assert f"grid_first_time_period_{n}_start" in W
81+
assert f"batt_first_time_period_{n}_start" in W

0 commit comments

Comments
 (0)