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,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.mqtt.homie-${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-mqtt-homie" description="MQTT Binding Homie" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature>openhab-transport-mqtt</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version}</bundle>
<bundle start-level="81">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}</bundle>
<bundle start-level="82">mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.homie/${project.version}</bundle>
</feature>
</features>

View File

@@ -0,0 +1,40 @@
/**
* 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.mqtt.homie.generic.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link MqttBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class MqttBindingConstants {
public static final String BINDING_ID = "mqtt";
// List of all Thing Type UIDs
public static final ThingTypeUID HOMIE300_MQTT_THING = new ThingTypeUID(BINDING_ID, "homie300");
public static final String CONFIG_HOMIE_CHANNEL = "mqtt:homie_channel";
public static final String HOMIE_PROPERTY_VERSION = "homieversion";
public static final String HOMIE_PROPERTY_HEARTBEAT_INTERVAL = "heartbeat_interval";
public static final int HOMIE_DEVICE_TIMEOUT_MS = 30000;
public static final int HOMIE_SUBSCRIBE_TIMEOUT_MS = 500;
public static final int HOMIE_ATTRIBUTE_TIMEOUT_MS = 200;
}

View File

@@ -0,0 +1,102 @@
/**
* 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.mqtt.homie.generic.internal;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.MqttChannelStateDescriptionProvider;
import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
import org.openhab.binding.mqtt.homie.internal.handler.HomieThingHandler;
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.openhab.core.transform.TransformationHelper;
import org.openhab.core.transform.TransformationService;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link MqttThingHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author David Graeff - Initial contribution
*/
@Component(service = ThingHandlerFactory.class)
@NonNullByDefault
public class MqttThingHandlerFactory extends BaseThingHandlerFactory implements TransformationServiceProvider {
private @NonNullByDefault({}) MqttChannelTypeProvider typeProvider;
private @NonNullByDefault({}) MqttChannelStateDescriptionProvider stateDescriptionProvider;
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Stream
.of(MqttBindingConstants.HOMIE300_MQTT_THING).collect(Collectors.toSet());
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Activate
@Override
protected void activate(ComponentContext componentContext) {
super.activate(componentContext);
}
@Deactivate
@Override
protected void deactivate(ComponentContext componentContext) {
super.deactivate(componentContext);
}
@Reference
protected void setStateDescriptionProvider(MqttChannelStateDescriptionProvider stateDescription) {
this.stateDescriptionProvider = stateDescription;
}
protected void unsetStateDescriptionProvider(MqttChannelStateDescriptionProvider stateDescription) {
this.stateDescriptionProvider = null;
}
@Reference
protected void setChannelProvider(MqttChannelTypeProvider provider) {
this.typeProvider = provider;
}
protected void unsetChannelProvider(MqttChannelTypeProvider provider) {
this.typeProvider = null;
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(MqttBindingConstants.HOMIE300_MQTT_THING)) {
return new HomieThingHandler(thing, typeProvider, MqttBindingConstants.HOMIE_DEVICE_TIMEOUT_MS,
MqttBindingConstants.HOMIE_SUBSCRIBE_TIMEOUT_MS, MqttBindingConstants.HOMIE_ATTRIBUTE_TIMEOUT_MS);
}
return null;
}
@Override
public @Nullable TransformationService getTransformationService(String type) {
return TransformationHelper.getTransformationService(bundleContext, type);
}
}

View File

@@ -0,0 +1,117 @@
/**
* 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.mqtt.homie.internal.discovery;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.discovery.AbstractMQTTDiscovery;
import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService;
import org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link Homie300Discovery} is responsible for discovering device nodes that follow the
* Homie 3.x convention (https://github.com/homieiot/convention).
*
* @author David Graeff - Initial contribution
*/
@Component(immediate = true, service = DiscoveryService.class, configurationPid = "discovery.mqtthomie")
@NonNullByDefault
public class Homie300Discovery extends AbstractMQTTDiscovery {
private final Logger logger = LoggerFactory.getLogger(Homie300Discovery.class);
protected final MQTTTopicDiscoveryService discoveryService;
@Activate
public Homie300Discovery(@Reference MQTTTopicDiscoveryService discoveryService) {
super(Collections.singleton(MqttBindingConstants.HOMIE300_MQTT_THING), 3, true, "+/+/$homie");
this.discoveryService = discoveryService;
}
@Override
protected MQTTTopicDiscoveryService getDiscoveryService() {
return discoveryService;
}
/**
* @param topic A topic like "homie/mydevice/$homie"
* @return Returns the "mydevice" part of the example
*/
public static @Nullable String extractDeviceID(String topic) {
String[] strings = topic.split("/");
if (strings.length > 2) {
return strings[1];
}
return null;
}
/**
* Returns true if the version is something like "3.x" or "4.x".
*/
public static boolean checkVersion(byte[] payload) {
return payload.length > 0 && (payload[0] == '3' || payload[0] == '4');
}
@Override
public void receivedMessage(ThingUID connectionBridge, MqttBrokerConnection connection, String topic,
byte[] payload) {
resetTimeout();
if (!checkVersion(payload)) {
logger.trace("Found homie device. But version {} is out of range.",
new String(payload, StandardCharsets.UTF_8));
return;
}
final String deviceID = extractDeviceID(topic);
if (deviceID == null) {
logger.trace("Found homie device. But deviceID {} is invalid.", deviceID);
return;
}
publishDevice(connectionBridge, connection, deviceID, topic, deviceID);
}
void publishDevice(ThingUID connectionBridge, MqttBrokerConnection connection, String deviceID, String topic,
String name) {
Map<String, Object> properties = new HashMap<>();
properties.put("deviceid", deviceID);
properties.put("basetopic", topic.substring(0, topic.indexOf("/")));
thingDiscovered(DiscoveryResultBuilder
.create(new ThingUID(MqttBindingConstants.HOMIE300_MQTT_THING, connectionBridge, deviceID))
.withBridge(connectionBridge).withProperties(properties).withRepresentationProperty("deviceid")
.withLabel(name).build());
}
@Override
public void topicVanished(ThingUID connectionBridge, MqttBrokerConnection connection, String topic) {
String deviceID = extractDeviceID(topic);
if (deviceID == null) {
return;
}
thingRemoved(new ThingUID(MqttBindingConstants.HOMIE300_MQTT_THING, connectionBridge, deviceID));
}
}

View File

