Skip to content

Apply READ_ATTEMPTS to the identity read - #687

Open
heinrich321 wants to merge 2 commits into
kellerza:mainfrom
heinrich321:fix/identity-read-attempts
Open

Apply READ_ATTEMPTS to the identity read#687
heinrich321 wants to merge 2 commits into
kellerza:mainfrom
heinrich321:fix/identity-read-attempts

Conversation

@heinrich321

Copy link
Copy Markdown

Fixes #686

AInverter.read_identity() built the Identity Component over self.inv.unit, so the registers
0-7 read went straight to the unit and never through Sunsynk.read_holding_registers() — the only
place READ_ATTEMPTS is applied. Nothing in modbus_connection.model retries, so the identity read
got a single attempt whatever READ_ATTEMPTS said, and main_loop() turns the resulting
ConnectionError into return 2.

Passing the Sunsynk rather than its unit reuses the path that already exists: same
read_holding_registers(address, count) signature, plus the retries, the serial flush between
attempts, and the timeouts counter behind the RS485 timeout entity. A failed identity read now
also 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 ModbusProtocolError branch in
read_holding_registers 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. That is reachable today on a noisy RS485 link, and the identity read now shares
the path.

Scope

Only Identity moves onto this path, and it reads holding registers only — the one space Sunsynk
exposes. 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_replyModbusTimeoutError on the first attempt,
    registers on the second; asserts two calls and a decoded serial.
  • test_ss_protocol_error_raises_exception_group — every attempt a ModbusProtocolError; asserts
    the group carries them.

ruff check, ruff format --check, zuban check src and pytest are clean (128 passed).

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]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do you get the modbus ID from? This is part of the unit?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Identity read (regs 0-7) ignores READ_ATTEMPTS, so one dropped reply aborts the add-on

2 participants