Files
DriverVault/API Server/internal/api/chargingtasks.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

399 lines
12 KiB
Go

package api
// The home-charger scheduler: the user's own list of charging tasks.
//
// The charger's own cloud schedule can say one thing — "charge between these
// hours" — and it says it inside one charger. This is a list: as many tasks as
// the owner likes, each with its own action, time, days and set of chargers, and
// one list covering every charger on the account rather than a schedule hidden
// inside each box.
//
// GET /api/charging-tasks — the caller's tasks, earliest first
// POST /api/charging-tasks — write one
// PATCH /api/charging-tasks/{id} — edit one (any subset of its fields)
// DELETE /api/charging-tasks/{id} — forget one
// POST /api/charging-tasks/{id}/run — fire it now, without waiting for its time
//
// A task belongs to the person, like the chargers it acts on, so there is no
// sharing here: everyone sees their own list only. Firing one is the business of
// chargingtasks_run.go, which is also what the ticker calls.
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"drivervault/apiserver/internal/models"
)
// The actions a task may carry. Each is a control command the charger already
// understands; the scheduler adds no verbs of its own, so a task can only ask
// for something the buttons on the Charging page can ask for by hand.
var taskActions = map[string]bool{
"start": true,
"stop": true,
"limit": true,
"boost": true,
}
// chargingTaskRecord is the PocketBase-facing shape of a task.
type chargingTaskRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Chargers []string `json:"chargers"`
Action string `json:"action"`
Amps float64 `json:"amps"`
Time string `json:"time"`
Zone string `json:"zone"`
Days []int `json:"days"`
Enabled bool `json:"enabled"`
LastRun string `json:"last_run"`
LastResult string `json:"last_result"`
Owner string `json:"owner"`
Created string `json:"created"`
}
func (rec chargingTaskRecord) toModel() models.ChargingTask {
// The two lists are never null on the wire: the page iterates them, and a
// null would make "every charger" and "every day" read as an error there.
chargers := rec.Chargers
if chargers == nil {
chargers = []string{}
}
days := rec.Days
if days == nil {
days = []int{}
}
return models.ChargingTask{
ID: rec.ID,
Name: rec.Name,
Chargers: chargers,
Action: rec.Action,
Amps: rec.Amps,
Time: rec.Time,
Zone: rec.Zone,
Days: days,
Enabled: rec.Enabled,
LastRun: rec.LastRun,
LastResult: rec.LastResult,
Created: rec.Created,
}
}
// chargingTaskBody is the write shape. Every field is a pointer so a PATCH can
// name one of them without the rest being read as "clear these" — a task
// switched off from the list must not lose its days on the way.
type chargingTaskBody struct {
Name *string `json:"name"`
Chargers *[]string `json:"chargers"`
Action *string `json:"action"`
Amps *float64 `json:"amps"`
Time *string `json:"time"`
Zone *string `json:"zone"`
Days *[]int `json:"days"`
Enabled *bool `json:"enabled"`
}
// parseHHMM splits a 24-hour clock time into its two numbers. "H:MM" is accepted
// as well as "HH:MM"; anything else is not a time of day.
func parseHHMM(raw string) (int, int, bool) {
hh, mm, found := strings.Cut(strings.TrimSpace(raw), ":")
if !found || len(hh) == 0 || len(hh) > 2 || len(mm) != 2 {
return 0, 0, false
}
h, err := strconv.Atoi(hh)
if err != nil {
return 0, 0, false
}
m, err := strconv.Atoi(mm)
if err != nil {
return 0, 0, false
}
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, false
}
return h, m, true
}
// normalizeTaskTime returns the padded 24-hour form of a time of day, or "" for
// anything that is not one.
func normalizeTaskTime(raw string) string {
h, m, ok := parseHHMM(raw)
if !ok {
return ""
}
return fmt.Sprintf("%02d:%02d", h, m)
}
// normalizeDays drops anything that is not a weekday number and sorts what is
// left, so the stored list reads the same however the client sent it. Empty
// stays empty, which means every day.
func normalizeDays(days []int) []int {
seen := [7]bool{}
for _, d := range days {
if d >= 0 && d <= 6 {
seen[d] = true
}
}
out := []int{}
for d, ok := range seen {
if ok {
out = append(out, d)
}
}
return out
}
// taskPayload turns a write body into the PocketBase fields it names, validating
// as it goes. `full` demands the fields a task cannot exist without, so a POST is
// checked as a whole and a PATCH only where it speaks.
func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
payload := map[string]any{}
if body.Name != nil {
name := strings.TrimSpace(*body.Name)
if name == "" {
return nil, fmt.Errorf("name is required")
}
payload["name"] = name
} else if full {
return nil, fmt.Errorf("name is required")
}
if body.Action != nil {
action := strings.TrimSpace(*body.Action)
if !taskActions[action] {
return nil, fmt.Errorf("unknown action: %s", action)
}
payload["action"] = action
} else if full {
return nil, fmt.Errorf("action is required")
}
if body.Time != nil {
at := normalizeTaskTime(*body.Time)
if at == "" {
return nil, fmt.Errorf("time must be a 24-hour clock time, e.g. 23:00")
}
payload["time"] = at
} else if full {
return nil, fmt.Errorf("time is required")
}
// The ceiling only means anything to "limit", but it is stored whatever the
// action is: switching a task to "limit" and back should not lose the amps
// that were typed. 6 A is the charger's own floor — below it the box pauses
// rather than charging slowly — and its ratings top out at 32 A.
if body.Amps != nil {
amps := *body.Amps
if amps != 0 && (amps < 6 || amps > 32) {
return nil, fmt.Errorf("the current limit must be between 6 and 32 A")
}
payload["amps"] = amps
}
if body.Chargers != nil {
ids := []string{}
for _, id := range *body.Chargers {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
payload["chargers"] = ids
}
if body.Days != nil {
payload["days"] = normalizeDays(*body.Days)
}
if body.Zone != nil {
payload["zone"] = strings.TrimSpace(*body.Zone)
}
if body.Enabled != nil {
payload["enabled"] = *body.Enabled
} else if full {
// A task written without saying otherwise is on: nobody fills in a
// schedule in order to leave it switched off.
payload["enabled"] = true
}
return payload, nil
}
// checkLimitAmps refuses a task that ends up a "limit" with nothing to limit to,
// which would fire and ask the charger for 0 A. Both halves are read from what
// the write leaves behind — the payload where it speaks, the stored record where
// it does not — so a PATCH that names only one of them is checked against the
// other rather than against nothing.
func checkLimitAmps(payload map[string]any, stored chargingTaskRecord) error {
action := stored.Action
if v, ok := payload["action"].(string); ok {
action = v
}
amps := stored.Amps
if v, ok := payload["amps"].(float64); ok {
amps = v
}
if action == "limit" && amps <= 0 {
return fmt.Errorf("a limit task needs the amps to limit to")
}
return nil
}
// listChargingTasks returns the caller's own tasks, earliest in the day first —
// the order the day runs in, which is the order a schedule is read in.
func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
me := s.currentUserID(r)
if me == "" {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
query := func(sort string) url.Values {
q := url.Values{
"filter": {fmt.Sprintf("owner='%s'", me)},
"perPage": {"200"},
}
if sort != "" {
q.Set("sort", sort)
}
return q
}
res, err := s.pb.List(r.Context(), colChargingTasks, query("time"))
if err != nil {
// Same fallback the charger list makes: an order is a nicety, the list is
// not, so a collection that predates the sort field still answers.
res, err = s.pb.List(r.Context(), colChargingTasks, query(""))
}
if err != nil {
writePBError(w, err)
return
}
var recs []chargingTaskRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]models.ChargingTask, 0, len(recs))
for _, rec := range recs {
out = append(out, rec.toModel())
}
writeJSON(w, http.StatusOK, map[string]any{"tasks": out})
}
// ownedChargingTask fetches one task and checks it is the caller's. It writes the
// error response itself and returns ok=false when it is not.
func (s *Server) ownedChargingTask(w http.ResponseWriter, r *http.Request) (chargingTaskRecord, bool) {
var rec chargingTaskRecord
id := strings.TrimSpace(r.PathValue("id"))
if id == "" {
writeError(w, http.StatusBadRequest, "task id is required")
return rec, false
}
if err := s.pb.GetOne(r.Context(), colChargingTasks, id, &rec); err != nil {
writePBError(w, err)
return rec, false
}
// Someone else's task is not found rather than forbidden, as a charger is:
// whether a record exists is not this caller's business either.
if rec.Owner != s.currentUserID(r) {
writeError(w, http.StatusNotFound, "task not found")
return rec, false
}
return rec, true
}
func (s *Server) createChargingTask(w http.ResponseWriter, r *http.Request) {
me := s.currentUserID(r)
if me == "" {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var body chargingTaskBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload, err := taskPayload(body, true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := checkLimitAmps(payload, chargingTaskRecord{}); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload["owner"] = me
var rec chargingTaskRecord
if err := s.pb.Create(r.Context(), colChargingTasks, payload, &rec); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"task": rec.toModel()})
}
func (s *Server) updateChargingTask(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedChargingTask(w, r)
if !ok {
return
}
var body chargingTaskBody
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload, err := taskPayload(body, false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := checkLimitAmps(payload, rec); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if len(payload) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"task": rec.toModel()})
return
}
var updated chargingTaskRecord
if err := s.pb.Update(r.Context(), colChargingTasks, rec.ID, payload, &updated); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"task": updated.toModel()})
}
func (s *Server) deleteChargingTask(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedChargingTask(w, r)
if !ok {
return
}
if err := s.pb.Delete(r.Context(), colChargingTasks, rec.ID); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// runChargingTaskNow fires a task on demand — the "Run now" button beside it. It
// takes the same path the ticker does, so what comes back is exactly what the
// task will do at its own time, errors included. A switched-off task still runs
// from here: the switch says whether the clock fires it, not whether the button
// does.
func (s *Server) runChargingTaskNow(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedChargingTask(w, r)
if !ok {
return
}
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
results := s.fireChargingTask(r.Context(), who, rec)
s.recordTaskRun(r.Context(), rec, results)
writeJSON(w, http.StatusOK, map[string]any{
"results": results,
"summary": summarizeTaskRun(results),
})
}