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