added migrated 2.x add-ons

Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Kai Kreuzer
2020-09-21 01:58:32 +02:00
parent bbf1a7fd29
commit 6df6783b60
11662 changed files with 1302875 additions and 11 deletions

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.oceanic-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-oceanic" description="Oceanic Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-serial</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.oceanic/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2010-2020 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.oceanic.internal;
/**
* @author Karel Goderis - Initial contribution
*/
public class NetworkOceanicBindingConfiguration {
public String ipAddress;
public Integer portNumber;
public Integer interval;
}

View File

@@ -0,0 +1,305 @@
/**
* Copyright (c) 2010-2020 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.oceanic.internal;
import java.io.InvalidClassException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.eclipse.jdt.annotation.NonNullByDefault;
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.StringType;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.types.Type;
/**
* The {@link OceanicBinding} class defines common constants, which are used
* across the whole binding.
*
* @author Karel Goderis - Initial contribution
*/
@NonNullByDefault
public class OceanicBindingConstants {
public static final String BINDING_ID = "oceanic";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_SERIAL = new ThingTypeUID(BINDING_ID, "serial");
public static final ThingTypeUID THING_TYPE_NETWORK = new ThingTypeUID(BINDING_ID, "network");
// List of all Channel ids
public enum OceanicChannelSelector {
getSRN("serial", StringType.class, ValueSelectorType.GET, true),
getMAC("mac", StringType.class, ValueSelectorType.GET, true),
getDNA("name", StringType.class, ValueSelectorType.GET, true),
getSCR("type", StringType.class, ValueSelectorType.GET, true) {
@Override
public String convertValue(String value) {
int index = Integer.valueOf(value);
String convertedValue = value;
switch (index) {
case 0:
convertedValue = "Single";
break;
case 1:
convertedValue = "Double Alternative";
break;
case 2:
convertedValue = "Triple Alternative";
break;
case 3:
convertedValue = "Double Parallel";
break;
case 4:
convertedValue = "Triple Parallel";
break;
case 5:
convertedValue = "Single Filter";
break;
case 6:
convertedValue = "Double Filter";
break;
case 7:
convertedValue = "Triple Filter";
break;
default:
break;
}
return convertedValue;
}
},
getALM("alarm", StringType.class, ValueSelectorType.GET, false) {
@Override
public String convertValue(String value) {
int index = Integer.valueOf(value);
String convertedValue = value;
switch (index) {
case 0:
convertedValue = "No Alarm";
break;
case 1:
convertedValue = "Lack of salt during regeneration";
break;
case 2:
convertedValue = "Water pressure too low";
break;
case 3:
convertedValue = "Water pressure too high";
break;
case 4:
convertedValue = "Pressure sensor failure";
break;
case 5:
convertedValue = "Camshaft failure";
break;
default:
break;
}
return convertedValue;
}
},
getNOT("alert", StringType.class, ValueSelectorType.GET, false) {
@Override
public String convertValue(String value) {
int index = Integer.valueOf(value);
String convertedValue = value;
switch (index) {
case 0:
convertedValue = "No Alert";
break;
case 1:
convertedValue = "Imminent lack of salt";
break;
default:
break;
}
return convertedValue;
}
},
getFLO("totalflow", DecimalType.class, ValueSelectorType.GET, false),
getRES("reserve", DecimalType.class, ValueSelectorType.GET, false),
getCYN("cycle", StringType.class, ValueSelectorType.GET, false),
getCYT("endofcycle", StringType.class, ValueSelectorType.GET, false),
getRTI("endofregeneration", StringType.class, ValueSelectorType.GET, false),
getWHU("hardnessunit", StringType.class, ValueSelectorType.GET, false) {
@Override
public String convertValue(String value) {
int index = Integer.valueOf(value);
String convertedValue = value;
switch (index) {
case 0:
convertedValue = "dH";
break;
case 1:
convertedValue = "fH";
break;
case 2:
convertedValue = "e";
break;
case 3:
convertedValue = "mg CaCO3/l";
break;
case 4:
convertedValue = "ppm";
break;
case 5:
convertedValue = "mmol/l";
break;
case 6:
convertedValue = "mval/l";
break;
default:
break;
}
return convertedValue;
}
},
getIWH("inlethardness", DecimalType.class, ValueSelectorType.GET, false),
getOWH("outlethardness", DecimalType.class, ValueSelectorType.GET, false),
getRG1("cylinderstate", StringType.class, ValueSelectorType.GET, false) {
@Override
public String convertValue(String value) {
int index = Integer.valueOf(value);
String convertedValue = value;
switch (index) {
case 0:
convertedValue = "No regeneration";
break;
case 1:
convertedValue = "Paused";
break;
case 2:
convertedValue = "Regeneration";
break;
default:
break;
}
return convertedValue;
}
},
setSV1("salt", DecimalType.class, ValueSelectorType.SET, false),
getSV1("salt", DecimalType.class, ValueSelectorType.GET, false),
setSIR("regeneratenow", OnOffType.class, ValueSelectorType.SET, false),
setSDR("regeneratelater", OnOffType.class, ValueSelectorType.SET, false),
setSMR("multiregenerate", OnOffType.class, ValueSelectorType.SET, false),
getMOF("consumptionmonday", DecimalType.class, ValueSelectorType.GET, false),
getTUF("consumptiontuesday", DecimalType.class, ValueSelectorType.GET, false),
getWEF("consumptionwednesday", DecimalType.class, ValueSelectorType.GET, false),
getTHF("consumptionthursday", DecimalType.class, ValueSelectorType.GET, false),
getFRF("consumptionfriday", DecimalType.class, ValueSelectorType.GET, false),
getSAF("consumptionsaturday", DecimalType.class, ValueSelectorType.GET, false),
getSUF("consumptionsunday", DecimalType.class, ValueSelectorType.GET, false),
getTOF("consumptiontoday", DecimalType.class, ValueSelectorType.GET, false),
getYEF("consumptionyesterday", DecimalType.class, ValueSelectorType.GET, false),
getCWF("consumptioncurrentweek", DecimalType.class, ValueSelectorType.GET, false),
getLWF("consumptionlastweek", DecimalType.class, ValueSelectorType.GET, false),
getCMF("consumptioncurrentmonth", DecimalType.class, ValueSelectorType.GET, false),
getLMF("consumptionlastmonth", DecimalType.class, ValueSelectorType.GET, false),
getCOF("consumptioncomplete", DecimalType.class, ValueSelectorType.GET, false),
getUWF("consumptionuntreated", DecimalType.class, ValueSelectorType.GET, false),
getTFO("consumptionpeaklevel", DecimalType.class, ValueSelectorType.GET, false),
getPRS("pressure", DecimalType.class, ValueSelectorType.GET, false),
getMXP("maxpressure", DecimalType.class, ValueSelectorType.GET, false),
getMNP("minpressure", DecimalType.class, ValueSelectorType.GET, false),
getMXF("maxflow", DecimalType.class, ValueSelectorType.GET, false),
getLAR("lastgeneration", DateTimeType.class, ValueSelectorType.GET, false) {
@Override
public String convertValue(String value) {
final SimpleDateFormat inDateFormatter = new SimpleDateFormat("dd.MM.yy HH:mm:ss");
final SimpleDateFormat outDateFormatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
try {
Date date = inDateFormatter.parse(value);
return outDateFormatter.format(date);
} catch (ParseException fpe) {
throw new IllegalArgumentException(value + " is not in a valid format.", fpe);
}
}
},
getNOR("normalregenerations", DecimalType.class, ValueSelectorType.GET, false),
getSRE("serviceregenerations", DecimalType.class, ValueSelectorType.GET, false),
getINR("incompleteregenerations", DecimalType.class, ValueSelectorType.GET, false),
getTOR("allregenerations", DecimalType.class, ValueSelectorType.GET, false);
private final String text;
private Class<? extends Type> typeClass;
private ValueSelectorType typeValue;
private boolean isProperty;
private OceanicChannelSelector(final String text, Class<? extends Type> typeClass, ValueSelectorType typeValue,
boolean isProperty) {
this.text = text;
this.typeClass = typeClass;
this.typeValue = typeValue;
this.isProperty = isProperty;
}
@Override
public String toString() {
return text;
}
public Class<? extends Type> getTypeClass() {
return typeClass;
}
public ValueSelectorType getTypeValue() {
return typeValue;
}
public boolean isProperty() {
return isProperty;
}
/**
* Procedure to convert selector string to value selector class.
*
* @param valueSelectorText selector string e.g. RawData, Command, Temperature
* @return corresponding selector value.
* @throws InvalidClassException Not valid class for value selector.
*/
public static OceanicChannelSelector getValueSelector(String valueSelectorText,
ValueSelectorType valueSelectorType) throws IllegalArgumentException {
for (OceanicChannelSelector c : OceanicChannelSelector.values()) {
if (c.text.equals(valueSelectorText) && c.typeValue == valueSelectorType) {
return c;
}
}
throw new IllegalArgumentException("Not valid value selector");
}
public static ValueSelectorType getValueSelectorType(String valueSelectorText) throws IllegalArgumentException {
for (OceanicChannelSelector c : OceanicChannelSelector.values()) {
if (c.text.equals(valueSelectorText)) {
return c.typeValue;
}
}
throw new IllegalArgumentException("Not valid value selector");
}
public String convertValue(String value) {
return value;
}
public enum ValueSelectorType {
GET,
SET
}
}
}

