134 lines
2.9 KiB
Go
134 lines
2.9 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
"woke/db"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
)
|
|
|
|
// AlarmEvent is sent when an alarm triggers.
|
|
type AlarmEvent struct {
|
|
Alarm db.Alarm
|
|
}
|
|
|
|
// Scheduler checks alarms every second and fires events when they trigger.
|
|
type Scheduler struct {
|
|
store db.AlarmStore
|
|
events chan AlarmEvent
|
|
stop chan struct{}
|
|
mu sync.Mutex
|
|
parser cron.Parser
|
|
clientMode bool // If true, skip MarkTriggered/ToggleAlarm (server handles it)
|
|
}
|
|
|
|
func New(store db.AlarmStore, clientMode bool) *Scheduler {
|
|
return &Scheduler{
|
|
store: store,
|
|
clientMode: clientMode,
|
|
events: make(chan AlarmEvent, 10),
|
|
stop: make(chan struct{}),
|
|
parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow),
|
|
}
|
|
}
|
|
|
|
// Events returns the channel that receives alarm trigger events.
|
|
func (s *Scheduler) Events() <-chan AlarmEvent {
|
|
return s.events
|
|
}
|
|
|
|
// Start begins the alarm checking loop.
|
|
func (s *Scheduler) Start() {
|
|
go s.loop()
|
|
}
|
|
|
|
// Stop halts the scheduler.
|
|
func (s *Scheduler) Stop() {
|
|
close(s.stop)
|
|
}
|
|
|
|
func (s *Scheduler) loop() {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
// Track which alarms already fired this minute to avoid duplicates
|
|
firedThisMinute := make(map[int]string) // alarm ID -> "HH:MM" when last fired
|
|
|
|
for {
|
|
select {
|
|
case <-s.stop:
|
|
return
|
|
case now := <-ticker.C:
|
|
s.check(now, firedThisMinute)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) check(now time.Time, firedThisMinute map[int]string) {
|
|
alarms, err := s.store.ListAlarms()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
currentMinute := now.Format("15:04")
|
|
|
|
// Clean up old entries from firedThisMinute
|
|
for id, minute := range firedThisMinute {
|
|
if minute != currentMinute {
|
|
delete(firedThisMinute, id)
|
|
}
|
|
}
|
|
|
|
for _, a := range alarms {
|
|
if !a.Enabled {
|
|
continue
|
|
}
|
|
|
|
// Already fired this minute
|
|
if firedThisMinute[a.ID] == currentMinute {
|
|
continue
|
|
}
|
|
|
|
if s.shouldTrigger(a, now) {
|
|
firedThisMinute[a.ID] = currentMinute
|
|
|
|
// In host mode, update the DB; in client mode, server handles this
|
|
if !s.clientMode {
|
|
_ = s.store.MarkTriggered(a.ID)
|
|
// Disable one-shot alarms after triggering
|
|
if a.Trigger == "once" {
|
|
_ = s.store.ToggleAlarm(a.ID)
|
|
}
|
|
}
|
|
|
|
select {
|
|
case s.events <- AlarmEvent{Alarm: a}:
|
|
default:
|
|
// Don't block if channel is full
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) shouldTrigger(a db.Alarm, now time.Time) bool {
|
|
if a.Trigger == "once" {
|
|
// Simple time match: HH:MM
|
|
return now.Format("15:04") == a.Time
|
|
}
|
|
|
|
// Cron expression
|
|
sched, err := s.parser.Parse(a.Trigger)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Check if now falls within the current minute of the cron schedule.
|
|
// We truncate to the minute and check if the next time after (now - 1min) is now.
|
|
truncated := now.Truncate(time.Minute)
|
|
prev := truncated.Add(-time.Minute)
|
|
next := sched.Next(prev)
|
|
|
|
return next.Equal(truncated)
|
|
}
|