]> git.basschouten.com Git - openhab-addons.git/blob
86e251a8900313b3b9b7981f6f1287eb23a5f440
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.types.util.UnitUtils;
52 import org.openhab.core.util.UIDUtils;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
55
56 /**
57  * A homie Property (which translates into a channel).
58  *
59  * @author David Graeff - Initial contribution
60  */
61 @NonNullByDefault
62 public class Property implements AttributeChanged {
63     private final Logger logger = LoggerFactory.getLogger(Property.class);
64     // Homie data
65     public final PropertyAttributes attributes;
66     public final Node parentNode;
67     public final String propertyID;
68     // Runtime state
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;
77
78     /**
79      * Creates a Homie Property.
80      *
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).
84      */
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
96     }
97
98     /**
99      * Subscribe to property attributes. This will not subscribe
100      * to the property value though. Call {@link Device#startChannels(MqttBrokerConnection)} to do that.
101      *
102      * @return Returns a future that completes as soon as all attribute values have been received or requests have timed
103      *         out.
104      */
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) -> {
112                     initialized = true;
113                 });
114     }
115
116     private @Nullable BigDecimal convertFromString(String value) {
117         try {
118             return new BigDecimal(value);
119         } catch (NumberFormatException ignore) {
120             logger.debug("Cannot convert {} to a number", value);
121             return null;
122         }
123     }
124
125     /**
126      * As soon as subscribing succeeded and corresponding MQTT values have been received, the ChannelType and
127      * ChannelState are determined.
128      */
129     public void attributesReceived() {
130         createChannelFromAttribute();
131         callback.propertyAddedOrChanged(this);
132     }
133
134     /**
135      * Creates the ChannelType of the Homie property.
136      *
137      * @param attributes Attributes of the property.
138      * @param channelState ChannelState of the property.
139      *
140      * @return Returns the ChannelType to be used to build the Channel.
141      */
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())
149                     .build();
150         } else {
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();
157             }
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;
168                 }
169             }
170             return ChannelTypeBuilder.trigger(channelTypeUID, attributes.name)
171                     .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HOMIE_CHANNEL)).build();
172         }
173     }
174
175     public void createChannelFromAttribute() {
176         final String commandTopic = topic + "/set";
177         final String stateTopic = topic;
178
179         Value value;
180         Boolean isDecimal = null;
181
182         if (attributes.name.isEmpty()) {
183             attributes.name = propertyID;
184         }
185
186         switch (attributes.datatype) {
187             case boolean_:
188                 value = new OnOffValue("true", "false");
189                 break;
190             case color_:
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);
195                 } else {
196                     logger.warn("Non supported color format: '{}'. Only 'hsv' and 'rgb' are supported",
197                             attributes.format);
198                     value = new TextValue();
199                 }
200                 break;
201             case enum_:
202                 String enumValues[] = attributes.format.split(",");
203                 value = new TextValue(enumValues);
204                 break;
205             case float_:
206             case integer_:
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))
213                         : null;
214                 if (step != null && !isDecimal && step.intValue() <= 0) {
215                     step = new BigDecimal(1);
216                 }
217                 if (attributes.unit.contains("%") && attributes.settable) {
218                     value = new PercentageValue(min, max, step, null, null);
219                 } else {
220                     value = new NumberValue(min, max, step, UnitUtils.parseUnit(attributes.unit));
221                 }
222                 break;
223             case datetime_:
224                 value = new DateTimeValue();
225                 break;
226             case string_:
227             case unknown:
228             default:
229                 value = new TextValue();
230                 break;
231         }
232
233         ChannelConfigBuilder b = ChannelConfigBuilder.create().makeTrigger(!attributes.retained)
234                 .withStateTopic(stateTopic);
235
236         if (isDecimal != null && !isDecimal) {
237             b = b.withFormatter("%d"); // Apply formatter to only publish integers
238         }
239
240         if (attributes.settable) {
241             b = b.withCommandTopic(commandTopic).withRetain(false);
242         }
243
244         final ChannelState channelState = new ChannelState(b.build(), channelUID, value, callback);
245         this.channelState = channelState;
246
247         final ChannelType type = createChannelType(attributes, channelState);
248         this.type = type;
249
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();
253     }
254
255     /**
256      * Unsubscribe from all property attributes and the property value.
257      *
258      * @return Returns a future that completes as soon as all unsubscriptions have been performed.
259      */
260     public CompletableFuture<@Nullable Void> stop() {
261         final ChannelState channelState = this.channelState;
262         if (channelState != null) {
263             return channelState.stop().thenCompose(b -> attributes.unsubscribe());
264         }
265         return attributes.unsubscribe();
266     }
267
268     /**
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.
272      */
273     public @Nullable ChannelState getChannelState() {
274         return channelState;
275     }
276
277     /**
278      * Subscribes to the state topic on the given connection and informs about updates on the given listener.
279      *
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.
285      */
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!"));
292             return f;
293         }
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);
297     }
298
299     /**
300      * @return Returns the channel type of this property.
301      *         The type is a dummy only if {@link #channelState} has not been set yet.
302      */
303     public ChannelType getType() {
304         return type;
305     }
306
307     /**
308      * @return Returns the channel of this property.
309      *         The channel is a dummy only if {@link #channelState} has not been set yet.
310      */
311     public Channel getChannel() {
312         return channel;
313     }
314
315     @Override
316     public String toString() {
317         return channelUID.toString();
318     }
319
320     /**
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.
323      */
324     @Override
325     public void attributeChanged(String name, Object value, MqttBrokerConnection connection,
326             ScheduledExecutorService scheduler, boolean allMandatoryFieldsReceived) {
327         if (!initialized || !allMandatoryFieldsReceived) {
328             return;
329         }
330         attributesReceived();
331     }
332
333     /**
334      * Creates a list of retained topics related to the property
335      *
336      * @return Returns a list of relative topics
337      */
338     public List<String> getRetainedTopics() {
339         List<String> topics = new ArrayList<>();
340
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()));
344
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)
347         try {
348             if (attributes.getClass().getDeclaredField("retained").getBoolean(attributes)) {
349                 topics.add(this.propertyID);
350             }
351         } catch (NoSuchFieldException ignored) {
352         } catch (SecurityException ignored) {
353         } catch (IllegalArgumentException ignored) {
354         } catch (IllegalAccessException ignored) {
355         }
356
357         return topics;
358     }
359 }