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>
407 lines
13 KiB
Go
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),
|
|
})
|
|
}
|