2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.http.internal;
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;
19 import java.net.MalformedURLException;
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;
28 import java.util.Objects;
30 import java.util.concurrent.CompletableFuture;
31 import java.util.concurrent.TimeUnit;
32 import java.util.function.Function;
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;
79 * The {@link HttpThingHandler} is responsible for handling commands, which are
80 * sent to one of the channels.
82 * @author Jan N. Klug - Initial contribution
85 public class HttpThingHandler extends BaseThingHandler implements HttpStatusListener {
86 private static final Set<Character> URL_PART_DELIMITER = Set.of('/', '?', '&');
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;
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<>();
99 public HttpThingHandler(Thing thing, HttpClientProvider httpClientProvider,
100 HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider,
101 TimeZoneProvider timeZoneProvider) {
103 this.httpClientProvider = httpClientProvider;
104 this.rateLimitedHttpClient = new RateLimitedHttpClient(httpClientProvider.getSecureClient(), scheduler);
105 this.httpDynamicStateDescriptionProvider = httpDynamicStateDescriptionProvider;
106 this.timeZoneProvider = timeZoneProvider;
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);
117 if (command instanceof RefreshType) {
118 String key = channelUrls.get(channelUID);
120 RefreshingUrlCache refreshingUrlCache = urlHandlers.get(key);
121 if (refreshingUrlCache != null) {
123 refreshingUrlCache.get().ifPresentOrElse(itemValueConverter::process, () -> {
124 if (config.strictErrorHandling) {
125 itemValueConverter.process(null);
128 } catch (IllegalArgumentException | IllegalStateException e) {
129 logger.warn("Failed processing REFRESH command for channel {}: {}", channelUID, e.getMessage());
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);
145 public void initialize() {
146 config = getConfigAs(HttpThingConfig.class);
148 if (config.baseURL.isEmpty()) {
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
150 "Parameter baseURL must not be empty!");
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.");
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());
166 logger.info("Using the secure client for thing '{}'.", thing.getUID());
167 rateLimitedHttpClient.setHttpClient(httpClientProvider.getSecureClient());
169 rateLimitedHttpClient.setDelay(config.delay);
171 // remove empty headers
172 config.headers.removeIf(String::isBlank);
174 // configure authentication
176 AuthenticationStore authStore = rateLimitedHttpClient.getAuthenticationStore();
177 URI uri = new URI(config.baseURL);
179 // clear old auths if available
180 Authentication.Result authResult = authStore.findAuthenticationResult(uri);
181 if (authResult != null) {
182 authStore.removeAuthenticationResult(authResult);
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);
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());
199 if (!config.password.isEmpty()) {
200 config.headers.add("Authorization=Bearer " + config.password);
201 logger.debug("Token/Bearer Authentication configured for thing '{}'", thing.getUID());
203 logger.warn("Token/Bearer Authentication configured for thing '{}' but token is empty!",
208 authStore.addAuthentication(new BasicAuthentication(uri, Authentication.ANY_REALM,
209 config.username, config.password));
210 logger.debug("Basic Authentication configured for thing '{}'", thing.getUID());
213 authStore.addAuthentication(new DigestAuthentication(uri, Authentication.ANY_REALM,
214 config.username, config.password));
215 logger.debug("Digest Authentication configured for thing '{}'", thing.getUID());
218 logger.warn("Unknown authentication method '{}' for thing '{}'", config.authMode,
222 logger.debug("No authentication configured for thing '{}'", thing.getUID());
224 } catch (URISyntaxException e) {
225 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Cannot create URI from baseUrl.");
228 thing.getChannels().forEach(this::createChannel);
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;
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);
239 urlHandlers.values().forEach(urlHandler -> urlHandler.start(scheduler, config.refresh));
241 updateStatus(ThingStatus.UNKNOWN);
245 public void dispose() {
247 urlHandlers.values().forEach(RefreshingUrlCache::stop);
248 rateLimitedHttpClient.shutdown();
255 // remove state descriptions
256 httpDynamicStateDescriptionProvider.removeDescriptionsForThing(thing.getUID());
262 * create all necessary information to handle every channel
264 * @param channel a thing channel
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
271 ChannelUID channelUID = channel.getUID();
272 HttpChannelConfig channelConfig = channel.getConfiguration().as(HttpChannelConfig.class);
274 String stateUrl = concatenateUrlParts(config.baseURL, channelConfig.stateExtension);
275 String commandUrl = channelConfig.commandExtension == null ? stateUrl
276 : concatenateUrlParts(config.baseURL, channelConfig.commandExtension);
278 String acceptedItemType = channel.getAcceptedItemType();
279 if (acceptedItemType == null) {
280 logger.warn("Cannot determine item-type for channel '{}'", channelUID);
284 ChannelHandler itemValueConverter;
285 switch (acceptedItemType) {
287 itemValueConverter = createChannelHandler(ColorChannelHandler::new, commandUrl, channelUID,
291 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig,
295 itemValueConverter = createChannelHandler(DimmerChannelHandler::new, commandUrl, channelUID,
300 itemValueConverter = createChannelHandler(FixedValueMappingChannelHandler::new, commandUrl, channelUID,
304 itemValueConverter = new ImageChannelHandler(state -> updateState(channelUID, state));
307 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig, PointType::new);
310 itemValueConverter = createChannelHandler(NumberChannelHandler::new, commandUrl, channelUID,
314 itemValueConverter = createChannelHandler(PlayerChannelHandler::new, commandUrl, channelUID,
317 case "Rollershutter":
318 itemValueConverter = createChannelHandler(RollershutterChannelHandler::new, commandUrl, channelUID,
322 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig,
326 logger.warn("Unsupported item-type '{}'", channel.getAcceptedItemType());
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);
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);
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, ""));
360 public void onHttpSuccess() {
361 updateState(CHANNEL_LAST_SUCCESS, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone())));
362 updateStatus(ThingStatus.ONLINE);
365 private void sendHttpValue(String commandUrl, String command) {
366 sendHttpValue(commandUrl, command, false);
369 private void sendHttpValue(String commandUrl, String command, boolean isRetry) {
372 URI uri = Util.uriFromString(String.format(commandUrl, new Date(), command));
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);
380 CompletableFuture<@Nullable ChannelHandlerContent> responseContentFuture = new CompletableFuture<>();
381 responseContentFuture.exceptionally(t -> {
382 if (t instanceof HttpAuthException) {
383 if (isRetry || !rateLimitedHttpClient.reAuth(uri)) {
385 "Retry after authentication failure failed again for '{}', failing here",
387 onHttpError("Authentication failed");
389 sendHttpValue(commandUrl, command, true);
395 if (logger.isTraceEnabled()) {
396 logger.trace("Sending to '{}': {}", uri, Util.requestToLogString(request));
399 request.send(new HttpResponseListener(responseContentFuture, null, config.bufferSize, this));
401 } catch (IllegalArgumentException | URISyntaxException | MalformedURLException e) {
402 logger.warn("Creating request for '{}' failed: {}", commandUrl, e.getMessage());
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;
412 return baseUrl + extension;
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);
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);