]> git.basschouten.com Git - openhab-addons.git/blob
8947852e11057c01c296ec8ea107e6e569b90c05
[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         // remove empty headers
172         config.headers.removeIf(String::isBlank);
173
174         // configure authentication
175         try {
176             AuthenticationStore authStore = rateLimitedHttpClient.getAuthenticationStore();
177             URI uri = new URI(config.baseURL);
178
179             // clear old auths if available
180             Authentication.Result authResult = authStore.findAuthenticationResult(uri);
181             if (authResult != null) {
182                 authStore.removeAuthenticationResult(authResult);
183             }
184             for (String authType : List.of("Basic", "Digest")) {
185                 Authentication authentication = authStore.findAuthentication(authType, uri, Authentication.ANY_REALM);
186                 if (authentication != null) {
187                     authStore.removeAuthentication(authentication);
188                 }
189             }
190
191             if (!config.username.isEmpty() || !config.password.isEmpty()) {
192                 switch (config.authMode) {
193                     case BASIC_PREEMPTIVE:
194                         config.headers.add("Authorization=Basic " + Base64.getEncoder()
195                                 .encodeToString((config.username + ":" + config.password).getBytes()));
196                         logger.debug("Preemptive Basic Authentication configured for thing '{}'", thing.getUID());
197                         break;
198                     case TOKEN:
199                         if (!config.password.isEmpty()) {
200                             config.headers.add("Authorization=Bearer " + config.password);
201                             logger.debug("Token/Bearer Authentication configured for thing '{}'", thing.getUID());
202                         } else {
203                             logger.warn("Token/Bearer Authentication configured for thing '{}' but token is empty!",
204                                     thing.getUID());
205                         }
206                         break;
207                     case BASIC:
208                         authStore.addAuthentication(new BasicAuthentication(uri, Authentication.ANY_REALM,
209                                 config.username, config.password));
210                         logger.debug("Basic Authentication configured for thing '{}'", thing.getUID());
211                         break;
212                     case DIGEST:
213                         authStore.addAuthentication(new DigestAuthentication(uri, Authentication.ANY_REALM,
214                                 config.username, config.password));
215                         logger.debug("Digest Authentication configured for thing '{}'", thing.getUID());
216                         break;
217                     default:
218                         logger.warn("Unknown authentication method '{}' for thing '{}'", config.authMode,
219                                 thing.getUID());
220                 }
221             } else {
222                 logger.debug("No authentication configured for thing '{}'", thing.getUID());
223             }
224         } catch (URISyntaxException e) {
225             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Cannot create URI from baseUrl.");
226         }
227         // create channels
228         thing.getChannels().forEach(this::createChannel);
229
230         int urlHandlerCount = urlHandlers.size();
231         if (urlHandlerCount * config.delay > config.refresh * 1000) {
232             // this should prevent the rate limit queue from filling up
233             config.refresh = (urlHandlerCount * config.delay) / 1000 + 1;
234             logger.warn(
235                     "{} channels in thing {} with a delay of {} incompatible with the configured refresh time. Refresh-Time increased to the minimum of {}",
236                     urlHandlerCount, thing.getUID(), config.delay, config.refresh);
237         }
238
239         urlHandlers.values().forEach(urlHandler -> urlHandler.start(scheduler, config.refresh));
240
241         updateStatus(ThingStatus.UNKNOWN);
242     }
243
244     @Override
245     public void dispose() {
246         // stop update tasks
247         urlHandlers.values().forEach(RefreshingUrlCache::stop);
248         rateLimitedHttpClient.shutdown();
249
250         // clear lists
251         urlHandlers.clear();
252         channels.clear();
253         channelUrls.clear();
254
255         // remove state descriptions
256         httpDynamicStateDescriptionProvider.removeDescriptionsForThing(thing.getUID());
257
258         super.dispose();
259     }
260
261     /**
262      * create all necessary information to handle every channel
263      *
264      * @param channel a thing channel
265      */
266     private void createChannel(Channel channel) {
267         if (REQUEST_DATE_TIME_CHANNELTYPE_UID.equals(channel.getChannelTypeUID())) {
268             // do not generate refreshUrls for lastSuccess / lastFailure channels
269             return;
270         }
271         ChannelUID channelUID = channel.getUID();
272         HttpChannelConfig channelConfig = channel.getConfiguration().as(HttpChannelConfig.class);
273
274         String stateUrl = concatenateUrlParts(config.baseURL, channelConfig.stateExtension);
275         String commandUrl = channelConfig.commandExtension == null ? stateUrl
276                 : concatenateUrlParts(config.baseURL, channelConfig.commandExtension);
277
278         String acceptedItemType = channel.getAcceptedItemType();
279         if (acceptedItemType == null) {
280             logger.warn("Cannot determine item-type for channel '{}'", channelUID);
281             return;
282         }
283
284         ChannelHandler itemValueConverter;
285         switch (acceptedItemType) {
286             case "Color":
287                 itemValueConverter = createChannelHandler(ColorChannelHandler::new, commandUrl, channelUID,
288                         channelConfig);
289                 break;
290             case "DateTime":
291                 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig,
292                         DateTimeType::new);
293                 break;
294             case "Dimmer":
295                 itemValueConverter = createChannelHandler(DimmerChannelHandler::new, commandUrl, channelUID,
296                         channelConfig);
297                 break;
298             case "Contact":
299             case "Switch":
300                 itemValueConverter = createChannelHandler(FixedValueMappingChannelHandler::new, commandUrl, channelUID,
301                         channelConfig);
302                 break;
303             case "Image":
304                 itemValueConverter = new ImageChannelHandler(state -> updateState(channelUID, state));
305                 break;
306             case "Location":
307                 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig, PointType::new);
308                 break;
309             case "Number":
310                 itemValueConverter = createChannelHandler(NumberChannelHandler::new, commandUrl, channelUID,
311                         channelConfig);
312                 break;
313             case "Player":
314                 itemValueConverter = createChannelHandler(PlayerChannelHandler::new, commandUrl, channelUID,
315                         channelConfig);
316                 break;
317             case "Rollershutter":
318                 itemValueConverter = createChannelHandler(RollershutterChannelHandler::new, commandUrl, channelUID,
319                         channelConfig);
320                 break;
321             case "String":
322                 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig,
323                         StringType::new);
324                 break;
325             default:
326                 logger.warn("Unsupported item-type '{}'", channel.getAcceptedItemType());
327                 return;
328         }
329
330         channels.put(channelUID, itemValueConverter);
331         if (channelConfig.mode != ChannelMode.WRITEONLY) {
332             // we need a key consisting of stateContent and URL, only if both are equal, we can use the same cache
333             String key = channelConfig.stateContent + "$" + stateUrl;
334             channelUrls.put(channelUID, key);
335             Objects.requireNonNull(
336                     urlHandlers.computeIfAbsent(key,
337                             k -> new RefreshingUrlCache(rateLimitedHttpClient, stateUrl, config,
338                                     channelConfig.stateContent, config.contentType, this)))
339                     .addConsumer(itemValueConverter::process);
340         }
341
342         StateDescription stateDescription = StateDescriptionFragmentBuilder.create()
343                 .withReadOnly(channelConfig.mode == ChannelMode.READONLY).build().toStateDescription();
344         if (stateDescription != null) {
345             // if the state description is not available, we don't need to add it
346             httpDynamicStateDescriptionProvider.setDescription(channelUID, stateDescription);
347         }
348     }
349
350     @Override
351     public void onHttpError(@Nullable String message) {
352         updateState(CHANNEL_LAST_FAILURE, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone())));
353         if (config.strictErrorHandling) {
354             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
355                     Objects.requireNonNullElse(message, ""));
356         }
357     }
358
359     @Override
360     public void onHttpSuccess() {
361         updateState(CHANNEL_LAST_SUCCESS, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone())));
362         updateStatus(ThingStatus.ONLINE);
363     }
364
365     private void sendHttpValue(String commandUrl, String command) {
366         sendHttpValue(commandUrl, command, false);
367     }
368
369     private void sendHttpValue(String commandUrl, String command, boolean isRetry) {
370         try {
371             // format URL
372             URI uri = Util.uriFromString(String.format(commandUrl, new Date(), command));
373
374             // build request
375             rateLimitedHttpClient.newPriorityRequest(uri, config.commandMethod, command, config.contentType)
376                     .thenAccept(request -> {
377                         request.timeout(config.timeout, TimeUnit.MILLISECONDS);
378                         config.getHeaders().forEach(request::header);
379
380                         CompletableFuture<@Nullable ChannelHandlerContent> responseContentFuture = new CompletableFuture<>();
381                         responseContentFuture.exceptionally(t -> {
382                             if (t instanceof HttpAuthException) {
383                                 if (isRetry || !rateLimitedHttpClient.reAuth(uri)) {
384                                     logger.warn(
385                                             "Retry after authentication failure failed again for '{}', failing here",
386                                             uri);
387                                     onHttpError("Authentication failed");
388                                 } else {
389                                     sendHttpValue(commandUrl, command, true);
390                                 }
391                             }
392                             return null;
393                         });
394
395                         if (logger.isTraceEnabled()) {
396                             logger.trace("Sending to '{}': {}", uri, Util.requestToLogString(request));
397                         }
398
399                         request.send(new HttpResponseListener(responseContentFuture, null, config.bufferSize, this));
400                     });
401         } catch (IllegalArgumentException | URISyntaxException | MalformedURLException e) {
402             logger.warn("Creating request for '{}' failed: {}", commandUrl, e.getMessage());
403         }
404     }
405
406     private String concatenateUrlParts(String baseUrl, @Nullable String extension) {
407         if (extension != null && !extension.isEmpty()) {
408             if (!URL_PART_DELIMITER.contains(baseUrl.charAt(baseUrl.length() - 1))
409                     && !URL_PART_DELIMITER.contains(extension.charAt(0))) {
410                 return baseUrl + "/" + extension;
411             } else {
412                 return baseUrl + extension;
413             }
414         } else {
415             return baseUrl;
416         }
417     }
418
419     private ChannelHandler createChannelHandler(AbstractTransformingChannelHandler.Factory factory, String commandUrl,
420             ChannelUID channelUID, HttpChannelConfig channelConfig) {
421         return factory.create(state -> updateState(channelUID, state), command -> postCommand(channelUID, command),
422                 command -> sendHttpValue(commandUrl, command),
423                 new ChannelTransformation(channelConfig.stateTransformation),
424                 new ChannelTransformation(channelConfig.commandTransformation), channelConfig);
425     }
426
427     private ChannelHandler createGenericChannelHandler(String commandUrl, ChannelUID channelUID,
428             HttpChannelConfig channelConfig, Function<String, State> toState) {
429         AbstractTransformingChannelHandler.Factory factory = (state, command, value, stateTrans, commandTrans,
430                 config) -> new GenericChannelHandler(toState, state, command, value, stateTrans, commandTrans, config);
431         return createChannelHandler(factory, commandUrl, channelUID, channelConfig);
432     }
433 }