diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index 27b2666987..0b1c4bb73e 100644 --- a/bundles/org.openhab.binding.boschshc/README.md +++ b/bundles/org.openhab.binding.boschshc/README.md @@ -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] @@ -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 diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java index 2326e50b69..4a2c62cbcc 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java @@ -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 diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java index b37387d32e..b46ea516f6 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java @@ -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"; diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java index d83be22c25..157ac35104 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java @@ -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) { diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java index 31e9ec5688..97cb2d3aa1 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java @@ -943,8 +943,8 @@ public class BridgeHandler extends BaseBridgeHandler { * @throws ExecutionException * @throws TimeoutException */ - public @Nullable Response putState(String deviceId, String serviceName, T state) - throws InterruptedException, TimeoutException, ExecutionException { + public @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 @Nullable Response postState(String deviceId, String serviceName, T state) - throws InterruptedException, TimeoutException, ExecutionException { + public @Nullable ContentResponse postState(String deviceId, String serviceName, + T state) throws InterruptedException, TimeoutException, ExecutionException { return sendState(deviceId, serviceName, state, POST); } - private @Nullable Response sendState(String deviceId, String serviceName, T state, - HttpMethod method) throws InterruptedException, TimeoutException, ExecutionException { + private @Nullable ContentResponse sendState(String deviceId, String serviceName, + T state, HttpMethod method) throws InterruptedException, TimeoutException, ExecutionException { @Nullable BoschHttpClient localHttpClient = this.httpClient; if (localHttpClient == null) { diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/thermostat/AbstractThermostatHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/thermostat/AbstractThermostatHandler.java index 0295735e5c..b82b5e3efc 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/thermostat/AbstractThermostatHandler.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/thermostat/AbstractThermostatHandler.java @@ -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()); diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCService.java index 650d72d281..1135e8e790 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCService.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCService.java @@ -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 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 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 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)); + } + } } /** diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/TemperatureOffsetService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/TemperatureOffsetService.java new file mode 100644 index 0000000000..c4f13e8f5b --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/TemperatureOffsetService.java @@ -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 { + + 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 relativeValue = getRelativeTemperatureValue(quantityCommand); + if (relativeValue != null) { + return createNewTemperatureOffsetState(relativeValue.toBigDecimal()); + } + } + return super.handleCommand(command); + } + + private @Nullable QuantityType 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 temperatureCommand = (QuantityType) 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(); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/dto/TemperatureOffsetServiceState.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/dto/TemperatureOffsetServiceState.java new file mode 100644 index 0000000000..efb91dddba --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/dto/TemperatureOffsetServiceState.java @@ -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 getOffsetState() { + return new QuantityType<>(offset, SIUnits.CELSIUS); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties index 69bf17df59..2a938ea1ed 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties +++ b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties @@ -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 diff --git a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml index 31459d03e8..176de61633 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml @@ -275,6 +275,7 @@ + @@ -282,6 +283,10 @@ + + 1 + + @@ -297,6 +302,7 @@ + @@ -305,6 +311,10 @@ + + 1 + + @@ -320,6 +330,7 @@ + @@ -328,6 +339,10 @@ + + 1 + + @@ -730,6 +745,16 @@ + + Number:Temperature + + Temperature offset to be added or subtracted from the measured temperature. + + Temperature + + + + String diff --git a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml index 5d440641c3..48de5bce6d 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml +++ b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml @@ -38,4 +38,28 @@ + + + + boschshc:temperature-offset + + + + + + + + boschshc:temperature-offset + + + + + + + + boschshc:temperature-offset + + + + diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/thermostat/AbstractThermostatHandlerTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/thermostat/AbstractThermostatHandlerTest.java index dd9ddc452e..6541415fab 100644 --- a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/thermostat/AbstractThermostatHandlerTest.java +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/thermostat/AbstractThermostatHandlerTest.java @@ -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 { private @Captor @NonNullByDefault({}) ArgumentCaptor childLockServiceStateCaptor; + private @Captor @NonNullByDefault({}) ArgumentCaptor temperatureOffsetCaptor; @Test void testHandleCommandChildLockService() throws InterruptedException, TimeoutException, ExecutionException { @@ -113,4 +118,130 @@ public abstract class AbstractThermostatHandlerTest(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()); + } } diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/intrusion/IntrusionDetectionControlStateServiceTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/intrusion/IntrusionDetectionControlStateServiceTest.java index 24d29ae98f..ed476ca836 100644 --- a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/intrusion/IntrusionDetectionControlStateServiceTest.java +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/intrusion/IntrusionDetectionControlStateServiceTest.java @@ -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); diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/TemperatureOffsetServiceTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/TemperatureOffsetServiceTest.java new file mode 100644 index 0000000000..c77b7c0d6e --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/TemperatureOffsetServiceTest.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/dto/TemperatureOffsetServiceStateTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/dto/TemperatureOffsetServiceStateTest.java new file mode 100644 index 0000000000..bad89b4305 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/temperatureoffset/dto/TemperatureOffsetServiceStateTest.java @@ -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")); + } +}