[webexteams] Initial contribution (#13492)

* [webexteams] Initial contribution

Signed-off-by: Tom Deckers <tom@ducbase.com>
This commit is contained in:
Tom Deckers 2022-12-04 12:15:42 +01:00 committed by GitHub
parent e7fcd03d59
commit b696aebb36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2935 additions and 0 deletions

View File

@ -360,6 +360,7 @@
/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

View File

@ -1796,6 +1796,11 @@
<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>

View File

@ -0,0 +1,13 @@
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

View File

@ -0,0 +1,91 @@
# 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.

View File

@ -0,0 +1,17 @@
<?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>

View File

@ -0,0 +1,9 @@
<?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>

View File

@ -0,0 +1,185 @@
/**
* 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;
}
}

View File

@ -0,0 +1,219 @@
/**
* 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();
}
}

View File

@ -0,0 +1,37 @@
/**
* 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);
}
}

View File

@ -0,0 +1,233 @@
/**
* 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;
}
}

View File

@ -0,0 +1,53 @@
/**
* 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'";
}

View File

@ -0,0 +1,39 @@
/**
* 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 = "";
}

View File

@ -0,0 +1,37 @@
/**
* 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);
}
}

View File

@ -0,0 +1,461 @@
/**
* 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) {
}
}

View File

@ -0,0 +1,81 @@
/**
* 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);
}
}
}

View File

@ -0,0 +1,85 @@
/**
* 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;
}
}

View File

@ -0,0 +1,107 @@
/**
* 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;
}
}

View File

@ -0,0 +1,176 @@
/**
* 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;
}
}

View File

@ -0,0 +1,38 @@
/**
* 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);
}
}

View File

@ -0,0 +1,9 @@
<?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>

View File

@ -0,0 +1,49 @@
# 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

View File

@ -0,0 +1,64 @@
<?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>

View File

@ -0,0 +1,8 @@
<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>

View File

@ -0,0 +1,57 @@
<!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>

View File

@ -0,0 +1,15 @@
.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; }

View File

@ -0,0 +1,427 @@
/*! 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;
}

View File

@ -0,0 +1,418 @@
/*
* 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) {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -393,6 +393,7 @@
<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>