diff --git a/bundles/org.openhab.binding.sbus/DEVELOPERS.md b/bundles/org.openhab.binding.sbus/DEVELOPERS.md new file mode 100644 index 000000000000..599ddc0eef2a --- /dev/null +++ b/bundles/org.openhab.binding.sbus/DEVELOPERS.md @@ -0,0 +1,189 @@ +# For Developers + +## Debugging an addon + +Please follow IDE setup guide at . + +When configuring dependencies in `openhab-distro/launch/app/pom.xml`, add all dependencies, including the transitive dependencies: + +```xml + + org.openhab.addons.bundles + org.openhab.binding.sbus + ${project.version} + runtime + + + org.openhab.core.bundles + org.openhab.core.io.transport.sbus + ${project.version} + runtime + + + net.wimpi + jamod + 1.2.4.OH + runtime + +``` + +## Testing Serial Implementation + +You can use test serial slaves without any hardware on Linux using these steps: + +1. Set-up virtual null modem emulator using [tty0tty](https://github.com/freemed/tty0tty) +1. Download [diagslave](https://www.modbusdriver.com/diagslave.html) and start modbus serial slave up using this command: + +```shell +./diagslave -m rtu -a 1 -b 38400 -d 8 -s 1 -p none -4 10 /dev/pts/7 +``` + +1. Configure openHAB's modbus slave to connect to `/dev/pts/8`. + +1. Modify `start.sh` or `start_debug.sh` to include the unconventional port name by adding the following argument to `java`: + +```text +-Dgnu.io.rxtx.SerialPorts=/dev/pts/8 +``` + +Naturally this is not the same thing as the real thing but helps to identify simple issues. + +## Testing UDP Implementation + +1. Download [diagslave](https://www.modbusdriver.com/diagslave.html) and start modbus udp server (slave) using this command: + + ```shell + ./diagslave -m udp -a 1 -p 6000 + ``` + +1. Configure openHAB's modbus slave to connect to `127.0.0.1:6000`. + +## Writing Data + +See this [community post](https://community.openhab.org/t/something-is-rounding-my-float-values-in-sitemap/13704/32?u=ssalonen) explaining how `pollmb` and `diagslave` can be used to debug modbus communication. + +You can also use `modpoll` to write data: + +```shell +# write value=5 to holding register 40001 (index=0 in the binding) +./modpoll -m udp -a 1 -r 1 -t4 -p 6000 127.0.0.1 5 +# set coil 00001 (index=0 in the binding) to TRUE +./modpoll -m udp -a 1 -r 1 -t0 -p 6000 127.0.0.1 1 +# write float32 +./modpoll -m udp -a 1 -r 1 -t4:float -p 6000 127.0.0.1 3.14 +``` + +## Extending Modbus Binding + +This Modbus binding can be extended by other OSGi bundles to add more specific support for Modbus enabled devices. +To do so to you have to create a new OSGi bundle which has the same binding id as this binding. +The best way is to use the `ModbusBindingConstants.BINDING_ID` constant. + +### Thing Handler + +You will have to create one or more handler classes for the devices you want to support. +For the modbus connection setup and handling you can use the Modbus UDP Slave or Modbus Serial Slave handlers. +Your handler should use these handlers as bridges and you can set up your regular or one shot modbus requests to read from the slave. +This is done by extending your `ThingHandler` by `BaseModbusThingHandler`. +You can use the inherited methods `submitOneTimePoll()` and `registerRegularPoll()` to poll values and `submitOneTimeWrite()` to send values to a slave. +The `BaseModbusThingHandler` takes care that every regular poll task is cancelled, when the Thing is disposed. +Despite that, you can cancel the task manually by storing the return value of `registerRegularPoll()` and use it as an argument to `unregisterRegularPoll()`. + +Please keep in mind that these reads are asynchronous and they will call your callback once the read is done. + +Once you have your data read from the modbus device you can parse and transform them then update your channels to publish these data to the openHAB system. + +See the following example: + +```java +@NonNullByDefault +public class MyHandler extends BaseModbusThingHandler { + public MyHandler(Thing thing) { + super(thing); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + ModbusReadRequestBlueprint blueprint = new ModbusReadRequestBlueprint(getSlaveId(), + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 0, 1, 2); + + submitOneTimePoll(blueprint, this::readSuccessful, this::readError); + } + } + + @Override + public void modbusInitialize() { + // do other Thing initialization + + ModbusReadRequestBlueprint blueprint = new ModbusReadRequestBlueprint(getSlaveId(), + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 0, 1, 2); + + registerRegularPoll(blueprint, 1000, 0, this::readSuccessful, this::readError); + } + + private void readSuccessful(AsyncModbusReadResult result) { + result.getRegisters().ifPresent(registers -> { + Optional value = ModbusBitUtilities.extractStateFromRegisters(registers, 0, ValueType.INT16); + // process value + }); + } + + private void readError(AsyncModbusFailure error) { + // set the Thing offline + } +} +``` + +### Discovery + +If you write a device specific handler then adding discovery for this device is very welcome. +You will have to write a discovery participant class which implements the `ModbusDiscoveryParticipant` interface and registers itself as a component. Example: + +```java + +@Component +@NonNullByDefault +public class SunspecDiscoveryParticipant implements ModbusDiscoveryParticipant { +... +} +``` + +There are two methods you have to implement: + +- `getSupportedThingTypeUIDs` should return a list of the thing type UIDs that are supported by this discovery participant. This is fairly straightforward. + +- `startDiscovery` method will be called when a discovery process has began. This method receives two parameters: + + - `ModbusEndpointThingHandler` is the endpoint's handler that should be tested if it is known by your bundle. You can start your read requests against this handler. + + - `ModbusDiscoveryListener` this listener instance should be used to report any known devices found and to notify the main discovery process when your binding has finished the discovery. + +Please try to avoid write requests to the endpoint because it could be some unknown device that write requests could misconfigure. + +When a known device is found a `DiscoveryResult` object has to be created then the `thingDiscovered` method has to be called. +The `DiscoveryResult` supports properties, and you should use this to store any data that will be useful when the actual thing will be created. +For example you could store the start Modbus address of the device or vendor/model informations. + +When the discovery process is finished either by detecting a device or by realizing it is not supported you should call the `discoveryFinished` method. +This will tear down any resources allocated for the discovery process. + +### Discovery Architecture + +The following diagram shows the concept how discovery is implemented in this binding. (Note that some intermediate classes and interfaces are not shown for clarity.) + +![Discovery architecture](doc/images/ModbusExtensibleDiscovery.png) + +As stated above the discovery process can be extended by OSGi bundles. +For this they have to define their own `ModbusDisvoceryParticipant` that gets registered at the `ModbusDiscoveryService`. +This object also keeps track of any of the Modbus handlers. +Handler level discovery logic is implemented in the `ModbusEndpointDiscoveryService` which gets instantiated for each Modbus `BridgeHandler`. + +The communication flow is detailed in the diagram below: + +![Discovery process](doc/images/DiscoveryProcess.png) + +As can be seen the process is initiated by the `ModbusDiscoveryService` which calls each of the `ModbusEndpointDiscoveryService` instances to start the discovery on the available participants. +Then a reference to the `ThingHandler` is passed to each of the participants who can use this to do the actual discovery. + +Any things discovered are reported back in this chain and ultimately sent to openHAB core. diff --git a/bundles/org.openhab.binding.sbus/NOTICE b/bundles/org.openhab.binding.sbus/NOTICE new file mode 100644 index 000000000000..38d625e34923 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.sbus/README.md b/bundles/org.openhab.binding.sbus/README.md new file mode 100644 index 000000000000..23353a7a078f --- /dev/null +++ b/bundles/org.openhab.binding.sbus/README.md @@ -0,0 +1,1304 @@ +# Modbus Binding + +This is the binding to access Modbus UDP and serial slaves. +RTU, ASCII and BIN variants of Serial Modbus are supported. +Modbus UDP slaves are usually also called as Modbus UDP servers. + +The binding can act as + +- Modbus UDP Client (that is, as modbus master), querying data from Modbus UDP servers (that is, modbus slaves) +- Modbus serial master, querying data from modbus serial slaves + +The Modbus binding polls the slave data with a configurable poll period. +openHAB commands are translated to write requests. + +The binding has the following extensions: + + + +The rest of this page contains details for configuring this binding: + +{::options toc_levels="2..4"/} + +- TOC +{:toc} + +## Main Features + +The binding polls (or _reads_) Modbus data using function codes (FC) FC01 (Read coils), FC02 (Read discrete inputs), FC03 (Read multiple holding registers) or FC04 (Read input registers). +This polled data is converted to data suitable for use in openHAB. +Functionality exists to interpret typical number formats (e.g. single precision float). + +The binding can also _write_ data to Modbus slaves using FC05 (Write single coil), FC06 (Write single holding register), FC15 (Write multiple coils) or FC16 (Write multiple holding registers). + +## Caveats And Limitations + +Please note the following caveats or limitations + +- The binding does _not_ act as Modbus slave (e.g. as Modbus UDP server). +- The binding _does_ support Modbus RTU over Modbus UDP, (also known as "Modbus over UDP/IP" or "Modbus over UDP" or "Modbus RTU/IP"), as well as normal "Modbus UDP". + +## Background Material + +Reader of the documentation should understand the basics of Modbus protocol. +Good sources for further information: + +- [Wikipedia article](https://en.wikipedia.org/wiki/Modbus): good read on modbus basics and addressing. +- [Simplymodbus.ca](https://www.simplymodbus.ca/): good reference as well as excellent tutorial like explanation of the protocol + +Useful tools + +- [binaryconvert.com](https://www.binaryconvert.com/): tool to convert numbers between different binary presentations +- [rapidscada.net Modbus parser](https://modbus.rapidscada.net/): tool to parse Modbus requests and responses. Useful for debugging purposes when you want to understand the message sent / received. +- [JSFiddle tool](https://jsfiddle.net/rgypuuxq/) to test JavaScript (JS) transformations interactively + +## Supported Things + +This binding supports 4 different things types + +| Thing | Type | Description | +| -------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `udp` | Bridge | Modbus UDP server (Modbus UDP slave) | +| `serial` | Bridge | Modbus serial slave | +| `poller` | Bridge | Thing taking care of polling the data from modbus slaves. One poller corresponds to single Modbus read request (FC01, FC02, FC03, or FC04). Is child of `udp` or `serial`. | +| `data` | Thing | thing for converting polled data to meaningful numbers. Analogously, is responsible of converting openHAB commands to Modbus write requests. Is child of `poller` (read-only or read-write things) or `udp`/`serial` (write-only things). | + +Typically one defines either `udp` or `serial` bridge, depending on the variant of Modbus slave. +For each Modbus read request, a `poller` is defined. +Finally, one ore more `data` things are introduced to extract relevant numbers from the raw Modbus data. +For write-only communication, `data` things can be introduced directly as children of `udp` or `serial` bridges. + +## Binding Configuration + +Other than the things themselves, there is no binding configuration. + +## Serial Port Configuration + +With serial Modbus slaves, configuration of the serial port in openHAB is important. +Otherwise you might encounter errors preventing all communication. + +See [general documentation about serial port configuration](/docs/administration/serial.html) to configure the serial port correctly. + +## Thing Configuration + +In the tables below the thing configuration parameters are grouped by thing type. + +Things can be configured using the UI, or using a `.things` file. +The configuration in this documentation explains the `.things` file, although you can find the same parameters in the UI. + +Note that parameter type is very critical when writing `.things` file yourself, since it affects how the parameter value is encoded in the text file. + +Some examples: + +- `parameter="value"` for `text` parameters +- `parameter=4` for `integer` +- `parameter=true` for `boolean` + +Note the differences with quoting. + +Required parameters _must_ be specified in the `.things` file. +When optional parameters are not specified, they default to the values shown in the table below. + +### `udp` Thing + +`udp` is representing a particular Modbus UDP server (slave). + +Basic parameters + +| Parameter | Type | Required | Default if omitted | Description | +| ------------ | ------- | -------- | ------------------ | ----------------------------------------------------------- | +| `host` | text | | `"localhost"` | IP Address or hostname | +| `port` | integer | | `6000` | Port number | +| `id` | integer | | `1` | Slave id. Also known as station address or unit identifier. | +| `rtuEncoded` | boolean | | `false` | Use RTU encoding instead of regular UDP encoding. | + +Advanced parameters + +| Parameter | Required | Type | Default if omitted | Description | +| ------------------------------- | -------- | ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `timeBetweenTransactionsMillis` | | integer | `60` | How long to delay we must have at minimum between two consecutive MODBUS transactions. In milliseconds. | +| `timeBetweenReconnectMillis` | | integer | `0` | How long to wait to before trying to establish a new connection after the previous one has been disconnected. In milliseconds. | +| `connectMaxTries` | | integer | `1` | How many times we try to establish the connection. Should be at least 1. | +| `afterConnectionDelayMillis` | | integer | `0` | Connection warm-up time. Additional time which is spent on preparing connection which should be spent waiting while end device is getting ready to answer first modbus call. In milliseconds. | +| `reconnectAfterMillis` | | integer | `0` | The connection is kept open at least the time specified here. Value of zero means that connection is disconnected after every MODBUS transaction. In milliseconds. | +| `connectTimeoutMillis` | | integer | `10000` | The maximum time that is waited when establishing the connection. Value of zero means that system/OS default is respected. In milliseconds. | +| `enableDiscovery` | | boolean | false | Enable auto-discovery feature. Effective only if a supporting extension has been installed. | + +**Note:** Advanced parameters must be equal for all `udp` things sharing the same `host` and `port`. + +The advanced parameters have conservative defaults, meaning that they should work for most users. +In some cases when extreme performance is required (e.g. poll period below 10 ms), one might want to decrease the delay parameters, especially `timeBetweenTransactionsMillis`. +Similarly, with some slower devices on might need to increase the values. + +### `serial` Thing + +`serial` is representing a particular Modbus serial slave. + +Basic parameters + +| Parameter | Type | Required | Default if omitted | Description | | +| --------- | ------- | -------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | +| port | text | ✓ | | Serial port to use, for example `"/dev/ttyS0"` or `"COM1"` | | +| id | integer | | `1` | Slave id. Also known as station address or unit identifier. See [Wikipedia](https://en.wikipedia.org/wiki/Modbus) and [simplymodbus](https://www.simplymodbus.ca/index.html) articles for more information | | +| baud | integer | ✓ | | Baud of the connection. Valid values are: `75`, `110`, `300`, `1200`, `2400`, `4800`, `9600`, `19200`, `38400`, `57600`, `115200`. | | +| stopBits | text | ✓ | | Stop bits. Valid values are: `"1.0"`, `"1.5"`, `"2.0"`. | | +| parity | text | ✓ | | Parity. Valid values are: `"none"`, `"even"`, `"odd"`. | | +| dataBits | integer | ✓ | | Data bits. Valid values are: `5`, `6`, `7` and `8`. | | +| encoding | text | | `"rtu"` | Encoding. Valid values are: `"ascii"`, `"rtu"`, `"bin"`. | | +| echo | boolean | | `false` | Flag for setting the RS485 echo mode. This controls whether we should try to read back whatever we send on the line, before reading the response. Valid values are: `true`, `false`. | | + +Advanced parameters + +| Parameter | Required | Type | Default if omitted | Description | +| ------------------------------- | -------- | ------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `receiveTimeoutMillis` | | integer | `1500` | Timeout for read operations. In milliseconds. | +| `flowControlIn` | | text | `"none"` | Type of flow control for receiving. Valid values are: `"none"`, `"xon/xoff in"`, `"rts/cts in"`. | +| `flowControlOut` | | text | `"none"` | Type of flow control for sending. Valid values are: `"none"`, `"xon/xoff out"`, `"rts/cts out"`. | +| `timeBetweenTransactionsMillis` | | integer | `35` | How long to delay we must have at minimum between two consecutive MODBUS transactions. In milliseconds. | +| `connectMaxTries` | | integer | `1` | How many times we try to establish the connection. Should be at least 1. | +| `afterConnectionDelayMillis` | | integer | `0` | Connection warm-up time. Additional time which is spent on preparing connection which should be spent waiting while end device is getting ready to answer first modbus call. In milliseconds. | +| `connectTimeoutMillis` | | integer | `10000` | The maximum time that is waited when establishing the connection. Value of zero means thatsystem/OS default is respected. In milliseconds. | +| `enableDiscovery` | | boolean | false | Enable auto-discovery feature. Effective only if a supporting extension has been installed. | + +With the exception of `id` parameters should be equal for all `serial` things sharing the same `port`. + +These parameters have conservative defaults, meaning that they should work for most users. +In some cases when extreme performance is required (e.g. poll period below 10ms), one might want to decrease the delay parameters, especially `timeBetweenTransactionsMillis`. +With some slower devices on might need to increase the values. + +With low baud rates and/or long read requests (that is, many items polled), there might be need to increase the read timeout `receiveTimeoutMillis` to e.g. `5000` (=5 seconds). + +### `poller` Thing + +`poller` thing takes care of polling the Modbus serial slave or Modbus UDP server data regularly. +You must give each of your bridge Things a reference (thing ID) that is unique for this binding. + +| Parameter | Type | Required | Default if omitted | Description | +| ------------- | ------- | -------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `start` | integer | | `0` | Address of the first register, coil, or discrete input to poll. Input as zero-based index number. | +| `length` | integer | ✓ | (-) | Number of registers, coils or discrete inputs to read. Note that protocol limits max length, depending on type | +| `type` | text | ✓ | (-) | Type of modbus items to poll. This matches directly to Modbus request type or function code (FC). Valid values are: `"coil"` (FC01), `"discrete"` (FC02), `"holding"`(FC03), `"input"` (FC04). | +| `refresh` | integer | | `500` | Poll interval in milliseconds. Use zero to disable automatic polling. | +| `maxTries` | integer | | `3` | Maximum tries when reading.

