Files
Cyrene_v2/backend/services/gateway/engine/cron.go
T
Aska d80710d85a feat(gateway): automation rule engine and reminder scheduler
- 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
2026-08-21 12:54:11 +08:00

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())]
}