Based on @dbowles1975 post today, I thought it could be handy to have a driver device that could spit out the next schedule date/time string given a Quartz Cron String.
So I asked Gemini to write one. Once it was clear what I wanted, the code it gave me worked right out of the box, to my surprise! It did take three total iterations for it to be clear what I was looking for, but I didn't test any code, I was just clarifying what should have just been more detail written into my initial prompt.
So this is working for me, I haven't worked too hard to break it, though.
I realized that I have a use for it in my scheduling apps now, so I will probably make it a child device to the app and have it just return the next schedule date-time to the parent. Or just add the methods to my app.
metadata {
definition (name: "Pure Groovy Cron Parser", namespace: "utility", author: "Utility") {
capability "Actuator"
capability "Sensor"
command "setCronString", [[name: "Cron String*", type: "STRING", description: "Quartz Cron format (e.g., 0 0/15 * * * ?)"]]
attribute "nextRunTime", "String"
}
}
def setCronString(String cronString) {
if (!cronString) return
try {
// Step 1: Clean up and split the Cron string into Quartz components
def parts = cronString.trim().split(/\s+/)
if (parts.size() < 6 || parts.size() > 7) {
throw new Exception("Quartz cron must have 6 or 7 fields.")
}
// Map individual fields
def cronSec = parts[0]
def cronMin = parts[1]
def cronHour = parts[2]
def cronDom = parts[3] // Day of Month
def cronMonth = parts[4]
def cronDow = parts[5] // Day of Week
def cronYear = parts.size() == 7 ? parts[6] : "*"
// Step 2: Grab the current time using the hub's local timezone
Calendar cal = Calendar.getInstance(location.timeZone)
cal.set(Calendar.MILLISECOND, 0)
// Move ahead 1 second so we look for a strictly *future* execution time
cal.add(Calendar.SECOND, 1)
// Step 3: Brute-force calendar evaluation loop (Max limit 5 years out)
long endTimeout = cal.timeInMillis + (5L * 365 * 24 * 60 * 60 * 1000)
boolean matchFound = false
while (cal.timeInMillis < endTimeout) {
// Check Year
if (!matchField(cronYear, cal.get(Calendar.YEAR))) {
cal.add(Calendar.YEAR, 1)
cal.set(Calendar.MONTH, 0)
cal.set(Calendar.DAY_OF_MONTH, 1)
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
continue
}
// Check Month (Calendar.MONTH is 0-indexed, Quartz is 1-indexed)
if (!matchField(cronMonth, cal.get(Calendar.MONTH) + 1, ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"])) {
cal.add(Calendar.MONTH, 1)
cal.set(Calendar.DAY_OF_MONTH, 1)
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
continue
}
// Check Day of Month and Day of Week rules
int dayOfMonth = cal.get(Calendar.DAY_OF_MONTH)
int dayOfWeek = cal.get(Calendar.DAY_OF_WEEK) // 1=Sunday, 7=Saturday
// Handle Quartz specific '?' vs '*' exclusions
boolean domCheck = (cronDom == "?") || matchField(cronDom, dayOfMonth)
boolean dowCheck = (cronDow == "?") || matchField(cronDow, dayOfWeek, ["SUN","MON","TUE","WED","THU","FRI","SAT"])
if (!domCheck || !dowCheck) {
cal.add(Calendar.DAY_OF_MONTH, 1)
cal.set(Calendar.HOUR_OF_DAY, 0)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
continue
}
// Check Hour
if (!matchField(cronHour, cal.get(Calendar.HOUR_OF_DAY))) {
cal.add(Calendar.HOUR_OF_DAY, 1)
cal.set(Calendar.MINUTE, 0)
cal.set(Calendar.SECOND, 0)
continue
}
// Check Minute
if (!matchField(cronMin, cal.get(Calendar.MINUTE))) {
cal.add(Calendar.MINUTE, 1)
cal.set(Calendar.SECOND, 0)
continue
}
// Check Second
if (!matchField(cronSec, cal.get(Calendar.SECOND))) {
cal.add(Calendar.SECOND, 1)
continue
}
// If it rolls all the way through, we found a perfect time match!
matchFound = true
break
}
if (matchFound) {
def formattedDate = cal.time.format("yyyy-MM-dd HH:mm:ss z", location.timeZone)
sendEvent(name: "nextRunTime", value: formattedDate)
log.info "Parsed successfully! Next Run Time: ${formattedDate}"
} else {
sendEvent(name: "nextRunTime", value: "No match within 5 years")
}
} catch (Exception e) {
log.error "Cron parsing failed: ${e.message}"
sendEvent(name: "nextRunTime", value: "Invalid/Unsupported Expression")
}
}
// Helper method to evaluate structural patterns (*, increments, ranges, lists, names)
boolean matchField(String expression, int value, List aliases = []) {
String exp = expression.toUpperCase().trim()
if (exp == "*" || exp == "?") return true
// Convert named components (like "MON" or "JAN") to their integer values
aliases.eachWithIndex { name, index ->
exp = exp.replace(name, "${index + 1}")
}
// Handle comma-separated lists (e.g., "1,3,5")
if (exp.contains(",")) {
return exp.split(",").any { matchField(it, value, aliases) }
}
// Handle increments (e.g., "0/15" or "*/5")
if (exp.contains("/")) {
def parts = exp.split("/")
int start = parts[0] == "*" ? 0 : parts[0].toInteger()
int step = parts[1].toInteger()
return (value >= start) && ((value - start) % step == 0)
}
// Handle ranges (e.g., "10-15")
if (exp.contains("-")) {
def parts = exp.split("-")
int rangeStart = parts[0].toInteger()
int rangeEnd = parts[1].toInteger()
return (value >= rangeStart && value <= rangeEnd)
}
// Handle a single specific number string
if (exp.isInteger()) {
return exp.toInteger() == value
}
return false
}
How the Pure Groovy Parsing Logic Works:
- Incremental Fast-Forwarding Calendar: Instead of calculating complex date math or regex matrices, the script uses a
java.util.Calendarloop to step through values. - Cascading Continuation: To keep execution highly efficient for the hub's processor, if a check fails at a high-level tier (like the target Month or Day), the code fast-forwards the calendar to the beginning of the next valid structural threshold and skips individual minute/second increments entirely.
- Quartz Feature Mapping: The helper function maps standard configurations:
- Wildcards (
*,?): Auto-approves the step. - Increments (
/): Processes steps like0/15(every 15 minutes). - Ranges (
-): Bounds checks integers between specified zones. - Lists (
,): Splits variations and looks for a singular matches. - Text Substitutions: Replaces elements like
MONorJANwith numerical equivalents to align with standard configurations. [1, 2]
(Note: Advanced Quartz features like L for last day of month or # for the nth weekday of a month are excluded to prevent execution timeouts, but standard time structures are fully covered).