Number of tries when reading data, if some of the reading fail. For single try, enter 1. | +| `cacheMillis` | integer | | `50` | Duration for data cache to be valid, in milliseconds. This cache is used only to serve `REFRESH` commands. Use zero to disable the caching. | + +Polling can be manually triggered by sending `REFRESH` command to item bound to channel of `data` thing. +When manually triggering polling, a new poll is executed as soon as possible, and sibling `data` things (i.e. things that share the same `poller` bridge) are updated. +In case the `poller` had just received a data response or an error occurred, a cached response is used instead. +See [Refresh command](#refresh-command) section for more details. + +Some devices do not allow to query too many registers in a single readout action or a range that spans reserved registers. +Split your poller into multiple smaller ones to work around this problem. + +### `data` Thing + +`data` is responsible of extracting relevant piece of data (e.g. a number `3.14`) from binary received from the slave. +Similarly, `data` thing is responsible of converting openHAB commands to write requests to the Modbus slave. +n.b. note that some numerics like 'readStart' need to be entered as 'text'. +You must give each of your data Things a reference (thing ID) that is unique for this binding. + +| Parameter | Type | Required | Default if omitted | Description | +| ------------------------------------------- | ------- | -------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. 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"`. | +| `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. | + +## Channels + +Only the `data` thing has channels. +It has several "data channels", serving the polled data in different formats, and for accepting openHAB commands from different item types. + +Please note that transformations might be _necessary_ in order to update some data channels, or to convert some openHAB commands to suitable Modbus data. +See [Transformations](#transformations) for more details. + +| Channel Type ID | Item Type | Description | +| --------------- | --------------- | ----------------------------------- | +| `number` | `Number` | Data as number | +| `switch` | `Switch` | Data as switch (`ON` / `OFF`) | +| `contact` | `Contact` | Data as contact (`OPEN` / `CLOSED`) | +| `dimmer` | `Dimmer` | Data as dimmer | +| `datetime` | `DateTime` | Data as a date time | +| `string` | `String` | Data as string | +| `rollershutter` | `Rollershutter` | Data as roller shutter | + +You can send a `REFRESH` command to items linked to any of the above channels to ask binding to explicitly poll new data from the Modbus slave. +See [Refresh command](#refresh-command) section for more details. + +Furthermore, there are additional channels that are useful for diagnostics: + +| Channel Type ID | Item Type | Description | +| ------------------ | ---------- | --------------------- | +| `lastReadSuccess` | `DateTime` | Last successful read | +| `lastReadError` | `DateTime` | Last erroring read | +| `lastWriteSuccess` | `DateTime` | Last successful write | +| `lastWriteError` | `DateTime` | Last erroring write | + +## Item configuration + +Items are configured the typical way, using `channel` to bind the item to a particular channel. + +For example, in the following example, item `Temperature_Modbus_Livingroom` is bound to channel `number` of thing `modbus:data:siemensplc:holding:livingroom_temperature`. + +```java +Number Temperature_Modbus_Livingroom "Temperature Living room [%.1f °C]" { channel="modbus:data:siemensplc:holding:livingroom_temperature:number" } +``` + +Make sure you bind item to a channel that is compatible, or use transformations to make it compatible. +See [Transformations](#transformations) section for more information on transformation. + +### `autoupdate` parameter with items + +By default, openHAB has `autoupdate` enabled. +This means that item _state_ is updated according to received commands. +In some situations this might have unexpected side effects with polling bindings such as Modbus - see example below. + +Typically, you see something like this + +```java +1 [ome.event.ItemCommandEvent] - Item 'Kitchen_Bar_Table_Light' received command ON +2 [vent.ItemStateChangedEvent] - Kitchen_Bar_Table_Light changed from OFF to ON +3 [vent.ItemStateChangedEvent] - Kitchen_Bar_Table_Light changed from ON to OFF +4 [vent.ItemStateChangedEvent] - Kitchen_Bar_Table_Light changed from OFF to ON +``` + +Let's go through it step by step + +```java +// openHAB UI switch changed command is sent +1 [ome.event.ItemCommandEvent] - Item 'Kitchen_Bar_Table_Light' received command ON +// openHAB immediately updates the item state to match the command +2 [vent.ItemStateChangedEvent] - Kitchen_Bar_Table_Light changed from OFF to ON +// modbus binding poll completes (old value) +3 [vent.ItemStateChangedEvent] - Kitchen_Bar_Table_Light changed from ON to OFF +// (the binding writes the command over Modbus to the slave) +// modbus binding poll completes (updated value) +4 [vent.ItemStateChangedEvent] - Kitchen_Bar_Table_Light changed from OFF to ON +``` + +To prevent this "state fluctuation" (`OFF` -> `ON` -> `OFF` -> `ON`), some people prefer to disable `autoupdate` on Items used with polling bindings. +With `autoupdate` disabled, one would get + +```java +// openHAB UI switch changed command is sent +1 [ome.event.ItemCommandEvent] - Item 'Kitchen_Bar_Table_Light' received command ON +// modbus binding poll completes (STILL the old value) -- UI not updated, still showing OFF +// (the binding writes the command over Modbus to the slave) +// modbus binding poll completes (updated value) +4 [vent.ItemStateChangedEvent] - Kitchen_Bar_Table_Light changed from OFF to ON +``` + +Item state has no "fluctuation", it updates from `OFF` to `ON`. + +To summarize (credits to [rossko57's community post](https://community.openhab.org/t/rule-to-postupdate-an-item-works-but-item-falls-back-after-some-seconds/19986/2?u=ssalonen)): + +- `autoupdate="false"`: monitor the _actual_ state of device +- `autoupdate="true"`: (or defaulted) allows faster display of the _expected_ state in a sitemap + +You can disable `autoupdate` as follows: + +```java +Number Temperature_Modbus_Livingroom "Temperature Living room [%.1f °C]" { channel="modbus:data:siemensplc:holding:livingroom_temperature:number", autoupdate="false" } +``` + +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-gain-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 `udp` and `serial` bridges when they have `enableDiscovery` setting enabled. + +Note that the main binding does not recognize any devices, so it is pointless to turn this on unless you have a suitable add-on binding installed. + +## Details + +### Comment On Addressing + +[Modbus Wikipedia article](https://en.wikipedia.org/wiki/Modbus#Coil.2C_discrete_input.2C_input_register.2C_holding_register_numbers_and_addresses) summarizes this excellently: + +> In the traditional standard, [entity] numbers for those entities start with a digit, followed by a number of four digits in range 1–9,999: +> +> - coils numbers start with a zero and then span from 00001 to 09999 +> - discrete input numbers start with a one and then span from 10001 to 19999 +> - input register numbers start with a three and then span from 30001 to 39999 +> - holding register numbers start with a four and then span from 40001 to 49999 +> +> This translates into [entity] addresses between 0 and 9,998 in data frames. + +Note that entity begins counting at 1, data frame address at 0. + +The openHAB modbus binding uses data frame entity addresses when referring to modbus entities. +That is, the entity address configured in modbus binding is passed to modbus protocol frame as-is. +For example, Modbus `poller` thing with `start=3`, `length=2` and `type=holding` will read modbus entities with the following numbers 40004 and 40005. +The manufacturer of any modbus device may choose to use either notation, you may have to infer which, or use trial and error. + +### Value Types On Read And Write + +This section explains the detailed descriptions of different value types on read and write. +Note that value types less than 16 bits are not supported on write to holding registers (see [poller thing](#poller-thing) documentation for details). + +See [Full examples](#full-examples) section for practical examples. + +#### `bit`: + +- a single bit is read from the registers +- address is given as `X.Y`, where `Y` is between 0...15 (inclusive), representing bit of the register `X` +- index `Y=0` refers to the least significant bit +- index `Y=1` refers to the second least significant bit, etc. + +#### `int8`: + +- a byte (8 bits) from the registers is interpreted as signed integer +- address is given as `X.Y`, where `Y` is between 0...1 (inclusive), representing byte of the register `X` +- index `Y=0` refers to low byte +- index `Y=1` refers to high byte +- it is assumed that each high and low byte is encoded in most significant bit first order + +#### `uint8`: + +- same as `int8` except value is interpreted as unsigned integer + +#### `int16`: + +- register with index is interpreted as 16 bit signed integer. +- it is assumed that register is encoded in most significant bit first order + +#### `uint16`: + +- same as `int16` except value is interpreted as unsigned integer + +#### `int32`: + +- registers `index` and `(index + 1)` are interpreted as signed 32bit integer +- it assumed that the first register contains the most significant 16 bits +- it is assumed that each register is encoded in most significant bit first order + +#### `uint32`: + +- same as `int32` except value is interpreted as unsigned integer + +#### `float32`: + +- registers `index` and `(index + 1)` are interpreted as signed 32bit floating point number +- it assumed that the first register contains the most significant 16 bits +- it is assumed that each register is encoded in most significant bit first order + +#### `int64`: + +- registers `index`, `(index + 1)`, `(index + 2)`, `(index + 3)` are interpreted as signed 64bit integer. +- it assumed that the first register contains the most significant 16 bits +- it is assumed that each register is encoded in most significant bit first order + +#### `uint64`: + +- same as `int64` except value is interpreted as unsigned integer + +The MODBUS specification defines each 16bit word to be encoded as Big Endian, +but there is no specification on the order of those words within 32bit or larger data types. +The net result is that when you have a master and slave that operate with the same Endian mode things work fine, +but add a device with a different Endian mode and it is very hard to correct. +To resolve this the binding supports a second set of valuetypes that have the words swapped. + +If you get strange values using the `int32`, `uint32`, `float32`, `int64`, or `uint64` valuetypes then just try the `int32_swap`, `uint32_swap`, `float32_swap`, `int64_swap`, or `uint64_swap` valuetype, depending upon what your data type is. + +#### `int32_swap`: + +- registers `index` and `(index + 1)` are interpreted as signed 32bit integer +- it assumed that the first register contains the least significant 16 bits +- it is assumed that each register is encoded in most significant bit first order (Big Endian) + +#### `uint32_swap`: + +- same as `int32_swap` except value is interpreted as unsigned integer + +#### `float32_swap`: + +- registers `index` and `(index + 1)` are interpreted as signed 32bit floating point number +- it assumed that the first register contains the least significant 16 bits +- it is assumed that each register is encoded in most significant bit first order (Big Endian) + +#### `int64_swap`: + +- same as `int64` but registers swapped, that is, registers (index + 3), (index + 2), (index + 1), (index + 1) are interpreted as signed 64bit integer + +#### `uint64_swap`: + +- same as `uint64` except value is interpreted as unsigned integer + +### REFRESH Command + +`REFRESH` command to item bound to any [data channel](#channels) makes `poller` thing to poll new from the Modbus slave. +All data channels of children `data` things are refreshed per the normal logic. + +`REFRESH` can be useful tool if you like to refresh only on demand (`poller` has refresh disabled, i.e. `refresh=0`), or have custom logic of refreshing only in some special cases. + +Note that poller has `cacheMillis` parameter to re-use previously received data, and thus avoid polling the Modbus slave too much. +This parameter is specifically limiting the flood of requests that come when openHAB itself is calling `REFRESH` for new things. + +### Read Steps + +Every time data is read by the binding, these steps are taken to convert the raw binary data to actual item `State` in openHAB: + +1. Poll the data from Modbus slave. +Data received is stored in list of bits (discrete inputs and coils), or in list of registers (input registers and holding registers) +1. Extract a single number from the polled data, using specified location `readStart` and number "value type" `readValueType`. +As an example, we can tell the binding to extract 32-bit float (`readValueType="float32"`) from register index `readStart="105"`. +1. Number is converted to string (e.g. `"3.14"`) and passed as input to the transformation. + Note that in case `readTransform="default"`, a default transformation provided by the binding is used. + See [Transformations](#transformations) section for more details. +1. For each [data channel](#channels), we try to convert the transformation output of previous step to a State type (e.g. `ON`/`OFF`, or `DecimalType`) accepted by the channel. + If all the conversions fail (e.g. trying to convert `ON` to a number), the data channel is not updated. + +In case of read errors, all data channels are left unchanged, and `lastReadError` channel is updated with current time. +Examples of errors include connection errors, IO errors on read, and explicit exception responses from the slave. + +Note: there is a performance optimization that channel state is only updated when enough time has passed since last update, or when the state differs from previous update. +See `updateUnchangedValuesEveryMillis` parameter in `data` thing. + +### Write Steps + +#### Basic Case + +Commands passed to openHAB items that are bound to a [data channel](#channels) are most often processed according to following steps: + +1. Command is sent to openHAB item, that is bound to a [data channel](#channels). +Command must be such that it is accepted by the item in the first place +1. Command is converted to string (e.g. `"3.14"`) and passed to the transformation. +Note that in case `readTransform="default"`, a default transformation provided by the binding is used. + See [Transformations](#transformations) section for more details. +1. We try to convert transformation output to number (`DecimalType`), `OPEN`/`CLOSED` (`OpenClosedType`), and `ON`/`OFF` (`OnOffType`); in this order. + First successful conversion is stored. + For example, `"3.14"` would convert to number (`DecimalType`), while `"CLOSED"` would convert to `CLOSED` (of `OpenClosedType`).' +In case all conversions fail, the command is discarded and nothing is written to the Modbus slave. +1. Next step depends on the `writeType`: + - `writeType="coil"`: the command from the transformation is converted to boolean. + Non-zero numbers, `ON`, and `OPEN` are considered `true`; and rest as `false`. + - `writeType="holding"`: First, the command from the transformation is converted `1`/`0` number in case of `OPEN`/`ON` or `CLOSED`/`OFF`. The number is converted to one or more registers using `writeValueType`. + For example, number `3.14` would be converted to two registers when `writeValueType="float32"`: [0x4048, 0xF5C3]. +1. Boolean (`writeType="coil"`) or registers (`writeType="holding"`) are written to the Modbus slave using `FC05`, `FC06`, `FC15`, or `FC16`, depending on the value of `writeMultipleEvenWithSingleRegisterOrCoil`. + Write address is specified by `writeStart`. + +#### Advanced Write Using JSON + +There are some more advanced use cases which need more control how the command is converted to set of bits or requests. +Due to this reason, one can return a special [JSON](https://en.wikipedia.org/wiki/JSON) output from the transformation (step 3). +The JSON directly specifies the write requests to send to Modbus slave. +In this case, steps 4. and 5. are skipped. + +For example, if the transformation returns the following JSON + +```json +[ + { + "functionCode": 16, + "address": 5412, + "value": [1, 0, 5] + }, + { + "functionCode": 6, + "address": 555, + "value": [3], + "maxTries": 10 + } +] +``` + +Two write requests would be sent to the Modbus slave + +1. FC16 (write multiple holding register), with start address 5412, having three registers of data (1, 0, and 5). +1. FC06 (write single holding register), with start address 555, and single register of data (3). + Write is tried maximum of 10 times in case some of the writes fail. + +The JSON transformation output can be useful when you need full control how the write goes, for example in case where the write address depends on the incoming command. +Actually, you can omit specifying `writeStart`, `writeValueType` and `writeType` with JSON transformation output altogether. + +Empty JSON array (`[]`) can be used to suppress all writes. + +Explanation for the different properties of the JSON object in the array. + +| Key name | Value type | Required | Default if omitted | Description | +| -------------- | --------------------- | -------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `functionCode` | number | ✓ | (-) | Modbus function code to use with write. Use one of `5`, `6`, `15` or `16`. | +| `address` | number | ✓ | (-) | 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. | +| `value` | JSON array of numbers | ✓ | (-) | Array of coil or register values. Encode coil values as `0` or `1`. | +| `maxTries` | number | | 3 | Number of tries when writing data, in case some of the writes fail. Should be at least 1. | + +### Transformations + +Transformations serve two purpose + +- `readTransform`: doing preprocessing transformations to read binary data and to make it more usable in openHAB +- `writeTransform`: doing preprocessing to openHAB commands before writing them to Modbus slave + +Note that transformation is only one part of the overall process how polled data is converted to openHAB state, or how commands are converted to Modbus writes. +Consult [Read steps](#read-steps) and [Write steps](#write-steps) for more details. +Specifically, note that you might not need transformations at all in some uses cases. + +Please also note that you should install relevant transformations in openHAB as necessary. +For example, `openhab-transformation-javascript` feature provides the javascript (`JS`) transformation. + +#### Transform On Read + +**`readTransform`** can be used to transform the polled data, after a number is extracted from the polled data using `readValueType` and `readStart` (consult [Read steps](#read-steps)). + +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 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. + +#### Transform On Write + +**`writeTransform`** can be used to transform the openHAB command before it is converted to actual binary data (see [Write steps](#write-steps)). + +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 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. + +#### Example: Inverting Binary Data On Read And Write + +This example transformation is able to invert "boolean" input. +In this case, boolean input is considered to be either number `0`/`1`, `ON`/`OFF`, or `OPEN`/`CLOSED`. + +```javascript +// function to invert Modbus binary states +// variable "input" contains data passed by openHAB +(function(inputData) { + var out = inputData ; // allow UNDEF to pass through + if (inputData == '1' || inputData == 'ON' || inputData == 'OPEN') { + out = '0' ; // change to OFF or OPEN depending on your Item type + } else if (inputData == '0' || inputData == 'OFF' || inputData == 'CLOSED') { + out = '1' ; + } + return out ; // return a string +})(input) +``` + +## Full Examples + +Things can be configured in the UI, or using a `things` file like here. + +### Basic Example + +This example reads different kind of Modbus items from the slave. + +Please refer to the comments for more explanations. + +`things/modbus_ex1.things`: + +```java +Bridge modbus:udp:localhostUDP [ host="127.0.0.1", port=6000, id=2 ] { + + // read-write for coils. Reading 4 coils, with index 4, and 5. + // These correspond to input register numbers 000005, and 000005 + Bridge poller coils [ start=4, length=2, refresh=1000, type="coil" ] { + // Note the zero based indexing: first coil is index 0. + Thing data do4 [ readStart="4", readValueType="bit", writeStart="4", writeValueType="bit", writeType="coil" ] + Thing data do5 [ readStart="5", readValueType="bit", writeStart="5", writeValueType="bit", writeType="coil" ] + } + // read-write for holding registers. Reading 4 registers, with index 1500, 1501, 16000, 1503. + // These correspond to holding register numbers 401501, 4016000, 401503, 401504. + Bridge poller holding [ start=1500, length=4, refresh=1000, type="holding" ] { + Thing data holding1500 [ readStart="1500", readValueType="float32", writeStart="1500", writeValueType="float32", writeType="holding" ] + Thing data holding16000 [ readStart="16000", readValueType="float32", writeStart="16000", writeValueType="float32", writeType="holding" ] + } + // read-only for input registers. Reading 4 registers, with index 1500, 1501, 16000, 1503. + // These correspond to input register numbers 301501, 3016000, 301503, 301504. + Bridge poller inputRegisters [ start=1500, length=4, refresh=1000, type="input" ] { + Thing data input1500 [ readStart="1500", readValueType="float32" ] + Thing data input16000 [ readStart="16000", readValueType="float32" ] + + // Extract high or low byte of the 16-bit register as unsigned 8-bit integer (uint8) + Thing data input16000lo [ readStart="16000.0", readValueType="uint8" ] + Thing data input16000hi [ readStart="16000.1", readValueType="uint8" ] + + // Extract individual bits of the 16-bit register + // bit 0 is the least significant bit, and bit 15 is the most significant bit + Thing data input16000bit0 [ readStart="16000.0", readValueType="bit" ] + Thing data input16000bit1 [ readStart="16000.1", readValueType="bit" ] + Thing data input16000bit2 [ readStart="16000.2", readValueType="bit" ] + } + + // read-only for discrete inputs. Reading 4 discrete inputs, with index 1200, 1201, 1202, 1203. + // These correspond to input register numbers 101201, 101202, 101203, 101204. + Bridge poller discreteInputs [ start=1200, length=4, refresh=1000, type="discrete" ] { + Thing data di1200 [ readStart="1200", readValueType="bit" ] + Thing data di1201 [ readStart="1201", readValueType="bit" ] + } + + // Write-only entry: thing is child of udp directly. No readStart etc. need to be defined. + // Note that the openHAB state might differ from the physical slave since it is not refreshed at all + Thing data holding5write [ writeStart="5", writeValueType="int16", writeType="holding" ] +} +``` + +`items/modbus_ex1.items`: + +```java +Switch DO4 "Digital Output index 4 [%d]" { channel="modbus:data:localhostUDP:coils:do4:switch" } +Switch DO5 "Digital Output index 5 [%d]" { channel="modbus:data:localhostUDP:coils:do5:switch" } + +Contact DI1200 "Digital Input index 1200 [%d]" { channel="modbus:data:localhostUDP:discreteInputs:di1200:contact" } +Contact DI1201 "Digital Input index 1201 [%d]" { channel="modbus:data:localhostUDP:discreteInputs:di1201:contact" } + +Number Input1500Float32 "Input registers 1500-1501 as float32 [%.1f]" { channel="modbus:data:localhostUDP:inputRegisters:input1500:number" } +Number Input16000Float32 "Input registers 16000-1503 as float32 [%.1f]" { channel="modbus:data:localhostUDP:inputRegisters:input16000:number" } + +DateTime Input16000Float32LastOKRead "Input registers 16000-1503 last read [%1$tA, %1$td.%1$tm.%1$tY %1$tH:%1$tM:%1$tS]" { channel="modbus:data:localhostUDP:inputRegisters:input16000:lastReadSuccess" } +DateTime Input16000Float32LastBadRead "Input registers 16000-1503 last read [%1$tA, %1$td.%1$tm.%1$tY %1$tH:%1$tM:%1$tS]" { channel="modbus:data:localhostUDP:inputRegisters:input16000:lastReadError" } + +Number Holding5writeonly "Holding index 5 [%.1f]" { channel="modbus:data:localhostUDP:holding5write:number" } +``` + +`sitemaps/modbus_ex1.sitemap`: + +```perl +sitemap modbus_ex1 label="modbus_ex1" +{ + Frame { + Switch item=DO4 + Switch item=DO5 + Setpoint item=Holding5writeonly minValue=0 maxValue=100 step=20 + + Default item=DI1200 + Default item=DI1201 + + Default item=Input1500Float32 + Default item=Input16000Float32 + + Default item=Input1500Float32LastOKRead + Default item=Input1500Float32LastBadRead + + } +} +``` + +### Writing To Different Address And Type Than Read + +This updates the item from discrete input index 4, and writes commands to coil 5. +This can be useful when the discrete input is the measurement (e.g. "is valve open?"), and the command is the control (e.g. "open/close valve"). + +The sitemap shows the current coil status. +It also has switches to set/reset coil status, for debugging purposes. +Toggling these switches always have the same effect: either setting or resetting the bit. + +`things/modbus_ex2.things`: + +```java +Bridge modbus:udp:localhostUDPex2 [ host="127.0.0.1", port=6000 ] { + + Bridge poller items [ start=4, length=2, refresh=1000, type="discrete" ] { + // read from index 4, write to coil 5 + Thing data readDiscrete4WriteCoil5 [ readStart="4", readValueType="bit", writeStart="5", writeValueType="bit", writeType="coil" ] + Thing data resetCoil5 [ writeTransform="0", writeStart="5", writeValueType="bit", writeType="coil" ] + Thing data setCoil5 [ writeTransform="1", writeStart="5", writeValueType="bit", writeType="coil" ] + } + + Bridge poller coils [ start=5, length=1, refresh=500, type="coil" ] { + Thing data index5 [ readStart="5", readValueType="bit" ] + } +} +``` + +`items/modbus_ex2.items`: + +```java +Switch ReadDI4WriteDO5 "Coil 4/5 mix [%d]" { channel="modbus:data:localhostUDPex2:items:readDiscrete4WriteCoil5:switch" } +Switch ResetDO5 "Flip to turn Coil 5 OFF [%d]" { channel="modbus:data:localhostUDPex2:items:resetCoil5:switch" } +Switch SetDO5 "Flip to turn Coil 5 ON [%d]" { channel="modbus:data:localhostUDPex2:items:setCoil5:switch" } +Contact Coil5 "Coil 5 [%d]" { channel="modbus:data:localhostUDPex2:coils:index5:contact" } + +``` + +`sitemaps/modbus_ex2.sitemap`: + +```perl +sitemap modbus_ex2 label="modbus_ex2" +{ + Frame { + Switch item=ReadDI4WriteDO5 + Switch item=ResetDO5 + Switch item=SetDO5 + Text item=Coil5 + } +} +``` + +### Scaling Example + +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`: + +```java +Bridge modbus:udp:localhostUDP3 [ host="127.0.0.1", port=6000 ] { + Bridge poller holdingPoller [ start=5, length=1, refresh=5000, type="holding" ] { + Thing data temperatureDeciCelsius [ readStart="5", readValueType="int16", writeStart="5", writeValueType="int16", writeType="holding" ] + } +} +``` + +`items/modbus_ex_scaling.items`: + +```java +Number:Temperature TemperatureItem "Temperature [%.1f °C]" { channel="modbus:data:localhostUDP3:holdingPoller:temperatureDeciCelsius:number"[ profile="modbus:gainOffset", gain="0.1 °C", pre-gain-offset="0" ] } +``` + +`sitemaps/modbus_ex_scaling.sitemap`: + +```perl +sitemap modbus_ex_scaling label="modbus_ex_scaling" +{ + Frame { + 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`: + +```java +Bridge modbus:udp:localhostUDP3 [ host="127.0.0.1", port=6000 ] { + 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`: + +```java +Switch SecondLeastSignificantBit "2nd least significant bit write switch [%d]" { channel="modbus:data:localhostUDP3:holdingPoller:register5:switch" } +Number SecondLeastSignificantBitAltRead "2nd least significant bit is now [%d]" { channel="modbus:data:localhostUDP3:holdingPoller:register5Bit1:number" } +``` + +`sitemaps/modbus_ex_command_bit.sitemap`: + +```perl +sitemap modbus_ex_command_bit label="modbus_ex_command_bit" +{ + Frame { + Text item=SecondLeastSignificantBitAltRead + Switch item=SecondLeastSignificantBit + } +} +``` + +### Dimmer Example + +Dimmer type Items are not a straightforward match to Modbus registers, as they feature a numeric value which is limited to 0-100 Percent, as well as handling ON/OFF commands. + +Transforms can be used to match and scale both reading and writing. + +Example for a dimmer device where 255 register value = 100% for fully ON: + +`things/modbus_ex_dimmer.things`: + +```java +Bridge modbus:udp:remoteUDP [ host="192.168.0.10", port=6000 ] { + 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`: + +```java +Dimmer myDimmer "My Dimmer d2 [%.1f]" { channel="modbus:data:remoteUDP:MBDimmer:DimmerReg:dimmer" } +``` + +`sitemaps/modbus_ex_dimmer.sitemap`: + +```perl +sitemap modbus_ex_dimmer label="modbus_ex_dimmer" +{ + Frame { + Switch item=myDimmer + Slider item=myDimmer + } +} +``` + +`transform/dimread255.js`: + +```javascript +// Wrap everything in a function (no global variable pollution) +// variable "input" contains data string passed by binding +(function(inputData) { + // here set the 100% equivalent register value + var MAX_SCALE = 255; + // convert to percent + return Math.round( parseFloat(inputData, 10) * 100 / MAX_SCALE ); +})(input) +``` + +`transform/dimwrite255.js`: + +```javascript +// variable "input" contains command string passed by openHAB +(function(inputData) { + // here set the 100% equivalent register value + var MAX_SCALE = 255; + var out = 0 + if (inputData == 'ON') { + // set max + out = MAX_SCALE + } else if (inputData == 'OFF') { + out = 0 + } else { + // scale from percent + out = Math.round( parseFloat(inputData, 10) * MAX_SCALE / 100 ) + } + return out +})(input) +``` + +### Rollershutter Example + +#### Rollershutter + +This is an example how different Rollershutter commands can be written to Modbus. + +Roller shutter position is read from register 0, `UP`/`DOWN` commands are written to register 1, and `MOVE`/`STOP` commands are written to register 2. + +The logic of processing commands are summarized in the table + +| Command | Number written to Modbus slave | Register index | +| ------- | ------------------------------ | -------------- | +| `UP` | `1` | 1 | +| `DOWN` | `-1` | 1 | +| `MOVE` | `1` | 2 | +| `STOP` | `0` | 2 | + +`things/modbus_ex_rollershutter.things`: + +```java +Bridge modbus:udp:localhostUDPRollerShutter [ host="127.0.0.1", port=6000 ] { + Bridge poller holding [ start=0, length=3, refresh=1000, type="holding" ] { + // Since we are using advanced transformation outputting JSON, + // other write parameters (writeValueType, writeStart, writeType) can be omitted + Thing data rollershutterData [ readStart="0", readValueType="int16", writeTransform="JS:rollershutter.js" ] + + // For diagnostics + Thing data rollershutterDebug0 [ readStart="0", readValueType="int16", writeStart="0", writeValueType="int16", writeType="holding" ] + Thing data rollershutterDebug1 [ readStart="1", readValueType="int16" ] + Thing data rollershutterDebug2 [ readStart="2", readValueType="int16" ] + } +} +``` + +`items/modbus_ex_rollershutter.items`: + +```java +// 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:localhostUDPRollerShutter:holding:rollershutterData:rollershutter" } + +// For diagnostics +Number RollershutterItemDebug0 "Roller shutter Debug 0 [%d]" { channel="modbus:data:localhostUDPRollerShutter:holding:rollershutterDebug0:number" } +Number RollershutterItemDebug1 "Roller shutter Debug 1 [%d]" { channel="modbus:data:localhostUDPRollerShutter:holding:rollershutterDebug1:number" } +Number RollershutterItemDebug2 "Roller shutter Debug 2 [%d]" { channel="modbus:data:localhostUDPRollerShutter:holding:rollershutterDebug2:number" } +``` + +`sitemaps/modbus_ex_rollershutter.sitemap`: + +```perl +sitemap modbus_ex_rollershutter label="modbus_ex_rollershutter" { + Switch item=RollershutterItem label="Roller shutter [(%d)]" mappings=[UP="up", STOP="X", DOWN="down", MOVE="move"] + + // For diagnostics + Setpoint item=RollershutterItemDebug0 minValue=0 maxValue=100 step=20 + Text item=RollershutterItemDebug0 + Text item=RollershutterItemDebug1 + Text item=RollershutterItemDebug2 +} +``` + +`transform/rollershutter.js`: + +```javascript +// Wrap everything in a function +// variable "input" contains data passed by openHAB +(function(cmd) { + var cmdToValue = {"UP": 1, "DOWN": -1, "MOVE": 1, "STOP": 0}; + var cmdToAddress = {"UP": 1, "DOWN": 1, "MOVE": 2, "STOP": 2}; + + var value = cmdToValue[cmd]; + var address = cmdToAddress[cmd]; + if(value === undefined || address === undefined) { + // unknown command, do not write anything + return "[]"; + } else { + return ( + "[" + + "{\"functionCode\": 6, \"address\":" + address.toString() + ", \"value\": [" + value + "] }" + + "]" + ); + } +})(input) +``` + +### Eager Updates Using REFRESH + +In many cases fast enough poll interval is pretty long, e.g. 1 second. +This is problematic in cases when faster updates are wanted based on events in openHAB. + +For example, in some cases it is useful to update faster when a command is sent to some specific items. + +Simple solution is just increase the poll period with the associated performance penalties and possible burden to the slave device. + +It is also possible to use `REFRESH` command to ask the binding to update more frequently for a short while. + +`rules/fast_refresh.rules`: + +```javascript +import org.eclipse.xtext.xbase.lib.Procedures +import org.openhab.core.types.RefreshType + +val Procedures$Procedure0 refreshData = [ | + // Refresh SetTemperature. In fact, all data things in the same poller are refreshed + SetTemperature.sendCommand(RefreshType.REFRESH) + return null +] + +rule "Refresh modbus data quickly after changing settings" +when + Item VacationMode received command or + Item HeatingEnabled received command +then + if (receivedCommand != RefreshType.REFRESH) { + // Update more frequently for a short while, to get + // refereshed data after the newly received command + refreshData() + createTimer(now.plus(100), refreshData) + createTimer(now.plus(200), refreshData) + createTimer(now.plus(300), refreshData) + createTimer(now.plus(500), refreshData) + } +end +``` + +Please be aware that `REFRESH` commands are "throttled" (to be exact, responses are cached) with `poller` parameter `cacheMillis`. + +## Troubleshooting + +Modbus, while simple at its heart, potentially is a complicated standard to use because there's a lot of freedom (and bugs) when it comes to implementations. +There are many device or vendor specific quirks and wrinkles you might stumble across. Here's some: + +- With Modbus UDP devices, there may be multiple network interfaces available, e.g. Wifi and wired Ethernet. However, with some devices the Modbus data is accessible via only one of the interfaces. You need to check the device manufacturer manual, or simply try out which of the IPs are returning valid modbus data. +Attention: a device may have an interface with a port open (6000 or other) that it responds to Modbus requests on, but that may have no connection to the real bus hardware, resulting in generic Modbus error responses to _every_ request. +So check ALL interfaces. Usually either the IP on Ethernet will do. + +- some devices do not allow to query a range of registers that is too large or spans reserved registers. Do not poll more than 123 registers. +Devices may respond with an error or no error but invalid register data so this error can easily go undedetected. +Turn your poller thing into multiple things to cover smaller ranges to work around this problem. + +- there's potentially many more or less weird inconsistencies with some devices. + If you fail to read a register or you only ever get invalid values (such as 00 or FF bytes), try with various poller lengths such as the exact length of a register in question or twice the amount. + In extreme cases you might even need more than a poller for a single register so you have two or more poller with two or more data things and need to combine these into another item using a rule. + +## Changes From Modbus 1.x Binding + +The openHAB 1 Modbus binding is quite different from this binding. +The biggest difference is that this binding uses things. + +Unfortunately there is no conversion tool to convert old configurations to new thing structure. + +Due to the introduction of things, the configuration was bound to be backwards incompatible. +This offered opportunity to simplify some aspects of configuration. +The major differences in configuration logic are: + +### Absolute Addresses Instead Of Relative + +The new Modbus binding uses _absolute_ addresses. +This means that all parameters referring to addresses of input registers, holding registers, discrete inputs or coils are _entity addresses_. +This means that the addresses start from zero (first entity), and can go up to 65 535. See [Wikipedia explanation](https://en.wikipedia.org/wiki/Modbus#Coil.2C_discrete_input.2C_input_register.2C_holding_register_numbers_and_addresses) for more information. + +Previous binding sometimes used absolute addresses (`modbus.cfg`), sometimes relative to polled data (items configuration). + +### Register And Bit Addressing + +Now 32 bit value types refer start register address. For example `valueType="int32"` with `start="3"` refers to 32 bit integer in registers `3` and `4`. + +The old binding could not handle this case at all since it was assumed that the values were addressed differently. +Read index of `3` would refer to 32 bit integer in registers `3*2=6` and `3*2+1=7`. +It was not possible to refer to 32 bit type starting at odd index. + +It is still not possible to read 32 bit value type starting "middle" of register. +However, if such need arises the addressing syntax is extensible to covert these cases. + +Bits, and other <16 bit value types, inside registers are addressed using `start="X.Y"` convention. +This is more explicit notation hopefully reduces the risk of misinterpretation. + +### Polling Details + +The new binding polls data in parallel which means that errors with one slave do not necessarily slow down polling with some other slave. + +Furthermore, once can disable polling altogether and trigger polling on-demand using `REFRESH`. + +### Transformation Changes + +With the new binding the transformations get slightly different input. In polling, the transformation always receives number as input (see [Read steps](#read-steps)). +Old binding had converted the input based on item type. + +### Trigger Removed + +The old binding had `trigger` parameter in item configuration to react only to some openHAB commands, or to some polled states. +There is no trigger anymore but one can use transformations to accomplish the same thing. See [Transformations](#transformations) for examples. + +### Support For 32, 64 Bit Value Types In Writing + +The new binding supports 32 and 64 bit values types when writing. + +### How to manually migrate + +Here is a step by step example for a migration from a 1.x configuration to an equivalent 2.x configuration. +It does not cover all features the 1.x configuration offers, but it should serve as an example on how to get it done. + +The 1.x modbus configuration to be updated defined 4 slaves: + +`modbus.cfg` + +```text + poll=500 + + udp.slave1.connection=192.168.2.9:6000 + udp.slave1.type=coil + udp.slave1.start=12288 + udp.slave1.length=128 + udp.slave1.updateunchangeditems=false + + udp.slave2.connection=192.168.2.9:6000 + udp.slave2.type=holding + udp.slave2.start=12338 + udp.slave2.length=100 + udp.slave2.updateunchangeditems=false + + udp.slave3.connection=192.168.2.9:6000 + udp.slave3.type=holding + udp.slave3.start=12438 + udp.slave3.length=100 + udp.slave3.updateunchangeditems=false + + udp.slave4.connection=192.168.2.9:6000 + udp.slave4.type=holding + udp.slave4.start=12538 + udp.slave4.length=100 + udp.slave4.updateunchangeditems=false +``` + +As you can see, all the slaves poll the same modbus device (actually a Wago 750-841 controller). +We now have to create `Things` for this slaves. + +The 2.x modbus binding uses a three-level definition. +Level one defines a `Bridge` for every modbus device that is to be addressed. +The 1.x configuration in this example only addresses one device, so there will be one top level bridge. + +```java +Bridge modbus:udp:wago [ host="192.168.2.9", port=6000 ] { + +} +``` + +Host and Port are taken from the 1.x modbus config. + +Within the top level `Bridge` there are one or more second level bridges that replace the former `slave` configurations. +The poll frequency can now be set per `poller`, so you may want to define different poll cycles up to your needs. +The slave `Bridge` configs go inside the top level config. +For the four `poller`s defined in this example the 2.x configuration looks like this: + +```java +Bridge modbus:udp:wago [ host="192.168.2.9", port=6000, id=1 ] { + + Bridge poller wago_slave1 [ start=12288, length=128, refresh=500, type="coil" ] { + } + + Bridge poller wago_slave2 [ start=12338, length=100, refresh=4000, type="holding" ] { + } + + Bridge poller wago_slave3 [ start=12438, length=100, refresh=5000, type="holding" ] { + } + + Bridge poller wago_slave4 [ start=12538, length=100, refresh=10000, type="holding" ] { + } +} +``` + +Address, length and type can be directly taken over from the 1.x config. + +The third (and most complex) part is the definition of data `Thing` objects for every `Item` bound to modbus. +This definitions go into the corresponding 2nd level `Bridge` definitions. +Here it is especially important that the modbus binding now uses absolute addresses all over the place, while the addresses in the item definition for the 1.x binding were relative to the start address of the slave definition before. +For less work in the following final step, the update of the `Item` configuration, the naming of the `data` things in this example uses the offset of the modbus value within the `poller` as suffix, starting with 0(!). +See below for details. + +Here a few examples of the Item configuration from the 1.x binding: + +The first Item polled with the first `poller` used this configuration (with offset 0): + +```java +Switch FooSwitch "Foo Switch" {modbus="slave1:0"} +``` + +Now we have to define a `Thing` that can later be bound to that Item. + +The `slave1` `poller` uses `12288` as start address. +So we define the first data Thing within the `poller` `wago_slave1` with this address and choose a name that ends with `0`: + +```java +Thing data wago_s1_000 [ readStart="12288", readValueType="bit", writeStart="12288", writeValueType="bit", writeType="coil" ] +``` + +The second Item of the 1.x binding (offset `1`) is defined as follows. + +```java +Switch BarSwitch "Bar Switch" {modbus="slave1:1"} +``` + +This leads to the thing definition + +```java +Thing data wago_s1_001 [ readStart="12289", readValueType="bit", writeStart="12289", writeValueType="bit", writeType="coil" ] +``` + +Note the absolute address `12289` (12288+1) which has to be used here. + +Incorporating this definitions into the thing file leads to: + +`wago.things`: + +```java +Bridge modbus:udp:wago [ host="192.168.2.9", port=6000, id=1 ] { + + Bridge poller wago_slave1 [ start=12288, length=128, refresh=500, type="coil" ] { + Thing data wago_s1_000 [ readStart="12288", readValueType="bit", writeStart="12288", writeValueType="bit", writeType="coil" ] + Thing data wago_s1_001 [ readStart="12289", readValueType="bit", writeStart="12289", writeValueType="bit", writeType="coil" ] + } + + Bridge poller wago_slave2 [ start=12338, length=100, refresh=4000, type="holding" ] { + } + + Bridge poller wago_slave3 [ start=12438, length=100, refresh=5000, type="holding" ] { + } + + Bridge poller wago_slave4 [ start=12538, length=100, refresh=10000, type="holding" ] { + } +} +``` + +Save this in the `things` folder. +Watch the file `events.log` as it lists your new added `data` `Things`. +Given that there are no config errors, they quickly change from `INITIALIZING` to `ONLINE`. + +Finally the Item definition has to be changed to refer to the new created `data` `Thing`. +You can copy the names you need for this directly from the `events.log` file: + +```java +Switch FooSwitch "Foo Switch" {modbus="slave1:0"} +Switch BarSwitch "Bar Switch" {modbus="slave1:1"} +``` + +turn into + +```java +Switch FooSwitch "Foo Switch" {channel="modbus:data:wago:wago_slave1:wago_s1_000:switch", autopudate="false"} +Switch BarSwitch "Bar Switch" {channel="modbus:data:wago:wago_slave1:wago_s1_001:switch", autoupdate="false"} +``` + +If you have many Items to change and used the naming scheme recommended above, you can now use the following search-and-replace expressions in your editor: + +Replace + +`{modbus="slave1:` + +by + +`{channel="modbus:data:wago:wago_slave1:wago_s1_00` + +in all lines which used single digits for the address in the 1.x config. +Instead of `wago`, `wago_slave1` and `wago_s1_00` you have to use the names you have chosen for your `Bridge`, `poller` and `data` things. +Similar expressions are to be used for two-digit and three-digit relative addresses. + +Replace + +`"}` + +by + +`:switch"}` + +in all lines dealing with switches. +For other Item types use the respective replace strings. + +That way you can update even a large amount of Item definitions in only a few steps. + +The definition of `autoupdate` is optional; please refer to [`autoupdate`](#autoupdate-parameter-with-items) to check whether you need it or not. + +Continue to add `data` `Thing`s for all your other Items the same way and link them to your Items. + +Save your updated item file and check whether updates come in as expected. + +## Troubleshooting Tips + +### Thing Status + +Check thing status for errors in configuration or communication. + +### Enable Verbose Logging + +Enable `DEBUG` or `TRACE` (even more verbose) logging for the loggers named: + +- `org.openhab.binding.sbus` +- `org.openhab.core.io.transport.sbus` +- `net.wimpi.modbus` + +Consult [openHAB logging documentation](https://www.openhab.org/docs/administration/logging.html#defining-what-to-log) for more information. + +## For Developers + +This binding can be extended in many ways. +If you have a Modbus enabled device that you want to support in openHAB please read the [developer section](https://github.com/openhab/openhab-addons/blob/main/bundles/org.openhab.binding.sbus/DEVELOPERS.md). diff --git a/bundles/org.openhab.binding.sbus/doc/images/DiscoveryProcess.png b/bundles/org.openhab.binding.sbus/doc/images/DiscoveryProcess.png new file mode 100644 index 000000000000..afacf64d419c Binary files /dev/null and b/bundles/org.openhab.binding.sbus/doc/images/DiscoveryProcess.png differ diff --git a/bundles/org.openhab.binding.sbus/doc/images/ModbusExtensibleDiscovery.png b/bundles/org.openhab.binding.sbus/doc/images/ModbusExtensibleDiscovery.png new file mode 100644 index 000000000000..1f348c63b247 Binary files /dev/null and b/bundles/org.openhab.binding.sbus/doc/images/ModbusExtensibleDiscovery.png differ diff --git a/bundles/org.openhab.binding.sbus/pom.xml b/bundles/org.openhab.binding.sbus/pom.xml new file mode 100644 index 000000000000..ce6fa9a97936 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.2.0-SNAPSHOT + + + org.openhab.binding.sbus + + openHAB Add-ons :: Bundles :: S-Bus Binding + + diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/ModbusBindingConstants.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/ModbusBindingConstants.java new file mode 100644 index 000000000000..5847450dac1a --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/ModbusBindingConstants.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2024 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.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ModbusBindingConstants} class defines some constants + * public that might be used from other bundles as well. + * + * @author Ciprian Pascu - Initial contribution + * @author Nagy Attila Gabor - Split the original ModbusBindingConstants in two + */ +@NonNullByDefault +public class ModbusBindingConstants { + + public static final String BINDING_ID = "sbus"; +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/ModbusDiscoveryListener.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/ModbusDiscoveryListener.java new file mode 100644 index 000000000000..fdcc96684100 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/ModbusDiscoveryListener.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2010-2024 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.sbus.discovery; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.config.discovery.DiscoveryResult; + +/** + * Listener for discovery results + * + * Each discovered thing should be supplied to the thingDiscovered + * method. + * + * When the discovery process has been finished then the discoveryFinished + * method should be called. + * + * @author Nagy Attila Gabor - initial contribution + * + */ +@NonNullByDefault +public interface ModbusDiscoveryListener { + + /** + * Discovery participant should call this method when a new + * thing has been discovered + */ + void thingDiscovered(DiscoveryResult result); + + /** + * This method should be called once the discovery has been finished + * or aborted by any error. + * It is important to call this even when there were no things discovered. + */ + void discoveryFinished(); +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/ModbusDiscoveryParticipant.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/ModbusDiscoveryParticipant.java new file mode 100644 index 000000000000..d1de8e8d487a --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/ModbusDiscoveryParticipant.java @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2010-2024 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.sbus.discovery; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.sbus.handler.ModbusEndpointThingHandler; +import org.openhab.core.thing.ThingTypeUID; + +/** + * Interface for participants of Modbus discovery + * This is an asynchronous process where a participant can discover + * multiple things on a Modbus endpoint. + * + * Results should be submitted using the ModbusDiscvoeryListener + * supplied at the begin of the scan. + * + * @author Nagy Attila Gabor - initial contribution + * + */ +@NonNullByDefault +public interface ModbusDiscoveryParticipant { + + /** + * Defines the list of thing types that this participant can identify + * + * @return a set of thing type UIDs for which results can be created + */ + Set getSupportedThingTypeUIDs(); + + /** + * Start an asynchronous discovery process of a Modbus endpoint + * + * @param handler the endpoint that should be discovered + */ + void startDiscovery(ModbusEndpointThingHandler handler, ModbusDiscoveryListener listener); +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusDiscoveryService.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusDiscoveryService.java new file mode 100644 index 000000000000..061846b313ab --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusDiscoveryService.java @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2010-2024 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.sbus.discovery.internal; + +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.sbus.discovery.ModbusDiscoveryParticipant; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.thing.ThingTypeUID; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * + * Discovery service for Modbus bridges. + * + * This service acts as a rendezvous point between the different Modbus endpoints and any + * bundles that implement auto discovery through an endpoint. + * + * New bridges (UDP or Serial Modbus endpoint) should register with this service. This is + * handled automatically by the ModbusEndpointDiscoveryService. + * Also any bundles that perform auto discovery should register a ModbusDiscoveryParticipant. + * This ModbusDiscoveryParticipants will be called by the service when + * a discovery scan is requested. + * + * @author Nagy Attila Gabor - initial contribution + * + */ +@Component(service = DiscoveryService.class, configurationPid = "discovery.modbus") +@NonNullByDefault +public class ModbusDiscoveryService extends AbstractDiscoveryService { + + private final Logger logger = LoggerFactory.getLogger(ModbusDiscoveryService.class); + + // Set of services that support Modbus discovery + private final Set services = new CopyOnWriteArraySet<>(); + + // Set of the registered participants + private final Set participants = new CopyOnWriteArraySet<>(); + + // Set of the supported thing types. This is a union of all the thing types + // supported by the registered discovery services. + private final Set supportedThingTypes = new CopyOnWriteArraySet<>(); + + private static final int SEARCH_TIME_SECS = 5; + + /** + * Constructor for the discovery service. + * Set up default parameters + */ + public ModbusDiscoveryService() { + // No supported thing types by default + // Search time is for the visual reference + // Background discovery disabled by default + super(null, SEARCH_TIME_SECS, false); + } + + /** + * ThingHandlerService + * Begin a discovery scan over each endpoint + */ + @Override + protected void startScan() { + logger.trace("ModbusDiscoveryService starting scan"); + + if (participants.isEmpty()) { + // There's no point on continuing if there are no participants at the moment + stopScan(); + return; + } + + boolean scanStarted = false; + for (ModbusThingHandlerDiscoveryService service : services) { + scanStarted |= service.startScan(this); + } + if (!scanStarted) { + stopScan(); + } + } + + /** + * Interface to notify us when a handler has finished it's discovery process + */ + protected void scanFinished() { + for (ModbusThingHandlerDiscoveryService service : services) { + if (service.scanInProgress()) { + return; + } + } + logger.trace("All endpoints finished scanning, stopping scan"); + stopScan(); + } + + /** + * Real discovery is done by the ModbusDiscoveryParticipants + * They are executed in series for each Modbus endpoint by ModbusDiscoveryProcess + * instances. They call back this method when a thing has been discovered + */ + @Override + protected void thingDiscovered(DiscoveryResult discoveryResult) { + super.thingDiscovered(discoveryResult); + } + + /** + * Returns the list of {@code Thing} types which are supported by the {@link DiscoveryService}. + * + * @return the list of Thing types which are supported by the discovery service + * (not null, could be empty) + */ + @Override + public Set getSupportedThingTypes() { + return this.supportedThingTypes; + } + + /** + * This reference is used to register any new Modbus bridge with the discovery service + * Running bridges have a ModbusThingHandlerDiscoveryService connected + * which will be responsible for the discovery + * + * @param service discovery service + */ + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) + protected void addModbusEndpoint(ModbusThingHandlerDiscoveryService service) { + logger.trace("Received new handler: {}", service); + services.add(service); + } + + /** + * Remove an already registered thing handler discovery component + * + * @param service discovery service + */ + protected void removeModbusEndpoint(ModbusThingHandlerDiscoveryService service) { + logger.trace("Removed handler: {}", service); + services.remove(service); + } + + /** + * Register a discovery participant. This participant will be called + * with any new Modbus bridges that allow discovery + * + * @param participant + */ + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) + protected void addModbusDiscoveryParticipant(ModbusDiscoveryParticipant participant) { + logger.trace("Received new participant: {}", participant); + participants.add(participant); + supportedThingTypes.addAll(participant.getSupportedThingTypeUIDs()); + } + + /** + * Remove an already registered discovery participant + * + * @param participant + */ + protected void removeModbusDiscoveryParticipant(ModbusDiscoveryParticipant participant) { + logger.trace("Removing participant: {}", participant); + supportedThingTypes.removeAll(participant.getSupportedThingTypeUIDs()); + participants.remove(participant); + } + + /** + * Return the set of participants + * + * @return a set of the participants. Note: this is a copy of the original set + */ + public Set getDiscoveryParticipants() { + return new CopyOnWriteArraySet<>(participants); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusEndpointDiscoveryService.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusEndpointDiscoveryService.java new file mode 100644 index 000000000000..d418ce5ccf56 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusEndpointDiscoveryService.java @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2010-2024 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.sbus.discovery.internal; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.sbus.discovery.ModbusDiscoveryListener; +import org.openhab.binding.sbus.discovery.ModbusDiscoveryParticipant; +import org.openhab.binding.sbus.handler.ModbusEndpointThingHandler; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.thing.binding.ThingHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A new instance of this class is created for each Modbus endpoint handler + * that supports discovery. + * This service gets called each time a discovery is requested, and it is + * responsible to execute the discovery on the connected thing handler. + * Actual discovery is done by the registered ModbusDiscoveryparticipants + * + * @author Nagy Attila Gabor - initial contribution + * + */ +@NonNullByDefault +public class ModbusEndpointDiscoveryService implements ModbusThingHandlerDiscoveryService { + + private final Logger logger = LoggerFactory.getLogger(ModbusEndpointDiscoveryService.class); + + // This is the handler we will do the discovery on + private @Nullable ModbusEndpointThingHandler handler; + + // List of the registered participants + // this only contains data when there is scan in progress + private final List participants = new CopyOnWriteArrayList<>(); + + // This is set true when we're waiting for a participant to finish discovery + private boolean waitingForParticipant = false; + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + if (handler instanceof ModbusEndpointThingHandler thingHandler) { + this.handler = thingHandler; + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return (ThingHandler) handler; + } + + @Override + public boolean startScan(ModbusDiscoveryService service) { + ModbusEndpointThingHandler handler = this.handler; + if (handler == null || !handler.isDiscoveryEnabled()) { + return false; + } + logger.trace("Starting discovery on endpoint {}", handler.getUID().getAsString()); + + participants.addAll(service.getDiscoveryParticipants()); + + startNextParticipant(handler, service); + + return true; + } + + @Override + public boolean scanInProgress() { + return !participants.isEmpty() || waitingForParticipant; + } + + /** + * Run the next participant's discovery process + * + * @param service reference to the ModbusDiscoveryService that will collect all the + * discovered items + */ + private void startNextParticipant(final ModbusEndpointThingHandler handler, final ModbusDiscoveryService service) { + if (participants.isEmpty()) { + logger.trace("All participants has finished"); + service.scanFinished(); + return; // We're finished, this will exit the process + } + + ModbusDiscoveryParticipant participant = participants.remove(0); + + waitingForParticipant = true; + + // Call startDiscovery on the next participant. The ModbusDiscoveryListener + // callback will be notified each time a thing is discovered, and also when + // the discovery is finished by this participant + participant.startDiscovery(handler, new ModbusDiscoveryListener() { + + /** + * Participant has found a thing + */ + @Override + public void thingDiscovered(DiscoveryResult result) { + service.thingDiscovered(result); + } + + /** + * Participant finished discovery. + * We can continue to the next participant + */ + @Override + public void discoveryFinished() { + waitingForParticipant = false; + startNextParticipant(handler, service); + } + }); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusThingHandlerDiscoveryService.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusThingHandlerDiscoveryService.java new file mode 100644 index 000000000000..07677b2481ce --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/discovery/internal/ModbusThingHandlerDiscoveryService.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2010-2024 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.sbus.discovery.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.binding.ThingHandlerService; + +/** + * Implementation of this interface is responsible for discovery over + * a Modbus endpoint. Each time a supporting endpoint handler is created + * an instance of this service will be created as well and attached to the + * thing handler. + * + * @author Nagy Attila Gabor - initial contribution + */ +@NonNullByDefault +public interface ModbusThingHandlerDiscoveryService extends ThingHandlerService { + + /** + * Implementation should start a discovery when this method gets called + * + * @param service the discovery service that should be called when the discovery is finished + * @return returns true if discovery is enabled, false otherwise + */ + boolean startScan(ModbusDiscoveryService service); + + /** + * This method should return true, if an async scan is in progress + * + * @return true if a scan is in progress false otherwise + */ + boolean scanInProgress(); +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/BaseModbusThingHandler.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/BaseModbusThingHandler.java new file mode 100644 index 000000000000..ec8697082dba --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/BaseModbusThingHandler.java @@ -0,0 +1,210 @@ +/** + * Copyright (c) 2010-2024 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.sbus.handler; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Future; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.ModbusCommunicationInterface; +import org.openhab.core.io.transport.sbus.ModbusFailureCallback; +import org.openhab.core.io.transport.sbus.ModbusReadCallback; +import org.openhab.core.io.transport.sbus.ModbusReadRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteCallback; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprint; +import org.openhab.core.io.transport.sbus.PollTask; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.BridgeHandler; + +/** + * This is a convenience class to interact with the Thing's {@link ModbusCommunicationInterface}. + * + * @author Fabian Wolter - Initial contribution + * + */ +@NonNullByDefault +public abstract class BaseModbusThingHandler extends BaseThingHandler { + private List periodicPollers = Collections.synchronizedList(new ArrayList<>()); + private List> oneTimePollers = Collections.synchronizedList(new ArrayList<>()); + + public BaseModbusThingHandler(Thing thing) { + super(thing); + } + + /** + * This method is called when the Thing is being initialized, but only if the Modbus Bridge is configured correctly. + * The code that normally goes into `BaseThingHandler.initialize()` like configuration reading and validation goes + * here. + */ + public abstract void modbusInitialize(); + + @Override + public final void initialize() { + try { + // check if the Bridge is configured correctly (fail-fast) + getModbus(); + getSlaveId(); + } catch (IllegalStateException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, e.getMessage()); + } + + modbusInitialize(); + } + + /** + * Get Slave ID, also called as unit id, represented by the thing + * + * @return slave id represented by this thing handler + */ + public int getSlaveId() { + try { + return getBridgeHandler().getSlaveId(); + } catch (EndpointNotInitializedException e) { + throw new IllegalStateException("Bridge not initialized"); + } + } + + /** + * Return true if auto discovery is enabled for this endpoint + * + * @return boolean true if the discovery is enabled + */ + public boolean isDiscoveryEnabled() { + return getBridgeHandler().isDiscoveryEnabled(); + } + + /** + * Register regularly polled task. The method returns immediately, and the execution of the poll task will happen in + * the background. + * + * One can register only one regular poll task for triplet of (endpoint, request, callback). + * + * @param request request to send + * @param pollPeriodMillis poll interval, in milliseconds + * @param initialDelayMillis initial delay before starting polling, in milliseconds + * @param resultCallback callback to call with data + * @param failureCallback callback to call in case of failure + * @return poll task representing the regular poll + * @throws IllegalStateException when this communication has been closed already + */ + public PollTask registerRegularPoll(ModbusReadRequestBlueprint request, long pollPeriodMillis, + long initialDelayMillis, ModbusReadCallback resultCallback, + ModbusFailureCallback failureCallback) { + PollTask task = getModbus().registerRegularPoll(request, pollPeriodMillis, initialDelayMillis, resultCallback, + failureCallback); + periodicPollers.add(task); + + return task; + } + + /** + * Unregister regularly polled task + * + * If this communication interface is closed already, the method returns immediately with false return value + * + * @param task poll task to unregister + * @return whether poll task was unregistered. Poll task is not unregistered in case of unexpected errors or + * in the case where the poll task is not registered in the first place + * @throws IllegalStateException when this communication has been closed already + */ + public boolean unregisterRegularPoll(PollTask task) { + periodicPollers.remove(task); + return getModbus().unregisterRegularPoll(task); + } + + /** + * Submit one-time poll task. The method returns immediately, and the execution of the poll task will happen in + * background. + * + * @param request request to send + * @param resultCallback callback to call with data + * @param failureCallback callback to call in case of failure + * @return future representing the polled task + * @throws IllegalStateException when this communication has been closed already + */ + public Future submitOneTimePoll(ModbusReadRequestBlueprint request, ModbusReadCallback resultCallback, + ModbusFailureCallback failureCallback) { + Future future = getModbus().submitOneTimePoll(request, resultCallback, failureCallback); + oneTimePollers.add(future); + oneTimePollers.removeIf(Future::isDone); + + return future; + } + + /** + * Submit one-time write task. The method returns immediately, and the execution of the task will happen in + * background. + * + * @param request request to send + * @param resultCallback callback to call with response + * @param failureCallback callback to call in case of failure + * @return future representing the task + * @throws IllegalStateException when this communication has been closed already + */ + public Future submitOneTimeWrite(ModbusWriteRequestBlueprint request, ModbusWriteCallback resultCallback, + ModbusFailureCallback failureCallback) { + Future future = getModbus().submitOneTimeWrite(request, resultCallback, failureCallback); + oneTimePollers.add(future); + oneTimePollers.removeIf(Future::isDone); + + return future; + } + + private ModbusCommunicationInterface getModbus() { + ModbusCommunicationInterface communicationInterface = getBridgeHandler().getCommunicationInterface(); + + if (communicationInterface == null) { + throw new IllegalStateException("Bridge not initialized"); + } else { + return communicationInterface; + } + } + + private ModbusEndpointThingHandler getBridgeHandler() { + try { + Bridge bridge = getBridge(); + if (bridge == null) { + throw new IllegalStateException("No Bridge configured"); + } + + BridgeHandler handler = bridge.getHandler(); + + if (handler instanceof ModbusEndpointThingHandler thingHandler) { + return thingHandler; + } else { + throw new IllegalStateException("Not a Modbus Bridge: " + handler); + } + } catch (IllegalStateException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, e.getMessage()); + throw e; + } + } + + @Override + public void dispose() { + oneTimePollers.forEach(p -> p.cancel(true)); + oneTimePollers.clear(); + + ModbusCommunicationInterface modbus = getModbus(); + periodicPollers.forEach(p -> modbus.unregisterRegularPoll(p)); + periodicPollers.clear(); + + super.dispose(); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/EndpointNotInitializedException.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/EndpointNotInitializedException.java new file mode 100644 index 000000000000..aaaed0854948 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/EndpointNotInitializedException.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2024 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.sbus.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Signals that {@link ModbusEndpointThingHandler} is not properly initialized yet, and the requested operation cannot + * be completed. + * + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class EndpointNotInitializedException extends Exception { + + private static final long serialVersionUID = -6721646244844348903L; +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/ModbusEndpointThingHandler.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/ModbusEndpointThingHandler.java new file mode 100644 index 000000000000..4da19b1b7b00 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/ModbusEndpointThingHandler.java @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2010-2024 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.sbus.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.registry.Identifiable; +import org.openhab.core.io.transport.sbus.ModbusCommunicationInterface; +import org.openhab.core.thing.ThingUID; + +/** + * Base interface for thing handlers of endpoint things + * + * @author Ciprian Pascu - Initial contribution + * + */ +@NonNullByDefault +public interface ModbusEndpointThingHandler extends Identifiable { + + /** + * Gets the {@link ModbusCommunicationInterface} represented by the thing + * + * Note that this can be null in case of incomplete initialization + * + * @return communication interface represented by this thing handler + */ + @Nullable + ModbusCommunicationInterface getCommunicationInterface(); + + /** + * Get Slave ID, also called as unit id, represented by the thing + * + * @return slave id represented by this thing handler + * @throws EndpointNotInitializedException in case the initialization is not complete + */ + int getSlaveId() throws EndpointNotInitializedException; + + /** + * Get Slave ID, also called as unit id, represented by the thing + * + * @return slave id represented by this thing handler + * @throws EndpointNotInitializedException in case the initialization is not complete + */ + int getSubnetId() throws EndpointNotInitializedException; + + /** + * Return true if auto discovery is enabled for this endpoint + * + * @return boolean true if the discovery is enabled + */ + boolean isDiscoveryEnabled(); +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/ModbusPollerThingHandler.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/ModbusPollerThingHandler.java new file mode 100644 index 000000000000..14c46b1582ab --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/handler/ModbusPollerThingHandler.java @@ -0,0 +1,467 @@ +/** + * Copyright (c) 2010-2024 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.sbus.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; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.sbus.internal.AtomicStampedValue; +import org.openhab.binding.sbus.internal.ModbusBindingConstantsInternal; +import org.openhab.binding.sbus.internal.config.ModbusPollerConfiguration; +import org.openhab.binding.sbus.internal.handler.ModbusDataThingHandler; +import org.openhab.core.io.transport.sbus.AsyncModbusFailure; +import org.openhab.core.io.transport.sbus.AsyncModbusReadResult; +import org.openhab.core.io.transport.sbus.ModbusCommunicationInterface; +import org.openhab.core.io.transport.sbus.ModbusConstants; +import org.openhab.core.io.transport.sbus.ModbusFailureCallback; +import org.openhab.core.io.transport.sbus.ModbusReadCallback; +import org.openhab.core.io.transport.sbus.ModbusReadFunctionCode; +import org.openhab.core.io.transport.sbus.ModbusReadRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.io.transport.sbus.PollTask; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ModbusPollerThingHandler} is responsible for polling Modbus slaves. Errors and data is delegated to + * child thing handlers inheriting from {@link ModbusReadCallback} -- in practice: {@link ModbusDataThingHandler}. + * + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class ModbusPollerThingHandler extends BaseBridgeHandler { + + /** + * {@link ModbusReadCallback} that delegates all tasks forward. + * + * All instances of {@linkplain ReadCallbackDelegator} are considered equal, if they are connected to the same + * bridge. This makes sense, as the callback delegates + * to all child things of this bridge. + * + * @author Ciprian Pascu - Initial contribution + * + */ + private class ReadCallbackDelegator + implements ModbusReadCallback, ModbusFailureCallback { + + private volatile @Nullable AtomicStampedValue lastResult; + + public synchronized void handleResult(PollResult result) { + // Ignore all incoming data and errors if configuration is not correct + if (hasConfigurationError() || disposed) { + return; + } + if (config.getCacheMillis() >= 0) { + AtomicStampedValue localLastResult = this.lastResult; + if (localLastResult == null) { + this.lastResult = new AtomicStampedValue<>(System.currentTimeMillis(), result); + } else { + localLastResult.update(System.currentTimeMillis(), result); + this.lastResult = localLastResult; + } + } + logger.debug("Thing {} received response {}", thing.getUID(), result); + notifyChildren(result); + if (result.failure != null) { + Exception error = result.failure.getCause(); + assert error != null; + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + String.format("Error with read: %s: %s", error.getClass().getName(), error.getMessage())); + } else { + resetCommunicationError(); + } + } + + @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)); + } + + @Override + public synchronized void handle(AsyncModbusFailure failure) { + handleResult(new PollResult(failure)); + } + + private void resetCommunicationError() { + ThingStatusInfo statusInfo = thing.getStatusInfo(); + if (ThingStatus.OFFLINE.equals(statusInfo.getStatus()) + && ThingStatusDetail.COMMUNICATION_ERROR.equals(statusInfo.getStatusDetail())) { + updateStatus(ThingStatus.ONLINE); + } + } + + /** + * Update children data if data is fresh enough + * + * @param oldestStamp oldest data that is still passed to children + * @return whether data was updated. Data is not updated when it's too old or there's no data at all. + */ + @SuppressWarnings("null") + public boolean updateChildrenWithOldData(long oldestStamp) { + return Optional.ofNullable(this.lastResult).map(result -> result.copyIfStampAfter(oldestStamp)) + .map(result -> { + logger.debug("Thing {} reusing cached data: {}", thing.getUID(), result.getValue()); + notifyChildren(result.getValue()); + return true; + }).orElse(false); + } + + private void notifyChildren(PollResult pollResult) { + @Nullable + AsyncModbusReadResult result = pollResult.result; + @Nullable + AsyncModbusFailure failure = pollResult.failure; + childCallbacks.forEach(handler -> { + if (result != null) { + handler.onReadResult(result); + } else if (failure != null) { + handler.handleReadError(failure); + } + }); + } + + /** + * Rest data caches + */ + public void resetCache() { + lastResult = null; + } + } + + /** + * Immutable data object to cache the results of a poll request + */ + private class PollResult { + + public final @Nullable AsyncModbusReadResult result; + public final @Nullable AsyncModbusFailure failure; + + PollResult(AsyncModbusReadResult result) { + this.result = result; + this.failure = null; + } + + PollResult(AsyncModbusFailure failure) { + this.result = null; + this.failure = failure; + } + + @Override + public String toString() { + return failure == null ? String.format("PollResult(result=%s)", result) + : String.format("PollResult(failure=%s)", failure); + } + } + + private final Logger logger = LoggerFactory.getLogger(ModbusPollerThingHandler.class); + + private static final List SORTED_READ_FUNCTION_CODES = ModbusBindingConstantsInternal.READ_FUNCTION_CODES + .keySet().stream().sorted().collect(Collectors.toUnmodifiableList()); + + private @NonNullByDefault({}) ModbusPollerConfiguration config; + private long cacheMillis; + private volatile @Nullable PollTask pollTask; + 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(); + + private @Nullable ModbusReadFunctionCode functionCode; + + public ModbusPollerThingHandler(Bridge bridge) { + super(bridge); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // No channels, no commands + } + + private @Nullable ModbusEndpointThingHandler getEndpointThingHandler() { + Bridge bridge = getBridge(); + if (bridge == null) { + logger.debug("Bridge is null"); + return null; + } + if (bridge.getStatus() != ThingStatus.ONLINE) { + logger.debug("Bridge is not online"); + return null; + } + + ThingHandler handler = bridge.getHandler(); + if (handler == null) { + logger.debug("Bridge handler is null"); + return null; + } + + if (handler instanceof ModbusEndpointThingHandler thingHandler) { + return thingHandler; + } else { + logger.debug("Unexpected bridge handler: {}", handler); + return null; + } + } + + @Override + public synchronized void initialize() { + if (this.getThing().getStatus().equals(ThingStatus.ONLINE)) { + // If the bridge was online then first change it to offline. + // this ensures that children will be notified about the change + updateStatus(ThingStatus.OFFLINE); + } + this.callbackDelegator.resetCache(); + comms = null; + request = null; + disposed = false; + logger.trace("Initializing {} from status {}", this.getThing().getUID(), this.getThing().getStatus()); + try { + config = getConfigAs(ModbusPollerConfiguration.class); + String type = config.getType(); + if (!ModbusBindingConstantsInternal.READ_FUNCTION_CODES.containsKey(type)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + String.format("No function code found for type='%s'. Was expecting one of: %s", type, + String.join(", ", SORTED_READ_FUNCTION_CODES))); + return; + } + functionCode = ModbusBindingConstantsInternal.READ_FUNCTION_CODES.get(type); + switch (functionCode) { + case READ_INPUT_REGISTERS: + case READ_MULTIPLE_REGISTERS: + if (config.getLength() > ModbusConstants.MAX_REGISTERS_READ_COUNT) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format( + "Maximum of %d registers can be polled at once due to protocol limitations. Length %d is out of bounds.", + ModbusConstants.MAX_REGISTERS_READ_COUNT, config.getLength())); + return; + } + break; + case READ_COILS: + case READ_INPUT_DISCRETES: + if (config.getLength() > ModbusConstants.MAX_BITS_READ_COUNT) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format( + "Maximum of %d coils/discrete inputs can be polled at once due to protocol limitations. Length %d is out of bounds.", + ModbusConstants.MAX_BITS_READ_COUNT, config.getLength())); + return; + } + break; + } + cacheMillis = this.config.getCacheMillis(); + registerPollTask(); + } catch (EndpointNotInitializedException e) { + logger.debug("Exception during initialization", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String + .format("Exception during initialization: %s (%s)", e.getMessage(), e.getClass().getSimpleName())); + } finally { + logger.trace("initialize() of thing {} '{}' finished", thing.getUID(), thing.getLabel()); + } + } + + @Override + public synchronized void dispose() { + logger.debug("dispose()"); + // Mark handler as disposed as soon as possible to halt processing of callbacks + disposed = true; + unregisterPollTask(); + this.callbackDelegator.resetCache(); + comms = null; + lastPolledDataCache.set(null); + } + + /** + * Unregister poll task. + * + * No-op in case no poll task is registered, or if the initialization is incomplete. + */ + public synchronized void unregisterPollTask() { + logger.trace("unregisterPollTask()"); + if (config == null) { + return; + } + PollTask localPollTask = this.pollTask; + if (localPollTask != null) { + logger.debug("Unregistering polling from ModbusManager"); + comms.unregisterRegularPoll(localPollTask); + } + this.pollTask = null; + request = null; + comms = null; + updateStatus(ThingStatus.OFFLINE); + } + + /** + * Register poll task + * + * @throws EndpointNotInitializedException in case the bridge initialization is not complete. This should only + * happen in transient conditions, for example, when bridge is initializing. + */ + @SuppressWarnings("null") + private synchronized void registerPollTask() throws EndpointNotInitializedException { + logger.trace("registerPollTask()"); + if (pollTask != null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR); + logger.debug("pollTask should be unregistered before registering a new one!"); + return; + } + + ModbusEndpointThingHandler slaveEndpointThingHandler = getEndpointThingHandler(); + if (slaveEndpointThingHandler == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, String.format("Bridge '%s' is offline", + Optional.ofNullable(getBridge()).map(b -> b.getLabel()).orElse(""))); + logger.debug("No bridge handler available -- aborting init for {}", this); + return; + } + ModbusCommunicationInterface localComms = slaveEndpointThingHandler.getCommunicationInterface(); + if (localComms == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, String.format( + "Bridge '%s' not completely initialized", Optional.ofNullable(getBridge()).map(b -> b.getLabel()))); + logger.debug("Bridge not initialized fully (no communication interface) -- aborting init for {}", this); + return; + } + this.comms = localComms; + ModbusReadFunctionCode localFunctionCode = functionCode; + if (localFunctionCode == null) { + return; + } + + ModbusReadRequestBlueprint localRequest = new ModbusReadRequestBlueprint( + slaveEndpointThingHandler.getSubnetId(), slaveEndpointThingHandler.getSlaveId(), localFunctionCode, + config.getStart(), config.getLength(), config.getMaxTries()); + this.request = localRequest; + + if (config.getRefresh() <= 0L) { + logger.debug("Not registering polling with ModbusManager since refresh disabled"); + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, "Not polling"); + } else { + logger.debug("Registering polling with ModbusManager"); + pollTask = localComms.registerRegularPoll(localRequest, config.getRefresh(), 0, callbackDelegator, + callbackDelegator); + assert pollTask != null; + updateStatus(ThingStatus.ONLINE); + } + } + + private boolean hasConfigurationError() { + ThingStatusInfo statusInfo = getThing().getStatusInfo(); + return statusInfo.getStatus() == ThingStatus.OFFLINE + && statusInfo.getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR; + } + + @Override + public synchronized void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + logger.debug("bridgeStatusChanged for {}. Reseting handler", this.getThing().getUID()); + this.dispose(); + this.initialize(); + } + + @Override + public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) { + if (childHandler instanceof ModbusDataThingHandler modbusDataThingHandler) { + this.childCallbacks.add(modbusDataThingHandler); + } + } + + @SuppressWarnings("unlikely-arg-type") + @Override + public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { + if (childHandler instanceof ModbusDataThingHandler) { + this.childCallbacks.remove(childHandler); + } + } + + /** + * Return {@link ModbusReadRequestBlueprint} represented by this thing. + * + * Note that request might be null in case initialization is not complete. + * + * @return modbus request represented by this poller + */ + public @Nullable ModbusReadRequestBlueprint getRequest() { + return request; + } + + /** + * Get communication interface associated with this poller + * + * @return + */ + public ModbusCommunicationInterface getCommunicationInterface() { + return comms; + } + + /** + * Refresh the data + * + * If data or error was just recently received (i.e. cache is fresh), return the cached response. + */ + public void refresh() { + ModbusReadRequestBlueprint localRequest = this.request; + 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 + && this.callbackDelegator.updateChildrenWithOldData(oldDataThreshold); + if (cacheWasRecentEnoughForUpdate) { + logger.debug( + "Poller {} received refresh() and cache was recent enough (age at most {} ms). Reusing old response", + getThing().getUID(), cacheMillis); + } else { + // cache expired, poll new data + logger.debug("Poller {} received refresh() but the cache is not applicable. Polling new data", + getThing().getUID()); + ModbusCommunicationInterface localComms = comms; + if (localComms != null) { + localComms.submitOneTimePoll(localRequest, callbackDelegator, callbackDelegator); + } + } + } + + public AtomicReference<@Nullable ModbusRegisterArray> getLastPolledDataCache() { + return lastPolledDataCache; + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/AtomicStampedValue.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/AtomicStampedValue.java new file mode 100644 index 000000000000..c8ef754734ff --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/AtomicStampedValue.java @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Timestamp-value pair that can be updated atomically + * + * @author Ciprian Pascu - Initial contribution + * + * @param type of the value + */ +@NonNullByDefault +public class AtomicStampedValue implements Cloneable { + + private long stamp; + private V value; + + private AtomicStampedValue(AtomicStampedValue copy) { + this(copy.stamp, copy.value); + } + + /** + * Construct new stamped key-value pair + * + * @param stamp stamp for the data + * @param value value for the data + * + * @throws NullPointerException when key or value is null + */ + public AtomicStampedValue(long stamp, V value) { + Objects.requireNonNull(value, "value should not be null!"); + this.stamp = stamp; + this.value = value; + } + + /** + * Update data in this instance atomically + * + * @param stamp stamp for the data + * @param value value for the data + * + * @throws NullPointerException when value is null + */ + public synchronized void update(long stamp, V value) { + Objects.requireNonNull(value, "value should not be null!"); + this.stamp = stamp; + this.value = value; + } + + /** + * Copy data atomically and return the new (shallow) copy + * + * @return new copy of the data + */ + @SuppressWarnings("unchecked") + public synchronized AtomicStampedValue copy() { + return (AtomicStampedValue) this.clone(); + } + + /** + * Synchronized implementation of clone with exception swallowing + */ + @Override + protected synchronized Object clone() { + try { + return super.clone(); + } catch (CloneNotSupportedException e) { + // We should never end up here since this class implements Cloneable + throw new RuntimeException(e); + } + } + + /** + * Copy data atomically if data is after certain stamp ("fresh" enough) + * + * @param stampMin + * @return null, if the stamp of this instance is before stampMin. Otherwise return the data copied + */ + public synchronized @Nullable AtomicStampedValue copyIfStampAfter(long stampMin) { + if (stampMin <= this.stamp) { + return new AtomicStampedValue<>(this); + } else { + return null; + } + } + + /** + * Get stamp + */ + public long getStamp() { + return stamp; + } + + /** + * Get value + */ + public V getValue() { + return value; + } + + /** + * Compare two AtomicStampedKeyValue objects based on stamps + * + * Nulls are ordered first + * + * @param x first instance + * @param y second instance + * @return a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater + * than the second. + */ + public static int compare(@SuppressWarnings("rawtypes") @Nullable AtomicStampedValue x, + @SuppressWarnings("rawtypes") @Nullable AtomicStampedValue y) { + if (x == null) { + return -1; + } else if (y == null) { + return 1; + } else { + return Long.compare(x.stamp, y.stamp); + } + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/CascadedValueTransformationImpl.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/CascadedValueTransformationImpl.java new file mode 100644 index 000000000000..a7e08d348b53 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/CascadedValueTransformationImpl.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.osgi.framework.BundleContext; + +/** + * The {@link CascadedValueTransformationImpl} implements {@link SingleValueTransformation for a cascaded set of + * transformations} + * + * @author Jan N. Klug - Initial contribution + * @author Ciprian Pascu - Copied from HTTP binding to provide consistent user experience + */ +@NonNullByDefault +public class CascadedValueTransformationImpl implements ValueTransformation { + private final List transformations; + + public CascadedValueTransformationImpl(@Nullable String transformationString) { + String transformationNonNull = transformationString == null ? "" : transformationString; + List localTransformations = Arrays.stream(transformationNonNull.split("∩")) + .filter(s -> !s.isEmpty()).map(transformation -> new SingleValueTransformation(transformation)) + .collect(Collectors.toList()); + if (localTransformations.isEmpty()) { + localTransformations = List.of(new SingleValueTransformation(transformationString)); + } + transformations = localTransformations; + } + + @Override + public String transform(BundleContext context, String value) { + String input = value; + // process all transformations + for (final ValueTransformation transformation : transformations) { + input = transformation.transform(context, input); + } + return input; + } + + @Override + public boolean isIdentityTransform() { + return transformations.stream().allMatch(SingleValueTransformation::isIdentityTransform); + } + + @Override + public String toString() { + return "CascadedValueTransformationImpl(" + + transformations.stream().map(SingleValueTransformation::toString).collect(Collectors.joining(" ∩ ")) + + ")"; + } + + List getTransformations() { + return transformations; + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusBindingConstantsInternal.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusBindingConstantsInternal.java new file mode 100644 index 000000000000..279406ab8c37 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusBindingConstantsInternal.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import static org.openhab.binding.sbus.ModbusBindingConstants.BINDING_ID; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.ModbusReadFunctionCode; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link ModbusBindingConstantsInternal} class defines common constants, which are + * used across the whole binding. + * + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class ModbusBindingConstantsInternal { + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_MODBUS_UDP = new ThingTypeUID(BINDING_ID, "udp"); + public static final ThingTypeUID THING_TYPE_MODBUS_SERIAL = new ThingTypeUID(BINDING_ID, "serial"); + public static final ThingTypeUID THING_TYPE_MODBUS_POLLER = new ThingTypeUID(BINDING_ID, "poller"); + public static final ThingTypeUID THING_TYPE_MODBUS_DATA = new ThingTypeUID(BINDING_ID, "data"); + + // List of all Channel ids + public static final String CHANNEL_SWITCH = "switch"; + public static final String CHANNEL_CONTACT = "contact"; + public static final String CHANNEL_DATETIME = "datetime"; + public static final String CHANNEL_DIMMER = "dimmer"; + public static final String CHANNEL_NUMBER = "number"; + public static final String CHANNEL_STRING = "string"; + public static final String CHANNEL_ROLLERSHUTTER = "rollershutter"; + public static final String CHANNEL_LAST_READ_SUCCESS = "lastReadSuccess"; + public static final String CHANNEL_LAST_READ_ERROR = "lastReadError"; + public static final String CHANNEL_LAST_WRITE_SUCCESS = "lastWriteSuccess"; + public static final String CHANNEL_LAST_WRITE_ERROR = "lastWriteError"; + + public static final String[] DATA_CHANNELS = { CHANNEL_SWITCH, CHANNEL_CONTACT, CHANNEL_DATETIME, CHANNEL_DIMMER, + CHANNEL_NUMBER, CHANNEL_STRING, CHANNEL_ROLLERSHUTTER }; + + public static final String[] DATA_CHANNELS_TO_COPY_FROM_READ_TO_READWRITE = { CHANNEL_SWITCH, CHANNEL_CONTACT, + CHANNEL_DATETIME, CHANNEL_DIMMER, CHANNEL_NUMBER, CHANNEL_STRING, CHANNEL_ROLLERSHUTTER, + CHANNEL_LAST_READ_SUCCESS, CHANNEL_LAST_READ_ERROR }; + + public static final String[] DATA_CHANNELS_TO_DELEGATE_COMMAND_FROM_READWRITE_TO_WRITE = { CHANNEL_SWITCH, + CHANNEL_CONTACT, CHANNEL_DATETIME, CHANNEL_DIMMER, CHANNEL_NUMBER, CHANNEL_STRING, CHANNEL_ROLLERSHUTTER }; + + public static final String WRITE_TYPE_COIL = "coil"; + public static final String WRITE_TYPE_HOLDING = "holding"; + + public static final String READ_TYPE_COIL = "coil"; + public static final String READ_TYPE_HOLDING_REGISTER = "holding"; + public static final String READ_TYPE_DISCRETE_INPUT = "discrete"; + public static final String READ_TYPE_INPUT_REGISTER = "input"; + + public static final Map READ_FUNCTION_CODES = new HashMap<>(); + static { + READ_FUNCTION_CODES.put(READ_TYPE_COIL, ModbusReadFunctionCode.READ_COILS); + READ_FUNCTION_CODES.put(READ_TYPE_DISCRETE_INPUT, ModbusReadFunctionCode.READ_INPUT_DISCRETES); + READ_FUNCTION_CODES.put(READ_TYPE_INPUT_REGISTER, ModbusReadFunctionCode.READ_INPUT_REGISTERS); + READ_FUNCTION_CODES.put(READ_TYPE_HOLDING_REGISTER, ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusConfigurationException.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusConfigurationException.java new file mode 100644 index 000000000000..e9551082a435 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusConfigurationException.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception for binding configuration exceptions + * + * @author Ciprian Pascu - Initial contribution + * + */ +@NonNullByDefault +public class ModbusConfigurationException extends Exception { + + public ModbusConfigurationException(String errmsg) { + super(errmsg); + } + + private static final long serialVersionUID = -466597103876477780L; +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusHandlerFactory.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusHandlerFactory.java new file mode 100644 index 000000000000..bd99d9b2304f --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ModbusHandlerFactory.java @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import static org.openhab.binding.sbus.internal.ModbusBindingConstantsInternal.THING_TYPE_MODBUS_DATA; +import static org.openhab.binding.sbus.internal.ModbusBindingConstantsInternal.THING_TYPE_MODBUS_POLLER; +import static org.openhab.binding.sbus.internal.ModbusBindingConstantsInternal.THING_TYPE_MODBUS_SERIAL; +import static org.openhab.binding.sbus.internal.ModbusBindingConstantsInternal.THING_TYPE_MODBUS_UDP; + +import java.util.HashSet; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.sbus.handler.ModbusPollerThingHandler; +import org.openhab.binding.sbus.internal.handler.ModbusDataThingHandler; +import org.openhab.binding.sbus.internal.handler.ModbusSerialThingHandler; +import org.openhab.binding.sbus.internal.handler.ModbusUdpThingHandler; +import org.openhab.core.io.transport.sbus.ModbusManager; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link ModbusHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Ciprian Pascu - Initial contribution + */ +@Component(service = ThingHandlerFactory.class, configurationPid = "binding.modbus") +@NonNullByDefault +public class ModbusHandlerFactory extends BaseThingHandlerFactory { + + private final Logger logger = LoggerFactory.getLogger(ModbusHandlerFactory.class); + + private @NonNullByDefault({}) ModbusManager manager; + + private static final Set SUPPORTED_THING_TYPES_UIDS = new HashSet<>(); + static { + SUPPORTED_THING_TYPES_UIDS.add(THING_TYPE_MODBUS_UDP); + SUPPORTED_THING_TYPES_UIDS.add(THING_TYPE_MODBUS_SERIAL); + SUPPORTED_THING_TYPES_UIDS.add(THING_TYPE_MODBUS_POLLER); + SUPPORTED_THING_TYPES_UIDS.add(THING_TYPE_MODBUS_DATA); + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + if (thingTypeUID.equals(THING_TYPE_MODBUS_UDP)) { + logger.debug("createHandler Modbus udp"); + return new ModbusUdpThingHandler((Bridge) thing, manager); + } else if (thingTypeUID.equals(THING_TYPE_MODBUS_SERIAL)) { + logger.debug("createHandler Modbus serial"); + return new ModbusSerialThingHandler((Bridge) thing, manager); + } else if (thingTypeUID.equals(THING_TYPE_MODBUS_POLLER)) { + logger.debug("createHandler Modbus poller"); + return new ModbusPollerThingHandler((Bridge) thing); + } else if (thingTypeUID.equals(THING_TYPE_MODBUS_DATA)) { + logger.debug("createHandler data"); + return new ModbusDataThingHandler(thing); + } + logger.error("createHandler for unknown thing type uid {}. Thing label was: {}", thing.getThingTypeUID(), + thing.getLabel()); + + return null; + } + + @Reference + public void setModbusManager(ModbusManager manager) { + logger.debug("Setting manager: {}", manager); + this.manager = manager; + } + + public void unsetModbusManager(ModbusManager manager) { + this.manager = null; + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/SingleValueTransformation.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/SingleValueTransformation.java new file mode 100644 index 000000000000..da8dcb2675fc --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/SingleValueTransformation.java @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +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.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.transform.TransformationException; +import org.openhab.core.transform.TransformationHelper; +import org.openhab.core.transform.TransformationService; +import org.openhab.core.types.Command; +import org.openhab.core.types.TypeParser; +import org.osgi.framework.BundleContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class describing transformation of a command or state. + * + * Inspired from other openHAB binding "Transformation" classes. + * + * @author Ciprian Pascu - Initial contribution + * + */ +@NonNullByDefault +public class SingleValueTransformation implements ValueTransformation { + + public static final String TRANSFORM_DEFAULT = "default"; + public static final ValueTransformation IDENTITY_TRANSFORMATION = new SingleValueTransformation(TRANSFORM_DEFAULT, + null, null); + + /** RegEx to extract and parse a function String '(.*?)\((.*)\)' */ + private static final Pattern EXTRACT_FUNCTION_PATTERN_OLD = Pattern.compile("(?.*?)\\((?.*)\\)"); + private static final Pattern EXTRACT_FUNCTION_PATTERN_NEW = Pattern.compile("(?.*?):(?.*)"); + + /** + * Ordered list of types that are tried out first when trying to parse transformed command + */ + private static final List> DEFAULT_TYPES = new ArrayList<>(); + static { + DEFAULT_TYPES.add(DecimalType.class); + DEFAULT_TYPES.add(OpenClosedType.class); + DEFAULT_TYPES.add(OnOffType.class); + } + + private final Logger logger = LoggerFactory.getLogger(SingleValueTransformation.class); + + private final @Nullable String transformation; + final @Nullable String transformationServiceName; + final @Nullable String transformationServiceParam; + + /** + * + * @param transformation either FUN(VAL) (standard transformation syntax), default (identity transformation + * (output equals input)) or some other value (output is a constant). Futhermore, empty string is + * considered the same way as "default". + */ + public SingleValueTransformation(@Nullable String transformation) { + this.transformation = transformation; + // + // Parse transformation configuration here on construction, but delay the + // construction of TransformationService to call-time + if (transformation == null || transformation.isEmpty() || transformation.equalsIgnoreCase(TRANSFORM_DEFAULT)) { + // no-op (identity) transformation + transformationServiceName = null; + transformationServiceParam = null; + } else { + int colonIndex = transformation.indexOf(":"); + int parenthesisOpenIndex = transformation.indexOf("("); + + final Matcher matcher; + if (parenthesisOpenIndex != -1 && (colonIndex == -1 || parenthesisOpenIndex < colonIndex)) { + matcher = EXTRACT_FUNCTION_PATTERN_OLD.matcher(transformation); + } else { + matcher = EXTRACT_FUNCTION_PATTERN_NEW.matcher(transformation); + } + if (matcher.matches()) { + matcher.reset(); + matcher.find(); + transformationServiceName = matcher.group("service"); + transformationServiceParam = matcher.group("arg"); + } else { + logger.debug( + "Given transformation configuration '{}' did not match the FUN(VAL) pattern. Transformation output will be constant '{}'", + transformation, transformation); + transformationServiceName = null; + transformationServiceParam = null; + } + } + } + + /** + * For testing, thus package visibility by design + * + * @param transformation + * @param transformationServiceName + * @param transformationServiceParam + */ + SingleValueTransformation(String transformation, @Nullable String transformationServiceName, + @Nullable String transformationServiceParam) { + this.transformation = transformation; + this.transformationServiceName = transformationServiceName; + this.transformationServiceParam = transformationServiceParam; + } + + @Override + public String transform(BundleContext context, String value) { + String transformedResponse; + String transformationServiceName = this.transformationServiceName; + String transformationServiceParam = this.transformationServiceParam; + + if (transformationServiceName != null) { + try { + if (transformationServiceParam == null) { + throw new TransformationException( + "transformation service parameter is missing! Invalid transform?"); + } + @Nullable + TransformationService transformationService = TransformationHelper.getTransformationService(context, + transformationServiceName); + if (transformationService != null) { + transformedResponse = transformationService.transform(transformationServiceParam, value); + } else { + transformedResponse = value; + logger.warn("couldn't transform response because transformationService of type '{}' is unavailable", + transformationServiceName); + } + } catch (TransformationException te) { + logger.error("transformation throws exception [transformation={}, response={}]", transformation, value, + te); + + // in case of an error we return the response without any + // transformation + transformedResponse = value; + } + } else if (isIdentityTransform()) { + // identity transformation + transformedResponse = value; + } else { + // pass value as is + transformedResponse = this.transformation; + } + + return transformedResponse == null ? "" : transformedResponse; + } + + @Override + public boolean isIdentityTransform() { + return TRANSFORM_DEFAULT.equalsIgnoreCase(this.transformation); + } + + public static Optional tryConvertToCommand(String transformed) { + return Optional.ofNullable(TypeParser.parseCommand(DEFAULT_TYPES, transformed)); + } + + @Override + public String toString() { + return "SingleValueTransformation [transformation=" + transformation + ", transformationServiceName=" + + transformationServiceName + ", transformationServiceParam=" + transformationServiceParam + "]"; + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ValueTransformation.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ValueTransformation.java new file mode 100644 index 000000000000..cef9c9cda103 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/ValueTransformation.java @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.types.State; +import org.openhab.core.types.TypeParser; +import org.osgi.framework.BundleContext; + +/** + * Interface for Transformation + * + * @author Ciprian Pascu - Initial contribution + * + */ +@NonNullByDefault +public interface ValueTransformation { + + String transform(BundleContext context, String value); + + boolean isIdentityTransform(); + + /** + * Transform state to another state using this transformation + * + * @param context + * @param types types to used to parse the transformation result + * @param state + * @return Transformed command, or null if no transformation was possible + */ + default @Nullable State transformState(BundleContext context, List> types, State state) { + // Note that even identity transformations go through the State -> String -> State steps. This does add some + // overhead but takes care of DecimalType -> PercentType conversions, for example. + final String stateAsString = state.toString(); + final String transformed = transform(context, stateAsString); + return TypeParser.parseState(types, transformed); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusDataConfiguration.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusDataConfiguration.java new file mode 100644 index 000000000000..c36fc6647fab --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusDataConfiguration.java @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Configuration for data thing + * + * @author Ciprian Pascu - Initial contribution + * + */ +@NonNullByDefault +public class ModbusDataConfiguration { + + private @Nullable String readStart; + private @Nullable String readTransform; + private @Nullable String readValueType; + private @Nullable String writeStart; + private @Nullable String writeType; + private @Nullable String writeTransform; + private @Nullable String writeValueType; + private boolean writeMultipleEvenWithSingleRegisterOrCoil; + private int writeMaxTries = 3; // backwards compatibility and tests + private long updateUnchangedValuesEveryMillis = 1000L; + + public @Nullable String getReadStart() { + return readStart; + } + + public void setReadStart(String readStart) { + this.readStart = readStart; + } + + public @Nullable String getReadTransform() { + return readTransform; + } + + public void setReadTransform(String readTransform) { + this.readTransform = readTransform; + } + + public @Nullable String getReadValueType() { + return readValueType; + } + + public void setReadValueType(String readValueType) { + this.readValueType = readValueType; + } + + public @Nullable String getWriteStart() { + return writeStart; + } + + public void setWriteStart(String writeStart) { + this.writeStart = writeStart; + } + + public @Nullable String getWriteType() { + return writeType; + } + + public void setWriteType(String writeType) { + this.writeType = writeType; + } + + public @Nullable String getWriteTransform() { + return writeTransform; + } + + public void setWriteTransform(String writeTransform) { + this.writeTransform = writeTransform; + } + + public @Nullable String getWriteValueType() { + return writeValueType; + } + + public void setWriteValueType(String writeValueType) { + this.writeValueType = writeValueType; + } + + public boolean isWriteMultipleEvenWithSingleRegisterOrCoil() { + return writeMultipleEvenWithSingleRegisterOrCoil; + } + + public void setWriteMultipleEvenWithSingleRegisterOrCoil(boolean writeMultipleEvenWithSingleRegisterOrCoil) { + this.writeMultipleEvenWithSingleRegisterOrCoil = writeMultipleEvenWithSingleRegisterOrCoil; + } + + public int getWriteMaxTries() { + return writeMaxTries; + } + + public void setWriteMaxTries(int writeMaxTries) { + this.writeMaxTries = writeMaxTries; + } + + public long getUpdateUnchangedValuesEveryMillis() { + return updateUnchangedValuesEveryMillis; + } + + public void setUpdateUnchangedValuesEveryMillis(long updateUnchangedValuesEveryMillis) { + this.updateUnchangedValuesEveryMillis = updateUnchangedValuesEveryMillis; + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusPollerConfiguration.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusPollerConfiguration.java new file mode 100644 index 000000000000..8f4e19ca6944 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusPollerConfiguration.java @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Configuration for poller thing + * + * @author Ciprian Pascu - Initial contribution + * + */ +@NonNullByDefault +public class ModbusPollerConfiguration { + private long refresh; + private int start; + private int length; + private @Nullable String type; + private int maxTries = 3;// backwards compatibility and tests + private long cacheMillis = 50L; + + /** + * Gets refresh period in milliseconds + */ + public long getRefresh() { + return refresh; + } + + /** + * Sets refresh period in milliseconds + */ + + public void setRefresh(long refresh) { + this.refresh = refresh; + } + + /** + * Get address of the first register, coil, or discrete input to poll. Input as zero-based index number. + * + */ + public int getStart() { + return start; + } + + /** + * Sets address of the first register, coil, or discrete input to poll. Input as zero-based index number. + * + */ + public void setStart(int start) { + this.start = start; + } + + /** + * Gets number of registers, coils or discrete inputs to read. + */ + public int getLength() { + return length; + } + + /** + * Sets number of registers, coils or discrete inputs to read. + */ + public void setLength(int length) { + this.length = length; + } + + /** + * Gets type of modbus items to poll + * + */ + public @Nullable String getType() { + return type; + } + + /** + * Sets type of modbus items to poll + * + */ + public void setType(String type) { + this.type = type; + } + + public int getMaxTries() { + return maxTries; + } + + public void setMaxTries(int maxTries) { + this.maxTries = maxTries; + } + + /** + * Gets time to cache data. + * + * This is used for reusing cached data with explicit refresh calls. + */ + public long getCacheMillis() { + return cacheMillis; + } + + /** + * Sets time to cache data, in milliseconds + * + */ + public void setCacheMillis(long cacheMillis) { + this.cacheMillis = cacheMillis; + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusSerialConfiguration.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusSerialConfiguration.java new file mode 100644 index 000000000000..15e6e63ce70b --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusSerialConfiguration.java @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Configuration for serial thing + * + * @author Ciprian Pascu - Initial contribution + * + */ +@NonNullByDefault +public class ModbusSerialConfiguration { + private @Nullable String port; + private int id = 1; + private int subnetId = 1; + private int baud; + private @Nullable String stopBits; + private @Nullable String parity; + private int dataBits; + private String encoding = "rtu"; + private boolean echo; + private int receiveTimeoutMillis = 1500; + private String flowControlIn = "none"; + private String flowControlOut = "none"; + private int timeBetweenTransactionsMillis = 35; + private int connectMaxTries = 1; + private int afterConnectionDelayMillis; + private int connectTimeoutMillis = 10_000; + private boolean enableDiscovery; + + public @Nullable String getPort() { + return port; + } + + public void setPort(String port) { + this.port = port; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public int getSubnetId() { + return subnetId; + } + + public void setSubnetId(int subnetId) { + this.subnetId = subnetId; + } + + public int getBaud() { + return baud; + } + + public void setBaud(int baud) { + this.baud = baud; + } + + public @Nullable String getStopBits() { + return stopBits; + } + + public void setStopBits(String stopBits) { + this.stopBits = stopBits; + } + + public @Nullable String getParity() { + return parity; + } + + public void setParity(String parity) { + this.parity = parity; + } + + public int getDataBits() { + return dataBits; + } + + public void setDataBits(int dataBits) { + this.dataBits = dataBits; + } + + public @Nullable String getEncoding() { + return encoding; + } + + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + public boolean isEcho() { + return echo; + } + + public void setEcho(boolean echo) { + this.echo = echo; + } + + public int getReceiveTimeoutMillis() { + return receiveTimeoutMillis; + } + + public void setReceiveTimeoutMillis(int receiveTimeoutMillis) { + this.receiveTimeoutMillis = receiveTimeoutMillis; + } + + public @Nullable String getFlowControlIn() { + return flowControlIn; + } + + public void setFlowControlIn(String flowControlIn) { + this.flowControlIn = flowControlIn; + } + + public @Nullable String getFlowControlOut() { + return flowControlOut; + } + + public void setFlowControlOut(String flowControlOut) { + this.flowControlOut = flowControlOut; + } + + public int getTimeBetweenTransactionsMillis() { + return timeBetweenTransactionsMillis; + } + + public void setTimeBetweenTransactionsMillis(int timeBetweenTransactionsMillis) { + this.timeBetweenTransactionsMillis = timeBetweenTransactionsMillis; + } + + public int getConnectMaxTries() { + return connectMaxTries; + } + + public void setConnectMaxTries(int connectMaxTries) { + this.connectMaxTries = connectMaxTries; + } + + public int getAfterConnectionDelayMillis() { + return afterConnectionDelayMillis; + } + + public void setAfterConnectionDelayMillis(int afterConnectionDelayMillis) { + this.afterConnectionDelayMillis = afterConnectionDelayMillis; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public void setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + } + + public boolean isDiscoveryEnabled() { + return enableDiscovery; + } + + public void setDiscoveryEnabled(boolean enableDiscovery) { + this.enableDiscovery = enableDiscovery; + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusUdpConfiguration.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusUdpConfiguration.java new file mode 100644 index 000000000000..4e1e11403fb5 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/config/ModbusUdpConfiguration.java @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Configuration for udp thing + * + * @author Ciprian Pascu - Initial contribution + * + */ +@NonNullByDefault +public class ModbusUdpConfiguration { + private @Nullable String host; + private int port; + private int id = 1; + private int subnetId = 1; + private int timeBetweenTransactionsMillis = 60; + private int timeBetweenReconnectMillis; + private int connectMaxTries = 1; + private int reconnectAfterMillis; + private int afterConnectionDelayMillis; + private int connectTimeoutMillis = 10_000; + private boolean enableDiscovery; + private boolean rtuEncoded; + + public boolean getRtuEncoded() { + return rtuEncoded; + } + + public @Nullable String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public int getSubnetId() { + return subnetId; + } + + public void setSubnetId(int subnetId) { + this.subnetId = subnetId; + } + + public int getTimeBetweenTransactionsMillis() { + return timeBetweenTransactionsMillis; + } + + public void setTimeBetweenTransactionsMillis(int timeBetweenTransactionsMillis) { + this.timeBetweenTransactionsMillis = timeBetweenTransactionsMillis; + } + + public int getTimeBetweenReconnectMillis() { + return timeBetweenReconnectMillis; + } + + public void setTimeBetweenReconnectMillis(int timeBetweenReconnectMillis) { + this.timeBetweenReconnectMillis = timeBetweenReconnectMillis; + } + + public int getConnectMaxTries() { + return connectMaxTries; + } + + public void setConnectMaxTries(int connectMaxTries) { + this.connectMaxTries = connectMaxTries; + } + + public int getReconnectAfterMillis() { + return reconnectAfterMillis; + } + + public void setReconnectAfterMillis(int reconnectAfterMillis) { + this.reconnectAfterMillis = reconnectAfterMillis; + } + + public int getAfterConnectionDelayMillis() { + return afterConnectionDelayMillis; + } + + public void setAfterConnectionDelayMillis(int afterConnectionDelayMillis) { + this.afterConnectionDelayMillis = afterConnectionDelayMillis; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public void setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + } + + public boolean isDiscoveryEnabled() { + return enableDiscovery; + } + + public void setDiscoveryEnabled(boolean enableDiscovery) { + this.enableDiscovery = enableDiscovery; + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/AbstractModbusEndpointThingHandler.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/AbstractModbusEndpointThingHandler.java new file mode 100644 index 000000000000..e45bc32b9456 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/AbstractModbusEndpointThingHandler.java @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.sbus.handler.EndpointNotInitializedException; +import org.openhab.binding.sbus.handler.ModbusEndpointThingHandler; +import org.openhab.binding.sbus.internal.ModbusConfigurationException; +import org.openhab.core.io.transport.sbus.ModbusCommunicationInterface; +import org.openhab.core.io.transport.sbus.ModbusManager; +import org.openhab.core.io.transport.sbus.endpoint.EndpointPoolConfiguration; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class for Modbus Slave endpoint thing handlers + * + * @author Ciprian Pascu - Initial contribution + * + * @param endpoint class + * @param config class + */ +@NonNullByDefault +public abstract class AbstractModbusEndpointThingHandler extends BaseBridgeHandler + implements ModbusEndpointThingHandler { + + protected volatile @Nullable C config; + protected volatile @Nullable E endpoint; + protected ModbusManager modbusManager; + protected volatile @NonNullByDefault({}) EndpointPoolConfiguration poolConfiguration; + private final Logger logger = LoggerFactory.getLogger(AbstractModbusEndpointThingHandler.class); + private @NonNullByDefault({}) ModbusCommunicationInterface comms; + + public AbstractModbusEndpointThingHandler(Bridge bridge, ModbusManager modbusManager) { + super(bridge); + this.modbusManager = modbusManager; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + @Override + public void initialize() { + synchronized (this) { + logger.trace("Initializing {} from status {}", this.getThing().getUID(), this.getThing().getStatus()); + if (this.getThing().getStatus().equals(ThingStatus.ONLINE)) { + // If the bridge was online then first change it to offline. + // this ensures that children will be notified about the change + updateStatus(ThingStatus.OFFLINE); + } + try { + configure(); + @Nullable + E endpoint = this.endpoint; + if (endpoint == null) { + throw new IllegalStateException("endpoint null after configuration!"); + } + try { + comms = modbusManager.newModbusCommunicationInterface(endpoint, poolConfiguration); + updateStatus(ThingStatus.ONLINE); + } catch (IllegalArgumentException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + formatConflictingParameterError()); + } + } catch (ModbusConfigurationException e) { + logger.debug("Exception during initialization", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format( + "Exception during initialization: %s (%s)", e.getMessage(), e.getClass().getSimpleName())); + } finally { + logger.trace("initialize() of thing {} '{}' finished", thing.getUID(), thing.getLabel()); + } + } + } + + @Override + public void dispose() { + try { + ModbusCommunicationInterface localComms = comms; + if (localComms != null) { + localComms.close(); + } + } catch (Exception e) { + logger.warn("Error closing modbus communication interface", e); + } finally { + comms = null; + } + } + + @Override + public @Nullable ModbusCommunicationInterface getCommunicationInterface() { + return comms; + } + + @Nullable + public E getEndpoint() { + return endpoint; + } + + @Override + public abstract int getSlaveId() throws EndpointNotInitializedException; + + /** + * Must be overriden by subclasses to initialize config, endpoint, and poolConfiguration + */ + protected abstract void configure() throws ModbusConfigurationException; + + /** + * Format error message in case some other endpoint has been configured with different + * {@link EndpointPoolConfiguration} + */ + protected abstract String formatConflictingParameterError(); +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusDataThingHandler.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusDataThingHandler.java new file mode 100644 index 000000000000..ea4de048799a --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusDataThingHandler.java @@ -0,0 +1,1105 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.handler; + +import static org.openhab.binding.sbus.internal.ModbusBindingConstantsInternal.WRITE_TYPE_COIL; +import static org.openhab.binding.sbus.internal.ModbusBindingConstantsInternal.WRITE_TYPE_HOLDING; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +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; +import org.openhab.binding.sbus.handler.EndpointNotInitializedException; +import org.openhab.binding.sbus.handler.ModbusEndpointThingHandler; +import org.openhab.binding.sbus.handler.ModbusPollerThingHandler; +import org.openhab.binding.sbus.internal.CascadedValueTransformationImpl; +import org.openhab.binding.sbus.internal.ModbusBindingConstantsInternal; +import org.openhab.binding.sbus.internal.ModbusConfigurationException; +import org.openhab.binding.sbus.internal.SingleValueTransformation; +import org.openhab.binding.sbus.internal.ValueTransformation; +import org.openhab.binding.sbus.internal.config.ModbusDataConfiguration; +import org.openhab.core.io.transport.sbus.AsyncModbusFailure; +import org.openhab.core.io.transport.sbus.AsyncModbusReadResult; +import org.openhab.core.io.transport.sbus.AsyncModbusWriteResult; +import org.openhab.core.io.transport.sbus.BitArray; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; +import org.openhab.core.io.transport.sbus.ModbusCommunicationInterface; +import org.openhab.core.io.transport.sbus.ModbusConstants; +import org.openhab.core.io.transport.sbus.ModbusConstants.ValueType; +import org.openhab.core.io.transport.sbus.ModbusReadFunctionCode; +import org.openhab.core.io.transport.sbus.ModbusReadRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.io.transport.sbus.ModbusWriteCoilRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteRegisterRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprint; +import org.openhab.core.io.transport.sbus.exception.ModbusConnectionException; +import org.openhab.core.io.transport.sbus.exception.ModbusTransportException; +import org.openhab.core.io.transport.sbus.json.WriteRequestJsonUtilities; +import org.openhab.core.library.items.ContactItem; +import org.openhab.core.library.items.DateTimeItem; +import org.openhab.core.library.items.DimmerItem; +import org.openhab.core.library.items.NumberItem; +import org.openhab.core.library.items.RollershutterItem; +import org.openhab.core.library.items.StringItem; +import org.openhab.core.library.items.SwitchItem; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.BridgeHandler; +import org.openhab.core.thing.binding.ThingHandlerCallback; +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; +import org.slf4j.LoggerFactory; + +/** + * The {@link ModbusDataThingHandler} is responsible for interpreting polled modbus data, as well as handling openHAB + * commands + * + * Thing can be re-initialized by the bridge in case of configuration changes (bridgeStatusChanged). + * Because of this, initialize, dispose and all callback methods (onRegisters, onBits, onError, onWriteResponse) are + * synchronized + * to avoid data race conditions. + * + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class ModbusDataThingHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(ModbusDataThingHandler.class); + + private final BundleContext bundleContext; + + private static final Duration MIN_STATUS_INFO_UPDATE_INTERVAL = Duration.ofSeconds(1); + private static final Map>> CHANNEL_ID_TO_ACCEPTED_TYPES = new HashMap<>(); + + static { + CHANNEL_ID_TO_ACCEPTED_TYPES.put(ModbusBindingConstantsInternal.CHANNEL_SWITCH, + new SwitchItem("").getAcceptedDataTypes()); + CHANNEL_ID_TO_ACCEPTED_TYPES.put(ModbusBindingConstantsInternal.CHANNEL_CONTACT, + new ContactItem("").getAcceptedDataTypes()); + CHANNEL_ID_TO_ACCEPTED_TYPES.put(ModbusBindingConstantsInternal.CHANNEL_DATETIME, + new DateTimeItem("").getAcceptedDataTypes()); + CHANNEL_ID_TO_ACCEPTED_TYPES.put(ModbusBindingConstantsInternal.CHANNEL_DIMMER, + new DimmerItem("").getAcceptedDataTypes()); + CHANNEL_ID_TO_ACCEPTED_TYPES.put(ModbusBindingConstantsInternal.CHANNEL_NUMBER, + new NumberItem("").getAcceptedDataTypes()); + CHANNEL_ID_TO_ACCEPTED_TYPES.put(ModbusBindingConstantsInternal.CHANNEL_STRING, + new StringItem("").getAcceptedDataTypes()); + CHANNEL_ID_TO_ACCEPTED_TYPES.put(ModbusBindingConstantsInternal.CHANNEL_ROLLERSHUTTER, + new RollershutterItem("").getAcceptedDataTypes()); + } + // data channels + 4 for read/write last error/success + private static final int NUMER_OF_CHANNELS_HINT = CHANNEL_ID_TO_ACCEPTED_TYPES.size() + 4; + + // + // If you change the below default/initial values, please update the corresponding values in dispose() + // + private volatile @Nullable ModbusDataConfiguration config; + private volatile @Nullable ValueType readValueType; + private volatile @Nullable ValueType writeValueType; + private volatile @Nullable CascadedValueTransformationImpl readTransformation; + private volatile @Nullable CascadedValueTransformationImpl writeTransformation; + private volatile Optional readIndex = Optional.empty(); + private volatile Optional readSubIndex = Optional.empty(); + private volatile Optional writeStart = Optional.empty(); + private volatile Optional writeSubIndex = Optional.empty(); + private volatile int pollStart; + private volatile int subnetId; + private volatile int slaveId; + private volatile @Nullable ModbusReadFunctionCode functionCode; + private volatile @Nullable ModbusReadRequestBlueprint readRequest; + private volatile long updateUnchangedValuesEveryMillis; + private volatile @NonNullByDefault({}) ModbusCommunicationInterface comms; + private volatile boolean isWriteEnabled; + private volatile boolean isReadEnabled; + private volatile boolean writeParametersHavingTransformationOnly; + private volatile boolean childOfEndpoint; + private volatile @Nullable ModbusPollerThingHandler pollerHandler; + private volatile Map channelCache = new HashMap<>(); + private volatile Map channelLastUpdated = new HashMap<>(NUMER_OF_CHANNELS_HINT); + private volatile Map channelLastState = new HashMap<>(NUMER_OF_CHANNELS_HINT); + + private volatile LocalDateTime lastStatusInfoUpdate = LocalDateTime.MIN; + private volatile ThingStatusInfo statusInfo = new ThingStatusInfo(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, + null); + + public ModbusDataThingHandler(Thing thing) { + super(thing); + this.bundleContext = FrameworkUtil.getBundle(ModbusDataThingHandler.class).getBundleContext(); + } + + @Override + public synchronized void handleCommand(ChannelUID channelUID, Command command) { + logger.trace("Thing {} '{}' received command '{}' to channel '{}'", getThing().getUID(), getThing().getLabel(), + command, channelUID); + ModbusDataConfiguration config = this.config; + if (config == null) { + return; + } + + if (RefreshType.REFRESH == command) { + ModbusPollerThingHandler poller = pollerHandler; + if (poller == null) { + // Data thing must be child of endpoint, and thus write-only. + // There is no data to update + return; + } + // We *schedule* the REFRESH to avoid dead-lock situation where poller is trying update this + // data thing with cached data (resulting in deadlock in two synchronized methods: this (handleCommand) and + // onRegisters. + scheduler.schedule(() -> poller.refresh(), 0, TimeUnit.SECONDS); + return; + } else if (hasConfigurationError()) { + logger.debug( + "Thing {} '{}' command '{}' to channel '{}': Thing has configuration error so ignoring the command", + getThing().getUID(), getThing().getLabel(), command, channelUID); + return; + } else if (!isWriteEnabled) { + logger.debug( + "Thing {} '{}' command '{}' to channel '{}': no writing configured -> aborting processing command", + getThing().getUID(), getThing().getLabel(), command, channelUID); + return; + } + + Optional transformedCommand = transformCommandAndProcessJSON(channelUID, command); + if (transformedCommand == null) { + // We have, JSON as transform output (which has been processed) or some error. See + // transformCommandAndProcessJSON javadoc + return; + } + + // We did not have JSON output from the transformation, so writeStart is absolute required. Abort if it is + // missing + 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); + return; + } + + if (transformedCommand.isEmpty()) { + // transformation failed, return + logger.warn("Cannot process command {} (of type {}) with channel {} since transformation was unsuccessful", + command, command.getClass().getSimpleName(), channelUID); + return; + } + + ModbusWriteRequestBlueprint request = requestFromCommand(channelUID, command, config, transformedCommand.get(), + writeStart.get()); + if (request == null) { + return; + } + + logger.trace("Submitting write task {} to endpoint {}", request, comms.getEndpoint()); + comms.submitOneTimeWrite(request, this::onWriteResponse, this::handleWriteError); + } + + /** + * Transform received command using the transformation. + * + * In case of JSON as transformation output, the output processed using {@link processJsonTransform}. + * + * @param channelUID channel UID corresponding to received command + * @param command command to be transformed + * @return transformed command. Null is returned with JSON transformation outputs and configuration errors + * + * @see processJsonTransform + */ + private @Nullable Optional transformCommandAndProcessJSON(ChannelUID channelUID, Command command) { + String transformOutput; + Optional transformedCommand; + ValueTransformation writeTransformation = this.writeTransformation; + if (writeTransformation == null || writeTransformation.isIdentityTransform()) { + transformedCommand = Optional.of(command); + } else { + transformOutput = writeTransformation.transform(bundleContext, command.toString()); + if (transformOutput.contains("[")) { + processJsonTransform(command, transformOutput); + return null; + } else if (writeParametersHavingTransformationOnly) { + updateStatusIfChanged(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format( + "Seems to have writeTransformation but no other write parameters. Since the transformation did not return a JSON for command '%s' (channel %s), this is a configuration error", + command, channelUID)); + return null; + } else { + transformedCommand = SingleValueTransformation.tryConvertToCommand(transformOutput); + logger.trace("Converted transform output '{}' to command '{}' (type {})", transformOutput, + transformedCommand.map(c -> c.toString()).orElse(""), + transformedCommand.map(c -> c.getClass().getName()).orElse("")); + } + } + return transformedCommand; + } + + private @Nullable ModbusWriteRequestBlueprint requestFromCommand(ChannelUID channelUID, Command origCommand, + ModbusDataConfiguration config, Command transformedCommand, Integer writeStart) { + 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)) { + Optional commandAsBoolean = ModbusBitUtilities.translateCommand2Boolean(transformedCommand); + if (commandAsBoolean.isEmpty()) { + logger.warn( + "Cannot process command {} with channel {} since command is not OnOffType, OpenClosedType or Decimal trying to write to coil. Do not know how to convert to 0/1. Transformed command was '{}'", + origCommand, channelUID, transformedCommand); + return null; + } + boolean data = commandAsBoolean.get(); + request = new ModbusWriteCoilRequestBlueprint(subnetId, slaveId, writeStart, data, writeMultiple, + config.getWriteMaxTries()); + } else if (writeType.equals(WRITE_TYPE_HOLDING)) { + ValueType writeValueType = this.writeValueType; + if (writeValueType == null) { + // Should not happen in practice, since we are not in configuration error (checked above) + // This will make compiler happy anyways with the null checks + logger.warn("Received command but write value type not set! Ignoring command"); + return null; + } + 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(subnetId, slaveId, writeStart, data, writeMultiple, + config.getWriteMaxTries()); + } else { + // Should not happen! This method is not called in case configuration errors and writeType is validated + // already in initialization (validateAndParseWriteParameters). + // We keep this here for future-proofing the code (new writeType values) + throw new IllegalStateException(String.format( + "writeType does not equal %s or %s and thus configuration is invalid. Should not end up this far with configuration error.", + WRITE_TYPE_COIL, WRITE_TYPE_HOLDING)); + } + 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) { + return; + } + Collection requests; + try { + requests = WriteRequestJsonUtilities.fromJson(subnetId, slaveId, transformOutput); + } catch (IllegalArgumentException | IllegalStateException e) { + logger.warn( + "Thing {} '{}' could handle transformation result '{}'. Original command {}. Error details follow", + getThing().getUID(), getThing().getLabel(), transformOutput, command, e); + return; + } + + requests.stream().forEach(request -> { + logger.trace("Submitting write request: {} to endpoint {} (based from transformation {})", request, + localComms.getEndpoint(), transformOutput); + localComms.submitOneTimeWrite(request, this::onWriteResponse, this::handleWriteError); + }); + } + + @Override + public synchronized void initialize() { + // Initialize the thing. If done set status to ONLINE to indicate proper working. + // Long running initialization should be done asynchronously in background. + try { + logger.trace("initialize() of thing {} '{}' starting", thing.getUID(), thing.getLabel()); + ModbusDataConfiguration localConfig = config = getConfigAs(ModbusDataConfiguration.class); + updateUnchangedValuesEveryMillis = localConfig.getUpdateUnchangedValuesEveryMillis(); + Bridge bridge = getBridge(); + if (bridge == null || !bridge.getStatus().equals(ThingStatus.ONLINE)) { + logger.debug("Thing {} '{}' has no bridge or it is not online", getThing().getUID(), + getThing().getLabel()); + updateStatusIfChanged(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "No online bridge"); + return; + } + BridgeHandler bridgeHandler = bridge.getHandler(); + if (bridgeHandler == null) { + logger.warn("Bridge {} '{}' has no handler.", bridge.getUID(), bridge.getLabel()); + String errmsg = String.format("Bridge %s '%s' configuration incomplete or with errors", bridge.getUID(), + bridge.getLabel()); + throw new ModbusConfigurationException(errmsg); + } + if (bridgeHandler instanceof ModbusEndpointThingHandler endpointHandler) { + subnetId = endpointHandler.getSubnetId(); + slaveId = endpointHandler.getSlaveId(); + comms = endpointHandler.getCommunicationInterface(); + childOfEndpoint = true; + functionCode = null; + readRequest = null; + } else if (bridgeHandler instanceof ModbusPollerThingHandler localPollerHandler) { + pollerHandler = localPollerHandler; + ModbusReadRequestBlueprint localReadRequest = localPollerHandler.getRequest(); + if (localReadRequest == null) { + logger.debug( + "Poller {} '{}' has no read request -- configuration is changing or bridge having invalid configuration?", + bridge.getUID(), bridge.getLabel()); + updateStatusIfChanged(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, + String.format("Poller %s '%s' has no poll task", bridge.getUID(), bridge.getLabel())); + return; + } + readRequest = localReadRequest; + subnetId = localReadRequest.getSubnetID(); + slaveId = localReadRequest.getUnitID(); + functionCode = localReadRequest.getFunctionCode(); + comms = localPollerHandler.getCommunicationInterface(); + pollStart = localReadRequest.getReference(); + childOfEndpoint = false; + } else { + String errmsg = String.format("Thing %s is connected to an unsupported type of bridge.", + getThing().getUID()); + throw new ModbusConfigurationException(errmsg); + } + + validateAndParseReadParameters(localConfig); + validateAndParseWriteParameters(localConfig); + validateMustReadOrWrite(); + + updateStatusIfChanged(ThingStatus.ONLINE); + } catch (ModbusConfigurationException | EndpointNotInitializedException e) { + logger.debug("Thing {} '{}' initialization error: {}", getThing().getUID(), getThing().getLabel(), + e.getMessage()); + updateStatusIfChanged(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + } finally { + logger.trace("initialize() of thing {} '{}' finished", thing.getUID(), thing.getLabel()); + } + } + + @Override + public synchronized void dispose() { + config = null; + readValueType = null; + writeValueType = null; + readTransformation = null; + writeTransformation = null; + readIndex = Optional.empty(); + readSubIndex = Optional.empty(); + writeStart = Optional.empty(); + writeSubIndex = Optional.empty(); + pollStart = 0; + subnetId = 1; + slaveId = 0; + comms = null; + functionCode = null; + readRequest = null; + isWriteEnabled = false; + isReadEnabled = false; + writeParametersHavingTransformationOnly = false; + childOfEndpoint = false; + pollerHandler = null; + channelCache = new HashMap<>(); + lastStatusInfoUpdate = LocalDateTime.MIN; + statusInfo = new ThingStatusInfo(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, null); + channelLastUpdated = new HashMap<>(NUMER_OF_CHANNELS_HINT); + channelLastState = new HashMap<>(NUMER_OF_CHANNELS_HINT); + } + + @Override + public synchronized void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + logger.debug("bridgeStatusChanged for {}. Reseting handler", this.getThing().getUID()); + this.dispose(); + this.initialize(); + } + + private boolean hasConfigurationError() { + ThingStatusInfo statusInfo = getThing().getStatusInfo(); + return statusInfo.getStatus() == ThingStatus.OFFLINE + && statusInfo.getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR; + } + + private void validateMustReadOrWrite() throws ModbusConfigurationException { + if (!isReadEnabled && !isWriteEnabled) { + throw new ModbusConfigurationException("Should try to read or write data!"); + } + } + + private void validateAndParseReadParameters(ModbusDataConfiguration config) throws ModbusConfigurationException { + ModbusReadFunctionCode functionCode = this.functionCode; + boolean readingDiscreteOrCoil = functionCode == ModbusReadFunctionCode.READ_COILS + || functionCode == ModbusReadFunctionCode.READ_INPUT_DISCRETES; + boolean readStartMissing = config.getReadStart() == null || config.getReadStart().isBlank(); + boolean readValueTypeMissing = config.getReadValueType() == null || config.getReadValueType().isBlank(); + + if (childOfEndpoint && readRequest == null) { + if (!readStartMissing || !readValueTypeMissing) { + String errmsg = String.format( + "Thing %s was configured for reading (readStart and/or readValueType specified) but the parent is not a polling bridge. Consider using a bridge of type 'Regular Poll'.", + getThing().getUID()); + throw new ModbusConfigurationException(errmsg); + } + } + + // we assume readValueType=bit by default if it is missing + boolean allMissingOrAllPresent = (readStartMissing && readValueTypeMissing) + || (!readStartMissing && (!readValueTypeMissing || readingDiscreteOrCoil)); + if (!allMissingOrAllPresent) { + String errmsg = String.format( + "Thing %s readStart=%s, and readValueType=%s should be all present or all missing!", + getThing().getUID(), config.getReadStart(), config.getReadValueType()); + throw new ModbusConfigurationException(errmsg); + } else if (!readStartMissing) { + // all read values are present + isReadEnabled = true; + if (readingDiscreteOrCoil && readValueTypeMissing) { + readValueType = ModbusConstants.ValueType.BIT; + } else { + try { + readValueType = ValueType.fromConfigValue(config.getReadValueType()); + } catch (IllegalArgumentException e) { + String errmsg = String.format("Thing %s readValueType=%s is invalid!", getThing().getUID(), + config.getReadValueType()); + throw new ModbusConfigurationException(errmsg); + } + } + + if (readingDiscreteOrCoil && !ModbusConstants.ValueType.BIT.equals(readValueType)) { + String errmsg = String.format( + "Thing %s invalid readValueType: Only readValueType='%s' (or undefined) supported with coils or discrete inputs. Value type was: %s", + getThing().getUID(), ModbusConstants.ValueType.BIT, config.getReadValueType()); + throw new ModbusConfigurationException(errmsg); + } + } else { + isReadEnabled = false; + } + + if (isReadEnabled) { + String readStart = config.getReadStart(); + if (readStart == null) { + throw new ModbusConfigurationException( + String.format("Thing %s invalid readStart: %s", getThing().getUID(), config.getReadStart())); + } + String[] readParts = readStart.split("\\.", 2); + try { + readIndex = Optional.of(Integer.parseInt(readParts[0])); + if (readParts.length == 2) { + readSubIndex = Optional.of(Integer.parseInt(readParts[1])); + } else { + readSubIndex = Optional.empty(); + } + } catch (IllegalArgumentException e) { + String errmsg = String.format("Thing %s invalid readStart: %s", getThing().getUID(), + config.getReadStart()); + throw new ModbusConfigurationException(errmsg); + } + } + readTransformation = new CascadedValueTransformationImpl(config.getReadTransform()); + validateReadIndex(); + } + + private void validateAndParseWriteParameters(ModbusDataConfiguration config) throws ModbusConfigurationException { + boolean writeTypeMissing = config.getWriteType() == null || config.getWriteType().isBlank(); + boolean writeStartMissing = config.getWriteStart() == null || config.getWriteStart().isBlank(); + boolean writeValueTypeMissing = config.getWriteValueType() == null || config.getWriteValueType().isBlank(); + boolean writeTransformationMissing = config.getWriteTransform() == null || config.getWriteTransform().isBlank(); + writeTransformation = new CascadedValueTransformationImpl(config.getWriteTransform()); + boolean writingCoil = WRITE_TYPE_COIL.equals(config.getWriteType()); + writeParametersHavingTransformationOnly = (writeTypeMissing && writeStartMissing && writeValueTypeMissing + && !writeTransformationMissing); + boolean allMissingOrAllPresentOrOnlyNonDefaultTransform = // + // read-only thing, no write specified + (writeTypeMissing && writeStartMissing && writeValueTypeMissing) + // mandatory write parameters provided. With coils one can drop value type + || (!writeTypeMissing && !writeStartMissing && (!writeValueTypeMissing || writingCoil)) + // only transformation provided + || writeParametersHavingTransformationOnly; + if (!allMissingOrAllPresentOrOnlyNonDefaultTransform) { + String errmsg = String.format( + "writeType=%s, writeStart=%s, and writeValueType=%s should be all present, or all missing! Alternatively, you can provide just writeTransformation, and use transformation returning JSON.", + config.getWriteType(), config.getWriteStart(), config.getWriteValueType()); + throw new ModbusConfigurationException(errmsg); + } else if (!writeTypeMissing || writeParametersHavingTransformationOnly) { + isWriteEnabled = true; + // all write values are present + if (!writeParametersHavingTransformationOnly && !WRITE_TYPE_HOLDING.equals(config.getWriteType()) + && !WRITE_TYPE_COIL.equals(config.getWriteType())) { + String errmsg = String.format("Invalid writeType=%s. Expecting %s or %s!", config.getWriteType(), + WRITE_TYPE_HOLDING, WRITE_TYPE_COIL); + throw new ModbusConfigurationException(errmsg); + } + final ValueType localWriteValueType; + if (writeParametersHavingTransformationOnly) { + // Placeholder for further checks + localWriteValueType = writeValueType = ModbusConstants.ValueType.INT16; + } else if (writingCoil && writeValueTypeMissing) { + localWriteValueType = writeValueType = ModbusConstants.ValueType.BIT; + } else { + try { + localWriteValueType = writeValueType = ValueType.fromConfigValue(config.getWriteValueType()); + } catch (IllegalArgumentException e) { + String errmsg = String.format("Invalid writeValueType=%s!", config.getWriteValueType()); + throw new ModbusConfigurationException(errmsg); + } + } + + try { + if (!writeParametersHavingTransformationOnly) { + String localWriteStart = config.getWriteStart(); + if (localWriteStart == null) { + String errmsg = String.format("Thing %s invalid writeStart: %s", getThing().getUID(), + config.getWriteStart()); + throw new ModbusConfigurationException(errmsg); + } + 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 validateReadIndex() throws ModbusConfigurationException { + @Nullable + ModbusReadRequestBlueprint readRequest = this.readRequest; + ValueType readValueType = this.readValueType; + if (readIndex.isEmpty() || readRequest == null) { + return; + } + assert readValueType != null; + // bits represented by the value type, e.g. int32 -> 32 + int valueTypeBitCount = readValueType.getBits(); + int dataElementBits; + switch (readRequest.getFunctionCode()) { + case READ_INPUT_REGISTERS: + case READ_MULTIPLE_REGISTERS: + dataElementBits = 16; + break; + case READ_COILS: + case READ_INPUT_DISCRETES: + dataElementBits = 1; + break; + default: + throw new IllegalStateException(readRequest.getFunctionCode().toString()); + } + + boolean bitQuery = dataElementBits == 1; + if (bitQuery && readSubIndex.isPresent()) { + String errmsg = String.format("readStart=X.Y is not allowed to be used with coils or discrete inputs!"); + throw new ModbusConfigurationException(errmsg); + } + + if (valueTypeBitCount >= 16 && readSubIndex.isPresent()) { + String errmsg = String.format( + "readStart=X.Y notation is not allowed to be used with value types larger than 16bit! Use readStart=X instead."); + throw new ModbusConfigurationException(errmsg); + } else if (!bitQuery && valueTypeBitCount < 16 && readSubIndex.isEmpty()) { + // User has specified value type which is less than register width (16 bits). + // readStart=X.Y notation must be used to define which data to extract from the 16 bit register. + String errmsg = String + .format("readStart=X.Y must be used with value types (readValueType) less than 16bit!"); + throw new ModbusConfigurationException(errmsg); + } else if (readSubIndex.isPresent() && (readSubIndex.get() + 1) * valueTypeBitCount > 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() * dataElementBits; + int pollEndBitIndex = pollStartBitIndex + readRequest.getDataLength() * dataElementBits - 1; + + // Determine bit positions read, both start and end inclusive + int readStartBitIndex = readIndex.get() * dataElementBits + readSubIndex.orElse(0) * valueTypeBitCount; + int readEndBitIndex = readStartBitIndex + valueTypeBitCount - 1; + + if (readStartBitIndex < pollStartBitIndex || readEndBitIndex > pollEndBitIndex) { + String errmsg = String.format( + "Out-of-bounds: Poller is reading from index %d to %d (inclusive) but this thing configured to read '%s' starting from element %d. Exceeds polled data bounds.", + pollStartBitIndex / dataElementBits, pollEndBitIndex / dataElementBits, readValueType, + readIndex.get()); + throw new ModbusConfigurationException(errmsg); + } + } + + private void validateWriteIndex() throws ModbusConfigurationException { + @Nullable + ModbusReadRequestBlueprint readRequest = this.readRequest; + if (writeStart.isEmpty() || writeSubIndex.isEmpty()) { + // + // 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 -> clz.equals(OnOffType.class)); + } + + private boolean containsOpenClosed(List> acceptedDataTypes) { + return acceptedDataTypes.stream().anyMatch(clz -> clz.equals(OpenClosedType.class)); + } + + public synchronized void onReadResult(AsyncModbusReadResult result) { + result.getRegisters().ifPresent(registers -> onRegisters(result.getRequest(), registers)); + result.getBits().ifPresent(bits -> onBits(result.getRequest(), bits)); + } + + public synchronized void handleReadError(AsyncModbusFailure failure) { + onError(failure.getRequest(), failure.getCause()); + } + + public synchronized void handleWriteError(AsyncModbusFailure failure) { + onError(failure.getRequest(), failure.getCause()); + } + + private synchronized void onRegisters(ModbusReadRequestBlueprint request, ModbusRegisterArray registers) { + if (hasConfigurationError()) { + return; + } else if (!isReadEnabled) { + return; + } + ValueType readValueType = this.readValueType; + if (readValueType == null) { + return; + } + State numericState; + + // extractIndex: + // e.g. with bit, extractIndex=4 means 5th bit (from right) ("10.4" -> 5th bit of register 10, "10.4" -> 5th bit + // of register 10) + // bit of second register) + // e.g. with 8bit integer, extractIndex=3 means high byte of second register + // + // with <16 bit types, this is the index of the N'th 1-bit/8-bit item. Each register has 16/2 items, + // respectively. + // with >=16 bit types, this is index of first register + int extractIndex; + if (readValueType.getBits() >= 16) { + // Invariant, checked in initialize + assert readSubIndex.orElse(0) == 0; + extractIndex = readIndex.get() - pollStart; + } else { + int subIndex = readSubIndex.orElse(0); + int itemsPerRegister = 16 / readValueType.getBits(); + extractIndex = (readIndex.get() - pollStart) * itemsPerRegister + subIndex; + } + numericState = ModbusBitUtilities.extractStateFromRegisters(registers, extractIndex, readValueType) + .map(state -> (State) state).orElse(UnDefType.UNDEF); + boolean boolValue = !numericState.equals(DecimalType.ZERO); + Map values = processUpdatedValue(numericState, boolValue); + logger.debug( + "Thing {} channels updated: {}. readValueType={}, readIndex={}, readSubIndex(or 0)={}, extractIndex={} -> numeric value {} and boolValue={}. Registers {} for request {}", + thing.getUID(), values, readValueType, readIndex, readSubIndex.orElse(0), extractIndex, numericState, + boolValue, registers, request); + } + + private synchronized void onBits(ModbusReadRequestBlueprint request, BitArray bits) { + if (hasConfigurationError()) { + return; + } else if (!isReadEnabled) { + return; + } + boolean boolValue = bits.getBit(readIndex.get() - pollStart); + DecimalType numericState = boolValue ? new DecimalType(BigDecimal.ONE) : DecimalType.ZERO; + Map values = processUpdatedValue(numericState, boolValue); + logger.debug( + "Thing {} channels updated: {}. readValueType={}, readIndex={} -> numeric value {} and boolValue={}. Bits {} for request {}", + thing.getUID(), values, readValueType, readIndex, numericState, boolValue, bits, request); + } + + private synchronized void onError(ModbusReadRequestBlueprint request, Exception error) { + if (hasConfigurationError()) { + return; + } else if (!isReadEnabled) { + return; + } + if (error instanceof ModbusConnectionException) { + logger.trace("Thing {} '{}' had {} error on read: {}", getThing().getUID(), getThing().getLabel(), + error.getClass().getSimpleName(), error.toString()); + } else if (error instanceof ModbusTransportException) { + logger.trace("Thing {} '{}' had {} error on read: {}", getThing().getUID(), getThing().getLabel(), + error.getClass().getSimpleName(), error.toString()); + } else { + logger.error( + "Thing {} '{}' had {} error on read: {} (message: {}). Stack trace follows since this is unexpected error.", + getThing().getUID(), getThing().getLabel(), error.getClass().getName(), error.toString(), + error.getMessage(), error); + } + Map states = new HashMap<>(); + ChannelUID lastReadErrorUID = getChannelUID(ModbusBindingConstantsInternal.CHANNEL_LAST_READ_ERROR); + if (isLinked(lastReadErrorUID)) { + states.put(lastReadErrorUID, new DateTimeType()); + } + + synchronized (this) { + // Update channels + states.forEach((uid, state) -> { + tryUpdateState(uid, state); + }); + + updateStatusIfChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + String.format("Error (%s) with read. Request: %s. Description: %s. Message: %s", + error.getClass().getSimpleName(), request, error.toString(), error.getMessage())); + } + } + + private synchronized void onError(ModbusWriteRequestBlueprint request, Exception error) { + if (hasConfigurationError()) { + return; + } else if (!isWriteEnabled) { + return; + } + if (error instanceof ModbusConnectionException) { + logger.debug("Thing {} '{}' had {} error on write: {}", getThing().getUID(), getThing().getLabel(), + error.getClass().getSimpleName(), error.toString()); + } else if (error instanceof ModbusTransportException) { + logger.debug("Thing {} '{}' had {} error on write: {}", getThing().getUID(), getThing().getLabel(), + error.getClass().getSimpleName(), error.toString()); + } else { + logger.error( + "Thing {} '{}' had {} error on write: {} (message: {}). Stack trace follows since this is unexpected error.", + getThing().getUID(), getThing().getLabel(), error.getClass().getName(), error.toString(), + error.getMessage(), error); + } + Map states = new HashMap<>(); + ChannelUID lastWriteErrorUID = getChannelUID(ModbusBindingConstantsInternal.CHANNEL_LAST_WRITE_ERROR); + if (isLinked(lastWriteErrorUID)) { + states.put(lastWriteErrorUID, new DateTimeType()); + } + + synchronized (this) { + // Update channels + states.forEach((uid, state) -> { + tryUpdateState(uid, state); + }); + + updateStatusIfChanged(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + String.format("Error (%s) with write. Request: %s. Description: %s. Message: %s", + error.getClass().getSimpleName(), request, error.toString(), error.getMessage())); + } + } + + public synchronized void onWriteResponse(AsyncModbusWriteResult result) { + if (hasConfigurationError()) { + return; + } else if (!isWriteEnabled) { + return; + } + logger.debug("Successful write, matching request {}", result.getRequest()); + updateStatusIfChanged(ThingStatus.ONLINE); + ChannelUID lastWriteSuccessUID = getChannelUID(ModbusBindingConstantsInternal.CHANNEL_LAST_WRITE_SUCCESS); + if (isLinked(lastWriteSuccessUID)) { + updateState(lastWriteSuccessUID, new DateTimeType()); + } + } + + /** + * Update linked channels + * + * @param numericState numeric state corresponding to polled data (or UNDEF with floating point NaN or infinity) + * @param boolValue boolean value corresponding to polled data + * @return updated channel data + */ + private Map processUpdatedValue(State numericState, boolean boolValue) { + ValueTransformation localReadTransformation = readTransformation; + if (localReadTransformation == null) { + // We should always have transformation available if thing is initalized properly + logger.trace("No transformation available, aborting processUpdatedValue"); + return Collections.emptyMap(); + } + Map states = new HashMap<>(); + CHANNEL_ID_TO_ACCEPTED_TYPES.keySet().stream().forEach(channelId -> { + ChannelUID channelUID = getChannelUID(channelId); + if (!isLinked(channelUID)) { + return; + } + List> acceptedDataTypes = CHANNEL_ID_TO_ACCEPTED_TYPES.get(channelId); + if (acceptedDataTypes.isEmpty()) { + return; + } + + State boolLikeState; + if (containsOnOff(acceptedDataTypes)) { + boolLikeState = OnOffType.from(boolValue); + } else if (containsOpenClosed(acceptedDataTypes)) { + boolLikeState = boolValue ? OpenClosedType.OPEN : OpenClosedType.CLOSED; + } else { + boolLikeState = null; + } + + State transformedState; + if (localReadTransformation.isIdentityTransform()) { + if (boolLikeState != null) { + // A bit of smartness for ON/OFF and OPEN/CLOSED with boolean like items + transformedState = boolLikeState; + } else { + // Numeric states always go through transformation. This allows value of 17.5 to be + // converted to + // 17.5% with percent types (instead of raising error) + transformedState = localReadTransformation.transformState(bundleContext, acceptedDataTypes, + numericState); + } + } else { + transformedState = localReadTransformation.transformState(bundleContext, acceptedDataTypes, + numericState); + } + + if (transformedState != null) { + logger.trace( + "Channel {} will be updated to '{}' (type {}). Input data: number value {} (value type '{}' taken into account) and bool value {}. Transformation: {}", + channelId, transformedState, transformedState.getClass().getSimpleName(), numericState, + readValueType, boolValue, + localReadTransformation.isIdentityTransform() ? "" : localReadTransformation); + states.put(channelUID, transformedState); + } else { + String types = String.join(", ", + acceptedDataTypes.stream().map(cls -> cls.getSimpleName()).toArray(String[]::new)); + logger.warn( + "Channel {} will not be updated since transformation was unsuccessful. Channel is expecting the following data types [{}]. Input data: number value {} (value type '{}' taken into account) and bool value {}. Transformation: {}", + channelId, types, numericState, readValueType, boolValue, + localReadTransformation.isIdentityTransform() ? "" : localReadTransformation); + } + }); + + ChannelUID lastReadSuccessUID = getChannelUID(ModbusBindingConstantsInternal.CHANNEL_LAST_READ_SUCCESS); + if (isLinked(lastReadSuccessUID)) { + states.put(lastReadSuccessUID, new DateTimeType()); + } + updateExpiredChannels(states); + return states; + } + + private void updateExpiredChannels(Map states) { + synchronized (this) { + updateStatusIfChanged(ThingStatus.ONLINE); + long now = System.currentTimeMillis(); + // Update channels that have not been updated in a while, or when their values has changed + states.forEach((uid, state) -> updateExpiredChannel(now, uid, state)); + channelLastState = states; + } + } + + // since lastState can be null, and "lastState == null" in conditional is not useless + @SuppressWarnings("null") + private void updateExpiredChannel(long now, ChannelUID uid, State state) { + @Nullable + State lastState = channelLastState.get(uid); + long lastUpdatedMillis = channelLastUpdated.getOrDefault(uid, 0L); + long millisSinceLastUpdate = now - lastUpdatedMillis; + if (lastUpdatedMillis <= 0L || lastState == null || updateUnchangedValuesEveryMillis <= 0L + || millisSinceLastUpdate > updateUnchangedValuesEveryMillis || !lastState.equals(state)) { + tryUpdateState(uid, state); + channelLastUpdated.put(uid, now); + } + } + + private void tryUpdateState(ChannelUID uid, State state) { + try { + updateState(uid, state); + } catch (IllegalArgumentException e) { + logger.warn("Error updating state '{}' (type {}) to channel {}: {} {}", state, + Optional.ofNullable(state).map(s -> s.getClass().getName()).orElse("null"), uid, + e.getClass().getName(), e.getMessage()); + } + } + + private ChannelUID getChannelUID(String channelID) { + return Objects + .requireNonNull(channelCache.computeIfAbsent(channelID, id -> new ChannelUID(getThing().getUID(), id))); + } + + private void updateStatusIfChanged(ThingStatus status) { + updateStatusIfChanged(status, ThingStatusDetail.NONE, null); + } + + private void updateStatusIfChanged(ThingStatus status, ThingStatusDetail statusDetail, + @Nullable String description) { + ThingStatusInfo newStatusInfo = new ThingStatusInfo(status, statusDetail, description); + Duration durationSinceLastUpdate = Duration.between(lastStatusInfoUpdate, LocalDateTime.now()); + boolean intervalElapsed = MIN_STATUS_INFO_UPDATE_INTERVAL.minus(durationSinceLastUpdate).isNegative(); + if (statusInfo.getStatus() == ThingStatus.UNKNOWN || !statusInfo.equals(newStatusInfo) || intervalElapsed) { + statusInfo = newStatusInfo; + lastStatusInfoUpdate = LocalDateTime.now(); + updateStatus(newStatusInfo); + } + } + + /** + * Update status using pre-constructed ThingStatusInfo + * + * Implementation adapted from BaseThingHandler updateStatus implementations + * + * @param statusInfo new status info + */ + protected void updateStatus(ThingStatusInfo statusInfo) { + synchronized (this) { + ThingHandlerCallback callback = getCallback(); + if (callback != null) { + callback.statusUpdated(this.thing, statusInfo); + } else { + logger.warn("Handler {} tried updating the thing status although the handler was already disposed.", + this.getClass().getSimpleName()); + } + } + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusSerialThingHandler.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusSerialThingHandler.java new file mode 100644 index 000000000000..2e07090b92c0 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusSerialThingHandler.java @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.handler; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.sbus.discovery.internal.ModbusEndpointDiscoveryService; +import org.openhab.binding.sbus.handler.EndpointNotInitializedException; +import org.openhab.binding.sbus.internal.ModbusConfigurationException; +import org.openhab.binding.sbus.internal.config.ModbusSerialConfiguration; +import org.openhab.core.io.transport.sbus.ModbusManager; +import org.openhab.core.io.transport.sbus.endpoint.EndpointPoolConfiguration; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSerialSlaveEndpoint; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandlerService; + +/** + * Endpoint thing handler for serial slaves + * + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class ModbusSerialThingHandler + extends AbstractModbusEndpointThingHandler { + + public ModbusSerialThingHandler(Bridge bridge, ModbusManager manager) { + super(bridge, manager); + } + + @Override + protected void configure() throws ModbusConfigurationException { + ModbusSerialConfiguration config = getConfigAs(ModbusSerialConfiguration.class); + String port = config.getPort(); + int baud = config.getBaud(); + String flowControlIn = config.getFlowControlIn(); + String flowControlOut = config.getFlowControlOut(); + String stopBits = config.getStopBits(); + String parity = config.getParity(); + String encoding = config.getEncoding(); + if (port == null || flowControlIn == null || flowControlOut == null || stopBits == null || parity == null + || encoding == null) { + throw new ModbusConfigurationException( + "port, baud, flowControlIn, flowControlOut, stopBits, parity, encoding all must be non-null!"); + } + + this.config = config; + + EndpointPoolConfiguration poolConfiguration = new EndpointPoolConfiguration(); + this.poolConfiguration = poolConfiguration; + poolConfiguration.setConnectMaxTries(config.getConnectMaxTries()); + poolConfiguration.setAfterConnectionDelayMillis(config.getAfterConnectionDelayMillis()); + poolConfiguration.setConnectTimeoutMillis(config.getConnectTimeoutMillis()); + poolConfiguration.setInterTransactionDelayMillis(config.getTimeBetweenTransactionsMillis()); + + // Never reconnect serial connections "automatically" + poolConfiguration.setInterConnectDelayMillis(1000); + poolConfiguration.setReconnectAfterMillis(-1); + + endpoint = new ModbusSerialSlaveEndpoint(port, baud, flowControlIn, flowControlOut, config.getDataBits(), + stopBits, parity, encoding, config.isEcho(), config.getReceiveTimeoutMillis()); + } + + /** + * Return true if auto discovery is enabled in the config + */ + @Override + public boolean isDiscoveryEnabled() { + if (config != null) { + return config.isDiscoveryEnabled(); + } else { + return false; + } + } + + @SuppressWarnings("null") // Since endpoint in Optional.map cannot be null + @Override + protected String formatConflictingParameterError() { + return String.format( + "Endpoint '%s' has conflicting parameters: parameters of this thing (%s '%s') are different from some other thing's parameter. Ensure that all endpoints pointing to serial port '%s' have same parameters.", + endpoint, thing.getUID(), this.thing.getLabel(), + Optional.ofNullable(this.endpoint).map(e -> e.getPortName()).orElse("")); + } + + @Override + public int getSlaveId() throws EndpointNotInitializedException { + ModbusSerialConfiguration config = this.config; + if (config == null) { + throw new EndpointNotInitializedException(); + } + return config.getId(); + } + + @Override + public int getSubnetId() throws EndpointNotInitializedException { + ModbusSerialConfiguration config = this.config; + if (config == null) { + throw new EndpointNotInitializedException(); + } + return config.getSubnetId(); + } + + @Override + public ThingUID getUID() { + return getThing().getUID(); + } + + @Override + public Collection> getServices() { + return Set.of(ModbusEndpointDiscoveryService.class); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusUdpThingHandler.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusUdpThingHandler.java new file mode 100644 index 000000000000..a2b92b526c9d --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/handler/ModbusUdpThingHandler.java @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.handler; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.sbus.discovery.internal.ModbusEndpointDiscoveryService; +import org.openhab.binding.sbus.handler.EndpointNotInitializedException; +import org.openhab.binding.sbus.internal.ModbusConfigurationException; +import org.openhab.binding.sbus.internal.config.ModbusUdpConfiguration; +import org.openhab.core.io.transport.sbus.ModbusManager; +import org.openhab.core.io.transport.sbus.endpoint.EndpointPoolConfiguration; +import org.openhab.core.io.transport.sbus.endpoint.ModbusUDPSlaveEndpoint; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandlerService; + +/** + * Endpoint thing handler for UDP slaves + * + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class ModbusUdpThingHandler + extends AbstractModbusEndpointThingHandler { + + public ModbusUdpThingHandler(Bridge bridge, ModbusManager manager) { + super(bridge, manager); + } + + @Override + protected void configure() throws ModbusConfigurationException { + ModbusUdpConfiguration config = getConfigAs(ModbusUdpConfiguration.class); + + String host = config.getHost(); + if (host == null) { + throw new ModbusConfigurationException("host must be non-null!"); + } + + this.config = config; + endpoint = new ModbusUDPSlaveEndpoint(host, config.getPort()); + + EndpointPoolConfiguration poolConfiguration = new EndpointPoolConfiguration(); + this.poolConfiguration = poolConfiguration; + poolConfiguration.setConnectMaxTries(config.getConnectMaxTries()); + poolConfiguration.setAfterConnectionDelayMillis(config.getAfterConnectionDelayMillis()); + poolConfiguration.setConnectTimeoutMillis(config.getConnectTimeoutMillis()); + poolConfiguration.setInterConnectDelayMillis(config.getTimeBetweenReconnectMillis()); + poolConfiguration.setInterTransactionDelayMillis(config.getTimeBetweenTransactionsMillis()); + poolConfiguration.setReconnectAfterMillis(config.getReconnectAfterMillis()); + } + + @SuppressWarnings("null") // since Optional.map is always called with NonNull argument + @Override + protected String formatConflictingParameterError() { + return String.format( + "Endpoint '%s' has conflicting parameters: parameters of this thing (%s '%s') are different from some other thing's parameter. Ensure that all endpoints pointing to udp slave '%s:%s' have same parameters.", + endpoint, thing.getUID(), this.thing.getLabel(), + Optional.ofNullable(this.endpoint).map(e -> e.getAddress()).orElse(""), + Optional.ofNullable(this.endpoint).map(e -> String.valueOf(e.getPort())).orElse("")); + } + + @Override + public int getSlaveId() throws EndpointNotInitializedException { + ModbusUdpConfiguration localConfig = config; + if (localConfig == null) { + throw new EndpointNotInitializedException(); + } + return localConfig.getId(); + } + + @Override + public int getSubnetId() throws EndpointNotInitializedException { + ModbusUdpConfiguration localConfig = config; + if (localConfig == null) { + throw new EndpointNotInitializedException(); + } + return localConfig.getSubnetId(); + } + + @Override + public ThingUID getUID() { + return getThing().getUID(); + } + + /** + * Returns true if discovery is enabled + */ + @Override + public boolean isDiscoveryEnabled() { + if (config != null) { + return config.isDiscoveryEnabled(); + } else { + return false; + } + } + + @Override + public Collection> getServices() { + return Set.of(ModbusEndpointDiscoveryService.class); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusGainOffsetProfile.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusGainOffsetProfile.java new file mode 100644 index 000000000000..04edc1f6163e --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusGainOffsetProfile.java @@ -0,0 +1,253 @@ +/** + * Copyright (c) 2010-2024 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.sbus.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 Ciprian Pascu - 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 cmd) { + logger.trace("Command '{}' from item, sending converted '{}' state towards handler.", command, result); + callback.handleCommand(cmd); + } + } + + @Override + public void onCommandFromHandler(Command command) { + Type result = applyGainOffset(command, true); + if (result instanceof Command cmd) { + logger.trace("Command '{}' from handler, sending converted '{}' command towards item.", command, result); + callback.sendCommand(cmd); + } + } + + @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 pre-gain-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 quantityState) { + try { + if (towardsItem) { + @SuppressWarnings("unchecked") // xx.toUnit(ONE) returns null or QuantityType + @Nullable + QuantityType qtState = (QuantityType) (quantityState + .toUnit(Units.ONE)); + if (qtState == null) { + logger.warn("Profile can only process plain numbers from handler. Got unit {}. Returning UNDEF", + quantityState.getUnit()); + return UnDefType.UNDEF; + } + QuantityType offsetted = qtState.add(pregainOffsetQt); + result = applyGainTowardsItem(offsetted, gain); + } else { + result = applyGainTowardsHandler(quantityState, gain).subtract(pregainOffsetQt); + } + } catch (UnconvertibleException | UnsupportedOperationException e) { + logger.warn( + "Cannot apply gain ('{}') and pre-gain-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 decState) { + 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 str) { + try { + QuantityType qt = new QuantityType<>(str); + 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 parameterBigDecimal) { + 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 + * + */ + private > QuantityType applyGainTowardsItem(QuantityType qtState, + QuantityType gainDelta) { + return new QuantityType<>(qtState.toBigDecimal().multiply(gainDelta.toBigDecimal()), gainDelta.getUnit()); + } + + 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 str && str.isBlank()) { + return defaultValue; + } else { + return value; + } + } +} diff --git a/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusProfileFactory.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusProfileFactory.java new file mode 100644 index 000000000000..1fd7a769e690 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusProfileFactory.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal.profiles; + +import static org.openhab.binding.sbus.internal.profiles.ModbusProfiles.GAIN_OFFSET; +import static org.openhab.binding.sbus.internal.profiles.ModbusProfiles.GAIN_OFFSET_TYPE; + +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 Ciprian Pascu - 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.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusProfiles.java b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusProfiles.java new file mode 100644 index 000000000000..dc7eb250d0b9 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/java/org/openhab/binding/sbus/internal/profiles/ModbusProfiles.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2024 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.sbus.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 Ciprian Pascu - 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.sbus/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 000000000000..1d2cd4cbf9f2 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,10 @@ + + + binding + Modbus Binding + Binding for Modbus + local + + diff --git a/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/config/gainOffset.xml b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/config/gainOffset.xml new file mode 100644 index 000000000000..6cdfc58d4a02 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/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.sbus/src/main/resources/OH-INF/i18n/modbus.properties b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/i18n/modbus.properties new file mode 100644 index 000000000000..87c3e3d150a2 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/i18n/modbus.properties @@ -0,0 +1,188 @@ +# add-on + +addon.sbus.name = S-Bus Binding +addon.sbus.description = Binding for S-Bus + +# thing types + +thing-type.sbus.data.label = S-Bus Data +thing-type.sbus.data.description = Data thing extracts values from binary data received from S-Bus slave. Similarly, it is responsible of translating openHAB commands to S-Bus write requests +thing-type.sbus.poller.label = Regular Poll +thing-type.sbus.poller.description = Regular poll of data from S-Bus slaves +thing-type.sbus.serial.label = S-Bus Serial Slave +thing-type.sbus.serial.description = Endpoint for S-Bus serial slaves +thing-type.sbus.udp.label = S-Bus UDP Slave +thing-type.sbus.udp.description = Endpoint for S-Bus UDP slaves + +# thing types config + +thing-type.config.sbus.data.readStart.label = Read Address +thing-type.config.sbus.data.readStart.description = 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.
+thing-type.config.sbus.data.readTransform.label = Read Transform +thing-type.config.sbus.data.readTransform.description = 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 to use transformation service.
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 +thing-type.config.sbus.data.readValueType.label = Read Value Type +thing-type.config.sbus.data.readValueType.description = How data is read from modbus. Use empty for write-only things.

With registers all value types are applicable. +thing-type.config.sbus.data.readValueType.option.int64 = 64bit signed integer (int64) +thing-type.config.sbus.data.readValueType.option.uint64 = 64bit unsigned integer (uint64) +thing-type.config.sbus.data.readValueType.option.int64_swap = 64bit signed integer, 16bit words in reverse order (dcba) (int64_swap) +thing-type.config.sbus.data.readValueType.option.uint64_swap = 64bit unsigned integer, 16bit words in reverse order (dcba) (uint64_swap) +thing-type.config.sbus.data.readValueType.option.float32 = 32bit floating point (float32) +thing-type.config.sbus.data.readValueType.option.float32_swap = 32bit floating point, 16bit words swapped (float32_swap) +thing-type.config.sbus.data.readValueType.option.int32 = 32bit signed integer (int32) +thing-type.config.sbus.data.readValueType.option.uint32 = 32bit unsigned integer (uint32) +thing-type.config.sbus.data.readValueType.option.int32_swap = 32bit signed integer, 16bit words swapped (int32_swap) +thing-type.config.sbus.data.readValueType.option.uint32_swap = 32bit unsigned integer, 16bit words swapped (uint32_swap) +thing-type.config.sbus.data.readValueType.option.int16 = 16bit signed integer (int16) +thing-type.config.sbus.data.readValueType.option.uint16 = 16bit unsigned integer (uint16) +thing-type.config.sbus.data.readValueType.option.int8 = 8bit signed integer (int8) +thing-type.config.sbus.data.readValueType.option.uint8 = 8bit unsigned integer (uint8) +thing-type.config.sbus.data.readValueType.option.bit = individual bit (bit) +thing-type.config.sbus.data.updateUnchangedValuesEveryMillis.label = Interval for Updating Unchanged Values +thing-type.config.sbus.data.updateUnchangedValuesEveryMillis.description = Interval to update unchanged values. Normally unchanged values are not updated. In milliseconds. +thing-type.config.sbus.data.writeMaxTries.label = Maximum Tries When Writing +thing-type.config.sbus.data.writeMaxTries.description = Number of tries when writing data, if some of the writes fail. For single try, enter 1. +thing-type.config.sbus.data.writeMultipleEvenWithSingleRegisterOrCoil.label = Write Multiple Even with Single Register or Coil +thing-type.config.sbus.data.writeMultipleEvenWithSingleRegisterOrCoil.description = Whether single register / coil of data is written using FC16 ("Write Multiple Holding Registers") / FC15 ("Write Multiple Coils"), respectively.

If false, FC6/FC5 are used with single register and single coil, respectively. +thing-type.config.sbus.data.writeStart.label = Write Address +thing-type.config.sbus.data.writeStart.description = 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 write individual bits of a register using X.Y format where X is the register and Y is the bit (0 refers to least significant bit). +thing-type.config.sbus.data.writeTransform.label = Write Transform +thing-type.config.sbus.data.writeTransform.description = 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 to use transformation service.
Any other value than the above types will be interpreted as static text, in which case the actual content of the command
You can chain many transformations with ∩, for example SERVICE1:ARG1∩SERVICE2:ARG2 value is ignored. +thing-type.config.sbus.data.writeType.label = Write Type +thing-type.config.sbus.data.writeType.description = Type of data to write. Leave empty for read-only things.

Coil uses function code (FC) FC05 or FC15. Holding register uses FC06 or FC16. See writeMultipleEvenWithSingleRegisterOrCoil parameter. +thing-type.config.sbus.data.writeType.option.coil = coil, or digital out (DO) +thing-type.config.sbus.data.writeType.option.holding = holding register +thing-type.config.sbus.data.writeValueType.label = Write Value Type +thing-type.config.sbus.data.writeValueType.description = How data is written to modbus. Only applicable to registers, you can leave this undefined for coil.

Negative integers are encoded with two's complement, while positive integers are encoded as is. +thing-type.config.sbus.data.writeValueType.option.int64 = 64bit positive or negative integer, 4 registers (int64, uint64) +thing-type.config.sbus.data.writeValueType.option.int64_swap = 64bit positive or negative integer, 4 registers but with 16bit words/registers in reverse order (dcba) (int64_swap, uint64_swap) +thing-type.config.sbus.data.writeValueType.option.float32 = 32bit floating point (float32) +thing-type.config.sbus.data.writeValueType.option.float32_swap = 32bit floating point, 16bit words swapped (float32_swap) +thing-type.config.sbus.data.writeValueType.option.int32 = 32bit positive or negative integer, 2 registers (int32, uint32) +thing-type.config.sbus.data.writeValueType.option.int32_swap = 32bit positive or negative integer, 2 registers but with 16bit words/registers in reverse order (ba) (int32_swap, uint32_swap) +thing-type.config.sbus.data.writeValueType.option.int16 = 16bit positive or negative integer, 1 register (int16, uint16) +thing-type.config.sbus.data.writeValueType.option.bit = individual bit (bit) +thing-type.config.sbus.poller.cacheMillis.label = Cache Duration +thing-type.config.sbus.poller.cacheMillis.description = Duration for data cache to be valid, in milliseconds. This cache is used only to serve REFRESH commands.

Use zero to disable the caching. +thing-type.config.sbus.poller.length.label = Length +thing-type.config.sbus.poller.length.description = Number of registers, coils or discrete inputs to read.

Maximum number of registers is 125 while 2000 is maximum for coils and discrete inputs. +thing-type.config.sbus.poller.maxTries.label = Maximum Tries When Reading +thing-type.config.sbus.poller.maxTries.description = Number of tries when reading data, if some of the reading fail. For single try, enter 1. +thing-type.config.sbus.poller.refresh.label = Poll Interval +thing-type.config.sbus.poller.refresh.description = Poll interval in milliseconds. Use zero to disable automatic polling. +thing-type.config.sbus.poller.start.label = Start +thing-type.config.sbus.poller.start.description = Address of the first register, coil, or discrete input to poll.

Input as zero-based index number, e.g. in place of 400001 (first holding register), use the address 0. +thing-type.config.sbus.poller.type.label = Type +thing-type.config.sbus.poller.type.description = Type of modbus items to poll +thing-type.config.sbus.poller.type.option.coil = coil, or digital out (DO) +thing-type.config.sbus.poller.type.option.discrete = discrete input, or digital in (DI) +thing-type.config.sbus.poller.type.option.holding = holding register +thing-type.config.sbus.poller.type.option.input = input register +thing-type.config.sbus.serial.afterConnectionDelayMillis.label = Connection warm-up time +thing-type.config.sbus.serial.afterConnectionDelayMillis.description = Connection warm-up time. Additional time which is spent on preparing connection which should be spent waiting while end device is getting ready to answer first modbus call. In milliseconds. +thing-type.config.sbus.serial.baud.label = Baud +thing-type.config.sbus.serial.baud.description = Baud of the connection +thing-type.config.sbus.serial.baud.option.75 = 75 +thing-type.config.sbus.serial.baud.option.110 = 110 +thing-type.config.sbus.serial.baud.option.300 = 300 +thing-type.config.sbus.serial.baud.option.1200 = 1200 +thing-type.config.sbus.serial.baud.option.2400 = 2400 +thing-type.config.sbus.serial.baud.option.4800 = 4800 +thing-type.config.sbus.serial.baud.option.9600 = 9600 +thing-type.config.sbus.serial.baud.option.19200 = 19200 +thing-type.config.sbus.serial.baud.option.38400 = 38400 +thing-type.config.sbus.serial.baud.option.57600 = 57600 +thing-type.config.sbus.serial.baud.option.115200 = 115200 +thing-type.config.sbus.serial.connectMaxTries.label = Maximum Connection Tries +thing-type.config.sbus.serial.connectMaxTries.description = How many times we try to establish the connection. Should be at least 1. +thing-type.config.sbus.serial.connectTimeoutMillis.label = Timeout for Establishing the Connection +thing-type.config.sbus.serial.connectTimeoutMillis.description = The maximum time that is waited when establishing the connection. Value of zero means that system/OS default is respected. In milliseconds. +thing-type.config.sbus.serial.dataBits.label = Data Bits +thing-type.config.sbus.serial.dataBits.description = Data bits +thing-type.config.sbus.serial.dataBits.option.5 = 5 +thing-type.config.sbus.serial.dataBits.option.6 = 6 +thing-type.config.sbus.serial.dataBits.option.7 = 7 +thing-type.config.sbus.serial.dataBits.option.8 = 8 +thing-type.config.sbus.serial.echo.label = RS485 Echo Mode +thing-type.config.sbus.serial.echo.description = Flag for setting the RS485 echo mode

This controls whether we should try to read back whatever we send on the line, before reading the response. +thing-type.config.sbus.serial.enableDiscovery.label = Discovery Enabled +thing-type.config.sbus.serial.enableDiscovery.description = When enabled we try to find a device specific handler. Turn this on if you're using one of the supported devices. +thing-type.config.sbus.serial.encoding.label = Encoding +thing-type.config.sbus.serial.encoding.description = Encoding +thing-type.config.sbus.serial.encoding.option.ascii = ASCII +thing-type.config.sbus.serial.encoding.option.rtu = RTU +thing-type.config.sbus.serial.encoding.option.bin = BIN +thing-type.config.sbus.serial.flowControlIn.label = Flow Control In +thing-type.config.sbus.serial.flowControlIn.description = Type of flow control for receiving +thing-type.config.sbus.serial.flowControlIn.option.none = None +thing-type.config.sbus.serial.flowControlIn.option.xon/xoff in = XON/XOFF +thing-type.config.sbus.serial.flowControlIn.option.rts/cts in = RTS/CTS +thing-type.config.sbus.serial.flowControlOut.label = Flow Control Out +thing-type.config.sbus.serial.flowControlOut.description = Type of flow control for sending +thing-type.config.sbus.serial.flowControlOut.option.none = None +thing-type.config.sbus.serial.flowControlOut.option.xon/xoff out = XON/XOFF +thing-type.config.sbus.serial.flowControlOut.option.rts/cts out = RTS/CTS +thing-type.config.sbus.serial.id.label = Id +thing-type.config.sbus.serial.id.description = Slave id. Also known as station address or unit identifier. +thing-type.config.sbus.serial.parity.label = Parity +thing-type.config.sbus.serial.parity.description = Parity +thing-type.config.sbus.serial.parity.option.none = None +thing-type.config.sbus.serial.parity.option.even = Even +thing-type.config.sbus.serial.parity.option.odd = Odd +thing-type.config.sbus.serial.port.label = Serial Port +thing-type.config.sbus.serial.port.description = Serial port to use, for example /dev/ttyS0 or COM1 +thing-type.config.sbus.serial.receiveTimeoutMillis.label = Read Operation Timeout +thing-type.config.sbus.serial.receiveTimeoutMillis.description = Timeout for read operations. In milliseconds. +thing-type.config.sbus.serial.stopBits.label = Stop Bits +thing-type.config.sbus.serial.stopBits.description = Stop bits +thing-type.config.sbus.serial.stopBits.option.1.0 = 1 +thing-type.config.sbus.serial.stopBits.option.1.5 = 1.5 +thing-type.config.sbus.serial.stopBits.option.2.0 = 2 +thing-type.config.sbus.serial.timeBetweenTransactionsMillis.label = Time Between Transactions +thing-type.config.sbus.serial.timeBetweenTransactionsMillis.description = How long to delay we must have at minimum between two consecutive MODBUS transactions. In milliseconds. +thing-type.config.sbus.udp.afterConnectionDelayMillis.label = Connection warm-up time +thing-type.config.sbus.udp.afterConnectionDelayMillis.description = Connection warm-up time. Additional time which is spent on preparing connection which should be spent waiting while end device is getting ready to answer first modbus call. In milliseconds. +thing-type.config.sbus.udp.connectMaxTries.label = Maximum Connection Tries +thing-type.config.sbus.udp.connectMaxTries.description = How many times we try to establish the connection. Should be at least 1. +thing-type.config.sbus.udp.connectTimeoutMillis.label = Timeout for Establishing the Connection +thing-type.config.sbus.udp.connectTimeoutMillis.description = The maximum time that is waited when establishing the connection. Value of zero means that system/OS default is respected. In milliseconds. +thing-type.config.sbus.udp.enableDiscovery.label = Discovery Enabled +thing-type.config.sbus.udp.enableDiscovery.description = When enabled we try to find a device specific handler. Turn this on if you're using one of the supported devices. +thing-type.config.sbus.udp.host.label = IP Address or Hostname +thing-type.config.sbus.udp.host.description = Network address of the device +thing-type.config.sbus.udp.id.label = Id +thing-type.config.sbus.udp.id.description = Slave id. Also known as station address or unit identifier. +thing-type.config.sbus.udp.port.label = Port +thing-type.config.sbus.udp.port.description = Port of the slave +thing-type.config.sbus.udp.reconnectAfterMillis.label = Reconnect Again After +thing-type.config.sbus.udp.reconnectAfterMillis.description = The connection is kept open at least the time specified here. Value of zero means that connection is disconnected after every MODBUS transaction. In milliseconds. +thing-type.config.sbus.udp.rtuEncoded.label = RTU Encoding +thing-type.config.sbus.udp.rtuEncoded.description = Use RTU Encoding over IP +thing-type.config.sbus.udp.timeBetweenReconnectMillis.label = Time Between Reconnections +thing-type.config.sbus.udp.timeBetweenReconnectMillis.description = How long to wait to before trying to establish a new connection after the previous one has been disconnected. In milliseconds. +thing-type.config.sbus.udp.timeBetweenTransactionsMillis.label = Time Between Transactions +thing-type.config.sbus.udp.timeBetweenTransactionsMillis.description = How long to delay we must have at minimum between two consecutive MODBUS transactions. In milliseconds. + +# channel types + +channel-type.sbus.contact-type.label = Value as Contact +channel-type.sbus.contact-type.description = Contact item channel +channel-type.sbus.datetime-type.label = Value as DateTime +channel-type.sbus.datetime-type.description = DateTime item channel +channel-type.sbus.dimmer-type.label = Value as Dimmer +channel-type.sbus.dimmer-type.description = Dimmer item channel +channel-type.sbus.last-erroring-read-type.label = Last Erroring Read +channel-type.sbus.last-erroring-read-type.description = Date of last read error +channel-type.sbus.last-erroring-write-type.label = Last Erroring Write +channel-type.sbus.last-erroring-write-type.description = Date of last write error +channel-type.sbus.last-successful-read-type.label = Last Successful Read +channel-type.sbus.last-successful-read-type.description = Date of last read +channel-type.sbus.last-successful-write-type.label = Last Successful Write +channel-type.sbus.last-successful-write-type.description = Date of last write +channel-type.sbus.number-type.label = Value as Number +channel-type.sbus.number-type.description = Number item channel +channel-type.sbus.rollershutter-type.label = Value as Rollershutter +channel-type.sbus.rollershutter-type.description = Rollershutter item channel +channel-type.sbus.string-type.label = Value as String +channel-type.sbus.string-type.description = String item channel +channel-type.sbus.switch-type.label = Value as Switch +channel-type.sbus.switch-type.description = Switch item channel diff --git a/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-poller.xml b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-poller.xml new file mode 100644 index 000000000000..d30074a44b49 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-poller.xml @@ -0,0 +1,58 @@ + + + + + + + + + + Regular poll of data from Modbus slaves + + + + Poll interval in milliseconds. Use zero to disable automatic polling. + 500 + + + + +
Input as zero-based index number, e.g. in place of 400001 (first holding register), use the address 0.]]>
+ 0 +
+ + + +
Maximum number of registers is 125 while 2000 is maximum for coils and discrete inputs.]]>
+
+ + + Type of modbus items to poll + + + + + + + + + + 3 + Number of tries when reading data, if some of the reading fail. For single try, enter 1. + + + + 50 + +
Use zero to disable the caching.]]>
+ true +
+
+
+
diff --git a/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-serial.xml b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-serial.xml new file mode 100644 index 000000000000..1348ff4388a2 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-serial.xml @@ -0,0 +1,155 @@ + + + + + Endpoint for Modbus serial slaves + + + + serial-port + false + Serial port to use, for example /dev/ttyS0 or COM1 + + + + + Slave id. Also known as station address or unit identifier. + 1 + + + + + + Baud of the connection + 9600 + + + + + + + + + + + + + + + + + Stop bits + 1.0 + + + + + + + + + Parity + none + + + + + + + + + Data bits + 8 + + + + + + + + + + Encoding + rtu + + + + + + + + + When enabled we try to find a device specific handler. Turn this on if you're using one of the + supported devices. + false + + + + +
This controls whether we should try to read back whatever we send on the line, before reading the response. + ]]>
+ false + true +
+ + + Timeout for read operations. In milliseconds. + 1500 + true + + + + Type of flow control for receiving + none + + + + + + + + + + Type of flow control for sending + none + + + + + + + + + + + + How long to delay we must have at minimum between two consecutive MODBUS transactions. In milliseconds. + 35 + + + + How many times we try to establish the connection. Should be at least 1. + 1 + true + + + + Connection warm-up time. Additional time which is spent on preparing connection which should be spent + waiting while end device is getting ready to answer first modbus call. In milliseconds. + 0 + true + + + + The maximum time that is waited when establishing the connection. Value of zero means that system/OS + default is respected. In milliseconds. + 10000 + true + +
+
+
diff --git a/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-udp.xml b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-udp.xml new file mode 100644 index 000000000000..e6b454246c9f --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/bridge-udp.xml @@ -0,0 +1,89 @@ + + + + + Endpoint for Modbus UDP slaves + + + + Network address of the device + localhost + network-address + + + + Port of the slave + 6000 + + + + + Slave subnet id. Can take any value between 0 and 255. + 1 + + + + Slave id. Also known as station address or unit identifier. + 1 + + + + + When enabled we try to find a device specific handler. Turn this on if you're using one of the + supported devices. + false + + + + + Use RTU Encoding over IP + false + + + + + + How long to delay we must have at minimum between two consecutive MODBUS transactions. In milliseconds. + + 60 + + + + How long to wait to before trying to establish a new connection after the previous one has been + disconnected. In milliseconds. + 0 + true + + + + How many times we try to establish the connection. Should be at least 1. + 1 + true + + + + Connection warm-up time. Additional time which is spent on preparing connection which should be spent + waiting while end device is getting ready to answer first modbus call. In milliseconds. + 0 + true + + + + The connection is kept open at least the time specified here. Value of zero means that connection is + disconnected after every MODBUS transaction. In milliseconds. + 0 + true + + + + The maximum time that is waited when establishing the connection. Value of zero means that system/OS + default is respected. In milliseconds. + 10000 + true + + + + diff --git a/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/thing-data.xml b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/thing-data.xml new file mode 100644 index 000000000000..42df53a72823 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/thing-data.xml @@ -0,0 +1,150 @@ + + + + + + + + + Data thing extracts values from binary data received from Modbus slave. Similarly, it is responsible of + translating openHAB commands to Modbus write requests + + + + + + + + + + + + + + + + + + +
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.
  • +
