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"),
|
||||
|
||||
@@ -319,8 +319,9 @@ export const api = {
|
||||
// The home-charger scheduler: one list of charging tasks per user, covering
|
||||
// every charger they own. The server holds the clock — a schedule that only
|
||||
// fires while this page is open would be a reminder, not a schedule — so the
|
||||
// page only writes tasks and reads back how each one last went. runChargingTask
|
||||
// fires one now, whatever its time and whether or not it is switched on.
|
||||
// page only writes tasks and reads back how each one last went.
|
||||
// runChargingStep fires one step now, whatever its time and whether or not
|
||||
// the task it belongs to is switched on.
|
||||
listChargingTasks: () => request("/charging-tasks").then((r) => r.tasks),
|
||||
createChargingTask: (body) =>
|
||||
request("/charging-tasks", { method: "POST", body: JSON.stringify(body) }).then((r) => r.task),
|
||||
@@ -330,8 +331,11 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => r.task),
|
||||
deleteChargingTask: (id) => request(`/charging-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
runChargingTask: (id) =>
|
||||
request(`/charging-tasks/${encodeURIComponent(id)}/run`, { method: "POST" }),
|
||||
// One step of a flow, fired now: running a start and the stop that closes it
|
||||
// back to back would leave the charger where it began and prove nothing.
|
||||
runChargingStep: (id, step) =>
|
||||
request(`/charging-tasks/${encodeURIComponent(id)}/steps/${encodeURIComponent(step)}/run`,
|
||||
{ method: "POST" }),
|
||||
|
||||
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
|
||||
// returns the resolved view (effective/own/locked per field, secrets and
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
// of them" is a standing wish rather than the list that happened to exist that
|
||||
// day.
|
||||
//
|
||||
// One action per task. A charging window is the two tasks that open and close
|
||||
// it, which is also how it is read back, edited and switched off; folding both
|
||||
// ends into one row would have made the common case shorter and every other case
|
||||
// impossible.
|
||||
// A task holds a flow rather than a single command: start at 23:00, ease down to
|
||||
// 10 A at 01:00, stop at 06:30. That is one intention, so it is one named thing
|
||||
// with one switch — splitting a charging window across two tasks meant naming it
|
||||
// twice and remembering to switch off both ends.
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { api } from "../api";
|
||||
import { t } from "../i18n";
|
||||
@@ -31,9 +31,26 @@ const emit = defineEmits(["saved", "close"]);
|
||||
const editing = computed(() => !!props.task?.id);
|
||||
|
||||
const name = ref(props.task?.name || "");
|
||||
const action = ref(props.task?.action || "start");
|
||||
const at = ref(props.task?.time || "23:00");
|
||||
const amps = ref(props.task?.amps || 16);
|
||||
|
||||
// The flow, as rows the form edits in place. A new task opens with the one step
|
||||
// most schedules start from, so the common case is a name and a time rather than
|
||||
// a decision about how many rows to add.
|
||||
const steps = ref(
|
||||
(props.task?.steps || []).length
|
||||
? props.task.steps.map((s) => ({ action: s.action, time: s.time, amps: s.amps || 16 }))
|
||||
: [{ action: "start", time: "23:00", amps: 16 }]
|
||||
);
|
||||
|
||||
// A flow of one is a flow, so the last row cannot be removed — an empty task
|
||||
// would have nothing to fire and the server refuses it anyway.
|
||||
function addStep() {
|
||||
steps.value = [...steps.value, { action: "stop", time: "06:30", amps: 16 }];
|
||||
}
|
||||
|
||||
function removeStep(i) {
|
||||
if (steps.value.length <= 1) return;
|
||||
steps.value = steps.value.filter((_, n) => n !== i);
|
||||
}
|
||||
// The chargers this task acts on. Empty is meaningful — it means all of them —
|
||||
// so the picker has a switch of its own rather than leaving an empty list
|
||||
// looking like an unfinished form.
|
||||
@@ -90,8 +107,12 @@ watch(allChargers, (on) => {
|
||||
});
|
||||
|
||||
// A time still being typed is not a time — TimeField says so with an empty
|
||||
// value, and a task cannot be saved without one.
|
||||
const canSave = computed(() => !!name.value.trim() && !!at.value && !saving.value);
|
||||
// value — and one unfinished row is enough to make the whole flow unsaveable,
|
||||
// because the server would otherwise refuse it with a step number the form does
|
||||
// not show.
|
||||
const canSave = computed(
|
||||
() => !!name.value.trim() && steps.value.every((s) => !!s.time) && !saving.value
|
||||
);
|
||||
|
||||
function chargerSubtitle(c) {
|
||||
return [c.serial, c.model].filter(Boolean).join(" · ");
|
||||
@@ -103,9 +124,14 @@ async function submit() {
|
||||
error.value = "";
|
||||
const body = {
|
||||
name: name.value.trim(),
|
||||
action: action.value,
|
||||
time: at.value,
|
||||
amps: Number(amps.value) || 0,
|
||||
// The amps ride along on every step so switching one to "limit" and back
|
||||
// does not lose the number that was typed; the server keeps them for the
|
||||
// same reason and ignores them on the actions that have no ceiling.
|
||||
steps: steps.value.map((s) => ({
|
||||
action: s.action,
|
||||
time: s.time,
|
||||
amps: s.action === "limit" ? Number(s.amps) || 0 : 0,
|
||||
})),
|
||||
chargers: allChargers.value ? [] : [...picked.value],
|
||||
days: everyDay.value ? [] : [...days.value],
|
||||
// The time is a wall clock, and the server's is not the one it was set by.
|
||||
@@ -147,29 +173,46 @@ function browserZone() {
|
||||
<input v-model="name" required class="dh-input" :placeholder="t('forms.chargingTask.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<!-- What and when. Side by side because they are read as one sentence:
|
||||
"start, at 23:00". -->
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.action") }}</label>
|
||||
<select v-model="action" class="dh-input">
|
||||
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.time") }}</label>
|
||||
<TimeField v-model="at" :aria-label="t('forms.chargingTask.time')" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- The flow. One row per step, each an action and the time it fires —
|
||||
read down, they are the night: start at 23:00, ease off at 01:00, stop
|
||||
at 06:30. -->
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.flow") }}</label>
|
||||
|
||||
<!-- The ceiling, for the one action that takes one. -->
|
||||
<div v-if="action === 'limit'">
|
||||
<label class="dh-label">
|
||||
{{ t("forms.chargingTask.amps") }}
|
||||
<span class="data float-right font-semibold text-strong">{{ amps }} A</span>
|
||||
</label>
|
||||
<input v-model.number="amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
|
||||
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
|
||||
<div v-for="(s, i) in steps" :key="i" class="mb-2 rounded-control bg-sunken p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<select v-model="s.action" class="dh-input min-w-0 flex-1">
|
||||
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
|
||||
</select>
|
||||
<TimeField v-model="s.time" :aria-label="t('forms.chargingTask.time')" />
|
||||
<!-- The last step cannot go: a task with no steps has nothing to
|
||||
fire, so the control is absent rather than there and refusing. -->
|
||||
<button
|
||||
v-if="steps.length > 1"
|
||||
type="button"
|
||||
class="shrink-0 rounded-control px-2 py-2 text-xs font-medium text-muted transition-colors hover:text-danger"
|
||||
:title="t('forms.chargingTask.removeStep')"
|
||||
@click="removeStep(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The ceiling, under the one action that takes one. -->
|
||||
<div v-if="s.action === 'limit'" class="mt-3">
|
||||
<label class="dh-label">
|
||||
{{ t("forms.chargingTask.amps") }}
|
||||
<span class="data float-right font-semibold text-strong">{{ s.amps }} A</span>
|
||||
</label>
|
||||
<input v-model.number="s.amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
|
||||
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="dh-btn dh-btn-ghost w-full !py-2 text-xs" @click="addStep">
|
||||
{{ t("forms.chargingTask.addStep") }}
|
||||
</button>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.flowHint") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Which chargers. The point of one scheduler for all of them. -->
|
||||
|
||||
@@ -389,9 +389,9 @@
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Ladeopgaver",
|
||||
"subtitle": "Én plan for alle dine ladere. Hver opgave sender én kommando på ét tidspunkt, på de dage du vælger, til de ladere du vælger.",
|
||||
"subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, på de ladere du vælger.",
|
||||
"add": "Ny opgave",
|
||||
"empty": "Ingen opgaver endnu. En opgave er én kommando på ét tidspunkt — start kl. 23:00 på hverdage, begræns til 10 A når taksten skifter.",
|
||||
"empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.",
|
||||
"needCharger": "Importér først en lader under Hjemmeladere — en opgave skal have noget at handle på.",
|
||||
"serverHint": "Opgaverne kører på serveren, så de udføres uanset om denne side er åben. Tidspunkter læses i den tidszone, du skrev dem i.",
|
||||
"allChargers": "Alle ladere",
|
||||
@@ -1278,8 +1278,11 @@
|
||||
"editTitle": "Rediger ladeopgave",
|
||||
"name": "Navn",
|
||||
"namePlaceholder": "Nattakst",
|
||||
"action": "Gør dette",
|
||||
"time": "Kl.",
|
||||
"flow": "Forløbet",
|
||||
"flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.",
|
||||
"addStep": "+ Tilføj et trin",
|
||||
"removeStep": "Fjern dette trin",
|
||||
"amps": "Strømgrænse",
|
||||
"ampsHint": "6 A er bundgrænsen — derunder sætter laderen på pause i stedet for at lade langsomt.",
|
||||
"chargers": "På disse ladere",
|
||||
|
||||
@@ -388,9 +388,9 @@
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Charging tasks",
|
||||
"subtitle": "One schedule for every charger you own. Each task sends one command at one time, on the days you pick, to the chargers you pick.",
|
||||
"subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on the chargers you pick.",
|
||||
"add": "New task",
|
||||
"empty": "No tasks yet. A task is one command at one time — start at 23:00 on weeknights, cap at 10 A when the tariff changes.",
|
||||
"empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.",
|
||||
"needCharger": "Import a charger under Home chargers first — a task needs something to act on.",
|
||||
"serverHint": "Tasks run on the server, so they fire whether or not this page is open. Times are read in the time zone you wrote them in.",
|
||||
"allChargers": "All chargers",
|
||||
@@ -1277,8 +1277,11 @@
|
||||
"editTitle": "Edit charging task",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Night rate",
|
||||
"action": "Do this",
|
||||
"time": "At",
|
||||
"flow": "The flow",
|
||||
"flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.",
|
||||
"addStep": "+ Add a step",
|
||||
"removeStep": "Remove this step",
|
||||
"amps": "Current limit",
|
||||
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
|
||||
"chargers": "On these chargers",
|
||||
|
||||
@@ -391,9 +391,9 @@
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Zadania ładowania",
|
||||
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Każde zadanie wysyła jedno polecenie o jednej godzinie, w wybrane dni, do wybranych ładowarek.",
|
||||
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.",
|
||||
"add": "Nowe zadanie",
|
||||
"empty": "Brak zadań. Zadanie to jedno polecenie o jednej godzinie — start o 23:00 w dni robocze, ograniczenie do 10 A po zmianie taryfy.",
|
||||
"empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.",
|
||||
"needCharger": "Najpierw zaimportuj ładowarkę w zakładce Ładowarki domowe — zadanie musi mieć na czym działać.",
|
||||
"serverHint": "Zadania działają na serwerze, więc uruchamiają się niezależnie od tego, czy ta strona jest otwarta. Godziny są odczytywane w strefie czasowej, w której je zapisano.",
|
||||
"allChargers": "Wszystkie ładowarki",
|
||||
@@ -1294,8 +1294,11 @@
|
||||
"editTitle": "Edytuj zadanie ładowania",
|
||||
"name": "Nazwa",
|
||||
"namePlaceholder": "Taryfa nocna",
|
||||
"action": "Zrób to",
|
||||
"time": "O godzinie",
|
||||
"flow": "Przebieg",
|
||||
"flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.",
|
||||
"addStep": "+ Dodaj krok",
|
||||
"removeStep": "Usuń ten krok",
|
||||
"amps": "Limit prądu",
|
||||
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
|
||||
"chargers": "Na tych ładowarkach",
|
||||
|
||||
@@ -1943,7 +1943,7 @@ const tasksError = ref("");
|
||||
const tasksLoaded = ref(false);
|
||||
const showTaskForm = ref(false);
|
||||
const editingTask = ref(null); // the task being edited, or null for a new one
|
||||
const runningTask = ref(""); // the task whose "Run now" is in flight
|
||||
const runningTask = ref(""); // "<task id>:<step index>" of the step being fired
|
||||
const togglingTask = ref("");
|
||||
|
||||
async function loadChargingTasks() {
|
||||
@@ -1974,7 +1974,13 @@ function onTaskSaved(task) {
|
||||
editingTask.value = null;
|
||||
const i = tasks.value.findIndex((x) => x.id === task.id);
|
||||
if (i >= 0) tasks.value.splice(i, 1, task);
|
||||
else tasks.value = [...tasks.value, task].sort((a, b) => a.time.localeCompare(b.time));
|
||||
else {
|
||||
// Ordered by the time each task begins, which is what the server sends back
|
||||
// and what the day runs them in.
|
||||
tasks.value = [...tasks.value, task].sort(
|
||||
(a, b) => (a.steps?.[0]?.time || "").localeCompare(b.steps?.[0]?.time || "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The switch in the row. Written straight through rather than optimistically:
|
||||
@@ -1994,15 +2000,18 @@ async function toggleTask(task) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fire a task now, without waiting for its time — the only way to find out
|
||||
// whether it will actually reach the charger before the night it matters. The
|
||||
// server takes the same path the clock takes, so what comes back is what will
|
||||
// happen then, errors included.
|
||||
async function runTask(task) {
|
||||
// Fire one step now, without waiting for its time — the only way to find out
|
||||
// whether it will actually reach the charger before the night it matters. One
|
||||
// step rather than the whole flow: running a start and the stop that closes it
|
||||
// back to back would leave the charger where it began and prove nothing.
|
||||
//
|
||||
// The server takes the same path the clock takes, so what comes back is what
|
||||
// will happen then, errors included.
|
||||
async function runStep(task, index) {
|
||||
tasksError.value = "";
|
||||
runningTask.value = task.id;
|
||||
runningTask.value = `${task.id}:${index}`;
|
||||
try {
|
||||
const res = await api.runChargingTask(task.id);
|
||||
const res = await api.runChargingStep(task.id, index);
|
||||
// The row's own "last run" line is what reports this, so the answer is
|
||||
// folded into the record rather than announced somewhere else.
|
||||
const i = tasks.value.findIndex((x) => x.id === task.id);
|
||||
@@ -2031,12 +2040,11 @@ async function removeTask(task) {
|
||||
}
|
||||
}
|
||||
|
||||
// What a task says it will do, in one line: the action, the chargers, the days.
|
||||
// Built here rather than in the template because all three have an "everything"
|
||||
// case that reads as a word rather than as a list.
|
||||
function taskActionLabel(task) {
|
||||
const label = t(`charging.scheduler.actions.${task.action}`);
|
||||
return task.action === "limit" ? `${label} · ${task.amps} A` : label;
|
||||
// What one step of a flow does. The ceiling is part of the sentence for the one
|
||||
// action that has one — "Set current limit" alone does not say to what.
|
||||
function stepActionLabel(step) {
|
||||
const label = t(`charging.scheduler.actions.${step.action}`);
|
||||
return step.action === "limit" ? `${label} · ${step.amps} A` : label;
|
||||
}
|
||||
|
||||
function taskChargersLabel(task) {
|
||||
@@ -2059,14 +2067,14 @@ function taskDaysLabel(task) {
|
||||
return sortWeekdays(days).map(weekdayShortName).join(" ");
|
||||
}
|
||||
|
||||
// The task's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
|
||||
// — the schedule is a wall clock, not a moment, so there is no date to hand
|
||||
// A step's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
|
||||
// — a schedule is a wall clock, not a moment, so there is no date to hand
|
||||
// formatTime — and this is the same reading TimeField offers when editing it.
|
||||
function taskClock(at) {
|
||||
const parts = /^(\d{1,2}):(\d{2})$/.exec(at?.time || "");
|
||||
if (!parts) return at?.time || "";
|
||||
function stepClock(step) {
|
||||
const parts = /^(\d{1,2}):(\d{2})$/.exec(step?.time || "");
|
||||
if (!parts) return step?.time || "";
|
||||
const h = Number(parts[1]);
|
||||
if (!clockIsTwelveHour()) return at.time;
|
||||
if (!clockIsTwelveHour()) return step.time;
|
||||
return `${String(h % 12 || 12).padStart(2, "0")}:${parts[2]} ${h < 12 ? "am" : "pm"}`;
|
||||
}
|
||||
|
||||
@@ -2083,9 +2091,10 @@ function tasksFor(charger) {
|
||||
// it has fired once — a task written this afternoon has nothing to report.
|
||||
function taskRunTone(task) {
|
||||
if (!task.lastRun) return "var(--text-muted)";
|
||||
// The server words the outcome as "n of m sent", plus the reasons when the
|
||||
// two numbers differ. Every charger answering is the only green case.
|
||||
const m = /^(\d+) of (\d+) sent$/.exec(task.lastResult || "");
|
||||
// The server words the outcome as the step it fired and then "n of m sent",
|
||||
// plus the reasons when the two numbers differ. Every charger answering is
|
||||
// the only green case.
|
||||
const m = /(\d+) of (\d+) sent$/.exec(task.lastResult || "");
|
||||
return m && m[1] === m[2] ? TONE.good.fg : TONE.due.fg;
|
||||
}
|
||||
|
||||
@@ -3461,21 +3470,15 @@ onUnmounted(() => {
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span class="data text-lg font-medium tracking-[-0.02em] text-strong">{{ taskClock(task) }}</span>
|
||||
<span class="truncate text-sm font-semibold text-strong">{{ task.name }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ taskActionLabel(task) }} · {{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
|
||||
</p>
|
||||
<p v-if="task.lastRun" class="mt-1 text-[11px]" :style="{ color: taskRunTone(task) }">
|
||||
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} —
|
||||
{{ task.lastResult || t("charging.scheduler.noResult") }}
|
||||
<p class="truncate text-sm font-semibold text-strong">{{ task.name }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- The switch. Written straight through, so what it shows is
|
||||
what the server will act on. -->
|
||||
what the server will act on. It governs the whole flow:
|
||||
the task is one intention and is switched off as one. -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-pill px-3 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
|
||||
@@ -3488,15 +3491,39 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
|
||||
:disabled="runningTask === task.id"
|
||||
@click="runTask(task)"
|
||||
<!-- The flow, a line per step. Read down, they are the night —
|
||||
which is the whole reason a task holds more than one. -->
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<div
|
||||
v-for="(s, i) in task.steps"
|
||||
:key="i"
|
||||
class="flex items-baseline gap-2 rounded-control px-2 py-1 transition-colors hover:bg-card"
|
||||
>
|
||||
{{ runningTask === task.id ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
|
||||
</button>
|
||||
<span class="data w-16 shrink-0 text-sm font-medium text-strong">{{ stepClock(s) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-xs text-body">{{ stepActionLabel(s) }}</span>
|
||||
<!-- Per step, because 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. -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-[11px] font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
|
||||
:disabled="runningTask === `${task.id}:${i}`"
|
||||
@click="runStep(task, i)"
|
||||
>
|
||||
{{ runningTask === `${task.id}:${i}` ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="task.lastRun" class="mt-2 text-[11px]" :style="{ color: taskRunTone(task) }">
|
||||
<!-- A middle dot rather than a dash: the outcome opens with the
|
||||
step's own time and a dash of its own, and three dashes in a
|
||||
row read as one long smudge. -->
|
||||
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} ·
|
||||
{{ task.lastResult || t("charging.scheduler.noResult") }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-muted transition-colors hover:text-body"
|
||||
|
||||
Reference in New Issue
Block a user