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), }) }