+ ]]> +
+
+ + +
Use "default" to communicate that no transformation is done and value should be passed as is. +
Use SERVICENAME(ARG) or SERVICENAME:ARG to use transformation service. +
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]]>
+ default +
+ + +
With registers all value types are applicable.]]>
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + 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 a register using X.Y format where X is the register and Y is the bit (0 refers to least significant bit). + ]]>
+
+ + + +
+ Coil uses function code (FC) FC05 or FC15. Holding register uses FC06 or FC16. See writeMultipleEvenWithSingleRegisterOrCoil parameter.]]>
+ + + + +
+ + +
Use "default" to communicate that no transformation is done and value should be passed as is. +
Use SERVICENAME(ARG) or SERVICENAME:ARG to use transformation service. +
Any other value than the above types will be interpreted as static text, in which case the actual content of the command +
You can chain many transformations with ∩, for example SERVICE1:ARG1∩SERVICE2:ARG2 + value is ignored.]]>
+ default +
+ + +
Negative integers are encoded with two's complement, while positive integers are encoded as is. + ]]> +
+ + + + + + + + + + +
+ + + false + +
If false, FC6/FC5 are used with single register and single coil, respectively.]]>
+
+ + + 3 + Number of tries when writing data, if some of the writes fail. For single try, enter 1. + + + + 1000 + Interval to update unchanged values. Normally unchanged values are not updated. In milliseconds. + true + +
+
+
diff --git a/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 000000000000..c44d0b4db467 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,66 @@ + + + + Switch + + Switch item channel + + + Contact + + Contact item channel + + + DateTime + + DateTime item channel + + + Dimmer + + Dimmer item channel + + + Rollershutter + + Rollershutter item channel + + + String + + String item channel + + + Number + + Number item channel + + + + DateTime + + Date of last read + + + + DateTime + + Date of last read error + + + + DateTime + + Date of last write + + + + DateTime + + Date of last write error + + + diff --git a/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/AtomicStampedKeyValueTest.java b/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/AtomicStampedKeyValueTest.java new file mode 100644 index 000000000000..2dc3e7236de1 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/AtomicStampedKeyValueTest.java @@ -0,0 +1,172 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class AtomicStampedKeyValueTest { + + @Test + public void testInitWithNullValue() { + assertThrows(NullPointerException.class, () -> new AtomicStampedValue<>(0, null)); + } + + @Test + public void testGetters() { + Object val = new Object(); + AtomicStampedValue keyValue = new AtomicStampedValue<>(42L, val); + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(equalTo(val))); + } + + @Test + public void testUpdateWithSameStamp() { + Object val = new Object(); + AtomicStampedValue keyValue = new AtomicStampedValue<>(42L, val); + keyValue.update(42L, new Object()); + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(not(equalTo(val)))); + } + + @Test + public void testUpdateWithDifferentStamp() { + Object val = new Object(); + AtomicStampedValue keyValue = new AtomicStampedValue<>(42L, val); + keyValue.update(-99L, new Object()); + assertThat(keyValue.getStamp(), is(equalTo(-99L))); + assertThat(keyValue.getValue(), is(not(equalTo(val)))); + } + + @Test + public void testCopy() { + Object val = new Object(); + AtomicStampedValue keyValue = new AtomicStampedValue<>(42L, val); + AtomicStampedValue copy = keyValue.copy(); + + // unchanged + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(equalTo(val))); + + // data matches + assertThat(keyValue.getStamp(), is(equalTo(copy.getStamp()))); + assertThat(keyValue.getValue(), is(equalTo(copy.getValue()))); + + // after update they live life of their own + Object val2 = new Object(); + copy.update(-99L, val2); + + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(equalTo(val))); + + assertThat(copy.getStamp(), is(equalTo(-99L))); + assertThat(copy.getValue(), is(equalTo(val2))); + } + + /** + * instance(stamp=x).copyIfStampAfter(x) + */ + @Test + public void testCopyIfStampAfterEqual() { + Object val = new Object(); + AtomicStampedValue keyValue = new AtomicStampedValue<>(42L, val); + AtomicStampedValue copy = keyValue.copyIfStampAfter(42L); + + // keyValue unchanged + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(equalTo(val))); + + // data matches + assertThat(keyValue.getStamp(), is(equalTo(copy.getStamp()))); + assertThat(keyValue.getValue(), is(equalTo(copy.getValue()))); + + // after update they live life of their own + Object val2 = new Object(); + copy.update(-99L, val2); + + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(equalTo(val))); + + assertThat(copy.getStamp(), is(equalTo(-99L))); + assertThat(copy.getValue(), is(equalTo(val2))); + } + + /** + * instance(stamp=x-1).copyIfStampAfter(x) + */ + @Test + public void testCopyIfStampAfterTooOld() { + Object val = new Object(); + AtomicStampedValue keyValue = new AtomicStampedValue<>(42L, val); + AtomicStampedValue copy = keyValue.copyIfStampAfter(43L); + + // keyValue unchanged + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(equalTo(val))); + + // copy is null + assertThat(copy, is(nullValue())); + } + + /** + * instance(stamp=x).copyIfStampAfter(x-1) + */ + @Test + public void testCopyIfStampAfterFresh() { + Object val = new Object(); + AtomicStampedValue keyValue = new AtomicStampedValue<>(42L, val); + AtomicStampedValue copy = keyValue.copyIfStampAfter(41L); + + // keyValue unchanged + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(equalTo(val))); + + // data matches + assertThat(keyValue.getStamp(), is(equalTo(copy.getStamp()))); + assertThat(keyValue.getValue(), is(equalTo(copy.getValue()))); + + // after update they live life of their own + Object val2 = new Object(); + copy.update(-99L, val2); + + assertThat(keyValue.getStamp(), is(equalTo(42L))); + assertThat(keyValue.getValue(), is(equalTo(val))); + + assertThat(copy.getStamp(), is(equalTo(-99L))); + assertThat(copy.getValue(), is(equalTo(val2))); + } + + @Test + public void testCompare() { + // equal, smaller, larger + assertThat(AtomicStampedValue.compare(new AtomicStampedValue<>(42L, ""), new AtomicStampedValue<>(42L, "")), + is(equalTo(0))); + assertThat(AtomicStampedValue.compare(new AtomicStampedValue<>(41L, ""), new AtomicStampedValue<>(42L, "")), + is(equalTo(-1))); + assertThat(AtomicStampedValue.compare(new AtomicStampedValue<>(42L, ""), new AtomicStampedValue<>(41L, "")), + is(equalTo(1))); + + // Nulls come first + assertThat(AtomicStampedValue.compare(null, new AtomicStampedValue<>(42L, "")), is(equalTo(-1))); + assertThat(AtomicStampedValue.compare(new AtomicStampedValue<>(42L, ""), null), is(equalTo(1))); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/CascadedValueTransformationImplTest.java b/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/CascadedValueTransformationImplTest.java new file mode 100644 index 000000000000..ca51e9fddfc6 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/CascadedValueTransformationImplTest.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.osgi.framework.BundleContext; + +/** + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class CascadedValueTransformationImplTest { + + @Test + public void testTransformation() { + CascadedValueTransformationImpl transformation = new CascadedValueTransformationImpl( + "REGEX(myregex:foo(.*))∩REG_(EX(myregex:foo(.*))∩JIHAA:test"); + assertEquals(3, transformation.getTransformations().size()); + assertEquals("REGEX", transformation.getTransformations().get(0).transformationServiceName); + assertEquals("myregex:foo(.*)", transformation.getTransformations().get(0).transformationServiceParam); + + assertEquals("REG_", transformation.getTransformations().get(1).transformationServiceName); + assertEquals("EX(myregex:foo(.*)", transformation.getTransformations().get(1).transformationServiceParam); + + assertEquals("JIHAA", transformation.getTransformations().get(2).transformationServiceName); + assertEquals("test", transformation.getTransformations().get(2).transformationServiceParam); + + assertEquals(3, transformation.toString().split("∩").length); + } + + @Test + public void testTransformationEmpty() { + CascadedValueTransformationImpl transformation = new CascadedValueTransformationImpl(""); + assertFalse(transformation.isIdentityTransform()); + assertEquals("", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } + + @Test + public void testTransformationNull() { + CascadedValueTransformationImpl transformation = new CascadedValueTransformationImpl(null); + assertFalse(transformation.isIdentityTransform()); + assertEquals("", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } + + @Test + public void testTransformationDefault() { + CascadedValueTransformationImpl transformation = new CascadedValueTransformationImpl("deFault"); + assertTrue(transformation.isIdentityTransform()); + assertEquals("xx", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } + + @Test + public void testTransformationDefaultChained() { + CascadedValueTransformationImpl transformation = new CascadedValueTransformationImpl("deFault∩DEFAULT∩default"); + assertTrue(transformation.isIdentityTransform()); + assertEquals("xx", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } + + @Test + public void testTransformationDefaultChainedWithStatic() { + CascadedValueTransformationImpl transformation = new CascadedValueTransformationImpl( + "deFault∩DEFAULT∩default∩static"); + assertFalse(transformation.isIdentityTransform()); + assertEquals("static", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/SingleValueTransformationTest.java b/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/SingleValueTransformationTest.java new file mode 100644 index 000000000000..6f3172388501 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/SingleValueTransformationTest.java @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2010-2024 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.sbus.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.osgi.framework.BundleContext; + +/** + * @author Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class SingleValueTransformationTest { + + @Test + public void testTransformationOldStyle() { + SingleValueTransformation transformation = new SingleValueTransformation("REGEX(myregex:foo(.*))"); + assertEquals("REGEX", transformation.transformationServiceName); + assertEquals("myregex:foo(.*)", transformation.transformationServiceParam); + } + + @Test + public void testTransformationOldStyle2() { + SingleValueTransformation transformation = new SingleValueTransformation("REG_(EX(myregex:foo(.*))"); + assertEquals("REG_", transformation.transformationServiceName); + assertEquals("EX(myregex:foo(.*)", transformation.transformationServiceParam); + } + + @Test + public void testTransformationNewStyle() { + SingleValueTransformation transformation = new SingleValueTransformation("REGEX:myregex(.*)"); + assertEquals("REGEX", transformation.transformationServiceName); + assertEquals("myregex(.*)", transformation.transformationServiceParam); + } + + @Test + public void testTransformationNewStyle2() { + SingleValueTransformation transformation = new SingleValueTransformation("REGEX::myregex(.*)"); + assertEquals("REGEX", transformation.transformationServiceName); + assertEquals(":myregex(.*)", transformation.transformationServiceParam); + } + + @Test + public void testTransformationEmpty() { + SingleValueTransformation transformation = new SingleValueTransformation(""); + assertFalse(transformation.isIdentityTransform()); + assertEquals("", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } + + @Test + public void testTransformationNull() { + SingleValueTransformation transformation = new SingleValueTransformation(null); + assertFalse(transformation.isIdentityTransform()); + assertEquals("", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } + + @Test + public void testTransformationDefault() { + SingleValueTransformation transformation = new SingleValueTransformation("deFault"); + assertTrue(transformation.isIdentityTransform()); + assertEquals("xx", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } + + @Test + public void testTransformationDefaultChainedWithStatic() { + SingleValueTransformation transformation = new SingleValueTransformation("static"); + assertFalse(transformation.isIdentityTransform()); + assertEquals("static", transformation.transform(Mockito.mock(BundleContext.class), "xx")); + } +} diff --git a/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/profiles/ModbusGainOffsetProfileTest.java b/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/profiles/ModbusGainOffsetProfileTest.java new file mode 100644 index 000000000000..c4ff71827c10 --- /dev/null +++ b/bundles/org.openhab.binding.sbus/src/test/java/org/openhab/binding/sbus/internal/profiles/ModbusGainOffsetProfileTest.java @@ -0,0 +1,296 @@ +/** + * Copyright (c) 2010-2024 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.sbus.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 Ciprian Pascu - Initial contribution + */ +@NonNullByDefault +public class ModbusGainOffsetProfileTest { + + 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 pre-gain-offset=50, gain=2K + // e.g. (handler) 3 K <---> (item) 106K^2 with pre-gain-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) + + ); + } + + 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 preGainOffset, String gain, Object updateFromHandlerObj, + Object expectedUpdateTowardsItemObj) { + testOnUpdateFromHandlerGeneric(preGainOffset, gain, updateFromHandlerObj, expectedUpdateTowardsItemObj, true); + } + + /** + * + * Test profile behaviour when handler sends command + * + */ + @ParameterizedTest + @MethodSource({ "provideArgsForBoth", "provideAdditionalArgsForStateUpdateFromHandler" }) + public void testOnCommandFromHandler(String preGainOffset, String gain, Object updateFromHandlerObj, + Object expectedUpdateTowardsItemObj) { + // UNDEF is not a command, cannot be sent by handler + assumeTrue(updateFromHandlerObj != UnDefType.UNDEF); + testOnUpdateFromHandlerGeneric(preGainOffset, gain, updateFromHandlerObj, expectedUpdateTowardsItemObj, false); + } + + /** + * + * Test profile behaviour when handler updates the state + * + * @param preGainOffset profile pre-gain-offset 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 preGainOffset, String gain, Object updateFromHandlerObj, + Object expectedUpdateTowardsItemObj, boolean stateUpdateFromHandler) { + ProfileCallback callback = mock(ProfileCallback.class); + ModbusGainOffsetProfile profile = createProfile(callback, gain, preGainOffset); + + final Type actualStateUpdateTowardsItem; + if (stateUpdateFromHandler) { + final State updateFromHandler; + if (updateFromHandlerObj instanceof String str) { + updateFromHandler = new QuantityType(str); + } 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 str) { + updateFromHandler = new QuantityType(str); + } 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 s) ? new QuantityType(s) + : (Type) expectedUpdateTowardsItemObj; + assertEquals(expectedStateUpdateTowardsItem, actualStateUpdateTowardsItem); + verifyNoMoreInteractions(callback); + } + + 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 behavior when item receives command + * + * @param preGainOffset profile pre-gain-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 preGainOffset, String gain, + @Nullable Object expectedCommandTowardsHandlerObj, Object commandFromItemObj) { + assumeFalse(commandFromItemObj.equals(UnDefType.UNDEF)); + ProfileCallback callback = mock(ProfileCallback.class); + ModbusGainOffsetProfile profile = createProfile(callback, gain, preGainOffset); + + Command commandFromItem = (commandFromItemObj instanceof String str) ? new QuantityType(str) + : (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 str) + ? new QuantityType(str) + : (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() { + // preGainOffset 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 preGainOffset) { + ProfileCallback callback = mock(ProfileCallback.class); + ModbusGainOffsetProfile p = createProfile(callback, "1", preGainOffset); + 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/bundles/org.openhab.voice.googlestt/pom.xml b/bundles/org.openhab.voice.googlestt/pom.xml index a94726b9e6be..e5bfafee090a 100644 --- a/bundles/org.openhab.voice.googlestt/pom.xml +++ b/bundles/org.openhab.voice.googlestt/pom.xml @@ -97,67 +97,67 @@ io.grpc grpc-api - 1.53.0 + 1.60.1 compile io.grpc grpc-protobuf - 1.53.0 + 1.60.1 compile io.grpc grpc-protobuf-lite - 1.53.0 + 1.60.1 compile io.grpc grpc-alts - 1.53.0 + 1.60.1 compile io.grpc grpc-core - 1.53.0 + 1.60.1 compile io.grpc grpc-grpclb - 1.53.0 + 1.60.1 compile io.grpc grpc-auth - 1.53.0 + 1.60.1 compile io.grpc grpc-context - 1.53.0 + 1.60.1 compile io.grpc grpc-netty-shaded - 1.53.0 + 1.60.1 compile io.grpc grpc-xds - 1.53.0 + 1.60.1 compile io.grpc grpc-services - 1.53.0 + 1.60.1 compile diff --git a/bundles/pom.xml b/bundles/pom.xml index 14f345d1dfb2..514298c115a0 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -334,6 +334,7 @@ org.openhab.binding.sagercaster org.openhab.binding.samsungtv org.openhab.binding.satel + org.openhab.binding.sbus org.openhab.binding.semsportal org.openhab.binding.senechome org.openhab.binding.seneye diff --git a/features/openhab-addons/pom.xml b/features/openhab-addons/pom.xml index 62ce87ce3d5c..4ed904b67856 100644 --- a/features/openhab-addons/pom.xml +++ b/features/openhab-addons/pom.xml @@ -48,6 +48,7 @@ + @@ -73,6 +74,7 @@ openhab-binding-bluetooth openhab-binding-modbus + openhab-binding-sbus openhab-binding-mqtt diff --git a/features/openhab-addons/src/main/resources/footer.xml b/features/openhab-addons/src/main/resources/footer.xml index dd1acb32522e..752448d23eee 100644 --- a/features/openhab-addons/src/main/resources/footer.xml +++ b/features/openhab-addons/src/main/resources/footer.xml @@ -38,5 +38,9 @@ mvn:org.openhab.addons.bundles/org.openhab.binding.modbus.studer/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.modbus.sunspec/${project.version} - + + openhab-runtime-base + openhab-transport-sbus + mvn:org.openhab.addons.bundles/org.openhab.binding.sbus/${project.version} + diff --git a/tools/static-code-analysis/checkstyle/ruleset.properties b/tools/static-code-analysis/checkstyle/ruleset.properties index 3c4755ae4de1..e4e1555b6a04 100644 --- a/tools/static-code-analysis/checkstyle/ruleset.properties +++ b/tools/static-code-analysis/checkstyle/ruleset.properties @@ -4,4 +4,4 @@ checkstyle.forbiddenPackageUsageCheck.forbiddenPackages=com.fazecast.jSerialComm checkstyle.forbiddenPackageUsageCheck.exceptions= checkstyle.requiredFilesCheck.files=pom.xml checkstyle.karafAddonFeatureCheck.featureNameMappings=-transform-:-transformation-,-io-:-misc- -checkstyle.karafAddonFeatureCheck.excludeAddonPatterns=org.openhab.persistence.jdbc.*,org.openhab.binding.modbus.* +checkstyle.karafAddonFeatureCheck.excludeAddonPatterns=org.openhab.persistence.jdbc.*,org.openhab.binding.modbus.*,org.openhab.binding.sbus.*