Skip to content

Commit ebff9db

Browse files
0xAHAclaude
andcommitted
fix: profile access:'RO' now withholds the control (#374)
v1.6.0 added the VPP registers to the MOD profile as 'RO' and created five writable controls anyway - two selects, two numbers, and control_authority arriving 8 seconds later through the deferred path. One of them is a -100..+100% slider on register 30409, which #373 measured climbing toward its setpoint and importing 912 W from the grid with allow_grid_charge off. That is precisely the exposure #373 said would not ship. Two mistakes, both mine, both avoidable from what I already knew. The first: `access` was never read anywhere outside profiles/. The exploration for #371-373 told me so in as many words, and I wrote it into the #371 reply - "profile membership is the only gate number.py and select.py consult, so an RO entry would still create the entity" - and then relied on the flag in #373 regardless. The second: I believed the is_wit gate confined the VPP controls. It returns only INSIDE the WIT branch; non-WIT profiles fall through to the generic loop that creates a control for any WRITABLE_REGISTERS entry whose register is in the profile. The gate confines the WIT-specific classes and nothing else, so adding the registers to MOD was sufficient on its own. `access` now means what everyone assumes. is_read_only_register() in const.py is consulted by both generic loops and by the deferred VPP path in select.py - that last one matters, because gating only the setup loop fixes four of the five and leaves control_authority to arrive seconds later. Of 517 control/profile pairs, six change: the five above, plus SPE register 117, which the profile already documents as "firmware-determined, writes may be rejected". That is the same defect in miniature and removing the control is correct. The stale-control cleanup now treats read-only as stale too. Membership alone would have left the five entities behind as unavailable on every install that already has them, since their registers are still in the profile. On the tests. Two of them passed while this shipped, and both were the wrong shape rather than merely thin: test_mod_vpp_registers_are_read_only asserted the flag was 'RO'. It was. Nothing read it, so the assertion was true and meaningless. test_the_wit_gate_still_confines_... asserted the gate kept its shape. It did. The gate never confined the path that created these entities. Both tested a declaration, or a mechanism believed to enforce it, instead of the outcome. Replaced with tests on what the user gets. The model-based one is explicitly paired with a source-level check, because _controls_created_for() applies the read-only filter itself and would otherwise pass whatever the platforms actually do - a model agreeing with itself, which is how this class of error survives. Verified by stashing only number.py and select.py: the two load-bearing tests fail against the shipped code and pass with the fix. Confirmed from the reporter's diagnostics that the rest of v1.6.0 is sound - the peak-shaving values read 7.5/7.5/50/7.5/100 kW/%/kW/% against his portal, so the #372 mappings and scaling are right on hardware, 30474 reads -20 so the signed handling works, and vpp_control_authority_available is True, which is independent confirmation of the capability #373 reported. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8b9a156 commit ebff9db

8 files changed

Lines changed: 241 additions & 28 deletions

File tree

RELEASENOTES.md

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

55
---
66

7+
## v1.6.1 (pre-release)
8+
9+
Issues: #374
10+
11+
> **Fixes a defect in the v1.6.0 pre-release.** If you installed v1.6.0 on a MOD or MID
12+
> TL3-XH, please take this one — v1.5.5 remains the stable release and was never affected.
13+
14+
- **Five writable VPP controls appeared on MOD TL3-XH in v1.6.0 and should not have.**
15+
Control Authority, Remote Power Control, Remote Duration, Remote Charge/Discharge Power
16+
and VPP AC Charge Enable were created as operable entities, including a −100…+100 %
17+
power slider. v1.6.0 marked those registers read-only in the profile, but nothing read
18+
that flag.
19+
20+
They are removed on upgrade. Nothing was written to them and they were all at their safe
21+
values; the exposure is what is being fixed.
22+
23+
- **A profile marking a register read-only now withholds the control.** Previously `access`
24+
was documentation only. This also removes one pre-existing control — SPE Grid Compliance
25+
Region (register 117), which the profile already described as firmware-determined with
26+
writes rejected.
27+
28+
- The four VPP diagnostic sensors, the peak-shaving sensors and the Grid Charge Stopped SOC
29+
control from v1.6.0 are unaffected and stay.
30+
31+
Reported by @KevlarD-67, with the mechanism traced to the exact line.
32+
33+
---
34+
735
## v1.6.0 (pre-release)
836

937
Issues: #371, #372, #373 — all on MOD/MID TL3-XH

custom_components/growatt_modbus/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
REGISTER_MAPS,
2222
WRITABLE_REGISTERS,
2323
DEVICE_TYPE_INVERTER,
24+
is_read_only_register,
2425
)
2526
from .coordinator import GrowattConfigEntry, GrowattModbusCoordinator
2627
from .device_profiles import get_profile
@@ -466,7 +467,13 @@ def _cleanup_unsupported_vpp_entities() -> None:
466467
# SOC-limit cleanup.
467468
if profile_is_known and holding:
468469
for control_name, control_config in WRITABLE_REGISTERS.items():
469-
if control_config.get("register") in holding:
470+
_reg = control_config.get("register")
471+
# Stale for either reason: the profile no longer maps the register at all, or
472+
# it maps it read-only. The second case is why v1.6.0's five VPP controls on
473+
# MOD would otherwise survive the v1.6.1 fix — their registers are still in
474+
# the profile, just marked RO, so a membership test alone leaves them behind
475+
# as unavailable (#374).
476+
if _reg in holding and not is_read_only_register(holding.get(_reg)):
470477
continue
471478
for _domain in ("number", "select", "time"):
472479
stale_eid = entity_registry.async_get_entity_id(

custom_components/growatt_modbus/const.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,25 @@ def get_entity_category(sensor_key: str) -> str | None:
11071107
}
11081108