View File

@@ -0,0 +1,61 @@
/**
* Copyright (c) 2010-2020 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.oceanic.internal;
import static org.openhab.binding.oceanic.internal.OceanicBindingConstants.*;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.openhab.binding.oceanic.internal.handler.NetworkOceanicThingHandler;
import org.openhab.binding.oceanic.internal.handler.SerialOceanicThingHandler;
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;
/**
* The {@link OceanicHandlerFactory} is responsible for creating things and
* thing handlers.
*
* @author Karel Goderis - Initial contribution
*/
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.oceanic")
public class OceanicHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
.unmodifiableSet(Stream.of(THING_TYPE_SERIAL, THING_TYPE_NETWORK).collect(Collectors.toSet()));
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(THING_TYPE_SERIAL)) {
return new SerialOceanicThingHandler(thing);
}
if (thingTypeUID.equals(THING_TYPE_NETWORK)) {
return new NetworkOceanicThingHandler(thing);
}
return null;
}
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2010-2020 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.oceanic.internal;
/**
* @author Karel Goderis - Initial contribution
*/
public class SerialOceanicBindingConfiguration {
public String port;
public Integer interval;
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) 2010-2020 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.oceanic.internal;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link Throttler} is helper class that regulates the frequency at which messages/packets are sent to
* the serial port.
*
* @author Karel Goderis - Initial Contribution
*/
public class Throttler {
private static Logger logger = LoggerFactory.getLogger(Throttler.class);
public static final long INTERVAL = 1000;
private static ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();
private static ConcurrentHashMap<String, Long> timestamps = new ConcurrentHashMap<>();
public static void lock(String key) {
if (!locks.containsKey(key)) {
locks.put(key, new ReentrantLock());
}
locks.get(key).lock();
if (timestamps.get(key) != null) {
long lastStamp = timestamps.get(key);
long timeToWait = Math.max(INTERVAL - (System.currentTimeMillis() - lastStamp), 0);
if (timeToWait > 0) {
try {
Thread.sleep(timeToWait);
} catch (InterruptedException e) {
logger.error("An exception occurred while putting the thread to sleep : '{}'", e.getMessage());
}
}
}
}
public static void unlock(String key) {
if (locks.containsKey(key)) {
timestamps.put(key, System.currentTimeMillis());
locks.get(key).unlock();
}
}
public static void lock() {
for (ReentrantLock aLock : locks.values()) {
aLock.lock();
}
long lastStamp = 0;
for (Long aStamp : timestamps.values()) {
if (aStamp > lastStamp) {
lastStamp = aStamp;
}
}
long timeToWait = Math.max(INTERVAL - (System.currentTimeMillis() - lastStamp), 0);
if (timeToWait > 0) {
try {
Thread.sleep(timeToWait);
} catch (InterruptedException e) {
logger.error("An exception occurred while putting the thread to sleep : '{}'", e.getMessage());
}
}
}
public static void unlock() {
for (String key : locks.keySet()) {
if (locks.get(key).isHeldByCurrentThread()) {
timestamps.put(key, System.currentTimeMillis());
locks.get(key).unlock();
}
}
}
}

