From 265fd30ba157516f4366b2be9cf8b89b6e23f620 Mon Sep 17 00:00:00 2001 From: Sami Salonen Date: Fri, 16 Apr 2021 23:59:55 +0300 Subject: [PATCH] [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 * [modbus] README trailing whitespaces Signed-off-by: Sami Salonen * [modbus] README and some final renaming Signed-off-by: Sami Salonen * [modbus] log error with incompatible units Signed-off-by: Sami Salonen * [modbus] gainOffset profile: test for incompatible unit Signed-off-by: Sami Salonen * [modbus] example renamed Signed-off-by: Sami Salonen * [modbus] Remove unused fields Signed-off-by: Sami Salonen * [modbus] gainOffset profile: make configuration parameters optional Signed-off-by: Sami Salonen * [modbus] xml indentantion fix Signed-off-by: Sami Salonen * [modbus] static code analysis fixes Signed-off-by: Sami Salonen * [modbus] Minor fixes for null checking Signed-off-by: Sami Salonen * [modbus] remove comment Signed-off-by: Sami Salonen * [modbus] bit profile README disclaimer with many commands Signed-off-by: Sami Salonen * [modbus] Grammar fixes in README Signed-off-by: Sami Salonen * [modbus] Fix bit profile UI configuration Signed-off-by: Sami Salonen * [modbus] Bit profile: Added possibility to invert value on read/write Signed-off-by: Sami Salonen * [modbus] fix typo with explanation of inverted Signed-off-by: Sami Salonen * [modbus] bit profile: unit tests for inverted parameter Signed-off-by: Sami Salonen * [modbus] spotless:apply Signed-off-by: Sami Salonen * [modbus] static checker fixes Signed-off-by: Sami Salonen * [modbus] write bit feature in data thing Signed-off-by: Sami Salonen * wip Signed-off-by: Sami Salonen * [modbus] resolve itest Signed-off-by: Sami Salonen * [modbus] fixes Signed-off-by: Sami Salonen * [modbus] Remove bit profile Signed-off-by: Sami Salonen * [modbus] Fix data thing readStart validation Signed-off-by: Sami Salonen * [modbus] readme fix Signed-off-by: Sami Salonen * [modbus] Remove bit profile test Signed-off-by: Sami Salonen * [modbus] Invalidate REFRESH data cache with cacheful writes Signed-off-by: Sami Salonen * [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 * [modbus] README Fix typo in example Signed-off-by: Sami Salonen * [modbus] fix data thing write when child of endpoint Also added regression test Signed-off-by: Sami Salonen * Update bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/config/gainOffset.xml Signed-off-by: Sami Salonen Co-authored-by: Fabian Wolter * [modbus] performance-optimized logging Signed-off-by: Sami Salonen * [modbus] README: Removing xtend syntax hint, not needed anymore Signed-off-by: Sami Salonen * [modbus] generics typing added Signed-off-by: Sami Salonen * [modbus] dead code Signed-off-by: Sami Salonen * [modbus] avoid supressing generic type warnings Signed-off-by: Sami Salonen * [modbus] unnecessary generics Signed-off-by: Sami Salonen * [modbus] rename type parameter name Signed-off-by: Sami Salonen * [modbus] QU (short for quantity output) generic type instead of Q2 Signed-off-by: Sami Salonen * [modbus] Remove unused localization Signed-off-by: Sami Salonen * [modbus] profile constant visibility harmonized Signed-off-by: Sami Salonen * [modbus] spotless:apply Signed-off-by: Sami Salonen Co-authored-by: Fabian Wolter --- bundles/org.openhab.binding.modbus/README.md | 149 +++++---- .../handler/ModbusPollerThingHandler.java | 26 ++ .../handler/ModbusDataThingHandler.java | 176 ++++++++-- .../profiles/ModbusGainOffsetProfile.java | 258 +++++++++++++++ .../profiles/ModbusProfileFactory.java | 65 ++++ .../internal/profiles/ModbusProfiles.java | 31 ++ .../resources/OH-INF/config/gainOffset.xml | 21 ++ .../resources/OH-INF/thing/thing-data.xml | 6 +- .../profiles/ModbusGainOffsetProfileTest.java | 307 ++++++++++++++++++ .../itest.bndrun | 1 + .../modbus/tests/ModbusDataHandlerTest.java | 271 +++++++++++++++- 11 files changed, 1217 insertions(+), 94 deletions(-) create mode 100644 bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusGainOffsetProfile.java create mode 100644 bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusProfileFactory.java create mode 100644 bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusProfiles.java create mode 100644 bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/config/gainOffset.xml create mode 100644 bundles/org.openhab.binding.modbus/src/test/java/org/openhab/binding/modbus/internal/profiles/ModbusGainOffsetProfileTest.java diff --git a/bundles/org.openhab.binding.modbus/README.md b/bundles/org.openhab.binding.modbus/README.md index ae30bbe53..00a1a5bc2 100644 --- a/bundles/org.openhab.binding.modbus/README.md +++ b/bundles/org.openhab.binding.modbus/README.md @@ -202,11 +202,11 @@ You must give each of your data Things a reference (thing ID) that is unique for | ------------------------------------------- | ------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `readValueType` | text | | (empty) | How data is read from modbus. Use empty for write-only things.

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.

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).

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:
  • For example, `"3.1"` would mean pick second bit from register index `3` with bit value type.
  • With int8 valuetype, it would pick the high byte of register index `3`.
| -| `readTransform` | text | | `"default"` | Transformation to apply to polled data, after it has been converted to number using `readValueType`.

Use "default" to communicate that no transformation is done and value should be passed as is.
Use `"SERVICENAME:ARG"` or `"SERVICENAME(ARG)"` (old syntax) to use transformation service `SERVICENAME` with argument `ARG`.
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.
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`.

Use "default" to communicate that no transformation is done and value should be passed as is.
Use `"SERVICENAME:ARG"` or `"SERVICENAME(ARG)"` (old syntax) to use transformation service `SERVICENAME` with argument `ARG`.
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.
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"`.

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.

Use `"default"` to communicate that no transformation is done and value should be passed as is.
Use `"SERVICENAME:ARG"` or `"SERVICENAME(ARG)"` (old syntax) to use transformation service `SERVICENAME` with argument `ARG`.
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.

Use `"default"` to communicate that no transformation is done and value should be passed as is.
Use `"SERVICENAME:ARG"` or `"SERVICENAME(ARG)"` (old syntax) to use transformation service `SERVICENAME` with argument `ARG`.
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.
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

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.

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. | @@ -310,6 +310,21 @@ Number Temperature_Modbus_Livingroom "Temperature Living 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. @@ -551,7 +566,7 @@ For example, `openhab-transformation-javascript` feature provides the javascript 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. @@ -563,42 +578,8 @@ Consult [background documentation on items](https://www.openhab.org/docs/concept 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. @@ -630,7 +611,7 @@ Please refer to the comments for more explanations. `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. @@ -678,7 +659,7 @@ Bridge modbus:tcp:localhostTCP [ host="127.0.0.1", port=502, id=2 ] { `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" } @@ -696,7 +677,7 @@ Number Holding5writeonly "Holding index 5 [%.1f]" { channel="modbu `sitemaps/modbus_ex1.sitemap`: -```xtend +``` sitemap modbus_ex1 label="modbus_ex1" { Frame { @@ -728,7 +709,7 @@ Toggling these switches always have the same effect: either setting or resetting `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" ] { @@ -746,7 +727,7 @@ Bridge modbus:tcp:localhostTCPex2 [ host="127.0.0.1", port=502 ] { `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" } @@ -756,7 +737,7 @@ Contact Coil5 "Coil 5 [%d]" { channel="modbus:data:localhostTCPex2 `sitemaps/modbus_ex2.sitemap`: -```xtend +``` sitemap modbus_ex2 label="modbus_ex2" { Frame { @@ -770,37 +751,83 @@ sitemap modbus_ex2 label="modbus_ex2" ### 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 } } ``` -See [transformation example](#transformation-example-scaling) for the `divide10.js` and `multiply10.js`. + +### 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" ] + } +} +``` + +`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 @@ -812,7 +839,7 @@ Example for a dimmer device where 255 register value = 100% for fully ON: `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" ] @@ -821,13 +848,13 @@ Bridge modbus:tcp:remoteTCP [ host="192.168.0.10", port=502 ] { ``` `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 { @@ -890,7 +917,7 @@ The logic of processing commands are summarized in the table `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, @@ -907,7 +934,7 @@ Bridge modbus:tcp:localhostTCPRollerShutter [ host="127.0.0.1", port=502 ] { `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]" { autoupdate="false", channel="modbus:data:localhostTCPRollerShutter:holding:rollershutterData:rollershutter" } @@ -919,7 +946,7 @@ Number RollershutterItemDebug2 "Roller shutter Debug 2 [%d]" { cha `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"] diff --git a/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/handler/ModbusPollerThingHandler.java b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/handler/ModbusPollerThingHandler.java index 12f18c765..c26594638 100644 --- a/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/handler/ModbusPollerThingHandler.java +++ b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/handler/ModbusPollerThingHandler.java @@ -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 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 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; + } } diff --git a/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/handler/ModbusDataThingHandler.java b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/handler/ModbusDataThingHandler.java index 9b6c22327..d1b03d1c1 100644 --- a/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/handler/ModbusDataThingHandler.java +++ b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/handler/ModbusDataThingHandler.java @@ -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 readIndex = Optional.empty(); private volatile Optional readSubIndex = Optional.empty(); - private volatile @Nullable Integer writeStart; + private volatile Optional writeStart = Optional.empty(); + private volatile Optional 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 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 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> channelAcceptedDataTypes) { return channelAcceptedDataTypes.stream().anyMatch(clz -> { return clz.equals(OnOffType.class); diff --git a/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusGainOffsetProfile.java b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusGainOffsetProfile.java new file mode 100644 index 000000000..8c855e61f --- /dev/null +++ b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusGainOffsetProfile.java @@ -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> 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> pregainOffset; + private Optional> 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> getPregainOffset() { + return pregainOffset; + } + + public Optional> 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> localGain = gain; + Optional> 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 gain = localGain.get(); + QuantityType 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 + @Nullable + QuantityType qtState = (QuantityType) (((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 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> parameterAsQuantityType(String parameterName, Object parameterValue) { + return parameterAsQuantityType(parameterName, parameterValue, null); + } + + private > Optional> parameterAsQuantityType(String parameterName, + Object parameterValue, @Nullable Unit assertUnit) { + Optional> result = Optional.empty(); + Unit sourceUnit = null; + if (parameterValue instanceof String) { + try { + QuantityType 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(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 > @Nullable QuantityType convertUnit(QuantityType quantityType, + @Nullable Unit unit) { + if (unit == null) { + return quantityType; + } + QuantityType 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 > QuantityType applyGainTowardsItem(QuantityType qtState, + QuantityType gainDelta) { + return (QuantityType) qtState.multiply(gainDelta); + } + + private QuantityType 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; + } + } +} diff --git a/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusProfileFactory.java b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusProfileFactory.java new file mode 100644 index 000000000..702c7078a --- /dev/null +++ b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusProfileFactory.java @@ -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 SUPPORTED_PROFILE_TYPES = Set.of(GAIN_OFFSET_TYPE); + + private static final Set 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 getProfileTypes(@Nullable Locale locale) { + return SUPPORTED_PROFILE_TYPES; + } + + @Override + public Collection getSupportedProfileTypeUIDs() { + return SUPPORTED_PROFILE_TYPE_UIDS; + } +} diff --git a/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusProfiles.java b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusProfiles.java new file mode 100644 index 000000000..0abded313 --- /dev/null +++ b/bundles/org.openhab.binding.modbus/src/main/java/org/openhab/binding/modbus/internal/profiles/ModbusProfiles.java @@ -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(); +} diff --git a/bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/config/gainOffset.xml b/bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/config/gainOffset.xml new file mode 100644 index 000000000..6cdfc58d4 --- /dev/null +++ b/bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/config/gainOffset.xml @@ -0,0 +1,21 @@ + + + + + + + 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. + + + + 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. + + + diff --git a/bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/thing/thing-data.xml b/bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/thing/thing-data.xml index df2937230..649e2fac2 100644 --- a/bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/thing/thing-data.xml +++ b/bundles/org.openhab.binding.modbus/src/main/resources/OH-INF/thing/thing-data.xml @@ -26,7 +26,7 @@ - + @@ -81,7 +81,9 @@ 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.]]> +
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 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). + ]]>
diff --git a/bundles/org.openhab.binding.modbus/src/test/java/org/openhab/binding/modbus/internal/profiles/ModbusGainOffsetProfileTest.java b/bundles/org.openhab.binding.modbus/src/test/java/org/openhab/binding/modbus/internal/profiles/ModbusGainOffsetProfileTest.java new file mode 100644 index 000000000..b7dda53cb --- /dev/null +++ b/bundles/org.openhab.binding.modbus/src/test/java/org/openhab/binding/modbus/internal/profiles/ModbusGainOffsetProfileTest.java @@ -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 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 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 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 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 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 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); + } +} diff --git a/itests/org.openhab.binding.modbus.tests/itest.bndrun b/itests/org.openhab.binding.modbus.tests/itest.bndrun index 957129333..8f7625484 100644 --- a/itests/org.openhab.binding.modbus.tests/itest.bndrun +++ b/itests/org.openhab.binding.modbus.tests/itest.bndrun @@ -53,6 +53,7 @@ Fragment-Host: org.openhab.binding.modbus 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)',\ diff --git a/itests/org.openhab.binding.modbus.tests/src/main/java/org/openhab/binding/modbus/tests/ModbusDataHandlerTest.java b/itests/org.openhab.binding.modbus.tests/src/main/java/org/openhab/binding/modbus/tests/ModbusDataHandlerTest.java index 1f3cf38aa..a3a05f989 100644 --- a/itests/org.openhab.binding.modbus.tests/src/main/java/org/openhab/binding/modbus/tests/ModbusDataHandlerTest.java +++ b/itests/org.openhab.binding.modbus.tests/src/main/java/org/openhab/binding/modbus/tests/ModbusDataHandlerTest.java @@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any; 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; @@ -30,13 +31,20 @@ import java.util.Objects; 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; @@ -46,6 +54,7 @@ import org.openhab.core.io.transport.modbus.AsyncModbusWriteResult; 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; @@ -94,6 +103,9 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { } } + private static final String HOST = "thisishost"; + private static final int PORT = 44; + private static final Map CHANNEL_TO_ACCEPTED_TYPE = new HashMap<>(); static { CHANNEL_TO_ACCEPTED_TYPE.put(CHANNEL_SWITCH, "Switch"); @@ -109,10 +121,49 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { CHANNEL_TO_ACCEPTED_TYPE.put(CHANNEL_LAST_READ_ERROR, "DateTime"); } private List 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() { @@ -455,10 +506,17 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { 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 @@ -468,7 +526,13 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { 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", ""); @@ -479,7 +543,7 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { 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))); @@ -596,6 +660,25 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { 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(); @@ -837,14 +920,94 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { waitForAssert(() -> verify((ModbusPollerThingHandler) poller.getHandler()).refresh()); } + private static Stream 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 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 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 statusConsumer) { int pollLength = 3; @@ -857,6 +1020,7 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { // Minimally mocked request ModbusReadRequestBlueprint request = Mockito.mock(ModbusReadRequestBlueprint.class); + doReturn(pollerStart).when(request).getReference(); doReturn(pollLength).when(request).getDataLength(); doReturn(pollerFunctionCode).when(request).getFunctionCode(); @@ -947,7 +1111,19 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { 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))); }); } @@ -955,11 +1131,85 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { @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))); }); } @@ -980,7 +1230,7 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { 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)))); }); @@ -995,7 +1245,7 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { 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 @@ -1110,7 +1360,6 @@ public class ModbusDataHandlerTest extends AbstractModbusOSGiTest { @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");