]> git.basschouten.com Git - openhab-addons.git/blob
b87e525c2ec4064391a8f4d615d3d57e9a69891f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.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.util.UIDUtils;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
54
55 /**
56  * A homie Property (which translates into a channel).
57  *
58  * @author David Graeff - Initial contribution
59  */
60 @NonNullByDefault
61 public class Property implements AttributeChanged {
62     private final Logger logger = LoggerFactory.getLogger(Property.class);
63     // Homie data
64     public final PropertyAttributes attributes;
65     public final Node parentNode;
66     public final String propertyID;
67     // Runtime state
68     protected @Nullable ChannelState channelState;
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                     .withStateDescriptionFragment(
147                             channelState.getCache().createStateDescription(!attributes.settable).build())
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.isEmpty()) {
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 datetime_:
223                 value = new DateTimeValue();
224                 break;
225             case string_:
226             case unknown:
227             default:
228                 value = new TextValue();
229                 break;
230         }
231
232         ChannelConfigBuilder b = ChannelConfigBuilder.create().makeTrigger(!attributes.retained)
233                 .withStateTopic(stateTopic);
234
235         if (isDecimal != null && !isDecimal) {
236             b = b.withFormatter("%d"); // Apply formatter to only publish integers
237         }
238
239         if (attributes.settable) {
240             b = b.withCommandTopic(commandTopic).withRetain(false);
241         }
242
243         final ChannelState channelState = new ChannelState(b.build(), channelUID, value, callback);
244         this.channelState = channelState;
245
246         final ChannelType type = createChannelType(attributes, channelState);
247         this.type = type;
248
249         this.channel = ChannelBuilder.create(channelUID, type.getItemType()).withType(type.getUID())
250                 .withKind(type.getKind()).withLabel(attributes.name)
251                 .withConfiguration(new Configuration(attributes.asMap())).build();
252     }
253
254     /**
255      * Unsubscribe from all property attributes and the property value.
256      *
257      * @return Returns a future that completes as soon as all unsubscriptions have been performed.
258      */
259     public CompletableFuture<@Nullable Void> stop() {
260         final ChannelState channelState = this.channelState;
261         if (channelState != null) {
262             return channelState.stop().thenCompose(b -> attributes.unsubscribe());
263         }
264         return attributes.unsubscribe();
265     }
266
267     /**
268      * @return Returns the channelState. You should have called
269      *         {@link Property#subscribe(AbstractMqttAttributeClass, int)}
270      *         and waited for the future to complete before calling this Getter.
271      */
272     public @Nullable ChannelState getChannelState() {
273         return channelState;
274     }
275
276     /**
277      * Subscribes to the state topic on the given connection and informs about updates on the given listener.
278      *
279      * @param connection A broker connection
280      * @param scheduler A scheduler to realize the timeout
281      * @param timeout A timeout in milliseconds. Can be 0 to disable the timeout and let the future return earlier.
282      * @param channelStateUpdateListener An update listener
283      * @return A future that completes with true if the subscribing worked and false and/or exceptionally otherwise.
284      */
285     public CompletableFuture<@Nullable Void> startChannel(MqttBrokerConnection connection,
286             ScheduledExecutorService scheduler, int timeout) {
287         final ChannelState channelState = this.channelState;
288         if (channelState == null) {
289             CompletableFuture<@Nullable Void> f = new CompletableFuture<>();
290             f.completeExceptionally(new IllegalStateException("Attributes not yet received!"));
291             return f;
292         }
293         // Make sure we set the callback again which might have been nulled during an stop
294         channelState.setChannelStateUpdateListener(this.callback);
295         return channelState.start(connection, scheduler, timeout);
296     }
297
298     /**
299      * @return Returns the channel type of this property.
300      *         The type is a dummy only if {@link #channelState} has not been set yet.
301      */
302     public ChannelType getType() {
303         return type;
304     }
305
306     /**
307      * @return Returns the channel of this property.
308      *         The channel is a dummy only if {@link #channelState} has not been set yet.
309      */
310     public Channel getChannel() {
311         return channel;
312     }
313
314     @Override
315     public String toString() {
316         return channelUID.toString();
317     }
318
319     /**
320      * Because the remote device could change any of the property attributes in-between,
321      * whenever that happens, we re-create the channel, channel-type and channelState.
322      */
323     @Override
324     public void attributeChanged(String name, Object value, MqttBrokerConnection connection,
325             ScheduledExecutorService scheduler, boolean allMandatoryFieldsReceived) {
326         if (!initialized || !allMandatoryFieldsReceived) {
327             return;
328         }
329         attributesReceived();
330     }
331
332     /**
333      * Creates a list of retained topics related to the property
334      *
335      * @return Returns a list of relative topics
336      */
337     public List<String> getRetainedTopics() {
338         List<String> topics = new ArrayList<>();
339
340         topics.addAll(Stream.of(this.attributes.getClass().getDeclaredFields()).map(f -> {
341             return String.format("%s/$%s", this.propertyID, f.getName());
342         }).collect(Collectors.toList()));
343
344         // All exceptions can be ignored because the 'retained' attribute of the PropertyAttributes class
345         // is public, is a boolean variable and has a default value (true)
346         try {
347             if (attributes.getClass().getDeclaredField("retained").getBoolean(attributes)) {
348                 topics.add(this.propertyID);
349             }
350         } catch (NoSuchFieldException ignored) {
351         } catch (SecurityException ignored) {
352         } catch (IllegalArgumentException ignored) {
353         } catch (IllegalAccessException ignored) {
354         }
355
356         return topics;
357     }
358 }