Skip to content

Commit dd9a680

Browse files
0xAHAclaude
andcommitted
perf: time entities skip writes that would change nothing (#392)
TOU slot registers are believed to be non-volatile with finite write endurance - believed rather than known, since Growatt marks a handful of VPP registers 'Not storage' and documents nothing about the rest. number.py has skipped no-op writes since v1.6.6. The time entities did not, and a TOU scheduler writes through the time entities. A reporter recomputing nine slots daily from day-ahead prices will find most of them unchanged on most days; there is no reason to spend a write cycle proving it. Applied to GrowattGenericTime and GrowattModTouTime only. Both already re-read the sibling registers from hardware before writing - to avoid back-to-back writes reverting each other - so the comparison is against fresh values, not coordinator.data. On the cached fallback the guard is disabled: a skipped write that should have happened is worse than a redundant one, which is the same reasoning behind the fresh read. GrowattWitVppTouTime is deliberately left unguarded. Its current value comes from coordinator.wit_vpp_tou_p*_{start,end}, which is assigned only after a successful write and never populated from a register read. Comparing against what we last commanded would skip a write intended to correct a change made by the Growatt cloud or app. A test asserts coordinator.py still never touches those attributes, so if a read-back is added the entity becomes eligible. For MOD the comparison is on the raw words rather than the times, because new_start carries the priority and enable bits (13-15) forward - equality there means the whole register is unchanged, not just hour and minute. Verified red without the guard (6 failures). No version bump; rides along with the next release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 56e6bda commit dd9a680

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

custom_components/growatt_modbus/time.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ async def async_set_value(self, value: dt_time) -> None:
246246
)
247247
if triple is not None and len(triple) >= 3:
248248
current_start, current_end, current_enable = int(triple[0]), int(triple[1]), int(triple[2])
249+
values_are_fresh = True
249250
else:
250251
_LOGGER.warning(
251252
"%s: could not read fresh register triple (reg %d) — falling back to cached data",
@@ -255,10 +256,32 @@ async def async_set_value(self, value: dt_time) -> None:
255256
current_start = int(getattr(data, start_name, 0) or 0) if data else 0
256257
current_end = int(getattr(data, end_name, 0) or 0) if data else 0
257258
current_enable = int(getattr(data, enable_name, 0) or 0) if data else 0
259+
values_are_fresh = False
258260

259261
new_start = raw_value if is_start else current_start
260262
new_end = raw_value if not is_start else current_end
261263

264+
# Skip a write that would change nothing (#392).
265+
#
266+
# These registers are believed to be held in non-volatile memory with finite write
267+
# endurance — believed rather than known: Growatt marks a handful of VPP registers
268+
# "Not storage" and documents nothing about the rest, so we treat the rest
269+
# conservatively. A price-driven controller recomputing all nine slots daily will
270+
# usually find most of them unchanged, and there is no reason to spend a write
271+
# cycle proving it. `number.py` has done this since v1.6.6; the time entities did
272+
# not, which is where a TOU scheduler actually writes.
273+
#
274+
# Only when the comparison is against a *fresh* read. On the cached fallback the
275+
# values may be up to a scan interval old, and a skipped write that should have
276+
# happened is worse than a redundant one — the same reasoning that makes this
277+
# method re-read the siblings rather than trust coordinator.data at all.
278+
if values_are_fresh and new_start == current_start and new_end == current_end:
279+
_LOGGER.debug(
280+
"%s: already reads start=0x%04X end=0x%04X — skipping write to register %d",
281+
name, current_start, current_end, start_reg,
282+
)
283+
return
284+
262285
_LOGGER.debug(
263286
"%s: atomic FC16 → reg %d [start=0x%04X, end=0x%04X, enable=%d]",
264287
name, start_reg, new_start, new_end, current_enable,
@@ -390,6 +413,7 @@ async def async_set_value(self, value: dt_time) -> None:
390413
)
391414
if pair is not None and len(pair) >= 2:
392415
current_start, current_end = int(pair[0]), int(pair[1])
416+
values_are_fresh = True
393417
else:
394418
_LOGGER.warning(
395419
"MOD TOU period %d: could not read fresh register pair (reg %d) — falling back to cached data",
@@ -398,6 +422,7 @@ async def async_set_value(self, value: dt_time) -> None:
398422
data = self.coordinator.data
399423
current_start = int(getattr(data, period["start_field"], 0) if data else 0)
400424
current_end = int(getattr(data, period["end_field"], 0) if data else 0)
425+
values_are_fresh = False
401426

402427
# Compute new start raw, preserving priority (bits 13-14) and enable (bit 15)
403428
if self._is_start:
@@ -412,6 +437,21 @@ async def async_set_value(self, value: dt_time) -> None:
412437
new_end = current_end # unchanged — keep current end when writing start
413438

414439
slot = "start" if self._is_start else "end"
440+
441+
# Skip a write that would change nothing (#392) — see the matching note in
442+
# GrowattGenericTime.async_set_value above. Only against a fresh read; the cached
443+
# fallback may be a scan interval old, and a missed write is worse than a spare one.
444+
#
445+
# Comparing the raw words rather than the times matters here: new_start preserves
446+
# the priority and enable bits (13-15) from the current value, so an equal
447+
# comparison means the whole register is unchanged, not just the hour and minute.
448+
if values_are_fresh and new_start == current_start and new_end == current_end:
449+
_LOGGER.debug(
450+
"MOD TOU period %d %s: already reads start=0x%04X end=0x%04X — skipping write",
451+
self._period, slot, current_start, current_end,
452+
)
453+
return
454+
415455
try:
416456
success = await self.hass.async_add_executor_job(
417457
self.coordinator.modbus_client.write_registers,

tests/test_time_no_op_writes.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Time entities skip writes that would change nothing (#392).
2+
3+
TOU slot registers are believed to be held in non-volatile memory with finite write
4+
endurance. Believed, not known — Growatt marks a handful of VPP registers "Not storage"
5+
and documents nothing about the rest, so the rest are treated conservatively.
6+
7+
A reporter building a price-driven controller that recomputes nine TOU slots daily asked
8+
what the write budget was. Most of those slots will be unchanged on most days, and there is
9+
no reason to spend a write cycle proving it. `number.py` has skipped no-op writes since
10+
v1.6.6; the time entities did not, and a TOU scheduler writes through the time entities.
11+
12+
The interesting half is when it must NOT skip:
13+
14+
* On the cached fallback, where values may be a scan interval old. A skipped write that
15+
should have happened is worse than a redundant one — the same reasoning that makes these
16+
methods re-read sibling registers rather than trust coordinator.data at all.
17+
* On the WIT TOU entity, whose "current value" is a command cache written only after a
18+
successful write and never read back from the inverter. Comparing against what we last
19+
commanded would skip a write that was correcting an external change.
20+
"""
21+
from __future__ import annotations
22+
23+
import ast
24+
from pathlib import Path
25+
26+
import pytest
27+
28+
SOURCE_PATH = (Path(__file__).parent.parent / "custom_components" / "growatt_modbus"
29+
/ "time.py")
30+
SOURCE = SOURCE_PATH.read_text(encoding="utf-8")
31+
TREE = ast.parse(SOURCE)
32+
33+
34+
def _method(class_name: str, method_name: str) -> ast.FunctionDef:
35+
for node in TREE.body:
36+
if isinstance(node, ast.ClassDef) and node.name == class_name:
37+
for child in node.body:
38+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
39+
if child.name == method_name:
40+
return child
41+
raise AssertionError(f"{class_name}.{method_name} not found")
42+
43+
44+
def _source_of(node) -> str:
45+
return ast.get_source_segment(SOURCE, node) or ""
46+
47+
48+
# --------------------------------------------------------------------------
49+
# The guard exists where a fresh read is available
50+
# --------------------------------------------------------------------------
51+
52+
@pytest.mark.parametrize(
53+
"class_name",
54+
["GrowattGenericTime", "GrowattModTouTime"],
55+
)
56+
def test_the_guard_is_present(class_name):
57+
body = _source_of(_method(class_name, "async_set_value"))
58+
assert "values_are_fresh" in body, (
59+
f"{class_name} writes unconditionally — a daily recompute burns a write cycle per "
60+
f"slot even when nothing changed"
61+
)
62+
assert "new_start == current_start" in body and "new_end == current_end" in body, (
63+
f"{class_name} does not compare both halves of the pair before skipping"
64+
)
65+
66+
67+
@pytest.mark.parametrize(
68+
"class_name",
69+
["GrowattGenericTime", "GrowattModTouTime"],
70+
)
71+
def test_the_guard_returns_before_writing(class_name):
72+
"""A guard that logs but falls through would be decoration."""
73+
body = _source_of(_method(class_name, "async_set_value"))
74+
guard = body.index("values_are_fresh and")
75+
write = body.index("write_registers")
76+
assert guard < write, f"{class_name} evaluates the guard after the write"
77+
assert "return" in body[guard:write], (
78+
f"{class_name} does not return from the guard, so the write happens anyway"
79+
)
80+
81+
82+
# --------------------------------------------------------------------------
83+
# ...and only where it is safe
84+
# --------------------------------------------------------------------------
85+
86+
@pytest.mark.parametrize(
87+
"class_name",
88+
["GrowattGenericTime", "GrowattModTouTime"],
89+
)
90+
def test_the_cached_fallback_still_writes(class_name):
91+
"""`values_are_fresh` must be set False on the fallback branch. Skipping on stale data
92+
could drop a write the user asked for."""
93+
body = _source_of(_method(class_name, "async_set_value"))
94+
assert "values_are_fresh = False" in body, (
95+
f"{class_name} never marks the cached fallback as stale, so the guard would trust "
96+
f"values up to a scan interval old"
97+
)
98+
assert body.index("values_are_fresh = True") < body.index("values_are_fresh = False"), (
99+
"the fresh-read branch must be the one that sets True"
100+
)
101+
102+
103+
def test_the_wit_entity_is_deliberately_not_guarded():
104+
"""GrowattWitVppTouTime compares against coordinator.wit_vpp_tou_p*_{start,end}, which
105+
is set only after a successful write and never populated from a read. Guarding on it
106+
would skip a write intended to correct a change made by the Growatt cloud or app."""
107+
body = _source_of(_method("GrowattWitVppTouTime", "async_set_value"))
108+
assert "values_are_fresh" not in body, (
109+
"the WIT TOU entity is guarded against a command cache, not against inverter "
110+
"state — it can skip a correcting write"
111+
)
112+
113+
114+
def test_the_wit_value_is_never_populated_from_a_register_read():
115+
"""Pins the reason above.
116+
117+
coordinator.py is where register values are decoded onto the coordinator. It does not
118+
mention the WIT TOU start/end attributes at all, which is what makes them a command
119+
cache rather than inverter state. If a read-back is ever added there, this fails and
120+
GrowattWitVppTouTime becomes eligible for the same guard as the other two.
121+
"""
122+
coordinator = (SOURCE_PATH.parent / "coordinator.py").read_text(encoding="utf-8")
123+
assert "wit_vpp_tou_p" not in coordinator, (
124+
"coordinator.py now references the WIT TOU period attributes. If it populates "
125+
"them from a register read they are no longer a command cache, and the no-op "
126+
"write guard can be extended to GrowattWitVppTouTime."
127+
)

0 commit comments

Comments
 (0)