| ------------------------------------------- | ------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readValueType` | text | | (empty) | How data is read from modbus. Use empty for write-only things.<br /><br />Bit value type must be used with coils and discrete inputs. With registers all value types are applicable. Valid values are: `"int64"`, `"int64_swap"`, `"uint64"`, `"uint64_swap"`, `"float32"`, `"float32_swap"`, `"int32"`, `"int32_swap"`, `"uint32"`, `"uint32_swap"`, `"int16"`, `"uint16"`, `"int8"`, `"uint8"`, or `"bit"`. See also [Value types on read and write](#value-types-on-read-and-write). |
| `readStart` | text | | (empty) | Start address to start reading the value. Use empty for write-only things. <br /><br />Input as zero-based index number, e.g. in place of `400001` (first holding register), use the address `"0"`. Must be between (poller start) and (poller start + poller length - 1) (inclusive).<br /><br />With registers and value type less than 16 bits, you must use `"X.Y"` format where `Y` specifies the sub-element to read from the 16 bit register:<ul> <li>For example, `"3.1"` would mean pick second bit from register index `3` with bit value type. </li><li>With int8 valuetype, it would pick the high byte of register index `3`.</li></ul> |
-| `readTransform` | text | | `"default"` | Transformation to apply to polled data, after it has been converted to number using `readValueType`. <br /><br />Use "default" to communicate that no transformation is done and value should be passed as is.<br />Use `"SERVICENAME:ARG"` or `"SERVICENAME(ARG)"` (old syntax) to use transformation service `SERVICENAME` with argument `ARG`. <br />Any other value than the above types will be interpreted as static text, in which case the actual content of the polled value is ignored. ou can chain many transformations with ∩, for example `"SERVICE1:ARG1∩SERVICE2:ARG2"`. |
-| `writeValueType` | text | | (empty) | How data is written to modbus. Only applicable to registers. Valid values are: `"int64"`, `"int64_swap"`, `"float32"`, `"float32_swap"`, `"int32"`, `"int32_swap"`, `"int16"`. See also [Value types on read and write](#value-types-on-read-and-write). |
-| `writeStart` | text | | (empty) | 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. |
+| `readTransform` | text | | `"default"` | Transformation to apply to polled data, after it has been converted to number using `readValueType`. <br /><br />Use "default" to communicate that no transformation is done and value should be passed as is.<br />Use `"SERVICENAME:ARG"` or `"SERVICENAME(ARG)"` (old syntax) to use transformation service `SERVICENAME` with argument `ARG`. <br />Any other value than the above types will be interpreted as static text, in which case the actual content of the polled value is ignored. You can chain many transformations with ∩, for example `"SERVICE1:ARG1∩SERVICE2:ARG2"`. |
+| `writeValueType` | text | | (empty) | How data is written to modbus. Only applicable to registers. Valid values are: `"int64"`, `"int64_swap"`, `"float32"`, `"float32_swap"`, `"int32"`, `"int32_swap"`, `"int16"`. See also [Value types on read and write](#value-types-on-read-and-write). Value of `"bit"` can be used with registers as well when `writeStart` is of format `"X.Y"` (see below). See also [Value types on read and write](#value-types-on-read-and-write). |
+| `writeStart` | text | | (empty) | 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. One can use `"X.Y"` to write individual bit `Y` of an holding `X` (analogous to `readStart`). |
| `writeType` | text | | (empty) | Type of data to write. Use empty for read-only things. Valid values: `"coil"` or `"holding"`.<br /><br /> Coil uses function code (FC) FC05 or FC15. Holding register uses FC06 or FC16. See `writeMultipleEvenWithSingleRegisterOrCoil` parameter. |
-| `writeTransform` | text | | `"default"` | Transformation to apply to received commands.<br /><br />Use `"default"` to communicate that no transformation is done and value should be passed as is. <br />Use `"SERVICENAME:ARG"` or `"SERVICENAME(ARG)"` (old syntax) to use transformation service `SERVICENAME` with argument `ARG`. <br />Any other value than the above types will be interpreted as static text, in which case the actual content of the command value is ignored. You can chain many transformations with ∩, for example `"SERVICE1:ARG1∩SERVICE2:ARG2"`. |
+| `writeTransform` | text | | `"default"` | Transformation to apply to received commands.<br /><br />Use `"default"` to communicate that no transformation is done and value should be passed as is. <br />Use `"SERVICENAME:ARG"` or `"SERVICENAME(ARG)"` (old syntax) to use transformation service `SERVICENAME` with argument `ARG`. <br />Any other value than the above types will be interpreted as static text, in which case the actual content of the command value is ignored. You can chain many transformations with ∩, for example `"SERVICE1:ARG1∩SERVICE2:ARG2"`. |
| `writeMultipleEvenWithSingleRegisterOrCoil` | boolean | | `false` | Controls how single register / coil of data is written.<br /> By default, or when 'false, FC06 ("Write single holding register") / FC05 ("Write single coil"). Or when 'true', using FC16 ("Write Multiple Holding Registers") / FC15 ("Write Multiple Coils"). |
| `writeMaxTries` | integer | | `3` | Maximum tries when writing <br /><br />Number of tries when writing data, if some of the writes fail. For single try, enter `1`. |
| `updateUnchangedValuesEveryMillis` | integer | | `1000` | Interval to update unchanged values. <br /><br />Modbus binding by default is not updating the item and channel state every time new data is polled from a slave, for performance reasons. Instead, the state is updated whenever it differs from previously updated state, or when enough time has passed since the last update. The time interval can be adjusted using this parameter. Use value of `0` if you like to update state with every poll, even though the value has not changed. In milliseconds. |
Main documentation on `autoupdate` in [Items section of openHAB docs](https://www.openhab.org/docs/configuration/items.html#item-definition-and-syntax).
+### Profiles
+
+#### `modbus:gainOffset`
+
+This profile is meant for simple scaling and offsetting of values received from the Modbus slave.
+The profile works also in the reverse direction, when commanding items.
+
+In addition, the profile allows attaching units to the raw numbers, as well as converting the quantity-aware numbers to bare numbers on write.
+
+Profile has two parameters, `gain` (bare number or number with unit) and `pre-offset` (bare number), both of which must be provided.
+
+When reading from Modbus, the result will be `updateTowardsItem = (raw_value_from_modbus + preOffset) * gain`.
+When applying command, the calculation goes in reverse.
+
+See examples for concrete use case with value scaling.
### Discovery
Device specific modbus bindings can take part in the discovery of things, and detect devices automatically. The discovery is initiated by the `tcp` and `serial` bridges when they have `enableDiscovery` setting enabled.
There are three different format to specify the configuration:
1. String `"default"`, in which case the default transformation is used. The default is to convert non-zero numbers to `ON`/`OPEN`, and zero numbers to `OFF`/`CLOSED`, respectively. If the item linked to the data channel does not accept these states, the number is converted to best-effort-basis to the states accepted by the item. For example, the extracted number is passed as-is for `Number` items, while `ON`/`OFF` would be used with `DimmerItem`.
-1. `"SERVICENAME:ARG"` for calling a transformation service. The transformation receives the extracted number as input. This is useful for example scaling (divide by x) the polled data before it is used in openHAB. See examples for more details.
+1. `"SERVICENAME:ARG"` for calling a transformation service. The transformation receives the extracted number as input. This is useful for applying complex arithmetic of the polled data before it is used in openHAB. See examples for more details.
1. Any other value is interpreted as static text, in which case the actual content of the polled value is ignored. Transformation result is always the same. The transformation output is converted to best-effort-basis to the states accepted by the item.
Consult [background documentation on items](https://www.openhab.org/docs/concepts/items.html) to understand accepted data types (state) by each item.
There are three different format to specify the configuration:
1. String `"default"`, in which case the default transformation is used. The default is to do no conversion to the command.
-1. `"SERVICENAME:ARG"` for calling a transformation service. The transformation receives the command as input. This is useful for example scaling ("multiply by x") commands before the data is written to Modbus. See examples for more details.
+1. `"SERVICENAME:ARG"` for calling a transformation service. The transformation receives the command as input. This is useful for applying complex arithmetic for commands before the data is written to Modbus. See examples for more details.
1. Any other value is interpreted as static text, in which case the actual command is ignored. Transformation result is always the same.
-
-#### Transformation Example: Scaling
-
-Typical use case for transformations is scaling of numbers.
-The data in Modbus slaves is quite commonly encoded as integers, and thus scaling is necessary to convert them to useful float numbers.
-
-`transform/multiply10.js`:
-
-```javascript
-// Wrap everything in a function (no global variable pollution)
-// variable "input" contains data passed by openHAB
-(function(inputData) {
- // on read: the polled number as string
- // on write: openHAB command as string
- var MULTIPLY_BY = 10;
- return Math.round(parseFloat(inputData, 10) * MULTIPLY_BY);
-})(input)
-```
-
-`transform/divide10.js`:
-
-```javascript
-// Wrap everything in a function (no global variable pollution)
-// variable "input" contains data passed by openHAB
-(function(inputData) {
- // on read: the polled number as string
- // on write: openHAB command as string
- var DIVIDE_BY = 10;
- return parseFloat(inputData) / DIVIDE_BY;
-})(input)
-```
-
-See [Scaling example](#scaling-example) for full example with things, items and a sitemap.
-
#### Example: Inverting Binary Data On Read And Write
This example transformation is able to invert "boolean" input.
`things/modbus_ex1.things`:
-```xtend
+```
Bridge modbus:tcp:localhostTCP [ host="127.0.0.1", port=502, id=2 ] {
// read-write for coils. Reading 4 coils, with index 4, and 5.
`items/modbus_ex1.items`:
-```xtend
+```
Switch DO4 "Digital Output index 4 [%d]" { channel="modbus:data:localhostTCP:coils:do4:switch" }
Switch DO5 "Digital Output index 5 [%d]" { channel="modbus:data:localhostTCP:coils:do5:switch" }
`sitemaps/modbus_ex1.sitemap`:
-```xtend
+```
sitemap modbus_ex1 label="modbus_ex1"
{
Frame {
`things/modbus_ex2.things`:
-```xtend
+```
Bridge modbus:tcp:localhostTCPex2 [ host="127.0.0.1", port=502 ] {
Bridge poller items [ start=4, length=2, refresh=1000, type="discrete" ] {
`items/modbus_ex2.items`:
-```xtend
+```
Switch ReadDI4WriteDO5 "Coil 4/5 mix [%d]" { channel="modbus:data:localhostTCPex2:items:readDiscrete4WriteCoil5:switch" }
Switch ResetDO5 "Flip to turn Coil 5 OFF [%d]" { channel="modbus:data:localhostTCPex2:items:resetCoil5:switch" }
Switch SetDO5 "Flip to turn Coil 5 ON [%d]" { channel="modbus:data:localhostTCPex2:items:setCoil5:switch" }
`sitemaps/modbus_ex2.sitemap`:
-```xtend
+```
sitemap modbus_ex2 label="modbus_ex2"
{
Frame {
### Scaling Example
-This example divides value on read, and multiplies them on write, using JS transforms.
+Often Modbus slave might have the numbers stored as integers, with no information of the measurement unit.
+In openHAB, it is recommended to scale and attach units for the read data.
+
+In the below example, modbus data needs to be multiplied by `0.1` to convert the value to Celsius.
+For example, raw modbus register value of `45` corresponds to `4.5 °C`.
+
+Note how that unit can be specified within the `gain` parameter of `modbus:gainOffset` profile.
+This enables the use of quantity-aware `Number` item `Number:Temperature`.
+
+The profile also works the other way round, scaling the commands sent to the item to bare-numbers suitable for Modbus.
`things/modbus_ex_scaling.things`:
-```xtend
+```
Bridge modbus:tcp:localhostTCP3 [ host="127.0.0.1", port=502 ] {
Bridge poller holdingPoller [ start=5, length=1, refresh=5000, type="holding" ] {
- Thing data holding5Scaled [ readStart="5", readValueType="int16", readTransform="JS:divide10.js", writeStart="5", writeValueType="int16", writeType="holding", writeTransform="JS:multiply10.js" ]
+ Thing data temperatureDeciCelsius [ readStart="5", readValueType="int16", writeStart="5", writeValueType="int16", writeType="holding" ]
}
}
```
`items/modbus_ex_scaling.items`:
-```xtend
-Number Holding5Scaled "Holding index 5 scaled [%.1f]" { channel="modbus:data:localhostTCP3:holdingPoller:holding5Scaled:number" }
+```
+Number:Temperature TemperatureItem "Temperature [%.1f °C]" { channel="modbus:data:localhostTCP3:holdingPoller:temperatureDeciCelsius:number"[ profile="modbus:gainOffset", gain="0.1 °C", offset="0" ] }
```
`sitemaps/modbus_ex_scaling.sitemap`:
-```xtend
+```
sitemap modbus_ex_scaling label="modbus_ex_scaling"
{
Frame {
- Text item=Holding5Scaled
- Setpoint item=Holding5Scaled minValue=0 maxValue=100 step=20
+ Text item=TemperatureItem
+ Setpoint item=TemperatureItem minValue=0 maxValue=100 step=20
+ }
+}
+```
+
+
+### Commanding Individual Bits
+
+In Modbus, holding registers represent 16 bits of data. The protocol allow to write the whole register at once.
+
+The binding provides convenience functionality to command individual bits of a holding register by keeping a cache of the register internally.
+
+In order to use this feature, one specifies `writeStart="X.Y"` (register `X`, bit `Y`) with `writeValueType="bit"` and `writeType="holding"`.
+
+`things/modbus_ex_command_bit.things`:
+
+```
+Bridge modbus:tcp:localhostTCP3 [ host="127.0.0.1", port=502 ] {
+ Bridge poller holdingPoller [ start=5, length=1, refresh=5000, type="holding" ] {
+ Thing data register5 [ readStart="5.1", readValueType="bit", writeStart="5.1", writeValueType="bit", writeType="holding" ]
+ Thing data register5Bit1 [ readStart="5.1", readValueType="bit" ]
}
}
```
-See [transformation example](#transformation-example-scaling) for the `divide10.js` and `multiply10.js`.
+`items/modbus_ex_command_bit.items`:
+
+```
+Switch SecondLeastSignificantBit "2nd least significant bit write switch [%d]" { channel="modbus:data:localhostTCP3:holdingPoller:register5:switch" }
+Number SecondLeastSignificantBitAltRead "2nd least significant bit is now [%d]" { channel="modbus:data:localhostTCP3:holdingPoller:register5Bit1:number" }
+```
+
+`sitemaps/modbus_ex_command_bit.sitemap`:
+
+```
+sitemap modbus_ex_command_bit label="modbus_ex_command_bit"
+{
+ Frame {
+ Text item=SecondLeastSignificantBitAltRead
+ Switch item=SecondLeastSignificantBit
+ }
+}
+```
### Dimmer Example
`things/modbus_ex_dimmer.things`:
-```xtend
+```
Bridge modbus:tcp:remoteTCP [ host="192.168.0.10", port=502 ] {
Bridge poller MBDimmer [ start=4700, length=2, refresh=1000, type="holding" ] {
Thing data DimmerReg [ readStart="4700", readValueType="uint16", readTransform="JS:dimread255.js", writeStart="4700", writeValueType="uint16", writeType="holding", writeTransform="JS:dimwrite255.js" ]
```
`items/modbus_ex_dimmer.items`:
-```xtend
+```
Dimmer myDimmer "My Dimmer d2 [%.1f]" { channel="modbus:data:remoteTCP:MBDimmer:DimmerReg:dimmer" }
```
`sitemaps/modbus_ex_dimmer.sitemap`:
-```xtend
+```
sitemap modbus_ex_dimmer label="modbus_ex_dimmer"
{
Frame {
`things/modbus_ex_rollershutter.things`:
-```xtend
+```
Bridge modbus:tcp:localhostTCPRollerShutter [ host="127.0.0.1", port=502 ] {
Bridge poller holding [ start=0, length=3, refresh=1000, type="holding" ] {
// Since we are using advanced transformation outputting JSON,
`items/modbus_ex_rollershutter.items`:
-```xtend
+```
// We disable auto-update to make sure that rollershutter position is updated from the slave, not "automatically" via commands
Rollershutter RollershutterItem "Roller shutter position [%.1f]" <temperature> { autoupdate="false", channel="modbus:data:localhostTCPRollerShutter:holding:rollershutterData:rollershutter" }
`sitemaps/modbus_ex_rollershutter.sitemap`:
-```xtend
+```
sitemap modbus_ex_rollershutter label="modbus_ex_rollershutter" {
Switch item=RollershutterItem label="Roller shutter [(%d)]" mappings=[UP="up", STOP="X", DOWN="down", MOVE="move"]
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;
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;
@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));
}
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();
unregisterPollTask();
this.callbackDelegator.resetCache();
comms = null;
+ lastPolledDataCache.set(null);
}
/**
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
}
}
}
+
+ public AtomicReference<@Nullable ModbusRegisterArray> getLastPolledDataCache() {
+ return lastPolledDataCache;
+ }
}
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;
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;
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;
// 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);
}
ModbusWriteRequestBlueprint request = requestFromCommand(channelUID, command, config, transformedCommand.get(),
- writeStart);
+ writeStart.get());
if (request == null) {
return;
}
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)) {
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());
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) {
writeTransformation = null;
readIndex = Optional.empty();
readSubIndex = Optional.empty();
- writeStart = null;
+ writeStart = Optional.empty();
+ writeSubIndex = Optional.empty();
pollStart = 0;
slaveId = 0;
comms = null;
}
}
- 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();
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;
}
}
}
+ 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);
--- /dev/null
+/**
+ * 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;
+ }
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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();
+}
--- /dev/null
+<?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>
</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 />
<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>
--- /dev/null
+/**
+ * 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);
+ }
+}
org.mockito.mockito-core;version='[3.7.0,3.7.1)',\
org.objenesis;version='[3.1.0,3.1.1)',\
org.mockito.junit-jupiter;version='[3.7.0,3.7.1)',\
+ junit-jupiter-params;version='[5.7.0,5.7.1)',\
org.glassfish.hk2.osgi-resource-locator;version='[1.0.3,1.0.4)',\
biz.aQute.tester.junit-platform;version='[5.3.0,5.3.1)',\
com.google.gson;version='[2.8.6,2.8.7)',\
import static org.mockito.Mockito.*;
import static org.openhab.binding.modbus.internal.ModbusBindingConstantsInternal.*;
+import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.function.Consumer;
import java.util.function.Function;
+import java.util.stream.Stream;
import org.hamcrest.Matcher;
import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
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.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
import org.openhab.binding.modbus.handler.EndpointNotInitializedException;
import org.openhab.binding.modbus.handler.ModbusPollerThingHandler;
+import org.openhab.binding.modbus.internal.ModbusBindingConstantsInternal;
import org.openhab.binding.modbus.internal.handler.ModbusDataThingHandler;
import org.openhab.binding.modbus.internal.handler.ModbusTcpThingHandler;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.io.transport.modbus.BitArray;
import org.openhab.core.io.transport.modbus.ModbusConstants;
import org.openhab.core.io.transport.modbus.ModbusConstants.ValueType;
+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;
}
}
+ private static final String HOST = "thisishost";
+ private static final int PORT = 44;
+
private static final Map<String, String> CHANNEL_TO_ACCEPTED_TYPE = new HashMap<>();
static {
CHANNEL_TO_ACCEPTED_TYPE.put(CHANNEL_SWITCH, "Switch");
CHANNEL_TO_ACCEPTED_TYPE.put(CHANNEL_LAST_READ_ERROR, "DateTime");
}
private List<ModbusWriteRequestBlueprint> writeRequests = new ArrayList<>();
+ private Bridge realEndpointWithMockedComms;
+
+ public ModbusReadCallback getPollerCallback(ModbusPollerThingHandler handler) {
+ Field callbackField;
+ try {
+ callbackField = ModbusPollerThingHandler.class.getDeclaredField("callbackDelegator");
+ callbackField.setAccessible(true);
+ return (ModbusReadCallback) callbackField.get(handler);
+ } catch (NoSuchFieldException | SecurityException | IllegalArgumentException | IllegalAccessException e) {
+ fail(e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ @BeforeEach
+ public void beforeEach() {
+ mockCommsToModbusManager();
+ Configuration tcpConfig = new Configuration();
+ tcpConfig.put("host", HOST);
+ tcpConfig.put("port", PORT);
+ tcpConfig.put("id", 9);
+
+ realEndpointWithMockedComms = BridgeBuilder
+ .create(ModbusBindingConstantsInternal.THING_TYPE_MODBUS_TCP,
+ new ThingUID(ModbusBindingConstantsInternal.THING_TYPE_MODBUS_TCP, "mytcp"))
+ .withLabel("label for mytcp").withConfiguration(tcpConfig).build();
+ addThing(realEndpointWithMockedComms);
+ assertEquals(ThingStatus.ONLINE, realEndpointWithMockedComms.getStatus(),
+ realEndpointWithMockedComms.getStatusInfo().getDescription());
+ }
@AfterEach
public void tearDown() {
writeRequests.clear();
+ if (realEndpointWithMockedComms != null) {
+ thingProvider.remove(realEndpointWithMockedComms.getUID());
+ }
+ }
+
+ private static Arguments appendArg(Arguments args, Object obj) {
+ Object[] newArgs = Arrays.copyOf(args.get(), args.get().length + 1);
+ newArgs[args.get().length] = obj;
+ return Arguments.of(newArgs);
}
private void captureModbusWrites() {
return dataHandler;
}
- @SuppressWarnings({ "null" })
private ModbusDataThingHandler testWriteHandlingGeneric(String start, String transform, ValueType valueType,
String writeType, ModbusWriteFunctionCode successFC, String channel, Command command, Exception error,
BundleContext context) {
+ return testWriteHandlingGeneric(start, transform, valueType, writeType, successFC, channel, command, error,
+ context, false);
+ }
+
+ @SuppressWarnings({ "null" })
+ private ModbusDataThingHandler testWriteHandlingGeneric(String start, String transform, ValueType valueType,
+ String writeType, ModbusWriteFunctionCode successFC, String channel, Command command, Exception error,
+ BundleContext context, boolean parentIsEndpoint) {
ModbusSlaveEndpoint endpoint = new ModbusTCPSlaveEndpoint("thisishost", 502, false);
// Minimally mocked request
doReturn(endpoint).when(task).getEndpoint();
doReturn(request).when(task).getRequest();
- Bridge poller = createPollerMock("poller1", task);
+ final Bridge parent;
+ if (parentIsEndpoint) {
+ parent = createTcpMock();
+ addThing(parent);
+ } else {
+ parent = createPollerMock("poller1", task);
+ }
Configuration dataConfig = new Configuration();
dataConfig.put("readStart", "");
String thingId = "write";
- ModbusDataThingHandler dataHandler = createDataHandler(thingId, poller,
+ ModbusDataThingHandler dataHandler = createDataHandler(thingId, parent,
builder -> builder.withConfiguration(dataConfig), context);
assertThat(dataHandler.getThing().getStatus(), is(equalTo(ThingStatus.ONLINE)));
assertSingleStateUpdate(dataHandler, CHANNEL_STRING, is(equalTo(new StringType("ON"))));
}
+ @Test
+ public void testWriteWithDataAsChildOfEndpoint() throws InvalidSyntaxException {
+ captureModbusWrites();
+ mockTransformation("MULTIPLY", new MultiplyTransformation());
+ ModbusDataThingHandler dataHandler = testWriteHandlingGeneric("50", "MULTIPLY(10)",
+ ModbusConstants.ValueType.BIT, "coil", ModbusWriteFunctionCode.WRITE_COIL, "number",
+ new DecimalType("2"), null, bundleContext, /* parent is endpoint */true);
+
+ assertSingleStateUpdate(dataHandler, CHANNEL_LAST_WRITE_SUCCESS, is(notNullValue(State.class)));
+ assertSingleStateUpdate(dataHandler, CHANNEL_LAST_WRITE_ERROR, is(nullValue(State.class)));
+ assertThat(writeRequests.size(), is(equalTo(1)));
+ ModbusWriteRequestBlueprint writeRequest = writeRequests.get(0);
+ assertThat(writeRequest.getFunctionCode(), is(equalTo(ModbusWriteFunctionCode.WRITE_COIL)));
+ assertThat(writeRequest.getReference(), is(equalTo(50)));
+ assertThat(((ModbusWriteCoilRequestBlueprint) writeRequest).getCoils().size(), is(equalTo(1)));
+ // Since transform output is non-zero, it is mapped as "true"
+ assertThat(((ModbusWriteCoilRequestBlueprint) writeRequest).getCoils().getBit(0), is(equalTo(true)));
+ }
+
@Test
public void testWriteRealTransformation() throws InvalidSyntaxException {
captureModbusWrites();
waitForAssert(() -> verify((ModbusPollerThingHandler) poller.getHandler()).refresh());
}
+ private static Stream<Arguments> provideArgsForUpdateThenCommandFromItem()
+
+ {
+ return Stream.of(//
+ // ON/OFF commands
+ Arguments.of((short) 0b1011_0100_0000_1111, "1", (short) 0b1011_0100_0000_1101, OnOffType.OFF),
+ Arguments.of((short) 0b1011_0100_0000_1111, "4", (short) 0b1011_0100_0001_1111, OnOffType.ON),
+ // OPEN/CLOSED commands
+ Arguments.of((short) 0b1011_0100_0000_1111, "1", (short) 0b1011_0100_0000_1101, OpenClosedType.CLOSED),
+ Arguments.of((short) 0b1011_0100_0000_1111, "4", (short) 0b1011_0100_0001_1111, OpenClosedType.OPEN),
+ // DecimalType commands
+ Arguments.of((short) 0b1011_0100_0000_1111, "1", (short) 0b1011_0100_0000_1101, new DecimalType(0)),
+ Arguments.of((short) 0b1011_0100_0010_1111, "5", (short) 0b1011_0100_0000_1111, new DecimalType(0)),
+ Arguments.of((short) 0b1011_0100_0000_1111, "4", (short) 0b1011_0100_0001_1111, new DecimalType(5)),
+ Arguments.of((short) 0b1011_0100_0000_1111, "15", (short) 0b0011_0100_0000_1111, new DecimalType(0))
+
+ ).flatMap(a -> {
+ // parametrize by channel (yes, it does not matter what channel is used, commands are interpreted all the
+ // same)
+ Stream<String> channels = Stream.of("switch", "number", "contact");
+ return channels.map(channel -> appendArg(a, channel));
+ });
+ }
+
+ @ParameterizedTest
+ @MethodSource("provideArgsForUpdateThenCommandFromItem")
+ public void testUpdateFromHandlerThenCommandFromItem(short stateUpdateFromHandler, String bitIndex,
+ short expectedWriteDataToSlave, Command commandFromItem, String channel) {
+ int expectedWriteDataToSlaveUnsigned = expectedWriteDataToSlave & 0xFFFF;
+ captureModbusWrites();
+ Configuration pollerConfig = new Configuration();
+ pollerConfig.put("refresh", 0L); // 0 -> non polling
+ pollerConfig.put("start", "2");
+ pollerConfig.put("length", "3");
+ pollerConfig.put("type", ModbusBindingConstantsInternal.READ_TYPE_HOLDING_REGISTER);
+ ThingUID pollerUID = new ThingUID(ModbusBindingConstantsInternal.THING_TYPE_MODBUS_POLLER, "realPoller");
+ Bridge poller = BridgeBuilder.create(ModbusBindingConstantsInternal.THING_TYPE_MODBUS_POLLER, pollerUID)
+ .withLabel("label for realPoller").withConfiguration(pollerConfig)
+ .withBridge(realEndpointWithMockedComms.getUID()).build();
+ addThing(poller);
+ assertEquals(ThingStatus.ONLINE, poller.getStatus(), poller.getStatusInfo().getDescription());
+
+ Configuration dataConfig = new Configuration();
+ dataConfig.put("writeStart", "3." + bitIndex);
+ dataConfig.put("writeValueType", "bit");
+ dataConfig.put("writeType", "holding");
+
+ String thingId = "read1";
+
+ ModbusDataThingHandler dataHandler = createDataHandler(thingId, poller,
+ builder -> builder.withConfiguration(dataConfig), bundleContext);
+ assertEquals(ThingStatus.ONLINE, dataHandler.getThing().getStatus());
+ assertEquals(pollerUID, dataHandler.getThing().getBridgeUID());
+
+ AsyncModbusReadResult result = new AsyncModbusReadResult(Mockito.mock(ModbusReadRequestBlueprint.class),
+ new ModbusRegisterArray(/* register 2, dummy data */0, /* register 3 */ stateUpdateFromHandler,
+ /* register 4, dummy data */9));
+
+ // poller receives some data (and therefore data as well)
+ getPollerCallback(((ModbusPollerThingHandler) poller.getHandler())).handle(result);
+ dataHandler.handleCommand(new ChannelUID(dataHandler.getThing().getUID(), channel), commandFromItem);
+
+ // Assert data written
+ {
+ assertEquals(1, writeRequests.size());
+ ModbusWriteRequestBlueprint writeRequest = writeRequests.get(0);
+ assertEquals(writeRequest.getFunctionCode(), ModbusWriteFunctionCode.WRITE_SINGLE_REGISTER);
+ assertEquals(writeRequest.getReference(), 3);
+ assertEquals(((ModbusWriteRegisterRequestBlueprint) writeRequest).getRegisters().size(), 1);
+ assertEquals(expectedWriteDataToSlaveUnsigned,
+ ((ModbusWriteRegisterRequestBlueprint) writeRequest).getRegisters().getRegister(0));
+ }
+ }
+
+ private void testInitGeneric(ModbusReadFunctionCode pollerFunctionCode, Configuration config,
+ Consumer<ThingStatusInfo> statusConsumer) {
+ testInitGeneric(pollerFunctionCode, 0, config, statusConsumer);
+ }
+
/**
*
* @param pollerFunctionCode poller function code. Use null if you want to have data thing direct child of endpoint
* thing
+ * @param pollerStart start index of poller
* @param config thing config
* @param statusConsumer assertion method for data thingstatus
*/
- private void testInitGeneric(ModbusReadFunctionCode pollerFunctionCode, Configuration config,
+ private void testInitGeneric(ModbusReadFunctionCode pollerFunctionCode, int pollerStart, Configuration config,
Consumer<ThingStatusInfo> statusConsumer) {
int pollLength = 3;
// Minimally mocked request
ModbusReadRequestBlueprint request = Mockito.mock(ModbusReadRequestBlueprint.class);
+ doReturn(pollerStart).when(request).getReference();
doReturn(pollLength).when(request).getDataLength();
doReturn(pollerFunctionCode).when(request).getFunctionCode();
dataConfig.put("writeValueType", "int8");
dataConfig.put("writeType", "holding");
testInitGeneric(null, dataConfig, status -> {
- assertThat(status.getStatus(), is(equalTo(ThingStatus.OFFLINE)));
+ assertEquals(ThingStatus.OFFLINE, status.getStatus(), status.getDescription());
+ assertThat(status.getStatusDetail(), is(equalTo(ThingStatusDetail.CONFIGURATION_ERROR)));
+ });
+ }
+
+ @Test
+ public void testWriteHoldingBitDataWrongWriteType() {
+ Configuration dataConfig = new Configuration();
+ dataConfig.put("writeStart", "0.15");
+ dataConfig.put("writeValueType", "bit");
+ dataConfig.put("writeType", "coil"); // X.Y writeStart only applicable with holding
+ testInitGeneric(ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, dataConfig, status -> {
+ assertEquals(ThingStatus.OFFLINE, status.getStatus(), status.getDescription());
assertThat(status.getStatusDetail(), is(equalTo(ThingStatusDetail.CONFIGURATION_ERROR)));
});
}
@Test
public void testWriteHoldingBitData() {
Configuration dataConfig = new Configuration();
- dataConfig.put("writeStart", "0");
+ dataConfig.put("writeStart", "0.15");
dataConfig.put("writeValueType", "bit");
dataConfig.put("writeType", "holding");
- testInitGeneric(null, dataConfig, status -> {
- assertThat(status.getStatus(), is(equalTo(ThingStatus.OFFLINE)));
+ testInitGeneric(ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, dataConfig, status -> {
+ assertEquals(status.getStatus(), ThingStatus.ONLINE, status.getDescription());
+ });
+ }
+
+ @Test
+ public void testWriteHoldingInt8WithSubIndexData() {
+ Configuration dataConfig = new Configuration();
+ dataConfig.put("writeStart", "1.0");
+ dataConfig.put("writeValueType", "int8");
+ dataConfig.put("writeType", "holding");
+ // OFFLINE since sub-register writes are not supported for other than bit
+ testInitGeneric(ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, dataConfig, status -> {
+ assertEquals(status.getStatus(), ThingStatus.OFFLINE, status.getDescription());
+ });
+ }
+
+ @Test
+ public void testWriteHoldingBitDataRegisterOutOfBounds() {
+ Configuration dataConfig = new Configuration();
+ // in this test poller reads from register 2. Register 1 is out of bounds
+ dataConfig.put("writeStart", "1.15");
+ dataConfig.put("writeValueType", "bit");
+ dataConfig.put("writeType", "holding");
+ testInitGeneric(ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, /* poller start */2, dataConfig, status -> {
+ assertEquals(ThingStatus.OFFLINE, status.getStatus(), status.getDescription());
+ assertThat(status.getStatusDetail(), is(equalTo(ThingStatusDetail.CONFIGURATION_ERROR)));
+ });
+ }
+
+ @Test
+ public void testWriteHoldingBitDataRegisterOutOfBounds2() {
+ Configuration dataConfig = new Configuration();
+ // register 3 is the last one polled, 4 is out of bounds
+ dataConfig.put("writeStart", "4.15");
+ dataConfig.put("writeValueType", "bit");
+ dataConfig.put("writeType", "holding");
+ testInitGeneric(ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, dataConfig, status -> {
+ assertEquals(ThingStatus.OFFLINE, status.getStatus(), status.getDescription());
+ });
+ }
+
+ @ParameterizedTest
+ @CsvSource({ "READ_COILS", "READ_INPUT_DISCRETES", "READ_INPUT_REGISTERS" })
+ public void testWriteHoldingBitDataWrongPoller(ModbusReadFunctionCode poller) {
+ Configuration dataConfig = new Configuration();
+ dataConfig.put("writeStart", "0.15");
+ dataConfig.put("writeValueType", "bit");
+ dataConfig.put("writeType", "holding");
+ testInitGeneric(poller, dataConfig, status -> {
+ assertEquals(ThingStatus.OFFLINE, status.getStatus(), status.getDescription());
+ assertThat(status.getStatusDetail(), is(equalTo(ThingStatusDetail.CONFIGURATION_ERROR)));
+ });
+ }
+
+ @Test
+ public void testWriteHoldingBitParentEndpointData() {
+ Configuration dataConfig = new Configuration();
+ dataConfig.put("writeStart", "0.15");
+ dataConfig.put("writeValueType", "bit");
+ dataConfig.put("writeType", "holding");
+ // OFFLINE since we require poller as parent when sub-register writes are used
+ testInitGeneric(/* poller not as parent */null, dataConfig, status -> {
+ assertEquals(ThingStatus.OFFLINE, status.getStatus(), status.getDescription());
+ assertThat(status.getStatusDetail(), is(equalTo(ThingStatusDetail.CONFIGURATION_ERROR)));
+ });
+ }
+
+ @Test
+ public void testWriteHoldingBitBadStartData() {
+ Configuration dataConfig = new Configuration();
+ dataConfig.put("writeStart", "0.16");
+ dataConfig.put("writeValueType", "int8");
+ dataConfig.put("writeType", "holding");
+ testInitGeneric(ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, dataConfig, status -> {
+ assertEquals(ThingStatus.OFFLINE, status.getStatus(), status.getDescription());
assertThat(status.getStatusDetail(), is(equalTo(ThingStatusDetail.CONFIGURATION_ERROR)));
});
}
dataConfig.put("writeValueType", "bit");
// missing writeType --> error
testInitGeneric(ModbusReadFunctionCode.READ_COILS, dataConfig, status -> {
- assertThat(status.getStatus(), is(equalTo(ThingStatus.OFFLINE)));
+ assertEquals(ThingStatus.OFFLINE, status.getStatus(), status.getDescription());
assertThat(status.getStatusDetail(), is(equalTo(ThingStatusDetail.CONFIGURATION_ERROR)));
assertThat(status.getDescription(), is(not(equalTo(null))));
});
dataConfig.put("writeStart", "0");
dataConfig.put("writeType", "coil");
testInitGeneric(ModbusReadFunctionCode.READ_COILS, dataConfig,
- status -> assertThat(status.getStatus(), is(equalTo(ThingStatus.ONLINE))));
+ status -> assertEquals(ThingStatus.ONLINE, status.getStatus(), status.getDescription()));
}
@Test
@Test
public void testWriteTransformAndNecessary() {
Configuration dataConfig = new Configuration();
- // It's illegal to have start and transform. Just have transform or have all
dataConfig.put("writeStart", "3");
dataConfig.put("writeType", "holding");
dataConfig.put("writeValueType", "int16");