mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 15:11:59 +01:00
[webexteams] Initial contribution (#13492)
* [webexteams] Initial contribution Signed-off-by: Tom Deckers <tom@ducbase.com>
This commit is contained in:
parent
e7fcd03d59
commit
b696aebb36
@ -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
|
||||
|
@ -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>
|
||||
|
13
bundles/org.openhab.binding.webexteams/NOTICE
Normal file
13
bundles/org.openhab.binding.webexteams/NOTICE
Normal 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
|
91
bundles/org.openhab.binding.webexteams/README.md
Normal file
91
bundles/org.openhab.binding.webexteams/README.md
Normal 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.
|
||||
|
||||
|
17
bundles/org.openhab.binding.webexteams/pom.xml
Normal file
17
bundles/org.openhab.binding.webexteams/pom.xml
Normal 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>
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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'";
|
||||
}
|
@ -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 = "";
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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>
|
@ -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
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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; }
|
427
bundles/org.openhab.binding.webexteams/src/main/resources/web/css/normalize.css
vendored
Normal file
427
bundles/org.openhab.binding.webexteams/src/main/resources/web/css/normalize.css
vendored
Normal 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;
|
||||
}
|
418
bundles/org.openhab.binding.webexteams/src/main/resources/web/css/skeleton.css
vendored
Normal file
418
bundles/org.openhab.binding.webexteams/src/main/resources/web/css/skeleton.css
vendored
Normal 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 |
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user