@@ -0,0 +1,252 @@
/**
* 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.mqtt.homie.internal.handler;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledFuture;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.AbstractMQTTThingHandler;
import org.openhab.binding.mqtt.generic.ChannelState;
import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
import org.openhab.binding.mqtt.generic.tools.DelayedBatchProcessing;
import org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants;
import org.openhab.binding.mqtt.homie.internal.homie300.Device;
import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes;
import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes.ReadyState;
import org.openhab.binding.mqtt.homie.internal.homie300.DeviceCallback;
import org.openhab.binding.mqtt.homie.internal.homie300.HandlerConfiguration;
import org.openhab.binding.mqtt.homie.internal.homie300.Node;
import org.openhab.binding.mqtt.homie.internal.homie300.Property;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Handles MQTT topics that follow the Homie MQTT convention. The convention specifies a MQTT topic layout
* and defines Devices, Nodes and Properties, corresponding to Things, Channel Groups and Channels respectively.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HomieThingHandler extends AbstractMQTTThingHandler implements DeviceCallback, Consumer<List<Object>> {
private final Logger logger = LoggerFactory.getLogger(HomieThingHandler.class);
protected Device device;
protected final MqttChannelTypeProvider channelTypeProvider;
/** The timeout per attribute field subscription */
protected final int attributeReceiveTimeout;
protected final int subscribeTimeout;
protected final int deviceTimeout;
protected HandlerConfiguration config = new HandlerConfiguration();
protected DelayedBatchProcessing<Object> delayedProcessing;
private @Nullable ScheduledFuture<?> heartBeatTimer;
/**
* Create a new thing handler for homie discovered things. A channel type provider and a topic value receive timeout
* must be provided.
*
* @param thing The thing of this handler
* @param channelTypeProvider A channel type provider
* @param deviceTimeout Timeout for the entire device subscription. In milliseconds.
* @param subscribeTimeout Timeout for an entire attribute class subscription and receive. In milliseconds.
* Even a slow remote device will publish a full node or property within 100ms.
* @param attributeReceiveTimeout The timeout per attribute field subscription. In milliseconds.
* One attribute subscription and receiving should not take longer than 50ms.
*/
public HomieThingHandler(Thing thing, MqttChannelTypeProvider channelTypeProvider, int deviceTimeout,
int subscribeTimeout, int attributeReceiveTimeout) {
super(thing, deviceTimeout);
this.channelTypeProvider = channelTypeProvider;
this.deviceTimeout = deviceTimeout;
this.subscribeTimeout = subscribeTimeout;
this.attributeReceiveTimeout = attributeReceiveTimeout;
this.delayedProcessing = new DelayedBatchProcessing<>(subscribeTimeout, this, scheduler);
this.device = new Device(this.thing.getUID(), this, new DeviceAttributes());
}
/**
* Overwrite the {@link Device} and {@link DelayedBatchProcessing} object.
* Those are set in the constructor already, but require to be replaced for tests.
*
* @param device The device object
* @param delayedProcessing The delayed processing object
*/
protected void setInternalObjects(Device device, DelayedBatchProcessing<Object> delayedProcessing) {
this.device = device;
this.delayedProcessing = delayedProcessing;
}
@Override
public void initialize() {
logger.debug("About to initialize Homie device {}", device.attributes.name);
config = getConfigAs(HandlerConfiguration.class);
if (config.deviceid.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Object ID unknown");
return;
}
device.initialize(config.basetopic, config.deviceid, thing.getChannels());
super.initialize();
}
@Override
public void handleRemoval() {
this.stop();
if (config.removetopics) {
this.removeRetainedTopics();
}
super.handleRemoval();
}
@Override
protected CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection) {
logger.debug("About to start Homie device {}", device.attributes.name);
if (connection.getQos() != 1) {
// QoS 1 is required.
logger.warn(
"Homie devices require QoS 1 but Qos 0/2 is configured. Using override. Please check the configuration");
connection.setQos(1);
}
return device.subscribe(connection, scheduler, attributeReceiveTimeout).thenCompose((Void v) -> {
return device.startChannels(connection, scheduler, attributeReceiveTimeout, this);
}).thenRun(() -> {
logger.debug("Homie device {} fully attached (start)", device.attributes.name);
});
}
@Override
protected void stop() {
logger.debug("About to stop Homie device {}", device.attributes.name);
final ScheduledFuture<?> heartBeatTimer = this.heartBeatTimer;
if (heartBeatTimer != null) {
heartBeatTimer.cancel(false);
this.heartBeatTimer = null;
}
delayedProcessing.join();
device.stop();
super.stop();
}
@Override
public CompletableFuture<Void> unsubscribeAll() {
// already unsubscribed everything by calling stop()
return CompletableFuture.allOf();
}
@Override
public @Nullable ChannelState getChannelState(ChannelUID channelUID) {
Property property = device.getProperty(channelUID);
return property != null ? property.getChannelState() : null;
}
@Override
public void readyStateChanged(ReadyState state) {
switch (state) {
case alert:
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_ERROR);
break;
case disconnected:
updateStatus(ThingStatus.OFFLINE);
break;
case init:
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING);
break;
case lost:
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Device did not send heartbeat in time");
break;
case ready:
updateStatus(ThingStatus.ONLINE);
break;
case sleeping:
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.DUTY_CYCLE);
break;
case unknown:
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Device did not publish a ready state");
break;
default:
break;
}
}
@Override
public void nodeRemoved(Node node) {
channelTypeProvider.removeChannelGroupType(node.channelGroupTypeUID);
delayedProcessing.accept(node);
}
@Override
public void propertyRemoved(Property property) {
channelTypeProvider.removeChannelType(property.channelTypeUID);
delayedProcessing.accept(property);
}
@Override
public void nodeAddedOrChanged(Node node) {
channelTypeProvider.setChannelGroupType(node.channelGroupTypeUID, node.type());
delayedProcessing.accept(node);
}
@Override
public void propertyAddedOrChanged(Property property) {
channelTypeProvider.setChannelType(property.channelTypeUID, property.getType());
delayedProcessing.accept(property);
}
/**
* Callback of {@link DelayedBatchProcessing}.
* Add all newly discovered nodes and properties to the Thing and start subscribe to each channel state topic.
*/
@Override
public void accept(@Nullable List<Object> t) {
if (!device.isInitialized()) {
return;
}
List<Channel> channels = device.nodes().stream().flatMap(n -> n.properties.stream()).map(Property::getChannel)
.collect(Collectors.toList());
updateThing(editThing().withChannels(channels).build());
updateProperty(MqttBindingConstants.HOMIE_PROPERTY_VERSION, device.attributes.homie);
final MqttBrokerConnection connection = this.connection;
if (connection != null) {
device.startChannels(connection, scheduler, attributeReceiveTimeout, this).thenRun(() -> {
logger.debug("Homie device {} fully attached (accept)", device.attributes.name);
});
}
}
/**
* Removes all retained topics related to the device
*/
private void removeRetainedTopics() {
MqttBrokerConnection connection = this.connection;
if (connection == null) {
logger.warn("couldn't remove retained topics for {} because connection is null", thing.getUID());
return;
}
device.getRetainedTopics().stream().map(d -> {
return String.format("%s/%s", config.basetopic, d);
}).collect(Collectors.toList()).forEach(t -> connection.publish(t, new byte[0], 1, true));
}
@Override
protected void updateThingStatus(boolean messageReceived, boolean availabilityTopicsSeen) {
// not used here
}
}

View File

