Apply READ_ATTEMPTS to the identity read - #687
Conversation
The Identity Component reads registers 0-7 straight off self.inv.unit, so it never passed through Sunsynk.read_holding_registers and its retry loop. A single dropped reply aborted startup, while every other read got READ_ATTEMPTS tries and, on serial, a flush between them. Pass the Sunsynk itself: same read_holding_registers signature, with the retry, the serial flush and the timeout counter.
The ModbusProtocolError branch logged but never appended to errs, so a group where every attempt desynced raised ValueError: second argument (exceptions) must be a non-empty sequence instead of the Modbus failure. Reachable today on a noisy RS485 link, and more so now that the identity read shares this path.
| # signature, but with READ_ATTEMPTS retries and the serial flush. | ||
| # ponytail: only holds while Identity reads holding registers only. A field | ||
| # in another space would need the unit's read_coils/read_input_registers. | ||
| identity = Identity(self.inv) # type: ignore[arg-type] |
There was a problem hiding this comment.
Where do you get the modbus ID from? This is part of the unit?
There was a problem hiding this comment.
Yes, the modbus ID is on the unit, and it stays there.
Sunsynk.read_holding_registers just calls self.unit.read_holding_registers (sunsynk.py:170), so passing self.inv instead of self.inv.unit still lands on the same unit that driver.py:82 builds with conn.for_unit(iopt.modbus_id). Same for solarman, where the id sits on SolarmanUnit(server_id=iopt.modbus_id) at driver.py:67.
The only thing added in between is the retry, the serial flush and the timeouts counter.
I checked it with a real connection and for_unit(3), recording what actually hits the wire for the old and the new call:
wire calls: [{'unit_id': 3, 'address': 0, 'count': 8},
{'unit_id': 3, 'address': 0, 'count': 8}]
serial before: 2303218594 after: 2303218594
First one is Identity(unit), second is Identity(ss). Same unit id, same result.
Happy to add an assert on the unit id to the test if you want that locked in.
There was a problem hiding this comment.
Ok, but we still pass something that is not really a HoldingUnit
Can we rather add this to sunsynk.py?
Add a tiny retrying unit facade on Sunsynk, and pass that to Identity:
@dataclass(frozen=True, slots=True)
class _RetryingUnit:
"""HoldingUnit that routes FC03 through Sunsynk retry policy."""
ss: Sunsynk
@property
def connected(self) -> bool:
return self.ss.unit.connected
async def read_holding_registers(self, address: int, count: int) -> list[int]:
return list(await self.ss.read_holding_registers(address, count))
async def write_registers(self, address: int, values: list[int]) -> None:
await self.ss.unit.write_registers(address, values)Then in read_identity:
identity = Identity(self.inv.retrying_unit) # property returning _RetryingUnit(self)
await identity.async_update()That gives you:
- Correct modbus id (still ends up on self.inv.unit)
- Sunsynk retry/flush behavior
- A object that actually matches HoldingUnit
- No coupling of Identity to the whole Sunsynk API
Fixes #686
AInverter.read_identity()built theIdentityComponent overself.inv.unit, so the registers0-7 read went straight to the unit and never through
Sunsynk.read_holding_registers()— the onlyplace
READ_ATTEMPTSis applied. Nothing inmodbus_connection.modelretries, so the identity readgot a single attempt whatever
READ_ATTEMPTSsaid, andmain_loop()turns the resultingConnectionErrorintoreturn 2.Passing the
Sunsynkrather than its unit reuses the path that already exists: sameread_holding_registers(address, count)signature, plus the retries, the serial flush betweenattempts, and the
timeoutscounter behind the RS485 timeout entity. A failed identity read nowalso logs
[attempt n/m]like every other read, which is what made this hard to spot in user logs.The second commit is needed for the first to report correctly. The
ModbusProtocolErrorbranch inread_holding_registerslogged but never appended toerrs, so a group where every attemptdesynced raised
ValueError: second argument (exceptions) must be a non-empty sequenceinstead ofthe Modbus failure. That is reachable today on a noisy RS485 link, and the identity read now shares
the path.
Scope
Only
Identitymoves onto this path, and it reads holding registers only — the one spaceSunsynkexposes. Flagged with a
ponytail:comment naming that ceiling.Verification
Each test fails with its fix reverted and passes with it:
test_read_identity_retries_dropped_reply—ModbusTimeoutErroron the first attempt,registers on the second; asserts two calls and a decoded serial.
test_ss_protocol_error_raises_exception_group— every attempt aModbusProtocolError; assertsthe group carries them.
ruff check,ruff format --check,zuban check srcandpytestare clean (128 passed).