/bundles/org.openhab.binding.warmup/ @jamesmelville
/bundles/org.openhab.binding.weathercompany/ @mhilbush
/bundles/org.openhab.binding.weatherunderground/ @lolodomo
+/bundles/org.openhab.binding.webexteams/ @tdeckers
/bundles/org.openhab.binding.webthing/ @grro
/bundles/org.openhab.binding.wemo/ @hmerk @jlaur
/bundles/org.openhab.binding.wifiled/ @rvt @xylo
<artifactId>org.openhab.binding.weatherunderground</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.binding.webexteams</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.webthing</artifactId>
--- /dev/null
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
--- /dev/null
+# WebexTeams Binding
+
+The Webex Team binding allows to send messages to [Webex Teams](https://web.webex.com/) users and rooms through a number of actions.
+
+Messages can use markdown syntax, and attachments are supported.
+
+## Supported Things
+
+- `account`: A Webex Teams account
+
+## Discovery
+
+No Things are being discovered by this binding.
+
+
+## Thing Configuration
+
+Webex Teams supports two main types of app integration:
+
+* Bot: a separate identity that can be used to communicate with people and rooms.
+* Person integration: OAuth integration that allows the binding to act on behalf of a persons.
+
+Both of these accounts must be first configured on the [Webex Developers](https://developer.webex.com/my-apps) website.
+When creating a person integration, it's important you customize the redirect URL based on your OpenHab installation.
+For example if you run your openHAB server on `http://openhab:8080` you should add [http://openhab:8080/connectwebex](http://openhab:8080/connectwebex) to the redirect URIs.
+
+To use a bot account, only configure the `token` (Authentication token).
+
+To use a person integration, configure `clientId` and `clientSecret`.
+When the account is configured as a Thing in OpenHab, navigate to the redirect URL (as described above) and authorize your account.
+
+You shouldn't configure both an authentication token (used for bots) AND clientId/clientSecret (used for person integrations). In that case the binding will use the authentication token.
+
+A default room id is required for use with the `sendMessage` action.
+
+### `account` Thing Configuration
+
+| Name | Type | Description | Default | Required | Advanced |
+|-----------------|---------|---------------------------------------|---------|----------|----------|
+| token | text | (Bot) authentication token | N/A | no | no |
+| clientId | text | (Person) client id | N/A | no | no |
+| clientSecret | text | (Person) client secret | N/A | no | no |
+| refreshPeriod | integer | Refresh period for channels (seconds) | 300 | no | no |
+| roomId | text | ID of the default room | N/A | no | no |
+
+## Channels
+
+| Thing | channel | type | description |
+|--------------------|--------------|-----------|--------------------------------------------------------------|
+| WebexTeams Account | status | String | Account presence status: active, call, inactive, ... |
+| WebexTeams Account | lastactivity | DateTime | The date and time of the person's last activity within Webex |
+
+Note: status and lastactivity are only updated for person integrations
+
+## Full Example
+
+
+webexteams.things:
+
+Configure a bot account:
+
+```
+Thing webexteams:account:bot [ token="XXXXXX", roomId="YYYYYY" ]
+```
+
+Configure a person integration account:
+
+```
+Thing webexteams:account:person [ clientId="XXXXXX", clientSecret="YYYYYY", roomId="ZZZZZZ" ]
+```
+
+## Rule Action
+
+DSL rules use `getActions` to get a reference to the thing.
+
+`val botActions = getActions("webexteams", "webexteams:account:bot")`
+
+This binding includes these rule actions for sending messages:
+
+* `var success = botActions.sendMessage(String markdown)` - Send a message to the default room.
+* `var success = botActions.sendMessage(String markdown, String attach)` - Send a message to the default room, with attachment.
+* `var success = botActions.sendRoomMessage(String roomId, String markdown)` - Send a message to a specific room.
+* `var success = botActions.sendRoomMessage(String roomId, String markdown, String attach)` - Send a message to a specific room, with attachment.
+* `var success = botActions.sendPersonMessage(String personEmail, String markdown)` - Send a direct message to a person.
+* `var success = botActions.sendPersonMessage(String personEmail, String markdown, String attach)` - Send a direct message to a person, with attachment.
+
+Sending messages for bot or person accounts works exactly the same.
+Attachments must be URLs.
+Sending local files is not supported at this moment.
+
+
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+ <version>3.4.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>org.openhab.binding.webexteams</artifactId>
+
+ <name>openHAB Add-ons :: Bundles :: WebexTeams Binding</name>
+
+</project>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.webexteams-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+ <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+ <feature name="openhab-binding-webexteams" description="WebexTeams Binding" version="${project.version}">
+ <feature>openhab-runtime-base</feature>
+ <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.webexteams/${project.version}</bundle>
+ </feature>
+</features>
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import static org.openhab.binding.webexteams.internal.WebexTeamsBindingConstants.*;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.ComponentContext;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.http.HttpService;
+import org.osgi.service.http.NamespaceException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WebexAuthService} class to manage the servlets and bind authorization servlet to bridges.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@Component(service = WebexAuthService.class, configurationPid = "binding.webexteams.authService")
+@NonNullByDefault
+public class WebexAuthService {
+
+ private static final String TEMPLATE_PATH = "templates/";
+ private static final String TEMPLATE_ACCOUNT = TEMPLATE_PATH + "account.html";
+ private static final String TEMPLATE_INDEX = TEMPLATE_PATH + "index.html";
+
+ private final Logger logger = LoggerFactory.getLogger(WebexAuthService.class);
+
+ private final List<WebexTeamsHandler> handlers = Collections.synchronizedList(new ArrayList<>());
+
+ private static final String ERROR_UKNOWN_BRIDGE = "Returned 'state' by oauth redirect doesn't match any accounts. Has the account been removed?";
+
+ private @NonNullByDefault({}) HttpService httpService;
+ private @NonNullByDefault({}) BundleContext bundleContext;
+
+ @Activate
+ protected void activate(ComponentContext componentContext, Map<String, Object> properties) {
+ logger.debug("Activating WebexAuthService");
+ try {
+ bundleContext = componentContext.getBundleContext();
+ httpService.registerServlet(WEBEX_ALIAS, createServlet(), new Hashtable<>(),
+ httpService.createDefaultHttpContext());
+ httpService.registerResources(WEBEX_ALIAS + WEBEX_RES_ALIAS, "web", null);
+ } catch (NamespaceException | ServletException | IOException e) {
+ logger.warn("Error during webex auth servlet startup", e);
+ }
+ }
+
+ @Deactivate
+ protected void deactivate(ComponentContext componentContext) {
+ logger.debug("Deactivating WebexAuthService");
+ httpService.unregister(WEBEX_ALIAS);
+ httpService.unregister(WEBEX_ALIAS + WEBEX_RES_ALIAS);
+ }
+
+ /**
+ * Creates a new {@link WebexAuthServlet}.
+ *
+ * @return the newly created servlet
+ * @throws IOException thrown when an HTML template could not be read
+ */
+ private HttpServlet createServlet() throws IOException {
+ return new WebexAuthServlet(this, readTemplate(TEMPLATE_INDEX), readTemplate(TEMPLATE_ACCOUNT));
+ }
+
+ /**
+ * Reads a template from file and returns the content as String.
+ *
+ * @param templateName name of the template file to read
+ * @return The content of the template file
+ * @throws IOException thrown when an HTML template could not be read
+ */
+ private String readTemplate(String templateName) throws IOException {
+ final URL index = bundleContext.getBundle().getEntry(templateName);
+
+ if (index == null) {
+ throw new FileNotFoundException(
+ String.format("Cannot find '{}' - failed to initialize Webex servlet", templateName));
+ } else {
+ try (InputStream inputStream = index.openStream()) {
+ return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+ /**
+ * Call with Webex redirect uri returned State and Code values to get the refresh and access tokens and persist
+ * these values
+ *
+ * @param servletBaseURL the servlet base, which will be the Webex redirect url
+ * @param state The Webex returned state value
+ * @param code The Webex returned code value
+ * @return returns the name of the Webex user that is authorized
+ * @throws WebexTeamsException if no handler was found for the state
+ */
+ public String authorize(String servletBaseURL, String state, String code) throws WebexTeamsException {
+ logger.debug("Authorizing for state: {}, code: {}", state, code);
+
+ final WebexTeamsHandler listener = getWebexTeamsHandler(state);
+
+ if (listener == null) {
+ logger.debug(
+ "Webex redirected with state '{}' but no matching account was found. Possible account has been removed.",
+ state);
+ throw new WebexTeamsException(ERROR_UKNOWN_BRIDGE);
+ } else {
+ return listener.authorize(servletBaseURL, code);
+ }
+ }
+
+ /**
+ * @param listener Adds the given handler
+ */
+ public void addWebexTeamsHandler(WebexTeamsHandler listener) {
+ if (!handlers.contains(listener)) {
+ handlers.add(listener);
+ }
+ }
+
+ /**
+ * @param handler Removes the given handler
+ */
+ public void removeWebexTeamsHandler(WebexTeamsHandler handler) {
+ handlers.remove(handler);
+ }
+
+ /**
+ * @return Returns all {@link WebexTeamsHandler}s.
+ */
+ public List<WebexTeamsHandler> getWebexTeamsHandlers() {
+ return handlers;
+ }
+
+ /**
+ * Get the {@link WebexTeamsHandler} that matches the given thing UID.
+ *
+ * @param thingUID UID of the thing to match the handler with
+ * @return the {@link WebexTeamsHandler} matching the thing UID or null
+ */
+ private @Nullable WebexTeamsHandler getWebexTeamsHandler(String thingUID) {
+ final Optional<WebexTeamsHandler> maybeListener = handlers.stream().filter(l -> l.equalsThingUID(thingUID))
+ .findFirst();
+ return maybeListener.isPresent() ? maybeListener.get() : null;
+ }
+
+ @Reference
+ protected void setHttpService(HttpService httpService) {
+ this.httpService = httpService;
+ }
+
+ protected void unsetHttpService(HttpService httpService) {
+ this.httpService = null;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.util.MultiMap;
+import org.eclipse.jetty.util.UrlEncoded;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WebexAuthServlet} manages the authorization with the Webex API. The servlet implements the
+ * Authorization Code flow and saves the resulting refreshToken with the bridge.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class WebexAuthServlet extends HttpServlet {
+ static final long serialVersionUID = 42L;
+ private static final String CONTENT_TYPE = "text/html;charset=UTF-8";
+
+ // Simple HTML templates for inserting messages.
+ private static final String HTML_EMPTY_ACCOUNTS = "<p class='block'>Manually add a Webex Account to authorize it here.<p>";
+ private static final String HTML_USER_AUTHORIZED = "<div class='row authorized'>Account authorized for user %s.</div>";
+ private static final String HTML_ERROR = "<p class='block error'>Call to Webex failed with error: %s</p>";
+
+ private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}");
+
+ // Keys present in the index.html
+ private static final String KEY_PAGE_REFRESH = "pageRefresh";
+ private static final String HTML_META_REFRESH_CONTENT = "<meta http-equiv='refresh' content='10; url=%s'>";
+ private static final String KEY_AUTHORIZED_USER = "authorizedUser";
+ private static final String KEY_ERROR = "error";
+ private static final String KEY_ACCOUNTS = "accounts";
+ private static final String KEY_REDIRECT_URI = "redirectUri";
+
+ // Keys present in the account.html
+ private static final String ACCOUNT_ID = "account.id";
+ private static final String ACCOUNT_NAME = "account.name";
+ private static final String ACCOUNT_USER_ID = "account.user";
+ private static final String ACCOUNT_TYPE = "account.type";
+ private static final String ACCOUNT_AUTHORIZE = "account.authorize";
+ private static final String ACCOUNT_SHOWBTN = "account.showbtn";
+ private static final String ACCOUNT_SHWOMSG = "account.showmsg";
+ private static final String ACCOUNT_MSG = "account.msg";
+
+ private final Logger logger = LoggerFactory.getLogger(WebexAuthServlet.class);
+ private final WebexAuthService authService;
+ private final String indexTemplate;
+ private final String accountTemplate;
+
+ public WebexAuthServlet(WebexAuthService authService, String indexTemplate, String accountTemplate) {
+ this.authService = authService;
+ this.indexTemplate = indexTemplate;
+ this.accountTemplate = accountTemplate;
+ }
+
+ @Override
+ protected void doGet(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
+ throws ServletException, IOException {
+ if (req != null && resp != null) {
+ logger.debug("Webex auth callback servlet received GET request {}.", req.getRequestURI());
+ final String servletBaseURL = req.getRequestURL().toString();
+ final Map<String, String> replaceMap = new HashMap<>();
+
+ handleRedirect(replaceMap, servletBaseURL, req.getQueryString());
+ resp.setContentType(CONTENT_TYPE);
+ replaceMap.put(KEY_REDIRECT_URI, servletBaseURL);
+ replaceMap.put(KEY_ACCOUNTS, formatAccounts(this.accountTemplate, servletBaseURL));
+ resp.getWriter().append(replaceKeysFromMap(this.indexTemplate, replaceMap));
+ resp.getWriter().close();
+ }
+ }
+
+ /**
+ * Handles a possible call from Webex to the redirect_uri. If that is the case Webex will pass the authorization
+ * codes via the url and these are processed. In case of an error this is shown to the user. If the user was
+ * authorized this is passed on to the handler. Based on all these different outcomes the HTML is generated to
+ * inform the user.
+ *
+ * @param replaceMap a map with key String values that will be mapped in the HTML templates.
+ * @param servletBaseURL the servlet base, which should be used as the Webex redirect_uri value
+ * @param queryString the query part of the GET request this servlet is processing
+ */
+ private void handleRedirect(Map<String, String> replaceMap, String servletBaseURL, @Nullable String queryString) {
+ replaceMap.put(KEY_AUTHORIZED_USER, "");
+ replaceMap.put(KEY_ERROR, "");
+ replaceMap.put(KEY_PAGE_REFRESH, "");
+
+ if (queryString != null) {
+ final MultiMap<String> params = new MultiMap<>();
+ UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name());
+ final String reqCode = params.getString("code");
+ final String reqState = params.getString("state");
+ final String reqError = params.getString("error");
+
+ replaceMap.put(KEY_PAGE_REFRESH,
+ params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL));
+ if (!reqError.isBlank()) {
+ logger.debug("Webex redirected with an error: {}", reqError);
+ replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError));
+ } else if (!reqState.isBlank()) {
+ try {
+ replaceMap.put(KEY_AUTHORIZED_USER, String.format(HTML_USER_AUTHORIZED,
+ authService.authorize(servletBaseURL, reqState, reqCode)));
+ } catch (WebexTeamsException e) {
+ logger.debug("Exception during authorizaton: ", e);
+ replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage()));
+ }
+ }
+ }
+ }
+
+ /**
+ * Formats the HTML of all available Webex Accounts and returns it as a String
+ *
+ * @param accountTemplate The account template to format the account values in
+ * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button.
+ * @return A String with the accounts formatted with the account template
+ */
+ private String formatAccounts(String accountTemplate, String servletBaseURL) {
+ final List<WebexTeamsHandler> accounts = authService.getWebexTeamsHandlers();
+
+ return accounts.isEmpty() ? HTML_EMPTY_ACCOUNTS
+ : accounts.stream().map(p -> formatAccount(accountTemplate, p, servletBaseURL))
+ .collect(Collectors.joining());
+ }
+
+ /**
+ * Formats the HTML of a Webex Account and returns it as a String
+ *
+ * @param accountTemplate The account template to format the account values in
+ * @param handler The handler for the account to format
+ * @param servletBaseURL the redirect_uri to be used in the authorization url created on the authorization button.
+ * @return A String with the account formatted with the account template
+ */
+ private String formatAccount(String accountTemplate, WebexTeamsHandler handler, String servletBaseURL) {
+ final Map<String, String> map = new HashMap<>();
+
+ map.put(ACCOUNT_ID, handler.getUID().getAsString());
+ map.put(ACCOUNT_NAME, handler.getLabel());
+ final String webexUser = handler.getUser();
+
+ if (!handler.isConfigured()) {
+ map.put(ACCOUNT_USER_ID, "");
+ map.put(ACCOUNT_SHOWBTN, "u-hide");
+ map.put(ACCOUNT_SHWOMSG, "u-show");
+ map.put(ACCOUNT_MSG, "Configure account.");
+ } else if (handler.isAuthorized()) {
+ map.put(ACCOUNT_USER_ID, String.format(" (Authorized user: %s)", webexUser));
+ map.put(ACCOUNT_SHOWBTN, "u-hide");
+ map.put(ACCOUNT_SHWOMSG, "u-show");
+ map.put(ACCOUNT_MSG, "Authorized.");
+ } else if (!webexUser.isBlank()) {
+ map.put(ACCOUNT_USER_ID, String.format(" (Unauthorized user: %s)", webexUser));
+ map.put(ACCOUNT_SHOWBTN, "u-show");
+ map.put(ACCOUNT_SHWOMSG, "u-hide");
+ map.put(ACCOUNT_MSG, "");
+ } else {
+ map.put(ACCOUNT_USER_ID, "");
+ map.put(ACCOUNT_SHOWBTN, "u-hide");
+ map.put(ACCOUNT_SHWOMSG, "u-show");
+ map.put(ACCOUNT_MSG, "UNKNOWN");
+ }
+
+ map.put(ACCOUNT_TYPE, handler.accountType);
+ map.put(ACCOUNT_AUTHORIZE, handler.formatAuthorizationUrl(servletBaseURL));
+ return replaceKeysFromMap(accountTemplate, map);
+ }
+
+ /**
+ * Replaces all keys from the map found in the template with values from the map. If the key is not found the key
+ * will be kept in the template.
+ *
+ * @param template template to replace keys with values
+ * @param map map with key value pairs to replace in the template
+ * @return a template with keys replaced
+ */
+ private String replaceKeysFromMap(String template, Map<String, String> map) {
+ final Matcher m = MESSAGE_KEY_PATTERN.matcher(template);
+ final StringBuffer sb = new StringBuffer();
+
+ while (m.find()) {
+ try {
+ final String key = m.group(1);
+ m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}')));
+ } catch (RuntimeException e) {
+ logger.debug("Error occurred during template filling, cause ", e);
+ }
+ }
+ m.appendTail(sb);
+ return sb.toString();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Signals an issue with API authentication.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class WebexAuthenticationException extends WebexTeamsException {
+ static final long serialVersionUID = 44L;
+
+ public WebexAuthenticationException() {
+ super();
+ }
+
+ public WebexAuthenticationException(String msg) {
+ super(msg);
+ }
+
+ public WebexAuthenticationException(String msg, Throwable t) {
+ super(msg, t);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.ActionOutput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.openhab.core.thing.binding.ThingActions;
+import org.openhab.core.thing.binding.ThingActionsScope;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WebexTeamsActions} class defines rule actions for sending messages
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@ThingActionsScope(name = "webexteams")
+@NonNullByDefault
+public class WebexTeamsActions implements ThingActions {
+
+ private final Logger logger = LoggerFactory.getLogger(WebexTeamsActions.class);
+ private @Nullable WebexTeamsHandler handler;
+
+ @RuleAction(label = "@text/sendMessageActionLabel", description = "@text/sendMessageActionDescription")
+ public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendMessage(
+ @ActionInput(name = "text") @Nullable String text) {
+ if (text == null) {
+ logger.warn("Cannot send Message as text is missing.");
+ return false;
+ }
+
+ final WebexTeamsHandler handler = this.handler;
+ if (handler == null) {
+ logger.debug("Handler is null, cannot send message.");
+ return false;
+ } else {
+ return handler.sendMessage(text);
+ }
+ }
+
+ @RuleAction(label = "@text/sendMessageAttActionLabel", description = "@text/sendMessageAttActionDescription")
+ public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendMessage(
+ @ActionInput(name = "text") @Nullable String text, @ActionInput(name = "attach") @Nullable String attach) {
+ if (text == null) {
+ logger.warn("Cannot send Message as text is missing.");
+ return false;
+ }
+ if (attach == null) {
+ logger.warn("Cannot send Message as attach is missing.");
+ return false;
+ }
+
+ final WebexTeamsHandler handler = this.handler;
+ if (handler == null) {
+ logger.debug("Handler is null, cannot send message.");
+ return false;
+ } else {
+ return handler.sendMessage(text, attach);
+ }
+ }
+
+ @RuleAction(label = "@text/sendRoomMessageActionLabel", description = "@text/sendRoomMessageActionDescription")
+ public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendRoomMessage(
+ @ActionInput(name = "roomId") @Nullable String roomId, @ActionInput(name = "text") @Nullable String text) {
+ if (text == null) {
+ logger.warn("Cannot send Message as text is missing.");
+ return false;
+ }
+ if (roomId == null) {
+ logger.warn("Cannot send Message as roomId is missing.");
+ return false;
+ }
+
+ final WebexTeamsHandler handler = this.handler;
+ if (handler == null) {
+ logger.debug("Handler is null, cannot send message.");
+ return false;
+ } else {
+ return handler.sendRoomMessage(roomId, text);
+ }
+ }
+
+ @RuleAction(label = "@text/sendRoomMessageAttActionLabel", description = "@text/sendRoomMessageAttActionDescription")
+ public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendRoomMessage(
+ @ActionInput(name = "roomId") @Nullable String roomId, @ActionInput(name = "text") @Nullable String text,
+ @ActionInput(name = "attach") @Nullable String attach) {
+ if (text == null) {
+ logger.warn("Cannot send Message as text is missing.");
+ return false;
+ }
+ if (roomId == null) {
+ logger.warn("Cannot send Message as roomId is missing.");
+ return false;
+ }
+ if (attach == null) {
+ logger.warn("Cannot send Message as attach is missing.");
+ return false;
+ }
+ final WebexTeamsHandler handler = this.handler;
+ if (handler == null) {
+ logger.debug("Handler is null, cannot send message.");
+ return false;
+ } else {
+ return handler.sendRoomMessage(roomId, text, attach);
+ }
+ }
+
+ @RuleAction(label = "@text/sendPersonMessageActionLabel", description = "@text/sendPersonMessageActionDescription")
+ public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendPersonMessage(
+ @ActionInput(name = "personEmail") @Nullable String personEmail,
+ @ActionInput(name = "text") @Nullable String text) {
+ if (text == null) {
+ logger.warn("Cannot send Message as text is missing.");
+ return false;
+ }
+ if (personEmail == null) {
+ logger.warn("Cannot send Message as personEmail is missing.");
+ return false;
+ }
+
+ final WebexTeamsHandler handler = this.handler;
+ if (handler == null) {
+ logger.debug("Handler is null, cannot send message.");
+ return false;
+ } else {
+ return handler.sendPersonMessage(personEmail, text);
+ }
+ }
+
+ @RuleAction(label = "@text/sendPersonMessageAttActionLabel", description = "@text/sendPersonMessageAttActionDescription")
+ public @ActionOutput(name = "success", type = "java.lang.Boolean") Boolean sendPersonMessage(
+ @ActionInput(name = "personEmail") @Nullable String personEmail,
+ @ActionInput(name = "text") @Nullable String text, @ActionInput(name = "attach") @Nullable String attach) {
+ if (text == null) {
+ logger.warn("Cannot send Message as text is missing.");
+ return false;
+ }
+ if (personEmail == null) {
+ logger.warn("Cannot send Message as personEmail is missing.");
+ return false;
+ }
+ if (attach == null) {
+ logger.warn("Cannot send Message as attach is missing.");
+ return false;
+ }
+
+ final WebexTeamsHandler handler = this.handler;
+ if (handler == null) {
+ logger.debug("Handler is null, cannot send message.");
+ return false;
+ } else {
+ return handler.sendPersonMessage(personEmail, text, attach);
+ }
+ }
+
+ public static boolean sendMessage(@Nullable ThingActions actions, @Nullable String text) {
+ if (actions instanceof WebexTeamsActions) {
+ return ((WebexTeamsActions) actions).sendMessage(text);
+ } else {
+ throw new IllegalArgumentException("Instance is not a WebexTeamsActions class.");
+ }
+ }
+
+ public static boolean sendMessage(@Nullable ThingActions actions, @Nullable String text, @Nullable String attach) {
+ if (actions instanceof WebexTeamsActions) {
+ return ((WebexTeamsActions) actions).sendMessage(text, attach);
+ } else {
+ throw new IllegalArgumentException("Instance is not a WebexTeamsActions class.");
+ }
+ }
+
+ public static boolean sendRoomMessage(@Nullable ThingActions actions, @Nullable String roomId,
+ @Nullable String text) {
+ if (actions instanceof WebexTeamsActions) {
+ return ((WebexTeamsActions) actions).sendRoomMessage(roomId, text);
+ } else {
+ throw new IllegalArgumentException("Instance is not a WebexTeamsActions class.");
+ }
+ }
+
+ public static boolean sendRoomMessage(@Nullable ThingActions actions, @Nullable String roomId,
+ @Nullable String text, @Nullable String attach) {
+ if (actions instanceof WebexTeamsActions) {
+ return ((WebexTeamsActions) actions).sendRoomMessage(roomId, text, attach);
+ } else {
+ throw new IllegalArgumentException("Instance is not a WebexTeamsActions class.");
+ }
+ }
+
+ public static boolean sendPersonMessage(@Nullable ThingActions actions, @Nullable String personEmail,
+ @Nullable String text) {
+ if (actions instanceof WebexTeamsActions) {
+ return ((WebexTeamsActions) actions).sendPersonMessage(personEmail, text);
+ } else {
+ throw new IllegalArgumentException("Instance is not a WebexTeamsActions class.");
+ }
+ }
+
+ public static boolean sendPersonMessage(@Nullable ThingActions actions, @Nullable String personEmail,
+ @Nullable String text, @Nullable String attach) {
+ if (actions instanceof WebexTeamsActions) {
+ return ((WebexTeamsActions) actions).sendPersonMessage(personEmail, text, attach);
+ } else {
+ throw new IllegalArgumentException("Instance is not a WebexTeamsActions class.");
+ }
+ }
+
+ @Override
+ public void setThingHandler(@Nullable ThingHandler handler) {
+ if (handler instanceof WebexTeamsHandler) {
+ this.handler = (WebexTeamsHandler) handler;
+ }
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return handler;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link WebexTeamsBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class WebexTeamsBindingConstants {
+
+ private static final String BINDING_ID = "webexteams";
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account");
+
+ // List of all Channel ids
+ public static final String CHANNEL_STATUS = "status";
+ public static final String CHANNEL_LASTACTIVITY = "lastactivity";
+
+ // List of properties
+ public static final String PROPERTY_WEBEX_NAME = "name";
+ public static final String PROPERTY_WEBEX_TYPE = "type";
+
+ // OAuth constants
+ public static final String OAUTH_REDIRECT_URL = "https://files.ducbase.com/authcode/index.html";
+ public static final String OAUTH_TOKEN_URL = "https://webexapis.com/v1/access_token";
+ public static final String OAUTH_AUTH_URL = "https://webexapis.com/v1/authorize";
+ public static final String OAUTH_AUTHORIZATION_URL = "https://webexapis.com/v1/authorize";
+ public static final String OAUTH_SCOPE = "spark:all";
+ public static final String WEBEX_ALIAS = "/connectwebex";
+ public static final String WEBEX_RES_ALIAS = "/res";
+
+ public static final String WEBEX_API_ENDPOINT = "https://webexapis.com/v1";
+
+ // other
+ public static final String ISO8601_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link WebexTeamsConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class WebexTeamsConfiguration {
+ // static strings used when interacting with Configuration.
+ public static final String TOKEN = "token";
+ public static final String CLIENT_ID = "clientId";
+ public static final String CLIENT_SECRET = "clientSecret";
+ public static final String REFRESH_PERIOD = "refreshPeriod";
+ public static final String ROOM_ID = "roomId";
+
+ /**
+ * Webex team configuration
+ */
+ public String token = "";
+ public String clientId = "";
+ public String clientSecret = "";
+ public int refreshPeriod = 300;
+ public String roomId = "";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Signals a general exception in the code.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class WebexTeamsException extends Exception {
+ static final long serialVersionUID = 43L;
+
+ public WebexTeamsException() {
+ super();
+ }
+
+ public WebexTeamsException(String message) {
+ super(message);
+ }
+
+ public WebexTeamsException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import static org.openhab.binding.webexteams.internal.WebexTeamsBindingConstants.*;
+
+import java.io.IOException;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Objects;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.webexteams.internal.api.Message;
+import org.openhab.binding.webexteams.internal.api.Person;
+import org.openhab.binding.webexteams.internal.api.WebexTeamsApi;
+import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+import org.openhab.core.auth.client.oauth2.OAuthClientService;
+import org.openhab.core.auth.client.oauth2.OAuthException;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.auth.client.oauth2.OAuthResponseException;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WebexTeamsHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class WebexTeamsHandler extends BaseThingHandler implements AccessTokenRefreshListener {
+
+ private final Logger logger = LoggerFactory.getLogger(WebexTeamsHandler.class);
+
+ // Object to synchronize refresh on
+ private final Object refreshSynchronization = new Object();
+
+ private @NonNullByDefault({}) WebexTeamsConfiguration config;
+
+ private final OAuthFactory oAuthFactory;
+ private final HttpClient httpClient;
+ private @Nullable WebexTeamsApi client;
+
+ private @Nullable OAuthClientService authService;
+
+ private boolean configured = false; // is the handler instance properly configured?
+ private volatile boolean active; // is the handler instance active?
+ String accountType = ""; // bot or person?
+
+ private @Nullable Future<?> refreshFuture;
+
+ public WebexTeamsHandler(Thing thing, OAuthFactory oAuthFactory, HttpClient httpClient) {
+ super(thing);
+ this.oAuthFactory = oAuthFactory;
+ this.httpClient = httpClient;
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ // No commands supported on any channel
+ }
+
+ // creates list of available Actions
+ @Override
+ public Collection<Class<? extends ThingHandlerService>> getServices() {
+ return Collections.singletonList(WebexTeamsActions.class);
+ }
+
+ @Override
+ public void initialize() {
+ logger.debug("Initializing thing {}", this.getThing().getUID());
+ active = true;
+ config = getConfigAs(WebexTeamsConfiguration.class);
+
+ final String token = config.token;
+ final String clientId = config.clientId;
+ final String clientSecret = config.clientSecret;
+
+ if (!token.isBlank()) { // For bots
+ logger.debug("I think I'm a bot.");
+ try {
+ createBotOAuthClientService(config);
+ } catch (WebexTeamsException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/confErrorNotAuth");
+ return;
+ }
+ } else if (!clientId.isBlank()) { // For integrations
+ logger.debug("I think I'm a person.");
+ if (clientSecret.isBlank()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/confErrorNoSecret");
+ return;
+ }
+ createIntegrationOAuthClientService(config);
+ } else { // If no bot or integration credentials, go offline
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/confErrorTokenOrId");
+ return;
+ }
+
+ OAuthClientService localAuthService = this.authService;
+ if (localAuthService == null) {
+ logger.warn("authService not properly initialized");
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "authService not properly initialized");
+ return;
+ }
+
+ updateStatus(ThingStatus.UNKNOWN);
+
+ this.client = new WebexTeamsApi(localAuthService, httpClient);
+
+ // Start with update status by calling Webex. If no credentials available no polling should be started.
+ scheduler.execute(this::startRefresh);
+ }
+
+ @Override
+ public void dispose() {
+ logger.debug("Disposing thing {}", this.getThing().getUID());
+ active = false;
+ OAuthClientService authService = this.authService;
+ if (authService != null) {
+ authService.removeAccessTokenRefreshListener(this);
+ }
+ oAuthFactory.ungetOAuthService(thing.getUID().getAsString());
+ cancelSchedulers();
+ }
+
+ private void createIntegrationOAuthClientService(WebexTeamsConfiguration config) {
+ String thingUID = this.getThing().getUID().getAsString();
+ logger.debug("Creating OAuth Client Service for {}", thingUID);
+ OAuthClientService service = oAuthFactory.createOAuthClientService(thingUID, OAUTH_TOKEN_URL, OAUTH_AUTH_URL,
+ config.clientId, config.clientSecret, OAUTH_SCOPE, false);
+ service.addAccessTokenRefreshListener(this);
+ this.authService = service;
+ this.configured = true;
+ }
+
+ private void createBotOAuthClientService(WebexTeamsConfiguration config) throws WebexTeamsException {
+ String thingUID = this.getThing().getUID().getAsString();
+ AccessTokenResponse response = new AccessTokenResponse();
+ response.setAccessToken(config.token);
+ response.setScope(OAUTH_SCOPE);
+ response.setTokenType("Bearer");
+ response.setExpiresIn(Long.MAX_VALUE); // Bot access tokens don't expire
+ logger.debug("Creating OAuth Client Service for {}", thingUID);
+ OAuthClientService service = oAuthFactory.createOAuthClientService(thingUID, OAUTH_TOKEN_URL,
+ OAUTH_AUTHORIZATION_URL, "not used", null, OAUTH_SCOPE, false);
+ try {
+ service.importAccessTokenResponse(response);
+ } catch (OAuthException e) {
+ throw new WebexTeamsException("Failed to create oauth client with bot token", e);
+ }
+ this.authService = service;
+ this.configured = true;
+ }
+
+ boolean isConfigured() {
+ return configured;
+ }
+
+ protected String authorize(String redirectUri, String reqCode) throws WebexTeamsException {
+ try {
+ logger.debug("Make call to Webex to get access token.");
+
+ // Not doing anything with the token. It's used indirectly through authService.
+ OAuthClientService authService = this.authService;
+ if (authService != null) {
+ authService.getAccessTokenResponseByAuthorizationCode(reqCode, redirectUri);
+ }
+
+ startRefresh();
+ final String user = getUser();
+ logger.info("Authorized for user: {}", user);
+
+ return user;
+ } catch (RuntimeException | OAuthException | IOException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+ throw new WebexTeamsException("Failed to authorize", e);
+ } catch (final OAuthResponseException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+ throw new WebexTeamsException("OAuth exception", e);
+ }
+ }
+
+ public boolean isAuthorized() {
+ final AccessTokenResponse accessTokenResponse = getAccessTokenResponse();
+
+ if ("person".equals(this.accountType)) {
+ return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null
+ && accessTokenResponse.getRefreshToken() != null;
+ } else {
+ // bots don't need no refreshToken!
+ return accessTokenResponse != null && accessTokenResponse.getAccessToken() != null;
+ }
+ }
+
+ private @Nullable AccessTokenResponse getAccessTokenResponse() {
+ try {
+ OAuthClientService authService = this.authService;
+ return authService == null ? null : authService.getAccessTokenResponse();
+ } catch (OAuthException | IOException | OAuthResponseException | RuntimeException e) {
+ logger.debug("Exception checking authorization: ", e);
+ return null;
+ }
+ }
+
+ public boolean equalsThingUID(String thingUID) {
+ return getThing().getUID().getAsString().equals(thingUID);
+ }
+
+ public String formatAuthorizationUrl(String redirectUri) {
+ try {
+ if (this.configured) {
+ OAuthClientService authService = this.authService;
+ if (authService != null) {
+ return authService.getAuthorizationUrl(redirectUri, null, thing.getUID().getAsString());
+ } else {
+ logger.warn("AuthService not properly initialized");
+ return "";
+ }
+ } else {
+ return "";
+ }
+ } catch (final OAuthException e) {
+ logger.warn("Error constructing AuthorizationUrl: ", e);
+ return "";
+ }
+ }
+
+ // mainly used to refresh the auth token when using OAuth
+ private boolean refresh() {
+ synchronized (refreshSynchronization) {
+ Person person;
+ try {
+ WebexTeamsApi client = this.client;
+ if (client == null) {
+ logger.warn("Client not properly initialized");
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "Client not properly initialized");
+ return false;
+ }
+ person = client.getPerson();
+ String type = person.getType();
+ if (type == null) {
+ type = "?";
+ }
+ updateProperty(PROPERTY_WEBEX_TYPE, type);
+ this.accountType = type;
+ updateProperty(PROPERTY_WEBEX_NAME, person.getDisplayName());
+
+ // Only when the identity is a person:
+ if ("person".equalsIgnoreCase(person.getType())) {
+ String status = person.getStatus();
+ updateState(CHANNEL_STATUS, StringType.valueOf(status));
+ DateFormat df = new SimpleDateFormat(ISO8601_FORMAT);
+ String lastActivity = df.format(person.getLastActivity());
+ if (lastActivity != null) {
+ updateState(CHANNEL_LASTACTIVITY, new DateTimeType(lastActivity));
+ }
+ }
+ updateStatus(ThingStatus.ONLINE);
+ return true;
+ } catch (WebexTeamsException e) {
+ logger.warn("Failed to refresh: {}", e.getMessage());
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ }
+ return false;
+ }
+ }
+
+ private void startRefresh() {
+ synchronized (refreshSynchronization) {
+ if (refresh()) {
+ cancelSchedulers();
+ if (active) {
+ refreshFuture = scheduler.scheduleWithFixedDelay(this::refresh, 0, config.refreshPeriod,
+ TimeUnit.SECONDS);
+ }
+ }
+ }
+ }
+
+ /**
+ * Cancels all running schedulers.
+ */
+ private synchronized void cancelSchedulers() {
+ Future<?> future = this.refreshFuture;
+ if (future != null) {
+ future.cancel(true);
+ this.refreshFuture = null;
+ }
+ }
+
+ public String getUser() {
+ return thing.getProperties().getOrDefault(PROPERTY_WEBEX_NAME, "");
+ }
+
+ public ThingUID getUID() {
+ return thing.getUID();
+ }
+
+ public String getLabel() {
+ return Objects.requireNonNullElse(thing.getLabel(), "");
+ }
+
+ /**
+ * Sends a message to the default room.
+ *
+ * @param msg markdown text string to be sent
+ *
+ * @return <code>true</code>, if sending the message has been successful and
+ * <code>false</code> in all other cases.
+ */
+ public boolean sendMessage(String msg) {
+ Message message = new Message();
+ message.setRoomId(config.roomId);
+ message.setMarkdown(msg);
+ logger.debug("Sending message to default room ({})", config.roomId);
+ return sendMessage(message);
+ }
+
+ /**
+ * Sends a message with file attachment to the default room.
+ *
+ * @param msg markdown text string to be sent
+ * @param attach URL of the attachment
+ *
+ * @return <code>true</code>, if sending the message has been successful and
+ * <code>false</code> in all other cases.
+ */
+ public boolean sendMessage(String msg, String attach) {
+ Message message = new Message();
+ message.setRoomId(config.roomId);
+ message.setMarkdown(msg);
+ message.setFile(attach);
+ logger.debug("Sending message with attachment to default room ({})", config.roomId);
+ return sendMessage(message);
+ }
+
+ /**
+ * Send a message to a specific room
+ *
+ * @param roomId roomId of the room to send to
+ * @param msg markdown text string to be sent
+ * @return <code>true</code>, if sending the message has been successful and
+ * <code>false</code> in all other cases.
+ */
+ public boolean sendRoomMessage(String roomId, String msg) {
+ Message message = new Message();
+ message.setRoomId(roomId);
+ message.setMarkdown(msg);
+ logger.debug("Sending message to room {}", roomId);
+ return sendMessage(message);
+ }
+
+ /**
+ * Send a message to a specific room, with attachment
+ *
+ * @param roomId roomId of the room to send to
+ * @param msg markdown text string to be sent
+ * @param attach URL of the attachment
+ *
+ * @return <code>true</code>, if sending the message has been successful and
+ * <code>false</code> in all other cases.
+ */
+ public boolean sendRoomMessage(String roomId, String msg, String attach) {
+ Message message = new Message();
+ message.setRoomId(roomId);
+ message.setMarkdown(msg);
+ message.setFile(attach);
+ logger.debug("Sending message with attachment to room {}", roomId);
+ return sendMessage(message);
+ }
+
+ /**
+ * Sends a message to a specific person, identified by email
+ *
+ * @param personEmail email address of the person to send to
+ * @param msg markdown text string to be sent
+ * @return <code>true</code>, if sending the message has been successful and
+ * <code>false</code> in all other cases.
+ */
+ public boolean sendPersonMessage(String personEmail, String msg) {
+ Message message = new Message();
+ message.setToPersonEmail(personEmail);
+ message.setMarkdown(msg);
+ logger.debug("Sending message to {}", personEmail);
+ return sendMessage(message);
+ }
+
+ /**
+ * Sends a message to a specific person, identified by email, with attachment
+ *
+ * @param personEmail email address of the person to send to
+ * @param msg markdown text string to be sent
+ * @param attach URL of the attachment*
+ * @return <code>true</code>, if sending the message has been successful and
+ * <code>false</code> in all other cases.
+ */
+ public boolean sendPersonMessage(String personEmail, String msg, String attach) {
+ Message message = new Message();
+ message.setToPersonEmail(personEmail);
+ message.setMarkdown(msg);
+ message.setFile(attach);
+ logger.debug("Sending message to {}", personEmail);
+ return sendMessage(message);
+ }
+
+ /**
+ * Sends a <code>Message</code>
+ *
+ * @param msg the <code>Message</code> to be sent
+ * @return <code>true</code>, if sending the message has been successful and
+ * <code>false</code> in all other cases.
+ */
+ private boolean sendMessage(Message msg) {
+ try {
+ WebexTeamsApi client = this.client;
+ if (client != null) {
+ client.sendMessage(msg);
+ return true;
+ } else {
+ logger.warn("Client not properly initialized");
+ return false;
+ }
+ } catch (WebexTeamsException e) {
+ logger.warn("Failed to send message: {}", e.getMessage());
+ }
+ return false;
+ }
+
+ @Override
+ public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal;
+
+import static org.openhab.binding.webexteams.internal.WebexTeamsBindingConstants.*;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link WebexTeamsHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.webexteams", service = ThingHandlerFactory.class)
+public class WebexTeamsHandlerFactory extends BaseThingHandlerFactory {
+
+ private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT);
+
+ private final OAuthFactory oAuthFactory;
+ private final HttpClient httpClient;
+ private final WebexAuthService authService;
+
+ @Activate
+ public WebexTeamsHandlerFactory(@Reference OAuthFactory oAuthFactory,
+ @Reference HttpClientFactory httpClientFactory, @Reference WebexAuthService authService) {
+ this.oAuthFactory = oAuthFactory;
+ this.httpClient = httpClientFactory.getCommonHttpClient();
+ this.authService = authService;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
+ final WebexTeamsHandler handler = new WebexTeamsHandler(thing, oAuthFactory, httpClient);
+ authService.addWebexTeamsHandler(handler);
+ return handler;
+ }
+
+ return null;
+ }
+
+ @Override
+ protected synchronized void removeHandler(ThingHandler thingHandler) {
+ if (thingHandler instanceof WebexTeamsHandler) {
+ authService.removeWebexTeamsHandler((WebexTeamsHandler) thingHandler);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal.api;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * a <code>Message</code> that is sent or received through the API.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class Message {
+ private @Nullable String id;
+ private @Nullable String roomId;
+ private @Nullable String toPersonEmail;
+ private @Nullable String text;
+ private @Nullable String markdown;
+ private @Nullable String file;
+
+ @Nullable
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ @Nullable
+ public String getRoomId() {
+ return roomId;
+ }
+
+ public void setRoomId(String roomId) {
+ this.roomId = roomId;
+ }
+
+ @Nullable
+ public String getToPersonEmail() {
+ return toPersonEmail;
+ }
+
+ public void setToPersonEmail(String toPersonEmail) {
+ this.toPersonEmail = toPersonEmail;
+ }
+
+ @Nullable
+ public String getText() {
+ return text;
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ }
+
+ @Nullable
+ public String getMarkdown() {
+ return markdown;
+ }
+
+ public void setMarkdown(String markdown) {
+ this.markdown = markdown;
+ }
+
+ @Nullable
+ public String getFile() {
+ return file;
+ }
+
+ public void setFile(String file) {
+ this.file = file;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal.api;
+
+import java.util.Date;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * a <code>Person</code> object that is received from the Webex API.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class Person {
+ private @Nullable String id;
+ private @Nullable String displayName;
+ private @Nullable String firstName;
+ private @Nullable String lastName;
+ private @Nullable String avatar;
+ private @Nullable Date lastActivity;
+ private @Nullable String status;
+ private @Nullable String type;
+
+ @Nullable
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ @Nullable
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public void setDisplayName(String displayName) {
+ this.displayName = displayName;
+ }
+
+ @Nullable
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ @Nullable
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ @Nullable
+ public String getAvatar() {
+ return avatar;
+ }
+
+ public void setAvatar(String avatar) {
+ this.avatar = avatar;
+ }
+
+ @Nullable
+ public Date getLastActivity() {
+ return lastActivity;
+ }
+
+ public void setLastActivity(Date lastActivity) {
+ this.lastActivity = lastActivity;
+ }
+
+ @Nullable
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ @Nullable
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal.api;
+
+import static org.openhab.binding.webexteams.internal.WebexTeamsBindingConstants.*;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.webexteams.internal.WebexAuthenticationException;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+import org.openhab.core.auth.client.oauth2.OAuthClientService;
+import org.openhab.core.auth.client.oauth2.OAuthException;
+import org.openhab.core.auth.client.oauth2.OAuthResponseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonIOException;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * WebexTeamsApi implements API integration with Webex Teams.
+ *
+ * Not using webex-java-sdk since it's not in a public maven repo, and it doesn't easily
+ * support caching refresh tokens between openhab restarts, etc..
+ *
+ * @author Tom Deckers - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class WebexTeamsApi {
+
+ private final Logger logger = LoggerFactory.getLogger(WebexTeamsApi.class);
+
+ private final OAuthClientService authService;
+ private final HttpClient httpClient;
+
+ public WebexTeamsApi(OAuthClientService authService, HttpClient httpClient) {
+ this.authService = authService;
+ this.httpClient = httpClient;
+ }
+
+ /**
+ * Get a <code>Person</code> object for the account.
+ *
+ * @return a <code>Person</code> object
+ * @throws WebexAuthenticationException when authentication fails
+ * @throws WebexTeamsApiException for other failures
+ */
+ public Person getPerson() throws WebexTeamsApiException, WebexAuthenticationException {
+ URI url = getUri(WEBEX_API_ENDPOINT + "/people/me");
+
+ Person person = request(url, HttpMethod.GET, Person.class, null);
+ return person;
+ }
+
+ private URI getUri(String url) throws WebexTeamsApiException {
+ URI uri;
+ try {
+ uri = new URI(url);
+ } catch (URISyntaxException e) {
+ throw new WebexTeamsApiException("bad url", e);
+ }
+ return uri;
+ }
+
+ private <I, O> O request(URI url, HttpMethod method, Class<O> clazz, I body)
+ throws WebexAuthenticationException, WebexTeamsApiException {
+ try {
+ // Refresh is handled automatically by this method
+ AccessTokenResponse response = this.authService.getAccessTokenResponse();
+
+ String authToken = response == null ? null : response.getAccessToken();
+ if (authToken == null) {
+ throw new WebexAuthenticationException("Auth token is null");
+ } else {
+ return doRequest(url, method, authToken, clazz, body);
+ }
+ } catch (OAuthException | IOException | OAuthResponseException e) {
+ throw new WebexAuthenticationException("Not authenticated", e);
+ }
+ }
+
+ private <I, O> O doRequest(URI url, HttpMethod method, String authToken, Class<O> clazz, I body)
+ throws WebexAuthenticationException, WebexTeamsApiException {
+ Gson gson = new Gson();
+ try {
+ Request req = httpClient.newRequest(url).method(method);
+ req.header("Authorization", "Bearer " + authToken);
+ logger.debug("Requesting {} with ({}, {})", url, clazz, body);
+
+ if (body != null) {
+ String bodyString = gson.toJson(body, body.getClass());
+ req.content(new StringContentProvider(bodyString));
+ req.header("Content-type", "application/json");
+ }
+
+ ContentResponse response = req.send();
+
+ logger.debug("Response: {} - {}", response.getStatus(), response.getReason());
+
+ if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
+ throw new WebexAuthenticationException();
+ } else if (response.getStatus() == HttpStatus.OK_200) {
+ // Obtain the input stream on the response content
+ try (InputStream input = new ByteArrayInputStream(response.getContent())) {
+ Reader reader = new InputStreamReader(input);
+ O entity = gson.fromJson(reader, clazz);
+ return entity;
+ } catch (IOException | JsonIOException | JsonSyntaxException e) {
+ logger.warn("Exception while processing API response: {}", e.getMessage());
+ throw new WebexTeamsApiException("Exception while processing API response", e);
+ }
+ } else {
+ logger.warn("Unexpected response {} - {}", response.getStatus(), response.getReason());
+ try (InputStream input = new ByteArrayInputStream(response.getContent())) {
+ String text = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)).lines()
+ .collect(Collectors.joining("\n"));
+ logger.warn("Content: {}", text);
+ } catch (IOException e) {
+ throw new WebexTeamsApiException(
+ String.format("Unexpected response code: {}", response.getStatus()), e);
+ }
+
+ throw new WebexTeamsApiException(
+ String.format("Unexpected response {} - {}", response.getStatus(), response.getReason()));
+ }
+ } catch (TimeoutException e) {
+ logger.warn("Request timeout", e);
+ throw new WebexTeamsApiException("Request timeout", e);
+ } catch (ExecutionException e) {
+ logger.warn("Request error", e);
+ throw new WebexTeamsApiException("Request error", e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ logger.warn("Request interrupted", e);
+ throw new WebexTeamsApiException("Request interrupted", e);
+ }
+ }
+
+ // sendMessage
+ public Message sendMessage(Message msg) throws WebexTeamsApiException, WebexAuthenticationException {
+ URI url = getUri(WEBEX_API_ENDPOINT + "/messages");
+ Message response = request(url, HttpMethod.POST, Message.class, msg);
+ logger.debug("Sent message, id: {}", response.getId());
+ return response;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.webexteams.internal.api;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.webexteams.internal.WebexTeamsException;
+
+/**
+ * Signals a general exception with interacting with the Webex API.
+ *
+ * @author Tom Deckers - Initial contribution
+ */
+@NonNullByDefault
+public class WebexTeamsApiException extends WebexTeamsException {
+ static final long serialVersionUID = 46L;
+
+ public WebexTeamsApiException() {
+ super();
+ }
+
+ public WebexTeamsApiException(String message) {
+ super(message);
+ }
+
+ public WebexTeamsApiException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="webexteams" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+ <name>WebexTeams Binding</name>
+ <description>This is the binding for WebexTeams. Send messages with actions.</description>
+
+</binding:binding>
--- /dev/null
+# binding
+
+binding.webexteams.name = WebexTeams Binding
+binding.webexteams.description = This is the binding for WebexTeams. Send messages with actions.
+
+# thing types
+
+thing-type.webexteams.account.label = WebexTeams Account
+thing-type.webexteams.account.description = WebexTeams account used to send messages.
+
+# thing types config
+
+thing-type.config.webexteams.account.clientId.label = Client Id
+thing-type.config.webexteams.account.clientId.description = Client Id. Only use with a person integration.
+thing-type.config.webexteams.account.clientSecret.label = Client Secret
+thing-type.config.webexteams.account.clientSecret.description = Client Secret. Only use with a person integration.
+thing-type.config.webexteams.account.refreshPeriod.label = Refresh Period (seconds)
+thing-type.config.webexteams.account.refreshPeriod.description = Refresh period for channels. Low numbers increase accuracy, but could hit API rate limits. Defaults to 300 secs.
+thing-type.config.webexteams.account.roomId.label = Default Room Id
+thing-type.config.webexteams.account.roomId.description = Id of the default room to send messages
+thing-type.config.webexteams.account.token.label = Authorization Token
+thing-type.config.webexteams.account.token.description = Authorization token. Only use with a bot account.
+
+# channel types
+
+channel-type.webexteams.lastactivity.label = Last Activity
+channel-type.webexteams.lastactivity.description = The date and time of the person's last activity within Webex
+channel-type.webexteams.status.label = Status
+channel-type.webexteams.status.description = The current presence status of the person
+
+# actions
+
+confErrorTokenOrId = Either token or client id/secret must be configured
+confErrorNoSecret = Using OAuth - Client secret must be configured
+confErrorNoRedirectUrl = Using OAuth - RedirectUrl must be configured
+confErrorNotAuth = Using OAuth - Could not authenticate
+confErrorInitial = Failed initial authentication (old auth code?)
+sendMessageActionLabel = send a message to the default room
+sendMessageActionDescription = Sends a message to the default room
+sendMessageAttActionLabel = send a message with attachment to the default room
+sendMessageAttActionDescription = Sends a message with attachment to the default room
+sendRoomMessageActionLabel = send a message to a specific room
+sendRoomMessageActionDescription = Sends a message to a specific room
+sendRoomMessageAttActionLabel = send a message with attachment to a specific room
+sendRoomMessageAttActionDescription = Sends a message with attachment to a specific room
+sendPersonMessageActionLabel = send a message to a person
+sendPersonMessageActionDescription = Sends a message to a person
+sendPersonMessageAttActionLabel = send a message with attachment to a person
+sendPersonMessageAttActionDescription = Sends a message with attachment to a person
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="webexteams"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <!-- Account Thing Type -->
+ <thing-type id="account">
+ <label>WebexTeams Account</label>
+ <description>WebexTeams account used to send messages.</description>
+
+ <channels>
+ <channel id="status" typeId="status"/>
+ <channel id="lastactivity" typeId="lastactivity"/>
+ </channels>
+
+ <properties>
+ <property name="type"></property>
+ <property name="name"></property>
+ </properties>
+
+ <config-description>
+ <parameter name="token" type="text" required="false">
+ <context>password</context>
+ <label>Authorization Token</label>
+ <description>Authorization token. Only use with a bot account.</description>
+ </parameter>
+ <parameter name="clientId" type="text" required="false">
+ <label>Client Id</label>
+ <description>Client Id. Only use with a person integration.</description>
+ </parameter>
+ <parameter name="clientSecret" type="text" required="false">
+ <context>password</context>
+ <label>Client Secret</label>
+ <description>Client Secret. Only use with a person integration.</description>
+ </parameter>
+ <parameter name="refreshPeriod" type="integer" required="false">
+ <label>Refresh Period (seconds)</label>
+ <default>300</default>
+ <description>Refresh period for channels. Low numbers increase accuracy, but could hit API rate limits. Defaults to
+ 300 secs.</description>
+ </parameter>
+ <parameter name="roomId" type="text" required="false">
+ <label>Default Room Id</label>
+ <description>Id of the default room to send messages</description>
+ </parameter>
+ </config-description>
+ </thing-type>
+
+ <!-- Botname Channel Type -->
+ <channel-type id="status">
+ <item-type>String</item-type>
+ <label>Status</label>
+ <description>The current presence status of the person</description>
+ <state readOnly="true"/>
+ </channel-type>
+ <channel-type id="lastactivity">
+ <item-type>DateTime</item-type>
+ <label>Last Activity</label>
+ <description>The date and time of the person's last activity within Webex</description>
+ <state readOnly="true"/>
+ </channel-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<div class="row" id="${account.id}">
+ <div class="one column">${account.type}:</div>
+ <div class="nine columns"><i>${account.name}${account.user}</i></div>
+ <div class="two columns ${account.showbtn}">
+ <div class="button-primary"><a href=${account.authorize}>Authorize Account</a></div>
+ </div>
+ <div class="two columns ${account.showmsg}">${account.msg}</div>
+</div>
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <!-- Basic Page Needs
+ –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+ <meta charset="utf-8">
+ <title>Authorize openHAB binding for Webex</title>
+ <meta name="description" content="">
+ <meta name="author" content="tom@ducbase.com">
+ ${pageRefresh}
+
+ <!-- Mobile Specific Metas
+ –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+
+ <!-- FONT
+ –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+ <link href="//fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
+
+ <!-- CSS
+ –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+ <link rel="stylesheet" href="res/css/normalize.css">
+ <link rel="stylesheet" href="res/css/skeleton.css">
+ <link rel="stylesheet" href="res/css/custom.css">
+
+ <!-- Favicon
+ –––––––––––––––––––––––––––––––––––––––––––––––––– -->
+ <link rel="shortcut icon" href="res/images/favicon.ico">
+ </head>
+ <body>
+ <div class="container">
+ <div class="row bottom-one">
+ <h3>Authorize openHAB binding for Webex</h3>
+ <p>On this page you can authorize your openHAB Webex Teams Account configured with the clientId and clientSecret of the Webex API on your Developer account.</p>
+ <p>To use this binding the following requirements apply:</p>
+ <ul>
+ <li>A Cisco Webex account.</li>
+ <li>Create an integration (a bot account doesn't require oAuth authorization)</li>
+ </ul>
+ <p>
+ The redirect URI to use with the Webex API for this openHAB installation is
+ <a href="${redirectUri}">${redirectUri}</a>
+ </p>
+
+ </div>
+
+ ${authorizedUser}
+ ${accounts}
+
+ <div class="row">
+ ${error}
+ </div>
+
+ </div>
+
+ </body>
+</html>
--- /dev/null
+.bottom-one {
+ margin-bottom: 1cm;
+}
+
+.u-hide {
+ display: none !important; }
+
+.u-show {
+ display: block !important; }
+
+.u-invisible {
+ visibility: hidden !important; }
+
+.u-visible {
+ visibility: visible !important; }
\ No newline at end of file
--- /dev/null
+/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
+
+/**
+ * 1. Set default font family to sans-serif.
+ * 2. Prevent iOS text size adjust after orientation change, without disabling
+ * user zoom.
+ */
+
+html {
+ font-family: sans-serif; /* 1 */
+ -ms-text-size-adjust: 100%; /* 2 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/**
+ * Remove default margin.
+ */
+
+body {
+ margin: 0;
+}
+
+/* HTML5 display definitions
+ ========================================================================== */
+
+/**
+ * Correct `block` display not defined for any HTML5 element in IE 8/9.
+ * Correct `block` display not defined for `details` or `summary` in IE 10/11
+ * and Firefox.
+ * Correct `block` display not defined for `main` in IE 11.
+ */
+
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+main,
+menu,
+nav,
+section,
+summary {
+ display: block;
+}
+
+/**
+ * 1. Correct `inline-block` display not defined in IE 8/9.
+ * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
+ */
+
+audio,
+canvas,
+progress,
+video {
+ display: inline-block; /* 1 */
+ vertical-align: baseline; /* 2 */
+}
+
+/**
+ * Prevent modern browsers from displaying `audio` without controls.
+ * Remove excess height in iOS 5 devices.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Address `[hidden]` styling not present in IE 8/9/10.
+ * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.
+ */
+
+[hidden],
+template {
+ display: none;
+}
+
+/* Links
+ ========================================================================== */
+
+/**
+ * Remove the gray background color from active links in IE 10.
+ */
+
+a {
+ background-color: transparent;
+}
+
+/**
+ * Improve readability when focused and also mouse hovered in all browsers.
+ */
+
+a:active,
+a:hover {
+ outline: 0;
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * Address styling not present in IE 8/9/10/11, Safari, and Chrome.
+ */
+
+abbr[title] {
+ border-bottom: 1px dotted;
+}
+
+/**
+ * Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
+ */
+
+b,
+strong {
+ font-weight: bold;
+}
+
+/**
+ * Address styling not present in Safari and Chrome.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Address variable `h1` font-size and margin within `section` and `article`
+ * contexts in Firefox 4+, Safari, and Chrome.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/**
+ * Address styling not present in IE 8/9.
+ */
+
+mark {
+ background: #ff0;
+ color: #000;
+}
+
+/**
+ * Address inconsistent and variable font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` affecting `line-height` in all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sup {
+ top: -0.5em;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Remove border when inside `a` element in IE 8/9/10.
+ */
+
+img {
+ border: 0;
+}
+
+/**
+ * Correct overflow not hidden in IE 9/10/11.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * Address margin not present in IE 8/9 and Safari.
+ */
+
+figure {
+ margin: 1em 40px;
+}
+
+/**
+ * Address differences between Firefox and other browsers.
+ */
+
+hr {
+ -moz-box-sizing: content-box;
+ box-sizing: content-box;
+ height: 0;
+}
+
+/**
+ * Contain overflow in all browsers.
+ */
+
+pre {
+ overflow: auto;
+}
+
+/**
+ * Address odd `em`-unit font size rendering in all browsers.
+ */
+
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * Known limitation: by default, Chrome and Safari on OS X allow very limited
+ * styling of `select`, unless a `border` property is set.
+ */
+
+/**
+ * 1. Correct color not being inherited.
+ * Known issue: affects color of disabled elements.
+ * 2. Correct font properties not being inherited.
+ * 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ color: inherit; /* 1 */
+ font: inherit; /* 2 */
+ margin: 0; /* 3 */
+}
+
+/**
+ * Address `overflow` set to `hidden` in IE 8/9/10/11.
+ */
+
+button {
+ overflow: visible;
+}
+
+/**
+ * Address inconsistent `text-transform` inheritance for `button` and `select`.
+ * All other form control elements do not inherit `text-transform` values.
+ * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
+ * Correct `select` style inheritance in Firefox.
+ */
+
+button,
+select {
+ text-transform: none;
+}
+
+/**
+ * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
+ * and `video` controls.
+ * 2. Correct inability to style clickable `input` types in iOS.
+ * 3. Improve usability and consistency of cursor style between image-type
+ * `input` and others.
+ */
+
+button,
+html input[type="button"], /* 1 */
+input[type="reset"],
+input[type="submit"] {
+ -webkit-appearance: button; /* 2 */
+ cursor: pointer; /* 3 */
+}
+
+/**
+ * Re-set default cursor for disabled elements.
+ */
+
+button[disabled],
+html input[disabled] {
+ cursor: default;
+}
+
+/**
+ * Remove inner padding and border in Firefox 4+.
+ */
+
+button::-moz-focus-inner,
+input::-moz-focus-inner {
+ border: 0;
+ padding: 0;
+}
+
+/**
+ * Address Firefox 4+ setting `line-height` on `input` using `!important` in
+ * the UA stylesheet.
+ */
+
+input {
+ line-height: normal;
+}
+
+/**
+ * It's recommended that you don't attempt to style these elements.
+ * Firefox's implementation doesn't respect box-sizing, padding, or width.
+ *
+ * 1. Address box sizing set to `content-box` in IE 8/9/10.
+ * 2. Remove excess padding in IE 8/9/10.
+ */
+
+input[type="checkbox"],
+input[type="radio"] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Fix the cursor style for Chrome's increment/decrement buttons. For certain
+ * `font-size` values of the `input`, it causes the cursor style of the
+ * decrement button to change from `default` to `text`.
+ */
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Address `appearance` set to `searchfield` in Safari and Chrome.
+ * 2. Address `box-sizing` set to `border-box` in Safari and Chrome
+ * (include `-moz` to future-proof).
+ */
+
+input[type="search"] {
+ -webkit-appearance: textfield; /* 1 */
+ -moz-box-sizing: content-box;
+ -webkit-box-sizing: content-box; /* 2 */
+ box-sizing: content-box;
+}
+
+/**
+ * Remove inner padding and search cancel button in Safari and Chrome on OS X.
+ * Safari (but not Chrome) clips the cancel button when the search input has
+ * padding (and `textfield` appearance).
+ */
+
+input[type="search"]::-webkit-search-cancel-button,
+input[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * Define consistent border, margin, and padding.
+ */
+
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+
+/**
+ * 1. Correct `color` not being inherited in IE 8/9/10/11.
+ * 2. Remove padding so people aren't caught out if they zero out fieldsets.
+ */
+
+legend {
+ border: 0; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Remove default vertical scrollbar in IE 8/9/10/11.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * Don't inherit the `font-weight` (applied by a rule above).
+ * NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
+ */
+
+optgroup {
+ font-weight: bold;
+}
+
+/* Tables
+ ========================================================================== */
+
+/**
+ * Remove most spacing between table cells.
+ */
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+td,
+th {
+ padding: 0;
+}
\ No newline at end of file
--- /dev/null
+/*
+* Skeleton V2.0.4
+* Copyright 2014, Dave Gamache
+* www.getskeleton.com
+* Free to use under the MIT license.
+* http://www.opensource.org/licenses/mit-license.php
+* 12/29/2014
+*/
+
+
+/* Table of contents
+––––––––––––––––––––––––––––––––––––––––––––––––––
+- Grid
+- Base Styles
+- Typography
+- Links
+- Buttons
+- Forms
+- Lists
+- Code
+- Tables
+- Spacing
+- Utilities
+- Clearing
+- Media Queries
+*/
+
+
+/* Grid
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.container {
+ position: relative;
+ width: 100%;
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 0 20px;
+ box-sizing: border-box; }
+.column,
+.columns {
+ width: 100%;
+ float: left;
+ box-sizing: border-box; }
+
+/* For devices larger than 400px */
+@media (min-width: 400px) {
+ .container {
+ width: 85%;
+ padding: 0; }
+}
+
+/* For devices larger than 550px */
+@media (min-width: 550px) {
+ .container {
+ width: 80%; }
+ .column,
+ .columns {
+ margin-left: 4%; }
+ .column:first-child,
+ .columns:first-child {
+ margin-left: 0; }
+
+ .one.column,
+ .one.columns { width: 4.66666666667%; }
+ .two.columns { width: 13.3333333333%; }
+ .three.columns { width: 22%; }
+ .four.columns { width: 30.6666666667%; }
+ .five.columns { width: 39.3333333333%; }
+ .six.columns { width: 48%; }
+ .seven.columns { width: 56.6666666667%; }
+ .eight.columns { width: 65.3333333333%; }
+ .nine.columns { width: 74.0%; }
+ .ten.columns { width: 82.6666666667%; }
+ .eleven.columns { width: 91.3333333333%; }
+ .twelve.columns { width: 100%; margin-left: 0; }
+
+ .one-third.column { width: 30.6666666667%; }
+ .two-thirds.column { width: 65.3333333333%; }
+
+ .one-half.column { width: 48%; }
+
+ /* Offsets */
+ .offset-by-one.column,
+ .offset-by-one.columns { margin-left: 8.66666666667%; }
+ .offset-by-two.column,
+ .offset-by-two.columns { margin-left: 17.3333333333%; }
+ .offset-by-three.column,
+ .offset-by-three.columns { margin-left: 26%; }
+ .offset-by-four.column,
+ .offset-by-four.columns { margin-left: 34.6666666667%; }
+ .offset-by-five.column,
+ .offset-by-five.columns { margin-left: 43.3333333333%; }
+ .offset-by-six.column,
+ .offset-by-six.columns { margin-left: 52%; }
+ .offset-by-seven.column,
+ .offset-by-seven.columns { margin-left: 60.6666666667%; }
+ .offset-by-eight.column,
+ .offset-by-eight.columns { margin-left: 69.3333333333%; }
+ .offset-by-nine.column,
+ .offset-by-nine.columns { margin-left: 78.0%; }
+ .offset-by-ten.column,
+ .offset-by-ten.columns { margin-left: 86.6666666667%; }
+ .offset-by-eleven.column,
+ .offset-by-eleven.columns { margin-left: 95.3333333333%; }
+
+ .offset-by-one-third.column,
+ .offset-by-one-third.columns { margin-left: 34.6666666667%; }
+ .offset-by-two-thirds.column,
+ .offset-by-two-thirds.columns { margin-left: 69.3333333333%; }
+
+ .offset-by-one-half.column,
+ .offset-by-one-half.columns { margin-left: 52%; }
+
+}
+
+
+/* Base Styles
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+/* NOTE
+html is set to 62.5% so that all the REM measurements throughout Skeleton
+are based on 10px sizing. So basically 1.5rem = 15px :) */
+html {
+ font-size: 62.5%; }
+body {
+ font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */
+ line-height: 1.6;
+ font-weight: 400;
+ font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
+ color: #222; }
+
+
+/* Typography
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+h1, h2, h3, h4, h5, h6 {
+ margin-top: 0;
+ margin-bottom: 2rem;
+ font-weight: 300; }
+h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;}
+h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; }
+h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; }
+h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; }
+h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; }
+h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; }
+
+/* Larger than phablet */
+@media (min-width: 550px) {
+ h1 { font-size: 5.0rem; }
+ h2 { font-size: 4.2rem; }
+ h3 { font-size: 3.6rem; }
+ h4 { font-size: 3.0rem; }
+ h5 { font-size: 2.4rem; }
+ h6 { font-size: 1.5rem; }
+}
+
+p {
+ margin-top: 0; }
+
+
+/* Links
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+a {
+ color: #1EAEDB; }
+a:hover {
+ color: #0FA0CE; }
+
+
+/* Buttons
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.button,
+button,
+input[type="submit"],
+input[type="reset"],
+input[type="button"] {
+ display: inline-block;
+ height: 38px;
+ padding: 0 30px;
+ color: #555;
+ text-align: center;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 38px;
+ letter-spacing: .1rem;
+ text-transform: uppercase;
+ text-decoration: none;
+ white-space: nowrap;
+ background-color: transparent;
+ border-radius: 4px;
+ border: 1px solid #bbb;
+ cursor: pointer;
+ box-sizing: border-box; }
+.button:hover,
+button:hover,
+input[type="submit"]:hover,
+input[type="reset"]:hover,
+input[type="button"]:hover,
+.button:focus,
+button:focus,
+input[type="submit"]:focus,
+input[type="reset"]:focus,
+input[type="button"]:focus {
+ color: #333;
+ border-color: #888;
+ outline: 0; }
+.button.button-primary,
+button.button-primary,
+input[type="submit"].button-primary,
+input[type="reset"].button-primary,
+input[type="button"].button-primary {
+ color: #FFF;
+ background-color: #33C3F0;
+ border-color: #33C3F0; }
+.button.button-primary:hover,
+button.button-primary:hover,
+input[type="submit"].button-primary:hover,
+input[type="reset"].button-primary:hover,
+input[type="button"].button-primary:hover,
+.button.button-primary:focus,
+button.button-primary:focus,
+input[type="submit"].button-primary:focus,
+input[type="reset"].button-primary:focus,
+input[type="button"].button-primary:focus {
+ color: #FFF;
+ background-color: #1EAEDB;
+ border-color: #1EAEDB; }
+
+
+/* Forms
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+input[type="email"],
+input[type="number"],
+input[type="search"],
+input[type="text"],
+input[type="tel"],
+input[type="url"],
+input[type="password"],
+textarea,
+select {
+ height: 38px;
+ padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
+ background-color: #fff;
+ border: 1px solid #D1D1D1;
+ border-radius: 4px;
+ box-shadow: none;
+ box-sizing: border-box; }
+/* Removes awkward default styles on some inputs for iOS */
+input[type="email"],
+input[type="number"],
+input[type="search"],
+input[type="text"],
+input[type="tel"],
+input[type="url"],
+input[type="password"],
+textarea {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none; }
+textarea {
+ min-height: 65px;
+ padding-top: 6px;
+ padding-bottom: 6px; }
+input[type="email"]:focus,
+input[type="number"]:focus,
+input[type="search"]:focus,
+input[type="text"]:focus,
+input[type="tel"]:focus,
+input[type="url"]:focus,
+input[type="password"]:focus,
+textarea:focus,
+select:focus {
+ border: 1px solid #33C3F0;
+ outline: 0; }
+label,
+legend {
+ display: block;
+ margin-bottom: .5rem;
+ font-weight: 600; }
+fieldset {
+ padding: 0;
+ border-width: 0; }
+input[type="checkbox"],
+input[type="radio"] {
+ display: inline; }
+label > .label-body {
+ display: inline-block;
+ margin-left: .5rem;
+ font-weight: normal; }
+
+
+/* Lists
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+ul {
+ list-style: circle inside; }
+ol {
+ list-style: decimal inside; }
+ol, ul {
+ padding-left: 0;
+ margin-top: 0; }
+ul ul,
+ul ol,
+ol ol,
+ol ul {
+ margin: 1.5rem 0 1.5rem 3rem;
+ font-size: 90%; }
+li {
+ margin-bottom: 1rem; }
+
+
+/* Code
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+code {
+ padding: .2rem .5rem;
+ margin: 0 .2rem;
+ font-size: 90%;
+ white-space: nowrap;
+ background: #F1F1F1;
+ border: 1px solid #E1E1E1;
+ border-radius: 4px; }
+pre > code {
+ display: block;
+ padding: 1rem 1.5rem;
+ white-space: pre; }
+
+
+/* Tables
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+th,
+td {
+ padding: 12px 15px;
+ text-align: left;
+ border-bottom: 1px solid #E1E1E1; }
+th:first-child,
+td:first-child {
+ padding-left: 0; }
+th:last-child,
+td:last-child {
+ padding-right: 0; }
+
+
+/* Spacing
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+button,
+.button {
+ margin-bottom: 1rem; }
+input,
+textarea,
+select,
+fieldset {
+ margin-bottom: 1.5rem; }
+pre,
+blockquote,
+dl,
+figure,
+table,
+p,
+ul,
+ol,
+form {
+ margin-bottom: 2.5rem; }
+
+
+/* Utilities
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+.u-full-width {
+ width: 100%;
+ box-sizing: border-box; }
+.u-max-full-width {
+ max-width: 100%;
+ box-sizing: border-box; }
+.u-pull-right {
+ float: right; }
+.u-pull-left {
+ float: left; }
+
+
+/* Misc
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+hr {
+ margin-top: 3rem;
+ margin-bottom: 3.5rem;
+ border-width: 0;
+ border-top: 1px solid #E1E1E1; }
+
+
+/* Clearing
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+
+/* Self Clearing Goodness */
+.container:after,
+.row:after,
+.u-cf {
+ content: "";
+ display: table;
+ clear: both; }
+
+
+/* Media Queries
+–––––––––––––––––––––––––––––––––––––––––––––––––– */
+/*
+Note: The best way to structure the use of media queries is to create the queries
+near the relevant code. For example, if you wanted to change the styles for buttons
+on small devices, paste the mobile query code up in the buttons section and style it
+there.
+*/
+
+
+/* Larger than mobile */
+@media (min-width: 400px) {}
+
+/* Larger than phablet (also point when grid becomes active) */
+@media (min-width: 550px) {}
+
+/* Larger than tablet */
+@media (min-width: 750px) {}
+
+/* Larger than desktop */
+@media (min-width: 1000px) {}
+
+/* Larger than Desktop HD */
+@media (min-width: 1200px) {}
<module>org.openhab.binding.warmup</module>
<module>org.openhab.binding.weathercompany</module>
<module>org.openhab.binding.weatherunderground</module>
+ <module>org.openhab.binding.webexteams</module>
<module>org.openhab.binding.webthing</module>
<module>org.openhab.binding.wemo</module>
<module>org.openhab.binding.wifiled</module>