@@ -0,0 +1,326 @@
/**
* 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.mqtt.homie.internal.homie300;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.ChannelConfig;
import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
import org.openhab.binding.mqtt.generic.tools.ChildMap;
import org.openhab.binding.mqtt.homie.internal.handler.HomieThingHandler;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.util.UIDUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Homie 3.x Device. This is also the base class to subscribe to and parse a homie MQTT topic tree.
* First use {@link #subscribe(AbstractMqttAttributeClass)} to subscribe to the device/nodes/properties tree.
* If everything has been received and parsed, call {@link #startChannels(MqttBrokerConnection, HomieThingHandler)}
* to also subscribe to the property values. Usage:
*
* <pre>
* Device device(thingUID, callback);
* device.subscribe(topicMapper,timeout).thenRun(()-> {
* System.out.println("All attributes received. Device tree ready");
* device.startChannels(connection, handler);
* });
* </pre>
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class Device implements AbstractMqttAttributeClass.AttributeChanged {
private final Logger logger = LoggerFactory.getLogger(Device.class);
// The device attributes, statistics and nodes of this device
public final DeviceAttributes attributes;
public final ChildMap<Node> nodes;
// The corresponding ThingUID and callback of this device object
public final ThingUID thingUID;
private final DeviceCallback callback;
// Unique identifier and topic
private String topic = "";
public String deviceID = "";
private boolean initialized = false;
/**
* Creates a Homie Device structure. It consists of device attributes, device statistics and nodes.
*
* @param thingUID The thing UID
* @param callback A callback, used to notify about new/removed nodes/properties and more.
* @param attributes The device attributes object
*/
public Device(ThingUID thingUID, DeviceCallback callback, DeviceAttributes attributes) {
this.thingUID = thingUID;
this.callback = callback;
this.attributes = attributes;
this.nodes = new ChildMap<>();
}
/**
* Creates a Homie Device structure. It consists of device attributes, device statistics and nodes.
*
* @param thingUID The thing UID
* @param callback A callback, used to notify about new/removed nodes/properties and more.
* @param attributes The device attributes object
* @param nodes The nodes map
*/
public Device(ThingUID thingUID, DeviceCallback callback, DeviceAttributes attributes, ChildMap<Node> nodes) {
this.thingUID = thingUID;
this.callback = callback;
this.attributes = attributes;
this.nodes = nodes;
}
/**
* Subscribe to all device attributes and device statistics. Parse the nodes
* and subscribe to all node attributes. Parse node properties. This will not subscribe
* to properties though. If subscribing to all necessary topics worked {@link #isInitialized()} will return true.
*
* Call {@link #startChannels(MqttBrokerConnection)} subsequently.
*
* @param connection A broker connection
* @param scheduler A scheduler to realize the timeout
* @param timeout A timeout in milliseconds
* @return A future that is complete as soon as all attributes, nodes and properties have been requested and have
* been subscribed to.
*/
public CompletableFuture<@Nullable Void> subscribe(MqttBrokerConnection connection,
ScheduledExecutorService scheduler, int timeout) {
if (topic.isEmpty()) {
throw new IllegalStateException("You must call initialize()!");
}
return attributes.subscribeAndReceive(connection, scheduler, topic, this, timeout)
// On success, create all nodes and tell the handler about the ready state
.thenCompose(b -> attributesReceived(connection, scheduler, timeout))
// No matter if values have been received or not -> the subscriptions have been performed
.whenComplete((r, e) -> {
initialized = true;
});
}
public CompletableFuture<@Nullable Void> attributesReceived(MqttBrokerConnection connection,
ScheduledExecutorService scheduler, int timeout) {
callback.readyStateChanged(attributes.state);
return applyNodes(connection, scheduler, timeout);
}
/**
* Subscribe to all property state topics. The handler will receive an update call for each
* received value. Therefore the thing channels should have been created before.
*
* @param connection A broker connection
* @param scheduler A scheduler to realize the timeout
* @param timeout A timeout in milliseconds. Can be 0 to disable the timeout and let the future return earlier.
* @param handler The Homie handler, that receives property (channel) updates.
* @return A future that is complete as soon as all properties have subscribed to their state topics.
*/
public CompletableFuture<@Nullable Void> startChannels(MqttBrokerConnection connection,
ScheduledExecutorService scheduler, int timeout, HomieThingHandler handler) {
if (!isInitialized() || deviceID.isEmpty()) {
CompletableFuture<@Nullable Void> c = new CompletableFuture<>();
c.completeExceptionally(new Exception("Homie Device Tree not inialized yet."));
return c;
}
return CompletableFuture.allOf(nodes.stream().flatMap(node -> node.properties.stream())
.map(p -> p.startChannel(connection, scheduler, timeout)).toArray(CompletableFuture[]::new));
}
/**
* Get a homie property (which translates to an ESH channel).
*
* @param channelUID The group ID corresponds to the Homie Node, the channel ID (without group ID) corresponds to
* the Nodes Property.
* @return A Homie Property, addressed by the given ChannelUID
*/
@SuppressWarnings({ "null", "unused" })
public @Nullable Property getProperty(ChannelUID channelUID) {
final String groupId = channelUID.getGroupId();
if (groupId == null) {
return null;
}
Node node = nodes.get(UIDUtils.decode(groupId));
if (node == null) {
return null;
}
return node.properties.get(UIDUtils.decode(channelUID.getIdWithoutGroup()));
}
/**
* Unsubscribe from everything.
*/
public CompletableFuture<@Nullable Void> stop() {
return attributes.unsubscribe().thenCompose(
b -> CompletableFuture.allOf(nodes.stream().map(Node::stop).toArray(CompletableFuture[]::new)));
}
/**
* Return all homie nodes on this device
*/
public ChildMap<Node> nodes() {
return nodes;
}
/**
* @return Return true if this device is initialized
*/
public boolean isInitialized() {
return initialized;
}
/**
* Restore Nodes and Properties from Thing channels after handler initalization.
*
* @param channels
*/
@SuppressWarnings({ "null", "unused" })
public void initialize(String baseTopic, String deviceID, List<Channel> channels) {
this.topic = baseTopic + "/" + deviceID;
this.deviceID = deviceID;
nodes.clear();
for (Channel channel : channels) {
final ChannelConfig channelConfig = channel.getConfiguration().as(ChannelConfig.class);
if (!channelConfig.commandTopic.isEmpty() && !channelConfig.retained) {
logger.warn("Channel {} in device {} is missing the 'retained' flag. Check your configuration.",
channel.getUID(), deviceID);
}
final String channelGroupId = channel.getUID().getGroupId();
if (channelGroupId == null) {
continue;
}
final String nodeID = UIDUtils.decode(channelGroupId);
final String propertyID = UIDUtils.decode(channel.getUID().getIdWithoutGroup());
Node node = nodes.get(nodeID);
if (node == null) {
node = createNode(nodeID);
node.nodeRestoredFromConfig();
nodes.put(nodeID, node);
}
// Restores the properties attribute object via the channels configuration.
Property property = node.createProperty(propertyID,
channel.getConfiguration().as(PropertyAttributes.class));
property.attributesReceived();
node.properties.put(propertyID, property);
}
}
/**
* Creates a new Homie Node, a child of this Homie Device.
*
* <p>
* Implementation detail: Cannot be used for mocking or spying within tests.
* </p>
*
* @param nodeID The node ID
* @return A child node
*/
public Node createNode(String nodeID) {
return new Node(topic, nodeID, thingUID, callback, new NodeAttributes());
}
/**
* Creates a new Homie Node, a child of this Homie Device.
*
* @param nodeID The node ID
* @param attributes The node attributes object
* @return A child node
*/
public Node createNode(String nodeID, NodeAttributes attributes) {
return new Node(topic, nodeID, thingUID, callback, attributes);
}
/**
* <p>
* The nodes of a device are determined by the device attribute "$nodes". If that attribute changes,
* {@link #attributeChanged(CompletableFuture, String, Object, MqttBrokerConnection, ScheduledExecutorService)} is
* called. The {@link #nodes} map will be synchronized and this method will be called for every removed node.
* </p>
*
* <p>
* This method will stop the node and will notify about the removed node all removed properties.
* </p>
*
* @param node The removed node.
*/
protected void notifyNodeRemoved(Node node) {
node.stop();
node.properties.stream().forEach(property -> node.notifyPropertyRemoved(property));
callback.nodeRemoved(node);
}
CompletableFuture<@Nullable Void> applyNodes(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
int timeout) {
return nodes.apply(attributes.nodes, node -> node.subscribe(connection, scheduler, timeout), this::createNode,
this::notifyNodeRemoved).exceptionally(e -> {
logger.warn("Could not subscribe", e);
return null;
});
}
@Override
public void attributeChanged(String name, Object value, MqttBrokerConnection connection,
ScheduledExecutorService scheduler, boolean allMandatoryFieldsReceived) {
if (!initialized || !allMandatoryFieldsReceived) {
return;
}
// Special case: Not all fields were known before
if (!attributes.isComplete()) {
attributesReceived(connection, scheduler, 500);
} else {
switch (name) {
case "state": {
callback.readyStateChanged(attributes.state);
return;
}
case "nodes": {
applyNodes(connection, scheduler, 500);
return;
}
}
}
}
/**
* Creates a list of retained topics related to the device
*
* @return Returns a list of relative topics
*/
public List<String> getRetainedTopics() {
List<String> topics = new ArrayList<>();
topics.addAll(Stream.of(this.attributes.getClass().getDeclaredFields()).map(f -> {
return String.format("%s/$%s", this.deviceID, f.getName());
}).collect(Collectors.toList()));
this.nodes.stream().map(n -> n.getRetainedTopics().stream().map(a -> {
return String.format("%s/%s", this.deviceID, a);
}).collect(Collectors.toList())).collect(Collectors.toList()).forEach(topics::addAll);
return topics;
}
}

View File

@@ -0,0 +1,48 @@
/**
* 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.mqtt.homie.internal.homie300;
import org.eclipse.jdt.annotation.NonNull;
import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
import org.openhab.binding.mqtt.generic.mapping.MQTTvalueTransform;
import org.openhab.binding.mqtt.generic.mapping.MandatoryField;
import org.openhab.binding.mqtt.generic.mapping.TopicPrefix;
/**
* Homie 3.x Device attributes
*
* @author David Graeff - Initial contribution
*/
@TopicPrefix
public class DeviceAttributes extends AbstractMqttAttributeClass {
// Lower-case enum value names required. Those are identifiers for the MQTT/homie protocol.
public enum ReadyState {
unknown,
init,
ready,
disconnected,
sleeping,
lost,
alert
}
public @MandatoryField String homie;
public @MandatoryField String name;
public @MandatoryField ReadyState state = ReadyState.unknown;
public @MandatoryField @MQTTvalueTransform(splitCharacter = ",") String[] nodes;
@Override
public @NonNull Object getFieldsOf() {
return this;
}
}

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.mqtt.homie.internal.homie300;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes.ReadyState;
/**
* Callbacks to inform about the Homie Device state, statistics changes, node layout changes.
* Meant to be used by the Homie thing handler.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public interface DeviceCallback extends ChannelStateUpdateListener {
/**
* Called whenever the device state changed
*
* @param state The new state
*/
void readyStateChanged(ReadyState state);
/**
* Called, whenever a Homie node was existing before, but is not anymore.
*
* @param node The affected node class.
*/
void nodeRemoved(Node node);
/**
* Called, whenever a Homie property was existing before, but is not anymore.
*
* @param node The affected property class.
*/
void propertyRemoved(Property property);
/**
* Called, whenever a Homie node was added or changed.
*
* @param node The affected node class.
*/
void nodeAddedOrChanged(Node node);
/**
* Called, whenever a Homie property was added or changed.
*
* @param node The affected property class.
*/
void propertyAddedOrChanged(Property property);
}

