2 * Copyright (c) 2010-2020 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;
16 import java.net.URISyntaxException;
17 import java.util.Date;
18 import java.util.HashMap;
21 import java.util.concurrent.CompletableFuture;
22 import java.util.concurrent.TimeUnit;
23 import java.util.function.Function;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.eclipse.jetty.client.api.Authentication;
29 import org.eclipse.jetty.client.api.AuthenticationStore;
30 import org.eclipse.jetty.client.api.Request;
31 import org.eclipse.jetty.client.util.BasicAuthentication;
32 import org.eclipse.jetty.client.util.DigestAuthentication;
33 import org.eclipse.jetty.client.util.StringContentProvider;
34 import org.eclipse.jetty.http.HttpMethod;
35 import org.openhab.binding.http.internal.config.HttpChannelConfig;
36 import org.openhab.binding.http.internal.config.HttpChannelMode;
37 import org.openhab.binding.http.internal.config.HttpThingConfig;
38 import org.openhab.binding.http.internal.converter.AbstractTransformingItemConverter;
39 import org.openhab.binding.http.internal.converter.ColorItemConverter;
40 import org.openhab.binding.http.internal.converter.DimmerItemConverter;
41 import org.openhab.binding.http.internal.converter.FixedValueMappingItemConverter;
42 import org.openhab.binding.http.internal.converter.GenericItemConverter;
43 import org.openhab.binding.http.internal.converter.ImageItemConverter;
44 import org.openhab.binding.http.internal.converter.ItemValueConverter;
45 import org.openhab.binding.http.internal.converter.PlayerItemConverter;
46 import org.openhab.binding.http.internal.converter.RollershutterItemConverter;
47 import org.openhab.binding.http.internal.http.Content;
48 import org.openhab.binding.http.internal.http.HttpAuthException;
49 import org.openhab.binding.http.internal.http.HttpResponseListener;
50 import org.openhab.binding.http.internal.http.RefreshingUrlCache;
51 import org.openhab.binding.http.internal.transform.ValueTransformationProvider;
52 import org.openhab.core.library.types.DateTimeType;
53 import org.openhab.core.library.types.DecimalType;
54 import org.openhab.core.library.types.PointType;
55 import org.openhab.core.library.types.StringType;
56 import org.openhab.core.thing.Channel;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.binding.BaseThingHandler;
62 import org.openhab.core.types.Command;
63 import org.openhab.core.types.RefreshType;
64 import org.openhab.core.types.State;
65 import org.openhab.core.types.StateDescription;
66 import org.openhab.core.types.StateDescriptionFragmentBuilder;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
71 * The {@link HttpThingHandler} is responsible for handling commands, which are
72 * sent to one of the channels.
74 * @author Jan N. Klug - Initial contribution
77 public class HttpThingHandler extends BaseThingHandler {
78 private static final Set<Character> URL_PART_DELIMITER = Set.of('/', '?', '&');
80 private final Logger logger = LoggerFactory.getLogger(HttpThingHandler.class);
81 private final ValueTransformationProvider valueTransformationProvider;
82 private final HttpClientProvider httpClientProvider;
83 private HttpClient httpClient;
84 private final HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider;
86 private HttpThingConfig config = new HttpThingConfig();
87 private final Map<String, RefreshingUrlCache> urlHandlers = new HashMap<>();
88 private final Map<ChannelUID, ItemValueConverter> channels = new HashMap<>();
89 private final Map<ChannelUID, String> channelUrls = new HashMap<>();
90 private @Nullable Authentication authentication;
92 public HttpThingHandler(Thing thing, HttpClientProvider httpClientProvider,
93 ValueTransformationProvider valueTransformationProvider,
94 HttpDynamicStateDescriptionProvider httpDynamicStateDescriptionProvider) {
96 this.httpClientProvider = httpClientProvider;
97 this.httpClient = httpClientProvider.getSecureClient();
98 this.valueTransformationProvider = valueTransformationProvider;
99 this.httpDynamicStateDescriptionProvider = httpDynamicStateDescriptionProvider;
103 public void handleCommand(ChannelUID channelUID, Command command) {
104 ItemValueConverter itemValueConverter = channels.get(channelUID);
105 if (itemValueConverter == null) {
106 logger.warn("Cannot find channel implementation for channel {}.", channelUID);
110 if (command instanceof RefreshType) {
111 String stateUrl = channelUrls.get(channelUID);
112 if (stateUrl != null) {
113 RefreshingUrlCache refreshingUrlCache = urlHandlers.get(stateUrl);
114 if (refreshingUrlCache != null) {
116 refreshingUrlCache.get().ifPresent(itemValueConverter::process);
117 } catch (IllegalArgumentException | IllegalStateException e) {
118 logger.warn("Failed processing REFRESH command for channel {}: {}", channelUID, e.getMessage());
124 itemValueConverter.send(command);
125 } catch (IllegalArgumentException e) {
126 logger.warn("Failed to convert command '{}' to channel '{}' for sending", command, channelUID);
127 } catch (IllegalStateException e) {
128 logger.debug("Writing to read-only channel {} not permitted", channelUID);
134 public void initialize() {
135 config = getConfigAs(HttpThingConfig.class);
137 if (config.baseURL.isEmpty()) {
138 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
139 "Parameter baseURL must not be empty!");
142 authentication = null;
143 if (!config.username.isEmpty()) {
145 URI uri = new URI(config.baseURL);
146 switch (config.authMode) {
148 authentication = new BasicAuthentication(uri, Authentication.ANY_REALM, config.username,
150 logger.debug("Basic Authentication configured for thing '{}'", thing.getUID());
153 authentication = new DigestAuthentication(uri, Authentication.ANY_REALM, config.username,
155 logger.debug("Digest Authentication configured for thing '{}'", thing.getUID());
158 logger.warn("Unknown authentication method '{}' for thing '{}'", config.authMode,
161 if (authentication != null) {
162 AuthenticationStore authStore = httpClient.getAuthenticationStore();
163 authStore.addAuthentication(authentication);
165 } catch (URISyntaxException e) {
166 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
167 "failed to create authentication: baseUrl is invalid");
170 logger.debug("No authentication configured for thing '{}'", thing.getUID());
173 if (config.ignoreSSLErrors) {
174 logger.info("Using the insecure client for thing '{}'.", thing.getUID());
175 httpClient = httpClientProvider.getInsecureClient();
177 logger.info("Using the secure client for thing '{}'.", thing.getUID());
178 httpClient = httpClientProvider.getSecureClient();
181 thing.getChannels().forEach(this::createChannel);
183 updateStatus(ThingStatus.ONLINE);
187 public void dispose() {
189 urlHandlers.values().forEach(RefreshingUrlCache::stop);
196 // remove state descriptions
197 httpDynamicStateDescriptionProvider.removeDescriptionsForThing(thing.getUID());
203 * create all necessary information to handle every channel
205 * @param channel a thing channel
207 private void createChannel(Channel channel) {
208 ChannelUID channelUID = channel.getUID();
209 HttpChannelConfig channelConfig = channel.getConfiguration().as(HttpChannelConfig.class);
211 String stateUrl = concatenateUrlParts(config.baseURL, channelConfig.stateExtension);
212 String commandUrl = channelConfig.commandExtension == null ? stateUrl
213 : concatenateUrlParts(config.baseURL, channelConfig.commandExtension);
215 String acceptedItemType = channel.getAcceptedItemType();
216 if (acceptedItemType == null) {
217 logger.warn("Cannot determine item-type for channel '{}'", channelUID);
221 ItemValueConverter itemValueConverter;
222 switch (acceptedItemType) {
224 itemValueConverter = createItemConverter(ColorItemConverter::new, commandUrl, channelUID,
228 itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig,
232 itemValueConverter = createItemConverter(DimmerItemConverter::new, commandUrl, channelUID,
237 itemValueConverter = createItemConverter(FixedValueMappingItemConverter::new, commandUrl, channelUID,
241 itemValueConverter = new ImageItemConverter(state -> updateState(channelUID, state));
244 itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig, PointType::new);
247 itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig,
251 itemValueConverter = createItemConverter(PlayerItemConverter::new, commandUrl, channelUID,
254 case "Rollershutter":
255 itemValueConverter = createItemConverter(RollershutterItemConverter::new, commandUrl, channelUID,
259 itemValueConverter = createGenericItemConverter(commandUrl, channelUID, channelConfig, StringType::new);
262 logger.warn("Unsupported item-type '{}'", channel.getAcceptedItemType());
266 channels.put(channelUID, itemValueConverter);
267 if (channelConfig.mode != HttpChannelMode.WRITEONLY) {
268 channelUrls.put(channelUID, stateUrl);
269 urlHandlers.computeIfAbsent(stateUrl, url -> new RefreshingUrlCache(scheduler, httpClient, url, config))
270 .addConsumer(itemValueConverter::process);
273 StateDescription stateDescription = StateDescriptionFragmentBuilder.create()
274 .withReadOnly(channelConfig.mode == HttpChannelMode.READONLY).build().toStateDescription();
275 if (stateDescription != null) {
276 // if the state description is not available, we don'tneed to add it
277 httpDynamicStateDescriptionProvider.setDescription(channelUID, stateDescription);
281 private void sendHttpValue(String commandUrl, String command) {
282 sendHttpValue(commandUrl, command, false);
285 private void sendHttpValue(String commandUrl, String command, boolean isRetry) {
288 URI finalUrl = new URI(String.format(commandUrl, new Date(), command));
291 Request request = httpClient.newRequest(finalUrl).timeout(config.timeout, TimeUnit.MILLISECONDS)
292 .method(config.commandMethod);
293 if (config.commandMethod != HttpMethod.GET) {
294 final String contentType = config.contentType;
295 if (contentType != null) {
296 request.content(new StringContentProvider(command), contentType);
298 request.content(new StringContentProvider(command));
302 config.headers.forEach(header -> {
303 String[] keyValuePair = header.split("=", 2);
304 if (keyValuePair.length == 2) {
305 request.header(keyValuePair[0], keyValuePair[1]);
307 logger.warn("Splitting header '{}' failed. No '=' was found. Ignoring", header);
311 if (logger.isTraceEnabled()) {
312 logger.trace("Sending to '{}': {}", finalUrl, Util.requestToLogString(request));
315 CompletableFuture<@Nullable Content> f = new CompletableFuture<>();
316 f.exceptionally(e -> {
317 if (e instanceof HttpAuthException) {
319 logger.warn("Retry after authentication failure failed again for '{}', failing here", finalUrl);
321 AuthenticationStore authStore = httpClient.getAuthenticationStore();
322 Authentication.Result authResult = authStore.findAuthenticationResult(finalUrl);
323 if (authResult != null) {
324 authStore.removeAuthenticationResult(authResult);
325 logger.debug("Cleared authentication result for '{}', retrying immediately", finalUrl);
326 sendHttpValue(commandUrl, command, true);
328 logger.warn("Could not find authentication result for '{}', failing here", finalUrl);
334 request.send(new HttpResponseListener(f));
335 } catch (IllegalArgumentException | URISyntaxException e) {
336 logger.warn("Creating request for '{}' failed: {}", commandUrl, e.getMessage());
340 private String concatenateUrlParts(String baseUrl, @Nullable String extension) {
341 if (extension != null && !extension.isEmpty()) {
342 if (!URL_PART_DELIMITER.contains(baseUrl.charAt(baseUrl.length() - 1))
343 && !URL_PART_DELIMITER.contains(extension.charAt(0))) {
344 return baseUrl + "/" + extension;
346 return baseUrl + extension;
353 private ItemValueConverter createItemConverter(AbstractTransformingItemConverter.Factory factory, String commandUrl,
354 ChannelUID channelUID, HttpChannelConfig channelConfig) {
355 return factory.create(state -> updateState(channelUID, state), command -> postCommand(channelUID, command),
356 command -> sendHttpValue(commandUrl, command),
357 valueTransformationProvider.getValueTransformation(channelConfig.stateTransformation),
358 valueTransformationProvider.getValueTransformation(channelConfig.commandTransformation), channelConfig);
361 private ItemValueConverter createGenericItemConverter(String commandUrl, ChannelUID channelUID,
362 HttpChannelConfig channelConfig, Function<String, State> toState) {
363 AbstractTransformingItemConverter.Factory factory = (state, command, value, stateTrans, commandTrans,
364 config) -> new GenericItemConverter(toState, state, command, value, stateTrans, commandTrans, config);
365 return createItemConverter(factory, commandUrl, channelUID, channelConfig);