]> git.basschouten.com Git - openhab-addons.git/blob
f9ef62542e8603371768dce3a65ece95cd94efbc
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.io.openhabcloud.internal;
14
15 import java.io.File;
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.security.SecureRandom;
21 import java.util.Arrays;
22 import java.util.HashSet;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Set;
26
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.openhab.core.OpenHAB;
30 import org.openhab.core.config.core.ConfigurableService;
31 import org.openhab.core.events.Event;
32 import org.openhab.core.events.EventFilter;
33 import org.openhab.core.events.EventPublisher;
34 import org.openhab.core.events.EventSubscriber;
35 import org.openhab.core.id.InstanceUUID;
36 import org.openhab.core.io.net.http.HttpClientFactory;
37 import org.openhab.core.items.Item;
38 import org.openhab.core.items.ItemNotFoundException;
39 import org.openhab.core.items.ItemRegistry;
40 import org.openhab.core.items.events.ItemEventFactory;
41 import org.openhab.core.items.events.ItemStateEvent;
42 import org.openhab.core.library.items.RollershutterItem;
43 import org.openhab.core.library.items.SwitchItem;
44 import org.openhab.core.library.types.OnOffType;
45 import org.openhab.core.library.types.UpDownType;
46 import org.openhab.core.model.script.engine.action.ActionService;
47 import org.openhab.core.net.HttpServiceUtil;
48 import org.openhab.core.types.Command;
49 import org.openhab.core.types.TypeParser;
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;
60
61 /**
62  * This class starts the cloud connection service and implements interface to communicate with the cloud.
63  *
64  * @author Victor Belov - Initial contribution
65  * @author Kai Kreuzer - migrated code to new Jetty client and ESH APIs
66  */
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 {
72
73     protected static final String CONFIG_URI = "io:openhabcloud";
74
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";
83     private static final String CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
84     private static final SecureRandom SR = new SecureRandom();
85
86     private final Logger logger = LoggerFactory.getLogger(CloudService.class);
87
88     public static String clientVersion = null;
89     private CloudClient cloudClient;
90     private String cloudBaseUrl = null;
91     private final HttpClient httpClient;
92     protected final ItemRegistry itemRegistry;
93     protected final EventPublisher eventPublisher;
94
95     private boolean remoteAccessEnabled = true;
96     private Set<String> exposedItems = null;
97     private int localPort;
98
99     @Activate
100     public CloudService(final @Reference HttpClientFactory httpClientFactory,
101             final @Reference ItemRegistry itemRegistry, final @Reference EventPublisher eventPublisher) {
102         this.httpClient = httpClientFactory.createHttpClient(HTTPCLIENT_NAME);
103         this.httpClient.setStopTimeout(0);
104         this.httpClient.setMaxConnectionsPerDestination(DEFAULT_LOCAL_OPENHAB_MAX_CONCURRENT_REQUESTS);
105         this.httpClient.setConnectTimeout(DEFAULT_LOCAL_OPENHAB_REQUEST_TIMEOUT);
106         this.httpClient.setFollowRedirects(false);
107
108         this.itemRegistry = itemRegistry;
109         this.eventPublisher = eventPublisher;
110     }
111
112     /**
113      * This method sends notification message to mobile app through the openHAB Cloud service
114      *
115      * @param userId the {@link String} containing the openHAB Cloud user id to send message to
116      * @param message the {@link String} containing a message to send to specified user id
117      * @param icon the {@link String} containing a name of the icon to be used with this notification
118      * @param severity the {@link String} containing severity (good, info, warning, error) of notification
119      */
120     public void sendNotification(String userId, String message, @Nullable String icon, @Nullable String severity) {
121         logger.debug("Sending message '{}' to user id {}", message, userId);
122         cloudClient.sendNotification(userId, message, icon, severity);
123     }
124
125     /**
126      * Sends an advanced notification to log. Log notifications are not pushed to user
127      * devices but are shown to all account users in notifications log
128      *
129      * @param message the {@link String} containing a message to send to specified user id
130      * @param icon the {@link String} containing a name of the icon to be used with this notification
131      * @param severity the {@link String} containing severity (good, info, warning, error) of notification
132      */
133     public void sendLogNotification(String message, @Nullable String icon, @Nullable String severity) {
134         logger.debug("Sending log message '{}'", message);
135         cloudClient.sendLogNotification(message, icon, severity);
136     }
137
138     /**
139      * Sends a broadcast notification. Broadcast notifications are pushed to all
140      * mobile devices of all users of the account
141      *
142      * @param message the {@link String} containing a message to send to specified user id
143      * @param icon the {@link String} containing a name of the icon to be used with this notification
144      * @param severity the {@link String} containing severity (good, info, warning, error) of notification
145      */
146     public void sendBroadcastNotification(String message, @Nullable String icon, @Nullable String severity) {
147         logger.debug("Sending broadcast message '{}' to all users", message);
148         cloudClient.sendBroadcastNotification(message, icon, severity);
149     }
150
151     private String substringBefore(String str, String separator) {
152         int index = str.indexOf(separator);
153         return index == -1 ? str : str.substring(0, index);
154     }
155
156     @Activate
157     protected void activate(BundleContext context, Map<String, ?> config) {
158         clientVersion = substringBefore(context.getBundle().getVersion().toString(), ".qualifier");
159         localPort = HttpServiceUtil.getHttpServicePort(context);
160         if (localPort == -1) {
161             logger.warn("openHAB Cloud connector not started, since no local HTTP port could be determined");
162         } else {
163             logger.debug("openHAB Cloud connector activated");
164             checkJavaVersion();
165             modified(config);
166         }
167     }
168
169     private void checkJavaVersion() {
170         String version = System.getProperty("java.version");
171         if (version.charAt(2) == '8') {
172             // we are on Java 8, let's check the update
173             String update = version.substring(version.indexOf('_') + 1);
174             try {
175                 Integer uVersion = Integer.valueOf(update);
176                 if (uVersion < 101) {
177                     logger.warn(
178                             "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                             version);
180                 }
181             } catch (NumberFormatException e) {
182                 logger.debug("Could not determine update version of java {}", version);
183             }
184         }
185     }
186
187     @Deactivate
188     protected void deactivate() {
189         logger.debug("openHAB Cloud connector deactivated");
190         cloudClient.shutdown();
191         try {
192             httpClient.stop();
193         } catch (Exception e) {
194             logger.debug("Could not stop Jetty http client", e);
195         }
196     }
197
198     @Modified
199     protected void modified(Map<String, ?> config) {
200         if (config != null && config.get(CFG_MODE) != null) {
201             remoteAccessEnabled = "remote".equals(config.get(CFG_MODE));
202         } else {
203             logger.debug("remoteAccessEnabled is not set, keeping value '{}'", remoteAccessEnabled);
204         }
205
206         if (config.get(CFG_BASE_URL) != null) {
207             cloudBaseUrl = (String) config.get(CFG_BASE_URL);
208         } else {
209             cloudBaseUrl = DEFAULT_URL;
210         }
211
212         exposedItems = new HashSet<>();
213         Object expCfg = config.get(CFG_EXPOSE);
214         if (expCfg instanceof String) {
215             String value = (String) expCfg;
216             while (value.startsWith("[")) {
217                 value = value.substring(1);
218             }
219             while (value.endsWith("]")) {
220                 value = value.substring(0, value.length() - 1);
221             }
222             for (String itemName : Arrays.asList((value).split(","))) {
223                 exposedItems.add(itemName.trim());
224             }
225         } else if (expCfg instanceof Iterable) {
226             for (Object entry : ((Iterable<?>) expCfg)) {
227                 exposedItems.add(entry.toString());
228             }
229         }
230
231         logger.debug("UUID = {}, secret = {}", censored(InstanceUUID.get()), censored(getSecret()));
232
233         if (cloudClient != null) {
234             cloudClient.shutdown();
235         }
236
237         if (!httpClient.isRunning()) {
238             try {
239                 httpClient.start();
240                 // we act as a blind proxy, don't try to auto decode content
241                 httpClient.getContentDecoderFactories().clear();
242             } catch (Exception e) {
243                 logger.error("Could not start Jetty http client", e);
244             }
245         }
246
247         String localBaseUrl = "http://localhost:" + localPort;
248         cloudClient = new CloudClient(httpClient, InstanceUUID.get(), getSecret(), cloudBaseUrl, localBaseUrl,
249                 remoteAccessEnabled, exposedItems);
250         cloudClient.setOpenHABVersion(OpenHAB.getVersion());
251         cloudClient.connect();
252         cloudClient.setListener(this);
253         NotificationAction.cloudService = this;
254     }
255
256     @Override
257     public String getActionClassName() {
258         return NotificationAction.class.getCanonicalName();
259     }
260
261     @Override
262     public Class<?> getActionClass() {
263         return NotificationAction.class;
264     }
265
266     /**
267      * Reads the first line from specified file
268      */
269
270     private String readFirstLine(File file) {
271         List<String> lines = null;
272         try {
273             lines = Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
274         } catch (IOException e) {
275             // no exception handling - we just return the empty String
276         }
277         return lines == null || lines.isEmpty() ? "" : lines.get(0);
278     }
279
280     /**
281      * Writes a String to a specified file
282      */
283
284     private void writeFile(File file, String content) {
285         // create intermediary directories
286         file.getParentFile().mkdirs();
287         try {
288             Files.writeString(file.toPath(), content, StandardCharsets.UTF_8);
289             logger.debug("Created file '{}' with content '{}'", file.getAbsolutePath(), censored(content));
290         } catch (FileNotFoundException e) {
291             logger.error("Couldn't create file '{}'.", file.getPath(), e);
292         } catch (IOException e) {
293             logger.error("Couldn't write to file '{}'.", file.getPath(), e);
294         }
295     }
296
297     private String randomString(int length) {
298         StringBuilder sb = new StringBuilder(length);
299         for (int i = 0; i < length; i++) {
300             sb.append(CHARS.charAt(SR.nextInt(CHARS.length())));
301         }
302         return sb.toString();
303     }
304
305     /**
306      * Creates a random secret and writes it to the <code>userdata/openhabcloud</code>
307      * directory. An existing <code>secret</code> file won't be overwritten.
308      * Returns either existing secret from the file or newly created secret.
309      */
310     private String getSecret() {
311         File file = new File(OpenHAB.getUserDataFolder() + File.separator + SECRET_FILE_NAME);
312         String newSecretString = "";
313
314         if (!file.exists()) {
315             newSecretString = randomString(20);
316             logger.debug("New secret = {}", censored(newSecretString));
317             writeFile(file, newSecretString);
318         } else {
319             newSecretString = readFirstLine(file);
320             logger.debug("Using secret at '{}' with content '{}'", file.getAbsolutePath(), censored(newSecretString));
321         }
322
323         return newSecretString;
324     }
325
326     private static String censored(String secret) {
327         if (secret.length() < 4) {
328             return "*******";
329         }
330         return secret.substring(0, 2) + "..." + secret.substring(secret.length() - 2, secret.length());
331     }
332
333     @Override
334     public void sendCommand(String itemName, String commandString) {
335         try {
336             Item item = itemRegistry.getItem(itemName);
337             Command command = null;
338             if ("toggle".equalsIgnoreCase(commandString)
339                     && (item instanceof SwitchItem || item instanceof RollershutterItem)) {
340                 if (OnOffType.ON.equals(item.getStateAs(OnOffType.class))) {
341                     command = OnOffType.OFF;
342                 }
343                 if (OnOffType.OFF.equals(item.getStateAs(OnOffType.class))) {
344                     command = OnOffType.ON;
345                 }
346                 if (UpDownType.UP.equals(item.getStateAs(UpDownType.class))) {
347                     command = UpDownType.DOWN;
348                 }
349                 if (UpDownType.DOWN.equals(item.getStateAs(UpDownType.class))) {
350                     command = UpDownType.UP;
351                 }
352             } else {
353                 command = TypeParser.parseCommand(item.getAcceptedCommandTypes(), commandString);
354             }
355             if (command != null) {
356                 logger.debug("Received command '{}' for item '{}'", commandString, itemName);
357                 eventPublisher.post(ItemEventFactory.createCommandEvent(itemName, command));
358             } else {
359                 logger.warn("Received invalid command '{}' for item '{}'", commandString, itemName);
360             }
361         } catch (ItemNotFoundException e) {
362             logger.warn("Received command '{}' for a non-existent item '{}'", commandString, itemName);
363         }
364     }
365
366     @Override
367     public Set<String> getSubscribedEventTypes() {
368         return Set.of(ItemStateEvent.TYPE);
369     }
370
371     @Override
372     public EventFilter getEventFilter() {
373         return null;
374     }
375
376     @Override
377     public void receive(Event event) {
378         ItemStateEvent ise = (ItemStateEvent) event;
379         if (supportsUpdates() && exposedItems != null && exposedItems.contains(ise.getItemName())) {
380             cloudClient.sendItemUpdate(ise.getItemName(), ise.getItemState().toString());
381         }
382     }
383
384     private boolean supportsUpdates() {
385         return cloudBaseUrl.indexOf(CFG_BASE_URL) >= 0;
386     }
387 }