Files
uopi/internal/controllogic/cron.go
T
Martino Ferrari afefba3184 Add control logic engine, panel logic dialogs, logic-edit restriction
Introduce server-side control-logic flow graphs (cron/alarm triggers,
Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and
user-interaction dialog nodes, and a synthetic node-graph editor.

Add an optional logic-editor allowlist (server.logic_editors) gating who
may add/edit panel logic and control logic, surfaced via /api/v1/me and
enforced in the API; hide logic affordances in the UI accordingly.

Update README, example config, and functional/technical specs to cover
all current features (plot panels, panel/control logic, local variables,
access control) and refresh the in-app manual and contextual help.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-19 07:27:35 +02:00

162 lines
4.1 KiB
Go

// Minimal 5-field cron parser for control-logic cron triggers.
//
// Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports:
//
// * any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
// a,b,c comma-separated list of the above
// N a single value
//
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
// matches when EITHER matches, following Vixie cron semantics.
package controllogic
import (
"fmt"
"strconv"
"strings"
"time"
)
// Schedule is a parsed 5-field cron specification.
type Schedule struct {
minute uint64 // bitmask 0..59
hour uint64 // bitmask 0..23
dom uint64 // bitmask 1..31
month uint64 // bitmask 1..12
dow uint64 // bitmask 0..6
domStar bool // day-of-month field was "*"
dowStar bool // day-of-week field was "*"
}
// ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated.
func ParseSchedule(spec string) (*Schedule, error) {
fields := strings.Fields(spec)
if len(fields) != 5 {
return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields))
}
s := &Schedule{}
var err error
if s.minute, err = parseField(fields[0], 0, 59); err != nil {
return nil, fmt.Errorf("minute: %w", err)
}
if s.hour, err = parseField(fields[1], 0, 23); err != nil {
return nil, fmt.Errorf("hour: %w", err)
}
if s.dom, err = parseField(fields[2], 1, 31); err != nil {
return nil, fmt.Errorf("day-of-month: %w", err)
}
if s.month, err = parseField(fields[3], 1, 12); err != nil {
return nil, fmt.Errorf("month: %w", err)
}
if s.dow, err = parseDOW(fields[4]); err != nil {
return nil, fmt.Errorf("day-of-week: %w", err)
}
s.domStar = strings.TrimSpace(fields[2]) == "*"
s.dowStar = strings.TrimSpace(fields[4]) == "*"
return s, nil
}
// Match reports whether t (truncated to the minute) satisfies the schedule.
func (s *Schedule) Match(t time.Time) bool {
if s.minute&bit(t.Minute()) == 0 {
return false
}
if s.hour&bit(t.Hour()) == 0 {
return false
}
if s.month&bit(int(t.Month())) == 0 {
return false
}
domMatch := s.dom&bit(t.Day()) != 0
dowMatch := s.dow&bit(int(t.Weekday())) != 0
// Vixie semantics: when both day fields are restricted, match either.
switch {
case s.domStar && s.dowStar:
return true
case s.domStar:
return dowMatch
case s.dowStar:
return domMatch
default:
return domMatch || dowMatch
}
}
func bit(n int) uint64 { return uint64(1) << uint(n) }
func parseDOW(field string) (uint64, error) {
// Normalise 7 → 0 (both mean Sunday) by parsing then folding.
mask, err := parseField(field, 0, 7)
if err != nil {
return 0, err
}
if mask&bit(7) != 0 {
mask = (mask &^ bit(7)) | bit(0)
}
return mask, nil
}
func parseField(field string, lo, hi int) (uint64, error) {
var mask uint64
for _, part := range strings.Split(field, ",") {
m, err := parsePart(strings.TrimSpace(part), lo, hi)
if err != nil {
return 0, err
}
mask |= m
}
if mask == 0 {
return 0, fmt.Errorf("empty field %q", field)
}
return mask, nil
}
func parsePart(part string, lo, hi int) (uint64, error) {
if part == "" {
return 0, fmt.Errorf("empty term")
}
step := 1
rangePart := part
if idx := strings.IndexByte(part, '/'); idx >= 0 {
rangePart = part[:idx]
st, err := strconv.Atoi(part[idx+1:])
if err != nil || st < 1 {
return 0, fmt.Errorf("bad step %q", part[idx+1:])
}
step = st
}
start, end := lo, hi
if rangePart != "*" {
if idx := strings.IndexByte(rangePart, '-'); idx >= 0 {
a, err1 := strconv.Atoi(rangePart[:idx])
b, err2 := strconv.Atoi(rangePart[idx+1:])
if err1 != nil || err2 != nil {
return 0, fmt.Errorf("bad range %q", rangePart)
}
start, end = a, b
} else {
v, err := strconv.Atoi(rangePart)
if err != nil {
return 0, fmt.Errorf("bad value %q", rangePart)
}
start, end = v, v
}
}
if start < lo || end > hi || start > end {
return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part)
}
var mask uint64
for v := start; v <= end; v += step {
mask |= bit(v)
}
return mask, nil
}