View File

@@ -0,0 +1,39 @@
/**
* 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.mqtt.homie.internal.homie300;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.homie.internal.handler.HomieThingHandler;
/**
* The {@link HomieThingHandler} manages Things that are responsible for
* Homie MQTT devices.
* This class contains the necessary configuration for such a Thing handler.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class HandlerConfiguration {
/**
* The MQTT prefix topic
*/
public String basetopic = "homie";
/**
* The device id.
*/
public String deviceid = "";
/**
* Indicates if retained topics should be removed when the Thing is deleted.
*/
public boolean removetopics = false;
}

View File

@@ -0,0 +1,224 @@
/**
* 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.mqtt.homie.internal.homie300;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
import org.openhab.binding.mqtt.generic.tools.ChildMap;
import org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.type.ChannelDefinition;
import org.openhab.core.thing.type.ChannelDefinitionBuilder;
import org.openhab.core.thing.type.ChannelGroupType;
import org.openhab.core.thing.type.ChannelGroupTypeBuilder;
import org.openhab.core.thing.type.ChannelGroupTypeUID;
import org.openhab.core.util.UIDUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Homie 3.x Node.
*
* A Homie Node contains Homie Properties ({@link Property}) but can also have attributes ({@link NodeAttributes}).
* It corresponds to an ESH ChannelGroup.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class Node implements AbstractMqttAttributeClass.AttributeChanged {
private final Logger logger = LoggerFactory.getLogger(Node.class);
// Homie
public final String nodeID;
public final NodeAttributes attributes;
public ChildMap<Property> properties;
// Runtime
public final DeviceCallback callback;
// ESH
protected final ChannelGroupUID channelGroupUID;
public final ChannelGroupTypeUID channelGroupTypeUID;
private final String topic;
private boolean initialized = false;
/**
* Creates a Homie Node.
*
* @param topic The base topic for this node (e.g. "homie/device")
* @param nodeID The node ID
* @param thingUID The Thing UID, used to determine the ChannelGroupUID.
* @param callback The callback for the handler.
*/
public Node(String topic, String nodeID, ThingUID thingUID, DeviceCallback callback, NodeAttributes attributes) {
this.attributes = attributes;
this.topic = topic + "/" + nodeID;
this.nodeID = nodeID;
this.callback = callback;
channelGroupTypeUID = new ChannelGroupTypeUID(MqttBindingConstants.BINDING_ID, UIDUtils.encode(this.topic));
channelGroupUID = new ChannelGroupUID(thingUID, UIDUtils.encode(nodeID));
properties = new ChildMap<>();
}
/**
* Parse node properties. This will not subscribe to properties though. Call
* {@link Device#startChannels(MqttBrokerConnection)} as soon as the returned future has
* completed.
*/
public CompletableFuture<@Nullable Void> subscribe(MqttBrokerConnection connection,
ScheduledExecutorService scheduler, int timeout) {
return attributes.subscribeAndReceive(connection, scheduler, topic, this, timeout)
// On success, create all properties and tell the handler about this node
.thenCompose(b -> attributesReceived(connection, scheduler, timeout))
// No matter if values have been received or not -> the subscriptions have been performed
.whenComplete((r, e) -> {
initialized = true;
});
}
public CompletableFuture<@Nullable Void> attributesReceived(MqttBrokerConnection connection,
ScheduledExecutorService scheduler, int timeout) {
callback.nodeAddedOrChanged(this);
return applyProperties(connection, scheduler, timeout);
}
public void nodeRestoredFromConfig() {
initialized = true;
}
/**
* Unsubscribe from node attribute and also all property attributes and the property value
*
* @param connection A broker connection
* @return Returns a future that completes as soon as all unsubscriptions have been performed.
*/
public CompletableFuture<@Nullable Void> stop() {
return attributes.unsubscribe().thenCompose(b -> CompletableFuture
.allOf(properties.stream().map(Property::stop).toArray(CompletableFuture[]::new)));
}
/**
* Return the channel group type for this Node.
*/
public ChannelGroupType type() {
final List<ChannelDefinition> channelDefinitions = properties.stream()
.map(c -> new ChannelDefinitionBuilder(c.propertyID, c.channelTypeUID).build())
.collect(Collectors.toList());
return ChannelGroupTypeBuilder.instance(channelGroupTypeUID, attributes.name)
.withChannelDefinitions(channelDefinitions).build();
}
/**
* Return the channel group UID.
*/
public ChannelGroupUID uid() {
return channelGroupUID;
}
/**
* Create a Homie Property for this Node.
*
* @param propertyID The property ID
* @return A Homie Property
*/
public Property createProperty(String propertyID) {
return new Property(topic, this, propertyID, callback, new PropertyAttributes());
}
/**
* Create a Homie Property for this Node.
*
* @param propertyID The property ID
* @param attributes The node attributes object
* @return A Homie Property
*/
public Property createProperty(String propertyID, PropertyAttributes attributes) {
return new Property(topic, this, propertyID, callback, attributes);
}
/**
* <p>
* The properties of a node are determined by the node attribute "$properties". If that attribute changes,
* {@link #attributeChanged(CompletableFuture, String, Object, MqttBrokerConnection, ScheduledExecutorService)} is
* called. The {@link #properties} map will be synchronized and this method will be called for every removed
* property.
* </p>
*
* <p>
* This method will stop the property and will notify about the removed property.
* </p>
*
* @param property The removed property.
*/
protected void notifyPropertyRemoved(Property property) {
property.stop();
callback.propertyRemoved(property);
}
protected CompletableFuture<@Nullable Void> applyProperties(MqttBrokerConnection connection,
ScheduledExecutorService scheduler, int timeout) {
return properties.apply(attributes.properties, prop -> prop.subscribe(connection, scheduler, timeout),
this::createProperty, this::notifyPropertyRemoved).exceptionally(e -> {
logger.warn("Could not subscribe", e);
return null;
});
}
@Override
public void attributeChanged(String name, Object value, MqttBrokerConnection connection,
ScheduledExecutorService scheduler, boolean allMandatoryFieldsReceived) {
if (!initialized || !allMandatoryFieldsReceived) {
return;
}
// Special case: Not all fields were known before
if (!attributes.isComplete()) {
attributesReceived(connection, scheduler, 500);
} else {
if ("properties".equals(name)) {
applyProperties(connection, scheduler, 500);
}
}
callback.nodeAddedOrChanged(this);
}
@Override
public String toString() {
return channelGroupUID.toString();
}
/**
* Creates a list of retained topics related to the node
*
* @return Returns a list of relative topics
*/
public List<String> getRetainedTopics() {
List<String> topics = new ArrayList<>();
topics.addAll(Stream.of(this.attributes.getClass().getDeclaredFields()).map(f -> {
return String.format("%s/$%s", this.nodeID, f.getName());
}).collect(Collectors.toList()));
this.properties.stream().map(p -> p.getRetainedTopics().stream().map(a -> {
return String.format("%s/%s", this.nodeID, a);
}).collect(Collectors.toList())).collect(Collectors.toList()).forEach(topics::addAll);
return topics;
}
}

View File

@@ -0,0 +1,37 @@
/**
* 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.mqtt.homie.internal.homie300;
import org.eclipse.jdt.annotation.NonNull;
import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
import org.openhab.binding.mqtt.generic.mapping.MQTTvalueTransform;
import org.openhab.binding.mqtt.generic.mapping.MandatoryField;
import org.openhab.binding.mqtt.generic.mapping.TopicPrefix;
/**
* Homie 3.x Node attributes
*
* @author David Graeff - Initial contribution
*/
@TopicPrefix
public class NodeAttributes extends AbstractMqttAttributeClass {
public @MandatoryField String name;
public @MandatoryField @MQTTvalueTransform(splitCharacter = ",") String[] properties;
// Type has no meaning for ESH yet and is currently purely of textual, descriptive nature
public String type;
@Override
public @NonNull Object getFieldsOf() {
return this;
}
}

View File

