Skip to content

Commit e77d124

Browse files
committed
[ankersolix] Detect illegal data address precisely
Distinguish a genuine protocol-level rejection from a general communication failure using openHAB Core's public ModbusSlaveErrorResponseException/ILLEGAL_DATA_ACCESS instead of the previous ONLINE-status heuristic: a device can only respond with this exception if it is actually reachable and explicitly rejected the register, whereas connection/IO failures surface as different exception types. Also use maxTries=1 for optional registers, since retrying a permanently absent register cannot succeed. This reduces the one-time log footprint from openHAB Core from three lines (two WARN, one ERROR) to a single ERROR line, directly followed by the binding's own INFO note explaining the situation. Update tests and README accordingly. Signed-off-by: Thorben Grove <moin@thorbengrove.de>
1 parent 3273710 commit e77d124

3 files changed

Lines changed: 30 additions & 18 deletions

File tree

bundles/org.openhab.binding.modbus.ankersolix/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,4 @@ Number:Power Solarbank_Setpoint "Battery Setpoint [%.0f %unit%]" { channel="modb
254254
1. Setpoint direction seems wrong: Set `battery-power-direction` first, then write `battery-power-setpoint`.
255255
1. Setpoint or direction writes are ignored: Solarbank requires `third_party_control` operating mode. Keep `autoThirdPartyControl` enabled, or set `operating-mode` to `third_party_control` manually before writing.
256256
1. Temporary UI value jumps: Tune `writeProtectionDurationSeconds` to keep write shadow values long enough until the next stable readback.
257-
1. Log shows `ModbusManagerImpl` WARN/ERROR entries for the capability mask register once, then nothing: This is expected, not an error. The device (or its firmware) does not implement the optional capability mask register, openHAB Core logs the rejected read attempt, and the binding then stops polling that register regularly (all channels keep working normally with fail-open defaults). Polling resumes automatically if the device firmware version changes, or when another Anker SOLIX thing is added to the binding (the register is also used for parallel-machine capability negotiation, so it may only start answering once a second unit is paired). A general communication problem (e.g. network outage) does not trigger this, since the binding only stops polling the register while the Thing is otherwise ONLINE and reachable.
257+
1. Log shows a single `ModbusManagerImpl` ERROR entry for the capability mask register once, then nothing: This is expected, not an error. The device (or its firmware) does not implement the optional capability mask register (openHAB Core reports the rejected read as `Illegal Data Access`); the binding does not retry this specific register (so only one log entry appears instead of several) and then stops polling it regularly (all channels keep working normally with fail-open defaults). Polling resumes automatically if the device firmware version changes, or when another Anker SOLIX thing is added to the binding (the register is also used for parallel-machine capability negotiation, so it may only start answering once a second unit is paired). A general communication problem (e.g. network outage, timeout) does not trigger this, since only an explicit protocol-level rejection from the device is treated as "not supported".

bundles/org.openhab.binding.modbus.ankersolix/src/main/java/org/openhab/binding/modbus/ankersolix/internal/AbstractAnkerSolixHandler.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.openhab.core.io.transport.modbus.ModbusRegisterArray;
3232
import org.openhab.core.io.transport.modbus.ModbusWriteRegisterRequestBlueprint;
3333
import org.openhab.core.io.transport.modbus.PollTask;
34+
import org.openhab.core.io.transport.modbus.exception.ModbusSlaveErrorResponseException;
3435
import org.openhab.core.library.types.QuantityType;
3536
import org.openhab.core.library.types.StringType;
3637
import org.openhab.core.library.unit.Units;
@@ -151,8 +152,11 @@ public void dispose() {
151152
}
152153

153154
private void registerPoll(PollRange range, AnkerSolixConfiguration localConfig) {
155+
// optional registers are never worth retrying: a permanently absent register won't start existing on a
156+
// retry, so maxTries=1 keeps the one-time log footprint (from openHAB Core) to a single ERROR line
157+
int maxTries = range.optional() ? 1 : localConfig.maxTries;
154158
ModbusReadRequestBlueprint request = new ModbusReadRequestBlueprint(getSlaveId(), range.functionCode,
155-
range.startAddress, range.length, localConfig.maxTries);
159+
range.startAddress, range.length, maxTries);
156160
PollTask task = registerRegularPoll(request, localConfig.pollInterval, 0,
157161
result -> handleReadSuccess(range, result), failure -> handleReadFailure(range, failure));
158162
activePollTasks.put(range, task);
@@ -204,15 +208,16 @@ private void handleReadSuccess(PollRange range, AsyncModbusReadResult result) {
204208
}
205209

206210
private void handleReadFailure(PollRange range, AsyncModbusFailure<ModbusReadRequestBlueprint> failure) {
207-
String message = String.valueOf(failure.getCause().getMessage());
208-
logger.debug("Failed to read Anker SOLIX registers: {}", message);
211+
Exception cause = failure.getCause();
212+
logger.debug("Failed to read Anker SOLIX registers: {}", cause.getMessage());
209213
if (!range.optional()) {
210-
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
214+
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, cause.getMessage());
211215
return;
212216
}
213-
// only back off while ONLINE: a device that is reachable enough to reject this one register (rather than
214-
// failing to respond at all) has genuinely rejected it, whereas a network/bridge outage fails every range
215-
if (getThing().getStatus() != ThingStatus.ONLINE) {
217+
// only back off on an explicit "illegal data address" protocol response: the device answered and rejected
218+
// this exact register, as opposed to a connection/IO failure where no response was received at all
219+
if (!(cause instanceof ModbusSlaveErrorResponseException responseException)
220+
|| responseException.getExceptionCode() != ModbusSlaveErrorResponseException.ILLEGAL_DATA_ACCESS) {
216221
return;
217222
}
218223
if (backedOffRanges.add(range)) {

bundles/org.openhab.binding.modbus.ankersolix/src/test/java/org/openhab/binding/modbus/ankersolix/internal/AnkerSolixHandlerInternalsTest.java

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.junit.jupiter.api.Test;
3434
import org.openhab.core.io.transport.modbus.AsyncModbusFailure;
3535
import org.openhab.core.io.transport.modbus.ModbusReadRequestBlueprint;
36+
import org.openhab.core.io.transport.modbus.exception.ModbusSlaveErrorResponseException;
3637
import org.openhab.core.library.types.DecimalType;
3738
import org.openhab.core.library.types.OnOffType;
3839
import org.openhab.core.library.types.QuantityType;
@@ -304,7 +305,7 @@ void capabilityMaskReadFailureShouldBackOffRegularPolling() throws Exception {
304305
List<?> pollRanges = invoke(handler, "getPollRanges");
305306
Object capabilityRange = nonNull(pollRanges.get(pollRanges.size() - 1));
306307
AsyncModbusFailure<ModbusReadRequestBlueprint> failure = new AsyncModbusFailure<>(
307-
mock(ModbusReadRequestBlueprint.class), new RuntimeException("Illegal Data Address"));
308+
mock(ModbusReadRequestBlueprint.class), illegalDataAccessException());
308309

309310
invokeVoid(handler, "handleReadFailure", capabilityRange, failure);
310311

@@ -317,20 +318,17 @@ void capabilityMaskReadFailureShouldBackOffRegularPolling() throws Exception {
317318
}
318319

319320
@Test
320-
void capabilityMaskReadFailureShouldNotBackOffWhileThingIsNotOnline() throws Exception {
321-
Thing offlineThing = mock(Thing.class);
322-
when(offlineThing.getStatus()).thenReturn(ThingStatus.OFFLINE);
323-
AbstractAnkerSolixHandler offlineHandler = new AnkerSolixSolarbankHandler(offlineThing);
324-
325-
List<?> pollRanges = invoke(offlineHandler, "getPollRanges");
321+
void capabilityMaskReadFailureShouldNotBackOffOnGeneralCommunicationFailure() throws Exception {
322+
List<?> pollRanges = invoke(handler, "getPollRanges");
326323
Object capabilityRange = nonNull(pollRanges.get(pollRanges.size() - 1));
327324
AsyncModbusFailure<ModbusReadRequestBlueprint> failure = new AsyncModbusFailure<>(
328325
mock(ModbusReadRequestBlueprint.class), new RuntimeException("connection timed out"));
329326

330-
// a general communication failure (bridge/network down) must not be mistaken for register rejection
331-
invokeVoid(offlineHandler, "handleReadFailure", capabilityRange, failure);
327+
// a general communication failure (bridge/network down, timeout, ...) must not be mistaken for an
328+
// explicit register rejection, since no response was ever received from the device
329+
invokeVoid(handler, "handleReadFailure", capabilityRange, failure);
332330

333-
Set<Object> backedOffRanges = getField(offlineHandler, "backedOffRanges");
331+
Set<Object> backedOffRanges = getField(handler, "backedOffRanges");
334332
assertTrue(backedOffRanges.isEmpty());
335333
}
336334

@@ -449,4 +447,13 @@ private static AbstractAnkerSolixHandler newHandler() {
449447
when(thing.getStatus()).thenReturn(ThingStatus.ONLINE);
450448
return new AnkerSolixSolarbankHandler(thing);
451449
}
450+
451+
private static ModbusSlaveErrorResponseException illegalDataAccessException() {
452+
return new ModbusSlaveErrorResponseException() {
453+
@Override
454+
public int getExceptionCode() {
455+
return ModbusSlaveErrorResponseException.ILLEGAL_DATA_ACCESS;
456+
}
457+
};
458+
}
452459
}

0 commit comments

Comments
 (0)