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