@@ -0,0 +1,355 @@
/**
* 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.mqtt.homie.internal.homie300;
import java.math.BigDecimal;
import java.math.MathContext;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.ChannelConfigBuilder;
import org.openhab.binding.mqtt.generic.ChannelState;
import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass.AttributeChanged;
import org.openhab.binding.mqtt.generic.mapping.ColorMode;
import org.openhab.binding.mqtt.generic.values.ColorValue;
import org.openhab.binding.mqtt.generic.values.NumberValue;
import org.openhab.binding.mqtt.generic.values.OnOffValue;
import org.openhab.binding.mqtt.generic.values.PercentageValue;
import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.binding.mqtt.generic.values.Value;
import org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants;
import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes.DataTypeEnum;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.type.AutoUpdatePolicy;
import org.openhab.core.thing.type.ChannelType;
import org.openhab.core.thing.type.ChannelTypeBuilder;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.openhab.core.util.UIDUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A homie Property (which translates into an ESH channel).
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class Property implements AttributeChanged {
private final Logger logger = LoggerFactory.getLogger(Property.class);
// Homie data
public final PropertyAttributes attributes;
public final Node parentNode;
public final String propertyID;
// Runtime state
protected @Nullable ChannelState channelState;
// ESH
public final ChannelUID channelUID;
public final ChannelTypeUID channelTypeUID;
private ChannelType type;
private Channel channel;
private final String topic;
private final DeviceCallback callback;
protected boolean initialized = false;
/**
* Creates a Homie Property.
*
* @param topic The base topic for this property (e.g. "homie/device/node")
* @param node The parent Homie Node.
* @param propertyID The unique property ID (among all properties on this Node).
*/
public Property(String topic, Node node, String propertyID, DeviceCallback callback,
PropertyAttributes attributes) {
this.callback = callback;
this.attributes = attributes;
this.topic = topic + "/" + propertyID;
this.parentNode = node;
this.propertyID = propertyID;
channelUID = new ChannelUID(node.uid(), UIDUtils.encode(propertyID));
channelTypeUID = new ChannelTypeUID(MqttBindingConstants.BINDING_ID, UIDUtils.encode(this.topic));
type = ChannelTypeBuilder.trigger(channelTypeUID, "dummy").build(); // Dummy value
channel = ChannelBuilder.create(channelUID, "dummy").build();// Dummy value
}
/**
* Subscribe to property attributes. This will not subscribe
* to the property value though. Call {@link Device#startChannels(MqttBrokerConnection)} to do that.
*
* @return Returns a future that completes as soon as all attribute values have been received or requests have timed
* out.
*/
public CompletableFuture<@Nullable Void> subscribe(MqttBrokerConnection connection,
ScheduledExecutorService scheduler, int timeout) {
return attributes.subscribeAndReceive(connection, scheduler, topic, this, timeout)
// On success, create the channel and tell the handler about this property
.thenRun(this::attributesReceived)
// No matter if values have been received or not -> the subscriptions have been performed
.whenComplete((r, e) -> {
initialized = true;
});
}
private @Nullable BigDecimal convertFromString(String value) {
try {
return new BigDecimal(value);
} catch (NumberFormatException ignore) {
logger.debug("Cannot convert {} to a number", value);
return null;
}
}
/**
* As soon as subscribing succeeded and corresponding MQTT values have been received, the ChannelType and
* ChannelState are determined.
*/
public void attributesReceived() {
createChannelFromAttribute();
callback.propertyAddedOrChanged(this);
}
/**
* Creates the ChannelType of the Homie property.
*
* @param attributes Attributes of the property.
* @param channelState ChannelState of the property.
*
* @return Returns the ChannelType to be used to build the Channel.
*/
private ChannelType createChannelType(PropertyAttributes attributes, ChannelState channelState) {
// Retained property -> State channel
if (attributes.retained) {
return ChannelTypeBuilder.state(channelTypeUID, attributes.name, channelState.getItemType())
.withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL))
.withStateDescription(channelState.getCache().createStateDescription(!attributes.settable).build()
.toStateDescription())
.build();
} else {
// Non-retained and settable property -> State channel
if (attributes.settable) {
return ChannelTypeBuilder.state(channelTypeUID, attributes.name, channelState.getItemType())
.withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL))
.withCommandDescription(channelState.getCache().createCommandDescription().build())
.withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build();
}
// Non-retained and non settable property -> Trigger channel
if (attributes.datatype.equals(DataTypeEnum.enum_)) {
if (attributes.format.contains("PRESSED") && attributes.format.contains("RELEASED")) {
return DefaultSystemChannelTypeProvider.SYSTEM_RAWBUTTON;
} else if (attributes.format.contains("SHORT_PRESSED") && attributes.format.contains("LONG_PRESSED")
&& attributes.format.contains("DOUBLE_PRESSED")) {
return DefaultSystemChannelTypeProvider.SYSTEM_BUTTON;
} else if (attributes.format.contains("DIR1_PRESSED") && attributes.format.contains("DIR1_RELEASED")
&& attributes.format.contains("DIR2_PRESSED") && attributes.format.contains("DIR2_RELEASED")) {
return DefaultSystemChannelTypeProvider.SYSTEM_RAWROCKER;
}
}
return ChannelTypeBuilder.trigger(channelTypeUID, attributes.name)
.withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL)).build();
}
}
public void createChannelFromAttribute() {
final String commandTopic = topic + "/set";
final String stateTopic = topic;
Value value;
Boolean isDecimal = null;
if (attributes.name == "") {
attributes.name = propertyID;
}
switch (attributes.datatype) {
case boolean_:
value = new OnOffValue("true", "false");
break;
case color_:
if (attributes.format.equals("hsv")) {
value = new ColorValue(ColorMode.HSB, null, null, 100);
} else if (attributes.format.equals("rgb")) {
value = new ColorValue(ColorMode.RGB, null, null, 100);
} else {
logger.warn("Non supported color format: '{}'. Only 'hsv' and 'rgb' are supported",
attributes.format);
value = new TextValue();
}
break;
case enum_:
String enumValues[] = attributes.format.split(",");
value = new TextValue(enumValues);
break;
case float_:
case integer_:
isDecimal = attributes.datatype == DataTypeEnum.float_;
String s[] = attributes.format.split("\\:");
BigDecimal min = s.length == 2 ? convertFromString(s[0]) : null;
BigDecimal max = s.length == 2 ? convertFromString(s[1]) : null;
BigDecimal step = (min != null && max != null)
? max.subtract(min).divide(new BigDecimal(100.0), new MathContext(isDecimal ? 2 : 0))
: null;
if (step != null && !isDecimal && step.intValue() <= 0) {
step = new BigDecimal(1);
}
if (attributes.unit.contains("%") && attributes.settable) {
value = new PercentageValue(min, max, step, null, null);
} else {
value = new NumberValue(min, max, step, attributes.unit);
}
break;
case string_:
case unknown:
default:
value = new TextValue();
break;
}
ChannelConfigBuilder b = ChannelConfigBuilder.create().makeTrigger(!attributes.retained)
.withStateTopic(stateTopic);
if (isDecimal != null && !isDecimal) {
b = b.withFormatter("%d"); // Apply formatter to only publish integers
}
if (attributes.settable) {
b = b.withCommandTopic(commandTopic).withRetain(false);
}
final ChannelState channelState = new ChannelState(b.build(), channelUID, value, callback);
this.channelState = channelState;
final ChannelType type = createChannelType(attributes, channelState);
this.type = type;
this.channel = ChannelBuilder.create(channelUID, type.getItemType()).withType(type.getUID())
.withKind(type.getKind()).withLabel(attributes.name)
.withConfiguration(new Configuration(attributes.asMap())).build();
}
/**
* Unsubscribe from all property attributes and the property value.
*
* @return Returns a future that completes as soon as all unsubscriptions have been performed.
*/
public CompletableFuture<@Nullable Void> stop() {
final ChannelState channelState = this.channelState;
if (channelState != null) {
return channelState.stop().thenCompose(b -> attributes.unsubscribe());
}
return attributes.unsubscribe();
}
/**
* @return Returns the channelState. You should have called
* {@link Property#subscribe(AbstractMqttAttributeClass, int)}
* and waited for the future to complete before calling this Getter.
*/
public @Nullable ChannelState getChannelState() {
return channelState;
}
/**
* Subscribes to the state topic on the given connection and informs about updates on the given listener.
*
* @param connection A broker connection
* @param scheduler A scheduler to realize the timeout
* @param timeout A timeout in milliseconds. Can be 0 to disable the timeout and let the future return earlier.
* @param channelStateUpdateListener An update listener
* @return A future that completes with true if the subscribing worked and false and/or exceptionally otherwise.
*/
public CompletableFuture<@Nullable Void> startChannel(MqttBrokerConnection connection,
ScheduledExecutorService scheduler, int timeout) {
final ChannelState channelState = this.channelState;
if (channelState == null) {
CompletableFuture<@Nullable Void> f = new CompletableFuture<>();
f.completeExceptionally(new IllegalStateException("Attributes not yet received!"));
return f;
}
// Make sure we set the callback again which might have been nulled during an stop
channelState.setChannelStateUpdateListener(this.callback);
return channelState.start(connection, scheduler, timeout);
}
/**
* @return Returns the channel type of this property.
* The type is a dummy only if {@link #channelState} has not been set yet.
*/
public ChannelType getType() {
return type;
}
/**
* @return Returns the channel of this property.
* The channel is a dummy only if {@link #channelState} has not been set yet.
*/
public Channel getChannel() {
return channel;
}
@Override
public String toString() {
return channelUID.toString();
}
/**
* Because the remote device could change any of the property attributes in-between,
* whenever that happens, we re-create the channel, channel-type and channelState.
*/
@Override
public void attributeChanged(String name, Object value, MqttBrokerConnection connection,
ScheduledExecutorService scheduler, boolean allMandatoryFieldsReceived) {
if (!initialized || !allMandatoryFieldsReceived) {
return;
}
attributesReceived();
}
/**
* Creates a list of retained topics related to the property
*
* @return Returns a list of relative topics
*/
public List<String> getRetainedTopics() {
List<String> topics = new ArrayList<>();
topics.addAll(Stream.of(this.attributes.getClass().getDeclaredFields()).map(f -> {
return String.format("%s/$%s", this.propertyID, f.getName());
}).collect(Collectors.toList()));
// All exceptions can be ignored because the 'retained' attribute of the PropertyAttributes class
// is public, is a boolean variable and has a default value (true)
try {
if (attributes.getClass().getDeclaredField("retained").getBoolean(attributes)) {
topics.add(this.propertyID);
}
} catch (NoSuchFieldException ignored) {
} catch (SecurityException ignored) {
} catch (IllegalArgumentException ignored) {
} catch (IllegalAccessException ignored) {
}
return topics;
}
}

