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:
co-authored by
Claude Opus 5
parent
0f48093d1a
commit
2b4f4f034d
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user