A task holds the whole night, not one end of it

One command per task was the wrong unit. A charging window is two commands
and reads as one intention, so it was two rows that had to be named twice,
switched off twice, and kept in step by hand — and there was nowhere to put
the third thing, the ease down to 10 A once the house is asleep.

So a task holds a flow. Steps are rows in the editor: an action, a time, and
the ceiling under the one action that takes one. The chargers and the days
belong to the task, because they are the same for every step of a night, and
the switch governs all of it.

The steps keep the order they were written rather than being sorted by the
clock. A night crosses midnight, and clock order files "start at 23:00" last,
behind the stop that closes it — which is not the flow anybody described.
Nothing about firing depends on the order: every step is timed on its own,
and the sweep asks each one whether its minute has come.

Run now moved onto the step. A flow is not a thing that can happen at once —
firing a start and the stop that closes it back to back would leave the
charger where it began and prove nothing — so the button fires the one line
it sits on, and the outcome names the step by its time.

The stored shape changes with it: action/amps/time give way to a steps list.
The collection was a day old and empty, so this replaces them outright rather
than carrying a compatibility path for a schema nothing has run on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 23:32:07 +02:00
co-authored by Claude Opus 5
parent 0f48093d1a
commit 2b4f4f034d
13 changed files with 553 additions and 357 deletions
+77 -45
View File
@@ -26,6 +26,8 @@ import (
"strings"
"time"
_ "time/tzdata" // tasks are timed in the user's own zone; hosts without a zone database are common on Windows
"drivervault/apiserver/internal/models"
)
// How often the clock is looked at. Tasks are timed to the minute, so a sweep
@@ -97,7 +99,8 @@ func (s *Server) sweepChargingTasks(ctx context.Context) {
}
now := time.Now()
for _, rec := range recs {
if !taskIsDue(rec, now) {
due := dueSteps(rec, now)
if len(due) == 0 {
continue
}
who, _, err := s.callerForUser(ctx, rec.Owner)
@@ -105,29 +108,31 @@ func (s *Server) sweepChargingTasks(ctx context.Context) {
log.Printf("scheduler: task %s (%s): owner unavailable: %v", rec.ID, rec.Name, err)
continue
}
results := s.fireChargingTask(ctx, who, rec)
s.recordTaskRun(ctx, rec, results)
log.Printf("scheduler: task %s (%s) fired: %s", rec.ID, rec.Name, summarizeTaskRun(results))
// Two steps of one task timed to the same minute contradict each other,
// but nothing stops somebody writing them, so both are sent in the order
// the flow holds them rather than one silently winning.
for _, step := range due {
results := s.fireChargingSteps(ctx, who, rec, []models.ChargingStep{step})
s.recordTaskRun(ctx, rec, step, results)
log.Printf("scheduler: task %s (%s) step %s %s: %s",
rec.ID, rec.Name, step.Time, step.Action, summarizeTaskRun(results))
}
}
}
// taskIsDue reports whether a task's minute has come and it has not already been
// fired in it.
// dueSteps returns the steps of a task whose minute has come — usually one, and
// none at all for the rest of the day.
//
// The time is read in the task's own zone: the browser that wrote it said which,
// and the server's clock is not the one the user set 23:00 by. A zone the host
// has no database for falls back to the server's own rather than silently
// The times are read in the task's own zone: the browser that wrote it said
// which, and the server's clock is not the one the user set 23:00 by. A zone the
// host has no database for falls back to the server's own rather than silently
// shifting the schedule to UTC.
//
// A minute that passed while the server was down is not caught up afterwards. A
// charging window that opened an hour ago is not a window anyone still wants
// opened, and firing a backlog on boot would be the surprising half of the
// choice.
func taskIsDue(rec chargingTaskRecord, now time.Time) bool {
h, m, ok := parseHHMM(rec.Time)
if !ok {
return false
}
func dueSteps(rec chargingTaskRecord, now time.Time) []models.ChargingStep {
loc := time.Local
if rec.Zone != "" {
if l, err := time.LoadLocation(rec.Zone); err == nil {
@@ -135,20 +140,29 @@ func taskIsDue(rec chargingTaskRecord, now time.Time) bool {
}
}
local := now.In(loc)
if local.Hour() != h || local.Minute() != m {
return false
}
if len(rec.Days) > 0 && !containsDay(rec.Days, int(local.Weekday())) {
return false
return nil
}
// The guard against firing twice: the sweep runs more often than once a
// minute, so a task that has already run inside this minute is done.
// minute, so a task that has already run inside this minute is done. It is
// per task rather than per step because two steps never share a minute in
// any flow that means anything — and when they do, they are sent together.
if last, err := time.Parse(time.RFC3339, rec.LastRun); err == nil {
if !last.Before(local.Truncate(time.Minute)) {
return false
return nil
}
}
return true
var due []models.ChargingStep
for _, step := range rec.Steps {
h, m, ok := parseHHMM(step.Time)
if !ok {
continue // a step whose time is not a time of day fires nothing
}
if local.Hour() == h && local.Minute() == m {
due = append(due, step)
}
}
return due
}
func containsDay(days []int, day int) bool {
@@ -160,11 +174,12 @@ func containsDay(days []int, day int) bool {
return false
}
// fireChargingTask sends the task's command to each of its chargers and reports
// what each one said. A task naming no chargers acts on every charger its owner
// has — "all of them" is a standing wish, so a charger imported after the task
// was written is covered by it too.
func (s *Server) fireChargingTask(ctx context.Context, who *callerIdentity, rec chargingTaskRecord) []taskRunResult {
// fireChargingSteps sends the given steps to each of the task's chargers and
// reports what each one said. A task naming no chargers acts on every charger
// its owner has — "all of them" is a standing wish, so a charger imported after
// the task was written is covered by it too.
func (s *Server) fireChargingSteps(ctx context.Context, who *callerIdentity,
rec chargingTaskRecord, steps []models.ChargingStep) []taskRunResult {
chargers, err := s.ownerChargers(ctx, rec.Owner)
if err != nil {
return []taskRunResult{{Error: err.Error()}}
@@ -183,13 +198,16 @@ func (s *Server) fireChargingTask(ctx context.Context, who *callerIdentity, rec
results = append(results, out)
continue
}
status, err := s.sendTaskAction(ctx, who, c.Serial, rec)
if err != nil {
out.Error = err.Error()
} else {
out.Status = status
for _, step := range steps {
one := out
status, err := s.sendStep(ctx, who, c.Serial, step)
if err != nil {
one.Error = err.Error()
} else {
one.Status = status
}
results = append(results, one)
}
results = append(results, out)
}
if len(results) == 0 {
results = append(results, taskRunResult{Error: "this task names no charger that still exists"})
@@ -224,23 +242,23 @@ func containsID(ids []string, id string) bool {
return false
}
// sendTaskAction sends one task's command to one charger, through the control
// endpoint the page's own buttons use.
// sendStep sends one step's command to one charger, through the control endpoint
// the page's own buttons use.
//
// The request is synthesised rather than the transports being called directly,
// so a scheduled command cannot end up on a different footing from a pressed
// one: the cascade, the ownership gate, the rate limit and the audit line are
// the endpoint's, and there is no second copy of them here to drift.
func (s *Server) sendTaskAction(ctx context.Context, who *callerIdentity, serial string, rec chargingTaskRecord) (string, error) {
func (s *Server) sendStep(ctx context.Context, who *callerIdentity, serial string, step models.ChargingStep) (string, error) {
body := map[string]any{}
if rec.Action == "limit" {
body["amps"] = rec.Amps
if step.Action == "limit" {
body["amps"] = step.Amps
}
raw, err := json.Marshal(body)
if err != nil {
return "", err
}
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + rec.Action
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + step.Action
req, err := http.NewRequestWithContext(
context.WithValue(ctx, ctxCaller, who), http.MethodPost, path, bytes.NewReader(raw))
if err != nil {
@@ -250,7 +268,7 @@ func (s *Server) sendTaskAction(ctx context.Context, who *callerIdentity, serial
// The endpoint reads its charger and its verb from the route, which nothing
// matched here — this request never went through a mux.
req.SetPathValue("sn", serial)
req.SetPathValue("action", rec.Action)
req.SetPathValue("action", step.Action)
rw := &captureWriter{header: http.Header{}}
s.handleAnkerControlAction(rw, req)
@@ -332,14 +350,28 @@ func summarizeTaskRun(results []taskRunResult) string {
}
}
// recordTaskRun stamps a task with when it last fired and how it went. The stamp
// is also the guard that keeps a task from firing twice inside its minute, so a
// firing that could not be written down is worth a log line: without it the next
// sweep would send the command again.
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord, results []taskRunResult) {
// taskRunLine is the outcome of one firing as the list shows it: which step, and
// how it went. A task holds several steps now, and "2 of 2 sent" says nothing
// about which of them it was.
//
// The step is named by its time rather than its action, because everything this
// server writes into last_result is in its own words — charger names, the
// control gate's refusals — while the page has a translation for every action.
// A clock time reads the same in all three languages.
func taskRunLine(step models.ChargingStep, results []taskRunResult) string {
return step.Time + " — " + summarizeTaskRun(results)
}
// recordTaskRun stamps a task with when it last fired and how it went.
//
// The stamp is also the guard that keeps a task from firing twice inside its
// minute, so a firing that could not be written down is worth a log line:
// without it the next sweep would send the command again.
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord,
step models.ChargingStep, results []taskRunResult) {
payload := map[string]any{
"last_run": time.Now().UTC().Format(time.RFC3339),
"last_result": summarizeTaskRun(results),
"last_result": taskRunLine(step, results),
}
if err := s.pb.Update(ctx, colChargingTasks, rec.ID, payload, nil); err != nil {
log.Printf("scheduler: task %s fired but its outcome could not be saved: %v", rec.ID, err)