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>
399 lines
12 KiB
Go
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),
|
|
})
|
|
}
|