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