diff --git a/API Server/internal/api/chargingtasks.go b/API Server/internal/api/chargingtasks.go index 5e30031..aa86704 100644 --- a/API Server/internal/api/chargingtasks.go +++ b/API Server/internal/api/chargingtasks.go @@ -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), }) } diff --git a/API Server/internal/api/chargingtasks_run.go b/API Server/internal/api/chargingtasks_run.go index cda1f96..2321504 100644 --- a/API Server/internal/api/chargingtasks_run.go +++ b/API Server/internal/api/chargingtasks_run.go @@ -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) diff --git a/API Server/internal/api/chargingtasks_test.go b/API Server/internal/api/chargingtasks_test.go index 3a6590d..45076d1 100644 --- a/API Server/internal/api/chargingtasks_test.go +++ b/API Server/internal/api/chargingtasks_test.go @@ -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) } } diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 61d45ec..154fcd7 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -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) diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index 1fce83f..0b67c74 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -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"), diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index b527cd9..28adc25 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -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"` diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 0e3dc19..c2c755c 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -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"), diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 70b7f0a..ae5f2ed 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -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 diff --git a/Web App/web/src/components/ChargingTaskModal.vue b/Web App/web/src/components/ChargingTaskModal.vue index dc958b9..c4b87bf 100644 --- a/Web App/web/src/components/ChargingTaskModal.vue +++ b/Web App/web/src/components/ChargingTaskModal.vue @@ -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() { - -
-
- - -
-
- - -
-
+ +
+ - -
- - -

{{ t("forms.chargingTask.ampsHint") }}

+
+
+ + + + +
+ + +
+ + +

{{ t("forms.chargingTask.ampsHint") }}

+
+
+ + +

{{ t("forms.chargingTask.flowHint") }}

diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index ff36d78..8b7fd95 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -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", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 13e17de..0d8b058 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -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", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index b855717..5f30439 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -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", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 3263771..df50f58 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -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(""); // ":" 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(() => { >
-
- {{ taskClock(task) }} - {{ task.name }} -
-

- {{ taskActionLabel(task) }} · {{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }} -

-

- {{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} — - {{ task.lastResult || t("charging.scheduler.noResult") }} +

{{ task.name }}

+

+ {{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}

+ what the server will act on. It governs the whole flow: + the task is one intention and is switched off as one. -->
-
- + {{ stepClock(s) }} + {{ stepActionLabel(s) }} + + +
+
+ +

+ + {{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} · + {{ task.lastResult || t("charging.scheduler.noResult") }} +

+ +