[shelly] Fix addon sensor read errors causing spurious 0 °C spike (#20995)

* [shelly] Fix addon sensor read errors causing spurious 0 °C spike

Signed-off-by: Markus Michels <markus7017@gmail.com>
This commit is contained in:
Markus Michels
2026-06-21 15:04:47 +02:00
committed by GitHub
parent 97b8812de6
commit 43873cf0f3
6 changed files with 389 additions and 68 deletions
@@ -525,11 +525,12 @@ public class Shelly2ApiClient extends ShellyHttpClient implements ShellyDiscover
boolean channelUpdate) throws ShellyApiException {
boolean updated = false;
if (result.temperature0 != null && result.temperature0.tC != null && !getProfile().isSensor) {
Shelly2DeviceStatusTempId temperature0 = result.temperature0;
if (temperature0 != null && temperature0.tC != null && !getProfile().isSensor) {
if (status.tmp == null) {
status.tmp = new ShellySensorTmp();
}
status.temperature = status.tmp.tC = result.temperature0.tC;
status.temperature = status.tmp.tC = temperature0.tC;
}
updated |= updateInputStatus(status, result, channelUpdate);
@@ -597,17 +598,20 @@ public class Shelly2ApiClient extends ShellyHttpClient implements ShellyDiscover
if (rs.timerStartetAt != null && rs.timerStartetAt > 0) {
sr.timerRemaining = (int) (now() - rs.timerStartetAt);
}
if (rs.temperature != null && rs.temperature.tC != null) {
if (status.tmp == null) {
status.tmp = new ShellySensorTmp();
}
status.tmp.isValid = true;
status.tmp.tC = rs.temperature.tC;
status.tmp.tF = rs.temperature.tF;
status.tmp.units = "C";
sr.temperature = rs.temperature.tC;
if (status.temperature == null || rs.temperature.tC > status.temperature) {
status.temperature = sr.temperature;
if (rs.temperature != null) {
Double tC = rs.temperature.tC;
if (tC != null) {
if (status.tmp == null) {
status.tmp = new ShellySensorTmp();
}
status.tmp.isValid = true;
status.tmp.tC = tC;
status.tmp.tF = rs.temperature.tF;
status.tmp.units = "C";
sr.temperature = tC;
if (status.temperature == null || tC > status.temperature) {
status.temperature = sr.temperature;
}
}
}
@@ -686,17 +690,20 @@ public class Shelly2ApiClient extends ShellyHttpClient implements ShellyDiscover
if (bs.output != null) {
sr.ison = rstatus.ison = getBool(bs.output);
}
if (bs.temperature != null && bs.temperature.tC != null) {
if (status.tmp == null) {
status.tmp = new ShellySensorTmp();
}
status.tmp.isValid = true;
status.tmp.tC = bs.temperature.tC;
status.tmp.tF = bs.temperature.tF;
status.tmp.units = "C";
sr.temperature = getDouble(bs.temperature.tC);
if (status.temperature == null || getDouble(bs.temperature.tC) > status.temperature) {
status.temperature = sr.temperature;
if (bs.temperature != null) {
Double tC = bs.temperature.tC;
if (tC != null) {
if (status.tmp == null) {
status.tmp = new ShellySensorTmp();
}
status.tmp.isValid = true;
status.tmp.tC = tC;
status.tmp.tF = bs.temperature.tF;
status.tmp.units = "C";
sr.temperature = getDouble(tC);
if (status.temperature == null || getDouble(tC) > status.temperature) {
status.temperature = sr.temperature;
}
}
}
@@ -1140,31 +1147,58 @@ public class Shelly2ApiClient extends ShellyHttpClient implements ShellyDiscover
}
if (ds.temperature100 != null) {
ShellyExtTemperature extTemp = status.extTemperature;
if (extTemp == null) {
extTemp = new ShellyExtTemperature();
status.extTemperature = extTemp;
}
extTemp.sensor1 = updateExtTempSensor(ds.temperature100);
extTemp.sensor2 = updateExtTempSensor(ds.temperature101);
extTemp.sensor3 = updateExtTempSensor(ds.temperature102);
extTemp.sensor4 = updateExtTempSensor(ds.temperature103);
extTemp.sensor5 = updateExtTempSensor(ds.temperature104);
}
if (ds.humidity100 != null) {
Double rh = ds.humidity100.rh;
if (rh != null) {
status.extHumidity = new ShellyExtHumidity(rh);
ShellyShortTemp s1 = updateExtTempSensor(ds.temperature100);
ShellyShortTemp s2 = updateExtTempSensor(ds.temperature101);
ShellyShortTemp s3 = updateExtTempSensor(ds.temperature102);
ShellyShortTemp s4 = updateExtTempSensor(ds.temperature103);
ShellyShortTemp s5 = updateExtTempSensor(ds.temperature104);
if (s1 != null || s2 != null || s3 != null || s4 != null || s5 != null) {
ShellyExtTemperature extTemp = status.extTemperature;
if (extTemp == null) {
extTemp = new ShellyExtTemperature();
status.extTemperature = extTemp;
}
extTemp.sensor1 = s1;
extTemp.sensor2 = s2;
extTemp.sensor3 = s3;
extTemp.sensor4 = s4;
extTemp.sensor5 = s5;
} else {
// all sensors in this notification reported read errors — clear so
// hasAddon() returns false and sensors#lastUpdate is not written
status.extTemperature = null;
}
}
if (ds.voltmeter100 != null) {
Double voltage = ds.voltmeter100.voltage;
if (voltage != null) {
status.extVoltage = new ShellyExtVoltage(voltage);
Shelly2DeviceStatusHumidity humidity100 = ds.humidity100;
if (humidity100 != null) {
if (hasReadError(humidity100.errors)) {
logger.debug("{}: Addon humidity:100 sensor read error, skipping update", thingName);
status.extHumidity = null;
} else {
Double rh = humidity100.rh;
if (rh != null) {
status.extHumidity = new ShellyExtHumidity(rh);
}
}
}
Shelly2DeviceStatusVoltage voltmeter100 = ds.voltmeter100;
if (voltmeter100 != null) {
if (hasReadError(voltmeter100.errors)) {
logger.debug("{}: Addon voltmeter:100 sensor read error, skipping update", thingName);
status.extVoltage = null;
} else {
Double voltage = voltmeter100.voltage;
if (voltage != null) {
status.extVoltage = new ShellyExtVoltage(voltage);
}
}
}
if (ds.input100 != null) {
if (ds.input100.state != null) {
if (hasReadError(ds.input100.errors)) {
logger.debug("{}: Addon input:100 sensor read error, skipping update", thingName);
status.extDigitalInput = null;
status.extAnalogInput = null;
} else if (ds.input100.state != null) {
status.extDigitalInput = new ShellyExtDigitalInput(getBool(ds.input100.state));
} else if (ds.input100.percent != null) {
status.extAnalogInput = new ShellyExtAnalogInput(getDouble(ds.input100.percent));
@@ -1173,15 +1207,25 @@ public class Shelly2ApiClient extends ShellyHttpClient implements ShellyDiscover
}
private @Nullable ShellyShortTemp updateExtTempSensor(@Nullable Shelly2DeviceStatusTempId value) {
if (value != null) {
ShellyShortTemp temp = new ShellyShortTemp();
Integer idBox = value.id;
temp.hwID = idBox != null ? idBox.toString() : "999";
temp.tC = getDouble(value.tC);
temp.tF = getDouble(value.tF);
return temp;
if (value == null) {
return null;
}
return null;
if (hasReadError(value.errors)) {
Integer idBox = value.id;
logger.debug("{}: Addon temperature:{} sensor read error, skipping update", thingName,
idBox != null ? idBox : "?");
return null;
}
ShellyShortTemp temp = new ShellyShortTemp();
Integer idBox = value.id;
temp.hwID = idBox != null ? idBox.toString() : "999";
temp.tC = getDouble(value.tC);
temp.tF = getDouble(value.tF);
return temp;
}
private static boolean hasReadError(@Nullable ArrayList<String> errors) {
return errors != null && errors.contains("read");
}
protected void updateHumidityStatus(ShellyStatusSensor sdata, @Nullable Shelly2DeviceStatusHumidity value) {
@@ -598,7 +598,7 @@ public class Shelly2ApiJsonDTO {
public Integer id;
public Boolean state;
public Double percent; // analog input only
public ArrayList<String> errors;// shown only if at least one error is present.
public @Nullable ArrayList<String> errors;
public Double xpercent;
public Shelly2InputCounts counts;
public Double freq;
@@ -655,6 +655,7 @@ public class Shelly2ApiJsonDTO {
public class Shelly2DeviceStatusHumidity {
public @Nullable Integer id;
public @Nullable Double rh;
public @Nullable ArrayList<String> errors;
}
public class Shelly2DeviceStatusIlluminance {
@@ -666,6 +667,7 @@ public class Shelly2ApiJsonDTO {
public class Shelly2DeviceStatusVoltage {
public @Nullable Integer id;
public @Nullable Double voltage;
public @Nullable ArrayList<String> errors;
}
public class Shelly2DeviceStatusTempId extends Shelly2DeviceStatusTemp {
@@ -834,22 +836,22 @@ public class Shelly2ApiJsonDTO {
public Shelly2DeviceStatusLight light1;
@SerializedName("temperature:0")
public Shelly2DeviceStatusTempId temperature0;
public @Nullable Shelly2DeviceStatusTempId temperature0;
@SerializedName("temperature:100")
public Shelly2DeviceStatusTempId temperature100;
public @Nullable Shelly2DeviceStatusTempId temperature100;
@SerializedName("temperature:101")
public Shelly2DeviceStatusTempId temperature101;
public @Nullable Shelly2DeviceStatusTempId temperature101;
@SerializedName("temperature:102")
public Shelly2DeviceStatusTempId temperature102;
public @Nullable Shelly2DeviceStatusTempId temperature102;
@SerializedName("temperature:103")
public Shelly2DeviceStatusTempId temperature103;
public @Nullable Shelly2DeviceStatusTempId temperature103;
@SerializedName("temperature:104")
public Shelly2DeviceStatusTempId temperature104;
public @Nullable Shelly2DeviceStatusTempId temperature104;
@SerializedName("humidity:0")
public Shelly2DeviceStatusHumidity humidity0;
public @Nullable Shelly2DeviceStatusHumidity humidity0;
@SerializedName("humidity:100")
public Shelly2DeviceStatusHumidity humidity100;
public @Nullable Shelly2DeviceStatusHumidity humidity100;
@SerializedName("illuminance:0")
Shelly2DeviceStatusIlluminance illuminance0;
@@ -858,15 +860,15 @@ public class Shelly2ApiJsonDTO {
public Shelly2DeviceStatusSmoke smoke0;
@SerializedName("voltmeter:0")
public Shelly2DeviceStatusVoltage voltmeter0;
public @Nullable Shelly2DeviceStatusVoltage voltmeter0;
@SerializedName("voltmeter:1")
public Shelly2DeviceStatusVoltage voltmeter1;
public @Nullable Shelly2DeviceStatusVoltage voltmeter1;
@SerializedName("voltmeter:2")
public Shelly2DeviceStatusVoltage voltmeter2;
public @Nullable Shelly2DeviceStatusVoltage voltmeter2;
@SerializedName("voltmeter:3")
public Shelly2DeviceStatusVoltage voltmeter3;
public @Nullable Shelly2DeviceStatusVoltage voltmeter3;
@SerializedName("voltmeter:100")
public Shelly2DeviceStatusVoltage voltmeter100;
public @Nullable Shelly2DeviceStatusVoltage voltmeter100;
@SerializedName("devicepower:0")
public Shelly2DeviceStatusPower devicepower0;
@@ -988,8 +990,9 @@ public class Shelly2ApiJsonDTO {
}
public static class Shelly2DeviceStatusTemp {
public Double tC;
public Double tF;
public @Nullable Double tC;
public @Nullable Double tF;
public @Nullable ArrayList<String> errors;
}
public static class Shelly2Energy {
@@ -0,0 +1,271 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.shelly.internal.api2;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import static org.openhab.binding.shelly.internal.ShellyDevices.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.openhab.binding.shelly.internal.api.ShellyApiException;
import org.openhab.binding.shelly.internal.api.ShellyDeviceProfile;
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellySettingsStatus;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceStatus.Shelly2DeviceStatusResult;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceStatusTemp;
import org.openhab.binding.shelly.internal.config.ShellyApiConfiguration;
import org.openhab.binding.shelly.internal.config.ShellyBindingConfiguration;
import org.openhab.binding.shelly.internal.config.ShellyBindingRuntimeConfig;
import org.openhab.binding.shelly.internal.handler.ShellyThingInterface;
import org.openhab.core.net.NetworkAddressChangeListener;
import org.openhab.core.net.NetworkAddressService;
import com.google.gson.Gson;
/**
* Tests for the Gen2 addon sensor read-error path in {@link Shelly2ApiClient}.
*
* <p>
* Verifies that when a DS18B20 or AM2301 addon sensor reports {@code "errors":["read"]} (firmware
* sends {@code "tC":null} alongside the error), the binding:
* <ul>
* <li>correctly deserialises the {@code errors} array from the DTO,</li>
* <li>leaves {@link ShellySettingsStatus#extTemperature} / {@code extHumidity} / {@code extVoltage}
* null so that no channel update is emitted and {@code sensors#lastUpdate} is not advanced.</li>
* </ul>
*
* @author Markus Michels - Initial contribution
*/
@NonNullByDefault
@SuppressWarnings({ "null" })
public class Shelly2AddonStatusTest {
private static final String LOCAL_IP = "192.168.1.50";
private static final String DEVICE_IP = "192.168.1.100";
// ── helpers ──────────────────────────────────────────────────────────────────
private ShellyApiConfiguration testConfig() {
ShellyBindingConfiguration raw = ShellyBindingConfiguration
.fromProperties(Map.of(ShellyBindingConfiguration.CONFIG_LOCAL_IP, LOCAL_IP));
ShellyBindingRuntimeConfig bindingConfig = new ShellyBindingRuntimeConfig(raw, 8080, nullNas());
return new ShellyApiConfiguration(bindingConfig, "test-realm", DEVICE_IP);
}
private static NetworkAddressService nullNas() {
return new NetworkAddressService() {
@Override
public @Nullable String getPrimaryIpv4HostAddress() {
return null;
}
@Override
public @Nullable String getConfiguredBroadcastAddress() {
return null;
}
@Override
public boolean isUseOnlyOneAddress() {
return false;
}
@Override
public boolean isUseIPv6() {
return false;
}
@Override
public void addNetworkAddressChangeListener(NetworkAddressChangeListener listener) {
}
@Override
public void removeNetworkAddressChangeListener(NetworkAddressChangeListener listener) {
}
};
}
private ShellyThingInterface mockRelayThing() {
ShellyDeviceProfile profile = new ShellyDeviceProfile(THING_TYPE_SHELLYPLUS1PM);
profile.isSensor = false;
profile.hasBattery = false;
ShellyThingInterface handler = mock(ShellyThingInterface.class);
when(handler.getProfile()).thenReturn(profile);
when(handler.areChannelsCreated()).thenReturn(false);
when(handler.updateChannel(anyString(), anyString(), any())).thenReturn(false);
return handler;
}
/**
* Testable subclass of {@link Shelly2ApiClient} that injects a mock {@link ShellyThingInterface}
* and exposes the protected {@link #fillDeviceStatus} for unit testing.
*/
private static class TestableApiClient extends Shelly2ApiClient {
TestableApiClient(ShellyApiConfiguration config, ShellyThingInterface thing) {
super("test-thing", config, Mockito.mock(HttpClient.class));
this.thing = thing;
}
@Override
public <T> T apiRequest(String method, @Nullable Object params, Class<T> classOfT) throws ShellyApiException {
throw new ShellyApiException("Not expected in addon test: " + method);
}
public boolean testFillDeviceStatus(ShellySettingsStatus status, Shelly2DeviceStatusResult result)
throws ShellyApiException {
return fillDeviceStatus(status, result, false);
}
}
private TestableApiClient newClient() {
return new TestableApiClient(testConfig(), mockRelayThing());
}
// ── DTO parsing ───────────────────────────────────────────────────────────────
@Test
void temperatureStatusTemp_readError_parsesErrorsFieldAndNullTc() {
Gson gson = new Gson();
Shelly2DeviceStatusTemp t = Objects
.requireNonNull(gson.fromJson("{\"tC\":null,\"errors\":[\"read\"]}", Shelly2DeviceStatusTemp.class));
assertThat(t.tC, is(nullValue()));
assertThat(t.errors, is(not(nullValue())));
assertThat(t.errors.contains("read"), is(true));
}
@Test
void temperatureStatusTemp_normalReading_noErrorsField() {
Gson gson = new Gson();
Shelly2DeviceStatusTemp t = Objects
.requireNonNull(gson.fromJson("{\"tC\":22.5,\"tF\":72.5}", Shelly2DeviceStatusTemp.class));
assertThat(t.tC, is(22.5));
assertThat(t.errors, is(nullValue()));
}
// ── fillDeviceStatus — temperature ────────────────────────────────────────────
@Test
void fillDeviceStatus_tempReadError_extTemperatureNull() throws ShellyApiException {
Shelly2DeviceStatusResult result = new Shelly2DeviceStatusResult();
result.temperature100 = result.new Shelly2DeviceStatusTempId();
result.temperature100.id = 100;
result.temperature100.errors = new ArrayList<>(List.of("read"));
ShellySettingsStatus status = new ShellySettingsStatus();
newClient().testFillDeviceStatus(status, result);
assertThat("sensor read error must leave extTemperature null", status.extTemperature, is(nullValue()));
}
@Test
void fillDeviceStatus_tempValid_extTemperaturePopulated() throws ShellyApiException {
Shelly2DeviceStatusResult result = new Shelly2DeviceStatusResult();
result.temperature100 = result.new Shelly2DeviceStatusTempId();
result.temperature100.id = 100;
result.temperature100.tC = 22.5;
ShellySettingsStatus status = new ShellySettingsStatus();
newClient().testFillDeviceStatus(status, result);
assertThat("valid temp must set extTemperature", status.extTemperature, is(not(nullValue())));
assertThat(status.extTemperature.sensor1, is(not(nullValue())));
assertThat(status.extTemperature.sensor1.tC, is(22.5));
}
@Test
void fillDeviceStatus_mixedSensors_errorSlotNullValidSlotSet() throws ShellyApiException {
Shelly2DeviceStatusResult result = new Shelly2DeviceStatusResult();
// sensor 100 errors — sensor 101 is valid
result.temperature100 = result.new Shelly2DeviceStatusTempId();
result.temperature100.id = 100;
result.temperature100.errors = new ArrayList<>(List.of("read"));
result.temperature101 = result.new Shelly2DeviceStatusTempId();
result.temperature101.id = 101;
result.temperature101.tC = 18.0;
ShellySettingsStatus status = new ShellySettingsStatus();
newClient().testFillDeviceStatus(status, result);
// extTemperature must be set because sensor101 is valid
assertThat("at least one valid sensor must set extTemperature", status.extTemperature, is(not(nullValue())));
assertThat("errored slot must be null", status.extTemperature.sensor1, is(nullValue()));
assertThat("valid slot must be set", status.extTemperature.sensor2, is(not(nullValue())));
assertThat(status.extTemperature.sensor2.tC, is(18.0));
}
// ── fillDeviceStatus — humidity ───────────────────────────────────────────────
@Test
void fillDeviceStatus_humidityReadError_extHumidityNull() throws ShellyApiException {
Shelly2DeviceStatusResult result = new Shelly2DeviceStatusResult();
result.humidity100 = result.new Shelly2DeviceStatusHumidity();
result.humidity100.id = 100;
result.humidity100.errors = new ArrayList<>(List.of("read"));
ShellySettingsStatus status = new ShellySettingsStatus();
newClient().testFillDeviceStatus(status, result);
assertThat("humidity read error must leave extHumidity null", status.extHumidity, is(nullValue()));
}
@Test
void fillDeviceStatus_humidityValid_extHumidityPopulated() throws ShellyApiException {
Shelly2DeviceStatusResult result = new Shelly2DeviceStatusResult();
result.humidity100 = result.new Shelly2DeviceStatusHumidity();
result.humidity100.id = 100;
result.humidity100.rh = 55.0;
ShellySettingsStatus status = new ShellySettingsStatus();
newClient().testFillDeviceStatus(status, result);
assertThat("valid humidity must set extHumidity", status.extHumidity, is(not(nullValue())));
}
// ── fillDeviceStatus — voltage ────────────────────────────────────────────────
@Test
void fillDeviceStatus_voltageReadError_extVoltageNull() throws ShellyApiException {
Shelly2DeviceStatusResult result = new Shelly2DeviceStatusResult();
result.voltmeter100 = result.new Shelly2DeviceStatusVoltage();
result.voltmeter100.id = 100;
result.voltmeter100.errors = new ArrayList<>(List.of("read"));
ShellySettingsStatus status = new ShellySettingsStatus();
newClient().testFillDeviceStatus(status, result);
assertThat("voltage read error must leave extVoltage null", status.extVoltage, is(nullValue()));
}
@Test
void fillDeviceStatus_voltageValid_extVoltagePopulated() throws ShellyApiException {
Shelly2DeviceStatusResult result = new Shelly2DeviceStatusResult();
result.voltmeter100 = result.new Shelly2DeviceStatusVoltage();
result.voltmeter100.id = 100;
result.voltmeter100.voltage = 3.3;
ShellySettingsStatus status = new ShellySettingsStatus();
newClient().testFillDeviceStatus(status, result);
assertThat("valid voltage must set extVoltage", status.extVoltage, is(not(nullValue())));
}
}
@@ -45,6 +45,7 @@ import org.openhab.core.thing.ThingTypeUID;
* @author Markus Michels - Initial contribution
*/
@NonNullByDefault
@SuppressWarnings({ "null" })
public class ShellyBluApiTest {
// ── getStatus() connection guard ─────────────────────────────────────────
@@ -48,6 +48,7 @@ import org.openhab.core.types.UnDefType;
* @author Markus Michels - Initial contribution
*/
@NonNullByDefault
@SuppressWarnings({ "null" })
public class ShellyComponentsTest {
// ── hasAddon ─────────────────────────────────────────────────────────────
@@ -35,6 +35,7 @@ import org.junit.jupiter.api.Test;
* @author Markus Michels - Initial contribution
*/
@NonNullByDefault
@SuppressWarnings({ "null" })
public class ShellyThingTableTest {
private static final String GATEWAY_IP = "192.168.1.100";