]> git.basschouten.com Git - openhab-addons.git/blob
78018cf1d05c44104ea3ba4cda0e024bc72f92b0
[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.hueemulation.internal;
14
15 import java.util.ArrayList;
16 import java.util.List;
17 import java.util.Optional;
18 import java.util.Random;
19 import java.util.regex.Matcher;
20 import java.util.regex.Pattern;
21
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.openhab.core.automation.Action;
25 import org.openhab.core.automation.Trigger;
26 import org.openhab.core.automation.util.ModuleBuilder;
27 import org.openhab.core.config.core.Configuration;
28 import org.openhab.io.hueemulation.internal.dto.HueDataStore;
29 import org.openhab.io.hueemulation.internal.dto.HueGroupEntry;
30 import org.openhab.io.hueemulation.internal.dto.HueLightEntry;
31 import org.openhab.io.hueemulation.internal.dto.changerequest.HueCommand;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
34
35 /**
36  * Rule utility methods. The Hue scheduler and Hue rules support is based on the automation engine.
37  * This class provides methods to convert between Hue entries and automation rules.
38  *
39  * @author David Graeff - Initial contribution
40  */
41 @NonNullByDefault
42 public class RuleUtils {
43     private static final Logger LOGGER = LoggerFactory.getLogger(RuleUtils.class);
44     public static Random random = new Random(); // public for test mock
45
46     /**
47      * Splits the given base time (pattern "hh:mm[:ss]") on the colons and return the resulting array.
48      * If an upper time is given (same pattern), a random number between base and upper time is chosen.
49      *
50      * @param baseTime A base time with the pattern hh:mm[:ss]
51      * @param upperTime An optional upper time or null or an empty string
52      * @return Time components (hour, minute, seconds).
53      */
54     public static String[] computeRandomizedDayTime(String baseTime, @Nullable String upperTime) {
55         if (!baseTime.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
56             throw new IllegalStateException("Time pattern incorrect. Must be 'hh:mm[:ss]'. " + baseTime);
57         }
58
59         String[] randomizedTime = baseTime.split(":");
60
61         if (upperTime != null && !upperTime.isEmpty()) {
62             String[] upperTimeParts = upperTime.split(":");
63             if (!upperTime.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")
64                     || randomizedTime.length != upperTimeParts.length) {
65                 throw new IllegalStateException("Random Time pattern incorrect. Must be 'hh:mm[:ss]'. " + upperTime);
66             }
67             for (int i = 0; i < randomizedTime.length; ++i) {
68                 int n = Integer.parseInt(randomizedTime[i]);
69                 int n2 = Integer.parseInt(upperTimeParts[i]);
70                 int diff = Math.abs(n2 - n); // Example: 12 and 14 -> diff = 2
71                 if (diff > 0) { // diff = rnd [0,3)
72                     diff = random.nextInt(diff + 1);
73                 }
74                 randomizedTime[i] = String.valueOf(n2 > n ? n + diff : n2 + diff);
75             }
76         }
77
78         return randomizedTime;
79     }
80
81     /**
82      * Validates a hue http address used in schedules and hue rules.
83      *
84      * @param ds A hue datastore to verify that referred lights/groups do exist
85      * @param address Relative hue API address. Example: "/api/<username>/groups/1/action" or
86      *            "/api/<username>/lights/1/state"
87      * @throws IllegalStateException Thrown if address is invalid
88      */
89     @SuppressWarnings({ "unused", "null" })
90     public static void validateHueHttpAddress(HueDataStore ds, String address) throws IllegalStateException {
91         String[] validation = address.split("/");
92         if (validation.length < 6 || !validation[0].isEmpty() || !"api".equals(validation[1])) {
93             throw new IllegalStateException("Given address invalid!");
94         }
95         if ("groups".equals(validation[3]) && "action".equals(validation[5])) {
96             HueGroupEntry entry = ds.groups.get(validation[4]);
97             if (entry == null) {
98                 throw new IllegalStateException("Group does not exist: " + validation[4]);
99             }
100         } else if ("lights".equals(validation[3]) && "state".equals(validation[5])) {
101             HueLightEntry entry = ds.lights.get(validation[4]);
102             if (entry == null) {
103                 throw new IllegalStateException("Light does not exist: " + validation[4]);
104             }
105         } else {
106             throw new IllegalStateException("Can only handle groups and lights");
107         }
108     }
109
110     public static class ConfigHttpAction {
111         public String url = "";
112         public String method = "";
113         public String body = "";
114     }
115
116     @SuppressWarnings({ "unused", "null" })
117     public static @Nullable HueCommand httpActionToHueCommand(HueDataStore ds, Action a, @Nullable String ruleName) {
118         ConfigHttpAction config = a.getConfiguration().as(ConfigHttpAction.class);
119
120         // Example: "/api/<username>/groups/1/action" or "/api/<username>/lights/1/state"
121         String[] validation = config.url.split("/");
122         if (validation.length < 6 || !validation[0].isEmpty() || !"api".equals(validation[1])) {
123             LOGGER.warn("Hue Rule '{}': Given address in action {} invalid!", ruleName, a.getLabel());
124             return null;
125         }
126
127         if ("groups".equals(validation[3]) && "action".equals(validation[5])) {
128             HueGroupEntry gentry = ds.groups.get(validation[4]);
129             if (gentry == null) {
130                 LOGGER.warn("Hue Rule '{}': Given address in action {} invalid. Group does not exist: {}", ruleName,
131                         a.getLabel(), validation[4]);
132                 return null;
133             }
134             return new HueCommand(config.url, config.method, config.body);
135         } else if ("lights".equals(validation[3]) && "state".equals(validation[5])) {
136             HueLightEntry lentry = ds.lights.get(validation[4]);
137             if (lentry == null) {
138                 LOGGER.warn("Hue Rule '{}': Given address in action {} invalid. Light does not exist: {}", ruleName,
139                         a.getLabel(), validation[4]);
140                 return null;
141             }
142             return new HueCommand(config.url, config.method, config.body);
143         } else {
144             LOGGER.warn("Hue Rule '{}': Given address in action {} invalid. Can only handle lights and groups, not {}",
145                     ruleName, a.getLabel(), validation[3]);
146             return null;
147         }
148     }
149
150     public static Action createHttpAction(HueCommand command, String id) {
151         final Configuration actionConfig = new Configuration();
152         actionConfig.put("method", command.method);
153         actionConfig.put("url", command.address);
154         actionConfig.put("body", command.body);
155         return ModuleBuilder.createAction().withId(id).withTypeUID("rules.HttpAction").withConfiguration(actionConfig)
156                 .build();
157     }
158
159     // Recurring pattern "W[bbb]/T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]"
160     private static Trigger createRecurringFromTimeString(String localtime) {
161         Pattern timePattern = Pattern.compile("W(.*)/T(.*)A(.*)");
162         Matcher m = timePattern.matcher(localtime);
163         if (!m.matches() || m.groupCount() < 3) {
164             throw new IllegalStateException("Recurring time pattern incorrect");
165         }
166
167         String weekdays = m.group(1);
168         String time = m.group(2);
169         String randomize = m.group(3);
170
171         // Monday = 64, Tuesday = 32, Wednesday = 16, Thursday = 8, Friday = 4, Saturday = 2, Sunday = 1
172         int weekdaysBinaryEncoded = Integer.valueOf(weekdays);
173         // For the last part of the cron expression ("day"):
174         // A comma separated list of days starting with 0=sunday to 7=sunday
175         List<String> cronWeekdays = new ArrayList<>();
176         for (int bin = 64, c = 1; bin > 0; bin /= 2, c += 1) {
177             if (weekdaysBinaryEncoded / bin == 1) {
178                 weekdaysBinaryEncoded = weekdaysBinaryEncoded % bin;
179                 cronWeekdays.add(String.valueOf(c));
180             }
181         }
182         String[] hourMinSec = RuleUtils.computeRandomizedDayTime(time, randomize);
183
184         // Cron expression: min hour day month weekdays
185         String cronExpression = hourMinSec[1] + " " + hourMinSec[0] + " * * " + String.join(",", cronWeekdays);
186
187         final Configuration triggerConfig = new Configuration();
188         triggerConfig.put("cronExpression", cronExpression);
189         return ModuleBuilder.createTrigger().withId("crontrigger").withTypeUID("timer.GenericCronTrigger")
190                 .withConfiguration(triggerConfig).build();
191     }
192
193     // Timer pattern: R[nn]/PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss]
194     private static Trigger createTimerFromTimeString(String localtime) {
195         Pattern timePattern = Pattern.compile("R(.*)/PT(.*)A(.*)");
196         Matcher m = timePattern.matcher(localtime);
197         if (!m.matches() || m.groupCount() < 3) {
198             throw new IllegalStateException("Timer pattern incorrect");
199         }
200
201         String run = m.group(1);
202         String time = m.group(2);
203         String randomize = m.group(3);
204
205         final Configuration triggerConfig = new Configuration();
206         if (!time.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
207             throw new IllegalStateException("Time pattern incorrect. Must be 'hh:mm[:ss]'. " + time);
208         }
209         triggerConfig.put("time", time);
210         if (randomize.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
211             triggerConfig.put("randomizeTime", randomize);
212         }
213         if (!run.isEmpty()) {
214             if (!run.matches("[0-9]{1,2}")) {
215                 throw new IllegalStateException("Run pattern incorrent. Must be a number'. " + run);
216             } else {
217                 triggerConfig.put("repeat", run);
218             }
219         } else { // Infinite
220             triggerConfig.put("repeat", "-1");
221         }
222
223         return ModuleBuilder.createTrigger().withId("timertrigger").withTypeUID("timer.TimerTrigger")
224                 .withConfiguration(triggerConfig).build();
225     }
226
227     // Absolute date/time pattern "[YYYY]:[MM]:[DD]T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]"
228     private static Trigger createAbsoluteDateTimeFromTimeString(String localtime) {
229         Pattern timePattern = Pattern.compile("(.*)T(.*)A(.*)");
230         Matcher m = timePattern.matcher(localtime);
231         if (!m.matches() || m.groupCount() < 3) {
232             throw new IllegalStateException("Absolute date/time pattern incorrect");
233         }
234
235         String date = m.group(1);
236         String time = m.group(2);
237         if (!date.matches("[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}")) {
238             throw new IllegalStateException("Date pattern incorrect. Must be 'yyyy-mm-dd'. " + date);
239         }
240         if (!time.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
241             throw new IllegalStateException("Time pattern incorrect. Must be 'hh:mm[:ss]'. " + time);
242         }
243         final Configuration triggerConfig = new Configuration();
244
245         triggerConfig.put("date", date);
246         triggerConfig.put("time", time);
247
248         time = m.group(3);
249         if (time.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
250             triggerConfig.put("randomizeTime", time);
251         }
252
253         return ModuleBuilder.createTrigger().withId("absolutetrigger").withTypeUID("timer.AbsoluteDateTimeTrigger")
254                 .withConfiguration(triggerConfig).build();
255     }
256
257     /**
258      * Creates a trigger based on the given time string.
259      * According to <a href="https://developers.meethue.com/develop/hue-api/datatypes-and-time-patterns/">the Hue
260      * documentation</a> this can be:
261      * <p>
262      * <ul>
263      * <li>Absolute time [YYYY]-[MM]-[DD]T[hh]:[mm]:[ss] ([date]T[time])
264      * <li>Randomized time [YYYY]:[MM]:[DD]T[hh]:[mm]:[ss]A[hh]:[mm]:[ss] ([date]T[time]A[time])
265      * <li>Recurring times W[bbb]/T[hh]:[mm]:[ss]
266      * <li>Every day of the week given by bbb at given time
267      * <li>Recurring randomized times W[bbb]/T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]
268      * <li>Every weekday given by bbb at given left side time, randomized by right side time. Right side time has to be
269      * smaller than 12 hours
270      * <li>
271      * <ul>
272      * Timers
273      * <li>PT[hh]:[mm]:[ss] Timer, expiring after given time
274      * <li>PT[hh]:[mm]:[ss] Timer, expiring after given time
275      * <li>PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss] Timer with random element
276      * <li>R[nn]/PT[hh]:[mm]:[ss] Recurring timer
277      * <li>R/PT[hh]:[mm]:[ss] Recurring timer
278      * <li>R[nn]/PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss] Recurring timer with random element
279      * </ul>
280      * </ul>
281      *
282      * @param localtimeParameter An absolute time or recurring time or timer pattern
283      * @return A trigger based on {@link org.openhab.io.hueemulation.internal.automation.AbsoluteDateTimeTriggerHandler}
284      *         or {@link org.openhab.io.hueemulation.internal.automation.TimerTriggerHandler}
285      */
286     public static Trigger createTriggerForTimeString(final String localtimeParameter) throws IllegalStateException {
287         String localtime = localtimeParameter;
288         Trigger ruleTrigger;
289
290         // Normalize timer patterns
291         if (localtime.startsWith("PT")) {
292             localtime = "R1/" + localtime;
293         }
294         if (!localtime.contains("A")) {
295             localtime += "A";
296         }
297
298         // Recurring pattern "W[bbb]/T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]"
299         if (localtime.startsWith("W")) {
300             ruleTrigger = createRecurringFromTimeString(localtime);
301         } // Timer pattern: R[nn]/PT[hh]:[mm]:[ss]A[hh]:[mm]:[ss]
302         else if (localtime.startsWith("R")) {
303             ruleTrigger = createTimerFromTimeString(localtime);
304         } // Absolute date/time pattern "[YYYY]:[MM]:[DD]T[hh]:[mm]:[ss]A[hh]:[mm]:[ss]"
305         else {
306             ruleTrigger = createAbsoluteDateTimeFromTimeString(localtime);
307         }
308
309         return ruleTrigger;
310     }
311
312     public static class TimerConfig {
313         public String time = "";
314         public String randomizeTime = "";
315         public @Nullable Integer repeat;
316     }
317
318     public static @Nullable String timeStringFromTrigger(List<Trigger> triggers) {
319         Optional<Trigger> trigger;
320
321         trigger = triggers.stream().filter(p -> "crontrigger".equals(p.getId())).findFirst();
322         if (trigger.isPresent()) {
323             String[] cronParts = ((String) trigger.get().getConfiguration().get("cronExpression")).split(" ");
324             if (cronParts.length != 5) {
325                 LOGGER.warn("Cron trigger has no valid cron expression: {}", String.join(",", cronParts));
326                 return null;
327             }
328             // Monday = 64, Tuesday = 32, Wednesday = 16, Thursday = 8, Friday = 4, Saturday = 2, Sunday = 1
329             int weekdays = 0;
330             String[] cronWeekdays = cronParts[4].split(",");
331             for (String wdayT : cronWeekdays) {
332                 int wday = Integer.parseInt(wdayT);
333                 switch (wday) {
334                     case 0:
335                     case 7:
336                         weekdays += 1;
337                         break;
338                     case 1:
339                         weekdays += 64;
340                         break;
341                     case 2:
342                         weekdays += 32;
343                         break;
344                     case 3:
345                         weekdays += 16;
346                         break;
347                     case 4:
348                         weekdays += 8;
349                         break;
350                     case 5:
351                         weekdays += 4;
352                         break;
353                     case 6:
354                         weekdays += 2;
355                         break;
356                 }
357             }
358             return String.format("W%d/T%s:%s:00", weekdays, cronParts[1], cronParts[0]);
359         }
360
361         trigger = triggers.stream().filter(p -> "timertrigger".equals(p.getId())).findFirst();
362         if (trigger.isPresent()) {
363             TimerConfig c = trigger.get().getConfiguration().as(TimerConfig.class);
364             if (c.repeat == null) {
365                 return String.format(c.randomizeTime.isEmpty() ? "PT%s" : "PT%sA%s", c.time, c.randomizeTime);
366             } else if (c.repeat == -1) {
367                 return String.format(c.randomizeTime.isEmpty() ? "R/PT%s" : "R/PT%sA%s", c.time, c.randomizeTime);
368             } else {
369                 return String.format(c.randomizeTime.isEmpty() ? "R%d/PT%s" : "R%d/PT%sA%s", c.repeat, c.time,
370                         c.randomizeTime);
371             }
372         } else {
373             trigger = triggers.stream().filter(p -> "absolutetrigger".equals(p.getId())).findFirst();
374             if (trigger.isPresent()) {
375                 String date = (String) trigger.get().getConfiguration().get("date");
376                 String time = (String) trigger.get().getConfiguration().get("time");
377                 String randomizeTime = (String) trigger.get().getConfiguration().get("randomizeTime");
378                 return String.format(randomizeTime == null ? "%sT%s" : "%sT%sA%s", date, time, randomizeTime);
379             } else {
380                 LOGGER.warn("No recognised trigger type");
381                 return null;
382             }
383         }
384     }
385 }