2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.mqtt.homie.internal.homie300;
15 import java.math.BigDecimal;
16 import java.math.MathContext;
18 import java.util.ArrayList;
19 import java.util.List;
20 import java.util.concurrent.CompletableFuture;
21 import java.util.concurrent.ScheduledExecutorService;
22 import java.util.stream.Collectors;
23 import java.util.stream.Stream;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.openhab.binding.mqtt.generic.ChannelConfigBuilder;
28 import org.openhab.binding.mqtt.generic.ChannelState;
29 import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass;
30 import org.openhab.binding.mqtt.generic.mapping.AbstractMqttAttributeClass.AttributeChanged;
31 import org.openhab.binding.mqtt.generic.mapping.ColorMode;
32 import org.openhab.binding.mqtt.generic.values.ColorValue;
33 import org.openhab.binding.mqtt.generic.values.DateTimeValue;
34 import org.openhab.binding.mqtt.generic.values.NumberValue;
35 import org.openhab.binding.mqtt.generic.values.OnOffValue;
36 import org.openhab.binding.mqtt.generic.values.PercentageValue;
37 import org.openhab.binding.mqtt.generic.values.TextValue;
38 import org.openhab.binding.mqtt.generic.values.Value;
39 import org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants;
40 import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes.DataTypeEnum;
41 import org.openhab.core.config.core.Configuration;
42 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
43 import org.openhab.core.thing.Channel;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
46 import org.openhab.core.thing.binding.builder.ChannelBuilder;
47 import org.openhab.core.thing.type.AutoUpdatePolicy;
48 import org.openhab.core.thing.type.ChannelType;
49 import org.openhab.core.thing.type.ChannelTypeBuilder;
50 import org.openhab.core.thing.type.ChannelTypeUID;
51 import org.openhab.core.types.util.UnitUtils;
52 import org.openhab.core.util.UIDUtils;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
57 * A homie Property (which translates into a channel).
59 * @author David Graeff - Initial contribution
62 public class Property implements AttributeChanged {
63 private final Logger logger = LoggerFactory.getLogger(Property.class);
65 public final PropertyAttributes attributes;
66 public final Node parentNode;
67 public final String propertyID;
69 protected @Nullable ChannelState channelState;
70 public final ChannelUID channelUID;
71 public final ChannelTypeUID channelTypeUID;
72 private ChannelType type;
73 private Channel channel;
74 private final String topic;
75 private final DeviceCallback callback;
76 protected boolean initialized = false;
79 * Creates a Homie Property.
81 * @param topic The base topic for this property (e.g. "homie/device/node")
82 * @param node The parent Homie Node.
83 * @param propertyID The unique property ID (among all properties on this Node).
85 public Property(String topic, Node node, String propertyID, DeviceCallback callback,
86 PropertyAttributes attributes) {
87 this.callback = callback;
88 this.attributes = attributes;
89 this.topic = topic + "/" + propertyID;
90 this.parentNode = node;
91 this.propertyID = propertyID;
92 channelUID = new ChannelUID(node.uid(), UIDUtils.encode(propertyID));
93 channelTypeUID = new ChannelTypeUID(MqttBindingConstants.BINDING_ID, UIDUtils.encode(this.topic));
94 type = ChannelTypeBuilder.trigger(channelTypeUID, "dummy").build(); // Dummy value
95 channel = ChannelBuilder.create(channelUID, "dummy").build();// Dummy value
99 * Subscribe to property attributes. This will not subscribe
100 * to the property value though. Call {@link Device#startChannels(MqttBrokerConnection)} to do that.
102 * @return Returns a future that completes as soon as all attribute values have been received or requests have timed
105 public CompletableFuture<@Nullable Void> subscribe(MqttBrokerConnection connection,
106 ScheduledExecutorService scheduler, int timeout) {
107 return attributes.subscribeAndReceive(connection, scheduler, topic, this, timeout)
108 // On success, create the channel and tell the handler about this property
109 .thenRun(this::attributesReceived)
110 // No matter if values have been received or not -> the subscriptions have been performed
111 .whenComplete((r, e) -> {
116 private @Nullable BigDecimal convertFromString(String value) {
118 return new BigDecimal(value);
119 } catch (NumberFormatException ignore) {
120 logger.debug("Cannot convert {} to a number", value);
126 * As soon as subscribing succeeded and corresponding MQTT values have been received, the ChannelType and
127 * ChannelState are determined.
129 public void attributesReceived() {
130 createChannelFromAttribute();
131 callback.propertyAddedOrChanged(this);
135 * Creates the ChannelType of the Homie property.
137 * @param attributes Attributes of the property.
138 * @param channelState ChannelState of the property.
140 * @return Returns the ChannelType to be used to build the Channel.
142 private ChannelType createChannelType(PropertyAttributes attributes, ChannelState channelState) {
143 // Retained property -> State channel
144 if (attributes.retained) {
145 return ChannelTypeBuilder.state(channelTypeUID, attributes.name, channelState.getItemType())
146 .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL))
147 .withStateDescriptionFragment(
148 channelState.getCache().createStateDescription(!attributes.settable).build())
151 // Non-retained and settable property -> State channel
152 if (attributes.settable) {
153 return ChannelTypeBuilder.state(channelTypeUID, attributes.name, channelState.getItemType())
154 .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL))
155 .withCommandDescription(channelState.getCache().createCommandDescription().build())
156 .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build();
158 // Non-retained and non settable property -> Trigger channel
159 if (attributes.datatype.equals(DataTypeEnum.enum_)) {
160 if (attributes.format.contains("PRESSED") && attributes.format.contains("RELEASED")) {
161 return DefaultSystemChannelTypeProvider.SYSTEM_RAWBUTTON;
162 } else if (attributes.format.contains("SHORT_PRESSED") && attributes.format.contains("LONG_PRESSED")
163 && attributes.format.contains("DOUBLE_PRESSED")) {
164 return DefaultSystemChannelTypeProvider.SYSTEM_BUTTON;
165 } else if (attributes.format.contains("DIR1_PRESSED") && attributes.format.contains("DIR1_RELEASED")
166 && attributes.format.contains("DIR2_PRESSED") && attributes.format.contains("DIR2_RELEASED")) {
167 return DefaultSystemChannelTypeProvider.SYSTEM_RAWROCKER;
170 return ChannelTypeBuilder.trigger(channelTypeUID, attributes.name)
171 .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL)).build();
175 public void createChannelFromAttribute() {
176 final String commandTopic = topic + "/set";
177 final String stateTopic = topic;
180 Boolean isDecimal = null;
182 if (attributes.name.isEmpty()) {
183 attributes.name = propertyID;
186 switch (attributes.datatype) {
188 value = new OnOffValue("true", "false");
191 if (attributes.format.equals("hsv")) {
192 value = new ColorValue(ColorMode.HSB, null, null, 100);
193 } else if (attributes.format.equals("rgb")) {
194 value = new ColorValue(ColorMode.RGB, null, null, 100);
196 logger.warn("Non supported color format: '{}'. Only 'hsv' and 'rgb' are supported",
198 value = new TextValue();
202 String enumValues[] = attributes.format.split(",");
203 value = new TextValue(enumValues);
207 isDecimal = attributes.datatype == DataTypeEnum.float_;
208 String s[] = attributes.format.split("\\:");
209 BigDecimal min = s.length == 2 ? convertFromString(s[0]) : null;
210 BigDecimal max = s.length == 2 ? convertFromString(s[1]) : null;
211 BigDecimal step = (min != null && max != null)
212 ? max.subtract(min).divide(new BigDecimal(100.0), new MathContext(isDecimal ? 2 : 0))
214 if (step != null && !isDecimal && step.intValue() <= 0) {
215 step = new BigDecimal(1);
217 if (attributes.unit.contains("%") && attributes.settable) {
218 value = new PercentageValue(min, max, step, null, null);
220 value = new NumberValue(min, max, step, UnitUtils.parseUnit(attributes.unit));
224 value = new DateTimeValue();
229 value = new TextValue();
233 ChannelConfigBuilder b = ChannelConfigBuilder.create().makeTrigger(!attributes.retained)
234 .withStateTopic(stateTopic);
236 if (isDecimal != null && !isDecimal) {
237 b = b.withFormatter("%d"); // Apply formatter to only publish integers
240 if (attributes.settable) {
241 b = b.withCommandTopic(commandTopic).withRetain(false);
244 final ChannelState channelState = new ChannelState(b.build(), channelUID, value, callback);
245 this.channelState = channelState;
247 final ChannelType type = createChannelType(attributes, channelState);
250 this.channel = ChannelBuilder.create(channelUID, type.getItemType()).withType(type.getUID())
251 .withKind(type.getKind()).withLabel(attributes.name)
252 .withConfiguration(new Configuration(attributes.asMap())).build();
256 * Unsubscribe from all property attributes and the property value.
258 * @return Returns a future that completes as soon as all unsubscriptions have been performed.
260 public CompletableFuture<@Nullable Void> stop() {
261 final ChannelState channelState = this.channelState;
262 if (channelState != null) {
263 return channelState.stop().thenCompose(b -> attributes.unsubscribe());
265 return attributes.unsubscribe();
269 * @return Returns the channelState. You should have called
270 * {@link Property#subscribe(AbstractMqttAttributeClass, int)}
271 * and waited for the future to complete before calling this Getter.
273 public @Nullable ChannelState getChannelState() {
278 * Subscribes to the state topic on the given connection and informs about updates on the given listener.
280 * @param connection A broker connection
281 * @param scheduler A scheduler to realize the timeout
282 * @param timeout A timeout in milliseconds. Can be 0 to disable the timeout and let the future return earlier.
283 * @param channelStateUpdateListener An update listener
284 * @return A future that completes with true if the subscribing worked and false and/or exceptionally otherwise.
286 public CompletableFuture<@Nullable Void> startChannel(MqttBrokerConnection connection,
287 ScheduledExecutorService scheduler, int timeout) {
288 final ChannelState channelState = this.channelState;
289 if (channelState == null) {
290 CompletableFuture<@Nullable Void> f = new CompletableFuture<>();
291 f.completeExceptionally(new IllegalStateException("Attributes not yet received!"));
294 // Make sure we set the callback again which might have been nulled during a stop
295 channelState.setChannelStateUpdateListener(this.callback);
296 return channelState.start(connection, scheduler, timeout);
300 * @return Returns the channel type of this property.
301 * The type is a dummy only if {@link #channelState} has not been set yet.
303 public ChannelType getType() {
308 * @return Returns the channel of this property.
309 * The channel is a dummy only if {@link #channelState} has not been set yet.
311 public Channel getChannel() {
316 public String toString() {
317 return channelUID.toString();
321 * Because the remote device could change any of the property attributes in-between,
322 * whenever that happens, we re-create the channel, channel-type and channelState.
325 public void attributeChanged(String name, Object value, MqttBrokerConnection connection,
326 ScheduledExecutorService scheduler, boolean allMandatoryFieldsReceived) {
327 if (!initialized || !allMandatoryFieldsReceived) {
330 attributesReceived();
334 * Creates a list of retained topics related to the property
336 * @return Returns a list of relative topics
338 public List<String> getRetainedTopics() {
339 List<String> topics = new ArrayList<>();
341 topics.addAll(Stream.of(this.attributes.getClass().getDeclaredFields()).map(f -> {
342 return String.format("%s/$%s", this.propertyID, f.getName());
343 }).collect(Collectors.toList()));
345 // All exceptions can be ignored because the 'retained' attribute of the PropertyAttributes class
346 // is public, is a boolean variable and has a default value (true)
348 if (attributes.getClass().getDeclaredField("retained").getBoolean(attributes)) {
349 topics.add(this.propertyID);
351 } catch (NoSuchFieldException ignored) {
352 } catch (SecurityException ignored) {
353 } catch (IllegalArgumentException ignored) {
354 } catch (IllegalAccessException ignored) {