Files
DriverVault/API Server/internal/api/chargingtasks_run.go
T
tajniak81andClaude Opus 5 5a4515978f One schedule for every charger, and a clock on the server to keep it
The charger's own cloud schedule is one window inside one box: charge
between these hours, every day, and that is the whole vocabulary. A third
tab on Charging holds a list instead — each line an action, a time, the
days it repeats on and the chargers it acts on — and one list covers the
whole account rather than each charger hiding its own.

The clock is the server's. A schedule that only fires while a tab is open
is a reminder, so a ticker sweeps every enabled task and fires whichever
minute has come. It sends by handing a synthesised request to the same
control endpoint the page's buttons use, so a scheduled command goes
through the same cascade, ownership gate, rate limit and audit trail —
what the owner cannot press by hand, the scheduler cannot send for them.

A task names its chargers, or names none, which means all of them and
keeps meaning that for a charger imported next year. Times are stored as
a wall clock plus the zone they were written in, so 23:00 stays 23:00
wherever the server sits. One action per task: a charging window is the
two tasks that open and close it, which is how it is read back, edited
and switched off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:47:30 +02:00

348 lines
11 KiB
Go

package api
// Firing the scheduler's tasks: the ticker that watches the clock, and the one
// path a task takes whichever set it off.
//
// The scheduler is only worth having if it fires with nobody looking, so the
// clock is watched here rather than in the browser — a schedule that needs the
// page open is a reminder, not a schedule. The ticker sweeps every enabled task
// on the server, of every user, and fires whichever ones' minute has come.
//
// A task acts by sending exactly the control command the Charging page's own
// buttons send: the request is built here and handed to the same handler, so it
// goes through the same cascade, the same ownership gate, the same rate limit
// and the same audit trail. Nothing about a scheduled command is privileged —
// what the owner cannot press by hand, the scheduler cannot send for them.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
_ "time/tzdata" // tasks are timed in the user's own zone; hosts without a zone database are common on Windows
)
// How often the clock is looked at. Tasks are timed to the minute, so a sweep
// twice a minute is enough to land in every minute without the drift a
// once-a-minute ticker accumulates.
const taskTick = 30 * time.Second
// How long one sweep may take. Generous rather than tight: the cloud transport
// alone allows 45 seconds for a single command, and a task can name several
// chargers. A sweep that overran would not overlap the next one — the ticker
// drops a tick nobody is waiting on — but it would abandon chargers halfway
// down a task's list, which is the worst of both.
const taskSweepTimeout = 5 * time.Minute
// taskRunResult is what happened to one charger in one firing.
type taskRunResult struct {
Charger string `json:"charger"` // home charger record id
Name string `json:"name"`
Serial string `json:"serial,omitempty"`
Status string `json:"status,omitempty"` // the charger's own answer ("Accepted", …)
Error string `json:"error,omitempty"`
}
// StartScheduler starts the ticker that fires due charging tasks. It is safe to
// call before PocketBase is reachable: a sweep that cannot read the tasks simply
// finds none and the next one tries again.
//
// One server runs one scheduler. Two API Servers pointed at the same database
// would both sweep and both fire; the deployment is a single server (see the
// README), and the last_run guard below narrows the window rather than closing
// it.
func (s *Server) StartScheduler() {
ctx, cancel := context.WithCancel(context.Background())
s.schedulerStop = cancel
go func() {
t := time.NewTicker(taskTick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.sweepChargingTasks(ctx)
}
}
}()
}
// sweepChargingTasks fires every task whose minute has come.
func (s *Server) sweepChargingTasks(ctx context.Context) {
if !s.pb.Configured() {
return
}
ctx, cancel := context.WithTimeout(ctx, taskSweepTimeout)
defer cancel()
res, err := s.pb.List(ctx, colChargingTasks, url.Values{
"filter": {"enabled=true"},
"perPage": {"500"},
})
if err != nil {
// A database that is not answering is not an error worth a line every
// thirty seconds; the tasks are still there when it comes back.
return
}
var recs []chargingTaskRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
return
}
now := time.Now()
for _, rec := range recs {
if !taskIsDue(rec, now) {
continue
}
who, _, err := s.callerForUser(ctx, rec.Owner)
if err != nil {
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))
}
}
// taskIsDue reports whether a task's minute has come and it has not already been
// fired in it.
//
// 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
// 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
}
loc := time.Local
if rec.Zone != "" {
if l, err := time.LoadLocation(rec.Zone); err == nil {
loc = l
}
}
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
}
// 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.
if last, err := time.Parse(time.RFC3339, rec.LastRun); err == nil {
if !last.Before(local.Truncate(time.Minute)) {
return false
}
}
return true
}
func containsDay(days []int, day int) bool {
for _, d := range days {
if d == day {
return true
}
}
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 {
chargers, err := s.ownerChargers(ctx, rec.Owner)
if err != nil {
return []taskRunResult{{Error: err.Error()}}
}
wanted := rec.Chargers
results := []taskRunResult{}
for _, c := range chargers {
if len(wanted) > 0 && !containsID(wanted, c.ID) {
continue
}
out := taskRunResult{Charger: c.ID, Name: c.Name, Serial: c.Serial}
if c.Serial == "" {
// Every transport addresses a charger by its serial, so one without
// is not a charger anything can be sent to.
out.Error = "this charger has no serial number, so no command can be addressed to it"
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
}
results = append(results, out)
}
if len(results) == 0 {
results = append(results, taskRunResult{Error: "this task names no charger that still exists"})
}
return results
}
// ownerChargers reads one user's imported chargers. The scheduler reads them
// itself rather than trusting the ids on the task: a charger removed from the
// account should stop being acted on, whether or not the task was edited.
func (s *Server) ownerChargers(ctx context.Context, owner string) ([]homeChargerRecord, error) {
res, err := s.pb.List(ctx, colHomeChargers, url.Values{
"filter": {fmt.Sprintf("owner='%s'", owner)},
"perPage": {"200"},
})
if err != nil {
return nil, err
}
var recs []homeChargerRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
return nil, err
}
return recs, nil
}
func containsID(ids []string, id string) bool {
for _, x := range ids {
if x == id {
return true
}
}
return false
}
// sendTaskAction sends one task'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) {
body := map[string]any{}
if rec.Action == "limit" {
body["amps"] = rec.Amps
}
raw, err := json.Marshal(body)
if err != nil {
return "", err
}
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + rec.Action
req, err := http.NewRequestWithContext(
context.WithValue(ctx, ctxCaller, who), http.MethodPost, path, bytes.NewReader(raw))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
// 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)
rw := &captureWriter{header: http.Header{}}
s.handleAnkerControlAction(rw, req)
return rw.controlOutcome()
}
// captureWriter is an http.ResponseWriter that keeps the response in memory,
// so a handler can be called without a connection to write down.
type captureWriter struct {
status int
header http.Header
body bytes.Buffer
}
func (c *captureWriter) Header() http.Header { return c.header }
func (c *captureWriter) WriteHeader(status int) {
if c.status == 0 {
c.status = status
}
}
func (c *captureWriter) Write(p []byte) (int, error) {
if c.status == 0 {
c.status = http.StatusOK
}
return c.body.Write(p)
}
// controlOutcome reads a captured control response as the charger's answer or
// the reason there wasn't one. The endpoint answers in one of two shapes — a
// status on success, an error envelope otherwise — and both are read here rather
// than the status code alone, because "why not" is the half worth keeping.
func (c *captureWriter) controlOutcome() (string, error) {
var out struct {
Status string `json:"status"`
Error string `json:"error"`
}
_ = json.Unmarshal(c.body.Bytes(), &out)
if c.status >= 200 && c.status < 300 {
if out.Status == "" {
return "sent", nil
}
return out.Status, nil
}
if out.Error != "" {
return "", fmt.Errorf("%s", out.Error)
}
return "", fmt.Errorf("the charger could not be reached (HTTP %d)", c.status)
}
// summarizeTaskRun says how a firing went in one line — what the list shows
// beside a task, and what goes in the log.
func summarizeTaskRun(results []taskRunResult) string {
ok, failed := 0, []string{}
for _, r := range results {
if r.Error == "" {
ok++
continue
}
name := r.Name
if name == "" {
name = r.Serial
}
if name != "" {
failed = append(failed, name+": "+r.Error)
} else {
failed = append(failed, r.Error)
}
}
sort.Strings(failed)
switch {
case len(failed) == 0:
return fmt.Sprintf("%d of %d sent", ok, len(results))
case ok == 0 && len(failed) == 1:
return failed[0]
default:
return fmt.Sprintf("%d of %d sent — %s", ok, len(results), strings.Join(failed, "; "))
}
}
// 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) {
payload := map[string]any{
"last_run": time.Now().UTC().Format(time.RFC3339),
"last_result": summarizeTaskRun(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)
}
}