]> git.basschouten.com Git - openhab-addons.git/blob
1abca7c846807d6d6a2f9ffe78d35c6efdd1cedc
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.mqtt.homie.internal.homie300;
14
15 import java.math.BigDecimal;
16 import java.math.MathContext;
17 import java.net.URI;
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;
24
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.NumberValue;
34 import org.openhab.binding.mqtt.generic.values.OnOffValue;
35 import org.openhab.binding.mqtt.generic.values.PercentageValue;
36 import org.openhab.binding.mqtt.generic.values.TextValue;
37 import org.openhab.binding.mqtt.generic.values.Value;
38 import org.openhab.binding.mqtt.homie.generic.internal.MqttBindingConstants;
39 import org.openhab.binding.mqtt.homie.internal.homie300.PropertyAttributes.DataTypeEnum;
40 import org.openhab.core.config.core.Configuration;
41 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
42 import org.openhab.core.thing.Channel;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.DefaultSystemChannelTypeProvider;
45 import org.openhab.core.thing.binding.builder.ChannelBuilder;
46 import org.openhab.core.thing.type.AutoUpdatePolicy;
47 import org.openhab.core.thing.type.ChannelType;
48 import org.openhab.core.thing.type.ChannelTypeBuilder;
49 import org.openhab.core.thing.type.ChannelTypeUID;
50 import org.openhab.core.util.UIDUtils;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53
54 /**
55  * A homie Property (which translates into a channel).
56  *
57  * @author David Graeff - Initial contribution
58  */
59 @NonNullByDefault
60 public class Property implements AttributeChanged {
61     private final Logger logger = LoggerFactory.getLogger(Property.class);
62     // Homie data
63     public final PropertyAttributes attributes;
64     public final Node parentNode;
65     public final String propertyID;
66     // Runtime state
67     protected @Nullable ChannelState channelState;
68     public final ChannelUID channelUID;
69     public final ChannelTypeUID channelTypeUID;
70     private ChannelType type;
71     private Channel channel;
72     private final String topic;
73     private final DeviceCallback callback;
74     protected boolean initialized = false;
75
76     /**
77      * Creates a Homie Property.
78      *
79      * @param topic The base topic for this property (e.g. "homie/device/node")
80      * @param node The parent Homie Node.
81      * @param propertyID The unique property ID (among all properties on this Node).
82      */
83     public Property(String topic, Node node, String propertyID, DeviceCallback callback,
84             PropertyAttributes attributes) {
85         this.callback = callback;
86         this.attributes = attributes;
87         this.topic = topic + "/" + propertyID;
88         this.parentNode = node;
89         this.propertyID = propertyID;
90         channelUID = new ChannelUID(node.uid(), UIDUtils.encode(propertyID));
91         channelTypeUID = new ChannelTypeUID(MqttBindingConstants.BINDING_ID, UIDUtils.encode(this.topic));
92         type = ChannelTypeBuilder.trigger(channelTypeUID, "dummy").build(); // Dummy value
93         channel = ChannelBuilder.create(channelUID, "dummy").build();// Dummy value
94     }
95
96     /**
97      * Subscribe to property attributes. This will not subscribe
98      * to the property value though. Call {@link Device#startChannels(MqttBrokerConnection)} to do that.
99      *
100      * @return Returns a future that completes as soon as all attribute values have been received or requests have timed
101      *         out.
102      */
103     public CompletableFuture<@Nullable Void> subscribe(MqttBrokerConnection connection,
104             ScheduledExecutorService scheduler, int timeout) {
105         return attributes.subscribeAndReceive(connection, scheduler, topic, this, timeout)
106                 // On success, create the channel and tell the handler about this property
107                 .thenRun(this::attributesReceived)
108                 // No matter if values have been received or not -> the subscriptions have been performed
109                 .whenComplete((r, e) -> {
110                     initialized = true;
111                 });
112     }
113
114     private @Nullable BigDecimal convertFromString(String value) {
115         try {
116             return new BigDecimal(value);
117         } catch (NumberFormatException ignore) {
118             logger.debug("Cannot convert {} to a number", value);
119             return null;
120         }
121     }
122
123     /**
124      * As soon as subscribing succeeded and corresponding MQTT values have been received, the ChannelType and
125      * ChannelState are determined.
126      */
127     public void attributesReceived() {
128         createChannelFromAttribute();
129         callback.propertyAddedOrChanged(this);
130     }
131
132     /**
133      * Creates the ChannelType of the Homie property.
134      *
135      * @param attributes Attributes of the property.
136      * @param channelState ChannelState of the property.
137      *
138      * @return Returns the ChannelType to be used to build the Channel.
139      */
140     private ChannelType createChannelType(PropertyAttributes attributes, ChannelState channelState) {
141         // Retained property -> State channel
142         if (attributes.retained) {
143             return ChannelTypeBuilder.state(channelTypeUID, attributes.name, channelState.getItemType())
144                     .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL))
145                     .withStateDescriptionFragment(
146                             channelState.getCache().createStateDescription(!attributes.settable).build())
147                     .build();
148         } else {
149             // Non-retained and settable property -> State channel
150             if (attributes.settable) {
151                 return ChannelTypeBuilder.state(channelTypeUID, attributes.name, channelState.getItemType())
152                         .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL))
153                         .withCommandDescription(channelState.getCache().createCommandDescription().build())
154                         .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build();
155             }
156             // Non-retained and non settable property -> Trigger channel
157             if (attributes.datatype.equals(DataTypeEnum.enum_)) {
158                 if (attributes.format.contains("PRESSED") && attributes.format.contains("RELEASED")) {
159                     return DefaultSystemChannelTypeProvider.SYSTEM_RAWBUTTON;
160                 } else if (attributes.format.contains("SHORT_PRESSED") && attributes.format.contains("LONG_PRESSED")
161                         && attributes.format.contains("DOUBLE_PRESSED")) {
162                     return DefaultSystemChannelTypeProvider.SYSTEM_BUTTON;
163                 } else if (attributes.format.contains("DIR1_PRESSED") && attributes.format.contains("DIR1_RELEASED")
164                         && attributes.format.contains("DIR2_PRESSED") && attributes.format.contains("DIR2_RELEASED")) {
165                     return DefaultSystemChannelTypeProvider.SYSTEM_RAWROCKER;
166                 }
167             }
168             return ChannelTypeBuilder.trigger(channelTypeUID, attributes.name)
169                     .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL)).build();
170         }
171     }
172
173     public void createChannelFromAttribute() {
174         final String commandTopic = topic + "/set";
175         final String stateTopic = topic;
176
177         Value value;
178         Boolean isDecimal = null;
179
180         if (attributes.name.isEmpty()) {
181             attributes.name = propertyID;
182         }
183
184         switch (attributes.datatype) {
185             case boolean_:
186                 value = new OnOffValue("true", "false");
187                 break;
188             case color_:
189                 if (attributes.format.equals("hsv")) {
190                     value = new ColorValue(ColorMode.HSB, null, null, 100);
191                 } else if (attributes.format.equals("rgb")) {
192                     value = new ColorValue(ColorMode.RGB, null, null, 100);
193                 } else {
194                     logger.warn("Non supported color format: '{}'. Only 'hsv' and 'rgb' are supported",
195                             attributes.format);
196                     value = new TextValue();
197                 }
198                 break;
199             case enum_:
200                 String enumValues[] = attributes.format.split(",");
201                 value = new TextValue(enumValues);
202                 break;
203             case float_:
204             case integer_:
205                 isDecimal = attributes.datatype == DataTypeEnum.float_;
206                 String s[] = attributes.format.split("\\:");
207                 BigDecimal min = s.length == 2 ? convertFromString(s[0]) : null;
208                 BigDecimal max = s.length == 2 ? convertFromString(s[1]) : null;
209                 BigDecimal step = (min != null && max != null)
210                         ? max.subtract(min).divide(new BigDecimal(100.0), new MathContext(isDecimal ? 2 : 0))
211                         : null;
212                 if (step != null && !isDecimal && step.intValue() <= 0) {
213                     step = new BigDecimal(1);
214                 }
215                 if (attributes.unit.contains("%") && attributes.settable) {
216                     value = new PercentageValue(min, max, step, null, null);
217                 } else {
218                     value = new NumberValue(min, max, step, attributes.unit);
219                 }
220                 break;
221             case string_:
222             case unknown:
223             default:
224                 value = new TextValue();
225                 break;
226         }
227
228         ChannelConfigBuilder b = ChannelConfigBuilder.create().makeTrigger(!attributes.retained)
229                 .withStateTopic(stateTopic);
230
231         if (isDecimal != null && !isDecimal) {
232             b = b.withFormatter("%d"); // Apply formatter to only publish integers
233         }
234
235         if (attributes.settable) {
236             b = b.withCommandTopic(commandTopic).withRetain(false);
237         }
238
239         final ChannelState channelState = new ChannelState(b.build(), channelUID, value, callback);
240         this.channelState = channelState;
241
242         final ChannelType type = createChannelType(attributes, channelState);
243         this.type = type;
244
245         this.channel = ChannelBuilder.create(channelUID, type.getItemType()).withType(type.getUID())
246                 .withKind(type.getKind()).withLabel(attributes.name)
247                 .withConfiguration(new Configuration(attributes.asMap())).build();
248     }
249
250     /**
251      * Unsubscribe from all property attributes and the property value.
252      *
253      * @return Returns a future that completes as soon as all unsubscriptions have been performed.
254      */
255     public CompletableFuture<@Nullable Void> stop() {
256         final ChannelState channelState = this.channelState;
257         if (channelState != null) {
258             return channelState.stop().thenCompose(b -> attributes.unsubscribe());
259         }
260         return attributes.unsubscribe();
261     }
262
263     /**
264      * @return Returns the channelState. You should have called
265      *         {@link Property#subscribe(AbstractMqttAttributeClass, int)}
266      *         and waited for the future to complete before calling this Getter.
267      */
268     public @Nullable ChannelState getChannelState() {
269         return channelState;
270     }
271
272     /**
273      * Subscribes to the state topic on the given connection and informs about updates on the given listener.
274      *
275      * @param connection A broker connection
276      * @param scheduler A scheduler to realize the timeout
277      * @param timeout A timeout in milliseconds. Can be 0 to disable the timeout and let the future return earlier.
278      * @param channelStateUpdateListener An update listener
279      * @return A future that completes with true if the subscribing worked and false and/or exceptionally otherwise.
280      */
281     public CompletableFuture<@Nullable Void> startChannel(MqttBrokerConnection connection,
282             ScheduledExecutorService scheduler, int timeout) {
283         final ChannelState channelState = this.channelState;
284         if (channelState == null) {
285             CompletableFuture<@Nullable Void> f = new CompletableFuture<>();
286             f.completeExceptionally(new IllegalStateException("Attributes not yet received!"));
287             return f;
288         }
289         // Make sure we set the callback again which might have been nulled during an stop
290         channelState.setChannelStateUpdateListener(this.callback);
291         return channelState.start(connection, scheduler, timeout);
292     }
293
294     /**
295      * @return Returns the channel type of this property.
296      *         The type is a dummy only if {@link #channelState} has not been set yet.
297      */
298     public ChannelType getType() {
299         return type;
300     }
301
302     /**
303      * @return Returns the channel of this property.
304      *         The channel is a dummy only if {@link #channelState} has not been set yet.
305      */
306     public Channel getChannel() {
307         return channel;
308     }
309
310     @Override
311     public String toString() {
312         return channelUID.toString();
313     }
314
315     /**
316      * Because the remote device could change any of the property attributes in-between,
317      * whenever that happens, we re-create the channel, channel-type and channelState.
318      */
319     @Override
320     public void attributeChanged(String name, Object value, MqttBrokerConnection connection,
321             ScheduledExecutorService scheduler, boolean allMandatoryFieldsReceived) {
322         if (!initialized || !allMandatoryFieldsReceived) {
323             return;
324         }
325         attributesReceived();
326     }
327
328     /**
329      * Creates a list of retained topics related to the property
330      *
331      * @return Returns a list of relative topics
332      */
333     public List<String> getRetainedTopics() {
334         List<String> topics = new ArrayList<>();
335
336         topics.addAll(Stream.of(this.attributes.getClass().getDeclaredFields()).map(f -> {
337             return String.format("%s/$%s", this.propertyID, f.getName());
338         }).collect(Collectors.toList()));
339
340         // All exceptions can be ignored because the 'retained' attribute of the PropertyAttributes class
341         // is public, is a boolean variable and has a default value (true)
342         try {
343             if (attributes.getClass().getDeclaredField("retained").getBoolean(attributes)) {
344                 topics.add(this.propertyID);
345             }
346         } catch (NoSuchFieldException ignored) {
347         } catch (SecurityException ignored) {
348         } catch (IllegalArgumentException ignored) {
349         } catch (IllegalAccessException ignored) {
350         }
351
352         return topics;
353     }
354 }