2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.io.openhabcloud.internal;
16 import java.io.FileNotFoundException;
17 import java.io.IOException;
18 import java.nio.charset.StandardCharsets;
19 import java.nio.file.Files;
20 import java.util.Arrays;
21 import java.util.HashSet;
22 import java.util.List;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.openhab.core.OpenHAB;
29 import org.openhab.core.config.core.ConfigurableService;
30 import org.openhab.core.events.Event;
31 import org.openhab.core.events.EventFilter;
32 import org.openhab.core.events.EventPublisher;
33 import org.openhab.core.events.EventSubscriber;
34 import org.openhab.core.id.InstanceUUID;
35 import org.openhab.core.io.net.http.HttpClientFactory;
36 import org.openhab.core.items.Item;
37 import org.openhab.core.items.ItemNotFoundException;
38 import org.openhab.core.items.ItemRegistry;
39 import org.openhab.core.items.events.ItemEventFactory;
40 import org.openhab.core.items.events.ItemStateEvent;
41 import org.openhab.core.library.items.RollershutterItem;
42 import org.openhab.core.library.items.SwitchItem;
43 import org.openhab.core.library.types.OnOffType;
44 import org.openhab.core.library.types.UpDownType;
45 import org.openhab.core.model.script.engine.action.ActionService;
46 import org.openhab.core.net.HttpServiceUtil;
47 import org.openhab.core.types.Command;
48 import org.openhab.core.types.TypeParser;
49 import org.openhab.core.util.StringUtils;
50 import org.openhab.io.openhabcloud.NotificationAction;
51 import org.osgi.framework.BundleContext;
52 import org.osgi.framework.Constants;
53 import org.osgi.service.component.annotations.Activate;
54 import org.osgi.service.component.annotations.Component;
55 import org.osgi.service.component.annotations.Deactivate;
56 import org.osgi.service.component.annotations.Modified;
57 import org.osgi.service.component.annotations.Reference;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
62 * This class starts the cloud connection service and implements interface to communicate with the cloud.
64 * @author Victor Belov - Initial contribution
65 * @author Kai Kreuzer - migrated code to new Jetty client and ESH APIs
67 @Component(service = { CloudService.class, EventSubscriber.class,
68 ActionService.class }, configurationPid = "org.openhab.openhabcloud", property = Constants.SERVICE_PID
69 + "=org.openhab.openhabcloud")
70 @ConfigurableService(category = "io", label = "openHAB Cloud", description_uri = CloudService.CONFIG_URI)
71 public class CloudService implements ActionService, CloudClientListener, EventSubscriber {
73 protected static final String CONFIG_URI = "io:openhabcloud";
75 private static final String CFG_EXPOSE = "expose";
76 private static final String CFG_BASE_URL = "baseURL";
77 private static final String CFG_MODE = "mode";
78 private static final String SECRET_FILE_NAME = "openhabcloud" + File.separator + "secret";
79 private static final String DEFAULT_URL = "https://myopenhab.org/";
80 private static final int DEFAULT_LOCAL_OPENHAB_MAX_CONCURRENT_REQUESTS = 200;
81 private static final int DEFAULT_LOCAL_OPENHAB_REQUEST_TIMEOUT = 30000;
82 private static final String HTTPCLIENT_NAME = "openhabcloud";
84 private final Logger logger = LoggerFactory.getLogger(CloudService.class);
86 public static String clientVersion = null;
87 private CloudClient cloudClient;
88 private String cloudBaseUrl = null;
89 private final HttpClient httpClient;
90 protected final ItemRegistry itemRegistry;
91 protected final EventPublisher eventPublisher;
93 private boolean remoteAccessEnabled = true;
94 private Set<String> exposedItems = null;
95 private int localPort;
98 public CloudService(final @Reference HttpClientFactory httpClientFactory,
99 final @Reference ItemRegistry itemRegistry, final @Reference EventPublisher eventPublisher) {
100 this.httpClient = httpClientFactory.createHttpClient(HTTPCLIENT_NAME);
101 this.httpClient.setStopTimeout(0);
102 this.httpClient.setMaxConnectionsPerDestination(DEFAULT_LOCAL_OPENHAB_MAX_CONCURRENT_REQUESTS);
103 this.httpClient.setConnectTimeout(DEFAULT_LOCAL_OPENHAB_REQUEST_TIMEOUT);
104 this.httpClient.setFollowRedirects(false);
106 this.itemRegistry = itemRegistry;
107 this.eventPublisher = eventPublisher;
111 * This method sends notification message to mobile app through the openHAB Cloud service
113 * @param userId the {@link String} containing the openHAB Cloud user id to send message to
114 * @param message the {@link String} containing a message to send to specified user id
115 * @param icon the {@link String} containing a name of the icon to be used with this notification
116 * @param severity the {@link String} containing severity (good, info, warning, error) of notification
118 public void sendNotification(String userId, String message, @Nullable String icon, @Nullable String severity) {
119 logger.debug("Sending message '{}' to user id {}", message, userId);
120 cloudClient.sendNotification(userId, message, icon, severity);
124 * Sends an advanced notification to log. Log notifications are not pushed to user
125 * devices but are shown to all account users in notifications log
127 * @param message the {@link String} containing a message to send to specified user id
128 * @param icon the {@link String} containing a name of the icon to be used with this notification
129 * @param severity the {@link String} containing severity (good, info, warning, error) of notification
131 public void sendLogNotification(String message, @Nullable String icon, @Nullable String severity) {
132 logger.debug("Sending log message '{}'", message);
133 cloudClient.sendLogNotification(message, icon, severity);
137 * Sends a broadcast notification. Broadcast notifications are pushed to all
138 * mobile devices of all users of the account
140 * @param message the {@link String} containing a message to send to specified user id
141 * @param icon the {@link String} containing a name of the icon to be used with this notification
142 * @param severity the {@link String} containing severity (good, info, warning, error) of notification
144 public void sendBroadcastNotification(String message, @Nullable String icon, @Nullable String severity) {
145 logger.debug("Sending broadcast message '{}' to all users", message);
146 cloudClient.sendBroadcastNotification(message, icon, severity);
149 private String substringBefore(String str, String separator) {
150 int index = str.indexOf(separator);
151 return index == -1 ? str : str.substring(0, index);
155 protected void activate(BundleContext context, Map<String, ?> config) {
156 clientVersion = substringBefore(context.getBundle().getVersion().toString(), ".qualifier");
157 localPort = HttpServiceUtil.getHttpServicePort(context);
158 if (localPort == -1) {
159 logger.warn("openHAB Cloud connector not started, since no local HTTP port could be determined");
161 logger.debug("openHAB Cloud connector activated");
167 private void checkJavaVersion() {
168 String version = System.getProperty("java.version");
169 if (version.charAt(2) == '8') {
170 // we are on Java 8, let's check the update
171 String update = version.substring(version.indexOf('_') + 1);
173 Integer uVersion = Integer.valueOf(update);
174 if (uVersion < 101) {
176 "You are running Java {} - the openhab Cloud connection requires at least Java 1.8.0_101, if your cloud server uses Let's Encrypt certificates!",
179 } catch (NumberFormatException e) {
180 logger.debug("Could not determine update version of java {}", version);
186 protected void deactivate() {
187 logger.debug("openHAB Cloud connector deactivated");
188 cloudClient.shutdown();
191 } catch (Exception e) {
192 logger.debug("Could not stop Jetty http client", e);
197 protected void modified(Map<String, ?> config) {
198 if (config != null && config.get(CFG_MODE) != null) {
199 remoteAccessEnabled = "remote".equals(config.get(CFG_MODE));
201 logger.debug("remoteAccessEnabled is not set, keeping value '{}'", remoteAccessEnabled);
204 if (config.get(CFG_BASE_URL) != null) {
205 cloudBaseUrl = (String) config.get(CFG_BASE_URL);
207 cloudBaseUrl = DEFAULT_URL;
210 exposedItems = new HashSet<>();
211 Object expCfg = config.get(CFG_EXPOSE);
212 if (expCfg instanceof String value) {
213 while (value.startsWith("[")) {
214 value = value.substring(1);
216 while (value.endsWith("]")) {
217 value = value.substring(0, value.length() - 1);
219 for (String itemName : Arrays.asList((value).split(","))) {
220 exposedItems.add(itemName.trim());
222 } else if (expCfg instanceof Iterable iterable) {
223 for (Object entry : iterable) {
224 exposedItems.add(entry.toString());
228 logger.debug("UUID = {}, secret = {}", censored(InstanceUUID.get()), censored(getSecret()));
230 if (cloudClient != null) {
231 cloudClient.shutdown();
234 if (!httpClient.isRunning()) {
237 // we act as a blind proxy, don't try to auto decode content
238 httpClient.getContentDecoderFactories().clear();
239 } catch (Exception e) {
240 logger.error("Could not start Jetty http client", e);
244 String localBaseUrl = "http://localhost:" + localPort;
245 cloudClient = new CloudClient(httpClient, InstanceUUID.get(), getSecret(), cloudBaseUrl, localBaseUrl,
246 remoteAccessEnabled, exposedItems);
247 cloudClient.connect();
248 cloudClient.setListener(this);
249 NotificationAction.cloudService = this;
253 public String getActionClassName() {
254 return NotificationAction.class.getCanonicalName();
258 public Class<?> getActionClass() {
259 return NotificationAction.class;
263 * Reads the first line from specified file
266 private String readFirstLine(File file) {
267 List<String> lines = null;
269 lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
270 } catch (IOException e) {
271 // no exception handling - we just return the empty String
273 return lines == null || lines.isEmpty() ? "" : lines.get(0);
277 * Writes a String to a specified file
280 private void writeFile(File file, String content) {
281 // create intermediary directories
282 file.getParentFile().mkdirs();
284 Files.writeString(file.toPath(), content, StandardCharsets.UTF_8);
285 logger.debug("Created file '{}' with content '{}'", file.getAbsolutePath(), censored(content));
286 } catch (FileNotFoundException e) {
287 logger.error("Couldn't create file '{}'.", file.getPath(), e);
288 } catch (IOException e) {
289 logger.error("Couldn't write to file '{}'.", file.getPath(), e);
294 * Creates a random secret and writes it to the <code>userdata/openhabcloud</code>
295 * directory. An existing <code>secret</code> file won't be overwritten.
296 * Returns either existing secret from the file or newly created secret.
298 private String getSecret() {
299 File file = new File(OpenHAB.getUserDataFolder() + File.separator + SECRET_FILE_NAME);
300 String newSecretString = "";
302 if (!file.exists()) {
303 newSecretString = StringUtils.getRandomAlphanumeric(20);
304 logger.debug("New secret = {}", censored(newSecretString));
305 writeFile(file, newSecretString);
307 newSecretString = readFirstLine(file);
308 logger.debug("Using secret at '{}' with content '{}'", file.getAbsolutePath(), censored(newSecretString));
311 return newSecretString;
314 private static String censored(String secret) {
315 if (secret.length() < 4) {
318 return secret.substring(0, 2) + "..." + secret.substring(secret.length() - 2, secret.length());
322 public void sendCommand(String itemName, String commandString) {
324 Item item = itemRegistry.getItem(itemName);
325 Command command = null;
326 if ("toggle".equalsIgnoreCase(commandString)
327 && (item instanceof SwitchItem || item instanceof RollershutterItem)) {
328 if (OnOffType.ON.equals(item.getStateAs(OnOffType.class))) {
329 command = OnOffType.OFF;
331 if (OnOffType.OFF.equals(item.getStateAs(OnOffType.class))) {
332 command = OnOffType.ON;
334 if (UpDownType.UP.equals(item.getStateAs(UpDownType.class))) {
335 command = UpDownType.DOWN;
337 if (UpDownType.DOWN.equals(item.getStateAs(UpDownType.class))) {
338 command = UpDownType.UP;
341 command = TypeParser.parseCommand(item.getAcceptedCommandTypes(), commandString);
343 if (command != null) {
344 logger.debug("Received command '{}' for item '{}'", commandString, itemName);
345 eventPublisher.post(ItemEventFactory.createCommandEvent(itemName, command));
347 logger.warn("Received invalid command '{}' for item '{}'", commandString, itemName);
349 } catch (ItemNotFoundException e) {
350 logger.warn("Received command '{}' for a non-existent item '{}'", commandString, itemName);
355 public Set<String> getSubscribedEventTypes() {
356 return Set.of(ItemStateEvent.TYPE);
360 public EventFilter getEventFilter() {
365 public void receive(Event event) {
366 ItemStateEvent ise = (ItemStateEvent) event;
367 if (supportsUpdates() && exposedItems != null && exposedItems.contains(ise.getItemName())) {
368 cloudClient.sendItemUpdate(ise.getItemName(), ise.getItemState().toString());
372 private boolean supportsUpdates() {
373 return cloudBaseUrl.contains(CFG_BASE_URL);