View File

@@ -0,0 +1,217 @@
/**
* Copyright (c) 2010-2020 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.oceanic.internal.handler;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.oceanic.internal.NetworkOceanicBindingConfiguration;
import org.openhab.binding.oceanic.internal.Throttler;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link NetworkOceanicThingHandler} implements {@link OceanicThingHandler} for a Oceanic water softener that is
* reached using a socat TCP proxy
*
* @author Karel Goderis - Initial contribution
*/
public class NetworkOceanicThingHandler extends OceanicThingHandler {
private static final int REQUEST_TIMEOUT = 3000;
private static final int RECONNECT_INTERVAL = 15;
private final Logger logger = LoggerFactory.getLogger(NetworkOceanicThingHandler.class);
private Socket socket;
private InputStream inputStream;
private OutputStream outputStream;
protected ScheduledFuture<?> reconnectJob;
public NetworkOceanicThingHandler(Thing thing) {
super(thing);
}
@Override
public void initialize() {
super.initialize();
NetworkOceanicBindingConfiguration config = getConfigAs(NetworkOceanicBindingConfiguration.class);
try {
socket = new Socket(config.ipAddress, config.portNumber);
socket.setSoTimeout(REQUEST_TIMEOUT);
outputStream = socket.getOutputStream();
inputStream = socket.getInputStream();
updateStatus(ThingStatus.ONLINE);
} catch (UnknownHostException e) {
logger.error("An exception occurred while resolving host {}:{} : '{}'", config.ipAddress, config.portNumber,
e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Could not resolve host " + config.ipAddress + ": " + e.getMessage());
} catch (IOException e) {
logger.debug("An exception occurred while connecting to host {}:{} : '{}'", config.ipAddress,
config.portNumber, e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Could not connect to host " + config.ipAddress + ": " + e.getMessage());
reconnectJob = scheduler.schedule(reconnectRunnable, RECONNECT_INTERVAL, TimeUnit.SECONDS);
}
}
@Override
public void dispose() {
NetworkOceanicBindingConfiguration config = getConfigAs(NetworkOceanicBindingConfiguration.class);
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
logger.error("An exception occurred while disconnecting to host {}:{} : '{}'", config.ipAddress,
config.portNumber, e.getMessage());
} finally {
socket = null;
outputStream = null;
inputStream = null;
}
}
super.dispose();
}
@Override
protected String requestResponse(String commandAsString) {
synchronized (this) {
if (getThing().getStatus() == ThingStatus.ONLINE) {
NetworkOceanicBindingConfiguration config = getConfigAs(NetworkOceanicBindingConfiguration.class);
Throttler.lock(config.ipAddress);
String request = commandAsString + "\r";
byte[] dataBuffer = new byte[bufferSize];
byte[] tmpData = new byte[bufferSize];
String line;
int len = -1;
int index = 0;
boolean sequenceFound = false;
final byte lineFeed = (byte) '\n';
final byte carriageReturn = (byte) '\r';
final byte nullChar = (byte) '\0';
final byte eChar = (byte) 'E';
final byte rChar = (byte) 'R';
try {
logger.debug("Sending request '{}'", request);
outputStream.write(request.getBytes());
outputStream.flush();
while (true) {
if ((len = inputStream.read(tmpData)) > -1) {
if (logger.isTraceEnabled()) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < len; i++) {
sb.append(String.format("%02X ", tmpData[i]));
}
logger.trace("Read {} bytes : {}", len, sb.toString());
}
for (int i = 0; i < len; i++) {
if (logger.isTraceEnabled()) {
logger.trace("Byte {} equals '{}' (hex '{}')", i,
new String(new byte[] { tmpData[i] }), String.format("%02X", tmpData[i]));
}
if (tmpData[i] == nullChar && !sequenceFound) {
sequenceFound = true;
logger.trace("Start of sequence found");
}
if (sequenceFound && tmpData[i] != lineFeed && tmpData[i] != carriageReturn
&& tmpData[i] != nullChar) {
dataBuffer[index++] = tmpData[i];
if (logger.isTraceEnabled()) {
logger.trace("dataBuffer[{}] set to '{}'(hex '{}')", index - 1,
new String(new byte[] { dataBuffer[index - 1] }),
String.format("%02X", dataBuffer[index - 1]));
}
}
if (sequenceFound && i >= 2) {
if (tmpData[i - 2] == eChar && tmpData[i - 1] == rChar && tmpData[i] == rChar) {
// Received ERR from the device.
return null;
}
}
if (sequenceFound && i > 0
&& (tmpData[i - 1] != carriageReturn && tmpData[i] == nullChar)) {
index = 0;
// Ignore trash received
if (logger.isTraceEnabled()) {
StringBuilder sb = new StringBuilder();
for (int j = 0; j < i; j++) {
sb.append(String.format("%02X ", tmpData[j]));
}
logger.trace("Ingoring {} bytes : {}", i, sb);
}
}
if (sequenceFound && (tmpData[i] == carriageReturn)) {
if (index > 0) {
line = new String(Arrays.copyOf(dataBuffer, index));
logger.debug("Received response '{}'", line);
line = StringUtils.chomp(line);
line = line.replace(",", ".");
line = line.trim();
index = 0;
return line;
}
}
if (index == bufferSize) {
index = 0;
}
}
}
}
} catch (IOException e) {
logger.debug("An exception occurred while quering host {}:{} : '{}'", config.ipAddress,
config.portNumber, e.getMessage(), e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
reconnectJob = scheduler.schedule(reconnectRunnable, RECONNECT_INTERVAL, TimeUnit.SECONDS);
} finally {
Throttler.unlock(config.ipAddress);
}
}
return null;
}
}
private Runnable reconnectRunnable = () -> {
dispose();
initialize();
};
}

