[serial] Add TCP and binary protocol support (#16205)

* Added code to convert binary data into a hexa-decimal 'charset'

Signed-off-by: Roland Tapken <dev@cybso.de>
This commit is contained in:
Roland Tapken
2025-09-07 21:59:23 +02:00
committed by GitHub
parent a5fcfe40c4
commit a5381d1fed
21 changed files with 1730 additions and 279 deletions
+63 -24
View File
@@ -1,15 +1,15 @@
# Serial Binding
The Serial binding allows openHAB to communicate over serial ports attached to the openHAB server.
The Serial binding allows openHAB to communicate over serial ports attached to the openHAB server and TCP sockets.
The binding allows data to be sent and received from a serial port.
The binding allows data to be sent and received from a serial port or TCP socket.
The binding does not support any particular serial protocols and simply reads what is available and sends what is provided.
The binding can be used to communicate with simple serial devices for which a dedicated openHAB binding does not exist.
## Overview
The Serial binding represents a serial port as a bridge thing and data matching defined patterns as things connected to the bridge.
The Serial binding represents a serial port as a bridge thing, a TCP socket as an alternative bridge thing, and data matching defined patterns as things connected to the bridge.
### Serial Bridge
@@ -18,12 +18,25 @@ A Serial Bridge thing (`serialBridge`) represents a single serial port.
The bridge supports a String channel which is set to the currently received data from the serial port.
Sending a command to this channel sends the command as a string to the serial port.
To communicate with protocols requiring binary data it is possible to select a special 'HEX' charset.
This results into all binary data being converted into space-separacted hexadecimal strings that can be parsed using regular expressions.
In this mode, the input data is also expected to be encoded as hexadecimal characters.
The bridge also supports a String channel which encodes the received data as the string representation of a RawType to handle data that is
not supported by the REST interface.
A command sent to this channel will only be sent to the serial port if it is encoded as the string representation of a RawType.
A trigger channel is also provided which triggers when data is received.
### TCP Bridge
The TCP bridge is an alternative implementation of the Serial Bridge that uses TCP sockets instead of serial ports.
### TCP Server bridge
The TCP Server Bridge is similar to the _TCP Bridge_ but waits for an incoming connection on a given port.
Currently, only one connection per bridge is allowed.
### Serial Device
A Serial Device thing (`serialDevice`) can be used to represent data matching a defined pattern as a device.
@@ -44,14 +57,38 @@ When using a Serial Device the expectation is that the received data for each de
The configuration for the `serialBridge` consists of the following parameters:
| Parameter | Description |
| ---------- | --------------------------------------------------------------------------------------- |
| serialPort | The serial port to use (e.g. Linux: /dev/ttyUSB0, Windows: COM1) (mandatory) |
| baudRate | Set the baud rate. Valid values: 4800, 9600, 19200, 38400, 57600, 115200 (default 9600) |
| dataBits | Set the data bits. Valid values: 5, 6, 7, 8 (default 8) |
| parity | Set the parity. Valid values: N(one), O(dd), E(even), M(ark), S(pace) (default N) |
| stopBits | Set the stop bits. Valid values: 1, 1.5, 2 (default 1) |
| charset | The charset to use for converting between bytes and string (e.g. UTF-8,ISO-8859-1) |
| Parameter | Description |
|------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| serialPort | The serial port to use (e.g. Linux: /dev/ttyUSB0, Windows: COM1) (mandatory) |
| baudRate | Set the baud rate. Valid values: 4800, 9600, 19200, 38400, 57600, 115200 (default 9600) |
| dataBits | Set the data bits. Valid values: 5, 6, 7, 8 (default 8) |
| parity | Set the parity. Valid values: N(one), O(dd), E(even), M(ark), S(pace) (default N) |
| stopBits | Set the stop bits. Valid values: 1, 1.5, 2 (default 1) |
| charset | The charset to use for converting between bytes and string (e.g. UTF-8,ISO-8859-1). Enter 'HEX' to convert binary data into hexadecimal strings separated by space. |
| eolPattern | In charset=HEX mode, a regular expression is required to match the binaries equivalent of an 'End of line' character. For example, '\bFF' would match a byte value of 255 as end of the current response. |
The configuration for the `tcpBridge` consists of the following parameters:
| Parameter | Description |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| address | The IP or hostname to connect to. |
| port | The number of the TCP port to connect to |
| timeout | Socket timeout in seconds (0 = no timeout) |
| keepAlive | Enable socket keep-alive |
| reconnectInterval | Interval in seconds for automatic reconnect after connection failure (defaults to 10 seconds) |
| charset | The charset to use for converting between bytes and string (e.g. UTF-8,ISO-8859-1). Enter 'HEX' to convert binary data into hexadecimal strings separated by space. |
| eolPattern | In charset=HEX mode, a regular expression is required to match the binaries equivalent of an 'End of line' character. For example, '\bFF' would match a byte value of 255 as end of the current response. |
The configuration for the `tcpServerBridge` consists of the following parameters:
| Parameter | Description |
|-------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| port | The number of the TCP port to listen to |
| bindAddress | The IP to bind to. |
| timeout | Socket timeout in seconds (0 = no timeout) |
| keepAlive | Enable socket keep-alive |
| charset | The charset to use for converting between bytes and string (e.g. UTF-8,ISO-8859-1). Enter 'HEX' to convert binary data into hexadecimal strings separated by space. |
| eolPattern | In charset=HEX mode, a regular expression is required to match the binaries equivalent of an 'End of line' character. For example, '\bFF' would match a byte value of 255 as end of the current response. |
The configuration for the `serialDevice` consists of the following parameters:
@@ -79,20 +116,22 @@ The channels supported by the `serialDevice` are:
| `switch` | Switch | Channel for receiving commands from a Switch. The channel can be configured to apply a transform on the received data to convert to the channel state. The channel can be configured to apply a simple mapping for the ON and OFF commands. |
| `rollershutter` | Rollershutter | Channel for receiving commands from a Rollershutter. The channel can be configured to apply a transform on the received data to convert to the channel state. The channel can be configured to apply a simple mapping for the UP, DOWN and STOP commands. |
The configuration for the `serialBridge` channels consists of the following parameters:
The configuration for the `serialDevice` channels consists of the following parameters:
| Parameter | Description | Supported Channels |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `stateTransformation` | One or more transformation (concatenated with `∩`) used to convert device data to channel state, e.g. `REGEX(.*?STATE=(.*?);.*)` | string, number, dimmer, switch, rollershutter |
| `commandTransformation` | One or more transformation (concatenated with `∩`) used to convert command to device data, e.g. `JS(device.js)` | string, number, dimmer, switch, rollershutter |
| `commandFormat` | Format string applied to the command before transform, e.g. `ID=671;COMMAND=%s` | string, number, dimmer, rollershutter |
| `onValue` | Send this value when receiving an ON command | switch, dimmer |
| `offValue` | Send this value when receiving an OFF command | switch, dimmer |
| `increaseValue` | Send this value when receiving an INCREASE command | dimmer |
| `decreaseValue` | Send this value when receiving a DECREASE command | dimmer |
| `upValue` | Send this value when receiving an UP command | rollershutter |
| `downValue` | Send this value when receiving a DOWN command | rollershutter |
| `stopValue` | Send this value when receiving a STOP command | rollershutter |
| Parameter | Description | Supported Channels |
| ----------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------| --------------------------------------------- |
| `stateTransformation` | One or more transformation (concatenated with `∩`) used to convert device data to channel state, e.g. `REGEX(.*?STATE=(.*?);.*)` | string, number, dimmer, switch, rollershutter |
| `commandTransformation` | One or more transformation (concatenated with `∩`) used to convert command to device data, e.g. `JS(device.js)` | string, number, dimmer, switch, rollershutter |
| `commandFormat` | Format string applied to the command before transform, e.g. `ID=671;COMMAND=%s` | string, number, dimmer, rollershutter |
| `onValue` | Send this value when receiving an ON command | switch, dimmer |
| `offValue` | Send this value when receiving an OFF command | switch, dimmer |
| `increaseValue` | Send this value when receiving an INCREASE command | dimmer |
| `decreaseValue` | Send this value when receiving a DECREASE command | dimmer |
| `upValue` | Send this value when receiving an UP command | rollershutter |
| `downValue` | Send this value when receiving a DOWN command | rollershutter |
| `stopValue` | Send this value when receiving a STOP command | rollershutter |
| `refreshCommand` | Command that should be issued to receive the current channel state | string, number, dimmer, switch, rollershutter |
| `refreshInterval` | A value greater than the default of 0 will trigger a periodical "Refresh Command" for services that do not automatically push changed values to the client | string, number, dimmer, switch, rollershutter |
Transformations can be chained in the UI by listing each transformation on a separate line, or by separating them with the mathematical intersection character "∩".
Transformations are defined using this syntax: `TYPE(FUNCTION)`, e.g.: `JSONPATH($.path)`.
@@ -27,7 +27,9 @@ public class SerialBindingConstants {
private static final String BINDING_ID = "serial";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "serialBridge");
public static final ThingTypeUID THING_TYPE_SERIAL_BRIDGE = new ThingTypeUID(BINDING_ID, "serialBridge");
public static final ThingTypeUID THING_TYPE_TCP_BRIDGE = new ThingTypeUID(BINDING_ID, "tcpBridge");
public static final ThingTypeUID THING_TYPE_TCP_SERVER_BRIDGE = new ThingTypeUID(BINDING_ID, "tcpServerBridge");
public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "serialDevice");
// List of all Channel ids
@@ -12,8 +12,7 @@
*/
package org.openhab.binding.serial.internal;
import static org.openhab.binding.serial.internal.SerialBindingConstants.THING_TYPE_BRIDGE;
import static org.openhab.binding.serial.internal.SerialBindingConstants.THING_TYPE_DEVICE;
import static org.openhab.binding.serial.internal.SerialBindingConstants.*;
import java.util.Set;
@@ -21,6 +20,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.serial.internal.handler.SerialBridgeHandler;
import org.openhab.binding.serial.internal.handler.SerialDeviceHandler;
import org.openhab.binding.serial.internal.handler.TcpBridgeHandler;
import org.openhab.binding.serial.internal.handler.TcpServerBridgeHandler;
import org.openhab.core.io.transport.serial.SerialPortManager;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
@@ -42,7 +43,8 @@ import org.osgi.service.component.annotations.Reference;
@Component(configurationPid = "binding.serial", service = ThingHandlerFactory.class)
public class SerialHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE);
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SERIAL_BRIDGE,
THING_TYPE_TCP_BRIDGE, THING_TYPE_TCP_SERVER_BRIDGE, THING_TYPE_DEVICE);
private final SerialPortManager serialPortManager;
@@ -60,8 +62,12 @@ public class SerialHandlerFactory extends BaseThingHandlerFactory {
protected @Nullable ThingHandler createHandler(final Thing thing) {
final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_BRIDGE.equals(thingTypeUID)) {
if (THING_TYPE_SERIAL_BRIDGE.equals(thingTypeUID)) {
return new SerialBridgeHandler((Bridge) thing, serialPortManager);
} else if (THING_TYPE_TCP_BRIDGE.equals(thingTypeUID)) {
return new TcpBridgeHandler((Bridge) thing);
} else if (THING_TYPE_TCP_SERVER_BRIDGE.equals(thingTypeUID)) {
return new TcpServerBridgeHandler((Bridge) thing);
} else if (THING_TYPE_DEVICE.equals(thingTypeUID)) {
return new SerialDeviceHandler(thing);
}
@@ -21,6 +21,7 @@ import org.eclipse.jdt.annotation.Nullable;
* Class describing the channel user configuration
*
* @author Mike Major - Initial contribution
* @author Roland Tapken - Added refreshValue and refreshInterval
*/
@NonNullByDefault
public class ChannelConfig {
@@ -73,4 +74,19 @@ public class ChannelConfig {
* Decrease value
*/
public @Nullable String decreaseValue;
/**
* Command for refesh command
*/
public @Nullable String refreshValue;
/**
* Automatic refresh interval.
*
* This value is only required if the peer has to send the “Refresh Value”
* command regularly in order to return the current properties. It is not
* required if the peer automatically forwards changed values to its
* clients.
*/
public int refreshInterval = 0;
}
@@ -26,6 +26,7 @@ import org.slf4j.LoggerFactory;
* the ability to transform the device data into the channel state.
*
* @author Mike Major - Initial contribution
* @author Roland Tapken - Added refresh value and refresh interval
*/
@NonNullByDefault
public abstract class DeviceChannel {
@@ -74,7 +75,7 @@ public abstract class DeviceChannel {
* @param data the command to transform
* @return the transformed data if the transform produced a result.
*/
protected Optional<String> transformCommand(final String data) {
public Optional<String> transformCommand(final String data) {
return commandTransform.apply(data);
}
@@ -102,4 +103,16 @@ public abstract class DeviceChannel {
return data;
}
public String getRefreshValue() {
String command = config.refreshValue;
if (command == null) {
return "";
}
return command;
}
public int getRefreshInterval() {
return config.refreshInterval;
}
}
@@ -32,7 +32,6 @@ public class DeviceChannelFactory {
/**
* Create a {@link DeviceChannel} for the channel type
*
* @param valueTransformationProvider the transformation provider
* @param channelConfig the channel configuration
* @param channelTypeID the channel type id
* @return the DeviceChannel or null if the channel type is not supported.
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2010-2025 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.serial.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Class describing the serial bridge user configuration
*
* @author Roland Tapken - Initial contribution
*/
@NonNullByDefault
public class CommonBridgeConfiguration {
/**
* Charset
*/
public @Nullable String charset;
/**
* EOL regex for charset = HEX
*/
public @Nullable String eolPattern;
@Override
public String toString() {
return "CommonBridgeConfiguration [charset=" + charset + ", eolPattern=" + eolPattern + "]";
}
}
@@ -0,0 +1,347 @@
/*
* Copyright (c) 2010-2025 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.serial.internal.handler;
import static org.openhab.binding.serial.internal.SerialBindingConstants.*;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.StandardCharsets;
import java.nio.charset.UnsupportedCharsetException;
import java.util.Base64;
import java.util.HexFormat;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.*;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link CommonBridgeHandler} is responsible for handling commands, which
* are sent to one of the channels.
*
* @author Mike Major - Initial contribution
* @author Roland Tapken - Added code for charset=HEX and channel refresh
*/
@NonNullByDefault
public abstract class CommonBridgeHandler extends BaseBridgeHandler {
protected final Logger logger = LoggerFactory.getLogger(CommonBridgeHandler.class);
protected CommonBridgeConfiguration config = new CommonBridgeConfiguration();
protected @Nullable InputStream inputStream;
protected @Nullable OutputStream outputStream;
private Charset charset = StandardCharsets.UTF_8;
private boolean binaryHexData = false;
private @Nullable Pattern eolPattern;
private @Nullable String lastValue;
protected final AtomicBoolean readerActive = new AtomicBoolean(false);
@Nullable
private ScheduledFuture<?> reader;
public CommonBridgeHandler(final Bridge bridge) {
super(bridge);
}
@Override
public void handleCommand(final ChannelUID channelUID, final Command command) {
if (command instanceof RefreshType) {
final String lastValue = this.lastValue;
if (lastValue != null) {
refresh(channelUID.getId(), lastValue);
}
} else {
switch (channelUID.getId()) {
case STRING_CHANNEL -> writeString(command.toFullString(), false);
case BINARY_CHANNEL -> writeString(command.toFullString(), true);
default -> {
}
}
}
}
public boolean checkAndProcessConfiguration(CommonBridgeConfiguration config) {
this.config = config;
try {
String strCharset = config.charset;
if (strCharset != null) {
if ("hex".equalsIgnoreCase(strCharset)) {
binaryHexData = true;
logger.debug("{} converting to hex", getLogPrefix());
} else {
binaryHexData = false;
Charset charset = Charset.forName(strCharset);
logger.debug("{} charset '{}' set", getLogPrefix(), charset);
this.charset = charset;
}
}
} catch (final IllegalCharsetNameException | UnsupportedCharsetException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Invalid charset");
return false;
}
String eolPatternStr = config.eolPattern;
this.eolPattern = null;
if (eolPatternStr != null && !eolPatternStr.isBlank()) {
try {
Pattern eolPattern = Pattern.compile(eolPatternStr, Pattern.CASE_INSENSITIVE);
this.eolPattern = eolPattern;
logger.debug("{} eolPattern '{}' set", getLogPrefix(), eolPattern);
} catch (IllegalArgumentException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
"Invalid EOL sequence");
return false;
}
} else if (binaryHexData) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
"EOL pattern required for charset = HEX");
return false;
}
return true;
}
protected void disposeReader() {
final InputStream inputStream = this.inputStream;
this.inputStream = null;
if (inputStream != null) {
try {
inputStream.close();
} catch (final IOException e) {
logger.debug("Error while closing the input stream: {}", e.getMessage());
}
}
final OutputStream outputStream = this.outputStream;
this.outputStream = null;
if (outputStream != null) {
try {
outputStream.close();
} catch (final IOException e) {
logger.debug("Error while closing the output stream: {}", e.getMessage());
}
}
readerActive.set(false);
final ScheduledFuture<?> reader = this.reader;
this.reader = null;
if (reader != null) {
reader.cancel(false);
}
}
@Override
public void dispose() {
disposeReader();
lastValue = null;
}
/**
* Sends a string to the serial port.
*
* @param string the string to send
*/
public void writeString(final String string) {
writeString(string, false);
}
/**
* Refreshes the channel with the last received data
*
* @param channelUID the channel to refresh
* @param data the data to use
*/
protected void refresh(final String channelUID, final String data) {
if (!isLinked(channelUID)) {
return;
}
switch (channelUID) {
case STRING_CHANNEL -> updateState(channelUID, new StringType(data));
case BINARY_CHANNEL -> {
String sb = "data:" + RawType.DEFAULT_MIME_TYPE + ";base64,"
+ Base64.getEncoder().encodeToString(data.getBytes(charset));
updateState(channelUID, new StringType(sb));
}
default -> {
}
}
}
protected void receiveAndProcessNow() {
if (readerActive.compareAndSet(false, true)) {
reader = scheduler.schedule(() -> receiveAndProcess(new StringBuilder(), true), 0, TimeUnit.MILLISECONDS);
}
}
/**
* Read from the serial port and process the data
*
* @param sb the string builder to receive the data
* @param firstAttempt indicates if this is the first read attempt without waiting
*/
protected void receiveAndProcess(final StringBuilder sb, final boolean firstAttempt) {
final InputStream inputStream = this.inputStream;
if (inputStream == null) {
readerActive.set(false);
return;
}
try {
synchronized (inputStream) {
if (firstAttempt || inputStream.available() > 0) {
final byte[] readBuffer = new byte[20];
String line = "";
boolean binaryHexData = this.binaryHexData;
Pattern eolPattern = this.eolPattern;
Charset charset = this.charset;
// read data from serial device
while (inputStream.available() > 0) {
final int bytes = inputStream.read(readBuffer);
if (binaryHexData) {
for (int i = 0; i < bytes; i++) {
if (eolPattern == null) {
// Should not happen, but the code actually allows this to happen,
// so just make the compiler happy and suppress any warnings.
throw new IOException(
"Failed to parse input stream as HEX pattern: Parameter 'eolPattern' is null.");
}
line += String.format("%02X", readBuffer[i]);
if (eolPattern.matcher(line).find()) {
sb.append(line).append(System.lineSeparator());
line = "";
} else {
line += " ";
}
}
} else {
sb.append(new String(readBuffer, 0, bytes, charset));
}
}
line = line.trim();
if (!line.isEmpty()) {
sb.append(line);
}
// Add wait states around reading the stream, so that interrupted transmissions
// are merged
if (readerActive.get()) {
reader = scheduler.schedule(() -> receiveAndProcess(sb, false), 100, TimeUnit.MILLISECONDS);
}
} else {
final String result = sb.toString();
processInput(result);
result.lines().forEach(l -> getThing().getThings().forEach(t -> {
final SerialDeviceHandler device = (SerialDeviceHandler) t.getHandler();
if (device != null) {
device.handleData(l);
}
}));
lastValue = result;
if (readerActive.compareAndSet(true, false)) {
// Check we haven't received more data while processing
if (inputStream.available() > 0 && readerActive.compareAndSet(false, true)) {
reader = scheduler.schedule(() -> receiveAndProcess(new StringBuilder(), true), 0,
TimeUnit.MILLISECONDS);
}
}
}
}
} catch (final IOException e) {
logger.debug("Error reading from serial port: {}", e.getMessage(), e);
readerActive.set(false);
handleIOException(e);
}
}
protected void processInput(String result) {
}
/**
* Sends a string to the serial port.
*
* @param string the string to send
* @param isRawType the string should be handled as a RawType
*/
private void writeString(final String string, final boolean isRawType) {
final OutputStream outputStream = this.outputStream;
if (outputStream == null) {
return;
}
logger.debug("Writing '{}' to {}", string, getLogPrefix());
try {
synchronized (outputStream) {
// write string to serial port
if (isRawType) {
final RawType rt = RawType.valueOf(string);
outputStream.write(rt.getBytes());
} else if (binaryHexData) {
outputStream.write(parseHexString(string));
} else {
outputStream.write(string.getBytes(charset));
}
outputStream.flush();
}
} catch (final IOException e) {
logger.warn("Error writing '{}' to {}: {}", string, getLogPrefix(), e.getMessage());
handleIOException(e);
} catch (IllegalArgumentException e) {
logger.warn("Error writing '{}' to {}: {}", string, getLogPrefix(), e.getMessage());
}
}
private byte[] parseHexString(String input) {
input = input.replaceAll("\\s", "");
return HexFormat.of().parseHex(input);
}
protected void handleIOException(IOException e) {
// Only used in TcpBridgeHandler
}
protected abstract String getLogPrefix();
}
@@ -21,7 +21,7 @@ import org.eclipse.jdt.annotation.Nullable;
* @author Mike Major - Initial contribution
*/
@NonNullByDefault
public class SerialBridgeConfiguration {
public class SerialBridgeConfiguration extends CommonBridgeConfiguration {
/**
* Serial port name
*/
@@ -47,14 +47,10 @@ public class SerialBridgeConfiguration {
*/
public String stopBits = "1";
/**
* Charset
*/
public @Nullable String charset;
@Override
public String toString() {
return "SerialBridgeConfiguration [serialPort=" + serialPort + ", Baudrate=" + baudRate + ", Databits="
+ dataBits + ", Parity=" + parity + ", Stopbits=" + stopBits + ", charset=" + charset + "]";
+ dataBits + ", Parity=" + parity + ", Stopbits=" + stopBits + ", charset=" + charset + ", eolPattern="
+ eolPattern + "]";
}
}
@@ -12,21 +12,10 @@
*/
package org.openhab.binding.serial.internal.handler;
import static org.openhab.binding.serial.internal.SerialBindingConstants.BINARY_CHANNEL;
import static org.openhab.binding.serial.internal.SerialBindingConstants.STRING_CHANNEL;
import static org.openhab.binding.serial.internal.SerialBindingConstants.TRIGGER_CHANNEL;
import static org.openhab.binding.serial.internal.SerialBindingConstants.*;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.TooManyListenersException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -39,84 +28,36 @@ import org.openhab.core.io.transport.serial.SerialPortEventListener;
import org.openhab.core.io.transport.serial.SerialPortIdentifier;
import org.openhab.core.io.transport.serial.SerialPortManager;
import org.openhab.core.io.transport.serial.UnsupportedCommOperationException;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.CommonTriggerEvents;
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.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link SerialBridgeHandler} is responsible for handling commands, which
* are sent to one of the channels.
*
* @author Mike Major - Initial contribution
* @author Roland Tapken - Refactored common code into CommonBridgeHandler
*/
@NonNullByDefault
public class SerialBridgeHandler extends BaseBridgeHandler implements SerialPortEventListener {
private final Logger logger = LoggerFactory.getLogger(SerialBridgeHandler.class);
public class SerialBridgeHandler extends CommonBridgeHandler implements SerialPortEventListener {
private SerialBridgeConfiguration config = new SerialBridgeConfiguration();
private final SerialPortManager serialPortManager;
private @Nullable SerialPort serialPort;
private @Nullable InputStream inputStream;
private @Nullable OutputStream outputStream;
private Charset charset = StandardCharsets.UTF_8;
private @Nullable String lastValue;
private final AtomicBoolean readerActive = new AtomicBoolean(false);
private @Nullable ScheduledFuture<?> reader;
public SerialBridgeHandler(final Bridge bridge, final SerialPortManager serialPortManager) {
super(bridge);
this.serialPortManager = serialPortManager;
}
@Override
public void handleCommand(final ChannelUID channelUID, final Command command) {
if (command instanceof RefreshType) {
final String lastValue = this.lastValue;
if (lastValue != null) {
refresh(channelUID.getId(), lastValue);
}
} else {
switch (channelUID.getId()) {
case STRING_CHANNEL:
writeString(command.toFullString(), false);
break;
case BINARY_CHANNEL:
writeString(command.toFullString(), true);
break;
default:
break;
}
}
}
@Override
public void initialize() {
config = getConfigAs(SerialBridgeConfiguration.class);
try {
if (config.charset != null) {
charset = Charset.forName(config.charset);
}
logger.debug("Serial port '{}' charset '{}' set", config.serialPort, charset);
} catch (final IllegalCharsetNameException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Invalid charset");
if (!checkAndProcessConfiguration(config)) {
return;
}
@@ -163,6 +104,19 @@ public class SerialBridgeHandler extends BaseBridgeHandler implements SerialPort
}
}
@Override
protected void processInput(String result) {
if (isLinked(TRIGGER_CHANNEL)) {
triggerChannel(TRIGGER_CHANNEL, CommonTriggerEvents.PRESSED);
}
if (isLinked(STRING_CHANNEL)) {
refresh(STRING_CHANNEL, result);
}
if (isLinked(BINARY_CHANNEL)) {
refresh(BINARY_CHANNEL, result);
}
}
@Override
public void dispose() {
final SerialPort serialPort = this.serialPort;
@@ -172,172 +126,22 @@ public class SerialBridgeHandler extends BaseBridgeHandler implements SerialPort
this.serialPort = null;
}
final InputStream inputStream = this.inputStream;
if (inputStream != null) {
try {
inputStream.close();
} catch (final IOException e) {
logger.debug("Error while closing the input stream: {}", e.getMessage());
}
this.inputStream = null;
}
final OutputStream outputStream = this.outputStream;
if (outputStream != null) {
try {
outputStream.close();
} catch (final IOException e) {
logger.debug("Error while closing the output stream: {}", e.getMessage());
}
this.outputStream = null;
}
readerActive.set(false);
final ScheduledFuture<?> reader = this.reader;
if (reader != null) {
reader.cancel(false);
this.reader = null;
}
lastValue = null;
super.dispose();
}
@Override
public void serialEvent(final SerialPortEvent event) {
switch (event.getEventType()) {
case SerialPortEvent.DATA_AVAILABLE:
if (readerActive.compareAndSet(false, true)) {
reader = scheduler.schedule(() -> receiveAndProcess(new StringBuilder(), true), 0,
TimeUnit.MILLISECONDS);
}
receiveAndProcessNow();
break;
default:
break;
}
}
/**
* Sends a string to the serial port.
*
* @param string the string to send
*/
public void writeString(final String string) {
writeString(string, false);
}
/**
* Refreshes the channel with the last received data
*
* @param channelId the channel to refresh
* @param channelId the data to use
*/
private void refresh(final String channelId, final String data) {
if (!isLinked(channelId)) {
return;
}
switch (channelId) {
case STRING_CHANNEL:
updateState(channelId, new StringType(data));
break;
case BINARY_CHANNEL:
final StringBuilder sb = new StringBuilder("data:");
sb.append(RawType.DEFAULT_MIME_TYPE).append(";base64,")
.append(Base64.getEncoder().encodeToString(data.getBytes(charset)));
updateState(channelId, new StringType(sb.toString()));
break;
default:
break;
}
}
/**
* Read from the serial port and process the data
*
* @param sb the string builder to receive the data
* @param firstAttempt indicates if this is the first read attempt without waiting
*/
private void receiveAndProcess(final StringBuilder sb, final boolean firstAttempt) {
final InputStream inputStream = this.inputStream;
if (inputStream == null) {
readerActive.set(false);
return;
}
try {
if (firstAttempt || inputStream.available() > 0) {
final byte[] readBuffer = new byte[20];
// read data from serial device
while (inputStream.available() > 0) {
final int bytes = inputStream.read(readBuffer);
sb.append(new String(readBuffer, 0, bytes, charset));
}
// Add wait states around reading the stream, so that interrupted transmissions
// are merged
if (readerActive.get()) {
reader = scheduler.schedule(() -> receiveAndProcess(sb, false), 100, TimeUnit.MILLISECONDS);
}
} else {
final String result = sb.toString();
triggerChannel(TRIGGER_CHANNEL, CommonTriggerEvents.PRESSED);
refresh(STRING_CHANNEL, result);
refresh(BINARY_CHANNEL, result);
result.lines().forEach(l -> getThing().getThings().forEach(t -> {
final SerialDeviceHandler device = (SerialDeviceHandler) t.getHandler();
if (device != null) {
device.handleData(l);
}
}));
lastValue = result;
if (readerActive.compareAndSet(true, false)) {
// Check we haven't received more data while processing
if (inputStream.available() > 0 && readerActive.compareAndSet(false, true)) {
reader = scheduler.schedule(() -> receiveAndProcess(new StringBuilder(), true), 0,
TimeUnit.MILLISECONDS);
}
}
}
} catch (final IOException e) {
logger.debug("Error reading from serial port: {}", e.getMessage(), e);
readerActive.set(false);
}
}
/**
* Sends a string to the serial port.
*
* @param string the string to send
* @param isRawType the string should be handled as a RawType
*/
private void writeString(final String string, final boolean isRawType) {
final OutputStream outputStream = this.outputStream;
if (outputStream == null) {
return;
}
logger.debug("Writing '{}' to serial port {}", string, config.serialPort);
try {
// write string to serial port
if (isRawType) {
final RawType rt = RawType.valueOf(string);
outputStream.write(rt.getBytes());
} else {
outputStream.write(string.getBytes(charset));
}
outputStream.flush();
} catch (final IOException | IllegalArgumentException e) {
logger.warn("Error writing '{}' to serial port {}: {}", string, config.serialPort, e.getMessage());
}
@Override
protected String getLogPrefix() {
return String.format("Serial port '%s'", config.serialPort);
}
}
@@ -14,6 +14,9 @@ package org.openhab.binding.serial.internal.handler;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@@ -40,6 +43,7 @@ import org.openhab.core.types.RefreshType;
* sent to one of the channels.
*
* @author Mike Major - Initial contribution
* @author Roland Tapken - Added channel refresh
*/
@NonNullByDefault
public class SerialDeviceHandler extends BaseThingHandler {
@@ -50,6 +54,8 @@ public class SerialDeviceHandler extends BaseThingHandler {
private final Map<ChannelUID, DeviceChannel> channels = new HashMap<>();
private final Map<ChannelUID, ScheduledFuture<?>> futures = new HashMap<>();
public SerialDeviceHandler(final Thing thing) {
super(thing);
}
@@ -57,23 +63,16 @@ public class SerialDeviceHandler extends BaseThingHandler {
@Override
public void handleCommand(final ChannelUID channelUID, final Command command) {
if (command instanceof RefreshType) {
final String lastValue = this.lastValue;
if (lastValue != null) {
final DeviceChannel channel = channels.get(channelUID);
if (channel != null) {
refresh(channelUID, channel, lastValue);
}
final DeviceChannel channel = channels.get(channelUID);
if (channel != null) {
refresh(channelUID, channel);
}
} else {
final DeviceChannel channel = channels.get(channelUID);
if (channel != null) {
final Bridge bridge = getBridge();
if (bridge != null) {
final SerialBridgeHandler handler = (SerialBridgeHandler) bridge.getHandler();
if (handler != null) {
channel.mapCommand(command).ifPresent(value -> handler.writeString(value));
}
if (getBridge() instanceof Bridge bridge
&& bridge.getHandler() instanceof CommonBridgeHandler handler) {
channel.mapCommand(command).ifPresent(handler::writeString);
}
}
}
@@ -100,6 +99,13 @@ public class SerialDeviceHandler extends BaseThingHandler {
type.getId());
if (deviceChannel != null) {
channels.put(c.getUID(), deviceChannel);
int delay = deviceChannel.getRefreshInterval();
if (delay > 0) {
ScheduledFuture<?> future = scheduler.scheduleWithFixedDelay(
() -> refresh(c.getUID(), deviceChannel), delay, delay, TimeUnit.SECONDS);
futures.put(c.getUID(), future);
}
}
} catch (final IllegalArgumentException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
@@ -118,6 +124,9 @@ public class SerialDeviceHandler extends BaseThingHandler {
@Override
public void dispose() {
for (ScheduledFuture<?> future : this.futures.values()) {
future.cancel(true);
}
channels.clear();
lastValue = null;
super.dispose();
@@ -132,7 +141,11 @@ public class SerialDeviceHandler extends BaseThingHandler {
final Pattern devicePattern = this.devicePattern;
if (devicePattern != null && devicePattern.matcher(data).matches()) {
channels.forEach((channelUID, channel) -> refresh(channelUID, channel, data));
channels.forEach((channelUID, channel) -> {
if (isLinked(channelUID)) {
channel.transformData(data).ifPresent(value -> updateState(channelUID, new StringType(value)));
}
});
this.lastValue = data;
}
}
@@ -152,13 +165,27 @@ public class SerialDeviceHandler extends BaseThingHandler {
/**
* Refreshes the channel with the last received data
*
* @param channelId the channel to refresh
* @param channelUID the channel to refresh
*/
private void refresh(final ChannelUID channelUID, final DeviceChannel channel, final String data) {
private void refresh(final ChannelUID channelUID, final DeviceChannel channel) {
final String data = this.lastValue;
if (!isLinked(channelUID)) {
return;
}
channel.transformData(data).ifPresent(value -> updateState(channelUID, new StringType(value)));
if (!channel.getRefreshValue().isBlank()) {
if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof CommonBridgeHandler handler) {
Optional<String> value = channel.transformCommand(channel.getRefreshValue());
if (value.isPresent()) {
handler.writeString(value.get());
return;
}
}
}
// Use last result line
if (data != null) {
channel.transformData(data).ifPresent(value -> updateState(channelUID, new StringType(value)));
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2010-2025 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.serial.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Class describing the {@link TcpBridgeConfiguration} configuration
*
* @author Roland Tapken - Initial contribution
*/
@NonNullByDefault
public class TcpBridgeConfiguration extends CommonBridgeConfiguration {
/**
* IP address or hostname
*/
public String address = "";
/**
* TCP Port
*/
public int port = 0;
/**
* Socket timeout in seconds
*/
public int timeout = 0;
/**
* Keep Alive
*/
public boolean keepAlive = false;
/**
* Reconnection Interval in seconds
*/
public int reconnectInterval = 10;
@Override
public String toString() {
return "TcpBridgeConfiguration [Address=" + address + ", Port=" + port + ", timeout=" + timeout + ", keepAlive="
+ keepAlive + ", reconnectInterval=" + reconnectInterval + ", charset=" + charset + ", eolPattern="
+ eolPattern + "]";
}
}
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2010-2025 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.serial.internal.handler;
import static org.openhab.binding.serial.internal.SerialBindingConstants.*;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.CommonTriggerEvents;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
/**
* The {@link TcpBridgeHandler} is responsible for handling commands, which
* are sent to one of the channels.
*
* @author Roland Tapken - Initial contribution
*/
@NonNullByDefault
public class TcpBridgeHandler extends CommonBridgeHandler {
/**
* Since InputStream#read will block, we use our own instance of ScheduledThreadPoolExecutor
* instead of OpenHab's default one to not block a background thread.
*/
private final ScheduledThreadPoolExecutor readSchedulerExcecutor = new ScheduledThreadPoolExecutor(1);
private TcpBridgeConfiguration config = new TcpBridgeConfiguration();
private @Nullable Socket socket;
private @Nullable ScheduledFuture<?> readScheduler;
public TcpBridgeHandler(final Bridge bridge) {
super(bridge);
}
@Override
public void initialize() {
config = getConfigAs(TcpBridgeConfiguration.class);
if (!checkAndProcessConfiguration(config)) {
return;
}
final String address = config.address;
if (address.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Address must be set");
return;
}
final int port = config.port;
if (port <= 0) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Port must be set");
return;
}
// initialize serial port
try {
Socket socket = new Socket();
socket.connect(new InetSocketAddress(address, port), config.timeout * 1000);
if (config.timeout > 0) {
socket.setSoTimeout(config.timeout * 1000);
}
if (config.keepAlive) {
socket.setKeepAlive(true);
}
this.socket = socket;
inputStream = new BufferedInputStream(socket.getInputStream());
outputStream = socket.getOutputStream();
updateStatus(ThingStatus.ONLINE);
// Since there is not a thing like a SerialPortEvent for Sockets
// we have to trigger a read periodical.
waitForData();
} catch (final IllegalArgumentException ex) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, ex.getMessage());
} catch (final IOException ex) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, "I/O error");
handleIOException(ex);
}
}
/**
* Replacement for SerialBridgeHandler's SerialPortEventListener.
* Checks for available data every 250ms.
*/
private void waitForData() {
// If this is ever changed to values >= 1000,
// this may interfere with tryToReconect. In this case
// some kind of locking is required.
int interval = 250;
if (getThing().getStatus() == ThingStatus.ONLINE) {
ScheduledFuture<?> readScheduler = this.readScheduler;
if (readScheduler != null) {
readScheduler.cancel(false);
}
this.readScheduler = readSchedulerExcecutor.schedule(() -> {
if (getThing().getStatus() == ThingStatus.ONLINE) {
try {
InputStream inputStream = this.inputStream;
Socket socket = this.socket;
if (inputStream != null && socket != null) {
synchronized (inputStream) {
inputStream.mark(2);
// InputStream.available() does not recognise when a client has disconnected,
// so we will use BufferedInputStream and cache one byte.
int b = inputStream.read();
if (b < 0) {
throw new SocketException("Connection lost");
}
inputStream.reset();
receiveAndProcessNow();
}
}
waitForData();
} catch (IOException e) {
handleIOException(e);
}
}
}, interval, TimeUnit.MILLISECONDS);
}
}
@Override
protected void processInput(String result) {
if (isLinked(TRIGGER_CHANNEL)) {
triggerChannel(TRIGGER_CHANNEL, CommonTriggerEvents.PRESSED);
}
if (isLinked(STRING_CHANNEL)) {
refresh(STRING_CHANNEL, result);
}
if (isLinked(BINARY_CHANNEL)) {
refresh(BINARY_CHANNEL, result);
}
}
@Override
public void dispose() {
ScheduledFuture<?> readScheduler = this.readScheduler;
this.readScheduler = null;
if (readScheduler != null) {
readScheduler.cancel(true);
}
Socket socket = this.socket;
this.socket = null;
if (socket != null) {
try {
socket.close();
} catch (IOException ignore) {
}
}
super.dispose();
}
/**
* TCP Connections do not reconnect automatically. In case of an IOException,
* close the connection and try to re-connect.
*/
@Override
protected void handleIOException(IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
dispose();
super.handleIOException(e);
tryToReconnect();
}
private void tryToReconnect() {
int reconnectInterval = config.reconnectInterval;
if (reconnectInterval > 0) {
logger.info("Trying to reconnnect to {}:{} in {} seconds", this.config.address, this.config.port,
reconnectInterval);
scheduler.schedule(() -> {
if (getThing().getStatus() == ThingStatus.OFFLINE) {
// Will re-call tryToReconnect on failure
initialize();
}
}, reconnectInterval, TimeUnit.SECONDS);
}
}
@Override
protected String getLogPrefix() {
return String.format("TCP socket '%s:%d'", config.address, config.port);
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2010-2025 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.serial.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Class describing the {@link TcpServerBridgeConfiguration} configuration
*
* @author Roland Tapken - Initial contribution
*/
@NonNullByDefault
public class TcpServerBridgeConfiguration extends CommonBridgeConfiguration {
/**
* IP address or hostname
*/
public String bindAddress = "0.0.0.0";
/**
* TCP Port
*/
public int port = 0;
/**
* Socket timeout in seconds
*/
public int timeout = 0;
/**
* Keep Alive
*/
public boolean keepAlive = false;
@Override
public String toString() {
return "TcpServerBridgeConfiguration [BindAddress=" + bindAddress + ", Port=" + port + ", timeout=" + timeout
+ ", keepAlive=" + keepAlive + ", charset=" + charset + ", eolPattern=" + eolPattern + "]";
}
}
@@ -0,0 +1,269 @@
/*
* Copyright (c) 2010-2025 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.serial.internal.handler;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
/**
* The {@link TcpServerBridgeHandler} is responsible for handling commands, which
* are sent to one of the channels.
*
* Like {@link TcpBridgeHandler}, but listens for an incoming connection on a defined
* TCP port. Only one active connection per time is allowed.
*
* @author Roland Tapken - Initial contribution
*/
@NonNullByDefault
public class TcpServerBridgeHandler extends CommonBridgeHandler {
private TcpServerBridgeConfiguration config = new TcpServerBridgeConfiguration();
/**
* Since ServerSocket#accept will block, we use our own instance of ScheduledThreadPoolExecutor
* instead of OpenHab's default one to not block a background thread.
*/
private final ScheduledThreadPoolExecutor connectionSchedulerExcecutor = new ScheduledThreadPoolExecutor(1);
/**
* Since InputStream#read will block, we use our own instance of ScheduledThreadPoolExecutor
* instead of OpenHab's default one to not block a background thread.
*/
private final ScheduledThreadPoolExecutor readSchedulerExcecutor = new ScheduledThreadPoolExecutor(1);
private @Nullable ServerSocket server;
private @Nullable Socket socket;
private @Nullable ScheduledFuture<?> readScheduler;
private @Nullable ScheduledFuture<?> connectionScheduler;
public TcpServerBridgeHandler(final Bridge bridge) {
super(bridge);
}
@Override
public void initialize() {
config = getConfigAs(TcpServerBridgeConfiguration.class);
if (!checkAndProcessConfiguration(config)) {
return;
}
final int port = config.port;
if (port <= 0) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Port must be set");
return;
}
final String bindAddress = config.bindAddress;
if (bindAddress.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "BindAddress must be set");
return;
}
// initialize serial port
try {
ServerSocket server = new ServerSocket();
server.bind(new InetSocketAddress(bindAddress, port), 1);
this.server = server;
logger.info("Listening on TCP address {} port {}", bindAddress, port);
updateStatus(ThingStatus.ONLINE);
// Wait for an incoming connection
waitForConnection();
} catch (final IllegalArgumentException ex) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, ex.getMessage());
} catch (final IOException ex) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, "I/O error");
handleIOException(ex);
}
}
private void waitForConnection() {
ServerSocket server = this.server;
TcpServerBridgeConfiguration config = this.config;
if (getThing().getStatus() == ThingStatus.ONLINE && server != null) {
ScheduledFuture<?> acceptScheduler = this.connectionScheduler;
if (acceptScheduler != null) {
acceptScheduler.cancel(true);
}
this.connectionScheduler = connectionSchedulerExcecutor.schedule(() -> {
if (getThing().getStatus() == ThingStatus.ONLINE && server.equals(this.server)) {
try {
synchronized (server) {
Socket socket = server.accept();
if (!server.equals(this.server)) {
// Drop this connection, it is not valid anymore
logger.warn("Rejecting incoming connection from {}:{} (invalid)",
socket.getInetAddress(), socket.getPort());
socket.close();
return;
}
if (this.socket != null) {
// Normally, this should not happen because the ServerSocket has been bound with
// 'backlog=1' parameter. But it seems that this is only a recommendation to the
// operating system and might be ignored, so we have to handle this.
logger.warn("Rejecting incoming connection from {}:{} (socket already bound)",
socket.getInetAddress(), socket.getPort());
try {
socket.shutdownInput();
socket.shutdownOutput();
socket.close();
} catch (IOException ignore) {
}
waitForConnection();
return;
}
logger.info("Accepting incoming connection from {}:{}", socket.getInetAddress(),
socket.getPort());
socket.setKeepAlive(this.config.keepAlive);
if (config.timeout > 0) {
socket.setSoTimeout(config.timeout * 1000);
}
this.socket = socket;
this.inputStream = new BufferedInputStream(socket.getInputStream());
this.outputStream = socket.getOutputStream();
}
waitForData();
waitForConnection();
} catch (IOException e) {
handleIOException(e);
}
}
}, 100, TimeUnit.MILLISECONDS);
}
}
/**
* Replacement for SerialBridgeHandler's SerialPortEventListener.
* Checks for available data every 250ms.
*/
private void waitForData() {
// If this is ever changed to values >= 1000,
// this may interfere with tryToReconnect. In this case
// some kind of locking is required.
int interval = 250;
if (getThing().getStatus() == ThingStatus.ONLINE) {
ScheduledFuture<?> readScheduler = this.readScheduler;
if (readScheduler != null) {
readScheduler.cancel(false);
}
this.readScheduler = readSchedulerExcecutor.schedule(() -> {
if (getThing().getStatus() == ThingStatus.ONLINE) {
try {
InputStream inputStream = this.inputStream;
if (inputStream != null) {
synchronized (inputStream) {
inputStream.mark(1);
// InputStream.available() does not recognise when a client has disconnected,
// so we will use BufferedInputStream and cache one byte.
if (inputStream.read() < 0) {
logger.info("{} connection lost", getLogPrefix());
disposeSocket();
return;
}
inputStream.reset();
receiveAndProcessNow();
}
}
waitForData();
} catch (IOException e) {
handleIOException(e);
}
}
}, interval, TimeUnit.MILLISECONDS);
}
}
private void disposeSocket() {
// Dispose inputStream and outputStream and any active readers
disposeReader();
ScheduledFuture<?> readScheduler = this.readScheduler;
this.readScheduler = null;
if (readScheduler != null) {
readScheduler.cancel(true);
}
Socket socket = this.socket;
this.socket = null;
if (socket != null) {
try {
socket.close();
} catch (IOException ignore) {
}
}
}
@Override
public void dispose() {
super.dispose();
disposeSocket();
ScheduledFuture<?> connectionScheduler = this.connectionScheduler;
this.connectionScheduler = null;
if (connectionScheduler != null) {
connectionScheduler.cancel(true);
}
ServerSocket server = this.server;
this.server = null;
if (server != null) {
try {
server.close();
} catch (IOException ignore) {
}
}
}
/**
* TCP Connections do not reconnect automatically. In case of an IOException,
* close the connection and try to re-connect.
*/
@Override
protected void handleIOException(IOException e) {
Socket socket = this.socket;
if (socket != null) {
logger.warn("Connection to {}:{} failed with IOException: {}", socket.getInetAddress(), socket.getPort(),
e.getMessage());
}
// Unlike TcpBridgeHandler, this only disposes the current connection,
// not the listening port itself.
disposeSocket();
super.handleIOException(e);
}
@Override
protected String getLogPrefix() {
return String.format("TCP Server '%s:%d'", config.bindAddress, config.port);
}
}
@@ -5,7 +5,7 @@
<type>binding</type>
<name>Serial Binding</name>
<description>This binding supports sending/receiving data to/from a serial port</description>
<description>This binding supports sending/receiving data to/from a serial port or TCP connection</description>
<connection>local</connection>
</addon:addon>
@@ -1,7 +1,7 @@
# add-on
addon.serial.name = Serial Binding
addon.serial.description = This binding supports sending/receiving data to/from a serial port
addon.serial.description = This binding supports sending/receiving data to/from a serial port or TCP connection
# thing types
@@ -9,6 +9,10 @@ thing-type.serial.serialBridge.label = Serial Bridge
thing-type.serial.serialBridge.description = Serial port which can send and receive data
thing-type.serial.serialDevice.label = Serial Device
thing-type.serial.serialDevice.description = Represents a device
thing-type.serial.tcpBridge.label = TCP Bridge
thing-type.serial.tcpBridge.description = TCP socket which can send and receive data
thing-type.serial.tcpServerBridge.label = TCP Server Bridge
thing-type.serial.tcpServerBridge.description = Like TCP bridge, but listens on a local port for incoming connections
# thing types config
@@ -22,13 +26,15 @@ thing-type.config.serial.serialBridge.baudRate.option.38400 = 38400
thing-type.config.serial.serialBridge.baudRate.option.57600 = 57600
thing-type.config.serial.serialBridge.baudRate.option.115200 = 115200
thing-type.config.serial.serialBridge.charset.label = Charset
thing-type.config.serial.serialBridge.charset.description = The charset to use for converting between bytes and string (e.g. UTF-8, ISO-8859-1)
thing-type.config.serial.serialBridge.charset.description = The charset to use for converting between bytes and string (e.g. UTF-8, ISO-8859-1). Enter 'HEX' to convert binary data into hexadecimal strings, separated by space.
thing-type.config.serial.serialBridge.dataBits.label = Data Bits
thing-type.config.serial.serialBridge.dataBits.description = Set the data bits
thing-type.config.serial.serialBridge.dataBits.option.5 = 5
thing-type.config.serial.serialBridge.dataBits.option.6 = 6
thing-type.config.serial.serialBridge.dataBits.option.7 = 7
thing-type.config.serial.serialBridge.dataBits.option.8 = 8
thing-type.config.serial.serialBridge.eolPattern.label = EOL Pattern
thing-type.config.serial.serialBridge.eolPattern.description = Only for Charset = HEX: 'End of line' pattern as regular expression. For example, "0D 10" matches \r\n, "\\bFF" matches 0xFF. Please note that the matched pattern will be part of the result string.
thing-type.config.serial.serialBridge.parity.label = Parity
thing-type.config.serial.serialBridge.parity.description = Set the parity
thing-type.config.serial.serialBridge.parity.option.N = N(one)
@@ -45,6 +51,32 @@ thing-type.config.serial.serialBridge.stopBits.option.1.5 = 1.5
thing-type.config.serial.serialBridge.stopBits.option.2 = 2
thing-type.config.serial.serialDevice.patternMatch.label = Patern Match
thing-type.config.serial.serialDevice.patternMatch.description = Regular expression used to identify device from received data (must match the whole line)
thing-type.config.serial.tcpBridge.address.label = Address
thing-type.config.serial.tcpBridge.address.description = The IP address or hostname
thing-type.config.serial.tcpBridge.charset.label = Charset or HEX
thing-type.config.serial.tcpBridge.charset.description = The charset to use for converting between bytes and string (e.g. UTF-8, ISO-8859-1). Enter 'HEX' to convert binary data into hexadecimal strings, separated by space.
thing-type.config.serial.tcpBridge.eolPattern.label = EOL pattern
thing-type.config.serial.tcpBridge.eolPattern.description = Only for Charset = HEX: 'End of line' pattern as regular expression. For example, "0D 10" matches \r\n, "\\bFF" matches 0xFF. Please note that the matched pattern will be part of the result string.
thing-type.config.serial.tcpBridge.keepAlive.label = Keep-Alive
thing-type.config.serial.tcpBridge.keepAlive.description = Send keep-alive
thing-type.config.serial.tcpBridge.port.label = Port
thing-type.config.serial.tcpBridge.port.description = TCP port to connect
thing-type.config.serial.tcpBridge.reconnectInterval.label = Reconnect Interval
thing-type.config.serial.tcpBridge.reconnectInterval.description = Reconnection interval in seconds after connection failure (0 = disabled)
thing-type.config.serial.tcpBridge.timeout.label = Socket Timeout
thing-type.config.serial.tcpBridge.timeout.description = Timeout in seconds
thing-type.config.serial.tcpServerBridge.bindAddress.label = Address
thing-type.config.serial.tcpServerBridge.bindAddress.description = The IP address to bind to (default: 0.0.0.0)
thing-type.config.serial.tcpServerBridge.charset.label = Charset or HEX
thing-type.config.serial.tcpServerBridge.charset.description = The charset to use for converting between bytes and string (e.g. UTF-8, ISO-8859-1). Enter 'HEX' to convert binary data into hexadecimal strings, separated by space.
thing-type.config.serial.tcpServerBridge.eolPattern.label = EOL pattern
thing-type.config.serial.tcpServerBridge.eolPattern.description = Only for Charset = HEX: 'End of line' pattern as regular expression. For example, "0D 10" matches \r\n, "\\bFF" matches 0xFF. Please note that the matched pattern will be part of the result string.
thing-type.config.serial.tcpServerBridge.keepAlive.label = Keep-Alive
thing-type.config.serial.tcpServerBridge.keepAlive.description = Send keep-alive
thing-type.config.serial.tcpServerBridge.port.label = Port
thing-type.config.serial.tcpServerBridge.port.description = Listening TCP port
thing-type.config.serial.tcpServerBridge.timeout.label = Socket Timeout
thing-type.config.serial.tcpServerBridge.timeout.description = Timeout in seconds
# channel types
@@ -77,12 +109,20 @@ channel-type.config.serial.dimmer.offValue.label = Off Value
channel-type.config.serial.dimmer.offValue.description = Send this value when receiving an OFF command
channel-type.config.serial.dimmer.onValue.label = On Value
channel-type.config.serial.dimmer.onValue.description = Send this value when receiving an ON command
channel-type.config.serial.dimmer.refreshInterval.label = Refresh Interval
channel-type.config.serial.dimmer.refreshInterval.description = Interval for the automatic channel update in seconds. This value is only required if the peer has to send the “Refresh Value” command regularly in order to return the current properties. It is not required if the peer automatically forwards changed values to its clients.
channel-type.config.serial.dimmer.refreshValue.label = Refresh Value
channel-type.config.serial.dimmer.refreshValue.description = Send this value to receive the current state
channel-type.config.serial.dimmer.stateTransformation.label = State Transformation
channel-type.config.serial.dimmer.stateTransformation.description = Transformation used to convert device data to channel state, e.g. <code>REGEX(.*?STATE=(.*?);.*)</code>. Multiple transformations can be chained by listing each transformation on a separate line, or by concatenating them with "∩".
channel-type.config.serial.number.commandFormat.label = Number Format
channel-type.config.serial.number.commandFormat.description = Format string applied to the command, e.g. ID=671;VAL=%f
channel-type.config.serial.number.commandTransformation.label = Command Transformation
channel-type.config.serial.number.commandTransformation.description = Transformation used to convert command to device data, e.g. <code>JS(device.js)</code>. Multiple transformations can be chained by listing each transformation on a separate line, or by concatenating them with "∩".
channel-type.config.serial.number.refreshInterval.label = Refresh Interval
channel-type.config.serial.number.refreshInterval.description = Interval for the automatic channel update in seconds. This value is only required if the peer has to send the “Refresh Value” command regularly in order to return the current properties. It is not required if the peer automatically forwards changed values to its clients.
channel-type.config.serial.number.refreshValue.label = Refresh Value
channel-type.config.serial.number.refreshValue.description = Send this value to receive the current state
channel-type.config.serial.number.stateTransformation.label = State Transformation
channel-type.config.serial.number.stateTransformation.description = Transformation used to convert device data to channel state, e.g. <code>REGEX(.*?STATE=(.*?);.*)</code>. Multiple transformations can be chained by listing each transformation on a separate line, or by concatenating them with "∩".
channel-type.config.serial.rollershutter.commandFormat.label = Percent Format
@@ -91,6 +131,10 @@ channel-type.config.serial.rollershutter.commandTransformation.label = Command T
channel-type.config.serial.rollershutter.commandTransformation.description = Transformation used to convert command to device data, e.g. <code>JS(device.js)</code>. Multiple transformations can be chained by listing each transformation on a separate line, or by concatenating them with "∩".
channel-type.config.serial.rollershutter.downValue.label = Down Value
channel-type.config.serial.rollershutter.downValue.description = Send this value when receiving a DOWN command
channel-type.config.serial.rollershutter.refreshInterval.label = Refresh Interval
channel-type.config.serial.rollershutter.refreshInterval.description = Interval for the automatic channel update in seconds. This value is only required if the peer has to send the “Refresh Value” command regularly in order to return the current properties. It is not required if the peer automatically forwards changed values to its clients.
channel-type.config.serial.rollershutter.refreshValue.label = Refresh Value
channel-type.config.serial.rollershutter.refreshValue.description = Send this value to receive the current state
channel-type.config.serial.rollershutter.stateTransformation.label = State Transformation
channel-type.config.serial.rollershutter.stateTransformation.description = Transformation used to convert device data to channel state, e.g. <code>REGEX(.*?STATE=(.*?);.*)</code>. Multiple transformations can be chained by listing each transformation on a separate line, or by concatenating them with "∩".
channel-type.config.serial.rollershutter.stopValue.label = Stop Value
@@ -101,6 +145,10 @@ channel-type.config.serial.string.commandFormat.label = String Format
channel-type.config.serial.string.commandFormat.description = Format string applied to the command, e.g. ID=671;COMMAND=%s
channel-type.config.serial.string.commandTransformation.label = Command Transformation
channel-type.config.serial.string.commandTransformation.description = Transformation used to convert command to device data, e.g. <code>JS(device.js)</code>. Multiple transformations can be chained by listing each transformation on a separate line, or by concatenating them with "∩".
channel-type.config.serial.string.refreshInterval.label = Refresh Interval
channel-type.config.serial.string.refreshInterval.description = Interval for the automatic channel update in seconds. This value is only required if the peer has to send the “Refresh Value” command regularly in order to return the current properties. It is not required if the peer automatically forwards changed values to its clients.
channel-type.config.serial.string.refreshValue.label = Refresh Value
channel-type.config.serial.string.refreshValue.description = Send this value to receive the current state
channel-type.config.serial.string.stateTransformation.label = State Transformation
channel-type.config.serial.string.stateTransformation.description = Transformation used to convert device data to channel state, e.g. <code>REGEX(.*?STATE=(.*?);.*)</code>. Multiple transformations can be chained by listing each transformation on a separate line, or by concatenating them with "∩".
channel-type.config.serial.switch.commandTransformation.label = Command Transformation
@@ -109,5 +157,9 @@ channel-type.config.serial.switch.offValue.label = Off Value
channel-type.config.serial.switch.offValue.description = Send this value when receiving an OFF command
channel-type.config.serial.switch.onValue.label = On Value
channel-type.config.serial.switch.onValue.description = Send this value when receiving an ON command
channel-type.config.serial.switch.refreshInterval.label = Refresh Interval
channel-type.config.serial.switch.refreshInterval.description = Interval for the automatic channel update in seconds. This value is only required if the peer has to send the “Refresh Value” command regularly in order to return the current properties. It is not required if the peer automatically forwards changed values to its clients.
channel-type.config.serial.switch.refreshValue.label = Refresh Value
channel-type.config.serial.switch.refreshValue.description = Send this value to receive the current state
channel-type.config.serial.switch.stateTransformation.label = State Transformation
channel-type.config.serial.switch.stateTransformation.description = Transformation used to convert device data to channel state, e.g. <code>REGEX(.*?STATE=(.*?);.*)</code>. Multiple transformations can be chained by listing each transformation on a separate line, or by concatenating them with "∩".
@@ -19,7 +19,6 @@
<config-description>
<parameter name="serialPort" type="text" required="true">
<context>serial-port</context>
<limitToOptions>false</limitToOptions>
<label>Serial Port</label>
<description>The serial port to use (e.g. Linux: /dev/ttyUSB0, Windows: COM1)</description>
</parameter>
@@ -78,7 +77,108 @@
<parameter name="charset" type="text">
<advanced>true</advanced>
<label>Charset</label>
<description>The charset to use for converting between bytes and string (e.g. UTF-8, ISO-8859-1)</description>
<description>The charset to use for converting between bytes and string (e.g. UTF-8, ISO-8859-1). Enter 'HEX' to
convert binary data into hexadecimal strings, separated by space.</description>
</parameter>
<parameter name="eolPattern" type="text">
<advanced>true</advanced>
<label>EOL Pattern</label>
<description>Only for Charset = HEX: 'End of line' pattern as regular expression. For example, "0D 10" matches \r\n,
"\\bFF" matches 0xFF. Please note that the matched pattern will be part of the result string.</description>
</parameter>
</config-description>
</bridge-type>
<bridge-type id="tcpBridge">
<label>TCP Bridge</label>
<description>TCP socket which can send and receive data</description>
<channels>
<channel id="string" typeId="stringData"/>
<channel id="binary" typeId="binaryData"/>
<channel id="data" typeId="system.rawbutton"/>
</channels>
<config-description>
<parameter name="address" type="text" required="true">
<context>network-address</context>
<label>Address</label>
<description>The IP address or hostname</description>
</parameter>
<parameter name="port" type="integer" min="1" max="65535" required="true">
<label>Port</label>
<description>TCP port to connect</description>
<limitToOptions>false</limitToOptions>
</parameter>
<parameter name="timeout" type="integer" min="0">
<advanced>true</advanced>
<label>Socket Timeout</label>
<description>Timeout in seconds</description>
</parameter>
<parameter name="keepAlive" type="boolean">
<advanced>true</advanced>
<label>Keep-Alive</label>
<description>Send keep-alive</description>
</parameter>
<parameter name="reconnectInterval" type="integer" min="0">
<advanced>true</advanced>
<label>Reconnect Interval</label>
<description>Reconnection interval in seconds after connection failure (0 = disabled)</description>
<default>10</default>
</parameter>
<parameter name="charset" type="text">
<advanced>true</advanced>
<label>Charset or HEX</label>
<description>The charset to use for converting between bytes and string (e.g. UTF-8, ISO-8859-1). Enter 'HEX' to
convert binary data into hexadecimal strings, separated by space.</description>
</parameter>
<parameter name="eolPattern" type="text">
<advanced>true</advanced>
<label>EOL pattern</label>
<description>Only for Charset = HEX: 'End of line' pattern as regular expression. For example, "0D 10" matches \r\n,
"\\bFF" matches 0xFF. Please note that the matched pattern will be part of the result string.</description>
</parameter>
</config-description>
</bridge-type>
<bridge-type id="tcpServerBridge">
<label>TCP Server Bridge</label>
<description>Like TCP bridge, but listens on a local port for incoming connections</description>
<config-description>
<parameter name="port" type="integer" min="1" max="65535" required="true">
<label>Port</label>
<description>Listening TCP port</description>
<limitToOptions>false</limitToOptions>
</parameter>
<parameter name="bindAddress" type="text">
<context>network-address</context>
<limitToOptions>false</limitToOptions>
<label>Address</label>
<default>0.0.0.0</default>
<description>The IP address to bind to (default: 0.0.0.0)</description>
</parameter>
<parameter name="timeout" type="integer" min="0">
<advanced>true</advanced>
<label>Socket Timeout</label>
<description>Timeout in seconds</description>
</parameter>
<parameter name="keepAlive" type="boolean">
<advanced>true</advanced>
<label>Keep-Alive</label>
<description>Send keep-alive</description>
</parameter>
<parameter name="charset" type="text">
<advanced>true</advanced>
<label>Charset or HEX</label>
<description>The charset to use for converting between bytes and string (e.g. UTF-8, ISO-8859-1). Enter 'HEX' to
convert binary data into hexadecimal strings, separated by space.</description>
</parameter>
<parameter name="eolPattern" type="text">
<advanced>true</advanced>
<label>EOL pattern</label>
<description>Only for Charset = HEX: 'End of line' pattern as regular expression. For example, "0D 10" matches \r\n,
"\\bFF" matches 0xFF. Please note that the matched pattern will be part of the result string.</description>
</parameter>
</config-description>
</bridge-type>
@@ -86,6 +186,8 @@
<thing-type id="serialDevice" extensible="string, number, dimmer, switch, rollershutter">
<supported-bridge-type-refs>
<bridge-type-ref id="serialBridge"/>
<bridge-type-ref id="tcpBridge"/>
<bridge-type-ref id="tcpServerBridge"/>
</supported-bridge-type-refs>
<label>Serial Device</label>
@@ -136,6 +238,16 @@
Multiple transformations can be chained by listing each transformation on a separate line,
or by concatenating them with "∩".]]></description>
</parameter>
<parameter name="refreshValue" type="text">
<label>Refresh Value</label>
<description>Send this value to receive the current state</description>
</parameter>
<parameter name="refreshInterval" type="integer" min="0">
<label>Refresh Interval</label>
<description>Interval for the automatic channel update in seconds. This value is only required if the peer has to
send the “Refresh Value” command regularly in order to return the current properties. It is not required if the
peer automatically forwards changed values to its clients.</description>
</parameter>
</config-description>
</channel-type>
@@ -162,6 +274,16 @@
Multiple transformations can be chained by listing each transformation on a separate line,
or by concatenating them with "∩".]]></description>
</parameter>
<parameter name="refreshValue" type="text">
<label>Refresh Value</label>
<description>Send this value to receive the current state</description>
</parameter>
<parameter name="refreshInterval" type="integer" min="0">
<label>Refresh Interval</label>
<description>Interval for the automatic channel update in seconds. This value is only required if the peer has to
send the “Refresh Value” command regularly in order to return the current properties. It is not required if the
peer automatically forwards changed values to its clients.</description>
</parameter>
</config-description>
</channel-type>
@@ -208,6 +330,16 @@
Multiple transformations can be chained by listing each transformation on a separate line,
or by concatenating them with "∩".]]></description>
</parameter>
<parameter name="refreshValue" type="text">
<label>Refresh Value</label>
<description>Send this value to receive the current state</description>
</parameter>
<parameter name="refreshInterval" type="integer" min="0">
<label>Refresh Interval</label>
<description>Interval for the automatic channel update in seconds. This value is only required if the peer has to
send the “Refresh Value” command regularly in order to return the current properties. It is not required if the
peer automatically forwards changed values to its clients.</description>
</parameter>
</config-description>
</channel-type>
@@ -242,6 +374,16 @@
Multiple transformations can be chained by listing each transformation on a separate line,
or by concatenating them with "∩".]]></description>
</parameter>
<parameter name="refreshValue" type="text">
<label>Refresh Value</label>
<description>Send this value to receive the current state</description>
</parameter>
<parameter name="refreshInterval" type="integer" min="0">
<label>Refresh Interval</label>
<description>Interval for the automatic channel update in seconds. This value is only required if the peer has to
send the “Refresh Value” command regularly in order to return the current properties. It is not required if the
peer automatically forwards changed values to its clients.</description>
</parameter>
</config-description>
</channel-type>
@@ -284,6 +426,16 @@
Multiple transformations can be chained by listing each transformation on a separate line,
or by concatenating them with "∩".]]></description>
</parameter>
<parameter name="refreshValue" type="text">
<label>Refresh Value</label>
<description>Send this value to receive the current state</description>
</parameter>
<parameter name="refreshInterval" type="integer" min="0">
<label>Refresh Interval</label>
<description>Interval for the automatic channel update in seconds. This value is only required if the peer has to
send the “Refresh Value” command regularly in order to return the current properties. It is not required if the
peer automatically forwards changed values to its clients.</description>
</parameter>
</config-description>
</channel-type>
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2010-2025 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.serial.internal;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.LinkedList;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Provides an InputStream implementation that reads from a list of byte arrays.
*
* @author Roland Tapken - Initial contribution
*/
@NonNullByDefault
public class ByteBufferInputStream extends InputStream {
private final LinkedList<byte[]> bufferQueue = new LinkedList<>();
private int available = 0;
private int pos = 0;
public void appendBuffer(byte[] bytes) {
bufferQueue.add(bytes);
available += bytes.length;
}
public void appendString(String str, Charset charset) {
appendBuffer(str.getBytes(charset));
}
@Override
public int read() throws IOException {
if (available <= 0) {
throw new IOException("Nothing to read, on a socket input stream this would block");
}
byte[] buffer = bufferQueue.get(0);
int result = buffer[pos++];
available -= 1;
if (pos == buffer.length) {
bufferQueue.remove(0);
pos = 0;
}
return result;
}
@Override
public int available() {
return available;
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2010-2025 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.serial.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.serial.internal.handler.SerialBridgeConfiguration;
import org.openhab.binding.serial.internal.handler.SerialBridgeHandler;
import org.openhab.core.io.transport.serial.SerialPortManager;
import org.openhab.core.thing.Bridge;
/**
* Provides a special implementation of CommonBridgeHandler that allows
* access to the input- and output streams and changes the visibility
* for #receiveAndProcess so that it can be used in the tests.
*
* @author Roland Tapken - Initial contribution
*/
@NonNullByDefault
public class MockupBridgeHandler extends SerialBridgeHandler {
private final SerialBridgeConfiguration config;
MockupBridgeHandler(Bridge bridgeMock, SerialPortManager serialPortManager, SerialBridgeConfiguration config) {
super(bridgeMock, serialPortManager);
this.config = config;
}
@SuppressWarnings("unchecked")
@Override
protected <T> T getConfigAs(Class<T> configurationClass) {
return (T) this.config;
}
@Override
public void receiveAndProcess(final StringBuilder sb, final boolean firstAttempt) {
super.receiveAndProcess(sb, firstAttempt);
}
}
@@ -0,0 +1,262 @@
/*
* Copyright (c) 2010-2025 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.serial.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.openhab.binding.serial.internal.handler.SerialBridgeConfiguration;
import org.openhab.core.io.transport.serial.PortInUseException;
import org.openhab.core.io.transport.serial.SerialPort;
import org.openhab.core.io.transport.serial.SerialPortIdentifier;
import org.openhab.core.io.transport.serial.SerialPortManager;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandlerCallback;
/**
* Implements some tests for the CommonBridgeHandler
*
* @author Roland Tapken - Initial contribution
*/
@NonNullByDefault
public class SerialBindingHandlerTest {
private @Nullable MockupBridgeHandler handler;
private @Nullable SerialBridgeConfiguration config;
private @Nullable ByteBufferInputStream bin;
private @Nullable ByteArrayOutputStream bos;
private final ThingHandlerCallback callbackMock = Mockito.mock(ThingHandlerCallback.class);
private final Bridge bridgeMock = Mockito.mock(Bridge.class);
private final SerialPortIdentifier mockIdentifier = Mockito.mock(SerialPortIdentifier.class);
private final SerialPort mockSerialPort = Mockito.mock(SerialPort.class);
private final SerialPortManager serialPortManager = new SerialPortManager() {
@Override
public @NonNull SerialPortIdentifier getIdentifier(final String name) {
assertEquals("/dev/dummy-serial", name, "Expect the passed serial port name");
return mockIdentifier;
}
@Override
public @NonNull Stream<SerialPortIdentifier> getIdentifiers() {
Stream<SerialPortIdentifier> stream = Stream.empty();
assertNotNull(stream);
return stream;
}
};
@BeforeEach
@SuppressWarnings("null")
public void setUp() throws PortInUseException, IOException {
SerialBridgeConfiguration config = new SerialBridgeConfiguration();
config.serialPort = "/dev/dummy-serial";
this.config = config;
this.handler = new MockupBridgeHandler(bridgeMock, serialPortManager, config);
this.handler.setCallback(callbackMock);
this.bin = new ByteBufferInputStream();
this.bos = new ByteArrayOutputStream();
resetMock();
}
@AfterEach
public void tearDown() {
MockupBridgeHandler handler = this.handler;
this.handler = null;
if (handler != null) {
handler.dispose();
}
}
private void resetMock() throws IOException, PortInUseException {
reset(callbackMock, bridgeMock, mockSerialPort, mockIdentifier);
doReturn(new ThingUID("serial:serialBridge:test")).when(bridgeMock).getUID();
doReturn(mockSerialPort).when(mockIdentifier).open(anyString(), anyInt());
doReturn(bin).when(mockSerialPort).getInputStream();
doReturn(bos).when(mockSerialPort).getOutputStream();
}
@Test
public void initializeShouldCallTheCallback() throws PortInUseException, IOException {
MockupBridgeHandler handler = this.handler;
ThingHandlerCallback callbackMock = this.callbackMock;
Bridge bridgeMock = this.bridgeMock;
SerialBridgeConfiguration config = this.config;
assertNotNull(handler);
assertNotNull(callbackMock);
assertNotNull(bridgeMock);
assertNotNull(config);
handler.initialize();
verify(callbackMock).statusUpdated(eq(bridgeMock), argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE)));
resetMock();
config.charset = "UTF-8";
handler.initialize();
verify(callbackMock).statusUpdated(eq(bridgeMock), argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE)));
resetMock();
config.charset = "foobar";
handler.initialize();
verify(callbackMock).statusUpdated(eq(bridgeMock), argThat(arg -> arg.getStatus().equals(ThingStatus.OFFLINE)));
verify(callbackMock).statusUpdated(eq(bridgeMock),
argThat(arg -> arg.getStatusDetail().equals(ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR)));
resetMock();
config.charset = "HEX";
handler.initialize();
verify(callbackMock).statusUpdated(eq(bridgeMock), argThat(arg -> arg.getStatus().equals(ThingStatus.OFFLINE)));
verify(callbackMock).statusUpdated(eq(bridgeMock),
argThat(arg -> arg.getStatusDetail().equals(ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR)));
resetMock();
config.eolPattern = "Invalid(Pattern";
handler.initialize();
verify(callbackMock).statusUpdated(eq(bridgeMock), argThat(arg -> arg.getStatus().equals(ThingStatus.OFFLINE)));
verify(callbackMock).statusUpdated(eq(bridgeMock),
argThat(arg -> arg.getStatusDetail().equals(ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR)));
resetMock();
config.eolPattern = "ValidPattern";
handler.initialize();
verify(callbackMock).statusUpdated(eq(bridgeMock), argThat(arg -> arg.getStatus().equals(ThingStatus.ONLINE)));
}
@Test
void testWriteUtf8String() {
MockupBridgeHandler handler = this.handler;
SerialBridgeConfiguration config = this.config;
ByteArrayOutputStream bos = this.bos;
assertNotNull(config);
assertNotNull(handler);
assertNotNull(bos);
config.charset = "UTF-8";
handler.initialize();
handler.writeString("föö");
assertEquals("föö", bos.toString(StandardCharsets.UTF_8));
}
@Test
void testWriteLatin1String() {
MockupBridgeHandler handler = this.handler;
SerialBridgeConfiguration config = this.config;
ByteArrayOutputStream bos = this.bos;
assertNotNull(config);
assertNotNull(handler);
assertNotNull(bos);
config.charset = "ISO-8859-1";
handler.initialize();
handler.writeString("föö");
assertEquals("föö", bos.toString(StandardCharsets.ISO_8859_1));
}
@Test
void testWriteHexString() {
MockupBridgeHandler handler = this.handler;
SerialBridgeConfiguration config = this.config;
ByteArrayOutputStream bos = this.bos;
assertNotNull(config);
assertNotNull(handler);
assertNotNull(bos);
config.charset = "HEX";
config.eolPattern = "\\b0A";
handler.initialize();
byte[] bytes = { 'T', 'E', 'S', 'T' };
String str = HexFormat.of().withDelimiter(" ").formatHex(bytes);
assertEquals(str, "54 45 53 54");
handler.writeString(str);
assertArrayEquals(bytes, bos.toByteArray());
}
@Test
void testReadUtf8String() {
MockupBridgeHandler handler = this.handler;
SerialBridgeConfiguration config = this.config;
ByteBufferInputStream bin = this.bin;
assertNotNull(config);
assertNotNull(handler);
assertNotNull(bin);
config.charset = "UTF-8";
handler.initialize();
bin.appendString("föö", StandardCharsets.UTF_8);
StringBuilder sb = new StringBuilder();
handler.receiveAndProcess(sb, false);
assertEquals("föö", sb.toString());
}
@Test
void testReadLatin1String() {
MockupBridgeHandler handler = this.handler;
SerialBridgeConfiguration config = this.config;
ByteBufferInputStream bin = this.bin;
assertNotNull(config);
assertNotNull(handler);
assertNotNull(bin);
config.charset = "ISO-8859-1";
handler.initialize();
bin.appendString("föö", StandardCharsets.ISO_8859_1);
StringBuilder sb = new StringBuilder();
handler.receiveAndProcess(sb, false);
assertEquals("föö", sb.toString());
}
@Test
void testReadHexString() {
MockupBridgeHandler handler = this.handler;
SerialBridgeConfiguration config = this.config;
ByteArrayOutputStream bos = this.bos;
ByteBufferInputStream bin = this.bin;
assertNotNull(config);
assertNotNull(handler);
assertNotNull(bos);
assertNotNull(bin);
config.charset = "HEX";
config.eolPattern = "\\b0A";
handler.initialize();
bin.appendBuffer(new byte[] { 'T', 'E', 'S', 'T' });
StringBuilder sb = new StringBuilder();
handler.receiveAndProcess(sb, false);
assertEquals("54 45 53 54", sb.toString());
// Test with newlines
bin.appendBuffer(new byte[] { 'T', 'E', 'S', 'T', '1', '\n', 'T', 'E', 'S', 'T', '2' });
sb = new StringBuilder();
handler.receiveAndProcess(sb, false);
assertEquals("54 45 53 54 31 0A\n54 45 53 54 32", sb.toString());
}
}