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