One schedule for every charger, and a clock on the server to keep it

The charger's own cloud schedule is one window inside one box: charge
between these hours, every day, and that is the whole vocabulary. A third
tab on Charging holds a list instead — each line an action, a time, the
days it repeats on and the chargers it acts on — and one list covers the
whole account rather than each charger hiding its own.

The clock is the server's. A schedule that only fires while a tab is open
is a reminder, so a ticker sweeps every enabled task and fires whichever
minute has come. It sends by handing a synthesised request to the same
control endpoint the page's buttons use, so a scheduled command goes
through the same cascade, ownership gate, rate limit and audit trail —
what the owner cannot press by hand, the scheduler cannot send for them.

A task names its chargers, or names none, which means all of them and
keeps meaning that for a charger imported next year. Times are stored as
a wall clock plus the zone they were written in, so 23:00 stays 23:00
wherever the server sits. One action per task: a charging window is the
two tasks that open and close it, which is how it is read back, edited
and switched off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 22:47:30 +02:00
co-authored by Claude Opus 5
parent 8e4c22cfcf
commit 5a4515978f
15 changed files with 1856 additions and 7 deletions
+4
View File
@@ -73,6 +73,10 @@ func main() {
// endpoints answer 503 until the read succeeds. // endpoints answer 503 until the read succeeds.
_ = srv.StartPlugins() _ = 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{ httpServer := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: srv.Handler(), Handler: srv.Handler(),
+398
View File
@@ -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),
})
}
@@ -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)
}
}
@@ -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")
}
}
+16
View File
@@ -159,6 +159,7 @@ const (
colDocuments = "car_documents" colDocuments = "car_documents"
colReminders = "reminders" colReminders = "reminders"
colHomeChargers = "home_chargers" colHomeChargers = "home_chargers"
colChargingTasks = "charging_tasks"
colControlAudit = "control_audit" colControlAudit = "control_audit"
colAppSettings = "app_settings" colAppSettings = "app_settings"
) )
@@ -182,6 +183,10 @@ type Server struct {
ocpp *ocpp.CSMS ocpp *ocpp.CSMS
control *controlIndex // token -> owning user/charger for the /ocpp endpoint control *controlIndex // token -> owning user/charger for the /ocpp endpoint
ctlRL *rateLimiter // per user+charger control-command rate limit 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. // 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 { if s.pluginsStop != nil {
s.pluginsStop() s.pluginsStop()
} }
if s.schedulerStop != nil {
s.schedulerStop()
}
s.ocpp.Shutdown(ctx) s.ocpp.Shutdown(ctx)
s.plugins.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("PATCH /api/home-chargers/{id}", s.updateHomeCharger)
mux.HandleFunc("DELETE /api/home-chargers/{id}", s.deleteHomeCharger) 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. // Cars + sharing.
mux.HandleFunc("GET /api/cars", s.listCars) mux.HandleFunc("GET /api/cars", s.listCars)
mux.HandleFunc("POST /api/cars", s.createCar) mux.HandleFunc("POST /api/cars", s.createCar)
+33
View File
@@ -225,6 +225,34 @@ var collectionsSchema = map[string][]fieldDef{
// audit trail's. // audit trail's.
fAutodate("created", true, false), 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. // Custom fields layered onto the built-in "users" auth collection.
"users": { "users": {
fText("bio", false), fText("bio", false),
@@ -280,6 +308,7 @@ var createOrder = []string{
"reminders", "reminders",
"control_audit", "control_audit",
"home_chargers", "home_chargers",
"charging_tasks",
} }
// reconcileOrder additionally includes "users" so its custom fields (role, // reconcileOrder additionally includes "users" so its custom fields (role,
@@ -300,6 +329,7 @@ var reconcileOrder = []string{
"reminders", "reminders",
"control_audit", "control_audit",
"home_chargers", "home_chargers",
"charging_tasks",
} }
// indexes are extra SQL indexes applied at collection-create time. // 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 // A charger is looked up by its owner, and by serial when checking whether
// the account it came from has already been imported. // the account it came from has already been imported.
"home_chargers": {"CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"}, "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": { "control_audit": {
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)", "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`)", "CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
+42
View File
@@ -152,6 +152,48 @@ type HomeCharger struct {
Created string `json:"created,omitempty"` 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. // ServiceRecord is one row of the Service log for a car.
type ServiceRecord struct { type ServiceRecord struct {
ID string `json:"id"` ID string `json:"id"`
+33 -1
View File
@@ -462,6 +462,30 @@ const DESIRED = {
// defined through the API, so it is declared here like the audit trail's. // defined through the API, so it is declared here like the audit trail's.
F.autodate("created", true, false), 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 // Server-wide settings as a single record, keyed "global". Today it holds
// pluginSettings: the top (L1) layer of the integration cascade — every // pluginSettings: the top (L1) layer of the integration cascade — every
// plugin's enable state, its global config, and the registration of any // 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 // A charger is looked up by its owner, and by serial when checking whether the
// account it came from has already been imported. // account it came from has already been imported.
home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"], 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". // Audit is queried "this charger's events, newest first" and "this user's events".
control_audit: [ control_audit: [
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)", "CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
@@ -586,6 +615,7 @@ async function main() {
"reminders", "reminders",
"control_audit", "control_audit",
"home_chargers", "home_chargers",
"charging_tasks",
]) { ]) {
if (collections.some((c) => c.name === name)) continue; if (collections.some((c) => c.name === name)) continue;
await createCollection(token, name, DESIRED[name], format, idByName); await createCollection(token, name, DESIRED[name], format, idByName);
@@ -614,6 +644,7 @@ async function main() {
"reminders", "reminders",
"control_audit", "control_audit",
"home_chargers", "home_chargers",
"charging_tasks",
]) { ]) {
await reconcileFields(token, name, DESIRED[name], format, idByName); await reconcileFields(token, name, DESIRED[name], format, idByName);
} }
@@ -622,7 +653,8 @@ async function main() {
"\nDone. Collections ready: app_settings, organizations, users, cars,\n" + "\nDone. Collections ready: app_settings, organizations, users, cars,\n" +
"service_records,\n" + "service_records,\n" +
"technical_checks, parts, car_shares, fuel_entries, charging_sessions,\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( console.log(
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" + "Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
+17
View File
@@ -316,6 +316,23 @@ export const api = {
}), }),
deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }), 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 // Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
// returns the resolved view (effective/own/locked per field, secrets and // returns the resolved view (effective/own/locked per field, secrets and
// inherited emails masked); saveAnkerSolix writes the caller's editable layer; // inherited emails masked); saveAnkerSolix writes the caller's editable layer;
@@ -0,0 +1,234 @@
<script setup>
// One line of the home-charger scheduler, being written or edited.
//
// The charger's own cloud schedule asks four questions and asks them inside one
// charger: on/off, mode, from, to. This asks five, and the fifth is the one that
// makes it a scheduler rather than a second copy of that: *which* chargers. A
// task can name one, several, or none at all — and none means every charger on
// the account, including ones imported after the task was written, because "all
// 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.
import { ref, computed, watch } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
import TimeField from "./TimeField.vue";
const props = defineProps({
// The task being edited, or null to write a new one.
task: { type: Object, default: null },
// The chargers this account has, as the Charging page already loaded them.
chargers: { type: Array, default: () => [] },
});
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 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.
const allChargers = ref(!props.task || (props.task.chargers || []).length === 0);
const picked = ref([...(props.task?.chargers || [])]);
const days = ref([...(props.task?.days || [])]);
const everyDay = ref(!props.task || (props.task.days || []).length === 0);
const saving = ref(false);
const error = ref("");
// What the charger can actually be asked to do. Boost and the current limit only
// reach it over the Anker cloud connection; start and stop reach it over all
// three transports. The control mode is a Settings choice, not this form's
// business, so all four are offered and the one that cannot be sent says so when
// it fires — same as the buttons on the page behind this.
const ACTIONS = ["start", "stop", "limit", "boost"];
// Sunday first, as Intl numbers the weekdays — the names come from the user's
// own locale, so the row reads Pn Wt Śr… in Polish without a table here.
const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
function weekdayLabel(day) {
// 2024-01-07 was a Sunday, so this offset lands each index on its own day.
const date = new Date(Date.UTC(2024, 0, 7 + day));
try {
return new Intl.DateTimeFormat(undefined, { weekday: "short", timeZone: "UTC" }).format(date);
} catch {
return String(day);
}
}
function toggleDay(day) {
everyDay.value = false;
days.value = days.value.includes(day)
? days.value.filter((d) => d !== day)
: [...days.value, day];
}
function toggleCharger(id) {
allChargers.value = false;
picked.value = picked.value.includes(id)
? picked.value.filter((x) => x !== id)
: [...picked.value, id];
}
// Unticking every day (or every charger) by hand is the same wish as the "all"
// switch, so it lands there rather than leaving a task that acts on nothing.
watch(days, (list) => {
if (list.length === 0) everyDay.value = true;
});
watch(picked, (list) => {
if (list.length === 0) allChargers.value = true;
});
watch(everyDay, (on) => {
if (on) days.value = [];
});
watch(allChargers, (on) => {
if (on) picked.value = [];
});
// 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);
function chargerSubtitle(c) {
return [c.serial, c.model].filter(Boolean).join(" · ");
}
async function submit() {
if (!canSave.value) return;
saving.value = true;
error.value = "";
const body = {
name: name.value.trim(),
action: action.value,
time: at.value,
amps: Number(amps.value) || 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.
// Sending the zone the browser is in is what keeps 23:00 at 23:00 for a
// server sitting in another country.
zone: browserZone(),
};
try {
const task = editing.value
? await api.updateChargingTask(props.task.id, body)
: await api.createChargingTask(body);
emit("saved", task);
} catch (e) {
error.value = e.message;
} finally {
saving.value = false;
}
}
function browserZone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "";
} catch {
return "";
}
}
</script>
<template>
<Modal
:title="editing ? t('forms.chargingTask.editTitle') : t('forms.chargingTask.title')"
@close="emit('close')"
>
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="dh-label">{{ t("forms.chargingTask.name") }}</label>
<input v-model="name" required class="dh-input" :placeholder="t('forms.chargingTask.namePlaceholder')" />
</div>
<!-- What and when. Side by side because they are read as one sentence:
"start, at 23:00". -->
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="dh-label">{{ t("forms.chargingTask.action") }}</label>
<select v-model="action" class="dh-input">
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
</select>
</div>
<div>
<label class="dh-label">{{ t("forms.chargingTask.time") }}</label>
<TimeField v-model="at" :aria-label="t('forms.chargingTask.time')" />
</div>
</div>
<!-- The ceiling, for the one action that takes one. -->
<div v-if="action === 'limit'">
<label class="dh-label">
{{ t("forms.chargingTask.amps") }}
<span class="data float-right font-semibold text-strong">{{ amps }} A</span>
</label>
<input v-model.number="amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
</div>
<!-- Which chargers. The point of one scheduler for all of them. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.chargers") }}</label>
<label class="flex cursor-pointer items-center gap-2 rounded-control p-2 hover:bg-sunken">
<input v-model="allChargers" type="checkbox" />
<span class="text-sm font-medium text-strong">{{ t("forms.chargingTask.allChargers") }}</span>
</label>
<p v-if="allChargers" class="px-2 pb-1 text-xs text-muted">{{ t("forms.chargingTask.allChargersHint") }}</p>
<p v-if="chargers.length === 0" class="px-2 text-xs text-muted">{{ t("forms.chargingTask.noChargers") }}</p>
<label
v-for="c in chargers"
:key="c.id"
class="flex cursor-pointer items-center gap-2 rounded-control p-2 transition-colors hover:bg-sunken"
>
<input type="checkbox" :checked="!allChargers && picked.includes(c.id)" @change="toggleCharger(c.id)" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm text-strong">{{ c.name }}</span>
<span v-if="chargerSubtitle(c)" class="data block truncate text-[11px] text-muted">{{ chargerSubtitle(c) }}</span>
</span>
</label>
</div>
<!-- Which days. -->
<div>
<label class="dh-label">{{ t("forms.chargingTask.days") }}</label>
<label class="flex cursor-pointer items-center gap-2 rounded-control p-2 hover:bg-sunken">
<input v-model="everyDay" type="checkbox" />
<span class="text-sm font-medium text-strong">{{ t("forms.chargingTask.everyDay") }}</span>
</label>
<div class="mt-1 flex flex-wrap gap-1.5">
<button
v-for="d in WEEKDAYS"
:key="d"
type="button"
class="rounded-pill border px-3 py-1.5 text-xs font-semibold transition-colors"
:class="!everyDay && days.includes(d)
? 'border-accent bg-brand-100 text-strong'
: 'border-subtle text-muted hover:bg-sunken'"
@click="toggleDay(d)"
>
{{ weekdayLabel(d) }}
</button>
</div>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="!canSave" class="dh-btn dh-btn-primary">
{{ saving ? t("common.saving") : t("common.save") }}
</button>
</div>
</form>
</Modal>
</template>
+45 -1
View File
@@ -50,7 +50,8 @@
"tabs": { "tabs": {
"dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.", "dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.",
"public": "Offentlige ladere", "public": "Offentlige ladere",
"home": "Hjemmeladere" "home": "Hjemmeladere",
"scheduler": "Planlægning af hjemmelader"
}, },
"liveMap": "Live-kort", "liveMap": "Live-kort",
"youAreHere": "Du er her", "youAreHere": "Du er her",
@@ -385,6 +386,33 @@
"refresh": "Opdater", "refresh": "Opdater",
"rawTitle": "Som tjenesten melder det", "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." "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", "submit": "Del",
"peopleWithAccess": "Personer med adgang", "peopleWithAccess": "Personer med adgang",
"notShared": "Endnu ikke delt med nogen." "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"
} }
}, },
+45 -1
View File
@@ -50,7 +50,8 @@
"tabs": { "tabs": {
"dragHint": "Drag a tab to rearrange the charging tabs.", "dragHint": "Drag a tab to rearrange the charging tabs.",
"public": "Public chargers", "public": "Public chargers",
"home": "Home chargers" "home": "Home chargers",
"scheduler": "Home charger scheduler"
}, },
"liveMap": "Live map", "liveMap": "Live map",
"youAreHere": "You are here", "youAreHere": "You are here",
@@ -384,6 +385,33 @@
"full": "Full", "full": "Full",
"offPeak": "off-peak", "offPeak": "off-peak",
"homeCharger": "Home charger" "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", "submit": "Share",
"peopleWithAccess": "People with access", "peopleWithAccess": "People with access",
"notShared": "Not shared with anyone yet." "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"
} }
}, },
+47 -1
View File
@@ -50,7 +50,8 @@
"tabs": { "tabs": {
"dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.", "dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.",
"public": "Ładowarki publiczne", "public": "Ładowarki publiczne",
"home": "Ładowarki domowe" "home": "Ładowarki domowe",
"scheduler": "Harmonogram ładowarki"
}, },
"liveMap": "Mapa na żywo", "liveMap": "Mapa na żywo",
"youAreHere": "Tu jesteś", "youAreHere": "Tu jesteś",
@@ -387,6 +388,35 @@
"refresh": "Odśwież", "refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa", "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." "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", "submit": "Udostępnij",
"peopleWithAccess": "Osoby z dostępem", "peopleWithAccess": "Osoby z dostępem",
"notShared": "Jeszcze nikomu nie udostępniono." "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"
} }
}, },
+3 -1
View File
@@ -10,7 +10,9 @@
import { prefs } from "../prefs.js"; 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 // 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. // connected service, when there is one, is what you came to look at.
+320 -2
View File
@@ -4,10 +4,11 @@ import { t } from "../i18n";
import { prefs } from "../prefs"; import { prefs } from "../prefs";
import { askConfirm } from "../lib/confirm.js"; import { askConfirm } from "../lib/confirm.js";
import { api } from "../api"; import { api } from "../api";
import { formatDateTime } from "../lib/format.js"; import { formatDateTime, clockIsTwelveHour } from "../lib/format.js";
import TimeField from "../components/TimeField.vue"; import TimeField from "../components/TimeField.vue";
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js"; import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
import ChargerImportModal from "../components/ChargerImportModal.vue"; 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 & 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 // charging API yet (only the Anker Solix credential cascade in Settings), so the
@@ -1925,10 +1926,183 @@ function cancelReset() {
resetPassword.value = ""; 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 // 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) => { watch(chargerTab, (tab) => {
if (tab === "home") loadChargerLive(); if (tab === "home") loadChargerLive();
if (tab === "scheduler" && !tasksLoaded.value) loadChargingTasks();
startLivePoll(); startLivePoll();
}); });
@@ -1937,6 +2111,7 @@ onMounted(async () => {
await loadHomeChargers(); await loadHomeChargers();
await loadChargerProviders(); await loadChargerProviders();
if (chargerTab.value === "home") loadChargerLive(); if (chargerTab.value === "home") loadChargerLive();
if (chargerTab.value === "scheduler") loadChargingTasks();
startLivePoll(); startLivePoll();
await loadCtlMode(); await loadCtlMode();
if (ctlActive.value) await loadChargers(); if (ctlActive.value) await loadChargers();
@@ -3248,10 +3423,153 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- The scheduler: the user's own list of charging tasks, covering every
charger on the account. The charger's own cloud schedule is one window
inside one box; this is a list, and one list for all of them. -->
<div v-show="chargerTab === 'scheduler'" class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
<div class="flex flex-col gap-4">
<div class="dh-card p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-strong">{{ t("charging.scheduler.title") }}</p>
<p class="mt-1 text-xs text-muted">{{ t("charging.scheduler.subtitle") }}</p>
</div>
<button
type="button"
class="dh-btn dh-btn-primary shrink-0 !px-3 !py-1.5 text-xs"
:disabled="homeChargers.length === 0"
@click="newTask"
>
{{ t("charging.scheduler.add") }}
</button>
</div>
<p v-if="tasksError" class="mt-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">
{{ tasksError }}
</p>
<!-- Nothing to act on yet: a task with no charger behind it would
only ever report that it could not send anything. -->
<p v-if="homeChargers.length === 0" class="mt-4 text-sm text-muted">
{{ t("charging.scheduler.needCharger") }}
</p>
<p v-else-if="tasks.length === 0" class="mt-4 text-sm text-muted">
{{ t("charging.scheduler.empty") }}
</p>
<!-- One row per task: what it does and when, which chargers, which
days, and how the last firing went. -->
<div v-else class="mt-3 flex flex-col gap-2">
<div
v-for="task in tasks"
:key="task.id"
class="rounded-control bg-sunken p-3"
:class="task.enabled ? '' : 'opacity-60'"
>
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span class="data text-lg font-medium tracking-[-0.02em] text-strong">{{ taskClock(task) }}</span>
<span class="truncate text-sm font-semibold text-strong">{{ task.name }}</span>
</div>
<p class="mt-1 text-xs text-muted">
{{ taskActionLabel(task) }} · {{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
</p>
<p v-if="task.lastRun" class="mt-1 text-[11px]" :style="{ color: taskRunTone(task) }">
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }}
{{ task.lastResult || t("charging.scheduler.noResult") }}
</p>
</div>
<!-- The switch. Written straight through, so what it shows is
what the server will act on. -->
<button
type="button"
class="shrink-0 rounded-pill px-3 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
:class="task.enabled ? 'bg-brand-600 text-white' : 'bg-card text-muted'"
:disabled="togglingTask === task.id"
:title="t('charging.scheduler.toggleHint')"
@click="toggleTask(task)"
>
{{ task.enabled ? t("common.yes") : t("common.no") }}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
:disabled="runningTask === task.id"
@click="runTask(task)"
>
{{ runningTask === task.id ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
</button>
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-body"
@click="editTask(task)"
>
{{ t("common.edit") }}
</button>
<button
type="button"
class="text-xs font-medium text-muted transition-colors hover:text-danger"
@click="removeTask(task)"
>
{{ t("common.remove") }}
</button>
</div>
</div>
</div>
<p class="mt-4 text-xs text-muted">{{ t("charging.scheduler.serverHint") }}</p>
</div>
</div>
<!-- The same chargers the tab before this one lists, so the schedule can
be read against the boxes it acts on without switching back. Each says
how many tasks touch it, which is the question this column is here to
answer. Read-only: this tab is about the schedule. -->
<div class="flex flex-col gap-4">
<div class="dh-card p-2">
<div class="eyebrow px-3 pb-1.5 pt-2.5">
{{ t("charging.stations.homeHeading") }} · {{ t("charging.home.count", { n: homeChargers.length }) }}
</div>
<div v-for="c in homeChargers" :key="c.id" class="flex w-full items-center gap-3 rounded-control p-3 text-left">
<span
class="grid h-9 w-9 flex-none place-items-center rounded-control bg-sunken"
:title="homeChargerStatusLabel(c)"
>
<svg viewBox="0 0 24 24" fill="none" :stroke="homeChargerTone(c)" stroke-width="2" class="h-4.5 w-4.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-strong">{{ c.name }}</span>
<span class="data block truncate text-[11px] text-muted">{{ homeChargerSubtitle(c) }}</span>
</span>
<span class="shrink-0 text-[11px] text-muted">{{ t("charging.scheduler.taskCount", { n: tasksFor(c).length }) }}</span>
</div>
<div v-if="homeChargers.length === 0" class="px-3 pb-3 pt-1">
<p class="text-sm text-muted">{{ t("charging.home.empty") }}</p>
</div>
</div>
</div>
</div>
<ChargerImportModal <ChargerImportModal
v-if="showChargerImport" v-if="showChargerImport"
@saved="onChargerImported" @saved="onChargerImported"
@close="showChargerImport = false" @close="showChargerImport = false"
/> />
<ChargingTaskModal
v-if="showTaskForm"
:task="editingTask"
:chargers="homeChargers"
@saved="onTaskSaved"
@close="showTaskForm = false"
/>
</div> </div>
</template> </template>