Files
tajniak81andClaude Opus 5 2b4f4f034d 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>
2026-09-03 23:32:07 +02:00

407 lines
13 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, and each entry is
// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these
// chargers, on these days. One named thing, switched on and off as one.
//
// 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}/steps/{n}/run — fire one step now
//
// 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"
"sort"
"strconv"
"strings"
"drivervault/apiserver/internal/models"
)
// The actions a step 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,
}
// maxTaskSteps bounds one task's flow. Well past any real schedule — a night
// rate is two or three steps — and it keeps a client from parking an unbounded
// blob on the record, which the sweep then reads every thirty seconds.
const maxTaskSteps = 24
// chargingTaskRecord is the PocketBase-facing shape of a task.
type chargingTaskRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Chargers []string `json:"chargers"`
Steps []models.ChargingStep `json:"steps"`
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 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{}
}
steps := rec.Steps
if steps == nil {
steps = []models.ChargingStep{}
}
return models.ChargingTask{
ID: rec.ID,
Name: rec.Name,
Chargers: chargers,
Steps: steps,
Zone: rec.Zone,
Days: days,
Enabled: rec.Enabled,
LastRun: rec.LastRun,
LastResult: rec.LastResult,
Created: rec.Created,
}
}
// firstStepTime is the time of day a task begins — its first step's, which is
// what the list is ordered by, so the evening's task sits below the morning's. A
// task with no steps cannot exist, but the ordering must not depend on that.
func (rec chargingTaskRecord) firstStepTime() string {
if len(rec.Steps) == 0 {
return "99:99"
}
return rec.Steps[0].Time
}
// 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 flow on the way.
type chargingTaskBody struct {
Name *string `json:"name"`
Chargers *[]string `json:"chargers"`
Steps *[]models.ChargingStep `json:"steps"`
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
}
// normalizeSteps validates a flow, keeping the steps in the order they were
// written.
//
// Not sorted by time: a night crosses midnight, and clock order would file
// "start at 23:00" last, behind the stop that closes it. Nothing about firing
// depends on the order — every step is timed on its own — so the order is free
// to be the one that reads as the intention it is.
func normalizeSteps(steps []models.ChargingStep) ([]models.ChargingStep, error) {
if len(steps) == 0 {
return nil, fmt.Errorf("a task needs at least one step")
}
if len(steps) > maxTaskSteps {
return nil, fmt.Errorf("a task can hold at most %d steps", maxTaskSteps)
}
out := make([]models.ChargingStep, 0, len(steps))
for i, step := range steps {
action := strings.TrimSpace(step.Action)
if !taskActions[action] {
return nil, fmt.Errorf("step %d: unknown action: %s", i+1, action)
}
at := normalizeTaskTime(step.Time)
if at == "" {
return nil, fmt.Errorf("step %d: time must be a 24-hour clock time, e.g. 23:00", i+1)
}
// The ceiling only means anything to "limit", but it is kept whatever the
// action is: switching a step 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 step.Amps != 0 && (step.Amps < 6 || step.Amps > 32) {
return nil, fmt.Errorf("step %d: the current limit must be between 6 and 32 A", i+1)
}
// A limit step with no ceiling would fire and ask the charger for 0 A.
if action == "limit" && step.Amps <= 0 {
return nil, fmt.Errorf("step %d: a limit step needs the amps to limit to", i+1)
}
out = append(out, models.ChargingStep{Action: action, Amps: step.Amps, Time: at})
}
return out, nil
}
// 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.Steps != nil {
steps, err := normalizeSteps(*body.Steps)
if err != nil {
return nil, err
}
payload["steps"] = steps
} else if full {
return nil, fmt.Errorf("a task needs at least one step")
}
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
}
// listChargingTasks returns the caller's own tasks, the one that starts earliest
// first — the order the day runs them in, which is the order a schedule is read
// in. Ordered here rather than by PocketBase because the time a task starts is
// now inside its flow, which is not a column to sort on.
func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
me := s.currentUserID(r)
if me == "" {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
res, err := s.pb.List(r.Context(), colChargingTasks, url.Values{
"filter": {fmt.Sprintf("owner='%s'", me)},
"perPage": {"200"},
})
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
}
sort.SliceStable(recs, func(i, j int) bool {
return recs[i].firstStepTime() < recs[j].firstStepTime()
})
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
}
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 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)
}
// runChargingStepNow fires one step of a task on demand — the "Run now" beside
// it. One step rather than the whole flow, because a flow is not a thing that
// can happen at once: running a start and the stop that closes it back to back
// would leave the charger where it began and prove nothing.
//
// It takes the same path the ticker does, so what comes back is exactly what
// that step 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) runChargingStepNow(w http.ResponseWriter, r *http.Request) {
rec, ok := s.ownedChargingTask(w, r)
if !ok {
return
}
n, err := strconv.Atoi(strings.TrimSpace(r.PathValue("step")))
if err != nil || n < 0 || n >= len(rec.Steps) {
writeError(w, http.StatusNotFound, "this task has no such step")
return
}
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
step := rec.Steps[n]
results := s.fireChargingSteps(r.Context(), who, rec, []models.ChargingStep{step})
s.recordTaskRun(r.Context(), rec, step, results)
// The same line that was just stored, so the row the caller updates from this
// answer reads identically to the one a page reload would fetch.
writeJSON(w, http.StatusOK, map[string]any{
"results": results,
"summary": taskRunLine(step, results),
})
}