- engine: 5-field cron parser (*/n, ranges, lists) firing at most once per minute; device-state triggers with edge detection; manual trigger via POST /automation/rules/:id/trigger; actions run through the AI pipeline so rules can call any tool (IoT control, search, ...) - reminders: due-scan scheduler delivering via WebSocket broadcast, optional LLM-polished reminder text with plain-title fallback; Reminder gains Done flag with ListDue/MarkDone - tests: cron matching and validation, once-per-minute dedup, device edge triggering, manual trigger, reminder delivery
94 lines
2.5 KiB
Go
94 lines
2.5 KiB
Go
// Package engine provides the automation rule engine and reminder scheduler.
|
|
// Rules fire actions through the AI pipeline (which can call tools like IoT control).
|
|
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// cronField matches one field of a cron expression.
|
|
type cronField struct{ values map[int]bool }
|
|
|
|
// parseCronField parses "*", "*/n", "a", "a,b,c", "a-b", "a-b/n".
|
|
func parseCronField(s string, min, max int) (cronField, error) {
|
|
f := cronField{values: map[int]bool{}}
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
return f, fmt.Errorf("empty cron field part")
|
|
}
|
|
|
|
step := 1
|
|
base := part
|
|
if i := strings.Index(part, "/"); i >= 0 {
|
|
base = part[:i]
|
|
n, err := strconv.Atoi(part[i+1:])
|
|
if err != nil || n <= 0 {
|
|
return f, fmt.Errorf("invalid step %q", part)
|
|
}
|
|
step = n
|
|
}
|
|
|
|
lo, hi := min, max
|
|
switch {
|
|
case base == "*":
|
|
case strings.Contains(base, "-"):
|
|
segs := strings.SplitN(base, "-", 2)
|
|
a, err1 := strconv.Atoi(segs[0])
|
|
b, err2 := strconv.Atoi(segs[1])
|
|
if err1 != nil || err2 != nil {
|
|
return f, fmt.Errorf("invalid range %q", base)
|
|
}
|
|
lo, hi = a, b
|
|
default:
|
|
n, err := strconv.Atoi(base)
|
|
if err != nil {
|
|
return f, fmt.Errorf("invalid value %q", base)
|
|
}
|
|
lo, hi = n, n
|
|
}
|
|
if lo < min || hi > max || lo > hi {
|
|
return f, fmt.Errorf("value out of range %q", base)
|
|
}
|
|
for v := lo; v <= hi; v += step {
|
|
f.values[v] = true
|
|
}
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// CronSchedule is a parsed 5-field cron expression (min hour dom month dow).
|
|
type CronSchedule struct {
|
|
min, hour, dom, month, dow cronField
|
|
}
|
|
|
|
// ParseCron parses a standard 5-field cron expression.
|
|
func ParseCron(expr string) (*CronSchedule, error) {
|
|
parts := strings.Fields(expr)
|
|
if len(parts) != 5 {
|
|
return nil, fmt.Errorf("cron must have 5 fields, got %d", len(parts))
|
|
}
|
|
bounds := [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}}
|
|
var fields [5]cronField
|
|
for i, p := range parts {
|
|
f, err := parseCronField(p, bounds[i][0], bounds[i][1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("field %d: %w", i+1, err)
|
|
}
|
|
fields[i] = f
|
|
}
|
|
return &CronSchedule{fields[0], fields[1], fields[2], fields[3], fields[4]}, nil
|
|
}
|
|
|
|
// Match reports whether t matches the schedule (minute precision).
|
|
func (c *CronSchedule) Match(t time.Time) bool {
|
|
return c.min.values[t.Minute()] &&
|
|
c.hour.values[t.Hour()] &&
|
|
c.dom.values[t.Day()] &&
|
|
c.month.values[int(t.Month())] &&
|
|
c.dow.values[int(t.Weekday())]
|
|
}
|