Skip to content

Commit ffb17ca

Browse files
0xAHAclaude
andcommitted
fix: 30410 falls back to FC 0x10 when FC 0x06 is refused (#353)
Register 30410 (VPP AC charge enable) accepts only Write Multiple Registers on some WIT hardware - reported on a WIT 8000TL3-HU, where FC 0x06 is rejected and FC 0x10 with count=1 works. The failure mode was worse than a rejected write usually is. Both call sites wrapped the write in try/except and logged a warning, so the mode sequence carried on and every other register succeeded. Grid charging silently never engaged while the control reported success - the same shape as the registers that accept writes and ignore them, except this one was refusing outright and we were stepping over the refusal. FC 0x06 is still attempted first, deliberately. It is what the rest of the integration uses and what most hardware expects, and there is one report in each direction; switching wholesale to FC 0x10 risks the opposite failure on devices that only take the single-register form. The fallback only adds an attempt where the first was refused, so it cannot degrade a write that already works. Both the Hold and Charge sequences write 30410. A test asserts both go through the helper and that no bare FC 0x06 write to it remains - wiring one and not the other would have left half the feature broken in a way nobody would report separately. Reported by @jekmanis, who also found the documentation error fixed in 32702f2. 984 tests pass. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 32702f2 commit ffb17ca

5 files changed

Lines changed: 172 additions & 10 deletions

File tree

RELEASENOTES.md

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

55
---
66

7+
## v1.6.5 (pre-release)
8+
9+
Issues: #353
10+
11+
> **Pre-release for testing.** v1.6.2 remains the stable release.
12+
13+
- **WIT grid charging now works on models that reject Write Single Register.** Register
14+
30410 (VPP AC charge enable) accepts only FC 0x10 on some WIT hardware. The write was
15+
attempted with FC 0x06, and a refusal was logged as a warning and stepped over - so every
16+
other register in the mode sequence succeeded and grid charging silently never engaged.
17+
It now falls back to FC 0x10 when FC 0x06 is refused, and reports a real failure when
18+
neither works. Reported by @jekmanis. (#353)
19+
- Documentation: register 30476 (WIT priority mode) is no longer described as read-only. It
20+
is writable on some models - the integration's TOU Default Mode control writes it - and
21+
the guide now says so rather than telling people not to try. (#353)
22+
23+
---
24+
725
## v1.6.4 (pre-release)
826

927
Issues: #377, #383, #384

custom_components/growatt_modbus/growatt_modbus.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2490,6 +2490,47 @@ def write_register(self, register: int, value: int) -> bool:
24902490
logger.error(f"[WRITE] {error_msg}")
24912491
raise ModbusWriteError(register, [value], error_msg)
24922492

2493+
def write_single_register_any_fc(self, register: int, value: int) -> bool:
2494+
"""Write one register, falling back to FC 0x10 when FC 0x06 is refused.
2495+
2496+
Some VPP registers accept only Write Multiple Registers, even for a single value.
2497+
30410 (VPP AC charge enable) is the reported case: on a WIT 8000TL3-HU it rejects
2498+
FC 0x06 and accepts FC 0x10 with count=1 (#353).
2499+
2500+
That mattered more than a rejected write usually does, because the caller logged a
2501+
warning and carried on. Every other register in the mode sequence succeeded, so grid
2502+
charging silently never engaged while the control reported success.
2503+
2504+
FC 0x06 is tried first deliberately. It is what the rest of the integration uses and
2505+
what most hardware expects; switching everything to FC 0x10 would risk the opposite
2506+
failure on devices that only accept the single-register form. This only adds a second
2507+
attempt where the first is refused outright, so it cannot make a working write worse.
2508+
"""
2509+
try:
2510+
if self.write_register(register, value):
2511+
return True
2512+
logger.debug(
2513+
"[FC FALLBACK] register %d refused FC 0x06 (no exception) — trying FC 0x10",
2514+
register,
2515+
)
2516+
except Exception as exc: # noqa: BLE001
2517+
logger.debug(
2518+
"[FC FALLBACK] register %d raised on FC 0x06 (%s) — trying FC 0x10",
2519+
register, exc,
2520+
)
2521+
2522+
try:
2523+
if self.write_registers(register, [value]):
2524+
logger.info(
2525+
"[FC FALLBACK] register %d accepted FC 0x10 after refusing FC 0x06",
2526+
register,
2527+
)
2528+
return True
2529+
except Exception as exc: # noqa: BLE001
2530+
logger.debug("[FC FALLBACK] register %d also refused FC 0x10: %s", register, exc)
2531+
2532+
return False
2533+
24932534
def write_register_verified(self, register: int, value: int) -> tuple:
24942535
"""Write a holding register with read-back verification and retry.
24952536

custom_components/growatt_modbus/manifest.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
22
"domain": "growatt_modbus",
33
"name": "Growatt Modbus",
4-
"codeowners": ["@0xAHA"],
4+
"codeowners": [
5+
"@0xAHA"
6+
],
57
"config_flow": true,
68
"dependencies": [],
79
"documentation": "https://github.com/0xAHA/Growatt_ModbusTCP",
@@ -12,5 +14,5 @@
1214
"pymodbus>=3.0.0",
1315
"pyserial>=3.4"
1416
],
15-
"version": "1.6.4"
17+
"version": "1.6.5"
1618
}

custom_components/growatt_modbus/select.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -536,10 +536,14 @@ def _apply_mode(self, option: str) -> bool:
536536
_LOGGER.debug("[WIT-VPP] Setting HOLD mode via TOU +1%% workaround")
537537

538538
# Enable AC charging (required for TOU charge to work)
539-
try:
540-
client.write_register(self.VPP_AC_CHARGE_ENABLE, 1)
541-
except Exception as e: # noqa: BLE001
542-
_LOGGER.warning("[WIT-VPP] AC charge enable (30410) failed: %s", e)
539+
# FC 0x06 first, FC 0x10 if refused: 30410 accepts only Write Multiple
540+
# Registers on some WIT models (#353). A plain warning here meant grid
541+
# charging silently never engaged while every other write succeeded.
542+
if not client.write_single_register_any_fc(self.VPP_AC_CHARGE_ENABLE, 1):
543+
_LOGGER.warning(
544+
"[WIT-VPP] AC charge enable (30410) refused both FC 0x06 and "
545+
"FC 0x10 - grid charging will not engage"
546+
)
543547

544548
# Get current time for TOU period
545549
from datetime import datetime
@@ -578,10 +582,14 @@ def _apply_mode(self, option: str) -> bool:
578582
return False
579583

580584
# Enable AC charging (PV priority)
581-
try:
582-
client.write_register(self.VPP_AC_CHARGE_ENABLE, 1)
583-
except Exception as e: # noqa: BLE001
584-
_LOGGER.warning("[WIT-VPP] AC charge enable (30410) failed: %s", e)
585+
# FC 0x06 first, FC 0x10 if refused: 30410 accepts only Write Multiple
586+
# Registers on some WIT models (#353). A plain warning here meant grid
587+
# charging silently never engaged while every other write succeeded.
588+
if not client.write_single_register_any_fc(self.VPP_AC_CHARGE_ENABLE, 1):
589+
_LOGGER.warning(
590+
"[WIT-VPP] AC charge enable (30410) refused both FC 0x06 and "
591+
"FC 0x10 - grid charging will not engage"
592+
)
585593

586594
# Enable remote power control
587595
success = client.write_register(self.VPP_REMOTE_POWER_ENABLE, 1)

tests/test_fc_fallback.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""A single-register write must survive hardware that only accepts FC 0x10 (#353).
2+
3+
Register 30410 (VPP AC charge enable) rejects Write Single Register on at least one WIT
4+
8000TL3-HU and accepts Write Multiple Registers with count=1. The caller logged a warning
5+
and continued, so every other write in the mode sequence succeeded and grid charging
6+
silently never engaged - the control reported success and did nothing.
7+
8+
FC 0x06 is still tried first. Switching everything to FC 0x10 would risk the opposite
9+
failure on devices that only accept the single-register form, and there is one report in
10+
each direction. The fallback can only add an attempt where the first was refused outright.
11+
"""
12+
from __future__ import annotations
13+
14+
import importlib
15+
16+
import pytest
17+
18+
_gm = importlib.import_module("growatt_under_test.growatt_modbus")
19+
20+
REG = 30410
21+
22+
23+
class _Client(_gm.GrowattModbus):
24+
"""Records which function codes were attempted, and how each was answered."""
25+
26+
def __init__(self, single_ok=True, multi_ok=True, single_raises=False):
27+
super().__init__(connection_type="tcp", host="10.0.0.1", port=502,
28+
register_map="WIT_4000_15000TL3")
29+
self.calls: list[str] = []
30+
self._single_ok = single_ok
31+
self._multi_ok = multi_ok
32+
self._single_raises = single_raises
33+
34+
def write_register(self, register, value):
35+
self.calls.append("fc06")
36+
if self._single_raises:
37+
raise OSError("Illegal Function")
38+
return self._single_ok
39+
40+
def write_registers(self, register, values):
41+
self.calls.append("fc10")
42+
return self._multi_ok
43+
44+
45+
def test_fc06_is_tried_first_and_nothing_else_happens_when_it_works():
46+
"""The common path must be untouched - no extra traffic on healthy hardware."""
47+
c = _Client()
48+
assert c.write_single_register_any_fc(REG, 1) is True
49+
assert c.calls == ["fc06"], "FC 0x10 was attempted even though FC 0x06 succeeded"
50+
51+
52+
def test_it_falls_back_when_fc06_raises():
53+
"""The reported case: the device refuses Write Single Register outright."""
54+
c = _Client(single_raises=True, multi_ok=True)
55+
assert c.write_single_register_any_fc(REG, 1) is True
56+
assert c.calls == ["fc06", "fc10"]
57+
58+
59+
def test_it_falls_back_when_fc06_returns_false():
60+
"""Not every refusal raises - some paths report failure by return value."""
61+
c = _Client(single_ok=False, multi_ok=True)
62+
assert c.write_single_register_any_fc(REG, 1) is True
63+
assert c.calls == ["fc06", "fc10"]
64+
65+
66+
def test_both_refused_reports_failure():
67+
"""The caller must be able to tell the difference between 'worked somehow' and
68+
'nothing worked' - the old code could not, which is why this went unnoticed."""
69+
c = _Client(single_ok=False, multi_ok=False)
70+
assert c.write_single_register_any_fc(REG, 1) is False
71+
assert c.calls == ["fc06", "fc10"]
72+
73+
74+
def test_a_raising_fc10_is_not_propagated():
75+
"""A mode change must not abort on the fallback attempt itself."""
76+
c = _Client(single_raises=True)
77+
c.write_registers = lambda r, v: (_ for _ in ()).throw(OSError("also refused"))
78+
assert c.write_single_register_any_fc(REG, 1) is False
79+
80+
81+
def test_the_vpp_charge_paths_use_it():
82+
"""Both the Hold and Charge sequences write 30410. A helper wired into one of them
83+
would leave the other silently broken."""
84+
from pathlib import Path
85+
86+
source = (Path(__file__).parent.parent / "custom_components" / "growatt_modbus"
87+
/ "select.py").read_text(encoding="utf-8")
88+
assert source.count("write_single_register_any_fc(self.VPP_AC_CHARGE_ENABLE, 1)") == 2, (
89+
"not both 30410 write sites go through the fallback"
90+
)
91+
assert "client.write_register(self.VPP_AC_CHARGE_ENABLE" not in source, (
92+
"a bare FC 0x06 write to 30410 remains"
93+
)

0 commit comments

Comments
 (0)