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