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