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,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.openhab.binding.mqtt.homie</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
</projectDescription>

View File

@@ -0,0 +1,44 @@
This content is produced and maintained by the Eclipse SmartHome project.
* Project home: https://openhab.org/
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
The MQTT Homie convention is made available under the MIT license, attached at the end of this file.
== Source Code
https://github.com/eclipse/smarthome
== Copyright Holders
See the NOTICE file distributed with the source code at
https://github.com/eclipse/smarthome/blob/master/NOTICE
for detailed information regarding copyright ownership.
== MIT license ==
MIT License
Copyright (c) 2017 Marvin Roger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,20 @@
# MQTT Homie Binding
NOTE: This binding is provided by the [MQTT binding](https://www.openhab.org/addons/bindings/mqtt/), and therefore no explicit installation is necessary beyond installing the MQTT binding.
Devices that follow the [Homie convention](https://homieiot.github.io/) 3.x and better
are auto-discovered and represented by this binding and the Homie Thing.
Find the next table to understand the topology mapping from Homie to the Framework:
| Homie | Framework | Example MQTT topic |
|----------|---------------|------------------------------------|
| Device | Thing | homie/super-car |
| Node | Channel Group | homie/super-car/engine |
| Property | Channel | homie/super-car/engine/temperature |
System trigger channels are supported using non-retained properties, with *enum* data type and with the following formats:
* Format: "PRESSED,RELEASED" -> system.rawbutton
* Format: "SHORT\_PRESSED,DOUBLE\_PRESSED,LONG\_PRESSED" -> system.button
* Format: "DIR1\_PRESSED,DIR1\_RELEASED,DIR2\_PRESSED,DIR2\_RELEASED" -> system.rawrocker

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.mqtt.homie</artifactId>
<name>openHAB Add-ons :: Bundles :: MQTT Homie Convention</name>
<dependencies>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.mqtt</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.mqtt.generic</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

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;
}
}