Affected area
Battery communication or availability
Omnibattery version
release/v1.5.0 (89d3a96) plus #500, built locally as 1.5.0-blockread.1. The code path below is unchanged since block reads were introduced in #361.
Home Assistant version
2026.9.3 (HA OS 18.3)
Installation details
Marstek Venus D, EMS v150, seven packs, Modbus TCP. Measured both through a Modbus proxy and against the battery directly; the behaviour is the same, the proxy only changes how fast a refusal comes back.
What happened?
When a block read fails, its members are dropped from the snapshot and no attempt is made to read them individually. In drivers/marstek.py:
pending.difference_update(member_keys)
regs = await self._client.async_read_block(...)
if regs is None:
continue # <- members are gone from `pending` already
The keys leave pending before the request is sent, so the per-register loop below never sees them. One refused span therefore takes every register in it out of that cycle, and the only trace is a generic Modbus read error at register N, which does not say which entities just went without a value.
Today this is mostly invisible, because the spans in the table are known-good on the models that have them. It stops being invisible the moment a span covers an address a given device does not implement — and it is what makes an otherwise obvious optimisation unusable, which is how I ran into it.
I measured bridging small gaps on the Venus D, since the tables only ever group registers that are already adjacent:
|
fast schedule |
full pass |
values returned |
| no gap |
11 requests |
41 |
57 |
| gap of 2 |
11 requests |
38 |
57 |
| gap of 10 |
9 requests |
35 |
53 |
With a gap of 10, four of the five new spans answer fine:
30001+6 high battery_power, ac_power answered in 360 ms
30200+5 very_low ems/vms/bms_version answered in 163 ms
30300+4 low wifi_status, cloud_status, signal answered in 300 ms
34003+7 low battery_cycle_count, balancing_mode answered in 299 ms
42010+12 high force_mode, charge_to_soc,
set_charge_power, set_discharge_power REFUSED after 503 ms
Some address between 42012 and 42019 is not implemented, so the whole span is refused — and that span carries the four control registers. They simply vanish from every fast cycle, silently, and the two requests the gap "saves" are exactly the ones whose answers are lost.
Steps to reproduce
- Point the Marstek driver at a battery and block-read a span that covers an address the firmware does not implement (on a Venus D:
42010, 12 registers).
- Request all of that span's keys in one
read_telemetry call, the way a read group does.
- The returned snapshot contains none of them,
async_read_register is never called for them, and nothing in the log names the keys that were dropped.
Expected behavior
A block is a way to fetch registers that would otherwise be fetched one at a time, so a failed block should cost no more than the block read itself: fall back to reading its members individually. Failing that, log at warning which keys were dropped, so it is visible rather than silent.
One caveat from trying it: a blanket fallback is not free. regs is None also covers a connection outage, and then the fallback fires N doomed requests per cycle at the weakest Modbus stack in the supported range — which is the thing #361 set out to relieve. It probably needs to tell a refusal (the device answered, with an exception) from a timeout, and to remember a span that was refused rather than retrying it forever.
Relevant Home Assistant logs
Modbus read error at register 42010 (0xA41A) on attempt 1
That is the whole trace. Nothing says that force_mode, charge_to_soc, set_charge_power and set_discharge_power are missing from this cycle.
Additional context
Not a regression, and #500 does not introduce it: the derived table still only groups registers that are already adjacent, all of which would be read — and refused — individually anyway. I am filing it because the measurement above is worth recording either way, so nobody else spends a night finding out that gap bridging looks free and is not, and because the silent drop is worth fixing on its own terms.
Nothing of this is in #500 — that one changes which registers get grouped, not what happens when a group is refused. Happy to prepare a separate PR for it, once it is clear which of the two shapes above you would rather have.
Confirmations
Affected area
Battery communication or availability
Omnibattery version
release/v1.5.0(89d3a96) plus #500, built locally as 1.5.0-blockread.1. The code path below is unchanged since block reads were introduced in #361.Home Assistant version
2026.9.3 (HA OS 18.3)
Installation details
Marstek Venus D, EMS v150, seven packs, Modbus TCP. Measured both through a Modbus proxy and against the battery directly; the behaviour is the same, the proxy only changes how fast a refusal comes back.
What happened?
When a block read fails, its members are dropped from the snapshot and no attempt is made to read them individually. In
drivers/marstek.py:The keys leave
pendingbefore the request is sent, so the per-register loop below never sees them. One refused span therefore takes every register in it out of that cycle, and the only trace is a genericModbus read error at register N, which does not say which entities just went without a value.Today this is mostly invisible, because the spans in the table are known-good on the models that have them. It stops being invisible the moment a span covers an address a given device does not implement — and it is what makes an otherwise obvious optimisation unusable, which is how I ran into it.
I measured bridging small gaps on the Venus D, since the tables only ever group registers that are already adjacent:
With a gap of 10, four of the five new spans answer fine:
Some address between 42012 and 42019 is not implemented, so the whole span is refused — and that span carries the four control registers. They simply vanish from every fast cycle, silently, and the two requests the gap "saves" are exactly the ones whose answers are lost.
Steps to reproduce
42010, 12 registers).read_telemetrycall, the way a read group does.async_read_registeris never called for them, and nothing in the log names the keys that were dropped.Expected behavior
A block is a way to fetch registers that would otherwise be fetched one at a time, so a failed block should cost no more than the block read itself: fall back to reading its members individually. Failing that, log at warning which keys were dropped, so it is visible rather than silent.
One caveat from trying it: a blanket fallback is not free.
regs is Nonealso covers a connection outage, and then the fallback fires N doomed requests per cycle at the weakest Modbus stack in the supported range — which is the thing #361 set out to relieve. It probably needs to tell a refusal (the device answered, with an exception) from a timeout, and to remember a span that was refused rather than retrying it forever.Relevant Home Assistant logs
That is the whole trace. Nothing says that
force_mode,charge_to_soc,set_charge_powerandset_discharge_powerare missing from this cycle.Additional context
Not a regression, and #500 does not introduce it: the derived table still only groups registers that are already adjacent, all of which would be read — and refused — individually anyway. I am filing it because the measurement above is worth recording either way, so nobody else spends a night finding out that gap bridging looks free and is not, and because the silent drop is worth fixing on its own terms.
Nothing of this is in #500 — that one changes which registers get grouped, not what happens when a group is refused. Happy to prepare a separate PR for it, once it is clear which of the two shapes above you would rather have.
Confirmations