View File

@@ -0,0 +1,76 @@
/**
* 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.mqtt.homie.internal.homie300;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
import org.openhab.binding.mqtt.generic.mapping.MQTTvalueTransform;
import org.openhab.binding.mqtt.generic.mapping.TopicPrefix;
/**
* Homie 3.x Property attributes
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
@TopicPrefix
public class PropertyAttributes extends AbstractMqttAttributeClass {
// Lower-case enum value names required. Those are identifiers for the MQTT/homie protocol.
public enum DataTypeEnum {
unknown,
integer_,
float_,
boolean_,
string_,
enum_,
color_
}
public String name = "";
/**
* stateful + non-settable: The node publishes a property state (temperature sensor)
* stateful + settable: The node publishes a property state, and can receive commands for the property (by
* controller or other party) (lamp power)
* stateless + non-settable: The node publishes momentary events (door bell pressed)
* stateless + settable: The node publishes momentary events, and can receive commands for the property (by
* controller or other party) (brew coffee)
*/
public boolean settable = false;
public boolean retained = true;
public String unit = "";
public @MQTTvalueTransform(suffix = "_") DataTypeEnum datatype = DataTypeEnum.unknown;
public String format = "";
@Override
public Object getFieldsOf() {
return this;
}
/**
* Return a map with all field values.
*/
public Map<String, Object> asMap() {
Map<String, Object> properties = new TreeMap<>();
properties.put("unit", unit);
properties.put("name", name);
properties.put("settable", settable ? "true" : "false");
properties.put("retained", retained ? "true" : "false");
properties.put("format", format);
properties.put("datatype", datatype.name());
return properties;
}
}

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="mqtt:homie_channel">
<parameter name="unit" type="text">
<label>Unit</label>
<description>The channels unit</description>
<default></default>
</parameter>
<parameter name="name" type="text">
<label>Name</label>
<description>The channel name</description>
<default></default>
</parameter>
<parameter name="settable" type="text">
<label>Settable</label>
<description>Is this channel writable?</description>
<default>true</default>
</parameter>
<parameter name="retained" type="text">
<label>Retained</label>
<description>If set to false, the resulting channel will be a trigger channel (stateless), useful for non-permanent
events. This flag corresponds to the retained option for MQTT publish.</description>
<default>true</default>
</parameter>
<parameter name="format" type="text">
<label>Format</label>
<description>The output format.</description>
<default></default>
</parameter>
<parameter name="datatype" type="text">
<label>Data Type</label>
<description>The data type of this channel.</description>
<default>unknown</default>
<options>
<option value="integer_">Integer</option>
<option value="float_">Float</option>
<option value="boolean_">Boolean</option>
<option value="string_">String</option>
<option value="enum_">Enumeration</option>
<option value="color_">Colour</option>
</options>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="mqtt"
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="homie300">
<supported-bridge-type-refs>
<bridge-type-ref id="broker"/>
<bridge-type-ref id="systemBroker"/>
</supported-bridge-type-refs>
<label>Homie MQTT Device</label>
<description>You need a configured Broker first. This thing represents a device, that follows the "MQTT Homie
Convention" (Version 3.x).</description>
<properties>
<property name="homieversion"/>
</properties>
<config-description>
<parameter name="deviceid" type="text" required="true">
<label>Device ID</label>
<description>Homie Device ID. This is part of the MQTT topic, e.g. "homie/deviceid/$homie".</description>
</parameter>
<parameter name="basetopic" type="text" required="true">
<label>MQTT Base Prefix</label>
<description>MQTT base prefix</description>
<default>homie</default>
</parameter>
<parameter name="removetopics" type="boolean">
<label>Remove Retained Topics</label>
<description>Remove retained topics when thing is deleted</description>
<default>false</default>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>

View File

