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