diff --git a/API Server/cmd/server/main.go b/API Server/cmd/server/main.go index dc3299e..486a35c 100644 --- a/API Server/cmd/server/main.go +++ b/API Server/cmd/server/main.go @@ -73,6 +73,10 @@ func main() { // endpoints answer 503 until the read succeeds. _ = srv.StartPlugins() + // The home-charger scheduler's clock. A schedule that only fires while a + // browser tab is open is a reminder, not a schedule, so it is watched here. + srv.StartScheduler() + httpServer := &http.Server{ Addr: cfg.Addr, Handler: srv.Handler(), diff --git a/API Server/internal/api/chargingtasks.go b/API Server/internal/api/chargingtasks.go new file mode 100644 index 0000000..5e30031 --- /dev/null +++ b/API Server/internal/api/chargingtasks.go @@ -0,0 +1,398 @@ +package api + +// The home-charger scheduler: the user's own list of charging tasks. +// +// The charger's own cloud schedule can say one thing — "charge between these +// hours" — and it says it inside one charger. This is a list: as many tasks as +// the owner likes, each with its own action, time, days and set of chargers, and +// one list covering every charger on the account rather than a schedule hidden +// inside each box. +// +// GET /api/charging-tasks — the caller's tasks, earliest first +// POST /api/charging-tasks — write one +// PATCH /api/charging-tasks/{id} — edit one (any subset of its fields) +// DELETE /api/charging-tasks/{id} — forget one +// POST /api/charging-tasks/{id}/run — fire it now, without waiting for its time +// +// A task belongs to the person, like the chargers it acts on, so there is no +// sharing here: everyone sees their own list only. Firing one is the business of +// chargingtasks_run.go, which is also what the ticker calls. + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "drivervault/apiserver/internal/models" +) + +// The actions a task may carry. Each is a control command the charger already +// understands; the scheduler adds no verbs of its own, so a task can only ask +// for something the buttons on the Charging page can ask for by hand. +var taskActions = map[string]bool{ + "start": true, + "stop": true, + "limit": true, + "boost": true, +} + +// chargingTaskRecord is the PocketBase-facing shape of a task. +type chargingTaskRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Chargers []string `json:"chargers"` + Action string `json:"action"` + Amps float64 `json:"amps"` + Time string `json:"time"` + Zone string `json:"zone"` + Days []int `json:"days"` + Enabled bool `json:"enabled"` + LastRun string `json:"last_run"` + LastResult string `json:"last_result"` + Owner string `json:"owner"` + Created string `json:"created"` +} + +func (rec chargingTaskRecord) toModel() models.ChargingTask { + // The two lists are never null on the wire: the page iterates them, and a + // null would make "every charger" and "every day" read as an error there. + chargers := rec.Chargers + if chargers == nil { + chargers = []string{} + } + days := rec.Days + if days == nil { + days = []int{} + } + return models.ChargingTask{ + ID: rec.ID, + Name: rec.Name, + Chargers: chargers, + Action: rec.Action, + Amps: rec.Amps, + Time: rec.Time, + Zone: rec.Zone, + Days: days, + Enabled: rec.Enabled, + LastRun: rec.LastRun, + LastResult: rec.LastResult, + Created: rec.Created, + } +} + +// chargingTaskBody is the write shape. Every field is a pointer so a PATCH can +// name one of them without the rest being read as "clear these" — a task +// switched off from the list must not lose its days on the way. +type chargingTaskBody struct { + Name *string `json:"name"` + Chargers *[]string `json:"chargers"` + Action *string `json:"action"` + Amps *float64 `json:"amps"` + Time *string `json:"time"` + Zone *string `json:"zone"` + Days *[]int `json:"days"` + Enabled *bool `json:"enabled"` +} + +// parseHHMM splits a 24-hour clock time into its two numbers. "H:MM" is accepted +// as well as "HH:MM"; anything else is not a time of day. +func parseHHMM(raw string) (int, int, bool) { + hh, mm, found := strings.Cut(strings.TrimSpace(raw), ":") + if !found || len(hh) == 0 || len(hh) > 2 || len(mm) != 2 { + return 0, 0, false + } + h, err := strconv.Atoi(hh) + if err != nil { + return 0, 0, false + } + m, err := strconv.Atoi(mm) + if err != nil { + return 0, 0, false + } + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, 0, false + } + return h, m, true +} + +// normalizeTaskTime returns the padded 24-hour form of a time of day, or "" for +// anything that is not one. +func normalizeTaskTime(raw string) string { + h, m, ok := parseHHMM(raw) + if !ok { + return "" + } + return fmt.Sprintf("%02d:%02d", h, m) +} + +// normalizeDays drops anything that is not a weekday number and sorts what is +// left, so the stored list reads the same however the client sent it. Empty +// stays empty, which means every day. +func normalizeDays(days []int) []int { + seen := [7]bool{} + for _, d := range days { + if d >= 0 && d <= 6 { + seen[d] = true + } + } + out := []int{} + for d, ok := range seen { + if ok { + out = append(out, d) + } + } + return out +} + +// taskPayload turns a write body into the PocketBase fields it names, validating +// as it goes. `full` demands the fields a task cannot exist without, so a POST is +// checked as a whole and a PATCH only where it speaks. +func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) { + payload := map[string]any{} + + if body.Name != nil { + name := strings.TrimSpace(*body.Name) + if name == "" { + return nil, fmt.Errorf("name is required") + } + payload["name"] = name + } else if full { + return nil, fmt.Errorf("name is required") + } + + if body.Action != nil { + action := strings.TrimSpace(*body.Action) + if !taskActions[action] { + return nil, fmt.Errorf("unknown action: %s", action) + } + payload["action"] = action + } else if full { + return nil, fmt.Errorf("action is required") + } + + if body.Time != nil { + at := normalizeTaskTime(*body.Time) + if at == "" { + return nil, fmt.Errorf("time must be a 24-hour clock time, e.g. 23:00") + } + payload["time"] = at + } else if full { + return nil, fmt.Errorf("time is required") + } + + // The ceiling only means anything to "limit", but it is stored whatever the + // action is: switching a task to "limit" and back should not lose the amps + // that were typed. 6 A is the charger's own floor — below it the box pauses + // rather than charging slowly — and its ratings top out at 32 A. + if body.Amps != nil { + amps := *body.Amps + if amps != 0 && (amps < 6 || amps > 32) { + return nil, fmt.Errorf("the current limit must be between 6 and 32 A") + } + payload["amps"] = amps + } + + if body.Chargers != nil { + ids := []string{} + for _, id := range *body.Chargers { + if id = strings.TrimSpace(id); id != "" { + ids = append(ids, id) + } + } + payload["chargers"] = ids + } + if body.Days != nil { + payload["days"] = normalizeDays(*body.Days) + } + if body.Zone != nil { + payload["zone"] = strings.TrimSpace(*body.Zone) + } + if body.Enabled != nil { + payload["enabled"] = *body.Enabled + } else if full { + // A task written without saying otherwise is on: nobody fills in a + // schedule in order to leave it switched off. + payload["enabled"] = true + } + return payload, nil +} + +// checkLimitAmps refuses a task that ends up a "limit" with nothing to limit to, +// which would fire and ask the charger for 0 A. Both halves are read from what +// the write leaves behind — the payload where it speaks, the stored record where +// it does not — so a PATCH that names only one of them is checked against the +// other rather than against nothing. +func checkLimitAmps(payload map[string]any, stored chargingTaskRecord) error { + action := stored.Action + if v, ok := payload["action"].(string); ok { + action = v + } + amps := stored.Amps + if v, ok := payload["amps"].(float64); ok { + amps = v + } + if action == "limit" && amps <= 0 { + return fmt.Errorf("a limit task needs the amps to limit to") + } + return nil +} + +// listChargingTasks returns the caller's own tasks, earliest in the day first — +// the order the day runs in, which is the order a schedule is read in. +func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) { + me := s.currentUserID(r) + if me == "" { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + query := func(sort string) url.Values { + q := url.Values{ + "filter": {fmt.Sprintf("owner='%s'", me)}, + "perPage": {"200"}, + } + if sort != "" { + q.Set("sort", sort) + } + return q + } + res, err := s.pb.List(r.Context(), colChargingTasks, query("time")) + if err != nil { + // Same fallback the charger list makes: an order is a nicety, the list is + // not, so a collection that predates the sort field still answers. + res, err = s.pb.List(r.Context(), colChargingTasks, query("")) + } + if err != nil { + writePBError(w, err) + return + } + var recs []chargingTaskRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]models.ChargingTask, 0, len(recs)) + for _, rec := range recs { + out = append(out, rec.toModel()) + } + writeJSON(w, http.StatusOK, map[string]any{"tasks": out}) +} + +// ownedChargingTask fetches one task and checks it is the caller's. It writes the +// error response itself and returns ok=false when it is not. +func (s *Server) ownedChargingTask(w http.ResponseWriter, r *http.Request) (chargingTaskRecord, bool) { + var rec chargingTaskRecord + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "task id is required") + return rec, false + } + if err := s.pb.GetOne(r.Context(), colChargingTasks, id, &rec); err != nil { + writePBError(w, err) + return rec, false + } + // Someone else's task is not found rather than forbidden, as a charger is: + // whether a record exists is not this caller's business either. + if rec.Owner != s.currentUserID(r) { + writeError(w, http.StatusNotFound, "task not found") + return rec, false + } + return rec, true +} + +func (s *Server) createChargingTask(w http.ResponseWriter, r *http.Request) { + me := s.currentUserID(r) + if me == "" { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + var body chargingTaskBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + payload, err := taskPayload(body, true) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := checkLimitAmps(payload, chargingTaskRecord{}); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + payload["owner"] = me + var rec chargingTaskRecord + if err := s.pb.Create(r.Context(), colChargingTasks, payload, &rec); err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"task": rec.toModel()}) +} + +func (s *Server) updateChargingTask(w http.ResponseWriter, r *http.Request) { + rec, ok := s.ownedChargingTask(w, r) + if !ok { + return + } + var body chargingTaskBody + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + payload, err := taskPayload(body, false) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := checkLimitAmps(payload, rec); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if len(payload) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"task": rec.toModel()}) + return + } + var updated chargingTaskRecord + if err := s.pb.Update(r.Context(), colChargingTasks, rec.ID, payload, &updated); err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"task": updated.toModel()}) +} + +func (s *Server) deleteChargingTask(w http.ResponseWriter, r *http.Request) { + rec, ok := s.ownedChargingTask(w, r) + if !ok { + return + } + if err := s.pb.Delete(r.Context(), colChargingTasks, rec.ID); err != nil { + writePBError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// runChargingTaskNow fires a task on demand — the "Run now" button beside it. It +// takes the same path the ticker does, so what comes back is exactly what the +// task will do at its own time, errors included. A switched-off task still runs +// from here: the switch says whether the clock fires it, not whether the button +// does. +func (s *Server) runChargingTaskNow(w http.ResponseWriter, r *http.Request) { + rec, ok := s.ownedChargingTask(w, r) + if !ok { + return + } + who := caller(r) + if who == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + results := s.fireChargingTask(r.Context(), who, rec) + s.recordTaskRun(r.Context(), rec, results) + writeJSON(w, http.StatusOK, map[string]any{ + "results": results, + "summary": summarizeTaskRun(results), + }) +} diff --git a/API Server/internal/api/chargingtasks_run.go b/API Server/internal/api/chargingtasks_run.go new file mode 100644 index 0000000..cda1f96 --- /dev/null +++ b/API Server/internal/api/chargingtasks_run.go @@ -0,0 +1,347 @@ +package api + +// Firing the scheduler's tasks: the ticker that watches the clock, and the one +// path a task takes whichever set it off. +// +// The scheduler is only worth having if it fires with nobody looking, so the +// clock is watched here rather than in the browser — a schedule that needs the +// page open is a reminder, not a schedule. The ticker sweeps every enabled task +// on the server, of every user, and fires whichever ones' minute has come. +// +// A task acts by sending exactly the control command the Charging page's own +// buttons send: the request is built here and handed to the same handler, so it +// goes through the same cascade, the same ownership gate, the same rate limit +// and the same audit trail. Nothing about a scheduled command is privileged — +// what the owner cannot press by hand, the scheduler cannot send for them. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "sort" + "strings" + "time" + _ "time/tzdata" // tasks are timed in the user's own zone; hosts without a zone database are common on Windows +) + +// How often the clock is looked at. Tasks are timed to the minute, so a sweep +// twice a minute is enough to land in every minute without the drift a +// once-a-minute ticker accumulates. +const taskTick = 30 * time.Second + +// How long one sweep may take. Generous rather than tight: the cloud transport +// alone allows 45 seconds for a single command, and a task can name several +// chargers. A sweep that overran would not overlap the next one — the ticker +// drops a tick nobody is waiting on — but it would abandon chargers halfway +// down a task's list, which is the worst of both. +const taskSweepTimeout = 5 * time.Minute + +// taskRunResult is what happened to one charger in one firing. +type taskRunResult struct { + Charger string `json:"charger"` // home charger record id + Name string `json:"name"` + Serial string `json:"serial,omitempty"` + Status string `json:"status,omitempty"` // the charger's own answer ("Accepted", …) + Error string `json:"error,omitempty"` +} + +// StartScheduler starts the ticker that fires due charging tasks. It is safe to +// call before PocketBase is reachable: a sweep that cannot read the tasks simply +// finds none and the next one tries again. +// +// One server runs one scheduler. Two API Servers pointed at the same database +// would both sweep and both fire; the deployment is a single server (see the +// README), and the last_run guard below narrows the window rather than closing +// it. +func (s *Server) StartScheduler() { + ctx, cancel := context.WithCancel(context.Background()) + s.schedulerStop = cancel + go func() { + t := time.NewTicker(taskTick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + s.sweepChargingTasks(ctx) + } + } + }() +} + +// sweepChargingTasks fires every task whose minute has come. +func (s *Server) sweepChargingTasks(ctx context.Context) { + if !s.pb.Configured() { + return + } + ctx, cancel := context.WithTimeout(ctx, taskSweepTimeout) + defer cancel() + + res, err := s.pb.List(ctx, colChargingTasks, url.Values{ + "filter": {"enabled=true"}, + "perPage": {"500"}, + }) + if err != nil { + // A database that is not answering is not an error worth a line every + // thirty seconds; the tasks are still there when it comes back. + return + } + var recs []chargingTaskRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + return + } + now := time.Now() + for _, rec := range recs { + if !taskIsDue(rec, now) { + continue + } + who, _, err := s.callerForUser(ctx, rec.Owner) + if err != nil { + 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)) + } +} + +// taskIsDue reports whether a task's minute has come and it has not already been +// fired in it. +// +// 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 +// 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 + } + loc := time.Local + if rec.Zone != "" { + if l, err := time.LoadLocation(rec.Zone); err == nil { + loc = l + } + } + 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 + } + // 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. + if last, err := time.Parse(time.RFC3339, rec.LastRun); err == nil { + if !last.Before(local.Truncate(time.Minute)) { + return false + } + } + return true +} + +func containsDay(days []int, day int) bool { + for _, d := range days { + if d == day { + return true + } + } + 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 { + chargers, err := s.ownerChargers(ctx, rec.Owner) + if err != nil { + return []taskRunResult{{Error: err.Error()}} + } + wanted := rec.Chargers + results := []taskRunResult{} + for _, c := range chargers { + if len(wanted) > 0 && !containsID(wanted, c.ID) { + continue + } + out := taskRunResult{Charger: c.ID, Name: c.Name, Serial: c.Serial} + if c.Serial == "" { + // Every transport addresses a charger by its serial, so one without + // is not a charger anything can be sent to. + out.Error = "this charger has no serial number, so no command can be addressed to it" + 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 + } + results = append(results, out) + } + if len(results) == 0 { + results = append(results, taskRunResult{Error: "this task names no charger that still exists"}) + } + return results +} + +// ownerChargers reads one user's imported chargers. The scheduler reads them +// itself rather than trusting the ids on the task: a charger removed from the +// account should stop being acted on, whether or not the task was edited. +func (s *Server) ownerChargers(ctx context.Context, owner string) ([]homeChargerRecord, error) { + res, err := s.pb.List(ctx, colHomeChargers, url.Values{ + "filter": {fmt.Sprintf("owner='%s'", owner)}, + "perPage": {"200"}, + }) + if err != nil { + return nil, err + } + var recs []homeChargerRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + return nil, err + } + return recs, nil +} + +func containsID(ids []string, id string) bool { + for _, x := range ids { + if x == id { + return true + } + } + return false +} + +// sendTaskAction sends one task'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) { + body := map[string]any{} + if rec.Action == "limit" { + body["amps"] = rec.Amps + } + raw, err := json.Marshal(body) + if err != nil { + return "", err + } + path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + rec.Action + req, err := http.NewRequestWithContext( + context.WithValue(ctx, ctxCaller, who), http.MethodPost, path, bytes.NewReader(raw)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + // 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) + + rw := &captureWriter{header: http.Header{}} + s.handleAnkerControlAction(rw, req) + return rw.controlOutcome() +} + +// captureWriter is an http.ResponseWriter that keeps the response in memory, +// so a handler can be called without a connection to write down. +type captureWriter struct { + status int + header http.Header + body bytes.Buffer +} + +func (c *captureWriter) Header() http.Header { return c.header } + +func (c *captureWriter) WriteHeader(status int) { + if c.status == 0 { + c.status = status + } +} + +func (c *captureWriter) Write(p []byte) (int, error) { + if c.status == 0 { + c.status = http.StatusOK + } + return c.body.Write(p) +} + +// controlOutcome reads a captured control response as the charger's answer or +// the reason there wasn't one. The endpoint answers in one of two shapes — a +// status on success, an error envelope otherwise — and both are read here rather +// than the status code alone, because "why not" is the half worth keeping. +func (c *captureWriter) controlOutcome() (string, error) { + var out struct { + Status string `json:"status"` + Error string `json:"error"` + } + _ = json.Unmarshal(c.body.Bytes(), &out) + if c.status >= 200 && c.status < 300 { + if out.Status == "" { + return "sent", nil + } + return out.Status, nil + } + if out.Error != "" { + return "", fmt.Errorf("%s", out.Error) + } + return "", fmt.Errorf("the charger could not be reached (HTTP %d)", c.status) +} + +// summarizeTaskRun says how a firing went in one line — what the list shows +// beside a task, and what goes in the log. +func summarizeTaskRun(results []taskRunResult) string { + ok, failed := 0, []string{} + for _, r := range results { + if r.Error == "" { + ok++ + continue + } + name := r.Name + if name == "" { + name = r.Serial + } + if name != "" { + failed = append(failed, name+": "+r.Error) + } else { + failed = append(failed, r.Error) + } + } + sort.Strings(failed) + switch { + case len(failed) == 0: + return fmt.Sprintf("%d of %d sent", ok, len(results)) + case ok == 0 && len(failed) == 1: + return failed[0] + default: + return fmt.Sprintf("%d of %d sent — %s", ok, len(results), strings.Join(failed, "; ")) + } +} + +// 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) { + payload := map[string]any{ + "last_run": time.Now().UTC().Format(time.RFC3339), + "last_result": summarizeTaskRun(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 new file mode 100644 index 0000000..3a6590d --- /dev/null +++ b/API Server/internal/api/chargingtasks_test.go @@ -0,0 +1,272 @@ +package api + +import ( + "strings" + "testing" + "time" +) + +// 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 +// 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) { + warsaw, err := time.LoadLocation("Europe/Warsaw") + if err != nil { + t.Fatalf("Europe/Warsaw: %v", err) + } + // 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") + } + + // Weekdays. 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") + } + weekends := base + weekends.Days = []int{0, 6} + if taskIsDue(weekends, at2300) { + t.Error("a weekend task fired on a Wednesday") + } + + // Fired already inside this minute: the sweep runs more than once a minute, + // 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") + } + // 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) { + 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. + broken := base + broken.Time = "later" + if taskIsDue(broken, at2300) { + t.Error("a task with an unreadable time fired") + } +} + +// 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) { + now := time.Now() + rec := chargingTaskRecord{ + Time: now.Format("15:04"), + Zone: "Mars/Olympus_Mons", + } + if !taskIsDue(rec, now) { + t.Error("a task in an unknown zone did not fall back to the server's clock") + } +} + +func TestParseHHMM(t *testing.T) { + for _, tc := range []struct { + in string + h, m int + wantOK bool + }{ + {in: "23:00", h: 23, m: 0, wantOK: true}, + {in: "7:05", h: 7, m: 5, wantOK: true}, + {in: " 00:00 ", h: 0, m: 0, wantOK: true}, + {in: "24:00"}, + {in: "12:60"}, + {in: "12:5"}, // a half-typed minute is not a minute + {in: "123:00"}, // nor is a three-digit hour + {in: "12"}, + {in: ""}, + {in: "ab:cd"}, + } { + h, m, ok := parseHHMM(tc.in) + if ok != tc.wantOK { + t.Errorf("parseHHMM(%q) ok = %v, want %v", tc.in, ok, tc.wantOK) + continue + } + if ok && (h != tc.h || m != tc.m) { + t.Errorf("parseHHMM(%q) = %d:%d, want %d:%d", tc.in, h, m, tc.h, tc.m) + } + } +} + +func TestNormalizeDays(t *testing.T) { + got := normalizeDays([]int{5, 1, 5, 9, -1, 0}) + want := []int{0, 1, 5} + if len(got) != len(want) { + t.Fatalf("normalizeDays = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("normalizeDays = %v, want %v", got, want) + } + } + // Empty means every day and stays empty rather than becoming all seven — + // the two read the same today, but only one keeps meaning "every day". + if len(normalizeDays(nil)) != 0 { + t.Error("normalizeDays(nil) invented days") + } +} + +func TestTaskPayloadValidation(t *testing.T) { + str := func(s string) *string { return &s } + num := func(f float64) *float64 { return &f } + + // A whole task, as a create sends it. + full := chargingTaskBody{ + Name: str(" Night rate "), + Action: str("limit"), + Time: str("7:05"), + Amps: num(10), + } + payload, err := taskPayload(full, true) + if err != nil { + t.Fatalf("taskPayload: %v", err) + } + 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") + } + + // The fields 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")}, + } { + 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. + on := true + partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false) + if err != nil { + t.Fatalf("taskPayload(partial): %v", err) + } + if len(partial) != 1 { + t.Errorf("a switch-only write touched %v, want only enabled", partial) + } +} + +// 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") + } + 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") + } +} + +func TestSummarizeTaskRun(t *testing.T) { + all := summarizeTaskRun([]taskRunResult{ + {Name: "Home-DK", Status: "Accepted"}, + {Name: "Home-PL", Status: "Accepted"}, + }) + if all != "2 of 2 sent" { + t.Errorf("summary = %q, want \"2 of 2 sent\"", all) + } + + // One charger failing is the case the list has to be able to show: the + // summary names which, because "1 of 2 sent" alone is not actionable. + some := summarizeTaskRun([]taskRunResult{ + {Name: "Home-DK", Status: "Accepted"}, + {Name: "Home-PL", Error: "charger is not connected"}, + }) + if !strings.Contains(some, "Home-PL") || !strings.Contains(some, "not connected") { + t.Errorf("summary = %q, want it to name the charger that failed and why", some) + } + + // A single failure is its own reason — no count to read past. + only := summarizeTaskRun([]taskRunResult{{Name: "Home-PL", Error: "control mode is off"}}) + if only != "Home-PL: control mode is off" { + t.Errorf("summary = %q, want the bare reason", only) + } +} + +// The runner reads a control response the same way whichever transport answered +// it: a status when the charger took the command, the endpoint's own words when +// it did not. +func TestCaptureWriterControlOutcome(t *testing.T) { + ok := &captureWriter{header: nil} + ok.WriteHeader(200) + _, _ = ok.Write([]byte(`{"status":"Accepted"}`)) + if got, err := ok.controlOutcome(); err != nil || got != "Accepted" { + t.Errorf("controlOutcome = %q, %v; want Accepted", got, err) + } + + // A 2xx with nothing to read still means the command went. + bare := &captureWriter{} + bare.WriteHeader(204) + if got, err := bare.controlOutcome(); err != nil || got != "sent" { + t.Errorf("controlOutcome = %q, %v; want sent", got, err) + } + + bad := &captureWriter{} + bad.WriteHeader(409) + _, _ = bad.Write([]byte(`{"error":"charger is not connected to the control backend"}`)) + _, err := bad.controlOutcome() + if err == nil || !strings.Contains(err.Error(), "not connected") { + t.Errorf("controlOutcome err = %v, want the endpoint's own reason", err) + } + + // An error status with no envelope to read still has to say something. + mute := &captureWriter{} + mute.WriteHeader(502) + if _, err := mute.controlOutcome(); err == nil { + t.Error("a 502 with an empty body was read as a success") + } +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index c5e511b..61d45ec 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -159,6 +159,7 @@ const ( colDocuments = "car_documents" colReminders = "reminders" colHomeChargers = "home_chargers" + colChargingTasks = "charging_tasks" colControlAudit = "control_audit" colAppSettings = "app_settings" ) @@ -182,6 +183,10 @@ type Server struct { ocpp *ocpp.CSMS control *controlIndex // token -> owning user/charger for the /ocpp endpoint ctlRL *rateLimiter // per user+charger control-command rate limit + + // schedulerStop ends the ticker that fires the home-charger scheduler's + // tasks, started by StartScheduler (see chargingtasks_run.go). + schedulerStop context.CancelFunc } // New constructs a Server around an already-built PocketBase client. @@ -285,6 +290,9 @@ func (s *Server) Stop(ctx context.Context) { if s.pluginsStop != nil { s.pluginsStop() } + if s.schedulerStop != nil { + s.schedulerStop() + } s.ocpp.Shutdown(ctx) s.plugins.Shutdown(ctx) } @@ -488,6 +496,14 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PATCH /api/home-chargers/{id}", s.updateHomeCharger) mux.HandleFunc("DELETE /api/home-chargers/{id}", s.deleteHomeCharger) + // The home-charger scheduler: one list of charging tasks per user, covering + // every charger they own. See chargingtasks.go. + mux.HandleFunc("GET /api/charging-tasks", s.listChargingTasks) + 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) + // Cars + sharing. mux.HandleFunc("GET /api/cars", s.listCars) mux.HandleFunc("POST /api/cars", s.createCar) diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index a087718..c07c5f4 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -225,6 +225,34 @@ var collectionsSchema = map[string][]fieldDef{ // audit trail's. fAutodate("created", true, false), }, + // The home-charger scheduler: the user's own list of charging tasks, one list + // covering every charger they own. The charger's own cloud schedule holds one + // window per box; this holds as many tasks as they like, each naming its own + // chargers, days and action. Run by the ticker in internal/api/chargingtasks_run.go. + "charging_tasks": { + fText("name", true), + // The home_chargers rows this task acts on. Stored as a list of ids rather + // than a relation because empty has to mean "every charger I own" — a + // 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), + fText("zone", false), + fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day + fBool("enabled"), + // The outcome of the last firing, so a task that has been failing quietly + // says so in the list. last_run is also the guard against firing twice in + // the same minute. + fText("last_run", false), // RFC3339, UTC + fText("last_result", false), + // Owner. Non-cascading, like a charger's. + fRelation("owner", "users", false, false), + fAutodate("created", true, false), + }, // Custom fields layered onto the built-in "users" auth collection. "users": { fText("bio", false), @@ -280,6 +308,7 @@ var createOrder = []string{ "reminders", "control_audit", "home_chargers", + "charging_tasks", } // reconcileOrder additionally includes "users" so its custom fields (role, @@ -300,6 +329,7 @@ var reconcileOrder = []string{ "reminders", "control_audit", "home_chargers", + "charging_tasks", } // indexes are extra SQL indexes applied at collection-create time. @@ -316,6 +346,9 @@ var indexes = map[string][]string{ // A charger is looked up by its owner, and by serial when checking whether // the account it came from has already been imported. "home_chargers": {"CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"}, + // The runner sweeps every enabled task on every tick, and the page reads one + // owner's; both go through these two columns. + "charging_tasks": {"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)"}, "control_audit": { "CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)", "CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)", diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index cbc4bed..f8116c4 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -152,6 +152,48 @@ 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. +// +// 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. +type ChargingTask struct { + ID string `json:"id"` + Name string `json:"name"` + + // The home-charger records this task acts on. Empty means every charger the + // owner has, including ones imported after the task was written — "all of + // 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"` + + // When, as a 24-hour "HH:MM" read in Zone — the IANA zone 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"` + Zone string `json:"zone,omitempty"` + + // The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day. + Days []int `json:"days"` + + 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. + LastRun string `json:"lastRun,omitempty"` // RFC3339, UTC + LastResult string `json:"lastResult,omitempty"` + + Created string `json:"created,omitempty"` +} + // ServiceRecord is one row of the Service log for a car. type ServiceRecord struct { ID string `json:"id"` diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 1cc69a4..61e2fbb 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -462,6 +462,30 @@ const DESIRED = { // defined through the API, so it is declared here like the audit trail's. F.autodate("created", true, false), ], + // The home-charger scheduler: a user's own list of charging tasks, one list + // covering every charger they own. The charger's own cloud schedule holds one + // window per box; this holds as many tasks as they like, each naming its own + // chargers, days and action. Run by the ticker in the API Server + // (internal/api/chargingtasks_run.go). + charging_tasks: [ + F.text("name", true), + // The home_chargers rows this task acts on. A list of ids rather than a + // 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), + F.text("zone"), + F.json("days", 200), // 0=Sunday … 6=Saturday; empty means every day + F.bool("enabled"), + F.text("last_run"), // RFC3339, UTC — also the guard against a double firing + F.text("last_result"), + // Owner. Non-cascading, like a charger's. + F.relation("owner", "users", false, false), + F.autodate("created", true, false), + ], // Server-wide settings as a single record, keyed "global". Today it holds // pluginSettings: the top (L1) layer of the integration cascade — every // plugin's enable state, its global config, and the registration of any @@ -549,6 +573,11 @@ const INDEXES = { // A charger is looked up by its owner, and by serial when checking whether the // account it came from has already been imported. home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"], + // The runner sweeps every enabled task on every tick, and the page reads one + // owner's; both go through these two columns. + charging_tasks: [ + "CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)", + ], // Audit is queried "this charger's events, newest first" and "this user's events". control_audit: [ "CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)", @@ -586,6 +615,7 @@ async function main() { "reminders", "control_audit", "home_chargers", + "charging_tasks", ]) { if (collections.some((c) => c.name === name)) continue; await createCollection(token, name, DESIRED[name], format, idByName); @@ -614,6 +644,7 @@ async function main() { "reminders", "control_audit", "home_chargers", + "charging_tasks", ]) { await reconcileFields(token, name, DESIRED[name], format, idByName); } @@ -622,7 +653,8 @@ async function main() { "\nDone. Collections ready: app_settings, organizations, users, cars,\n" + "service_records,\n" + "technical_checks, parts, car_shares, fuel_entries, charging_sessions,\n" + - "maintenance_entries, car_documents, reminders, control_audit, home_chargers.", + "maintenance_entries, car_documents, reminders, control_audit, home_chargers,\n" + + "charging_tasks.", ); console.log( "Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" + diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index ca87d70..70b7f0a 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -316,6 +316,23 @@ export const api = { }), deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }), + // 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. + listChargingTasks: () => request("/charging-tasks").then((r) => r.tasks), + createChargingTask: (body) => + request("/charging-tasks", { method: "POST", body: JSON.stringify(body) }).then((r) => r.task), + updateChargingTask: (id, body) => + request(`/charging-tasks/${encodeURIComponent(id)}`, { + method: "PATCH", + 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" }), + // Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix // returns the resolved view (effective/own/locked per field, secrets and // inherited emails masked); saveAnkerSolix writes the caller's editable layer; diff --git a/Web App/web/src/components/ChargingTaskModal.vue b/Web App/web/src/components/ChargingTaskModal.vue new file mode 100644 index 0000000..389bba7 --- /dev/null +++ b/Web App/web/src/components/ChargingTaskModal.vue @@ -0,0 +1,234 @@ + + + diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index ea4e120..3961a0c 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -50,7 +50,8 @@ "tabs": { "dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.", "public": "Offentlige ladere", - "home": "Hjemmeladere" + "home": "Hjemmeladere", + "scheduler": "Planlægning af hjemmelader" }, "liveMap": "Live-kort", "youAreHere": "Du er her", @@ -385,6 +386,33 @@ "refresh": "Opdater", "rawTitle": "Som tjenesten melder det", "rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt." + }, + "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.", + "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.", + "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", + "missingChargers": "Ingen lader på kontoen længere", + "everyDay": "Hver dag", + "runNow": "Kør nu", + "running": "Sender…", + "lastRun": "Sidst kørt {when}", + "noResult": "intet resultat registreret", + "toggleHint": "Om uret udløser denne opgave.", + "removeConfirm": "Slet opgaven “{name}”?", + "taskCount": { + "one": "{n} opgave", + "other": "{n} opgaver" + }, + "actions": { + "start": "Start opladning", + "stop": "Stop opladning", + "limit": "Sæt strømgrænse", + "boost": "Boost sessionen" + } } }, @@ -1239,6 +1267,22 @@ "submit": "Del", "peopleWithAccess": "Personer med adgang", "notShared": "Endnu ikke delt med nogen." + }, + "chargingTask": { + "title": "Ny ladeopgave", + "editTitle": "Rediger ladeopgave", + "name": "Navn", + "namePlaceholder": "Nattakst", + "action": "Gør dette", + "time": "Kl.", + "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", + "allChargers": "Alle mine ladere", + "allChargersHint": "Inklusive ladere du importerer senere.", + "noChargers": "Ingen ladere på kontoen endnu.", + "days": "På disse dage", + "everyDay": "Hver dag" } }, diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index e60298e..4ad506b 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -50,7 +50,8 @@ "tabs": { "dragHint": "Drag a tab to rearrange the charging tabs.", "public": "Public chargers", - "home": "Home chargers" + "home": "Home chargers", + "scheduler": "Home charger scheduler" }, "liveMap": "Live map", "youAreHere": "You are here", @@ -384,6 +385,33 @@ "full": "Full", "offPeak": "off-peak", "homeCharger": "Home charger" + }, + "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.", + "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.", + "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", + "missingChargers": "No charger on your account any more", + "everyDay": "Every day", + "runNow": "Run now", + "running": "Sending…", + "lastRun": "Last run {when}", + "noResult": "no result recorded", + "toggleHint": "Whether the clock fires this task.", + "removeConfirm": "Delete the task “{name}”?", + "taskCount": { + "one": "{n} task", + "other": "{n} tasks" + }, + "actions": { + "start": "Start charging", + "stop": "Stop charging", + "limit": "Set current limit", + "boost": "Boost the session" + } } }, @@ -1238,6 +1266,22 @@ "submit": "Share", "peopleWithAccess": "People with access", "notShared": "Not shared with anyone yet." + }, + "chargingTask": { + "title": "New charging task", + "editTitle": "Edit charging task", + "name": "Name", + "namePlaceholder": "Night rate", + "action": "Do this", + "time": "At", + "amps": "Current limit", + "ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.", + "chargers": "On these chargers", + "allChargers": "All my chargers", + "allChargersHint": "Including any charger you import later.", + "noChargers": "No chargers on your account yet.", + "days": "On these days", + "everyDay": "Every day" } }, diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 86e0bdb..1c4980f 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -50,7 +50,8 @@ "tabs": { "dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.", "public": "Ładowarki publiczne", - "home": "Ładowarki domowe" + "home": "Ładowarki domowe", + "scheduler": "Harmonogram ładowarki" }, "liveMap": "Mapa na żywo", "youAreHere": "Tu jesteś", @@ -387,6 +388,35 @@ "refresh": "Odśwież", "rawTitle": "Tak, jak podaje to usługa", "rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw." + }, + "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.", + "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.", + "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", + "missingChargers": "Nie ma już takiej ładowarki na koncie", + "everyDay": "Codziennie", + "runNow": "Uruchom teraz", + "running": "Wysyłanie…", + "lastRun": "Ostatnio {when}", + "noResult": "brak zapisanego wyniku", + "toggleHint": "Czy zegar uruchamia to zadanie.", + "removeConfirm": "Usunąć zadanie „{name}”?", + "taskCount": { + "one": "{n} zadanie", + "few": "{n} zadania", + "many": "{n} zadań", + "other": "{n} zadania" + }, + "actions": { + "start": "Rozpocznij ładowanie", + "stop": "Zatrzymaj ładowanie", + "limit": "Ustaw limit prądu", + "boost": "Przyspiesz sesję" + } } }, @@ -1253,6 +1283,22 @@ "submit": "Udostępnij", "peopleWithAccess": "Osoby z dostępem", "notShared": "Jeszcze nikomu nie udostępniono." + }, + "chargingTask": { + "title": "Nowe zadanie ładowania", + "editTitle": "Edytuj zadanie ładowania", + "name": "Nazwa", + "namePlaceholder": "Taryfa nocna", + "action": "Zrób to", + "time": "O godzinie", + "amps": "Limit prądu", + "ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.", + "chargers": "Na tych ładowarkach", + "allChargers": "Wszystkie moje ładowarki", + "allChargersHint": "Łącznie z ładowarkami zaimportowanymi później.", + "noChargers": "Na koncie nie ma jeszcze ładowarek.", + "days": "W te dni", + "everyDay": "Codziennie" } }, diff --git a/Web App/web/src/lib/tabs.js b/Web App/web/src/lib/tabs.js index 4e5db93..fe2026c 100644 --- a/Web App/web/src/lib/tabs.js +++ b/Web App/web/src/lib/tabs.js @@ -10,7 +10,9 @@ import { prefs } from "../prefs.js"; -export const CHARGING_TABS = ["public", "home"]; +// The scheduler comes last by default: it acts on the chargers the tab before +// it lists, so it reads as the thing you set up once the chargers are there. +export const CHARGING_TABS = ["public", "home", "scheduler"]; // A car's tabs in their default order. Information sits second because the // connected service, when there is one, is what you came to look at. diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index a3ff4cd..80651de 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -4,10 +4,11 @@ import { t } from "../i18n"; import { prefs } from "../prefs"; import { askConfirm } from "../lib/confirm.js"; import { api } from "../api"; -import { formatDateTime } from "../lib/format.js"; +import { formatDateTime, clockIsTwelveHour } from "../lib/format.js"; import TimeField from "../components/TimeField.vue"; import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js"; import ChargerImportModal from "../components/ChargerImportModal.vue"; +import ChargingTaskModal from "../components/ChargingTaskModal.vue"; // Charging & map screen, mirroring the web-dashboard UI kit. There is no live // charging API yet (only the Anker Solix credential cascade in Settings), so the @@ -1925,10 +1926,183 @@ function cancelReset() { resetPassword.value = ""; } +// --- The scheduler: one list of charging tasks, covering every charger -------- +// +// The charger's own cloud schedule is one window inside one box: charge between +// these hours, every day, and that is the whole vocabulary. This is a list — +// each line an action, a time, the days it repeats on and the chargers it acts +// on — and one list covers the whole account rather than each charger hiding its +// own. +// +// The clock is the server's, not this page's. A schedule that only fires while a +// tab is open would be a reminder; the page writes tasks and reads back how each +// one last went. +const tasks = ref([]); +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 togglingTask = ref(""); + +async function loadChargingTasks() { + tasksError.value = ""; + try { + tasks.value = await api.listChargingTasks(); + tasksLoaded.value = true; + } catch (e) { + tasksError.value = e.message; + } +} + +function newTask() { + editingTask.value = null; + showTaskForm.value = true; +} + +function editTask(task) { + editingTask.value = task; + showTaskForm.value = true; +} + +// A saved task replaces its old self in place rather than the list being fetched +// again: the row that was just edited should not move under the pointer that +// edited it, and a new one belongs where its time puts it. +function onTaskSaved(task) { + showTaskForm.value = false; + 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)); +} + +// The switch in the row. Written straight through rather than optimistically: +// a schedule that says it is on when the server thinks otherwise is the one +// mistake this list must not make. +async function toggleTask(task) { + tasksError.value = ""; + togglingTask.value = task.id; + try { + const saved = await api.updateChargingTask(task.id, { enabled: !task.enabled }); + const i = tasks.value.findIndex((x) => x.id === task.id); + if (i >= 0) tasks.value.splice(i, 1, saved); + } catch (e) { + tasksError.value = e.message; + } finally { + togglingTask.value = ""; + } +} + +// 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) { + tasksError.value = ""; + runningTask.value = task.id; + try { + const res = await api.runChargingTask(task.id); + // 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); + if (i >= 0) { + tasks.value.splice(i, 1, { + ...task, + lastRun: new Date().toISOString(), + lastResult: res.summary || "", + }); + } + } catch (e) { + tasksError.value = e.message; + } finally { + runningTask.value = ""; + } +} + +async function removeTask(task) { + tasksError.value = ""; + if (!(await askConfirm(t("charging.scheduler.removeConfirm", { name: task.name })))) return; + try { + await api.deleteChargingTask(task.id); + tasks.value = tasks.value.filter((x) => x.id !== task.id); + } catch (e) { + tasksError.value = e.message; + } +} + +// 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; +} + +function taskChargersLabel(task) { + const ids = task.chargers || []; + if (ids.length === 0) return t("charging.scheduler.allChargers"); + const names = ids + .map((id) => homeChargers.value.find((c) => c.id === id)?.name) + .filter(Boolean); + // A task can outlive a charger it names — the server skips the missing one + // rather than failing, and the row says as much instead of showing a gap. + if (names.length === 0) return t("charging.scheduler.missingChargers"); + return names.join(", "); +} + +function taskDaysLabel(task) { + const days = task.days || []; + if (days.length === 0) return t("charging.scheduler.everyDay"); + return [...days].sort((a, b) => a - b).map(weekdayShort).join(" "); +} + +// The weekday in the user's own language. 2024-01-07 was a Sunday, which is +// where Intl starts counting, so the offset lands each number on its own day. +function weekdayShort(day) { + try { + return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }) + .format(new Date(Date.UTC(2024, 0, 7 + day))); + } catch { + return String(day); + } +} + +// 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 +// 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 || ""; + const h = Number(parts[1]); + if (!clockIsTwelveHour()) return at.time; + return `${String(h % 12 || 12).padStart(2, "0")}:${parts[2]} ${h < 12 ? "am" : "pm"}`; +} + +// The tasks that will act on one charger — the ones that name it, plus every +// task that names none, since those act on all of them. +function tasksFor(charger) { + return tasks.value.filter((task) => { + const ids = task.chargers || []; + return ids.length === 0 || ids.includes(charger.id); + }); +} + +// Whether the last firing went through, so the row can colour it. Unknown until +// 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 || ""); + return m && m[1] === m[2] ? TONE.good.fg : TONE.due.fg; +} + // Opening the home tab is the moment reachability is being asked about; the -// public half never needs it. +// public half never needs it. The scheduler acts on the same chargers, so it +// wants the list too — and its own tasks, once. watch(chargerTab, (tab) => { if (tab === "home") loadChargerLive(); + if (tab === "scheduler" && !tasksLoaded.value) loadChargingTasks(); startLivePoll(); }); @@ -1937,6 +2111,7 @@ onMounted(async () => { await loadHomeChargers(); await loadChargerProviders(); if (chargerTab.value === "home") loadChargerLive(); + if (chargerTab.value === "scheduler") loadChargingTasks(); startLivePoll(); await loadCtlMode(); if (ctlActive.value) await loadChargers(); @@ -3248,10 +3423,153 @@ onUnmounted(() => { + +
+
+
+
+
+

{{ t("charging.scheduler.title") }}

+

{{ t("charging.scheduler.subtitle") }}

+
+ +
+ +

+ {{ tasksError }} +

+ + +

+ {{ t("charging.scheduler.needCharger") }} +

+ +

+ {{ t("charging.scheduler.empty") }} +

+ + +
+
+
+
+
+ {{ taskClock(task) }} + {{ task.name }} +
+

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

+

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

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

{{ t("charging.scheduler.serverHint") }}

+
+
+ + +
+
+
+ {{ t("charging.stations.homeHeading") }} · {{ t("charging.home.count", { n: homeChargers.length }) }} +
+ +
+ + + + + {{ c.name }} + {{ homeChargerSubtitle(c) }} + + {{ t("charging.scheduler.taskCount", { n: tasksFor(c).length }) }} +
+ +
+

{{ t("charging.home.empty") }}

+
+
+
+
+ + +