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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // tasks are timed in the user's own zone; hosts without a zone database are common on Windows
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// How often the clock is looked at. Tasks are timed to the minute, so a sweep
|
||||
@@ -97,7 +99,8 @@ func (s *Server) sweepChargingTasks(ctx context.Context) {
|
||||
}
|
||||
now := time.Now()
|
||||
for _, rec := range recs {
|
||||
if !taskIsDue(rec, now) {
|
||||
due := dueSteps(rec, now)
|
||||
if len(due) == 0 {
|
||||
continue
|
||||
}
|
||||
who, _, err := s.callerForUser(ctx, rec.Owner)
|
||||
@@ -105,29 +108,31 @@ func (s *Server) sweepChargingTasks(ctx context.Context) {
|
||||
log.Printf("scheduler: task %s (%s): owner unavailable: %v", rec.ID, rec.Name, err)
|
||||
continue
|
||||
}
|
||||
results := s.fireChargingTask(ctx, who, rec)
|
||||
s.recordTaskRun(ctx, rec, results)
|
||||
log.Printf("scheduler: task %s (%s) fired: %s", rec.ID, rec.Name, summarizeTaskRun(results))
|
||||
// Two steps of one task timed to the same minute contradict each other,
|
||||
// but nothing stops somebody writing them, so both are sent in the order
|
||||
// the flow holds them rather than one silently winning.
|
||||
for _, step := range due {
|
||||
results := s.fireChargingSteps(ctx, who, rec, []models.ChargingStep{step})
|
||||
s.recordTaskRun(ctx, rec, step, results)
|
||||
log.Printf("scheduler: task %s (%s) step %s %s: %s",
|
||||
rec.ID, rec.Name, step.Time, step.Action, summarizeTaskRun(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// taskIsDue reports whether a task's minute has come and it has not already been
|
||||
// fired in it.
|
||||
// dueSteps returns the steps of a task whose minute has come — usually one, and
|
||||
// none at all for the rest of the day.
|
||||
//
|
||||
// The time is read in the task's own zone: the browser that wrote it said which,
|
||||
// and the server's clock is not the one the user set 23:00 by. A zone the host
|
||||
// has no database for falls back to the server's own rather than silently
|
||||
// The times are read in the task's own zone: the browser that wrote it said
|
||||
// which, and the server's clock is not the one the user set 23:00 by. A zone the
|
||||
// host has no database for falls back to the server's own rather than silently
|
||||
// shifting the schedule to UTC.
|
||||
//
|
||||
// A minute that passed while the server was down is not caught up afterwards. A
|
||||
// charging window that opened an hour ago is not a window anyone still wants
|
||||
// opened, and firing a backlog on boot would be the surprising half of the
|
||||
// choice.
|
||||
func taskIsDue(rec chargingTaskRecord, now time.Time) bool {
|
||||
h, m, ok := parseHHMM(rec.Time)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
func dueSteps(rec chargingTaskRecord, now time.Time) []models.ChargingStep {
|
||||
loc := time.Local
|
||||
if rec.Zone != "" {
|
||||
if l, err := time.LoadLocation(rec.Zone); err == nil {
|
||||
@@ -135,20 +140,29 @@ func taskIsDue(rec chargingTaskRecord, now time.Time) bool {
|
||||
}
|
||||
}
|
||||
local := now.In(loc)
|
||||
if local.Hour() != h || local.Minute() != m {
|
||||
return false
|
||||
}
|
||||
if len(rec.Days) > 0 && !containsDay(rec.Days, int(local.Weekday())) {
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
// The guard against firing twice: the sweep runs more often than once a
|
||||
// minute, so a task that has already run inside this minute is done.
|
||||
// minute, so a task that has already run inside this minute is done. It is
|
||||
// per task rather than per step because two steps never share a minute in
|
||||
// any flow that means anything — and when they do, they are sent together.
|
||||
if last, err := time.Parse(time.RFC3339, rec.LastRun); err == nil {
|
||||
if !last.Before(local.Truncate(time.Minute)) {
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return true
|
||||
var due []models.ChargingStep
|
||||
for _, step := range rec.Steps {
|
||||
h, m, ok := parseHHMM(step.Time)
|
||||
if !ok {
|
||||
continue // a step whose time is not a time of day fires nothing
|
||||
}
|
||||
if local.Hour() == h && local.Minute() == m {
|
||||
due = append(due, step)
|
||||
}
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
func containsDay(days []int, day int) bool {
|
||||
@@ -160,11 +174,12 @@ func containsDay(days []int, day int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// fireChargingTask sends the task's command to each of its chargers and reports
|
||||
// what each one said. A task naming no chargers acts on every charger its owner
|
||||
// has — "all of them" is a standing wish, so a charger imported after the task
|
||||
// was written is covered by it too.
|
||||
func (s *Server) fireChargingTask(ctx context.Context, who *callerIdentity, rec chargingTaskRecord) []taskRunResult {
|
||||
// fireChargingSteps sends the given steps to each of the task's chargers and
|
||||
// reports what each one said. A task naming no chargers acts on every charger
|
||||
// its owner has — "all of them" is a standing wish, so a charger imported after
|
||||
// the task was written is covered by it too.
|
||||
func (s *Server) fireChargingSteps(ctx context.Context, who *callerIdentity,
|
||||
rec chargingTaskRecord, steps []models.ChargingStep) []taskRunResult {
|
||||
chargers, err := s.ownerChargers(ctx, rec.Owner)
|
||||
if err != nil {
|
||||
return []taskRunResult{{Error: err.Error()}}
|
||||
@@ -183,13 +198,16 @@ func (s *Server) fireChargingTask(ctx context.Context, who *callerIdentity, rec
|
||||
results = append(results, out)
|
||||
continue
|
||||
}
|
||||
status, err := s.sendTaskAction(ctx, who, c.Serial, rec)
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
} else {
|
||||
out.Status = status
|
||||
for _, step := range steps {
|
||||
one := out
|
||||
status, err := s.sendStep(ctx, who, c.Serial, step)
|
||||
if err != nil {
|
||||
one.Error = err.Error()
|
||||
} else {
|
||||
one.Status = status
|
||||
}
|
||||
results = append(results, one)
|
||||
}
|
||||
results = append(results, out)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
results = append(results, taskRunResult{Error: "this task names no charger that still exists"})
|
||||
@@ -224,23 +242,23 @@ func containsID(ids []string, id string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// sendTaskAction sends one task's command to one charger, through the control
|
||||
// endpoint the page's own buttons use.
|
||||
// sendStep sends one step's command to one charger, through the control endpoint
|
||||
// the page's own buttons use.
|
||||
//
|
||||
// The request is synthesised rather than the transports being called directly,
|
||||
// so a scheduled command cannot end up on a different footing from a pressed
|
||||
// one: the cascade, the ownership gate, the rate limit and the audit line are
|
||||
// the endpoint's, and there is no second copy of them here to drift.
|
||||
func (s *Server) sendTaskAction(ctx context.Context, who *callerIdentity, serial string, rec chargingTaskRecord) (string, error) {
|
||||
func (s *Server) sendStep(ctx context.Context, who *callerIdentity, serial string, step models.ChargingStep) (string, error) {
|
||||
body := map[string]any{}
|
||||
if rec.Action == "limit" {
|
||||
body["amps"] = rec.Amps
|
||||
if step.Action == "limit" {
|
||||
body["amps"] = step.Amps
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + rec.Action
|
||||
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + step.Action
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.WithValue(ctx, ctxCaller, who), http.MethodPost, path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
@@ -250,7 +268,7 @@ func (s *Server) sendTaskAction(ctx context.Context, who *callerIdentity, serial
|
||||
// The endpoint reads its charger and its verb from the route, which nothing
|
||||
// matched here — this request never went through a mux.
|
||||
req.SetPathValue("sn", serial)
|
||||
req.SetPathValue("action", rec.Action)
|
||||
req.SetPathValue("action", step.Action)
|
||||
|
||||
rw := &captureWriter{header: http.Header{}}
|
||||
s.handleAnkerControlAction(rw, req)
|
||||
@@ -332,14 +350,28 @@ func summarizeTaskRun(results []taskRunResult) string {
|
||||
}
|
||||
}
|
||||
|
||||
// recordTaskRun stamps a task with when it last fired and how it went. The stamp
|
||||
// is also the guard that keeps a task from firing twice inside its minute, so a
|
||||
// firing that could not be written down is worth a log line: without it the next
|
||||
// sweep would send the command again.
|
||||
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord, results []taskRunResult) {
|
||||
// taskRunLine is the outcome of one firing as the list shows it: which step, and
|
||||
// how it went. A task holds several steps now, and "2 of 2 sent" says nothing
|
||||
// about which of them it was.
|
||||
//
|
||||
// The step is named by its time rather than its action, because everything this
|
||||
// server writes into last_result is in its own words — charger names, the
|
||||
// control gate's refusals — while the page has a translation for every action.
|
||||
// A clock time reads the same in all three languages.
|
||||
func taskRunLine(step models.ChargingStep, results []taskRunResult) string {
|
||||
return step.Time + " — " + summarizeTaskRun(results)
|
||||
}
|
||||
|
||||
// recordTaskRun stamps a task with when it last fired and how it went.
|
||||
//
|
||||
// The stamp is also the guard that keeps a task from firing twice inside its
|
||||
// minute, so a firing that could not be written down is worth a log line:
|
||||
// without it the next sweep would send the command again.
|
||||
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord,
|
||||
step models.ChargingStep, results []taskRunResult) {
|
||||
payload := map[string]any{
|
||||
"last_run": time.Now().UTC().Format(time.RFC3339),
|
||||
"last_result": summarizeTaskRun(results),
|
||||
"last_result": taskRunLine(step, results),
|
||||
}
|
||||
if err := s.pb.Update(ctx, colChargingTasks, rec.ID, payload, nil); err != nil {
|
||||
log.Printf("scheduler: task %s fired but its outcome could not be saved: %v", rec.ID, err)
|
||||
|
||||
@@ -4,14 +4,20 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// The scheduler's clock is the part worth pinning down: it decides on its own,
|
||||
// with nobody watching, whether to send a car a command. A task that fires in
|
||||
// with nobody watching, whether to send a car a command. A step that fires in
|
||||
// the wrong hour is worse than one that does not fire at all, so the zone, the
|
||||
// weekday and the not-twice-in-one-minute guard are each checked here.
|
||||
|
||||
func TestTaskIsDue(t *testing.T) {
|
||||
func step(action, at string, amps float64) models.ChargingStep {
|
||||
return models.ChargingStep{Action: action, Time: at, Amps: amps}
|
||||
}
|
||||
|
||||
func TestDueSteps(t *testing.T) {
|
||||
warsaw, err := time.LoadLocation("Europe/Warsaw")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/Warsaw: %v", err)
|
||||
@@ -19,32 +25,52 @@ func TestTaskIsDue(t *testing.T) {
|
||||
// A Wednesday, 23:00 in Warsaw — 22:00 UTC.
|
||||
at2300 := time.Date(2026, 9, 2, 23, 0, 30, 0, warsaw)
|
||||
|
||||
base := chargingTaskRecord{Time: "23:00", Zone: "Europe/Warsaw", Enabled: true}
|
||||
|
||||
if !taskIsDue(base, at2300) {
|
||||
t.Error("a task set for 23:00 is not due at 23:00 in its own zone")
|
||||
}
|
||||
// The same instant, handed over as UTC. The zone on the task is what the
|
||||
// time is read in, so where the server thinks it is must not matter.
|
||||
if !taskIsDue(base, at2300.UTC()) {
|
||||
t.Error("the task stopped being due when the same instant arrived as UTC")
|
||||
}
|
||||
if taskIsDue(base, at2300.Add(time.Minute)) {
|
||||
t.Error("a task fired a minute after its time")
|
||||
}
|
||||
if taskIsDue(base, at2300.Add(-time.Minute)) {
|
||||
t.Error("a task fired a minute before its time")
|
||||
// A whole night in one task, which is the point of a flow: it opens, it
|
||||
// eases off, it closes.
|
||||
base := chargingTaskRecord{
|
||||
Zone: "Europe/Warsaw",
|
||||
Enabled: true,
|
||||
Steps: []models.ChargingStep{
|
||||
step("start", "23:00", 0),
|
||||
step("limit", "01:00", 10),
|
||||
step("stop", "06:30", 0),
|
||||
},
|
||||
}
|
||||
|
||||
// Weekdays. 2026-09-02 is a Wednesday (3).
|
||||
due := dueSteps(base, at2300)
|
||||
if len(due) != 1 || due[0].Action != "start" {
|
||||
t.Fatalf("at 23:00 got %+v, want just the start step", due)
|
||||
}
|
||||
// The same instant handed over as UTC. The zone on the task is what the
|
||||
// times are read in, so where the server thinks it is must not matter.
|
||||
if got := dueSteps(base, at2300.UTC()); len(got) != 1 || got[0].Action != "start" {
|
||||
t.Errorf("the step stopped being due when the same instant arrived as UTC: %+v", got)
|
||||
}
|
||||
// The other two steps, each in its own minute and no other.
|
||||
if got := dueSteps(base, time.Date(2026, 9, 3, 1, 0, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "limit" {
|
||||
t.Errorf("at 01:00 got %+v, want the limit step", got)
|
||||
}
|
||||
if got := dueSteps(base, time.Date(2026, 9, 3, 6, 30, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "stop" {
|
||||
t.Errorf("at 06:30 got %+v, want the stop step", got)
|
||||
}
|
||||
// A minute the flow says nothing about fires nothing — the point being that
|
||||
// a task with a step at 23:00 is not "on" from 23:00 onwards.
|
||||
for _, at := range []time.Time{at2300.Add(time.Minute), at2300.Add(-time.Minute),
|
||||
time.Date(2026, 9, 3, 3, 0, 0, 0, warsaw)} {
|
||||
if got := dueSteps(base, at); len(got) != 0 {
|
||||
t.Errorf("at %s got %+v, want nothing due", at.Format("15:04"), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Weekdays gate the whole task. 2026-09-02 is a Wednesday (3).
|
||||
weeknights := base
|
||||
weeknights.Days = []int{1, 2, 3, 4, 5}
|
||||
if !taskIsDue(weeknights, at2300) {
|
||||
t.Error("a weeknight task is not due on a Wednesday")
|
||||
if len(dueSteps(weeknights, at2300)) != 1 {
|
||||
t.Error("a weeknight task did not fire on a Wednesday")
|
||||
}
|
||||
weekends := base
|
||||
weekends.Days = []int{0, 6}
|
||||
if taskIsDue(weekends, at2300) {
|
||||
if len(dueSteps(weekends, at2300)) != 0 {
|
||||
t.Error("a weekend task fired on a Wednesday")
|
||||
}
|
||||
|
||||
@@ -52,34 +78,45 @@ func TestTaskIsDue(t *testing.T) {
|
||||
// and the second sweep must not send the command again.
|
||||
fired := base
|
||||
fired.LastRun = at2300.UTC().Format(time.RFC3339)
|
||||
if taskIsDue(fired, at2300.Add(20*time.Second)) {
|
||||
t.Error("a task fired twice inside its own minute")
|
||||
if len(dueSteps(fired, at2300.Add(20*time.Second))) != 0 {
|
||||
t.Error("a step fired twice inside its own minute")
|
||||
}
|
||||
// Yesterday's firing is not this minute's.
|
||||
yesterday := base
|
||||
yesterday.LastRun = at2300.Add(-24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
if !taskIsDue(yesterday, at2300) {
|
||||
if len(dueSteps(yesterday, at2300)) != 1 {
|
||||
t.Error("yesterday's run stopped today's from firing")
|
||||
}
|
||||
|
||||
// A time that is not a time of day fires nothing rather than firing at
|
||||
// midnight, which is what a zero hour and minute would have meant.
|
||||
// A step whose time is not a time of day fires nothing rather than firing at
|
||||
// midnight, which is what a zero hour and minute would have meant — and it
|
||||
// does not take the rest of the flow down with it.
|
||||
broken := base
|
||||
broken.Time = "later"
|
||||
if taskIsDue(broken, at2300) {
|
||||
t.Error("a task with an unreadable time fired")
|
||||
broken.Steps = []models.ChargingStep{step("start", "later", 0), step("stop", "23:00", 0)}
|
||||
got := dueSteps(broken, at2300)
|
||||
if len(got) != 1 || got[0].Action != "stop" {
|
||||
t.Errorf("got %+v, want only the readable step", got)
|
||||
}
|
||||
|
||||
// Two steps timed to the same minute contradict each other, but nothing
|
||||
// stops somebody writing them, so both are returned rather than one
|
||||
// silently winning.
|
||||
clash := base
|
||||
clash.Steps = []models.ChargingStep{step("start", "23:00", 0), step("boost", "23:00", 0)}
|
||||
if got := dueSteps(clash, at2300); len(got) != 2 {
|
||||
t.Errorf("got %+v, want both steps sharing the minute", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A zone the host has no database for falls back to the server's own clock
|
||||
// rather than silently shifting the schedule to UTC.
|
||||
func TestTaskIsDueUnknownZone(t *testing.T) {
|
||||
func TestDueStepsUnknownZone(t *testing.T) {
|
||||
now := time.Now()
|
||||
rec := chargingTaskRecord{
|
||||
Time: now.Format("15:04"),
|
||||
Zone: "Mars/Olympus_Mons",
|
||||
Zone: "Mars/Olympus_Mons",
|
||||
Steps: []models.ChargingStep{step("start", now.Format("15:04"), 0)},
|
||||
}
|
||||
if !taskIsDue(rec, now) {
|
||||
if len(dueSteps(rec, now)) != 1 {
|
||||
t.Error("a task in an unknown zone did not fall back to the server's clock")
|
||||
}
|
||||
}
|
||||
@@ -130,16 +167,58 @@ func TestNormalizeDays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSteps(t *testing.T) {
|
||||
// A night, written the way it is meant: it opens, it eases off, it closes.
|
||||
// The half-written times are the client's business to send and this
|
||||
// function's business to pad.
|
||||
got, err := normalizeSteps([]models.ChargingStep{
|
||||
step("start", "23:00", 0),
|
||||
step("limit", "1:00", 10),
|
||||
step("stop", "6:30", 0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeSteps: %v", err)
|
||||
}
|
||||
// Kept in the order it was written. Sorting by the clock would file the
|
||||
// 23:00 start last, behind the stop that closes it, which is not the flow
|
||||
// anybody described — and firing does not depend on the order at all.
|
||||
wantTimes := []string{"23:00", "01:00", "06:30"}
|
||||
for i, want := range wantTimes {
|
||||
if got[i].Time != want {
|
||||
t.Errorf("step %d time = %q, want %q (whole flow: %+v)", i, got[i].Time, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
for name, steps := range map[string][]models.ChargingStep{
|
||||
"no steps at all": {},
|
||||
"an unknown action": {step("melt", "23:00", 0)},
|
||||
"a time that is not one": {step("start", "half past", 0)},
|
||||
"a limit with no amps": {step("limit", "23:00", 0)},
|
||||
"amps below the floor": {step("limit", "23:00", 3)},
|
||||
"amps above the rating": {step("limit", "23:00", 40)},
|
||||
} {
|
||||
if _, err := normalizeSteps(steps); err == nil {
|
||||
t.Errorf("normalizeSteps accepted %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The cap bounds what the sweep re-reads every thirty seconds.
|
||||
tooMany := make([]models.ChargingStep, maxTaskSteps+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = step("start", "23:00", 0)
|
||||
}
|
||||
if _, err := normalizeSteps(tooMany); err == nil {
|
||||
t.Errorf("normalizeSteps accepted %d steps, past the cap of %d", len(tooMany), maxTaskSteps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskPayloadValidation(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
num := func(f float64) *float64 { return &f }
|
||||
steps := func(s ...models.ChargingStep) *[]models.ChargingStep { return &s }
|
||||
|
||||
// A whole task, as a create sends it.
|
||||
full := chargingTaskBody{
|
||||
Name: str(" Night rate "),
|
||||
Action: str("limit"),
|
||||
Time: str("7:05"),
|
||||
Amps: num(10),
|
||||
Name: str(" Night rate "),
|
||||
Steps: steps(step("start", "23:00", 0), step("stop", "6:30", 0)),
|
||||
}
|
||||
payload, err := taskPayload(full, true)
|
||||
if err != nil {
|
||||
@@ -148,36 +227,26 @@ func TestTaskPayloadValidation(t *testing.T) {
|
||||
if payload["name"] != "Night rate" {
|
||||
t.Errorf("name = %v, want it trimmed", payload["name"])
|
||||
}
|
||||
if payload["time"] != "07:05" {
|
||||
t.Errorf("time = %v, want the padded 07:05", payload["time"])
|
||||
}
|
||||
if payload["enabled"] != true {
|
||||
t.Error("a new task was written switched off; nobody fills in a schedule to leave it off")
|
||||
}
|
||||
if flow, _ := payload["steps"].([]models.ChargingStep); len(flow) != 2 || flow[0].Time != "23:00" {
|
||||
t.Errorf("steps = %+v, want both, as written", payload["steps"])
|
||||
}
|
||||
|
||||
// The fields a task cannot exist without.
|
||||
// The two things a task cannot exist without.
|
||||
for _, missing := range []chargingTaskBody{
|
||||
{Action: str("start"), Time: str("23:00")},
|
||||
{Name: str("x"), Time: str("23:00")},
|
||||
{Name: str("x"), Action: str("start")},
|
||||
{Name: str(" "), Action: str("start"), Time: str("23:00")},
|
||||
{Name: str("x"), Action: str("melt"), Time: str("23:00")},
|
||||
{Name: str("x"), Action: str("start"), Time: str("half past")},
|
||||
{Steps: steps(step("start", "23:00", 0))},
|
||||
{Name: str("x")},
|
||||
{Name: str(" "), Steps: steps(step("start", "23:00", 0))},
|
||||
} {
|
||||
if _, err := taskPayload(missing, true); err == nil {
|
||||
t.Errorf("taskPayload accepted an incomplete task: %+v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// The charger's own floor and ceiling.
|
||||
for _, amps := range []float64{1, 5, 33} {
|
||||
if _, err := taskPayload(chargingTaskBody{Amps: num(amps)}, false); err == nil {
|
||||
t.Errorf("taskPayload accepted %g A, outside what the charger will take", amps)
|
||||
}
|
||||
}
|
||||
|
||||
// A partial write says only what it names, so a task switched off from the
|
||||
// list keeps its days and its time.
|
||||
// list keeps its flow, its chargers and its days.
|
||||
on := true
|
||||
partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false)
|
||||
if err != nil {
|
||||
@@ -188,26 +257,17 @@ func TestTaskPayloadValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A "limit" with no ceiling would fire and ask the charger for 0 A. Both halves
|
||||
// are read from whatever the write leaves behind, so a PATCH naming one of them
|
||||
// is checked against the stored other.
|
||||
func TestCheckLimitAmps(t *testing.T) {
|
||||
stored := chargingTaskRecord{Action: "start", Amps: 0}
|
||||
if err := checkLimitAmps(map[string]any{"action": "limit"}, stored); err == nil {
|
||||
t.Error("a task switched to limit with no amps was accepted")
|
||||
// The list is ordered by the time a task begins, which lives inside its flow
|
||||
// rather than in a column PocketBase could sort on.
|
||||
func TestFirstStepTime(t *testing.T) {
|
||||
rec := chargingTaskRecord{Steps: []models.ChargingStep{step("start", "23:00", 0), step("stop", "06:30", 0)}}
|
||||
if got := rec.firstStepTime(); got != "23:00" {
|
||||
t.Errorf("firstStepTime = %q, want the first step's 23:00", got)
|
||||
}
|
||||
if err := checkLimitAmps(map[string]any{"action": "limit", "amps": 16.0}, stored); err != nil {
|
||||
t.Errorf("a complete limit task was refused: %v", err)
|
||||
}
|
||||
// The amps are already on the record; the PATCH only changes the action.
|
||||
hasAmps := chargingTaskRecord{Action: "start", Amps: 16}
|
||||
if err := checkLimitAmps(map[string]any{"action": "limit"}, hasAmps); err != nil {
|
||||
t.Errorf("a limit task with stored amps was refused: %v", err)
|
||||
}
|
||||
// And the other way round: clearing the amps on a task that limits.
|
||||
limits := chargingTaskRecord{Action: "limit", Amps: 16}
|
||||
if err := checkLimitAmps(map[string]any{"amps": 0.0}, limits); err == nil {
|
||||
t.Error("the amps were cleared off a limit task")
|
||||
// A task with no steps cannot be written, but sorting must not depend on
|
||||
// that — it sorts last rather than first.
|
||||
if got := (chargingTaskRecord{}).firstStepTime(); got < "23:59" {
|
||||
t.Errorf("an empty task sorted to %q, want it last", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -502,7 +502,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/charging-tasks", s.createChargingTask)
|
||||
mux.HandleFunc("PATCH /api/charging-tasks/{id}", s.updateChargingTask)
|
||||
mux.HandleFunc("DELETE /api/charging-tasks/{id}", s.deleteChargingTask)
|
||||
mux.HandleFunc("POST /api/charging-tasks/{id}/run", s.runChargingTaskNow)
|
||||
mux.HandleFunc("POST /api/charging-tasks/{id}/steps/{step}/run", s.runChargingStepNow)
|
||||
|
||||
// Cars + sharing.
|
||||
mux.HandleFunc("GET /api/cars", s.listCars)
|
||||
|
||||
@@ -236,11 +236,12 @@ var collectionsSchema = map[string][]fieldDef{
|
||||
// standing wish that keeps covering chargers imported later — and a
|
||||
// multi-relation would have to be rewritten on every import to say it.
|
||||
fJSON("chargers", 2000),
|
||||
fSelect("action", []string{"start", "stop", "limit", "boost"}, true),
|
||||
fNumber("amps"), // the ceiling, for the "limit" action
|
||||
// 24-hour "HH:MM", read in the IANA zone the task was written in. The
|
||||
// server's clock is not the one the user set the time by.
|
||||
fText("time", true),
|
||||
// The flow: [{action, amps, time}, …] in the order it runs. A whole
|
||||
// charging window is one task rather than the two that would otherwise
|
||||
// open and close it, so it is named once and switched off once. Times are
|
||||
// 24-hour "HH:MM" read in the zone below — the server's clock is not the
|
||||
// one the user set 23:00 by. Validated in internal/api/chargingtasks.go.
|
||||
fJSON("steps", 4000),
|
||||
fText("zone", false),
|
||||
fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day
|
||||
fBool("enabled"),
|
||||
|
||||
@@ -152,14 +152,26 @@ type HomeCharger struct {
|
||||
Created string `json:"created,omitempty"`
|
||||
}
|
||||
|
||||
// ChargingTask is one line of the home-charger scheduler: an action, a time of
|
||||
// day, the days it repeats on, and the chargers it acts on. It belongs to the
|
||||
// person, like the chargers themselves — one list covering every charger they
|
||||
// own, rather than a separate schedule inside each one.
|
||||
// ChargingStep is one command in a task's flow: what to do, and at what time
|
||||
// of day. A step is the smallest thing the scheduler sends.
|
||||
type ChargingStep struct {
|
||||
// start, stop, limit (to Amps) or boost.
|
||||
Action string `json:"action"`
|
||||
Amps float64 `json:"amps,omitempty"`
|
||||
|
||||
// A 24-hour "HH:MM", read in the task's zone.
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// ChargingTask is one entry in the home-charger scheduler: a flow of steps, the
|
||||
// days it repeats on, and the chargers it acts on. It belongs to the person,
|
||||
// like the chargers themselves — one list covering every charger they own,
|
||||
// rather than a separate schedule inside each one.
|
||||
//
|
||||
// The charger's own cloud schedule can only say "charge between these hours,
|
||||
// every day, on this one box". This says "at 23:00 on weeknights, cap these two
|
||||
// chargers at 10 A" — several tasks, each naming its own chargers.
|
||||
// every day, on this one box". This says "start at 23:00, cap to 10 A at 01:00,
|
||||
// stop at 06:30 — on weeknights, on these two chargers", as one named thing that
|
||||
// is switched on and off as one.
|
||||
type ChargingTask struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -169,16 +181,14 @@ type ChargingTask struct {
|
||||
// them" is a standing wish, not the list that happened to exist that day.
|
||||
Chargers []string `json:"chargers"`
|
||||
|
||||
// What to do: start, stop, limit (to Amps) or boost. One action per task; a
|
||||
// charging window is the two tasks that open and close it, which is also how
|
||||
// it is edited and how it is switched off.
|
||||
Action string `json:"action"`
|
||||
Amps float64 `json:"amps,omitempty"`
|
||||
// The flow, in the order it runs. A whole charging window lives in one task
|
||||
// rather than in the two that used to open and close it: it is named once,
|
||||
// switched off once, and reads as the one intention it is.
|
||||
Steps []ChargingStep `json:"steps"`
|
||||
|
||||
// When, as a 24-hour "HH:MM" read in Zone — the IANA zone the browser was in
|
||||
// The IANA zone the steps' times are read in — the one the browser was in
|
||||
// when the task was written. The server's own clock is not the one the user
|
||||
// set the time by, and a laptop that travels must not move the schedule.
|
||||
Time string `json:"time"`
|
||||
// set 23:00 by, and a laptop that travels must not move the schedule.
|
||||
Zone string `json:"zone,omitempty"`
|
||||
|
||||
// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
|
||||
@@ -186,8 +196,9 @@ type ChargingTask struct {
|
||||
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// What happened the last time it fired, so a task that has been failing
|
||||
// quietly for a week says so in the list rather than in a log nobody reads.
|
||||
// What happened the last time a step of it fired, so a task that has been
|
||||
// failing quietly for a week says so in the list rather than in a log nobody
|
||||
// reads.
|
||||
LastRun string `json:"lastRun,omitempty"` // RFC3339, UTC
|
||||
LastResult string `json:"lastResult,omitempty"`
|
||||
|
||||
|
||||
@@ -473,10 +473,11 @@ const DESIRED = {
|
||||
// relation because empty has to mean "every charger I own" — a standing wish
|
||||
// that keeps covering chargers imported later.
|
||||
F.json("chargers", 2000),
|
||||
F.select("action", ["start", "stop", "limit", "boost"], true),
|
||||
F.number("amps"), // the ceiling, for the "limit" action
|
||||
// 24-hour "HH:MM", read in the IANA zone the task was written in.
|
||||
F.text("time", true),
|
||||
// The flow: [{action, amps, time}, …] in the order it runs. A whole charging
|
||||
// window is one task rather than the two that would otherwise open and close
|
||||
// it, so it is named once and switched off once. Times are 24-hour "HH:MM"
|
||||
// read in the zone below.
|
||||
F.json("steps", 4000),
|
||||
F.text("zone"),
|
||||
F.json("days", 200), // 0=Sunday … 6=Saturday; empty means every day
|
||||
F.bool("enabled"),
|
||||
|
||||
Reference in New Issue
Block a user