11091109

1110+
def is_read_only_register(register_def) -> bool:
1111+
"""True when a profile marks this register read-only.
1112+
1113+
`access` was documentation that nothing read until v1.6.1. v1.6.0 added the VPP
1114+
registers to the MOD profile as 'RO' on the assumption the flag would stop controls
1115+
being created for them, and the generic loops in number.py and select.py created five
1116+
writable controls anyway — including the power setpoint that was measured importing
1117+
from the grid to reach its target (#374).
1118+
1119+
Absent or unrecognised means writable, so nothing that works today changes: a profile
1120+
has to say 'RO'/'R' explicitly to withhold a control. Of 517 control/profile pairs,
1121+
six are affected — the five above and SPE register 117, which documents itself as
1122+
"firmware-determined, writes may be rejected" and is the same defect in miniature.
1123+
"""
1124+
if not isinstance(register_def, dict):
1125+
return False
1126+
return str(register_def.get("access", "")).strip().upper() in ("RO", "R")
1127+
1128+
11101129
def resolve_block_size(value) -> int:
11111130
"""Resolve a stored max_block_size option to an integer.
11121131

custom_components/growatt_modbus/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
"pymodbus>=3.0.0",
1313
"pyserial>=3.4"
1414
],
15-
"version": "1.6.0"
15+
"version": "1.6.1"
1616
}

custom_components/growatt_modbus/number.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
WRITABLE_REGISTERS,
1616
CONF_REGISTER_MAP,
1717
get_device_type_for_control,
18+
is_read_only_register,
1819
)
1920
from .coordinator import GrowattModbusCoordinator
2021
from .entity import GrowattEntity
@@ -109,6 +110,21 @@ async def async_setup_entry(
109110
if register_num not in holding_registers:
110111
continue # Skip if register not in this profile
111112

113+
# A profile marking the register read-only means "this model has the address but
114+
# will not accept a write" — so do not offer a control for it (#374).
115+
#
116+
# Until v1.6.1 `access` was documentation that nothing read. v1.6.0 added the VPP
117+
# registers to the MOD profile as 'RO' expecting that to be enough, and this loop
118+
# created five writable controls anyway — including the power setpoint measured
119+
# importing from the grid to reach its target. The flag now means what everyone
120+
# already assumed it meant.
121+
if is_read_only_register(holding_registers.get(register_num)):
122+
_LOGGER.debug(
123+
"Skipping %s: register %d is read-only on this profile",
124+
control_name, register_num,
125+
)
126+
continue
127+
112128
# Profile-specific filter: only_profiles restricts to named maps; not_profiles excludes them
113129
_only = control_config.get('only_profiles')
114130
if _only and register_map_name not in _only:

custom_components/growatt_modbus/select.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
WRITABLE_REGISTERS,
1616
CONF_REGISTER_MAP,
1717
get_device_type_for_control,
18+
is_read_only_register,
1819
DEVICE_TYPE_BATTERY,
1920
MOD_TOU_PERIODS,
2021
)
@@ -90,6 +91,16 @@ async def async_setup_entry(
9091
if register_num not in holding_registers:
9192
continue # Skip if register not in this profile
9293

94+
# A profile marking the register read-only means "this model has the address but
95+
# will not accept a write" — so do not offer a control for it (#374). See
96+
# is_read_only_register() for what v1.6.0 shipped by assuming this already worked.
97+
if is_read_only_register(holding_registers.get(register_num)):
98+
_LOGGER.debug(
99+
"Skipping %s: register %d is read-only on this profile",
100+
control_name, register_num,
101+
)
102+
continue
103+
93104
# Profile-specific filter: only_profiles restricts to named maps; not_profiles excludes them
94105
_only = control_config.get('only_profiles')
95106
if _only and register_map_name not in _only:
@@ -151,6 +162,16 @@ async def async_setup_entry(
151162
cfg = WRITABLE_REGISTERS[ctrl]
152163
if cfg["register"] not in holding_registers:
153164
continue
165+
# Read-only on this profile, so there is nothing to defer — it is not "skipped
166+
# pending data", it is withheld deliberately (#374).
167+
#
168+
# This check has to be here as well as in the loop above, or the two paths
169+
# disagree: on the reported MOD install control_authority was skipped at setup
170+
# for want of live data, then added 8 seconds later by this path once the first
171+
# poll confirmed 30100 answers. Gating only the setup loop would have fixed four
172+
# of the five controls and left this one.
173+
if is_read_only_register(holding_registers.get(cfg["register"])):
174+
continue
154175
# Only defer if it was actually skipped above (flag was False at setup time)
155176
already_created = any(
156177
getattr(e, "_control_name", None) == ctrl for e in entities

tests/test_control_registers.py

Lines changed: 123 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -126,33 +126,138 @@ def test_only_3312_is_writable_in_the_peak_shaving_cluster():
126126

127127

128128
@pytest.mark.parametrize("addr", [30100, 30407, 30408, 30409, 30410, 30474])
129-
def test_mod_vpp_registers_are_read_only(addr):
130-
"""#373 established that this family supports remote power control, and also that the
131-
power value is a target rather than a cap: at 100% it climbed toward the setpoint and
132-
imported from the grid with allow_grid_charge off. Until that is guarded, these are
133-
exposed for visibility only."""
129+
def test_mod_vpp_registers_are_marked_read_only(addr):
130+
"""The declaration. On its own this proves nothing about what the user sees — see the
131+
test below, which is the one that matters."""
134132
reg = _holding("MOD_6000_15000TL3_XH").get(addr)
135133
assert reg is not None, f"holding {addr} is not mapped on MOD-XH"
136-
assert str(reg.get("access", "")).upper() in ("RO", "R"), (
134+
assert _const.is_read_only_register(reg), (
137135
f"holding {addr} is writable on MOD-XH. Commanding VPP power on this family needs "
138136
f"a guard against importing from the grid to reach the setpoint (#373)."
139137
)
140138

141139

142-
def test_the_wit_gate_still_confines_writable_vpp_controls():
143-
"""`access: 'RO'` in the profile does not by itself stop a control being created —
144-
number.py and select.py key off WRITABLE_REGISTERS plus profile membership, not the
145-
access flag. What actually keeps MOD read-only is the WIT-only name check.
140+
# ---------------------------------------------------------------------------
141+
# No control may exist for a register its profile marks read-only (#374)
142+
# ---------------------------------------------------------------------------
143+
#
144+
# This replaces two tests that passed while the defect shipped, which is worth recording
145+
# because both were the wrong shape rather than merely incomplete:
146+
#
147+
# test_mod_vpp_registers_are_read_only asserted the `access` flag was 'RO'. It was.
148+
# Nothing read the flag, so the assertion was
149+
# true and meaningless.
150+
# test_the_wit_gate_still_confines_... asserted the WIT gate still had its shape. It
151+
# did. But the gate only returns *inside* the
152+
# WIT branch — non-WIT profiles fall through to
153+
# the generic loop it was believed to confine.
154+
#
155+
# Both tested a declaration, or a mechanism believed to enforce it, rather than the
156+
# outcome. v1.6.0 created five writable VPP controls on MOD with both passing, including a
157+
# -100..+100% power slider on the register measured importing from the grid to reach its
158+
# setpoint. The tests below assert the outcome instead.
159+
160+
161+
PLATFORMS = ("number.py", "select.py")
146162

