[boschshc] Support TemperatureOffset service for thermostats (#20139)

* [boschshc] Support TemperatureOffset service for thermostats

- add new channel definition
- re-generate i18n properties
- add migration metadata
- add new service implementation
- register service in command extension
- add new service to abstract handler implementation
- add unit tests

Signed-off-by: David Pace <dev@davidpace.de>
This commit is contained in:
David Pace
2026-02-14 12:56:06 +01:00
committed by GitHub
parent 16345d12b4
commit c910d6994a
16 changed files with 507 additions and 45 deletions
+29 -26
View File
@@ -236,14 +236,15 @@ First generation radiator thermostat.
**Thing Type ID**: `thermostat`
| Channel ID | Item Type | Writable | Description |
| --------------------- | -------------------- | :------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| temperature | Number:Temperature | no | Current measured temperature. |
| valve-tappet-position | Number:Dimensionless | no | Current open ratio of valve tappet (0 to 100). |
| child-lock | Switch | yes | Indicates if child lock is active. |
| silent-mode | Switch | yes | Enables or disables silent mode on thermostats. When enabled, the battery usage is higher. |
| battery-level | Number | no | Current battery level percentage as integer number. Bosch-specific battery levels are mapped to numbers as follows: `OK`: 100, `LOW_BATTERY`: 10, `CRITICAL_LOW`: 1, `CRITICALLY_LOW_BATTERY`: 1, `NOT_AVAILABLE`: `UNDEF`. |
| low-battery | Switch | no | Indicates whether the battery is low (`ON`) or OK (`OFF`). |
| Channel ID | Item Type | Writable | Description |
| --------------------- | -------------------- | :------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| temperature | Number:Temperature | no | Current measured temperature. |
| temperature-offset | Number:Temperature | yes | Temperature offset to be added or subtracted from the measured temperature. Value range for Celsius and Kelvin quantities: minimum -5°C / -5 K, maximum +5°C / +5 K. Value range for Fahrenheit quantities: minimum -9°F, maximum +9°F. Step size: 0.1°C. Non-Celsius temperature offsets will be converted to Celsius deltas automatically and rounded to the first decimal digit. |
| valve-tappet-position | Number:Dimensionless | no | Current open ratio of valve tappet (0 to 100). |
| child-lock | Switch | yes | Indicates if child lock is active. |
| silent-mode | Switch | yes | Enables or disables silent mode on thermostats. When enabled, the battery usage is higher. |
| battery-level | Number | no | Current battery level percentage as integer number. Bosch-specific battery levels are mapped to numbers as follows: `OK`: 100, `LOW_BATTERY`: 10, `CRITICAL_LOW`: 1, `CRITICALLY_LOW_BATTERY`: 1, `NOT_AVAILABLE`: `UNDEF`. |
| low-battery | Switch | no | Indicates whether the battery is low (`ON`) or OK (`OFF`). |
### Radiator Thermostat II
@@ -251,15 +252,16 @@ Second generation radiator thermostat.
**Thing Type ID**: `thermostat-2`
| Channel ID | Item Type | Writable | Description |
| --------------------- | -------------------- | :------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| temperature | Number:Temperature | no | Current measured temperature. |
| valve-tappet-position | Number:Dimensionless | no | Current open ratio of valve tappet (0 to 100). |
| child-lock | Switch | yes | Indicates if child lock is active. |
| battery-level | Number | no | Current battery level percentage as integer number. Bosch-specific battery levels are mapped to numbers as follows: `OK`: 100, `LOW_BATTERY`: 10, `CRITICAL_LOW`: 1, `CRITICALLY_LOW_BATTERY`: 1, `NOT_AVAILABLE`: `UNDEF`. |
| low-battery | Switch | no | Indicates whether the battery is low (`ON`) or OK (`OFF`). |
| display-direction | Switch | yes | Configures the display direction. `OFF` corresponds to `NORMAL` and `ON` corresponds to `REVERSED`. |
| displayed-temperature | Switch | yes | Configures whether the measured temperature or the setpoint temperature is displayed. `OFF` corresponds to `MEASURED` and `ON` corresponds to `SETPOINT`. |
| Channel ID | Item Type | Writable | Description |
| --------------------- | -------------------- | :------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| temperature | Number:Temperature | no | Current measured temperature. |
| temperature-offset | Number:Temperature | yes | Temperature offset to be added or subtracted from the measured temperature. Value range for Celsius and Kelvin quantities: minimum -5°C / -5 K, maximum +5°C / +5 K. Value range for Fahrenheit quantities: minimum -9°F, maximum +9°F. Step size: 0.1°C. Non-Celsius temperature offsets will be converted to Celsius deltas automatically and rounded to the first decimal digit. |
| valve-tappet-position | Number:Dimensionless | no | Current open ratio of valve tappet (0 to 100). |
| child-lock | Switch | yes | Indicates if child lock is active. |
| battery-level | Number | no | Current battery level percentage as integer number. Bosch-specific battery levels are mapped to numbers as follows: `OK`: 100, `LOW_BATTERY`: 10, `CRITICAL_LOW`: 1, `CRITICALLY_LOW_BATTERY`: 1, `NOT_AVAILABLE`: `UNDEF`. |
| low-battery | Switch | no | Indicates whether the battery is low (`ON`) or OK (`OFF`). |
| display-direction | Switch | yes | Configures the display direction. `OFF` corresponds to `NORMAL` and `ON` corresponds to `REVERSED`. |
| displayed-temperature | Switch | yes | Configures whether the measured temperature or the setpoint temperature is displayed. `OFF` corresponds to `MEASURED` and `ON` corresponds to `SETPOINT`. |
### Radiator Thermostat II [+M]<a id="radiator-thermostat-ii-m"></a>
@@ -267,15 +269,16 @@ Second generation radiator thermostat with Matter support.
**Thing Type ID**: `thermostat-2-matter`
| Channel ID | Item Type | Writable | Description |
| --------------------- | -------------------- | :------: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| temperature | Number:Temperature | no | Current measured temperature. |
| valve-tappet-position | Number:Dimensionless | no | Current open ratio of valve tappet (0 to 100). |
| child-lock | Switch | yes | Indicates if child lock is active. |
| battery-level | Number | no | Current battery level percentage as integer number. Bosch-specific battery levels are mapped to numbers as follows: `OK`: 100, `LOW_BATTERY`: 10, `CRITICAL_LOW`: 1, `CRITICALLY_LOW_BATTERY`: 1, `NOT_AVAILABLE`: `UNDEF`. |
| low-battery | Switch | no | Indicates whether the battery is low (`ON`) or OK (`OFF`). |
| display-direction | Switch | yes | Configures the display direction. `OFF` corresponds to `NORMAL` and `ON` corresponds to `REVERSED`. |
| displayed-temperature | Switch | yes | Configures whether the measured temperature or the setpoint temperature is displayed. `OFF` corresponds to `MEASURED` and `ON` corresponds to `SETPOINT`. |
| Channel ID | Item Type | Writable | Description |
| --------------------- | -------------------- | :------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| temperature | Number:Temperature | no | Current measured temperature. |
| temperature-offset | Number:Temperature | yes | Temperature offset to be added or subtracted from the measured temperature. Value range for Celsius and Kelvin quantities: minimum -5°C / -5 K, maximum +5°C / +5 K. Value range for Fahrenheit quantities: minimum -9°F, maximum +9°F. Step size: 0.1°C. Non-Celsius temperature offsets will be converted to Celsius deltas automatically and rounded to the first decimal digit. |
| valve-tappet-position | Number:Dimensionless | no | Current open ratio of valve tappet (0 to 100). |
| child-lock | Switch | yes | Indicates if child lock is active. |
| battery-level | Number | no | Current battery level percentage as integer number. Bosch-specific battery levels are mapped to numbers as follows: `OK`: 100, `LOW_BATTERY`: 10, `CRITICAL_LOW`: 1, `CRITICALLY_LOW_BATTERY`: 1, `NOT_AVAILABLE`: `UNDEF`. |
| low-battery | Switch | no | Indicates whether the battery is low (`ON`) or OK (`OFF`). |
| display-direction | Switch | yes | Configures the display direction. `OFF` corresponds to `NORMAL` and `ON` corresponds to `REVERSED`. |
| displayed-temperature | Switch | yes | Configures whether the measured temperature or the setpoint temperature is displayed. `OFF` corresponds to `MEASURED` and `ON` corresponds to `SETPOINT`. |
### Climate Control
@@ -86,8 +86,8 @@ public class BoschShcCommandExtension extends AbstractConsoleCommandExtension im
"displayedtemperatureconfiguration", "hsbcoloractuator", "humiditylevel", "illuminance",
"impulseswitch", "intrusion", "keypad", "latestmotion", "multilevelswitch", "powermeter", "powerswitch",
"presence", "privacymode", "roomclimatecontrol", "shuttercontact", "shuttercontrol", "silentmode",
"smokedetectorcheck", "temperaturelevel", "userstate", "valvetappet", "vibrationsensor",
"waterleakagesensor", "waterleakagesensorcheck", "waterleakagesensortilt");
"smokedetectorcheck", "temperaturelevel", "temperatureoffset", "userstate", "valvetappet",
"vibrationsensor", "waterleakagesensor", "waterleakagesensorcheck", "waterleakagesensortilt");
}
@Override
@@ -79,6 +79,7 @@ public class BoschSHCBindingConstants {
public static final String CHANNEL_TRIGGER_SCENARIO = "trigger-scenario";
public static final String CHANNEL_POWER_SWITCH = "power-switch";
public static final String CHANNEL_TEMPERATURE = "temperature";
public static final String CHANNEL_TEMPERATURE_OFFSET = "temperature-offset";
public static final String CHANNEL_TEMPERATURE_RATING = "temperature-rating";
public static final String CHANNEL_HUMIDITY = "humidity";
public static final String CHANNEL_HUMIDITY_RATING = "humidity-rating";
@@ -402,7 +402,7 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
TService service, TState state) {
try {
service.setState(state);
} catch (TimeoutException | ExecutionException e) {
} catch (TimeoutException | ExecutionException | BoschSHCException e) {
this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, String.format(
"Error while trying to update state for service %s: %s", service.getServiceName(), e.getMessage()));
} catch (InterruptedException e) {
@@ -943,8 +943,8 @@ public class BridgeHandler extends BaseBridgeHandler {
* @throws ExecutionException
* @throws TimeoutException
*/
public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
throws InterruptedException, TimeoutException, ExecutionException {
public <T extends BoschSHCServiceState> @Nullable ContentResponse putState(String deviceId, String serviceName,
T state) throws InterruptedException, TimeoutException, ExecutionException {
return sendState(deviceId, serviceName, state, PUT);
}
@@ -963,13 +963,13 @@ public class BridgeHandler extends BaseBridgeHandler {
* @throws TimeoutException
* @throws ExecutionException
*/
public <T extends BoschSHCServiceState> @Nullable Response postState(String deviceId, String serviceName, T state)
throws InterruptedException, TimeoutException, ExecutionException {
public <T extends BoschSHCServiceState> @Nullable ContentResponse postState(String deviceId, String serviceName,
T state) throws InterruptedException, TimeoutException, ExecutionException {
return sendState(deviceId, serviceName, state, POST);
}
private <T extends BoschSHCServiceState> @Nullable Response sendState(String deviceId, String serviceName, T state,
HttpMethod method) throws InterruptedException, TimeoutException, ExecutionException {
private <T extends BoschSHCServiceState> @Nullable ContentResponse sendState(String deviceId, String serviceName,
T state, HttpMethod method) throws InterruptedException, TimeoutException, ExecutionException {
@Nullable
BoschHttpClient localHttpClient = this.httpClient;
if (localHttpClient == null) {
@@ -23,6 +23,8 @@ import org.openhab.binding.boschshc.internal.services.childlock.ChildLockService
import org.openhab.binding.boschshc.internal.services.childlock.dto.ChildLockServiceState;
import org.openhab.binding.boschshc.internal.services.temperaturelevel.TemperatureLevelService;
import org.openhab.binding.boschshc.internal.services.temperaturelevel.dto.TemperatureLevelServiceState;
import org.openhab.binding.boschshc.internal.services.temperatureoffset.TemperatureOffsetService;
import org.openhab.binding.boschshc.internal.services.temperatureoffset.dto.TemperatureOffsetServiceState;
import org.openhab.binding.boschshc.internal.services.valvetappet.ValveTappetService;
import org.openhab.binding.boschshc.internal.services.valvetappet.dto.ValveTappetServiceState;
import org.openhab.core.thing.ChannelUID;
@@ -46,10 +48,12 @@ import org.openhab.core.types.Command;
public abstract class AbstractThermostatHandler extends AbstractBatteryPoweredDeviceHandler {
private ChildLockService childLockService;
private TemperatureOffsetService temperatureOffsetService;
protected AbstractThermostatHandler(Thing thing) {
super(thing);
this.childLockService = new ChildLockService();
this.temperatureOffsetService = new TemperatureOffsetService();
}
@Override
@@ -60,6 +64,7 @@ public abstract class AbstractThermostatHandler extends AbstractBatteryPoweredDe
this.createService(ValveTappetService::new, this::updateChannels, List.of(CHANNEL_VALVE_TAPPET_POSITION));
this.registerService(this.childLockService, this::updateChannels, List.of(CHANNEL_CHILD_LOCK));
this.registerService(this.temperatureOffsetService, this::updateChannels, List.of(CHANNEL_TEMPERATURE_OFFSET));
}
@Override
@@ -68,6 +73,8 @@ public abstract class AbstractThermostatHandler extends AbstractBatteryPoweredDe
if (CHANNEL_CHILD_LOCK.equals(channelUID.getId())) {
this.handleServiceCommand(this.childLockService, command);
} else if (CHANNEL_TEMPERATURE_OFFSET.equals(channelUID.getId())) {
this.handleServiceCommand(this.temperatureOffsetService, command);
}
}
@@ -75,17 +82,30 @@ public abstract class AbstractThermostatHandler extends AbstractBatteryPoweredDe
* Updates the channels which are linked to the {@link TemperatureLevelService}
* of the device.
*
* @param state Current state of {@link TemperatureLevelService}.
* @param state current {@link TemperatureLevelServiceState} received from the
* Smart Home Controller
*/
private void updateChannels(TemperatureLevelServiceState state) {
super.updateState(CHANNEL_TEMPERATURE, state.getTemperatureState());
}
/**
* Updates the channels which are linked to the {@link TemperatureOffsetService}
* of the device.
*
* @param state current {@link TemperatureOffsetServiceState} received from the Smart
* Home Controller
*/
private void updateChannels(TemperatureOffsetServiceState state) {
super.updateState(CHANNEL_TEMPERATURE_OFFSET, state.getOffsetState());
}
/**
* Updates the channels which are linked to the {@link ValveTappetService} of
* the device.
*
* @param state Current state of {@link ValveTappetService}.
* @param state current {@link ValveTappetServiceState} received from the Smart
* Home Controller
*/
private void updateChannels(ValveTappetServiceState state) {
super.updateState(CHANNEL_VALVE_TAPPET_POSITION, state.getPositionState());
@@ -95,7 +115,8 @@ public abstract class AbstractThermostatHandler extends AbstractBatteryPoweredDe
* Updates the channels which are linked to the {@link ChildLockService} of the
* device.
*
* @param state Current state of {@link ChildLockService}.
* @param state current {@link ChildLockServiceState} received from the Smart
* Home Controller
*/
private void updateChannels(ChildLockServiceState state) {
super.updateState(CHANNEL_CHILD_LOCK, state.getActiveState());
@@ -18,6 +18,7 @@ import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.api.ContentResponse;
import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler;
import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
@@ -49,7 +50,7 @@ import com.google.gson.JsonElement;
@NonNullByDefault
public abstract class BoschSHCService<TState extends BoschSHCServiceState> extends AbstractBoschSHCService {
protected final Logger logger = LoggerFactory.getLogger(BoschSHCService.class);
private final Logger logger = LoggerFactory.getLogger(BoschSHCService.class);
/**
* Class of service state
@@ -141,8 +142,10 @@ public abstract class BoschSHCService<TState extends BoschSHCServiceState> exten
* @throws InterruptedException
* @throws ExecutionException
* @throws TimeoutException
* @throws BoschSHCException
*/
public void setState(TState state) throws InterruptedException, TimeoutException, ExecutionException {
public void setState(TState state)
throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
String deviceId = getDeviceId();
if (deviceId == null) {
return;
@@ -151,7 +154,18 @@ public abstract class BoschSHCService<TState extends BoschSHCServiceState> exten
if (bridgeHandler == null) {
return;
}
bridgeHandler.putState(deviceId, getServiceName(), state);
@Nullable
ContentResponse response = bridgeHandler.putState(deviceId, getServiceName(), state);
if (response != null) {
int status = response.getStatus();
if (status >= 400) {
String responseBody = response.getContentAsString();
throw new BoschSHCException(String.format(
"Error while updating state for service %s. SHC returned status code %d with response %s",
getServiceName(), status, responseBody));
}
}
}
/**
@@ -0,0 +1,101 @@
/*
* 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.boschshc.internal.services.temperatureoffset;
import java.math.BigDecimal;
import java.math.RoundingMode;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
import org.openhab.binding.boschshc.internal.services.BoschSHCService;
import org.openhab.binding.boschshc.internal.services.temperatureoffset.dto.TemperatureOffsetServiceState;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Service to configure temperature offsets for thermostats.
*
* @author David Pace - Initial contribution
*
*/
@NonNullByDefault
public class TemperatureOffsetService extends BoschSHCService<TemperatureOffsetServiceState> {
private final Logger logger = LoggerFactory.getLogger(TemperatureOffsetService.class);
public static final String TEMPERATURE_OFFSET_SERVICE_NAME = "TemperatureOffset";
public static final BigDecimal MINIMUM_OFFSET = BigDecimal.valueOf(-5);
public static final BigDecimal MAXIMUM_OFFSET = BigDecimal.valueOf(5);
public TemperatureOffsetService() {
super(TEMPERATURE_OFFSET_SERVICE_NAME, TemperatureOffsetServiceState.class);
}
@Override
public TemperatureOffsetServiceState handleCommand(Command command) throws BoschSHCException {
if (command instanceof DecimalType numberCommand) {
return createNewTemperatureOffsetState(numberCommand.toBigDecimal());
} else if (command instanceof QuantityType<?> quantityCommand) {
@Nullable
QuantityType<Temperature> relativeValue = getRelativeTemperatureValue(quantityCommand);
if (relativeValue != null) {
return createNewTemperatureOffsetState(relativeValue.toBigDecimal());
}
}
return super.handleCommand(command);
}
private @Nullable QuantityType<Temperature> getRelativeTemperatureValue(QuantityType<?> quantityCommand) {
// check if the given quantity has a temperature unit
if (!quantityCommand.getUnit().isCompatible(SIUnits.CELSIUS)) {
logger.warn("Received a command that has no temperature quantity: {}", quantityCommand);
return null;
}
@SuppressWarnings("unchecked")
QuantityType<Temperature> temperatureCommand = (QuantityType<Temperature>) quantityCommand;
return temperatureCommand.toUnitRelative(SIUnits.CELSIUS);
}
private TemperatureOffsetServiceState createNewTemperatureOffsetState(BigDecimal offset) {
TemperatureOffsetServiceState state = new TemperatureOffsetServiceState();
double sanitizedOffset = sanitizeOffsetValue(offset);
logger.debug("Temperature offset value {} was sanitized to {}.", offset, sanitizedOffset);
state.offset = sanitizedOffset;
return state;
}
static double sanitizeOffsetValue(BigDecimal bigDecimal) {
if (bigDecimal.compareTo(MINIMUM_OFFSET) < 0) {
return MINIMUM_OFFSET.doubleValue();
} else if (bigDecimal.compareTo(MAXIMUM_OFFSET) > 0) {
return MAXIMUM_OFFSET.doubleValue();
}
// accept at most one decimal digit
if (bigDecimal.scale() > 1) {
// round the value in case more than one decimal digits are provided
return bigDecimal.setScale(1, RoundingMode.HALF_UP).doubleValue();
}
return bigDecimal.doubleValue();
}
}
@@ -0,0 +1,45 @@
/*
* 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.boschshc.internal.services.temperatureoffset.dto;
import javax.measure.quantity.Temperature;
import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
/**
* DTO for temperature offset states.
*
* @author David Pace - Initial contribution
*
*/
public class TemperatureOffsetServiceState extends BoschSHCServiceState {
public TemperatureOffsetServiceState() {
super("temperatureOffsetState");
}
public double offset;
// Note: the following members are Double objects intentionally in order to make them nullable.
// Those members must NOT be serialized for outgoing status updates, otherwise they will be rejected
// by the Smart Home Controller, but they are needed for incoming service state updates
public Double stepSize;
public Double minOffset;
public Double maxOffset;
public QuantityType<Temperature> getOffsetState() {
return new QuantityType<>(offset, SIUnits.CELSIUS);
}
}
@@ -214,6 +214,8 @@ channel-type.boschshc.smoke-check.state.option.SMOKE_TEST_OK = Test successful
channel-type.boschshc.smoke-check.state.option.SMOKE_TEST_FAILED = Test failed
channel-type.boschshc.system-availability.label = System Availability
channel-type.boschshc.system-availability.description = Indicates whether the intrusion detection system is available.
channel-type.boschshc.temperature-offset.label = Temperature Offset
channel-type.boschshc.temperature-offset.description = Temperature offset to be added or subtracted from the measured temperature.
channel-type.boschshc.temperature-rating.label = Temperature Rating
channel-type.boschshc.temperature-rating.description = Rating of the currently measured temperature.
channel-type.boschshc.temperature-rating.state.option.GOOD = Good Temperature
@@ -275,6 +275,7 @@
<channels>
<channel id="temperature" typeId="temperature"/>
<channel id="temperature-offset" typeId="temperature-offset"/>
<channel id="valve-tappet-position" typeId="valve-tappet-position"/>
<channel id="child-lock" typeId="child-lock"/>
<channel id="silent-mode" typeId="silent-mode"/>
@@ -282,6 +283,10 @@
<channel id="low-battery" typeId="system.low-battery"/>
</channels>
<properties>
<property name="thingTypeVersion">1</property>
</properties>
<config-description-ref uri="thing-type:boschshc:device"/>
</thing-type>
@@ -297,6 +302,7 @@
<channels>
<channel id="temperature" typeId="temperature"/>
<channel id="temperature-offset" typeId="temperature-offset"/>
<channel id="valve-tappet-position" typeId="valve-tappet-position"/>
<channel id="child-lock" typeId="child-lock"/>
<channel id="battery-level" typeId="system.battery-level"/>
@@ -305,6 +311,10 @@
<channel id="displayed-temperature" typeId="displayed-temperature"/>
</channels>
<properties>
<property name="thingTypeVersion">1</property>
</properties>
<config-description-ref uri="thing-type:boschshc:device"/>
</thing-type>
@@ -320,6 +330,7 @@
<channels>
<channel id="temperature" typeId="temperature"/>
<channel id="temperature-offset" typeId="temperature-offset"/>
<channel id="valve-tappet-position" typeId="valve-tappet-position"/>
<channel id="child-lock" typeId="child-lock"/>
<channel id="battery-level" typeId="system.battery-level"/>
@@ -328,6 +339,10 @@
<channel id="displayed-temperature" typeId="displayed-temperature"/>
</channels>
<properties>
<property name="thingTypeVersion">1</property>
</properties>
<config-description-ref uri="thing-type:boschshc:device"/>
</thing-type>
@@ -730,6 +745,16 @@
<state min="0" max="40" step="0.5" pattern="%.1f %unit%" readOnly="true"/>
</channel-type>
<channel-type id="temperature-offset">
<item-type>Number:Temperature</item-type>
<label>Temperature Offset</label>
<description>Temperature offset to be added or subtracted from the measured temperature.</description>
<tags>
<tag>Temperature</tag>
</tags>
<state min="-9" max="9" step="0.1" pattern="%.1f %unit%" readOnly="false"/>
</channel-type>
<channel-type id="temperature-rating">
<item-type>String</item-type>
<label>Temperature Rating</label>
@@ -38,4 +38,28 @@
</instruction-set>
</thing-type>
<thing-type uid="boschshc:thermostat">
<instruction-set targetVersion="1">
<add-channel id="temperature-offset">
<type>boschshc:temperature-offset</type>
</add-channel>
</instruction-set>
</thing-type>
<thing-type uid="boschshc:thermostat-2">
<instruction-set targetVersion="1">
<add-channel id="temperature-offset">
<type>boschshc:temperature-offset</type>
</add-channel>
</instruction-set>
</thing-type>
<thing-type uid="boschshc:thermostat-2-matter">
<instruction-set targetVersion="1">
<add-channel id="temperature-offset">
<type>boschshc:temperature-offset</type>
</add-channel>
</instruction-set>
</thing-type>
</update:update-descriptions>
@@ -12,9 +12,9 @@
*/
package org.openhab.binding.boschshc.internal.devices.thermostat;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
@@ -27,10 +27,14 @@ import org.openhab.binding.boschshc.internal.devices.AbstractBatteryPoweredDevic
import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
import org.openhab.binding.boschshc.internal.services.childlock.dto.ChildLockServiceState;
import org.openhab.binding.boschshc.internal.services.childlock.dto.ChildLockState;
import org.openhab.binding.boschshc.internal.services.temperatureoffset.TemperatureOffsetService;
import org.openhab.binding.boschshc.internal.services.temperatureoffset.dto.TemperatureOffsetServiceState;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.ImperialUnits;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
@@ -51,6 +55,7 @@ public abstract class AbstractThermostatHandlerTest<T extends AbstractThermostat
extends AbstractBatteryPoweredDeviceHandlerTest<T> {
private @Captor @NonNullByDefault({}) ArgumentCaptor<ChildLockServiceState> childLockServiceStateCaptor;
private @Captor @NonNullByDefault({}) ArgumentCaptor<TemperatureOffsetServiceState> temperatureOffsetCaptor;
@Test
void testHandleCommandChildLockService() throws InterruptedException, TimeoutException, ExecutionException {
@@ -113,4 +118,130 @@ public abstract class AbstractThermostatHandlerTest<T extends AbstractThermostat
verify(getCallback()).stateUpdated(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_CHILD_LOCK), OnOffType.ON);
}
@Test
void testUpdateChannelsTemperatureOffsetService() {
JsonElement jsonObject = JsonParser.parseString("""
{
"@type": "temperatureOffsetState",
"offset": 1.3,
"stepSize": 0.1,
"minOffset": -5.0,
"maxOffset": 5.0
}\
""");
getFixture().processUpdate(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME, jsonObject);
verify(getCallback()).stateUpdated(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET),
new QuantityType<>(1.3d, SIUnits.CELSIUS));
}
@Test
void testHandleCommandTemperatureOffsetServiceQuantityTypeCelsius()
throws InterruptedException, TimeoutException, ExecutionException {
getFixture().handleCommand(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET),
new QuantityType<>(-1.3d, SIUnits.CELSIUS));
verify(getBridgeHandler()).putState(eq(getDeviceID()),
eq(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME), temperatureOffsetCaptor.capture());
TemperatureOffsetServiceState state = temperatureOffsetCaptor.getValue();
assertNull(state.minOffset);
assertNull(state.maxOffset);
assertNull(state.stepSize);
assertEquals(-1.3d, state.offset, 0.001d);
}
@Test
void testHandleCommandTemperatureOffsetServiceQuantityTypeCelsiusExceedMaxOffset()
throws InterruptedException, TimeoutException, ExecutionException {
getFixture().handleCommand(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET),
new QuantityType<>(6d, SIUnits.CELSIUS));
verify(getBridgeHandler()).putState(eq(getDeviceID()),
eq(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME), temperatureOffsetCaptor.capture());
TemperatureOffsetServiceState state = temperatureOffsetCaptor.getValue();
assertNull(state.minOffset);
assertNull(state.maxOffset);
assertNull(state.stepSize);
assertEquals(5d, state.offset, 0.001d);
}
@Test
void testHandleCommandTemperatureOffsetServiceQuantityTypeFahrenheit()
throws InterruptedException, TimeoutException, ExecutionException {
getFixture().handleCommand(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET),
new QuantityType<>(-1.3d, ImperialUnits.FAHRENHEIT));
verify(getBridgeHandler()).putState(eq(getDeviceID()),
eq(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME), temperatureOffsetCaptor.capture());
TemperatureOffsetServiceState state = temperatureOffsetCaptor.getValue();
assertNull(state.minOffset);
assertNull(state.maxOffset);
assertNull(state.stepSize);
assertEquals(-0.7d, state.offset, 0.001d);
}
@Test
void testHandleCommandTemperatureOffsetServiceQuantityTypeFahrenheitExceedMaxOffset()
throws InterruptedException, TimeoutException, ExecutionException {
getFixture().handleCommand(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET),
new QuantityType<>(10d, ImperialUnits.FAHRENHEIT));
verify(getBridgeHandler()).putState(eq(getDeviceID()),
eq(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME), temperatureOffsetCaptor.capture());
TemperatureOffsetServiceState state = temperatureOffsetCaptor.getValue();
assertNull(state.minOffset);
assertNull(state.maxOffset);
assertNull(state.stepSize);
assertEquals(5d, state.offset, 0.001d);
}
@Test
void testHandleCommandTemperatureOffsetServiceQuantityTypeKelvin()
throws InterruptedException, TimeoutException, ExecutionException {
getFixture().handleCommand(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET),
new QuantityType<>(-1.3d, Units.KELVIN));
verify(getBridgeHandler()).putState(eq(getDeviceID()),
eq(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME), temperatureOffsetCaptor.capture());
TemperatureOffsetServiceState state = temperatureOffsetCaptor.getValue();
assertNull(state.minOffset);
assertNull(state.maxOffset);
assertNull(state.stepSize);
assertEquals(-1.3d, state.offset, 0.001d);
}
@Test
void testHandleCommandTemperatureOffsetServiceQuantityTypeDimensionless()
throws InterruptedException, TimeoutException, ExecutionException {
getFixture().handleCommand(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET),
new QuantityType<>(-1.3d, Units.ONE));
verify(getBridgeHandler(), times(0)).putState(eq(getDeviceID()),
eq(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME), any());
}
@Test
void testHandleCommandTemperatureOffsetServiceDecimalType()
throws InterruptedException, TimeoutException, ExecutionException {
getFixture().handleCommand(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET),
new DecimalType(-1.3d));
verify(getBridgeHandler()).putState(eq(getDeviceID()),
eq(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME), temperatureOffsetCaptor.capture());
TemperatureOffsetServiceState state = temperatureOffsetCaptor.getValue();
assertNull(state.minOffset);
assertNull(state.maxOffset);
assertNull(state.stepSize);
assertEquals(-1.3d, state.offset, 0.001d);
}
@Test
void testHandleCommandTemperatureOffsetServiceInvalidCommand()
throws InterruptedException, TimeoutException, ExecutionException {
getFixture().handleCommand(
new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_TEMPERATURE_OFFSET), OnOffType.ON);
verify(getBridgeHandler(), times(0)).putState(eq(getDeviceID()),
eq(TemperatureOffsetService.TEMPERATURE_OFFSET_SERVICE_NAME), any());
}
}
@@ -78,7 +78,7 @@ class IntrusionDetectionControlStateServiceTest {
}
@Test
void setState() throws InterruptedException, TimeoutException, ExecutionException {
void setState() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
fixture.setState(testState);
verify(bridgeHandler).putState(BoschSHCBindingConstants.SERVICE_INTRUSION_DETECTION,
"IntrusionDetectionControl", testState);
@@ -0,0 +1,54 @@
/*
* 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.boschshc.internal.services.temperatureoffset;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link TemperatureOffsetService}.
*
* @author David Pace - Initial contribution
*
*/
@NonNullByDefault
class TemperatureOffsetServiceTest {
@Test
void sanitizeOffsetValue() {
assertEquals(-5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(-6)), 0.001d);
assertEquals(-5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(-5.1)), 0.001d);
assertEquals(5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(6)), 0.001d);
assertEquals(5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(5.1)), 0.001d);
assertEquals(-5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(-5)), 0.001d);
assertEquals(-5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(-5d)), 0.001d);
assertEquals(5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(5)), 0.001d);
assertEquals(5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(5d)), 0.001d);
assertEquals(0.6d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(0.56d)), 0.001d);
assertEquals(0.5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(0.54)), 0.001d);
assertEquals(0.5d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(0.548)), 0.001d);
assertEquals(-1.9d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(-1.9d)), 0.001d);
assertEquals(1.9d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(1.9d)), 0.001d);
assertEquals(0d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(0)), 0.001d);
assertEquals(0d, TemperatureOffsetService.sanitizeOffsetValue(BigDecimal.valueOf(0d)), 0.001d);
}
}
@@ -0,0 +1,41 @@
/*
* 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.boschshc.internal.services.temperatureoffset.dto;
import static org.junit.jupiter.api.Assertions.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
/**
* Unit tests for {@link TemperatureOffsetServiceState}.
*
* @author David Pace - Initial contribution
*
*/
@NonNullByDefault
class TemperatureOffsetServiceStateTest {
@Test
void testSerialization() {
TemperatureOffsetServiceState state = new TemperatureOffsetServiceState();
String json = GsonUtils.DEFAULT_GSON_INSTANCE.toJson(state);
assertTrue(json.contains("offset"));
assertTrue(json.contains("0.0"));
// make sure the nullable Double objects are not serialized
assertFalse(json.contains("stepSize"));
assertFalse(json.contains("minOffset"));
assertFalse(json.contains("maxOffset"));
}
}