Gemini First Time Right Quartz Cron Parser 😮

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:

  1. Incremental Fast-Forwarding Calendar: Instead of calculating complex date math or regex matrices, the script uses a java.util.Calendar loop to step through values.
  2. 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.
  3. Quartz Feature Mapping: The helper function maps standard configurations:
  • Wildcards (*, ?): Auto-approves the step.
  • Increments (/): Processes steps like 0/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 MON or JAN with 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).