@@ -0,0 +1,27 @@
/**
* 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.mqtt.homie;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.generic.ChannelState;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
/**
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class ChannelStateHelper {
public static void setConnection(ChannelState cs, MqttBrokerConnection connection) {
cs.setConnection(connection);
}
}

View File

@@ -0,0 +1,27 @@
/**
* 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.mqtt.homie;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.mqtt.generic.AbstractMQTTThingHandler;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
/**
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class ThingHandlerHelper {
public static void setConnection(AbstractMQTTThingHandler h, MqttBrokerConnection connection) {
h.setConnection(connection);
}
}

View File

@@ -0,0 +1,91 @@
/**
* 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.mqtt.homie.generic.internal.mapping;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.mockito.MockitoAnnotations.initMocks;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.openhab.binding.mqtt.generic.tools.ChildMap;
import org.openhab.binding.mqtt.homie.internal.handler.ThingChannelConstants;
import org.openhab.binding.mqtt.homie.internal.homie300.DeviceCallback;
import org.openhab.binding.mqtt.homie.internal.homie300.Node;
import org.openhab.binding.mqtt.homie.internal.homie300.NodeAttributes;
/**
* Tests cases for {@link HomieChildMap}.
*
* @author David Graeff - Initial contribution
*/
public class HomieChildMapTests {
private @Mock DeviceCallback callback;
private final String deviceID = ThingChannelConstants.TEST_HOMIE_THING.getId();
private final String deviceTopic = "homie/" + deviceID;
// A completed future is returned for a subscribe call to the attributes
final CompletableFuture<@Nullable Void> future = CompletableFuture.completedFuture(null);
ChildMap<Node> subject = new ChildMap<>();
private Node createNode(String id) {
Node node = new Node(deviceTopic, id, ThingChannelConstants.TEST_HOMIE_THING, callback,
spy(new NodeAttributes()));
doReturn(future).when(node.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
doReturn(future).when(node.attributes).unsubscribe();
return node;
}
private void removedNode(Node node) {
callback.nodeRemoved(node);
}
@Before
public void setUp() {
initMocks(this);
}
public static class AddedAction implements Function<Node, CompletableFuture<Void>> {
@Override
public CompletableFuture<Void> apply(Node t) {
return CompletableFuture.completedFuture(null);
}
}
@Test
public void testArrayToSubtopicCreateAndRemove() {
AddedAction addedAction = spy(new AddedAction());
// Assign "abc,def" to the
subject.apply(new String[] { "abc", "def" }, addedAction, this::createNode, this::removedNode);
assertThat(future.isDone(), is(true));
assertThat(subject.get("abc").nodeID, is("abc"));
assertThat(subject.get("def").nodeID, is("def"));
verify(addedAction, times(2)).apply(any());
Node soonToBeRemoved = subject.get("def");
subject.apply(new String[] { "abc" }, addedAction, this::createNode, this::removedNode);
verify(callback).nodeRemoved(eq(soonToBeRemoved));
}
}

View File

@@ -0,0 +1,372 @@
/**
* 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.mqtt.homie.internal.handler;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.openhab.binding.mqtt.homie.internal.handler.ThingChannelConstants.TEST_HOMIE_THING;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.invocation.InvocationOnMock;
import org.openhab.binding.mqtt.generic.ChannelState;
import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
import org.openhab.binding.mqtt.generic.mapping.SubscribeFieldToMQTTtopic;
import org.openhab.binding.mqtt.generic.tools.ChildMap;
import org.openhab.binding.mqtt.generic.tools.DelayedBatchProcessing;
import org.openhab.binding.mqtt.generic.values.Value;
import org.openhab.binding.mqtt.handler.AbstractBrokerHandler;
import org.openhab.binding.mqtt.homie.ChannelStateHelper;
import org.openhab.binding.mqtt.homie.ThingHandlerHelper;
import org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants;
import org.openhab.binding.mqtt.homie.internal.homie300.Device;
import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes;
import org.openhab.binding.mqtt.homie.internal.homie300.DeviceAttributes.ReadyState;
import org.openhab.binding.mqtt.homie.internal.homie300.Node;
import org.openhab.binding.mqtt.homie.internal.homie300.NodeAttributes;
import org.openhab.binding.mqtt.homie.internal.homie300.Property;
import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes;
import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes.DataTypeEnum;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingStatusInfo;
import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.thing.type.ChannelKind;
import org.openhab.core.thing.type.ThingTypeRegistry;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.TypeParser;
/**
* Tests cases for {@link HomieThingHandler}.
*
* @author David Graeff - Initial contribution
*/
public class HomieThingHandlerTests {
@Mock
private ThingHandlerCallback callback;
private Thing thing;
@Mock
private AbstractBrokerHandler bridgeHandler;
@Mock
private MqttBrokerConnection connection;
@Mock
private ScheduledExecutorService scheduler;
@Mock
private ScheduledFuture<?> scheduledFuture;
@Mock
private ThingTypeRegistry thingTypeRegistry;
private HomieThingHandler thingHandler;
private final MqttChannelTypeProvider channelTypeProvider = new MqttChannelTypeProvider(thingTypeRegistry);
private final String deviceID = ThingChannelConstants.TEST_HOMIE_THING.getId();
private final String deviceTopic = "homie/" + deviceID;
// A completed future is returned for a subscribe call to the attributes
CompletableFuture<@Nullable Void> future = CompletableFuture.completedFuture(null);
@Before
public void setUp() {
final ThingStatusInfo thingStatus = new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null);
MockitoAnnotations.initMocks(this);
final Configuration config = new Configuration();
config.put("basetopic", "homie");
config.put("deviceid", deviceID);
thing = ThingBuilder.create(MqttBindingConstants.HOMIE300_MQTT_THING, TEST_HOMIE_THING.getId())
.withConfiguration(config).build();
thing.setStatusInfo(thingStatus);
// Return the mocked connection object if the bridge handler is asked for it
when(bridgeHandler.getConnectionAsync()).thenReturn(CompletableFuture.completedFuture(connection));
doReturn(CompletableFuture.completedFuture(true)).when(connection).subscribe(any(), any());
doReturn(CompletableFuture.completedFuture(true)).when(connection).unsubscribe(any(), any());
doReturn(CompletableFuture.completedFuture(true)).when(connection).unsubscribeAll();
doReturn(CompletableFuture.completedFuture(true)).when(connection).publish(any(), any(), anyInt(),
anyBoolean());
doReturn(false).when(scheduledFuture).isDone();
doReturn(scheduledFuture).when(scheduler).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
final HomieThingHandler handler = new HomieThingHandler(thing, channelTypeProvider, 1000, 30, 5);
thingHandler = spy(handler);
thingHandler.setCallback(callback);
final Device device = new Device(thing.getUID(), thingHandler, spy(new DeviceAttributes()),
spy(new ChildMap<>()));
thingHandler.setInternalObjects(spy(device), spy(new DelayedBatchProcessing<>(500, thingHandler, scheduler)));
// Return the bridge handler if the thing handler asks for it
doReturn(bridgeHandler).when(thingHandler).getBridgeHandler();
// We are by default online
doReturn(thingStatus).when(thingHandler).getBridgeStatus();
}
@Test
public void initialize() {
assertThat(thingHandler.device.isInitialized(), is(false));
// // A completed future is returned for a subscribe call to the attributes
doReturn(future).when(thingHandler.device.attributes).subscribeAndReceive(any(), any(), anyString(), any(),
anyInt());
doReturn(future).when(thingHandler.device.attributes).unsubscribe();
// Prevent a call to accept, that would update our thing.
doNothing().when(thingHandler).accept(any());
// Pretend that a device state change arrived.
thingHandler.device.attributes.state = ReadyState.ready;
verify(callback, times(0)).statusUpdated(eq(thing), any());
thingHandler.initialize();
// Expect a call to the bridge status changed, the start, the propertiesChanged method
verify(thingHandler).bridgeStatusChanged(any());
verify(thingHandler).start(any());
verify(thingHandler).readyStateChanged(any());
verify(thingHandler.device.attributes).subscribeAndReceive(any(), any(),
argThat(arg -> deviceTopic.equals(arg)), any(), anyInt());
assertThat(thingHandler.device.isInitialized(), is(true));
verify(callback).statusUpdated(eq(thing), argThat((arg) -> arg.getStatus().equals(ThingStatus.ONLINE)
&& arg.getStatusDetail().equals(ThingStatusDetail.NONE)));
}
@Test
public void initializeGeneralTimeout() throws InterruptedException {
// A non completed future is returned for a subscribe call to the attributes
doReturn(future).when(thingHandler.device.attributes).subscribeAndReceive(any(), any(), anyString(), any(),
anyInt());
doReturn(future).when(thingHandler.device.attributes).unsubscribe();
// Prevent a call to accept, that would update our thing.
doNothing().when(thingHandler).accept(any());
thingHandler.initialize();
verify(callback).statusUpdated(eq(thing), argThat((arg) -> arg.getStatus().equals(ThingStatus.OFFLINE)
&& arg.getStatusDetail().equals(ThingStatusDetail.COMMUNICATION_ERROR)));
}
@Test
public void initializeNoStateReceived() throws InterruptedException {
// A completed future is returned for a subscribe call to the attributes
doReturn(future).when(thingHandler.device.attributes).subscribeAndReceive(any(), any(), anyString(), any(),
anyInt());
doReturn(future).when(thingHandler.device.attributes).unsubscribe();
// Prevent a call to accept, that would update our thing.
doNothing().when(thingHandler).accept(any());
thingHandler.initialize();
assertThat(thingHandler.device.isInitialized(), is(true));
verify(callback).statusUpdated(eq(thing), argThat((arg) -> arg.getStatus().equals(ThingStatus.OFFLINE)
&& arg.getStatusDetail().equals(ThingStatusDetail.GONE)));
}
@SuppressWarnings("null")
@Test
public void handleCommandRefresh() {
// Create mocked homie device tree with one node and one read-only property
Node node = thingHandler.device.createNode("node", spy(new NodeAttributes()));
doReturn(future).when(node.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
doReturn(future).when(node.attributes).unsubscribe();
node.attributes.name = "testnode";
Property property = node.createProperty("property", spy(new PropertyAttributes()));
doReturn(future).when(property.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
doReturn(future).when(property.attributes).unsubscribe();
property.attributes.name = "testprop";
property.attributes.datatype = DataTypeEnum.string_;
property.attributes.settable = false;
property.attributesReceived();
node.properties.put(property.propertyID, property);
thingHandler.device.nodes.put(node.nodeID, node);
ThingHandlerHelper.setConnection(thingHandler, connection);
// we need to set a channel value first, undefined values ignored on REFRESH
property.getChannelState().getCache().update(new StringType("testString"));
thingHandler.handleCommand(property.channelUID, RefreshType.REFRESH);
verify(callback).stateUpdated(argThat(arg -> property.channelUID.equals(arg)),
argThat(arg -> property.getChannelState().getCache().getChannelState().equals(arg)));
}
@SuppressWarnings("null")
@Test
public void handleCommandUpdate() {
// Create mocked homie device tree with one node and one writable property
Node node = thingHandler.device.createNode("node", spy(new NodeAttributes()));
doReturn(future).when(node.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
doReturn(future).when(node.attributes).unsubscribe();
node.attributes.name = "testnode";
Property property = node.createProperty("property", spy(new PropertyAttributes()));
doReturn(future).when(property.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
doReturn(future).when(property.attributes).unsubscribe();
property.attributes.name = "testprop";
property.attributes.datatype = DataTypeEnum.string_;
property.attributes.settable = true;
property.attributesReceived();
node.properties.put(property.propertyID, property);
thingHandler.device.nodes.put(node.nodeID, node);
ChannelState channelState = property.getChannelState();
assertNotNull(channelState);
ChannelStateHelper.setConnection(channelState, connection);// Pretend we called start()
ThingHandlerHelper.setConnection(thingHandler, connection);
StringType updateValue = new StringType("UPDATE");
thingHandler.handleCommand(property.channelUID, updateValue);
assertThat(property.getChannelState().getCache().getChannelState().toString(), is("UPDATE"));
verify(connection, times(1)).publish(any(), any(), anyInt(), anyBoolean());
// Check non writable property
property.attributes.settable = false;
property.attributesReceived();
// Assign old value
Value value = property.getChannelState().getCache();
Command command = TypeParser.parseCommand(value.getSupportedCommandTypes(), "OLDVALUE");
property.getChannelState().getCache().update(command);
// Try to update with new value
updateValue = new StringType("SOMETHINGNEW");
thingHandler.handleCommand(property.channelUID, updateValue);
// Expect old value and no MQTT publish
assertThat(property.getChannelState().getCache().getChannelState().toString(), is("OLDVALUE"));
verify(connection, times(1)).publish(any(), any(), anyInt(), anyBoolean());
}
public Object createSubscriberAnswer(InvocationOnMock invocation) {
final AbstractMqttAttributeClass attributes = (AbstractMqttAttributeClass) invocation.getMock();
final ScheduledExecutorService scheduler = (ScheduledExecutorService) invocation.getArguments()[0];
final Field field = (Field) invocation.getArguments()[1];
final String topic = (String) invocation.getArguments()[2];
final boolean mandatory = (boolean) invocation.getArguments()[3];
final SubscribeFieldToMQTTtopic s = spy(
new SubscribeFieldToMQTTtopic(scheduler, field, attributes, topic, mandatory));
doReturn(CompletableFuture.completedFuture(true)).when(s).subscribeAndReceive(any(), anyInt());
return s;
}
public Property createSpyProperty(String propertyID, Node node) {
// Create a property with the same ID and insert it instead
Property property = spy(node.createProperty(propertyID, spy(new PropertyAttributes())));
doAnswer(this::createSubscriberAnswer).when(property.attributes).createSubscriber(any(), any(), any(),
anyBoolean());
property.attributes.name = "testprop";
property.attributes.datatype = DataTypeEnum.string_;
return property;
}
public Node createSpyNode(String propertyID, Device device) {
// Create the node
Node node = spy(device.createNode("node", spy(new NodeAttributes())));
doReturn(future).when(node.attributes).subscribeAndReceive(any(), any(), anyString(), any(), anyInt());
doReturn(future).when(node.attributes).unsubscribe();
node.attributes.name = "testnode";
node.attributes.properties = new String[] { "property" };
doAnswer(this::createSubscriberAnswer).when(node.attributes).createSubscriber(any(), any(), any(),
anyBoolean());
// Intercept creating a property in the next call and inject a spy'ed property.
doAnswer(i -> createSpyProperty("property", node)).when(node).createProperty(any());
return node;
}
@Test
public void propertiesChanged() throws InterruptedException, ExecutionException {
thingHandler.device.initialize("homie", "device", new ArrayList<>());
ThingHandlerHelper.setConnection(thingHandler, connection);
// Create mocked homie device tree with one node and one property
doAnswer(this::createSubscriberAnswer).when(thingHandler.device.attributes).createSubscriber(any(), any(),
any(), anyBoolean());
thingHandler.device.attributes.state = ReadyState.ready;
thingHandler.device.attributes.name = "device";
thingHandler.device.attributes.homie = "3.0";
thingHandler.device.attributes.nodes = new String[] { "node" };
// Intercept creating a node in initialize()->start() and inject a spy'ed node.
doAnswer(i -> createSpyNode("node", thingHandler.device)).when(thingHandler.device).createNode(any());
verify(thingHandler, times(0)).nodeAddedOrChanged(any());
verify(thingHandler, times(0)).propertyAddedOrChanged(any());
thingHandler.initialize();
assertThat(thingHandler.device.isInitialized(), is(true));
verify(thingHandler).propertyAddedOrChanged(any());
verify(thingHandler).nodeAddedOrChanged(any());
verify(thingHandler.device).subscribe(any(), any(), anyInt());
verify(thingHandler.device).attributesReceived(any(), any(), anyInt());
assertNotNull(thingHandler.device.nodes.get("node").properties.get("property"));
assertTrue(thingHandler.delayedProcessing.isArmed());
// Simulate waiting for the delayed processor
thingHandler.delayedProcessing.forceProcessNow();
// Called for the updated property + for the new channels
verify(callback, atLeast(2)).thingUpdated(any());
final List<@NonNull Channel> channels = thingHandler.getThing().getChannels();
assertThat(channels.size(), is(1));
assertThat(channels.get(0).getLabel(), is("testprop"));
assertThat(channels.get(0).getKind(), is(ChannelKind.STATE));
final Map<@NonNull String, @NonNull String> properties = thingHandler.getThing().getProperties();
assertThat(properties.get(MqttBindingConstants.HOMIE_PROPERTY_VERSION), is("3.0"));
assertThat(properties.size(), is(1));
}
}

View File

@@ -0,0 +1,89 @@
/**
* 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.mqtt.homie.internal.handler;
import static org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.type.ChannelTypeUID;
/**
* Static test definitions, like thing, bridge and channel definitions
*
* @author David Graeff - Initial contribution
*/
public class ThingChannelConstants {
// Common ThingUID and ChannelUIDs
public static final ThingUID TEST_HOMIE_THING = new ThingUID(HOMIE300_MQTT_THING, "device123");
public static final ChannelTypeUID UNKNOWN_CHANNEL = new ChannelTypeUID(BINDING_ID, "unknown");
public static final String JSON_PATH_JSON = "{ \"device\": { \"status\": { \"temperature\": 23.2 }}}";
public static final String JSON_PATH_PATTERN = "$.device.status.temperature";
public static final List<Channel> THING_CHANNEL_LIST = new ArrayList<>();
public static final List<Channel> THING_CHANNEL_LIST_WITH_JSON = new ArrayList<>();
static Configuration textConfiguration() {
Map<String, Object> data = new HashMap<>();
data.put("stateTopic", "test/state");
data.put("commandTopic", "test/command");
return new Configuration(data);
}
static Configuration textConfigurationWithJson() {
Map<String, Object> data = new HashMap<>();
data.put("stateTopic", "test/state");
data.put("commandTopic", "test/command");
data.put("transformationPattern", "JSONPATH:" + JSON_PATH_PATTERN);
return new Configuration(data);
}
private static Configuration numberConfiguration() {
Map<String, Object> data = new HashMap<>();
data.put("stateTopic", "test/state");
data.put("commandTopic", "test/command");
data.put("min", BigDecimal.valueOf(1));
data.put("max", BigDecimal.valueOf(99));
data.put("step", BigDecimal.valueOf(2));
data.put("isDecimal", true);
return new Configuration(data);
}
private static Configuration percentageConfiguration() {
Map<String, Object> data = new HashMap<>();
data.put("stateTopic", "test/state");
data.put("commandTopic", "test/command");
data.put("on", "ON");
data.put("off", "OFF");
return new Configuration(data);
}
private static Configuration onoffConfiguration() {
Map<String, Object> data = new HashMap<>();
data.put("stateTopic", "test/state");
data.put("commandTopic", "test/command");
data.put("on", "ON");
data.put("off", "OFF");
data.put("inverse", true);
return new Configuration(data);
}
}

View File

@@ -0,0 +1,29 @@
/**
* 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.mqtt.homie.internal.homie300;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.ChannelState;
/**
* Helper to access {@link org.openhab.binding.mqtt.homie.internal.homie300.Property} internals.
*
* @author David Graeff - Initial contribution
*/
@NonNullByDefault
public class PropertyHelper {
public static void setChannelState(Property property, @Nullable ChannelState channelState) {
property.channelState = channelState;
}
}