added migrated 2.x add-ons
Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user