]> git.basschouten.com Git - openhab-addons.git/blob
f7ce74a0fad209019c7becdf7473ac76da59806c
[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.http.internal;
14
15 import java.net.URI;
16 import java.net.URISyntaxException;
17 import java.util.Date;
18 import java.util.HashMap;
19 import java.util.Map;
20 import java.util.Set;
21 import java.util.concurrent.CompletableFuture;
22 import java.util.concurrent.TimeUnit;
23 import java.util.function.Function;
24
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.eclipse.jetty.client.api.Authentication;
29 import org.eclipse.jetty.client.api.AuthenticationStore;
30 import org.eclipse.jetty.client.api.Request;
31 import org.eclipse.jetty.client.util.BasicAuthentication;
32 import org.eclipse.jetty.client.util.DigestAuthentication;
33 import org.eclipse.jetty.client.util.StringContentProvider;
34 import org.eclipse.jetty.http.HttpMethod;
35 import org.openhab.binding.http.internal.config.HttpChannelConfig;
36 import org.openhab.binding.http.internal.config.HttpChannelMode;
37 import org.openhab.binding.http.internal.config.HttpThingConfig;
38 import org.openhab.binding.http.internal.converter.AbstractTransformingItemConverter;
39 import org.openhab.binding.http.internal.converter.ColorItemConverter;
40 import org.openhab.binding.http.internal.converter.DimmerItemConverter;
41 import org.openhab.binding.http.internal.converter.FixedValueMappingItemConverter;
42 import org.openhab.binding.http.internal.converter.GenericItemConverter;
43 import org.openhab.binding.http.internal.converter.ImageItemConverter;
44 import org.openhab.binding.http.internal.converter.ItemValueConverter;
45 import org.openhab.binding.http.internal.converter.PlayerItemConverter;
46 import org.openhab.binding.http.internal.converter.RollershutterItemConverter;
47 import org.openhab.binding.http.internal.http.Content;
48 import org.openhab.binding.http.internal.http.HttpAuthException;
49 import org.openhab.binding.http.internal.http.HttpResponseListener;
50 import org.openhab.binding.http.internal.http.RefreshingUrlCache;
51 import org.openhab.binding.http.internal.transform.ValueTransformationProvider;
52 import org.openhab.core.library.types.DateTimeType;
53 import org.openhab.core.library.types.DecimalType;
54 import org.openhab.core.library.types.PointType;
55 import org.openhab.core.library.types.StringType;
56 import org.openhab.core.thing.Channel;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.binding.BaseThingHandler;
62 import org.openhab.core.types.Command;
63 import org.openhab.core.types.RefreshType;
64 import org.openhab.core.types.State;
65 import org.openhab.core.types.StateDescription;
66 import org.openhab.core.types.StateDescriptionFragmentBuilder;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
69
70 /**
71  * The {@link HttpThingHandler} is responsible for handling commands, which are
72  * sent to one of the channels.
73  *
74  * @author Jan N. Klug - Initial contribution
75  */
76 @NonNullByDefault
77 public class HttpThingHandler extends BaseThingHandler {
78     private static final Set<Character> URL_PART_DELIMITER = Set.of('/', '?', '&');
79
80     private final Logger logger = LoggerFactory.getLogger(HttpThingHandler.class);
81     private final ValueTransformationProvider valueTransformationProvider;
82     private final HttpClientProvider httpClientProvider;
83     private HttpClient httpClient;
84     private final HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider;
85
86     private HttpThingConfig config = new HttpThingConfig();
87     private final Map<String, RefreshingUrlCache> urlHandlers = new HashMap<>();
88     private final Map<ChannelUID, ItemValueConverter> channels = new HashMap<>();
89     private final Map<ChannelUID, String> channelUrls = new HashMap<>();
90     private @Nullable Authentication authentication;
91
92     public HttpThingHandler(Thing thing, HttpClientProvider httpClientProvider,
93             ValueTransformationProvider valueTransformationProvider,
94             HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider) {
95         super(thing);
96         this.httpClientProvider = httpClientProvider;
97         this.httpClient = httpClientProvider.getSecureClient();
98         this.valueTransformationProvider = valueTransformationProvider;
99         this.httpDynamicStateDescriptionProvider = httpDynamicStateDescriptionProvider;
100     }
101
102     @Override
103     public void handleCommand(ChannelUID channelUID, Command command) {
104         ItemValueConverter itemValueConverter = channels.get(channelUID);
105         if (itemValueConverter == null) {
106             logger.warn("Cannot find channel implementation for channel {}.", channelUID);
107             return;
108         }
109
110         if (command instanceof RefreshType) {
111             String stateUrl = channelUrls.get(channelUID);
112             if (stateUrl != null) {
113                 RefreshingUrlCache refreshingUrlCache = urlHandlers.get(stateUrl);
114                 if (refreshingUrlCache != null) {
115                     try {
116                         refreshingUrlCache.get().ifPresent(itemValueConverter::process);
117                     } catch (IllegalArgumentException | IllegalStateException e) {
118                         logger.warn("Failed processing REFRESH command for channel {}: {}", channelUID, e.getMessage());
119                     }
120                 }
121             }
122         } else {
123             try {
124                 itemValueConverter.send(command);
125             } catch (IllegalArgumentException e) {
126                 logger.warn("Failed to convert command '{}' to channel '{}' for sending", command, channelUID);
127             } catch (IllegalStateException e) {
128                 logger.debug("Writing to read-only channel {} not permitted", channelUID);
129             }
130         }
131     }
132
133     @Override
134     public void initialize() {
135         config = getConfigAs(HttpThingConfig.class);
136
137         if (config.baseURL.isEmpty()) {
138             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
139                     "Parameter baseURL must not be empty!");
140             return;
141         }
142         authentication = null;
143         if (!config.username.isEmpty()) {
144             try {
145                 URI uri = new URI(config.baseURL);
146                 switch (config.authMode) {
147                     case BASIC:
148                         authentication = new BasicAuthentication(uri, Authentication.ANY_REALM, config.username,
149                                 config.password);
150                         logger.debug("Basic Authentication configured for thing '{}'", thing.getUID());
151                         break;
152                     case DIGEST:
153                         authentication = new DigestAuthentication(uri, Authentication.ANY_REALM, config.username,
154                                 config.password);
155                         logger.debug("Digest Authentication configured for thing '{}'", thing.getUID());
156                         break;
157                     default:
158                         logger.warn("Unknown authentication method '{}' for thing '{}'", config.authMode,
159                                 thing.getUID());
160                 }
161                 if (authentication != null) {
162                     AuthenticationStore authStore = httpClient.getAuthenticationStore();
163                     authStore.addAuthentication(authentication);
164                 }
165             } catch (URISyntaxException e) {
166                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
167                         "failed to create authentication: baseUrl is invalid");
168             }
169         } else {
170             logger.debug("No authentication configured for thing '{}'", thing.getUID());
171         }
172
173         if (config.ignoreSSLErrors) {
174             logger.info("Using the insecure client for thing '{}'.", thing.getUID());
175             httpClient = httpClientProvider.getInsecureClient();
176         } else {
177             logger.info("Using the secure client for thing '{}'.", thing.getUID());
178             httpClient = httpClientProvider.getSecureClient();
179         }
180
181         thing.getChannels().forEach(this::createChannel);
182
183         updateStatus(ThingStatus.ONLINE);
184     }
185
186     @Override
187     public void dispose() {
188         // stop update tasks
189         urlHandlers.values().forEach(RefreshingUrlCache::stop);
190
191         // clear lists
192         urlHandlers.clear();
193         channels.clear();
194         channelUrls.clear();
195
196         // remove state descriptions
197         httpDynamicStateDescriptionProvider.removeDescriptionsForThing(thing.getUID());
198
199         super.dispose();
200     }
201
202     /**
203      * create all necessary information to handle every channel
204      *
205      * @param channel a thing channel
206      */
207     private void createChannel(Channel channel) {
208         ChannelUID channelUID = channel.getUID();
209         HttpChannelConfig channelConfig = channel.getConfiguration().as(HttpChannelConfig.class);
210
211         String stateUrl = concatenateUrlParts(config.baseURL, channelConfig.stateExtension);
212         String commandUrl = channelConfig.commandExtension == null ? stateUrl
213                 : concatenateUrlParts(config.baseURL, channelConfig.commandExtension);
214
215         String acceptedItemType = channel.getAcceptedItemType();
216         if (acceptedItemType == null) {
217             logger.warn("Cannot determine item-type for channel '{}'", channelUID);
218             return;
219         }
220
221         ItemValueConverter itemValueConverter;
222         switch (acceptedItemType) {
223             case "Color":
224                 itemValueConverter = createItemConverter(ColorItemConverter::new, commandUrl, channelUID,
225                         channelConfig);
226                 break;
227             case "DateTime":
228                 itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig,
229                         DateTimeType::new);
230                 break;
231             case "Dimmer":
232                 itemValueConverter = createItemConverter(DimmerItemConverter::new, commandUrl, channelUID,
233                         channelConfig);
234                 break;
235             case "Contact":
236             case "Switch":
237                 itemValueConverter = createItemConverter(FixedValueMappingItemConverter::new, commandUrl, channelUID,
238                         channelConfig);
239                 break;
240             case "Image":
241                 itemValueConverter = new ImageItemConverter(state -> updateState(channelUID, state));
242                 break;
243             case "Location":
244                 itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig, PointType::new);
245                 break;
246             case "Number":
247                 itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig,
248                         DecimalType::new);
249                 break;
250             case "Player":
251                 itemValueConverter = createItemConverter(PlayerItemConverter::new, commandUrl, channelUID,
252                         channelConfig);
253                 break;
254             case "Rollershutter":
255                 itemValueConverter = createItemConverter(RollershutterItemConverter::new, commandUrl, channelUID,
256                         channelConfig);
257                 break;
258             case "String":
259                 itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig, StringType::new);
260                 break;
261             default:
262                 logger.warn("Unsupported item-type '{}'", channel.getAcceptedItemType());
263                 return;
264         }
265
266         channels.put(channelUID, itemValueConverter);
267         if (channelConfig.mode != HttpChannelMode.WRITEONLY) {
268             channelUrls.put(channelUID, stateUrl);
269             urlHandlers.computeIfAbsent(stateUrl, url -> new RefreshingUrlCache(scheduler, httpClient, url, config))
270                     .addConsumer(itemValueConverter::process);
271         }
272
273         StateDescription stateDescription = StateDescriptionFragmentBuilder.create()
274                 .withReadOnly(channelConfig.mode == HttpChannelMode.READONLY).build().toStateDescription();
275         if (stateDescription != null) {
276             // if the state description is not available, we don'tneed to add it
277             httpDynamicStateDescriptionProvider.setDescription(channelUID, stateDescription);
278         }
279     }
280
281     private void sendHttpValue(String commandUrl, String command) {
282         sendHttpValue(commandUrl, command, false);
283     }
284
285     private void sendHttpValue(String commandUrl, String command, boolean isRetry) {
286         try {
287             // format URL
288             URI finalUrl = new URI(String.format(commandUrl, new Date(), command));
289
290             // build request
291             Request request = httpClient.newRequest(finalUrl).timeout(config.timeout, TimeUnit.MILLISECONDS)
292                     .method(config.commandMethod);
293             if (config.commandMethod != HttpMethod.GET) {
294                 final String contentType = config.contentType;
295                 if (contentType != null) {
296                     request.content(new StringContentProvider(command), contentType);
297                 } else {
298                     request.content(new StringContentProvider(command));
299                 }
300             }
301
302             config.headers.forEach(header -> {
303                 String[] keyValuePair = header.split("=", 2);
304                 if (keyValuePair.length == 2) {
305                     request.header(keyValuePair[0], keyValuePair[1]);
306                 } else {
307                     logger.warn("Splitting header '{}' failed. No '=' was found. Ignoring", header);
308                 }
309             });
310
311             if (logger.isTraceEnabled()) {
312                 logger.trace("Sending to '{}': {}", finalUrl, Util.requestToLogString(request));
313             }
314
315             CompletableFuture<@Nullable Content> f = new CompletableFuture<>();
316             f.exceptionally(e -> {
317                 if (e instanceof HttpAuthException) {
318                     if (isRetry) {
319                         logger.warn("Retry after authentication failure failed again for '{}', failing here", finalUrl);
320                     } else {
321                         AuthenticationStore authStore = httpClient.getAuthenticationStore();
322                         Authentication.Result authResult = authStore.findAuthenticationResult(finalUrl);
323                         if (authResult != null) {
324                             authStore.removeAuthenticationResult(authResult);
325                             logger.debug("Cleared authentication result for '{}', retrying immediately", finalUrl);
326                             sendHttpValue(commandUrl, command, true);
327                         } else {
328                             logger.warn("Could not find authentication result for '{}', failing here", finalUrl);
329                         }
330                     }
331                 }
332                 return null;
333             });
334             request.send(new HttpResponseListener(f, null, config.bufferSize));
335         } catch (IllegalArgumentException | URISyntaxException e) {
336             logger.warn("Creating request for '{}' failed: {}", commandUrl, e.getMessage());
337         }
338     }
339
340     private String concatenateUrlParts(String baseUrl, @Nullable String extension) {
341         if (extension != null && !extension.isEmpty()) {
342             if (!URL_PART_DELIMITER.contains(baseUrl.charAt(baseUrl.length() - 1))
343                     && !URL_PART_DELIMITER.contains(extension.charAt(0))) {
344                 return baseUrl + "/" + extension;
345             } else {
346                 return baseUrl + extension;
347             }
348         } else {
349             return baseUrl;
350         }
351     }
352
353     private ItemValueConverter createItemConverter(AbstractTransformingItemConverter.Factory factory, String commandUrl,
354             ChannelUID channelUID, HttpChannelConfig channelConfig) {
355         return factory.create(state -> updateState(channelUID, state), command -> postCommand(channelUID, command),
356                 command -> sendHttpValue(commandUrl, command),
357                 valueTransformationProvider.getValueTransformation(channelConfig.stateTransformation),
358                 valueTransformationProvider.getValueTransformation(channelConfig.commandTransformation), channelConfig);
359     }
360
361     private ItemValueConverter createGenericItemConverter(String commandUrl, ChannelUID channelUID,
362             HttpChannelConfig channelConfig, Function<String, State> toState) {
363         AbstractTransformingItemConverter.Factory factory = (state, command, value, stateTrans, commandTrans,
364                 config) -> new GenericItemConverter(toState, state, command, value, stateTrans, commandTrans, config);
365         return createItemConverter(factory, commandUrl, channelUID, channelConfig);
366     }
367 }