2 * Copyright (c) 2010-2023 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.hueemulation.internal;
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;
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;
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.
39 * @author David Graeff - Initial contribution
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
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.
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).
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);
59 String randomizedTime[] = baseTime.split(":");
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);
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);
74 randomizedTime[i] = String.valueOf(n2 > n ? n + diff : n2 + diff);
78 return randomizedTime;
82 * Validates a hue http address used in schedules and hue rules.
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
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() || !validation[1].equals("api")) {
93 throw new IllegalStateException("Given address invalid!");
95 if ("groups".equals(validation[3]) && "action".equals(validation[5])) {
96 HueGroupEntry entry = ds.groups.get(validation[4]);
98 throw new IllegalStateException("Group does not exist: " + validation[4]);
100 } else if ("lights".equals(validation[3]) && "state".equals(validation[5])) {
101 HueLightEntry entry = ds.lights.get(validation[4]);
103 throw new IllegalStateException("Light does not exist: " + validation[4]);
106 throw new IllegalStateException("Can only handle groups and lights");
110 public static class ConfigHttpAction {
111 public String url = "";
112 public String method = "";
113 public String body = "";
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);
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());
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]);
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]);
142 return new HueCommand(config.url, config.method, config.body);
144 LOGGER.warn("Hue Rule '{}': Given address in action {} invalid. Can only handle lights and groups, not {}",
145 ruleName, a.getLabel(), validation[3]);
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)
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");
167 String weekdays = m.group(1);
168 String time = m.group(2);
169 String randomize = m.group(3);
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));
182 String hourMinSec[] = RuleUtils.computeRandomizedDayTime(time, randomize);
184 // Cron expression: min hour day month weekdays
185 String cronExpression = hourMinSec[1] + " " + hourMinSec[0] + " * * " + String.join(",", cronWeekdays);
187 final Configuration triggerConfig = new Configuration();
188 triggerConfig.put("cronExpression", cronExpression);
189 return ModuleBuilder.createTrigger().withId("crontrigger").withTypeUID("timer.GenericCronTrigger")
190 .withConfiguration(triggerConfig).build();
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");
201 String run = m.group(1);
202 String time = m.group(2);
203 String randomize = m.group(3);
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);
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);
213 if (!run.isEmpty()) {
214 if (!run.matches("[0-9]{1,2}")) {
215 throw new IllegalStateException("Run pattern incorrent. Must be a number'. " + run);
217 triggerConfig.put("repeat", run);
220 triggerConfig.put("repeat", "-1");
223 return ModuleBuilder.createTrigger().withId("timertrigger").withTypeUID("timer.TimerTrigger")
224 .withConfiguration(triggerConfig).build();
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");
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);
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);
243 final Configuration triggerConfig = new Configuration();
245 triggerConfig.put("date", date);
246 triggerConfig.put("time", time);
249 if (time.matches("[0-9]{1,2}:[0-9]{1,2}(:[0-9]{1,2})?")) {
250 triggerConfig.put("randomizeTime", time);
253 return ModuleBuilder.createTrigger().withId("absolutetrigger").withTypeUID("timer.AbsoluteDateTimeTrigger")
254 .withConfiguration(triggerConfig).build();
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:
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
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
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}
286 public static Trigger createTriggerForTimeString(final String localtimeParameter) throws IllegalStateException {
287 String localtime = localtimeParameter;
290 // Normalize timer patterns
291 if (localtime.startsWith("PT")) {
292 localtime = "R1/" + localtime;
294 if (!localtime.contains("A")) {
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]"
306 ruleTrigger = createAbsoluteDateTimeFromTimeString(localtime);
312 public static class TimerConfig {
313 public String time = "";
314 public String randomizeTime = "";
315 public @Nullable Integer repeat;
318 public static @Nullable String timeStringFromTrigger(List<Trigger> triggers) {
319 Optional<Trigger> trigger;
321 trigger = triggers.stream().filter(p -> p.getId().equals("crontrigger")).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));
328 // Monday = 64, Tuesday = 32, Wednesday = 16, Thursday = 8, Friday = 4, Saturday = 2, Sunday = 1
330 String[] cronWeekdays = cronParts[4].split(",");
331 for (String wdayT : cronWeekdays) {
332 int wday = Integer.parseInt(wdayT);
358 return String.format("W%d/T%s:%s:00", weekdays, cronParts[1], cronParts[0]);
361 trigger = triggers.stream().filter(p -> p.getId().equals("timertrigger")).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);
369 return String.format(c.randomizeTime.isEmpty() ? "R%d/PT%s" : "R%d/PT%sA%s", c.repeat, c.time,
373 trigger = triggers.stream().filter(p -> p.getId().equals("absolutetrigger")).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);
380 LOGGER.warn("No recognised trigger type");