View File

@@ -0,0 +1,169 @@
/**
* Copyright (c) 2010-2020 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.oceanic.internal.handler;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNull;
import org.openhab.binding.oceanic.internal.OceanicBindingConstants.OceanicChannelSelector;
import org.openhab.core.thing.Channel;
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.binding.BaseThingHandler;
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.TypeParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link OceanicThingHandler} is the abstract class responsible for handling commands, which are
* sent to one of the channels
*
* @author Karel Goderis - Initial contribution
*/
public abstract class OceanicThingHandler extends BaseThingHandler {
public static final String INTERVAL = "interval";
public static final String BUFFER_SIZE = "buffer";
private final Logger logger = LoggerFactory.getLogger(OceanicThingHandler.class);
protected int bufferSize;
protected ScheduledFuture<?> pollingJob;
protected static String lastLineReceived = "";
public OceanicThingHandler(@NonNull Thing thing) {
super(thing);
}
private Runnable resetRunnable = () -> {
dispose();
initialize();
};
private Runnable pollingRunnable = () -> {
try {
if (getThing().getStatus() == ThingStatus.ONLINE) {
for (Channel aChannel : getThing().getChannels()) {
for (OceanicChannelSelector selector : OceanicChannelSelector.values()) {
ChannelUID theChannelUID = new ChannelUID(getThing().getUID(), selector.toString());
if (aChannel.getUID().equals(theChannelUID)
&& selector.getTypeValue() == OceanicChannelSelector.ValueSelectorType.GET) {
String response = requestResponse(selector.name());
if (response != null && response != "") {
if (selector.isProperty()) {
logger.debug("Updating the property '{}' with value '{}'", selector.toString(),
selector.convertValue(response));
Map<String, String> properties = editProperties();
properties.put(selector.toString(), selector.convertValue(response));
updateProperties(properties);
} else {
State value = createStateForType(selector, response);
updateState(theChannelUID, value);
}
} else {
logger.warn("Received an empty answer for '{}'", selector.name());
}
}
}
}
}
} catch (Exception e) {
logger.error("An exception occurred while polling the Oceanic Water Softener: '{}'", e.getMessage(), e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
scheduler.schedule(resetRunnable, 0, TimeUnit.SECONDS);
}
};
@Override
public void initialize() {
if (getConfig().get(BUFFER_SIZE) == null) {
bufferSize = 1024;
} else {
bufferSize = ((BigDecimal) getConfig().get(BUFFER_SIZE)).intValue();
}
if (pollingJob == null || pollingJob.isCancelled()) {
pollingJob = scheduler.scheduleWithFixedDelay(pollingRunnable, 1,
((BigDecimal) getConfig().get(INTERVAL)).intValue(), TimeUnit.SECONDS);
}
}
@Override
public void dispose() {
if (pollingJob != null && !pollingJob.isCancelled()) {
pollingJob.cancel(true);
pollingJob = null;
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (getThing().getStatus() == ThingStatus.ONLINE) {
if (!(command instanceof RefreshType)) {
String commandAsString = command.toString();
String channelID = channelUID.getId();
for (Channel aChannel : getThing().getChannels()) {
if (aChannel.getUID().equals(channelUID)) {
try {
OceanicChannelSelector selector = OceanicChannelSelector.getValueSelector(channelID,
OceanicChannelSelector.ValueSelectorType.SET);
switch (selector) {
case setSV1:
commandAsString = selector.name() + commandAsString;
break;
default:
commandAsString = selector.name();
break;
}
String response = requestResponse(commandAsString);
if (response.equals("ERR")) {
logger.error("An error occurred while setting '{}' to {}", selector.toString(),
commandAsString);
}
} catch (IllegalArgumentException e) {
logger.warn(
"An error occurred while trying to set the read-only variable associated with channel '{}' to '{}'",
channelID, command.toString());
}
break;
}
}
}
}
}
@SuppressWarnings("unchecked")
private State createStateForType(OceanicChannelSelector selector, String value) {
Class<? extends Type> typeClass = selector.getTypeClass();
List<Class<? extends State>> stateTypeList = new ArrayList<>();
stateTypeList.add((Class<? extends State>) typeClass);
State state = TypeParser.parseState(stateTypeList, selector.convertValue(value));
return state;
}
protected abstract String requestResponse(String commandAsString);
}

View File

@@ -0,0 +1,322 @@
/**
* Copyright (c) 2010-2020 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.oceanic.internal.handler;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Enumeration;
import org.apache.commons.lang.StringUtils;
import org.openhab.binding.oceanic.internal.SerialOceanicBindingConfiguration;
import org.openhab.binding.oceanic.internal.Throttler;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import gnu.io.CommPortIdentifier;
import gnu.io.NoSuchPortException;
import gnu.io.PortInUseException;
import gnu.io.RXTXCommDriver;
import gnu.io.SerialPort;
import gnu.io.UnsupportedCommOperationException;
/**
* The {@link SerialOceanicThingHandler} implements {@link OceanicThingHandler} for a Oceanic water softener that is
* directly connected to a serial port of the openHAB host
*
* @author Karel Goderis - Initial contribution
*/
public class SerialOceanicThingHandler extends OceanicThingHandler {
private static final long REQUEST_TIMEOUT = 10000;
private static final int BAUD = 19200;
private final Logger logger = LoggerFactory.getLogger(SerialOceanicThingHandler.class);
private SerialPort serialPort;
private CommPortIdentifier portId;
private InputStream inputStream;
private OutputStream outputStream;
private SerialPortReader readerThread;
public SerialOceanicThingHandler(Thing thing) {
super(thing);
}
@Override
public void initialize() {
super.initialize();
SerialOceanicBindingConfiguration config = getConfigAs(SerialOceanicBindingConfiguration.class);
if (serialPort == null && config.port != null) {
if (portId == null) {
try {
RXTXCommDriver rxtxCommDriver = new RXTXCommDriver();
rxtxCommDriver.initialize();
CommPortIdentifier.addPortName(config.port, CommPortIdentifier.PORT_RAW, rxtxCommDriver);
portId = CommPortIdentifier.getPortIdentifier(config.port);
} catch (NoSuchPortException e) {
logger.error("An exception occurred while setting up serial port '{}' : '{}'", config.port,
e.getMessage(), e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Could not setup serial port " + serialPort + ": " + e.getMessage());
return;
}
}
if (portId != null) {
try {
serialPort = portId.open(this.getThing().getUID().getBindingId(), 2000);
} catch (PortInUseException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Could not open serial port " + serialPort + ": " + e.getMessage());
return;
}
try {
inputStream = serialPort.getInputStream();
} catch (IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Could not open serial port " + serialPort + ": " + e.getMessage());
return;
}
serialPort.notifyOnDataAvailable(true);
try {
serialPort.setSerialPortParams(BAUD, SerialPort.DATABITS_8, SerialPort.STOPBITS_1,
SerialPort.PARITY_NONE);
serialPort.setFlowControlMode(SerialPort.FLOWCONTROL_NONE);
} catch (UnsupportedCommOperationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Could not configure serial port " + serialPort + ": " + e.getMessage());
return;
}
try {
outputStream = serialPort.getOutputStream();
updateStatus(ThingStatus.ONLINE);
} catch (IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Could not communicate with the serial port " + serialPort + ": " + e.getMessage());
return;
}
readerThread = new SerialPortReader(inputStream);
readerThread.start();
} else {
StringBuilder sb = new StringBuilder();
@SuppressWarnings("rawtypes")
Enumeration portList = CommPortIdentifier.getPortIdentifiers();
while (portList.hasMoreElements()) {
CommPortIdentifier id = (CommPortIdentifier) portList.nextElement();
if (id.getPortType() == CommPortIdentifier.PORT_SERIAL) {
sb.append(id.getName() + "\n");
}
}
logger.error("Serial port '{}' could not be found. Available ports are:\n {}", config.port, sb);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR);
}
}
}
@Override
public void dispose() {
if (readerThread != null) {
try {
readerThread.interrupt();
readerThread.join();
} catch (InterruptedException e) {
logger.error("An exception occurred while interrupting the serial port reader thread : {}",
e.getMessage(), e);
}
}
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
logger.debug("Error while closing the input stream: {}", e.getMessage());
}
}
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
logger.debug("Error while closing the output stream: {}", e.getMessage());
}
}
if (serialPort != null) {
serialPort.close();
}
readerThread = null;
inputStream = null;
outputStream = null;
serialPort = null;
super.dispose();
}
@Override
protected String requestResponse(String commandAsString) {
synchronized (this) {
SerialOceanicBindingConfiguration config = getConfigAs(SerialOceanicBindingConfiguration.class);
Throttler.lock(config.port);
lastLineReceived = "";
String request = commandAsString + "\r";
String response = null;
try {
if (logger.isTraceEnabled()) {
logger.trace("Requesting : {} ('{}')", request, request.getBytes());
}
outputStream.write(request.getBytes());
outputStream.flush();
} catch (IOException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Error writing '" + request + "' to serial port " + config.port + " : " + e.getMessage());
}
long timeStamp = System.currentTimeMillis();
while (lastLineReceived.equals("")) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
logger.error("An exception occurred while putting the thread to sleep: {}", e.getMessage());
}
if (System.currentTimeMillis() - timeStamp > REQUEST_TIMEOUT) {
logger.warn("A timeout occurred while requesting data from the water softener");
readerThread.reset();
break;
}
}
response = lastLineReceived;
Throttler.unlock(config.port);
return response;
}
}
public class SerialPortReader extends Thread {
private boolean interrupted = false;
private InputStream inputStream;
int index = 0;
SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss,SSS");
public SerialPortReader(InputStream in) {
this.inputStream = in;
this.setName("SerialPortReader-" + getThing().getUID());
}
public void reset() {
logger.trace("Resetting the SerialPortReader");
index = 0;
}
@Override
public void interrupt() {
logger.trace("Interrupting the SerialPortReader");
interrupted = true;
super.interrupt();
}
@Override
public void run() {
logger.trace("Starting the serial port reader");
byte[] dataBuffer = new byte[bufferSize];
byte[] tmpData = new byte[bufferSize];
String line;
final byte lineFeed = (byte) '\n';
final byte carriageReturn = (byte) '\r';
final byte nullChar = (byte) '\0';
long sleep = 10;
int len = -1;
try {
while (!interrupted) {
logger.trace("Reading the inputStream");
if ((len = inputStream.read(tmpData)) > -1) {
if (logger.isTraceEnabled()) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < len; i++) {
sb.append(String.format("%02X ", tmpData[i]));
}
logger.trace("Read {} bytes : {}", len, sb.toString());
}
for (int i = 0; i < len; i++) {
if (logger.isTraceEnabled()) {
logger.trace("Byte {} equals '{}' (hex '{}')", i, new String(new byte[] { tmpData[i] }),
String.format("%02X", tmpData[i]));
}
if (tmpData[i] != lineFeed && tmpData[i] != carriageReturn && tmpData[i] != nullChar) {
dataBuffer[index++] = tmpData[i];
if (logger.isTraceEnabled()) {
logger.trace("dataBuffer[{}] set to '{}'(hex '{}')", index - 1,
new String(new byte[] { dataBuffer[index - 1] }),
String.format("%02X", dataBuffer[index - 1]));
}
}
if (i > 0 && (tmpData[i] == lineFeed || tmpData[i] == carriageReturn
|| tmpData[i] == nullChar)) {
if (index > 0) {
if (logger.isTraceEnabled()) {
logger.trace("The resulting line is '{}'",
new String(Arrays.copyOf(dataBuffer, index)));
}
line = StringUtils.chomp(new String(Arrays.copyOf(dataBuffer, index)));
line = line.replace(",", ".");
line = line.trim();
index = 0;
lastLineReceived = line;
break;
}
}
if (index == bufferSize) {
index = 0;
}
}
}
try {
Thread.sleep(sleep);
} catch (InterruptedException e) {
// Move on silently
}
}
} catch (Exception e) {
logger.error("An exception occurred while reading serial port : {}", e.getMessage(), e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<binding:binding id="oceanic" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
<name>openHAB Oceanic Binding</name>
<description>This is the binding for Oceanic Water Softener.</description>
<author>Karel Goderis</author>
</binding:binding>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="oceanic"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Oceanic Channel Types -->
<channel-type id="alarm">
<item-type>String</item-type>
<label>Alarm</label>
<description>Current alarm description, if any</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="alert">
<item-type>String</item-type>
<label>Alert</label>
<description>Current alert description, if any, to notify a shortage of salt</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="flow" advanced="true">
<item-type>Decimal</item-type>
<label>Flow</label>
<description>Flow in l/min</description>
<state pattern="%.1f l/min" readOnly="true"></state>
</channel-type>
<channel-type id="reserve" advanced="true">
<item-type>Decimal</item-type>
<label>Water Reserve</label>
<description>Water reserve in l before regeneration has to start</description>
<state pattern="%d l" readOnly="true"></state>
</channel-type>
<channel-type id="cycle">
<item-type>String</item-type>
<label>Cycle</label>
<description>Indicates the stage of the regeneration cycle</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="time" advanced="true">
<item-type>String</item-type>
<label>Date/Time</label>
<description>Date/Time stamp</description>
<state pattern="%1$td.%1$tm.%1$tY %1$tT" readOnly="true"></state>
</channel-type>
<channel-type id="unit" advanced="true">
<item-type>String</item-type>
<label>Unit</label>
<description>Hardness unit used to express hardness</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="hardness" advanced="true">
<item-type>Number</item-type>
<label>Water Hardness</label>
<description>Water hardness expressed using the chosen hardness unit</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="cylinderstate">
<item-type>String</item-type>
<label>Cylinder State</label>
<description>Indicates the state of the regeneration cylinder(s)</description>
<state readOnly="true"></state>
</channel-type>
<channel-type id="salt">
<item-type>Number</item-type>
<label>Salt</label>
<description>Volume of salt remaining, in kg</description>
<state pattern="%d kg" readOnly="true"></state>
</channel-type>
<channel-type id="regeneratenow">
<item-type>Switch</item-type>
<label>Regenerate Now</label>
<description>Start immediate regeneration</description>
</channel-type>
<channel-type id="regeneratelater">
<item-type>Switch</item-type>
<label>Regenerate Later</label>
<description>Start a delayed regeneration</description>
</channel-type>
<channel-type id="multiregenerate">
<item-type>Switch</item-type>
<label>Start Multi-regeneration</label>
<description>Start a multi-regeneration</description>
</channel-type>
<channel-type id="consumption" advanced="true">
<item-type>Number</item-type>
<label>Water Consumption</label>
<description>Water consumption, in l</description>
<state pattern="%d l" readOnly="true"></state>
</channel-type>
<channel-type id="pressure">
<item-type>Number</item-type>
<label>Water Pressure</label>
<description>Water pressure, in bar</description>
<state pattern="%.1f bar" readOnly="true"></state>
</channel-type>
<channel-type id="number" advanced="true">
<item-type>Number</item-type>
<label>Regenerations</label>
<description>Number of regenerations</description>
<state readOnly="true"></state>
</channel-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="oceanic"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="network">
<label>Oceanic Water Softener</label>
<description>Oceanic Water Softener connected through a network proxy</description>
<channels>
<channel id="alarm" typeId="alarm"/>
<channel id="alert" typeId="alert"/>
<channel id="totalflow" typeId="flow"/>
<channel id="reserve" typeId="reserve"/>
<channel id="cycle" typeId="cycle"/>
<channel id="endofcycle" typeId="time"/>
<channel id="endofregeneration" typeId="time"/>
<channel id="hardnessunit" typeId="unit"/>
<channel id="inlethardness" typeId="hardness"/>
<channel id="outlethardness" typeId="hardness"/>
<channel id="cylinderstate" typeId="cylinderstate"/>
<channel id="salt" typeId="salt"/>
<channel id="regeneratenow" typeId="regeneratenow"/>
<channel id="regeneratelater" typeId="regeneratelater"/>
<channel id="multiregenerate" typeId="multiregenerate"/>
<channel id="consumptionmonday" typeId="consumption"/>
<channel id="consumptiontuesday" typeId="consumption"/>
<channel id="consumptionwednesday" typeId="consumption"/>
<channel id="consumptionthursday" typeId="consumption"/>
<channel id="consumptionfriday" typeId="consumption"/>
<channel id="consumptionsaturday" typeId="consumption"/>
<channel id="consumptionsunday" typeId="consumption"/>
<channel id="consumptiontoday" typeId="consumption"/>
<channel id="consumptionyesterday" typeId="consumption"/>
<channel id="consumptioncurrentweek" typeId="consumption"/>
<channel id="consumptionlastweek" typeId="consumption"/>
<channel id="consumptioncurrentmonth" typeId="consumption"/>
<channel id="consumptionlastmonth" typeId="consumption"/>
<channel id="consumptioncomplete" typeId="consumption"/>
<channel id="consumptionuntreated" typeId="consumption"/>
<channel id="consumptionpeaklevel" typeId="consumption"/>
<channel id="pressure" typeId="pressure"/>
<channel id="maxpressure" typeId="pressure"/>
<channel id="minpressure" typeId="pressure"/>
<channel id="maxflow" typeId="flow"/>
<channel id="lastgeneration" typeId="time"/>
<channel id="normalregenerations" typeId="number"/>
<channel id="serviceregenerations" typeId="number"/>
<channel id="incompleteregenerations" typeId="number"/>
<channel id="allregenerations" typeId="number"/>
</channels>
<config-description>
<parameter name="ipAddress" type="text" required="true">
<context>network-address</context>
<label>Network Address</label>
<description>Network address of the network proxy</description>
</parameter>
<parameter name="portNumber" type="integer" required="true">
<description>Port number of the network proxy</description>
<required>false</required>
<label>Port</label>
</parameter>
<parameter name="interval" type="decimal" required="true">
<label>Polling Interval</label>
<description>Interval in seconds to poll the Oceanic Water Softener for status updates</description>
<default>60</default>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="oceanic"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="serial">
<label>Oceanic Water Softener</label>
<description>Oceanic Water Softener connected through a serial port</description>
<channels>
<channel id="alarm" typeId="alarm"/>
<channel id="alert" typeId="alert"/>
<channel id="totalflow" typeId="flow"/>
<channel id="reserve" typeId="reserve"/>
<channel id="cycle" typeId="cycle"/>
<channel id="endofcycle" typeId="time"/>
<channel id="endofregeneration" typeId="time"/>
<channel id="hardnessunit" typeId="unit"/>
<channel id="inlethardness" typeId="hardness"/>
<channel id="outlethardness" typeId="hardness"/>
<channel id="cylinderstate" typeId="cylinderstate"/>
<channel id="salt" typeId="salt"/>
<channel id="regeneratenow" typeId="regeneratenow"/>
<channel id="regeneratelater" typeId="regeneratelater"/>
<channel id="multiregenerate" typeId="multiregenerate"/>
<channel id="consumptionmonday" typeId="consumption"/>
<channel id="consumptiontuesday" typeId="consumption"/>
<channel id="consumptionwednesday" typeId="consumption"/>
<channel id="consumptionthursday" typeId="consumption"/>
<channel id="consumptionfriday" typeId="consumption"/>
<channel id="consumptionsaturday" typeId="consumption"/>
<channel id="consumptionsunday" typeId="consumption"/>
<channel id="consumptiontoday" typeId="consumption"/>
<channel id="consumptionyesterday" typeId="consumption"/>
<channel id="consumptioncurrentweek" typeId="consumption"/>
<channel id="consumptionlastweek" typeId="consumption"/>
<channel id="consumptioncurrentmonth" typeId="consumption"/>
<channel id="consumptionlastmonth" typeId="consumption"/>
<channel id="consumptioncomplete" typeId="consumption"/>
<channel id="consumptionuntreated" typeId="consumption"/>
<channel id="consumptionpeaklevel" typeId="consumption"/>
<channel id="pressure" typeId="pressure"/>
<channel id="maxpressure" typeId="pressure"/>
<channel id="minpressure" typeId="pressure"/>
<channel id="maxflow" typeId="flow"/>
<channel id="lastgeneration" typeId="time"/>
<channel id="normalregenerations" typeId="number"/>
<channel id="serviceregenerations" typeId="number"/>
<channel id="incompleteregenerations" typeId="number"/>
<channel id="allregenerations" typeId="number"/>
</channels>
<config-description>
<parameter name="port" type="text" required="true">
<label>Serial Port</label>
<context>serial-port</context>
<limitToOptions>false</limitToOptions>
<description>Serial Port the Oceanic Water Softener is connected to</description>
</parameter>
<parameter name="interval" type="decimal" required="true">
<label>Polling Interval</label>
<description>Interval in seconds to poll the Oceanic Water Softener for status updates</description>
<default>60</default>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>