2 * Copyright (c) 2010-2021 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.remoteopenhab.internal.handler;
15 import java.net.MalformedURLException;
17 import java.time.DateTimeException;
18 import java.time.ZonedDateTime;
19 import java.time.format.DateTimeFormatter;
20 import java.util.ArrayList;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.List;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
29 import javax.ws.rs.client.ClientBuilder;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.openhab.binding.remoteopenhab.internal.RemoteopenhabChannelTypeProvider;
35 import org.openhab.binding.remoteopenhab.internal.RemoteopenhabCommandDescriptionOptionProvider;
36 import org.openhab.binding.remoteopenhab.internal.RemoteopenhabStateDescriptionOptionProvider;
37 import org.openhab.binding.remoteopenhab.internal.config.RemoteopenhabServerConfiguration;
38 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabCommandDescription;
39 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabCommandOption;
40 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabItem;
41 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStateDescription;
42 import org.openhab.binding.remoteopenhab.internal.data.RemoteopenhabStateOption;
43 import org.openhab.binding.remoteopenhab.internal.discovery.RemoteopenhabDiscoveryService;
44 import org.openhab.binding.remoteopenhab.internal.exceptions.RemoteopenhabException;
45 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabItemsDataListener;
46 import org.openhab.binding.remoteopenhab.internal.listener.RemoteopenhabStreamingDataListener;
47 import org.openhab.binding.remoteopenhab.internal.rest.RemoteopenhabRestClient;
48 import org.openhab.core.library.CoreItemFactory;
49 import org.openhab.core.library.types.DateTimeType;
50 import org.openhab.core.library.types.DecimalType;
51 import org.openhab.core.library.types.HSBType;
52 import org.openhab.core.library.types.OnOffType;
53 import org.openhab.core.library.types.OpenClosedType;
54 import org.openhab.core.library.types.PercentType;
55 import org.openhab.core.library.types.PlayPauseType;
56 import org.openhab.core.library.types.PointType;
57 import org.openhab.core.library.types.QuantityType;
58 import org.openhab.core.library.types.RawType;
59 import org.openhab.core.library.types.StringType;
60 import org.openhab.core.thing.Bridge;
61 import org.openhab.core.thing.Channel;
62 import org.openhab.core.thing.ChannelUID;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.thing.binding.BaseBridgeHandler;
66 import org.openhab.core.thing.binding.ThingHandlerService;
67 import org.openhab.core.thing.binding.builder.ChannelBuilder;
68 import org.openhab.core.thing.binding.builder.ThingBuilder;
69 import org.openhab.core.thing.type.AutoUpdatePolicy;
70 import org.openhab.core.thing.type.ChannelKind;
71 import org.openhab.core.thing.type.ChannelType;
72 import org.openhab.core.thing.type.ChannelTypeBuilder;
73 import org.openhab.core.thing.type.ChannelTypeUID;
74 import org.openhab.core.types.Command;
75 import org.openhab.core.types.CommandOption;
76 import org.openhab.core.types.RefreshType;
77 import org.openhab.core.types.State;
78 import org.openhab.core.types.StateDescriptionFragmentBuilder;
79 import org.openhab.core.types.StateOption;
80 import org.openhab.core.types.TypeParser;
81 import org.openhab.core.types.UnDefType;
82 import org.osgi.service.jaxrs.client.SseEventSourceFactory;
83 import org.slf4j.Logger;
84 import org.slf4j.LoggerFactory;
86 import com.google.gson.Gson;
89 * The {@link RemoteopenhabBridgeHandler} is responsible for handling commands and updating states
90 * using the REST API of the remote openHAB server.
92 * @author Laurent Garnier - Initial contribution
95 public class RemoteopenhabBridgeHandler extends BaseBridgeHandler
96 implements RemoteopenhabStreamingDataListener, RemoteopenhabItemsDataListener {
98 private static final String DATE_FORMAT_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSZ";
99 private static final DateTimeFormatter FORMATTER_DATE = DateTimeFormatter.ofPattern(DATE_FORMAT_PATTERN);
101 private static final int MAX_STATE_SIZE_FOR_LOGGING = 50;
103 private final Logger logger = LoggerFactory.getLogger(RemoteopenhabBridgeHandler.class);
105 private final HttpClient httpClientTrustingCert;
106 private final RemoteopenhabChannelTypeProvider channelTypeProvider;
107 private final RemoteopenhabStateDescriptionOptionProvider stateDescriptionProvider;
108 private final RemoteopenhabCommandDescriptionOptionProvider commandDescriptionProvider;
110 private final Object updateThingLock = new Object();
112 private @NonNullByDefault({}) RemoteopenhabServerConfiguration config;
114 private @Nullable ScheduledFuture<?> checkConnectionJob;
115 private RemoteopenhabRestClient restClient;
117 private Map<ChannelUID, State> channelsLastStates = new HashMap<>();
119 public RemoteopenhabBridgeHandler(Bridge bridge, HttpClient httpClient, HttpClient httpClientTrustingCert,
120 ClientBuilder clientBuilder, SseEventSourceFactory eventSourceFactory,
121 RemoteopenhabChannelTypeProvider channelTypeProvider,
122 RemoteopenhabStateDescriptionOptionProvider stateDescriptionProvider,
123 RemoteopenhabCommandDescriptionOptionProvider commandDescriptionProvider, final Gson jsonParser) {
125 this.httpClientTrustingCert = httpClientTrustingCert;
126 this.channelTypeProvider = channelTypeProvider;
127 this.stateDescriptionProvider = stateDescriptionProvider;
128 this.commandDescriptionProvider = commandDescriptionProvider;
129 this.restClient = new RemoteopenhabRestClient(httpClient, clientBuilder, eventSourceFactory, jsonParser);
133 public void initialize() {
134 logger.debug("Initializing remote openHAB handler for bridge {}", getThing().getUID());
136 config = getConfigAs(RemoteopenhabServerConfiguration.class);
138 String host = config.host.trim();
139 if (host.length() == 0) {
140 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
141 "Undefined server address setting in the thing configuration");
144 String path = config.restPath.trim();
145 if (path.length() == 0 || !path.startsWith("/")) {
146 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
147 "Invalid REST API path setting in the thing configuration");
152 url = new URL(config.useHttps ? "https" : "http", host, config.port, path);
153 } catch (MalformedURLException e) {
154 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
155 "Invalid REST URL built from the settings in the thing configuration");
159 String urlStr = url.toString();
160 logger.debug("REST URL = {}", urlStr);
162 restClient.setRestUrl(urlStr);
163 restClient.setAuthenticationData(config.authenticateAnyway, config.token, config.username, config.password);
164 if (config.useHttps && config.trustedCertificate) {
165 restClient.setHttpClient(httpClientTrustingCert);
166 restClient.setTrustedCertificate(true);
169 updateStatus(ThingStatus.UNKNOWN);
171 scheduler.submit(() -> checkConnection(false));
172 if (config.accessibilityInterval > 0) {
173 startCheckConnectionJob(config.accessibilityInterval, config.aliveInterval, config.restartIfNoActivity);
178 public void dispose() {
179 logger.debug("Disposing remote openHAB handler for bridge {}", getThing().getUID());
180 stopStreamingUpdates(false);
181 stopCheckConnectionJob();
182 channelsLastStates.clear();
186 public void handleCommand(ChannelUID channelUID, Command command) {
187 if (getThing().getStatus() != ThingStatus.ONLINE) {
192 if (command instanceof RefreshType) {
193 String state = restClient.getRemoteItemState(channelUID.getId());
194 updateChannelState(channelUID.getId(), null, state, false);
195 } else if (isLinked(channelUID)) {
196 restClient.sendCommandToRemoteItem(channelUID.getId(), command);
197 String commandStr = command.toFullString();
198 logger.debug("Sending command {} to remote item {} succeeded",
199 commandStr.length() < MAX_STATE_SIZE_FOR_LOGGING ? commandStr
200 : commandStr.substring(0, MAX_STATE_SIZE_FOR_LOGGING) + "...",
203 } catch (RemoteopenhabException e) {
204 logger.debug("{}", e.getMessage());
208 private boolean createChannels(List<RemoteopenhabItem> items, boolean replace) {
209 synchronized (updateThingLock) {
212 int nbChannelTypesCreated = 0;
213 List<Channel> channels = new ArrayList<>();
214 for (RemoteopenhabItem item : items) {
215 String itemType = item.type;
216 boolean readOnly = false;
217 if ("Group".equals(itemType)) {
218 if (item.groupType.isEmpty()) {
219 // Standard groups are ignored
223 itemType = item.groupType;
226 if (item.stateDescription != null && item.stateDescription.readOnly) {
230 // Ignore pattern containing a transformation (detected by a parenthesis in the pattern)
231 RemoteopenhabStateDescription stateDescription = item.stateDescription;
232 String pattern = (stateDescription == null || stateDescription.pattern.contains("(")) ? ""
233 : stateDescription.pattern;
234 ChannelTypeUID channelTypeUID;
235 ChannelType channelType = channelTypeProvider.getChannelType(itemType, readOnly, pattern);
238 if (channelType == null) {
239 channelTypeUID = channelTypeProvider.buildNewChannelTypeUID(itemType);
240 logger.trace("Create the channel type {} for item type {} ({} and with pattern {})",
241 channelTypeUID, itemType, readOnly ? "read only" : "read write", pattern);
242 label = String.format("Remote %s Item", itemType);
243 description = String.format("An item of type %s from the remote server.", itemType);
244 StateDescriptionFragmentBuilder stateDescriptionBuilder = StateDescriptionFragmentBuilder
245 .create().withReadOnly(readOnly);
246 if (!pattern.isEmpty()) {
247 stateDescriptionBuilder = stateDescriptionBuilder.withPattern(pattern);
249 channelType = ChannelTypeBuilder.state(channelTypeUID, label, itemType)
250 .withDescription(description)
251 .withStateDescriptionFragment(stateDescriptionBuilder.build())
252 .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build();
253 channelTypeProvider.addChannelType(itemType, channelType);
254 nbChannelTypesCreated++;
256 channelTypeUID = channelType.getUID();
258 ChannelUID channelUID = new ChannelUID(getThing().getUID(), item.name);
259 logger.trace("Create the channel {} of type {}", channelUID, channelTypeUID);
260 label = "Item " + item.name;
261 description = String.format("Item %s from the remote server.", item.name);
262 channels.add(ChannelBuilder.create(channelUID, itemType).withType(channelTypeUID)
263 .withKind(ChannelKind.STATE).withLabel(label).withDescription(description).build());
265 ThingBuilder thingBuilder = editThing();
267 thingBuilder.withChannels(channels);
268 updateThing(thingBuilder.build());
270 "{} channels defined (with {} different channel types) for the thing {} (from {} items including {} groups)",
271 channels.size(), nbChannelTypesCreated, getThing().getUID(), items.size(), nbGroups);
272 } else if (!channels.isEmpty()) {
274 for (Channel channel : channels) {
275 if (getThing().getChannel(channel.getUID()) != null) {
276 thingBuilder.withoutChannel(channel.getUID());
281 logger.debug("{} channels removed for the thing {} (from {} items)", nbRemoved,
282 getThing().getUID(), items.size());
284 for (Channel channel : channels) {
285 thingBuilder.withChannel(channel);
287 updateThing(thingBuilder.build());
289 logger.debug("{} channels added for the thing {} (from {} items including {} groups)",
290 channels.size(), getThing().getUID(), items.size(), nbGroups);
292 logger.debug("{} channels added for the thing {} (from {} items)", channels.size(),
293 getThing().getUID(), items.size());
297 } catch (IllegalArgumentException e) {
298 logger.warn("An error occurred while creating the channels for the server {}: {}", getThing().getUID(),
305 private void removeChannels(List<RemoteopenhabItem> items) {
306 synchronized (updateThingLock) {
308 ThingBuilder thingBuilder = editThing();
309 for (RemoteopenhabItem item : items) {
310 Channel channel = getThing().getChannel(item.name);
311 if (channel != null) {
312 thingBuilder.withoutChannel(channel.getUID());
317 updateThing(thingBuilder.build());
318 logger.debug("{} channels removed for the thing {} (from {} items)", nbRemoved, getThing().getUID(),
324 private void setDynamicOptions(List<RemoteopenhabItem> items) {
325 for (RemoteopenhabItem item : items) {
326 Channel channel = getThing().getChannel(item.name);
327 if (channel == null) {
330 RemoteopenhabStateDescription stateDescr = item.stateDescription;
331 List<RemoteopenhabStateOption> stateOptions = stateDescr == null ? null : stateDescr.options;
332 if (stateOptions != null && !stateOptions.isEmpty()) {
333 List<StateOption> options = new ArrayList<>();
334 for (RemoteopenhabStateOption option : stateOptions) {
335 options.add(new StateOption(option.value, option.label));
337 stateDescriptionProvider.setStateOptions(channel.getUID(), options);
338 logger.trace("{} state options set for the channel {}", options.size(), channel.getUID());
340 RemoteopenhabCommandDescription commandDescr = item.commandDescription;
341 List<RemoteopenhabCommandOption> commandOptions = commandDescr == null ? null : commandDescr.commandOptions;
342 if (commandOptions != null && !commandOptions.isEmpty()) {
343 List<CommandOption> options = new ArrayList<>();
344 for (RemoteopenhabCommandOption option : commandOptions) {
345 options.add(new CommandOption(option.command, option.label));
347 commandDescriptionProvider.setCommandOptions(channel.getUID(), options);
348 logger.trace("{} command options set for the channel {}", options.size(), channel.getUID());
353 public void checkConnection(boolean restartSse) {
354 logger.debug("Try the root REST API...");
357 if (restClient.getRestApiVersion() == null) {
358 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
359 "OH 1.x server not supported by the binding");
360 } else if (getThing().getStatus() != ThingStatus.ONLINE) {
361 List<RemoteopenhabItem> items = restClient.getRemoteItems("name,type,groupType,state,stateDescription");
363 if (createChannels(items, true)) {
364 setDynamicOptions(items);
365 for (RemoteopenhabItem item : items) {
366 updateChannelState(item.name, null, item.state, false);
369 updateStatus(ThingStatus.ONLINE);
371 restartStreamingUpdates();
373 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
374 "Dynamic creation of the channels for the remote server items failed");
375 stopStreamingUpdates();
377 } else if (restartSse) {
378 logger.debug("The SSE connection is restarted because there was no recent event received");
379 restartStreamingUpdates();
381 } catch (RemoteopenhabException e) {
382 logger.debug("{}", e.getMessage());
383 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
384 stopStreamingUpdates();
388 private void startCheckConnectionJob(int accessibilityInterval, int aliveInterval, boolean restartIfNoActivity) {
389 ScheduledFuture<?> localCheckConnectionJob = checkConnectionJob;
390 if (localCheckConnectionJob == null || localCheckConnectionJob.isCancelled()) {
391 checkConnectionJob = scheduler.scheduleWithFixedDelay(() -> {
392 long millisSinceLastEvent = System.currentTimeMillis() - restClient.getLastEventTimestamp();
393 if (getThing().getStatus() != ThingStatus.ONLINE || aliveInterval == 0
394 || restClient.getLastEventTimestamp() == 0) {
395 logger.debug("Time to check server accessibility");
396 checkConnection(restartIfNoActivity && aliveInterval != 0);
397 } else if (millisSinceLastEvent > (aliveInterval * 60000)) {
399 "Time to check server accessibility (maybe disconnected from streaming events, millisSinceLastEvent={})",
400 millisSinceLastEvent);
401 checkConnection(restartIfNoActivity);
404 "Bypass server accessibility check (receiving streaming events, millisSinceLastEvent={})",
405 millisSinceLastEvent);
407 }, accessibilityInterval, accessibilityInterval, TimeUnit.MINUTES);
411 private void stopCheckConnectionJob() {
412 ScheduledFuture<?> localCheckConnectionJob = checkConnectionJob;
413 if (localCheckConnectionJob != null) {
414 localCheckConnectionJob.cancel(true);
415 checkConnectionJob = null;
419 private void restartStreamingUpdates() {
420 synchronized (restClient) {
421 stopStreamingUpdates();
422 startStreamingUpdates();
426 private void startStreamingUpdates() {
427 synchronized (restClient) {
428 restClient.addStreamingDataListener(this);
429 restClient.addItemsDataListener(this);
434 private void stopStreamingUpdates() {
435 stopStreamingUpdates(true);
438 private void stopStreamingUpdates(boolean waitingForCompletion) {
439 synchronized (restClient) {
440 restClient.stop(waitingForCompletion);
441 restClient.removeStreamingDataListener(this);
442 restClient.removeItemsDataListener(this);
446 public RemoteopenhabRestClient gestRestClient() {
451 public void onConnected() {
452 updateStatus(ThingStatus.ONLINE);
456 public void onDisconnected() {
457 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Disconected from the remote server");
461 public void onError(String message) {
462 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
466 public void onItemStateEvent(String itemName, String stateType, String state, boolean onlyIfStateChanged) {
467 updateChannelState(itemName, stateType, state, onlyIfStateChanged);
471 public void onItemAdded(RemoteopenhabItem item) {
472 createChannels(List.of(item), false);
476 public void onItemRemoved(RemoteopenhabItem item) {
477 removeChannels(List.of(item));
481 public void onItemUpdated(RemoteopenhabItem newItem, RemoteopenhabItem oldItem) {
482 if (!newItem.type.equals(oldItem.type)) {
483 createChannels(List.of(newItem), false);
485 logger.trace("Updated remote item {} ignored because item type {} is unchanged", newItem.name,
491 public void onItemOptionsUpdatedd(RemoteopenhabItem item) {
492 setDynamicOptions(List.of(item));
495 private void updateChannelState(String itemName, @Nullable String stateType, String state,
496 boolean onlyIfStateChanged) {
497 Channel channel = getThing().getChannel(itemName);
498 if (channel == null) {
499 logger.trace("No channel for item {}", itemName);
502 String acceptedItemType = channel.getAcceptedItemType();
503 if (acceptedItemType == null) {
504 logger.trace("Channel without accepted item type for item {}", itemName);
507 if (!isLinked(channel.getUID())) {
508 logger.trace("Unlinked channel {}", channel.getUID());
511 State channelState = null;
513 if (stateType == null && "NULL".equals(state)) {
514 channelState = UnDefType.NULL;
515 } else if (stateType == null && "UNDEF".equals(state)) {
516 channelState = UnDefType.UNDEF;
517 } else if ("UnDef".equals(stateType)) {
520 channelState = UnDefType.NULL;
523 channelState = UnDefType.UNDEF;
526 logger.debug("Invalid UnDef value {} for item {}", state, itemName);
529 } else if (acceptedItemType.startsWith(CoreItemFactory.NUMBER + ":")) {
530 // Item type Number with dimension
531 if (stateType == null || "Quantity".equals(stateType)) {
532 List<Class<? extends State>> stateTypes = Collections.singletonList(QuantityType.class);
533 channelState = TypeParser.parseState(stateTypes, state);
534 } else if ("Decimal".equals(stateType)) {
535 channelState = new DecimalType(state);
537 logger.debug("Unexpected value type {} for item {}", stateType, itemName);
540 switch (acceptedItemType) {
541 case CoreItemFactory.STRING:
542 if (checkStateType(itemName, stateType, "String")) {
543 channelState = new StringType(state);
546 case CoreItemFactory.NUMBER:
547 if (checkStateType(itemName, stateType, "Decimal")) {
548 channelState = new DecimalType(state);
551 case CoreItemFactory.SWITCH:
552 if (checkStateType(itemName, stateType, "OnOff")) {
553 channelState = "ON".equals(state) ? OnOffType.ON : OnOffType.OFF;
556 case CoreItemFactory.CONTACT:
557 if (checkStateType(itemName, stateType, "OpenClosed")) {
558 channelState = "OPEN".equals(state) ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
561 case CoreItemFactory.DIMMER:
562 if (checkStateType(itemName, stateType, "Percent")) {
563 channelState = new PercentType(state);
566 case CoreItemFactory.COLOR:
567 if (checkStateType(itemName, stateType, "HSB")) {
568 channelState = HSBType.valueOf(state);
571 case CoreItemFactory.DATETIME:
572 if (checkStateType(itemName, stateType, "DateTime")) {
573 channelState = new DateTimeType(ZonedDateTime.parse(state, FORMATTER_DATE));
576 case CoreItemFactory.LOCATION:
577 if (checkStateType(itemName, stateType, "Point")) {
578 channelState = new PointType(state);
581 case CoreItemFactory.IMAGE:
582 if (checkStateType(itemName, stateType, "Raw")) {
583 channelState = RawType.valueOf(state);
586 case CoreItemFactory.PLAYER:
587 if (checkStateType(itemName, stateType, "PlayPause")) {
590 channelState = PlayPauseType.PLAY;
593 channelState = PlayPauseType.PAUSE;
596 logger.debug("Unexpected value {} for item {}", state, itemName);
601 case CoreItemFactory.ROLLERSHUTTER:
602 if (checkStateType(itemName, stateType, "Percent")) {
603 channelState = new PercentType(state);
607 logger.debug("Item type {} is not yet supported", acceptedItemType);
611 } catch (IllegalArgumentException | DateTimeException e) {
612 logger.warn("Failed to parse state \"{}\" for item {}: {}", state, itemName, e.getMessage());
613 channelState = UnDefType.UNDEF;
615 if (channelState != null) {
616 if (onlyIfStateChanged && channelState.equals(channelsLastStates.get(channel.getUID()))) {
617 logger.trace("ItemStateChangedEvent ignored for item {} as state is identical to the last state",
621 channelsLastStates.put(channel.getUID(), channelState);
622 updateState(channel.getUID(), channelState);
623 String channelStateStr = channelState.toFullString();
624 logger.debug("updateState {} with {}", channel.getUID(),
625 channelStateStr.length() < MAX_STATE_SIZE_FOR_LOGGING ? channelStateStr
626 : channelStateStr.substring(0, MAX_STATE_SIZE_FOR_LOGGING) + "...");
630 private boolean checkStateType(String itemName, @Nullable String stateType, String expectedType) {
631 if (stateType != null && !expectedType.equals(stateType)) {
632 logger.debug("Unexpected value type {} for item {}", stateType, itemName);
640 public Collection<Class<? extends ThingHandlerService>> getServices() {
641 return Collections.singleton(RemoteopenhabDiscoveryService.class);