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