147-
So if that gate becomes a capability probe, MOD gains writable VPP controls the moment
148-
its hardware answers — including the power setpoint that #373 measured importing from
149-
the grid to reach its target. That is the change to make deliberately, with a guard,
150-
not to arrive at by refactoring.
163+
164+
def test_both_control_platforms_consult_the_read_only_flag():
165+
"""The load-bearing test. Everything below models the loops; this checks the loops.
166+
167+
`_controls_created_for` applies the read-only filter itself, so on its own it would
168+
pass whatever number.py and select.py actually do — a model of the code agreeing with
169+
itself. That is the shape of mistake that let v1.6.0 ship: a test asserting a
170+
declaration rather than an outcome.
171+
"""
172+
component = Path(__file__).parent.parent / "custom_components" / "growatt_modbus"
173+
for platform in PLATFORMS:
174+
src = (component / platform).read_text(encoding="utf-8")
175+
assert "is_read_only_register" in src, (
176+
f"{platform} never consults the read-only flag, so a profile marking a "
177+
f"register RO does not stop the control being created — which is exactly "
178+
f"what shipped in v1.6.0 (#374)"
179+
)
180+
181+
182+
def _controls_created_for(map_key: str) -> set[str]:
183+
"""Control names the generic loops would create for a profile.
184+
185+
Mirrors the filters in number.py/select.py: profile membership, the read-only flag,
186+
only_profiles/not_profiles. Bespoke classes and the live-confirmation skips are not
187+
modelled — they can only ever remove entities from this set, never add one, so a
188+
control absent here cannot appear in Home Assistant.
189+
190+
This is a model, not the code. Pair it with
191+
test_both_control_platforms_consult_the_read_only_flag, which checks the real thing.
192+
"""
193+
holding = _holding(map_key)
194+
created = set()
195+
for name, cfg in WRITABLE_REGISTERS.items():
196+
addr = cfg.get("register")
197+
if addr not in holding:
198+
continue
199+
if _const.is_read_only_register(holding.get(addr)):
200+
continue
201+
only = cfg.get("only_profiles")
202+
if only and map_key not in only:
203+
continue
204+
not_p = cfg.get("not_profiles")
205+
if not_p and map_key in not_p:
206+
continue
207+
created.add(name)
208+
return created
209+
210+
211+
@pytest.mark.parametrize("map_key", sorted(REGISTER_MAPS))
212+
def test_no_control_is_created_for_a_read_only_register(map_key):
213+
"""The general rule. A profile marking a register read-only is a statement that the
214+
hardware will not accept a write, and the only way to honour it is not to offer the
215+
control."""
216+
holding = _holding(map_key)
217+
offending = sorted(
218+
name for name in _controls_created_for(map_key)
219+
if _const.is_read_only_register(holding.get(WRITABLE_REGISTERS[name]["register"]))
220+
)
221+
assert not offending, f"{map_key} would create controls for read-only registers: {offending}"
222+
223+
224+
@pytest.mark.parametrize("addr", [30100, 30407, 30408, 30409, 30410])
225+
def test_mod_creates_no_vpp_control(addr):
226+
"""The specific case, stated as the user-visible outcome rather than a flag.
227+
228+
Each of these had a control in v1.6.0: two selects, two numbers, and control_authority
229+
arriving 8 seconds later through the deferred-registration path.
230+
"""
231+
created = _controls_created_for("MOD_6000_15000TL3_XH")
232+
named = {
233+
name for name, cfg in WRITABLE_REGISTERS.items() if cfg.get("register") == addr
234+
}
235+
leaked = sorted(named & created)
236+
assert not leaked, (
237+
f"MOD-XH would create {leaked} for register {addr}. #373 defers writable VPP "
238+
f"controls on this family until commanding power is bounded against available PV."
239+
)
240+
241+
242+
def test_the_deferred_path_honours_read_only_too():
243+
"""Gating only the setup loop fixes four of the five.
244+
245+
control_authority is skipped at setup for want of live data, then added by the
246+
deferred listener once the first poll confirms 30100 answers — which is exactly what
247+
the reported log shows happening 8 seconds in. The two paths have to agree.
151248
"""
152249
src = (Path(__file__).parent.parent / "custom_components" / "growatt_modbus"
153250
/ "select.py").read_text(encoding="utf-8")
154-
assert "is_wit = str(register_map_name).upper() in" in src, (
155-
"the WIT gate in select.py has changed shape. If it is now a capability probe, "
156-
"MOD-XH will gain writable VPP controls — #373 defers those until commanding "
157-
"power is bounded against available PV."
251+
deferred = src[src.index("deferred_vpp: list"):src.index("if deferred_vpp:")]
252+
assert "is_read_only_register" in deferred, (
253+
"the deferred VPP registration path does not check the read-only flag, so a "
254+
"control withheld at setup will be added a few seconds later anyway"
158255
)
256+
257+
258+
def test_mod_still_creates_the_controls_it_should():
259+
"""The counterweight. It would be easy to fix #374 by withholding too much."""
260+
created = _controls_created_for("MOD_6000_15000TL3_XH")
261+
for expected in ("batt_first_charge_power_rate", "batt_first_charge_stopped_soc",
262+
"grid_first_discharge_stopped_soc", "grid_charge_stopped_soc"):
263+
assert expected in created, f"MOD-XH lost the {expected} control"

tests/test_stale_entity_cleanup.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,32 @@ def test_controls_are_cleaned_up_by_profile_too():
7676

7777

7878
def test_control_cleanup_checks_the_register_not_the_name():
79-
"""Controls are gated on `control_config['register'] in holding_registers`, so the
80-
cleanup has to test the same thing. Matching on the control name instead would keep
81-
entities whose register was removed but whose name still exists in const.py — which is
82-
exactly the 1090/1092 case, since both names are still defined there for other
83-
families."""
79+
"""Controls are gated on the register, so the cleanup has to test the same thing.
80+
Matching on the control name instead would keep entities whose register was removed
81+
but whose name still exists in const.py — exactly the 1090/1092 case, since both names
82+
remain defined there for other families."""
8483
body = _setup_entry_source()
85-
assert re.search(r"control_config\.get\(\s*[\"']register[\"']\s*\)\s+in\s+holding", body), (
86-
"stale-control cleanup does not test the control's register against the profile's "
87-
"holding registers"
84+
assert re.search(r"control_config\.get\(\s*[\"']register[\"']\s*\)", body), (
85+
"stale-control cleanup does not look up the control's register"
86+
)
87+
assert re.search(r"\bin\s+holding\b", body), (
88+
"stale-control cleanup does not test the register against the profile's holding "
89+
"registers"
90+
)
91+
92+
93+
def test_control_cleanup_also_removes_now_read_only_controls():
94+
"""A register can stop backing a control without leaving the profile.
95+
96+
v1.6.0 shipped five writable VPP controls on MOD because the profile's `access: 'RO'`
97+
was never read. v1.6.1 makes the loops honour it — but those registers are still in
98+
the profile, so a membership test alone would leave the five entities behind as
99+
`unavailable` on every install that already has them (#374).
100+
"""
101+
body = _setup_entry_source()
102+
assert "is_read_only_register" in body, (
103+
"stale-control cleanup does not consider read-only registers, so controls "
104+
"withdrawn by marking the register RO will linger in the registry"
88105
)
89106

90107

0 commit comments

Comments
 (0)