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
@@ -3,16 +3,15 @@ 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.
|
||||
// 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}/run — fire it now, without waiting for its time
|
||||
// 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
|
||||
@@ -23,13 +22,14 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// The actions a task may carry. Each is a control command the charger already
|
||||
// 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{
|
||||
@@ -39,26 +39,29 @@ var taskActions = map[string]bool{
|
||||
"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"`
|
||||
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"`
|
||||
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 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.
|
||||
// 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{}
|
||||
@@ -67,13 +70,15 @@ func (rec chargingTaskRecord) toModel() models.ChargingTask {
|
||||
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,
|
||||
Action: rec.Action,
|
||||
Amps: rec.Amps,
|
||||
Time: rec.Time,
|
||||
Steps: steps,
|
||||
Zone: rec.Zone,
|
||||
Days: days,
|
||||
Enabled: rec.Enabled,
|
||||
@@ -83,18 +88,26 @@ func (rec chargingTaskRecord) toModel() models.ChargingTask {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 days on the way.
|
||||
// switched off from the list must not lose its flow 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"`
|
||||
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
|
||||
@@ -147,6 +160,46 @@ func normalizeDays(days []int) []int {
|
||||
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.
|
||||
@@ -163,36 +216,14 @@ func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
|
||||
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)
|
||||
if body.Steps != nil {
|
||||
steps, err := normalizeSteps(*body.Steps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["action"] = action
|
||||
payload["steps"] = steps
|
||||
} 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
|
||||
return nil, fmt.Errorf("a task needs at least one step")
|
||||
}
|
||||
|
||||
if body.Chargers != nil {
|
||||
@@ -220,50 +251,20 @@ func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
|
||||
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.
|
||||
// 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
|
||||
}
|
||||
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(""))
|
||||
}
|
||||
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
|
||||
@@ -273,6 +274,9 @@ func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
@@ -318,10 +322,6 @@ func (s *Server) createChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -346,10 +346,6 @@ func (s *Server) updateChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -374,25 +370,37 @@ func (s *Server) deleteChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
// 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
|
||||
}
|
||||
results := s.fireChargingTask(r.Context(), who, rec)
|
||||
s.recordTaskRun(r.Context(), rec, results)
|
||||
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": summarizeTaskRun(results),
|
||||
"summary": taskRunLine(step, results),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user