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 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;
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);
180 // remove empty headers
181 config.headers.removeIf(String::isBlank);
183 // configure authentication
185 AuthenticationStore authStore = rateLimitedHttpClient.getAuthenticationStore();
186 URI uri = new URI(config.baseURL);
188 // clear old auths if available
189 Authentication.Result authResult = authStore.findAuthenticationResult(uri);
190 if (authResult != null) {
191 authStore.removeAuthenticationResult(authResult);
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);
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());
208 if (!config.password.isEmpty()) {
209 config.headers.add("Authorization=Bearer " + config.password);
210 logger.debug("Token/Bearer Authentication configured for thing '{}'", thing.getUID());
212 logger.warn("Token/Bearer Authentication configured for thing '{}' but token is empty!",
217 authStore.addAuthentication(new BasicAuthentication(uri, Authentication.ANY_REALM,
218 config.username, config.password));
219 logger.debug("Basic Authentication configured for thing '{}'", thing.getUID());
222 authStore.addAuthentication(new DigestAuthentication(uri, Authentication.ANY_REALM,
223 config.username, config.password));
224 logger.debug("Digest Authentication configured for thing '{}'", thing.getUID());
227 logger.warn("Unknown authentication method '{}' for thing '{}'", config.authMode,
231 logger.debug("No authentication configured for thing '{}'", thing.getUID());
233 } catch (URISyntaxException e) {
234 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Cannot create URI from baseUrl.");
237 thing.getChannels().forEach(this::createChannel);
239 updateStatus(ThingStatus.UNKNOWN);
243 public void dispose() {
245 urlHandlers.values().forEach(RefreshingUrlCache::stop);
246 rateLimitedHttpClient.shutdown();
253 // remove state descriptions
254 httpDynamicStateDescriptionProvider.removeDescriptionsForThing(thing.getUID());
260 * create all necessary information to handle every channel
262 * @param channel a thing channel
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
269 ChannelUID channelUID = channel.getUID();
270 HttpChannelConfig channelConfig = channel.getConfiguration().as(HttpChannelConfig.class);
272 String stateUrl = concatenateUrlParts(config.baseURL, channelConfig.stateExtension);
273 String commandUrl = channelConfig.commandExtension == null ? stateUrl
274 : concatenateUrlParts(config.baseURL, channelConfig.commandExtension);
276 String acceptedItemType = channel.getAcceptedItemType();
277 if (acceptedItemType == null) {
278 logger.warn("Cannot determine item-type for channel '{}'", channelUID);
282 ChannelHandler itemValueConverter;
283 switch (acceptedItemType) {
285 itemValueConverter = createChannelHandler(ColorChannelHandler::new, commandUrl, channelUID,
289 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig,
293 itemValueConverter = createChannelHandler(DimmerChannelHandler::new, commandUrl, channelUID,
298 itemValueConverter = createChannelHandler(FixedValueMappingChannelHandler::new, commandUrl, channelUID,
302 itemValueConverter = new ImageChannelHandler(state -> updateState(channelUID, state));
305 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig, PointType::new);
308 itemValueConverter = createChannelHandler(NumberChannelHandler::new, commandUrl, channelUID,
312 itemValueConverter = createChannelHandler(PlayerChannelHandler::new, commandUrl, channelUID,
315 case "Rollershutter":
316 itemValueConverter = createChannelHandler(RollershutterChannelHandler::new, commandUrl, channelUID,
320 itemValueConverter = createGenericChannelHandler(commandUrl, channelUID, channelConfig,
324 logger.warn("Unsupported item-type '{}'", channel.getAcceptedItemType());
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);
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);
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, ""));
357 public void onHttpSuccess() {
358 updateState(CHANNEL_LAST_SUCCESS, new DateTimeType(Instant.now().atZone(timeZoneProvider.getTimeZone())));
359 updateStatus(ThingStatus.ONLINE);
362 private void sendHttpValue(String commandUrl, String command) {
363 sendHttpValue(commandUrl, command, false);
366 private void sendHttpValue(String commandUrl, String command, boolean isRetry) {
369 URI uri = Util.uriFromString(String.format(commandUrl, new Date(), command));
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);
377 CompletableFuture<@Nullable ChannelHandlerContent> responseContentFuture = new CompletableFuture<>();
378 responseContentFuture.exceptionally(t -> {
379 if (t instanceof HttpAuthException) {
380 if (isRetry || !rateLimitedHttpClient.reAuth(uri)) {
382 "Retry after authentication failure failed again for '{}', failing here",
384 onHttpError("Authentication failed");
386 sendHttpValue(commandUrl, command, true);
392 if (logger.isTraceEnabled()) {
393 logger.trace("Sending to '{}': {}", uri, Util.requestToLogString(request));
396 request.send(new HttpResponseListener(responseContentFuture, null, config.bufferSize, this));
398 } catch (IllegalArgumentException | URISyntaxException | MalformedURLException e) {
399 logger.warn("Creating request for '{}' failed: {}", commandUrl, e.getMessage());
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;
409 return baseUrl + extension;
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);
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);