[modbus] Gain-offset profile (QuantityType support) and writing of individual bits of holding registers (#9980)
* [modbus] gainOffset and bitMask profiles for working with modbus data Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] README trailing whitespaces Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] README and some final renaming Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] log error with incompatible units Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] gainOffset profile: test for incompatible unit Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] example renamed Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Remove unused fields Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] gainOffset profile: make configuration parameters optional Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] xml indentantion fix Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] static code analysis fixes Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Minor fixes for null checking Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] remove comment Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] bit profile README disclaimer with many commands Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Grammar fixes in README Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Fix bit profile UI configuration Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Bit profile: Added possibility to invert value on read/write Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] fix typo with explanation of inverted Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] bit profile: unit tests for inverted parameter Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] spotless:apply Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] static checker fixes Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] write bit feature in data thing Signed-off-by: Sami Salonen <ssalonen@gmail.com> * wip Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] resolve itest Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] fixes Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Remove bit profile Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Fix data thing readStart validation Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] readme fix Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Remove bit profile test Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Invalidate REFRESH data cache with cacheful writes Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] cleanup - abort if command is not convertible to 0/1 (previously wrote the cached data) - fail fast conditionals instead of deep if's Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] README Fix typo in example Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] fix data thing write when child of endpoint Also added regression test Signed-off-by: Sami Salonen <ssalonen@gmail.com> * Update bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/config/gainOffset.xml Signed-off-by: Sami Salonen <ssalonen@gmail.com> Co-authored-by: Fabian Wolter <github@fabian-wolter.de> * [modbus] performance-optimized logging Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] README: Removing xtend syntax hint, not needed anymore Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] generics typing added Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] dead code Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] avoid supressing generic type warnings Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] unnecessary generics Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] rename type parameter name Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] QU (short for quantity output) generic type instead of Q2 Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] Remove unused localization Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] profile constant visibility harmonized Signed-off-by: Sami Salonen <ssalonen@gmail.com> * [modbus] spotless:apply Signed-off-by: Sami Salonen <ssalonen@gmail.com> Co-authored-by: Fabian Wolter <github@fabian-wolter.de>
This commit is contained in:
@@ -15,6 +15,7 @@ package org.openhab.binding.modbus.handler;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
@@ -31,6 +32,7 @@ import org.openhab.core.io.transport.modbus.ModbusFailureCallback;
|
||||
import org.openhab.core.io.transport.modbus.ModbusReadCallback;
|
||||
import org.openhab.core.io.transport.modbus.ModbusReadFunctionCode;
|
||||
import org.openhab.core.io.transport.modbus.ModbusReadRequestBlueprint;
|
||||
import org.openhab.core.io.transport.modbus.ModbusRegisterArray;
|
||||
import org.openhab.core.io.transport.modbus.PollTask;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
@@ -96,6 +98,10 @@ public class ModbusPollerThingHandler extends BaseBridgeHandler {
|
||||
|
||||
@Override
|
||||
public synchronized void handle(AsyncModbusReadResult result) {
|
||||
// Casting to allow registers.orElse(null) below..
|
||||
Optional<@Nullable ModbusRegisterArray> registers = (Optional<@Nullable ModbusRegisterArray>) result
|
||||
.getRegisters();
|
||||
lastPolledDataCache.set(registers.orElse(null));
|
||||
handleResult(new PollResult(result));
|
||||
}
|
||||
|
||||
@@ -186,6 +192,7 @@ public class ModbusPollerThingHandler extends BaseBridgeHandler {
|
||||
private volatile @Nullable ModbusReadRequestBlueprint request;
|
||||
private volatile boolean disposed;
|
||||
private volatile List<ModbusDataThingHandler> childCallbacks = new CopyOnWriteArrayList<>();
|
||||
private volatile AtomicReference<@Nullable ModbusRegisterArray> lastPolledDataCache = new AtomicReference<>();
|
||||
private @NonNullByDefault({}) ModbusCommunicationInterface comms;
|
||||
|
||||
private ReadCallbackDelegator callbackDelegator = new ReadCallbackDelegator();
|
||||
@@ -288,6 +295,7 @@ public class ModbusPollerThingHandler extends BaseBridgeHandler {
|
||||
unregisterPollTask();
|
||||
this.callbackDelegator.resetCache();
|
||||
comms = null;
|
||||
lastPolledDataCache.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,6 +428,20 @@ public class ModbusPollerThingHandler extends BaseBridgeHandler {
|
||||
if (localRequest == null) {
|
||||
return;
|
||||
}
|
||||
ModbusRegisterArray possiblyMutatedCache = lastPolledDataCache.get();
|
||||
AtomicStampedValue<PollResult> lastPollResult = callbackDelegator.lastResult;
|
||||
if (lastPollResult != null && possiblyMutatedCache != null) {
|
||||
AsyncModbusReadResult lastSuccessfulPollResult = lastPollResult.getValue().result;
|
||||
if (lastSuccessfulPollResult != null) {
|
||||
ModbusRegisterArray lastRegisters = ((Optional<@Nullable ModbusRegisterArray>) lastSuccessfulPollResult
|
||||
.getRegisters()).orElse(null);
|
||||
if (lastRegisters != null && !possiblyMutatedCache.equals(lastRegisters)) {
|
||||
// Register has been mutated in between by a data thing that writes "individual bits"
|
||||
// Invalidate cache for a fresh poll
|
||||
callbackDelegator.resetCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
long oldDataThreshold = System.currentTimeMillis() - cacheMillis;
|
||||
boolean cacheWasRecentEnoughForUpdate = cacheMillis > 0
|
||||
@@ -438,4 +460,8 @@ public class ModbusPollerThingHandler extends BaseBridgeHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AtomicReference<@Nullable ModbusRegisterArray> getLastPolledDataCache() {
|
||||
return lastPolledDataCache;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
@@ -78,6 +79,7 @@ import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
import org.openhab.core.util.HexUtils;
|
||||
import org.osgi.framework.BundleContext;
|
||||
import org.osgi.framework.FrameworkUtil;
|
||||
import org.slf4j.Logger;
|
||||
@@ -133,7 +135,8 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
private volatile @Nullable CascadedValueTransformationImpl writeTransformation;
|
||||
private volatile Optional<Integer> readIndex = Optional.empty();
|
||||
private volatile Optional<Integer> readSubIndex = Optional.empty();
|
||||
private volatile @Nullable Integer writeStart;
|
||||
private volatile Optional<Integer> writeStart = Optional.empty();
|
||||
private volatile Optional<Integer> writeSubIndex = Optional.empty();
|
||||
private volatile int pollStart;
|
||||
private volatile int slaveId;
|
||||
private volatile @Nullable ModbusReadFunctionCode functionCode;
|
||||
@@ -200,8 +203,8 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
|
||||
// We did not have JSON output from the transformation, so writeStart is absolute required. Abort if it is
|
||||
// missing
|
||||
Integer writeStart = this.writeStart;
|
||||
if (writeStart == null) {
|
||||
Optional<Integer> writeStart = this.writeStart;
|
||||
if (writeStart.isEmpty()) {
|
||||
logger.debug(
|
||||
"Thing {} '{}': not processing command {} since writeStart is missing and transformation output is not a JSON",
|
||||
getThing().getUID(), getThing().getLabel(), command);
|
||||
@@ -216,7 +219,7 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
}
|
||||
|
||||
ModbusWriteRequestBlueprint request = requestFromCommand(channelUID, command, config, transformedCommand.get(),
|
||||
writeStart);
|
||||
writeStart.get());
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
@@ -267,7 +270,9 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
ModbusWriteRequestBlueprint request;
|
||||
boolean writeMultiple = config.isWriteMultipleEvenWithSingleRegisterOrCoil();
|
||||
String writeType = config.getWriteType();
|
||||
ModbusPollerThingHandler pollerHandler = this.pollerHandler;
|
||||
if (writeType == null) {
|
||||
// disposed thing
|
||||
return null;
|
||||
}
|
||||
if (writeType.equals(WRITE_TYPE_COIL)) {
|
||||
@@ -289,7 +294,44 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
logger.warn("Received command but write value type not set! Ignoring command");
|
||||
return null;
|
||||
}
|
||||
ModbusRegisterArray data = ModbusBitUtilities.commandToRegisters(transformedCommand, writeValueType);
|
||||
final ModbusRegisterArray data;
|
||||
if (writeValueType.equals(ValueType.BIT)) {
|
||||
if (writeSubIndex.isEmpty()) {
|
||||
// Should not happen! should be in configuration error
|
||||
logger.error("Bug: sub index not present but writeValueType=BIT. Should be in configuration error");
|
||||
return null;
|
||||
}
|
||||
Optional<Boolean> commandBool = ModbusBitUtilities.translateCommand2Boolean(transformedCommand);
|
||||
if (commandBool.isEmpty()) {
|
||||
logger.warn(
|
||||
"Data thing is configured to write individual bit but we received command that is not convertible to 0/1 bit. Ignoring.");
|
||||
return null;
|
||||
} else if (pollerHandler == null) {
|
||||
logger.warn("Bug: sub index present but not child of poller. Should be in configuration erro");
|
||||
return null;
|
||||
}
|
||||
|
||||
// writing bit of an individual register. Using cache from poller
|
||||
AtomicReference<@Nullable ModbusRegisterArray> cachedRegistersRef = pollerHandler
|
||||
.getLastPolledDataCache();
|
||||
ModbusRegisterArray mutatedRegisters = cachedRegistersRef
|
||||
.updateAndGet(cachedRegisters -> cachedRegisters == null ? null
|
||||
: combineCommandWithRegisters(cachedRegisters, writeStart, writeSubIndex.get(),
|
||||
commandBool.get()));
|
||||
if (mutatedRegisters == null) {
|
||||
logger.warn(
|
||||
"Received command to thing with writeValueType=bit (pointing to individual bit of a holding register) but internal cache not yet populated. Ignoring command");
|
||||
return null;
|
||||
}
|
||||
// extract register (first byte index = register index * 2)
|
||||
byte[] allMutatedBytes = mutatedRegisters.getBytes();
|
||||
int writeStartRelative = writeStart - pollStart;
|
||||
data = new ModbusRegisterArray(allMutatedBytes[writeStartRelative * 2],
|
||||
allMutatedBytes[writeStartRelative * 2 + 1]);
|
||||
|
||||
} else {
|
||||
data = ModbusBitUtilities.commandToRegisters(transformedCommand, writeValueType);
|
||||
}
|
||||
writeMultiple = writeMultiple || data.size() > 1;
|
||||
request = new ModbusWriteRegisterRequestBlueprint(slaveId, writeStart, data, writeMultiple,
|
||||
config.getWriteMaxTries());
|
||||
@@ -304,6 +346,33 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine boolean-like command with registers. Updated registers are returned
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
private ModbusRegisterArray combineCommandWithRegisters(ModbusRegisterArray registers, int registerIndex,
|
||||
int bitIndex, boolean b) {
|
||||
byte[] allBytes = registers.getBytes();
|
||||
int bitIndexWithinRegister = bitIndex % 16;
|
||||
boolean hiByte = bitIndexWithinRegister >= 8;
|
||||
int indexWithinByte = bitIndexWithinRegister % 8;
|
||||
int registerIndexRelative = registerIndex - pollStart;
|
||||
int byteIndex = 2 * registerIndexRelative + (hiByte ? 0 : 1);
|
||||
if (b) {
|
||||
allBytes[byteIndex] |= 1 << indexWithinByte;
|
||||
} else {
|
||||
allBytes[byteIndex] &= ~(1 << indexWithinByte);
|
||||
}
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace(
|
||||
"Boolean-like command {} from item, combining command with internal register ({}) with registerIndex={} (relative {}), bitIndex={}, resulting register {}",
|
||||
b, HexUtils.bytesToHex(registers.getBytes()), registerIndex, registerIndexRelative, bitIndex,
|
||||
HexUtils.bytesToHex(allBytes));
|
||||
}
|
||||
return new ModbusRegisterArray(allBytes);
|
||||
}
|
||||
|
||||
private void processJsonTransform(Command command, String transformOutput) {
|
||||
ModbusCommunicationInterface localComms = this.comms;
|
||||
if (localComms == null) {
|
||||
@@ -398,7 +467,8 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
writeTransformation = null;
|
||||
readIndex = Optional.empty();
|
||||
readSubIndex = Optional.empty();
|
||||
writeStart = null;
|
||||
writeStart = Optional.empty();
|
||||
writeSubIndex = Optional.empty();
|
||||
pollStart = 0;
|
||||
slaveId = 0;
|
||||
comms = null;
|
||||
@@ -553,19 +623,6 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (writingCoil && !ModbusConstants.ValueType.BIT.equals(localWriteValueType)) {
|
||||
String errmsg = String.format(
|
||||
"Invalid writeValueType: Only writeValueType='%s' (or undefined) supported with coils. Value type was: %s",
|
||||
ModbusConstants.ValueType.BIT, config.getWriteValueType());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
} else if (!writingCoil && localWriteValueType.getBits() < 16) {
|
||||
// trying to write holding registers with < 16 bit value types. Not supported
|
||||
String errmsg = String.format(
|
||||
"Invalid writeValueType: Only writeValueType with larger or equal to 16 bits are supported holding registers. Value type was: %s",
|
||||
config.getWriteValueType());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!writeParametersHavingTransformationOnly) {
|
||||
String localWriteStart = config.getWriteStart();
|
||||
@@ -574,13 +631,57 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
config.getWriteStart());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
writeStart = Integer.parseInt(localWriteStart.trim());
|
||||
String[] writeParts = localWriteStart.split("\\.", 2);
|
||||
try {
|
||||
writeStart = Optional.of(Integer.parseInt(writeParts[0]));
|
||||
if (writeParts.length == 2) {
|
||||
writeSubIndex = Optional.of(Integer.parseInt(writeParts[1]));
|
||||
} else {
|
||||
writeSubIndex = Optional.empty();
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
String errmsg = String.format("Thing %s invalid writeStart: %s", getThing().getUID(),
|
||||
config.getReadStart());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
String errmsg = String.format("Thing %s invalid writeStart: %s", getThing().getUID(),
|
||||
config.getWriteStart());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
|
||||
if (writingCoil && !ModbusConstants.ValueType.BIT.equals(localWriteValueType)) {
|
||||
String errmsg = String.format(
|
||||
"Invalid writeValueType: Only writeValueType='%s' (or undefined) supported with coils. Value type was: %s",
|
||||
ModbusConstants.ValueType.BIT, config.getWriteValueType());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
} else if (writeSubIndex.isEmpty() && !writingCoil && localWriteValueType.getBits() < 16) {
|
||||
// trying to write holding registers with < 16 bit value types. Not supported
|
||||
String errmsg = String.format(
|
||||
"Invalid writeValueType: Only writeValueType with larger or equal to 16 bits are supported holding registers. Value type was: %s",
|
||||
config.getWriteValueType());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
|
||||
if (writeSubIndex.isPresent()) {
|
||||
if (writeValueTypeMissing || writeTypeMissing || !WRITE_TYPE_HOLDING.equals(config.getWriteType())
|
||||
|| !ModbusConstants.ValueType.BIT.equals(localWriteValueType) || childOfEndpoint) {
|
||||
String errmsg = String.format(
|
||||
"Thing %s invalid writeType, writeValueType or parent. Since writeStart=X.Y, one should set writeType=holding, writeValueType=bit and have the thing as child of poller",
|
||||
getThing().getUID(), config.getWriteStart());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
ModbusReadRequestBlueprint readRequest = this.readRequest;
|
||||
if (readRequest == null
|
||||
|| readRequest.getFunctionCode() != ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS) {
|
||||
String errmsg = String.format(
|
||||
"Thing %s invalid. Since writeStart=X.Y, expecting poller reading holding registers.",
|
||||
getThing().getUID());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
}
|
||||
validateWriteIndex();
|
||||
} else {
|
||||
isWriteEnabled = false;
|
||||
}
|
||||
@@ -646,6 +747,41 @@ public class ModbusDataThingHandler extends BaseThingHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private void validateWriteIndex() throws ModbusConfigurationException {
|
||||
@Nullable
|
||||
ModbusReadRequestBlueprint readRequest = this.readRequest;
|
||||
if (!writeStart.isPresent() || !writeSubIndex.isPresent()) {
|
||||
//
|
||||
// this validation is really about writeStart=X.Y validation
|
||||
//
|
||||
return;
|
||||
} else if (readRequest == null) {
|
||||
// should not happen, already validated
|
||||
throw new ModbusConfigurationException("Must poll data with writeStart=X.Y");
|
||||
}
|
||||
|
||||
if (writeSubIndex.isPresent() && (writeSubIndex.get() + 1) > 16) {
|
||||
// the sub index Y (in X.Y) is above the register limits
|
||||
String errmsg = String.format("readStart=X.Y, the value Y is too large");
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
|
||||
// Determine bit positions polled, both start and end inclusive
|
||||
int pollStartBitIndex = readRequest.getReference() * 16;
|
||||
int pollEndBitIndex = pollStartBitIndex + readRequest.getDataLength() * 16 - 1;
|
||||
|
||||
// Determine bit positions read, both start and end inclusive
|
||||
int writeStartBitIndex = writeStart.get() * 16 + readSubIndex.orElse(0);
|
||||
int writeEndBitIndex = writeStartBitIndex - 1;
|
||||
|
||||
if (writeStartBitIndex < pollStartBitIndex || writeEndBitIndex > pollEndBitIndex) {
|
||||
String errmsg = String.format(
|
||||
"Out-of-bounds: Poller is reading from index %d to %d (inclusive) but this thing configured to write starting from element %d. Must write within polled limits",
|
||||
pollStartBitIndex / 16, pollEndBitIndex / 16, writeStart.get());
|
||||
throw new ModbusConfigurationException(errmsg);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean containsOnOff(List<Class<? extends State>> channelAcceptedDataTypes) {
|
||||
return channelAcceptedDataTypes.stream().anyMatch(clz -> {
|
||||
return clz.equals(OnOffType.class);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 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.modbus.internal.profiles;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Optional;
|
||||
|
||||
import javax.measure.Quantity;
|
||||
import javax.measure.UnconvertibleException;
|
||||
import javax.measure.Unit;
|
||||
import javax.measure.quantity.Dimensionless;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.Units;
|
||||
import org.openhab.core.thing.profiles.ProfileCallback;
|
||||
import org.openhab.core.thing.profiles.ProfileContext;
|
||||
import org.openhab.core.thing.profiles.ProfileTypeUID;
|
||||
import org.openhab.core.thing.profiles.StateProfile;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.Type;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Profile for applying gain and offset to values.
|
||||
*
|
||||
* Output of the profile is
|
||||
* - (incoming value + pre-gain-offset) * gain (update towards item)
|
||||
* - (incoming value / gain) - pre-gain-offset (command from item)
|
||||
*
|
||||
* Gain can also specify unit of the result, converting otherwise bare numbers to ones with quantity.
|
||||
*
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ModbusGainOffsetProfile<Q extends Quantity<Q>> implements StateProfile {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(ModbusGainOffsetProfile.class);
|
||||
private static final String PREGAIN_OFFSET_PARAM = "pre-gain-offset";
|
||||
private static final String GAIN_PARAM = "gain";
|
||||
|
||||
private final ProfileCallback callback;
|
||||
private final ProfileContext context;
|
||||
|
||||
private Optional<QuantityType<Dimensionless>> pregainOffset;
|
||||
private Optional<QuantityType<Q>> gain;
|
||||
|
||||
public ModbusGainOffsetProfile(ProfileCallback callback, ProfileContext context) {
|
||||
this.callback = callback;
|
||||
this.context = context;
|
||||
{
|
||||
Object rawOffsetValue = orDefault("0", this.context.getConfiguration().get(PREGAIN_OFFSET_PARAM));
|
||||
logger.debug("Configuring profile with {} parameter '{}'", PREGAIN_OFFSET_PARAM, rawOffsetValue);
|
||||
pregainOffset = parameterAsQuantityType(PREGAIN_OFFSET_PARAM, rawOffsetValue, Units.ONE);
|
||||
|
||||
}
|
||||
{
|
||||
Object gainValue = orDefault("1", this.context.getConfiguration().get(GAIN_PARAM));
|
||||
logger.debug("Configuring profile with {} parameter '{}'", GAIN_PARAM, gainValue);
|
||||
gain = parameterAsQuantityType(GAIN_PARAM, gainValue);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return pregainOffset.isPresent() && gain.isPresent();
|
||||
}
|
||||
|
||||
public Optional<QuantityType<Dimensionless>> getPregainOffset() {
|
||||
return pregainOffset;
|
||||
}
|
||||
|
||||
public Optional<QuantityType<Q>> getGain() {
|
||||
return gain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProfileTypeUID getProfileTypeUID() {
|
||||
return ModbusProfiles.GAIN_OFFSET;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStateUpdateFromItem(State state) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCommandFromItem(Command command) {
|
||||
Type result = applyGainOffset(command, false);
|
||||
if (result instanceof Command) {
|
||||
logger.trace("Command '{}' from item, sending converted '{}' state towards handler.", command, result);
|
||||
callback.handleCommand((Command) result);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCommandFromHandler(Command command) {
|
||||
Type result = applyGainOffset(command, true);
|
||||
if (result instanceof Command) {
|
||||
logger.trace("Command '{}' from handler, sending converted '{}' command towards item.", command, result);
|
||||
callback.sendCommand((Command) result);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStateUpdateFromHandler(State state) {
|
||||
State result = (State) applyGainOffset(state, true);
|
||||
logger.trace("State update '{}' from handler, sending converted '{}' state towards item.", state, result);
|
||||
callback.sendUpdate(result);
|
||||
}
|
||||
|
||||
private Type applyGainOffset(Type state, boolean towardsItem) {
|
||||
Type result = UnDefType.UNDEF;
|
||||
Optional<QuantityType<Q>> localGain = gain;
|
||||
Optional<QuantityType<Dimensionless>> localPregainOffset = pregainOffset;
|
||||
if (localGain.isEmpty() || localPregainOffset.isEmpty()) {
|
||||
logger.warn("Gain or offset unavailable. Check logs for configuration errors.");
|
||||
return UnDefType.UNDEF;
|
||||
} else if (state instanceof UnDefType) {
|
||||
return UnDefType.UNDEF;
|
||||
}
|
||||
|
||||
QuantityType<Q> gain = localGain.get();
|
||||
QuantityType<Dimensionless> pregainOffsetQt = localPregainOffset.get();
|
||||
String formula = towardsItem ? String.format("( '%s' + '%s') * '%s'", state, pregainOffsetQt, gain)
|
||||
: String.format("'%s'/'%s' - '%s'", state, gain, pregainOffsetQt);
|
||||
if (state instanceof QuantityType) {
|
||||
try {
|
||||
if (towardsItem) {
|
||||
@SuppressWarnings("unchecked") // xx.toUnit(ONE) returns null or QuantityType<Dimensionless>
|
||||
@Nullable
|
||||
QuantityType<Dimensionless> qtState = (QuantityType<Dimensionless>) (((QuantityType<?>) state)
|
||||
.toUnit(Units.ONE));
|
||||
if (qtState == null) {
|
||||
logger.warn("Profile can only process plain numbers from handler. Got unit {}. Returning UNDEF",
|
||||
((QuantityType<?>) state).getUnit());
|
||||
return UnDefType.UNDEF;
|
||||
}
|
||||
QuantityType<Dimensionless> offsetted = qtState.add(pregainOffsetQt);
|
||||
result = applyGainTowardsItem(offsetted, gain);
|
||||
} else {
|
||||
final QuantityType<?> qtState = (QuantityType<?>) state;
|
||||
result = applyGainTowardsHandler(qtState, gain).subtract(pregainOffsetQt);
|
||||
|
||||
}
|
||||
} catch (UnconvertibleException | UnsupportedOperationException e) {
|
||||
logger.warn(
|
||||
"Cannot apply gain ('{}') and offset ('{}') to state ('{}') (formula {}) because types do not match (towardsItem={}): {}",
|
||||
gain, pregainOffsetQt, state, formula, towardsItem, e.getMessage());
|
||||
return UnDefType.UNDEF;
|
||||
}
|
||||
} else if (state instanceof DecimalType) {
|
||||
DecimalType decState = (DecimalType) state;
|
||||
return applyGainOffset(new QuantityType<>(decState, Units.ONE), towardsItem);
|
||||
} else if (state instanceof RefreshType) {
|
||||
result = state;
|
||||
} else {
|
||||
logger.warn(
|
||||
"Gain '{}' cannot be applied to the incompatible state '{}' of type {} sent from the binding (towardsItem={}). Returning original state.",
|
||||
gain, state, state.getClass().getSimpleName(), towardsItem);
|
||||
result = state;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Optional<QuantityType<Q>> parameterAsQuantityType(String parameterName, Object parameterValue) {
|
||||
return parameterAsQuantityType(parameterName, parameterValue, null);
|
||||
}
|
||||
|
||||
private <QU extends Quantity<QU>> Optional<QuantityType<QU>> parameterAsQuantityType(String parameterName,
|
||||
Object parameterValue, @Nullable Unit<QU> assertUnit) {
|
||||
Optional<QuantityType<QU>> result = Optional.empty();
|
||||
Unit<QU> sourceUnit = null;
|
||||
if (parameterValue instanceof String) {
|
||||
try {
|
||||
QuantityType<QU> qt = new QuantityType<>((String) parameterValue);
|
||||
result = Optional.of(qt);
|
||||
sourceUnit = qt.getUnit();
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("Cannot convert value '{}' of parameter '{}' into a QuantityType.", parameterValue,
|
||||
parameterName);
|
||||
}
|
||||
} else if (parameterValue instanceof BigDecimal) {
|
||||
BigDecimal parameterBigDecimal = (BigDecimal) parameterValue;
|
||||
result = Optional.of(new QuantityType<QU>(parameterBigDecimal.toString()));
|
||||
} else {
|
||||
logger.error("Parameter '{}' is not of type String or BigDecimal", parameterName);
|
||||
return result;
|
||||
}
|
||||
result = result.map(quantityType -> convertUnit(quantityType, assertUnit));
|
||||
if (result.isEmpty()) {
|
||||
logger.error("Unable to convert parameter '{}' to unit {}. Unit was {}.", parameterName, assertUnit,
|
||||
sourceUnit);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private <QU extends Quantity<QU>> @Nullable QuantityType<QU> convertUnit(QuantityType<QU> quantityType,
|
||||
@Nullable Unit<QU> unit) {
|
||||
if (unit == null) {
|
||||
return quantityType;
|
||||
}
|
||||
QuantityType<QU> normalizedQt = quantityType.toUnit(unit);
|
||||
if (normalizedQt != null) {
|
||||
return normalizedQt;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate qtState * gain or qtState/gain
|
||||
*
|
||||
* When the conversion is towards the handler (towardsItem=false), unit will be ONE
|
||||
*
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // Safe cast since QU = Dimensionless * QU
|
||||
private <QU extends Quantity<QU>> QuantityType<QU> applyGainTowardsItem(QuantityType<Dimensionless> qtState,
|
||||
QuantityType<QU> gainDelta) {
|
||||
return (QuantityType<QU>) qtState.multiply(gainDelta);
|
||||
}
|
||||
|
||||
private QuantityType<Dimensionless> applyGainTowardsHandler(QuantityType<?> qtState, QuantityType<?> gainDelta) {
|
||||
QuantityType<?> plain = qtState.toUnit(gainDelta.getUnit());
|
||||
if (plain == null) {
|
||||
throw new UnconvertibleException(
|
||||
String.format("Cannot process command '%s', unit should compatible with gain", qtState));
|
||||
}
|
||||
return new QuantityType<>(plain.toBigDecimal().divide(gainDelta.toBigDecimal()), Units.ONE);
|
||||
}
|
||||
|
||||
private static Object orDefault(Object defaultValue, @Nullable Object value) {
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
} else if (value instanceof String && ((String) value).isBlank()) {
|
||||
return defaultValue;
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 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.modbus.internal.profiles;
|
||||
|
||||
import static org.openhab.binding.modbus.internal.profiles.ModbusProfiles.*;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.core.thing.profiles.Profile;
|
||||
import org.openhab.core.thing.profiles.ProfileCallback;
|
||||
import org.openhab.core.thing.profiles.ProfileContext;
|
||||
import org.openhab.core.thing.profiles.ProfileFactory;
|
||||
import org.openhab.core.thing.profiles.ProfileType;
|
||||
import org.openhab.core.thing.profiles.ProfileTypeProvider;
|
||||
import org.openhab.core.thing.profiles.ProfileTypeUID;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
|
||||
/**
|
||||
* A factory and advisor for modbus profiles.
|
||||
*
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(service = { ProfileFactory.class, ProfileTypeProvider.class })
|
||||
public class ModbusProfileFactory implements ProfileFactory, ProfileTypeProvider {
|
||||
|
||||
private static final Set<ProfileType> SUPPORTED_PROFILE_TYPES = Set.of(GAIN_OFFSET_TYPE);
|
||||
|
||||
private static final Set<ProfileTypeUID> SUPPORTED_PROFILE_TYPE_UIDS = Set.of(GAIN_OFFSET);
|
||||
|
||||
@Override
|
||||
public @Nullable Profile createProfile(ProfileTypeUID profileTypeUID, ProfileCallback callback,
|
||||
ProfileContext context) {
|
||||
if (GAIN_OFFSET.equals(profileTypeUID)) {
|
||||
return new ModbusGainOffsetProfile<>(callback, context);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ProfileType> getProfileTypes(@Nullable Locale locale) {
|
||||
return SUPPORTED_PROFILE_TYPES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ProfileTypeUID> getSupportedProfileTypeUIDs() {
|
||||
return SUPPORTED_PROFILE_TYPE_UIDS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 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.modbus.internal.profiles;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.core.thing.profiles.ProfileTypeBuilder;
|
||||
import org.openhab.core.thing.profiles.ProfileTypeUID;
|
||||
import org.openhab.core.thing.profiles.StateProfileType;
|
||||
|
||||
/**
|
||||
* Modbus profile constants.
|
||||
*
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public interface ModbusProfiles {
|
||||
static final String MODBUS_SCOPE = "modbus";
|
||||
static final ProfileTypeUID GAIN_OFFSET = new ProfileTypeUID(MODBUS_SCOPE, "gainOffset");
|
||||
static final StateProfileType GAIN_OFFSET_TYPE = ProfileTypeBuilder.newState(GAIN_OFFSET, "Gain-Offset Correction")
|
||||
.build();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config-description:config-descriptions
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0
|
||||
https://openhab.org/schemas/config-description-1.0.0.xsd">
|
||||
|
||||
<config-description uri="profile:modbus:gainOffset">
|
||||
<parameter name="pre-gain-offset" type="decimal">
|
||||
<label>Pre-gain Offset</label>
|
||||
<description>Offset to add to raw value towards the item (before the gain). The negative
|
||||
offset will be applied in the
|
||||
reverse direction (before inverting the gain). If omitted, zero offset is used.</description>
|
||||
</parameter>
|
||||
<parameter name="gain" type="text">
|
||||
<label>Gain</label>
|
||||
<description>Gain to apply to the state towards the item. One can also specify the unit to declare resulting unit.
|
||||
This is used as divisor for values in the reverse direction. If omitted, gain of 1 is used.</description>
|
||||
</parameter>
|
||||
</config-description>
|
||||
</config-description:config-descriptions>
|
||||
@@ -26,7 +26,7 @@
|
||||
</channels>
|
||||
<config-description>
|
||||
<!-- what to read -->
|
||||
<parameter name="readStart" type="text" pattern="^(0|[1-9][0-9]*(\.[0-9]{1,2})?)?$">
|
||||
<parameter name="readStart" type="text" pattern="^(0|[0-9][0-9]*(\.[0-9]{1,2})?)?$">
|
||||
<label>Read Address</label>
|
||||
<description><![CDATA[Start address to start reading the value. Use empty for write-only things.
|
||||
<br />
|
||||
@@ -81,7 +81,9 @@
|
||||
<parameter name="writeStart" type="text">
|
||||
<label>Write Address</label>
|
||||
<description><![CDATA[Start address of the first holding register or coil in the write. Use empty for read-only things.
|
||||
<br />Use zero based address, e.g. in place of 400001 (first holding register), use the address 0. This address is passed to data frame as is.]]></description>
|
||||
<br />Use zero based address, e.g. in place of 400001 (first holding register), use the address 0. This address is passed to data frame as is.
|
||||
<br />One can write individual bits of an register using X.Y format where X is the register and Y is the bit (0 refers to least significant bit).
|
||||
]]></description>
|
||||
</parameter>
|
||||
<parameter name="writeType" type="text">
|
||||
<label>Write Type</label>
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2021 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.modbus.internal.profiles;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assumptions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.EmptySource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.junit.jupiter.params.provider.NullSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.openhab.core.config.core.Configuration;
|
||||
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.thing.profiles.ProfileCallback;
|
||||
import org.openhab.core.thing.profiles.ProfileContext;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.Type;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
|
||||
/**
|
||||
* @author Sami Salonen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class ModbusGainOffsetProfileTest {
|
||||
|
||||
private static Stream<Arguments> provideArgsForBoth() {
|
||||
return Stream.of(
|
||||
// dimensionless
|
||||
Arguments.of("100", "0.5", "250", "175.0"), Arguments.of("0", "1 %", "250", "250 %"),
|
||||
//
|
||||
// gain with same unit
|
||||
//
|
||||
// e.g. (handler) 3 <---> (item) 106K with raw-offset=50, gain=2K
|
||||
// e.g. (handler) 3 K <---> (item) 106K^2 with raw-offset=50K, gain=2K
|
||||
//
|
||||
Arguments.of("50", "2 K", "3", "106 K"),
|
||||
//
|
||||
// gain with different unit
|
||||
//
|
||||
Arguments.of("50", "2 m/s", "3", "106 m/s"),
|
||||
//
|
||||
// gain without unit
|
||||
//
|
||||
Arguments.of("50", "2", "3", "106"),
|
||||
//
|
||||
// temperature tests
|
||||
//
|
||||
// celsius gain
|
||||
Arguments.of("0", "0.1 °C", "25", "2.5 °C"),
|
||||
// kelvin gain
|
||||
Arguments.of("0", "0.1 K", "25", "2.5 K"),
|
||||
// fahrenheit gain
|
||||
Arguments.of("0", "10 °F", "0.18", "1.80 °F"),
|
||||
//
|
||||
// unsupported types are passed with error
|
||||
Arguments.of("0", "0", OnOffType.ON, OnOffType.ON)
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> provideAdditionalArgsForStateUpdateFromHandler() {
|
||||
return Stream.of(
|
||||
|
||||
// Dimensionless conversion 2.5/1% = 250%/1% = 250
|
||||
Arguments.of("0", "1 %", "250", "250 %"), Arguments.of("2 %", "1 %", "249.9800", "250.0000 %"),
|
||||
Arguments.of("50", "2 m/s", new DecimalType("3"), "106 m/s"),
|
||||
// UNDEF passes the profile unchanged
|
||||
Arguments.of("0", "0", UnDefType.UNDEF, UnDefType.UNDEF));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Test profile behaviour when handler updates the state
|
||||
*
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@MethodSource({ "provideArgsForBoth", "provideAdditionalArgsForStateUpdateFromHandler" })
|
||||
public void testOnStateUpdateFromHandler(String rawOffset, String gain, Object updateFromHandlerObj,
|
||||
Object expectedUpdateTowardsItemObj) {
|
||||
testOnUpdateFromHandlerGeneric(rawOffset, gain, updateFromHandlerObj, expectedUpdateTowardsItemObj, true);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Test profile behaviour when handler sends command
|
||||
*
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@MethodSource({ "provideArgsForBoth", "provideAdditionalArgsForStateUpdateFromHandler" })
|
||||
public void testOnCommandFromHandler(String rawOffset, String gain, Object updateFromHandlerObj,
|
||||
Object expectedUpdateTowardsItemObj) {
|
||||
// UNDEF is not a command, cannot be sent by handler
|
||||
assumeTrue(updateFromHandlerObj != UnDefType.UNDEF);
|
||||
testOnUpdateFromHandlerGeneric(rawOffset, gain, updateFromHandlerObj, expectedUpdateTowardsItemObj, false);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Test profile behaviour when handler updates the state
|
||||
*
|
||||
* @param rawOffset profile raw offset
|
||||
* @param gain profile gain
|
||||
* @param updateFromHandlerObj state update from handler. String representing QuantityType or State/Command
|
||||
* @param expectedUpdateTowardsItemObj expected state/command update towards item. String representing QuantityType
|
||||
* or
|
||||
* State
|
||||
* @param stateUpdateFromHandler whether there is state update from handler. Otherwise command
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
private void testOnUpdateFromHandlerGeneric(String rawOffset, String gain, Object updateFromHandlerObj,
|
||||
Object expectedUpdateTowardsItemObj, boolean stateUpdateFromHandler) {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
ModbusGainOffsetProfile profile = createProfile(callback, gain, rawOffset);
|
||||
|
||||
final Type actualStateUpdateTowardsItem;
|
||||
if (stateUpdateFromHandler) {
|
||||
final State updateFromHandler;
|
||||
if (updateFromHandlerObj instanceof String) {
|
||||
updateFromHandler = new QuantityType((String) updateFromHandlerObj);
|
||||
} else {
|
||||
assertTrue(updateFromHandlerObj instanceof State);
|
||||
updateFromHandler = (State) updateFromHandlerObj;
|
||||
}
|
||||
|
||||
profile.onStateUpdateFromHandler(updateFromHandler);
|
||||
|
||||
ArgumentCaptor<State> capture = ArgumentCaptor.forClass(State.class);
|
||||
verify(callback, times(1)).sendUpdate(capture.capture());
|
||||
actualStateUpdateTowardsItem = capture.getValue();
|
||||
} else {
|
||||
final Command updateFromHandler;
|
||||
if (updateFromHandlerObj instanceof String) {
|
||||
updateFromHandler = new QuantityType((String) updateFromHandlerObj);
|
||||
} else {
|
||||
assertTrue(updateFromHandlerObj instanceof State);
|
||||
updateFromHandler = (Command) updateFromHandlerObj;
|
||||
}
|
||||
|
||||
profile.onCommandFromHandler(updateFromHandler);
|
||||
|
||||
ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
|
||||
verify(callback, times(1)).sendCommand(capture.capture());
|
||||
actualStateUpdateTowardsItem = capture.getValue();
|
||||
}
|
||||
|
||||
Type expectedStateUpdateTowardsItem = (expectedUpdateTowardsItemObj instanceof String)
|
||||
? new QuantityType((String) expectedUpdateTowardsItemObj)
|
||||
: (Type) expectedUpdateTowardsItemObj;
|
||||
// Workaround for errors like "java.lang.UnsupportedOperationException: °C is non-linear, cannot convert"
|
||||
if (expectedStateUpdateTowardsItem instanceof QuantityType<?>) {
|
||||
assertTrue(actualStateUpdateTowardsItem instanceof QuantityType<?>);
|
||||
assertEquals(((QuantityType<?>) expectedStateUpdateTowardsItem).getUnit(),
|
||||
((QuantityType<?>) actualStateUpdateTowardsItem).getUnit());
|
||||
assertEquals(((QuantityType<?>) expectedStateUpdateTowardsItem).toBigDecimal(),
|
||||
((QuantityType<?>) actualStateUpdateTowardsItem).toBigDecimal());
|
||||
} else {
|
||||
assertEquals(expectedStateUpdateTowardsItem, actualStateUpdateTowardsItem);
|
||||
}
|
||||
verifyNoMoreInteractions(callback);
|
||||
}
|
||||
|
||||
private static Stream<Arguments> provideAdditionalArgsForCommandFromItem() {
|
||||
return Stream.of(
|
||||
// Dimensionless conversion 2.5/1% = 250%/1% = 250
|
||||
// gain in %, command as bare ratio and the other way around
|
||||
Arguments.of("0", "1 %", "250", "2.5"), Arguments.of("2%", "1 %", "249.9800", "2.5"),
|
||||
|
||||
// celsius gain, kelvin command
|
||||
Arguments.of("0", "0.1 °C", "-2706.5", "2.5 K"),
|
||||
|
||||
// incompatible command unit, should be convertible with gain
|
||||
Arguments.of("0", "0.1 °C", null, "2.5 m/s"),
|
||||
//
|
||||
// incompatible offset unit
|
||||
//
|
||||
Arguments.of("50 K", "21", null, "30 m/s"), Arguments.of("50 m/s", "21", null, "30 K"),
|
||||
//
|
||||
// UNDEF command is not processed
|
||||
//
|
||||
Arguments.of("0", "0", null, UnDefType.UNDEF),
|
||||
//
|
||||
// REFRESH command is forwarded
|
||||
//
|
||||
Arguments.of("0", "0", RefreshType.REFRESH, RefreshType.REFRESH)
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Test profile behaviour when item receives command
|
||||
*
|
||||
* @param rawOffset profile raw offset
|
||||
* @param gain profile gain
|
||||
* @param expectedCommandTowardsHandlerObj expected command towards handler. String representing QuantityType or
|
||||
* Command. Use null to verify that no commands are sent to handler.
|
||||
* @param commandFromItemObj command that item receives. String representing QuantityType or Command.
|
||||
*/
|
||||
@SuppressWarnings({ "rawtypes" })
|
||||
@ParameterizedTest
|
||||
@MethodSource({ "provideArgsForBoth", "provideAdditionalArgsForCommandFromItem" })
|
||||
public void testOnCommandFromItem(String rawOffset, String gain, @Nullable Object expectedCommandTowardsHandlerObj,
|
||||
Object commandFromItemObj) {
|
||||
assumeFalse(commandFromItemObj.equals(UnDefType.UNDEF));
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
ModbusGainOffsetProfile profile = createProfile(callback, gain, rawOffset);
|
||||
|
||||
Command commandFromItem = (commandFromItemObj instanceof String) ? new QuantityType((String) commandFromItemObj)
|
||||
: (Command) commandFromItemObj;
|
||||
profile.onCommandFromItem(commandFromItem);
|
||||
|
||||
boolean callsExpected = expectedCommandTowardsHandlerObj != null;
|
||||
if (callsExpected) {
|
||||
ArgumentCaptor<Command> capture = ArgumentCaptor.forClass(Command.class);
|
||||
verify(callback, times(1)).handleCommand(capture.capture());
|
||||
Command actualCommandTowardsHandler = capture.getValue();
|
||||
Command expectedCommandTowardsHandler = (expectedCommandTowardsHandlerObj instanceof String)
|
||||
? new QuantityType((String) expectedCommandTowardsHandlerObj)
|
||||
: (Command) expectedCommandTowardsHandlerObj;
|
||||
assertEquals(expectedCommandTowardsHandler, actualCommandTowardsHandler);
|
||||
verifyNoMoreInteractions(callback);
|
||||
} else {
|
||||
verifyNoInteractions(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Test behaviour when item receives state update from item (no-op)
|
||||
*
|
||||
**/
|
||||
@Test
|
||||
public void testOnCommandFromItem() {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
ModbusGainOffsetProfile<?> profile = createProfile(callback, "1.0", "0.0");
|
||||
|
||||
profile.onStateUpdateFromItem(new DecimalType(3.78));
|
||||
// should be no-op
|
||||
verifyNoInteractions(callback);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInvalidInit() {
|
||||
// offset must be dimensionless
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
ModbusGainOffsetProfile<?> profile = createProfile(callback, "1.0", "0.0 K");
|
||||
assertFalse(profile.isValid());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@EmptySource
|
||||
public void testInitGainDefault(String gain) {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
ModbusGainOffsetProfile<?> p = createProfile(callback, gain, "0.0");
|
||||
assertTrue(p.isValid());
|
||||
assertEquals(p.getGain(), Optional.of(QuantityType.ONE));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@NullSource
|
||||
@EmptySource
|
||||
public void testInitOffsetDefault(String offset) {
|
||||
ProfileCallback callback = mock(ProfileCallback.class);
|
||||
ModbusGainOffsetProfile<?> p = createProfile(callback, "1", offset);
|
||||
assertTrue(p.isValid());
|
||||
assertEquals(p.getPregainOffset(), Optional.of(QuantityType.ZERO));
|
||||
}
|
||||
|
||||
private ModbusGainOffsetProfile<?> createProfile(ProfileCallback callback, @Nullable String gain,
|
||||
@Nullable String preGainOffset) {
|
||||
ProfileContext context = mock(ProfileContext.class);
|
||||
Configuration config = new Configuration();
|
||||
if (gain != null) {
|
||||
config.put("gain", gain);
|
||||
}
|
||||
if (preGainOffset != null) {
|
||||
config.put("pre-gain-offset", preGainOffset);
|
||||
}
|
||||
when(context.getConfiguration()).thenReturn(config);
|
||||
|
||||
return new ModbusGainOffsetProfile<>(callback, context);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user