Compare commits
2
Commits
5a4515978f
...
2b4f4f034d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b4f4f034d | ||
|
|
0f48093d1a |
@@ -3,16 +3,15 @@ package api
|
||||
// The home-charger scheduler: the user's own list of charging tasks.
|
||||
//
|
||||
// The charger's own cloud schedule can say one thing — "charge between these
|
||||
// hours" — and it says it inside one charger. This is a list: as many tasks as
|
||||
// the owner likes, each with its own action, time, days and set of chargers, and
|
||||
// one list covering every charger on the account rather than a schedule hidden
|
||||
// inside each box.
|
||||
// hours" — and it says it inside one charger. This is a list, and each entry is
|
||||
// a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30, on these
|
||||
// chargers, on these days. One named thing, switched on and off as one.
|
||||
//
|
||||
// GET /api/charging-tasks — the caller's tasks, earliest first
|
||||
// POST /api/charging-tasks — write one
|
||||
// PATCH /api/charging-tasks/{id} — edit one (any subset of its fields)
|
||||
// DELETE /api/charging-tasks/{id} — forget one
|
||||
// POST /api/charging-tasks/{id}/run — fire it now, without waiting for its time
|
||||
// GET /api/charging-tasks — the caller's tasks, earliest first
|
||||
// POST /api/charging-tasks — write one
|
||||
// PATCH /api/charging-tasks/{id} — edit one (any subset of its fields)
|
||||
// DELETE /api/charging-tasks/{id} — forget one
|
||||
// POST /api/charging-tasks/{id}/steps/{n}/run — fire one step now
|
||||
//
|
||||
// A task belongs to the person, like the chargers it acts on, so there is no
|
||||
// sharing here: everyone sees their own list only. Firing one is the business of
|
||||
@@ -23,13 +22,14 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// The actions a task may carry. Each is a control command the charger already
|
||||
// The actions a step may carry. Each is a control command the charger already
|
||||
// understands; the scheduler adds no verbs of its own, so a task can only ask
|
||||
// for something the buttons on the Charging page can ask for by hand.
|
||||
var taskActions = map[string]bool{
|
||||
@@ -39,26 +39,29 @@ var taskActions = map[string]bool{
|
||||
"boost": true,
|
||||
}
|
||||
|
||||
// maxTaskSteps bounds one task's flow. Well past any real schedule — a night
|
||||
// rate is two or three steps — and it keeps a client from parking an unbounded
|
||||
// blob on the record, which the sweep then reads every thirty seconds.
|
||||
const maxTaskSteps = 24
|
||||
|
||||
// chargingTaskRecord is the PocketBase-facing shape of a task.
|
||||
type chargingTaskRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Chargers []string `json:"chargers"`
|
||||
Action string `json:"action"`
|
||||
Amps float64 `json:"amps"`
|
||||
Time string `json:"time"`
|
||||
Zone string `json:"zone"`
|
||||
Days []int `json:"days"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRun string `json:"last_run"`
|
||||
LastResult string `json:"last_result"`
|
||||
Owner string `json:"owner"`
|
||||
Created string `json:"created"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Chargers []string `json:"chargers"`
|
||||
Steps []models.ChargingStep `json:"steps"`
|
||||
Zone string `json:"zone"`
|
||||
Days []int `json:"days"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRun string `json:"last_run"`
|
||||
LastResult string `json:"last_result"`
|
||||
Owner string `json:"owner"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
func (rec chargingTaskRecord) toModel() models.ChargingTask {
|
||||
// The two lists are never null on the wire: the page iterates them, and a
|
||||
// null would make "every charger" and "every day" read as an error there.
|
||||
// The lists are never null on the wire: the page iterates them, and a null
|
||||
// would make "every charger" and "every day" read as an error there.
|
||||
chargers := rec.Chargers
|
||||
if chargers == nil {
|
||||
chargers = []string{}
|
||||
@@ -67,13 +70,15 @@ func (rec chargingTaskRecord) toModel() models.ChargingTask {
|
||||
if days == nil {
|
||||
days = []int{}
|
||||
}
|
||||
steps := rec.Steps
|
||||
if steps == nil {
|
||||
steps = []models.ChargingStep{}
|
||||
}
|
||||
return models.ChargingTask{
|
||||
ID: rec.ID,
|
||||
Name: rec.Name,
|
||||
Chargers: chargers,
|
||||
Action: rec.Action,
|
||||
Amps: rec.Amps,
|
||||
Time: rec.Time,
|
||||
Steps: steps,
|
||||
Zone: rec.Zone,
|
||||
Days: days,
|
||||
Enabled: rec.Enabled,
|
||||
@@ -83,18 +88,26 @@ func (rec chargingTaskRecord) toModel() models.ChargingTask {
|
||||
}
|
||||
}
|
||||
|
||||
// firstStepTime is the time of day a task begins — its first step's, which is
|
||||
// what the list is ordered by, so the evening's task sits below the morning's. A
|
||||
// task with no steps cannot exist, but the ordering must not depend on that.
|
||||
func (rec chargingTaskRecord) firstStepTime() string {
|
||||
if len(rec.Steps) == 0 {
|
||||
return "99:99"
|
||||
}
|
||||
return rec.Steps[0].Time
|
||||
}
|
||||
|
||||
// chargingTaskBody is the write shape. Every field is a pointer so a PATCH can
|
||||
// name one of them without the rest being read as "clear these" — a task
|
||||
// switched off from the list must not lose its days on the way.
|
||||
// switched off from the list must not lose its flow on the way.
|
||||
type chargingTaskBody struct {
|
||||
Name *string `json:"name"`
|
||||
Chargers *[]string `json:"chargers"`
|
||||
Action *string `json:"action"`
|
||||
Amps *float64 `json:"amps"`
|
||||
Time *string `json:"time"`
|
||||
Zone *string `json:"zone"`
|
||||
Days *[]int `json:"days"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name *string `json:"name"`
|
||||
Chargers *[]string `json:"chargers"`
|
||||
Steps *[]models.ChargingStep `json:"steps"`
|
||||
Zone *string `json:"zone"`
|
||||
Days *[]int `json:"days"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// parseHHMM splits a 24-hour clock time into its two numbers. "H:MM" is accepted
|
||||
@@ -147,6 +160,46 @@ func normalizeDays(days []int) []int {
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeSteps validates a flow, keeping the steps in the order they were
|
||||
// written.
|
||||
//
|
||||
// Not sorted by time: a night crosses midnight, and clock order would file
|
||||
// "start at 23:00" last, behind the stop that closes it. Nothing about firing
|
||||
// depends on the order — every step is timed on its own — so the order is free
|
||||
// to be the one that reads as the intention it is.
|
||||
func normalizeSteps(steps []models.ChargingStep) ([]models.ChargingStep, error) {
|
||||
if len(steps) == 0 {
|
||||
return nil, fmt.Errorf("a task needs at least one step")
|
||||
}
|
||||
if len(steps) > maxTaskSteps {
|
||||
return nil, fmt.Errorf("a task can hold at most %d steps", maxTaskSteps)
|
||||
}
|
||||
out := make([]models.ChargingStep, 0, len(steps))
|
||||
for i, step := range steps {
|
||||
action := strings.TrimSpace(step.Action)
|
||||
if !taskActions[action] {
|
||||
return nil, fmt.Errorf("step %d: unknown action: %s", i+1, action)
|
||||
}
|
||||
at := normalizeTaskTime(step.Time)
|
||||
if at == "" {
|
||||
return nil, fmt.Errorf("step %d: time must be a 24-hour clock time, e.g. 23:00", i+1)
|
||||
}
|
||||
// The ceiling only means anything to "limit", but it is kept whatever the
|
||||
// action is: switching a step to "limit" and back should not lose the amps
|
||||
// that were typed. 6 A is the charger's own floor — below it the box pauses
|
||||
// rather than charging slowly — and its ratings top out at 32 A.
|
||||
if step.Amps != 0 && (step.Amps < 6 || step.Amps > 32) {
|
||||
return nil, fmt.Errorf("step %d: the current limit must be between 6 and 32 A", i+1)
|
||||
}
|
||||
// A limit step with no ceiling would fire and ask the charger for 0 A.
|
||||
if action == "limit" && step.Amps <= 0 {
|
||||
return nil, fmt.Errorf("step %d: a limit step needs the amps to limit to", i+1)
|
||||
}
|
||||
out = append(out, models.ChargingStep{Action: action, Amps: step.Amps, Time: at})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// taskPayload turns a write body into the PocketBase fields it names, validating
|
||||
// as it goes. `full` demands the fields a task cannot exist without, so a POST is
|
||||
// checked as a whole and a PATCH only where it speaks.
|
||||
@@ -163,36 +216,14 @@ func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
|
||||
return nil, fmt.Errorf("name is required")
|
||||
}
|
||||
|
||||
if body.Action != nil {
|
||||
action := strings.TrimSpace(*body.Action)
|
||||
if !taskActions[action] {
|
||||
return nil, fmt.Errorf("unknown action: %s", action)
|
||||
if body.Steps != nil {
|
||||
steps, err := normalizeSteps(*body.Steps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["action"] = action
|
||||
payload["steps"] = steps
|
||||
} else if full {
|
||||
return nil, fmt.Errorf("action is required")
|
||||
}
|
||||
|
||||
if body.Time != nil {
|
||||
at := normalizeTaskTime(*body.Time)
|
||||
if at == "" {
|
||||
return nil, fmt.Errorf("time must be a 24-hour clock time, e.g. 23:00")
|
||||
}
|
||||
payload["time"] = at
|
||||
} else if full {
|
||||
return nil, fmt.Errorf("time is required")
|
||||
}
|
||||
|
||||
// The ceiling only means anything to "limit", but it is stored whatever the
|
||||
// action is: switching a task to "limit" and back should not lose the amps
|
||||
// that were typed. 6 A is the charger's own floor — below it the box pauses
|
||||
// rather than charging slowly — and its ratings top out at 32 A.
|
||||
if body.Amps != nil {
|
||||
amps := *body.Amps
|
||||
if amps != 0 && (amps < 6 || amps > 32) {
|
||||
return nil, fmt.Errorf("the current limit must be between 6 and 32 A")
|
||||
}
|
||||
payload["amps"] = amps
|
||||
return nil, fmt.Errorf("a task needs at least one step")
|
||||
}
|
||||
|
||||
if body.Chargers != nil {
|
||||
@@ -220,50 +251,20 @@ func taskPayload(body chargingTaskBody, full bool) (map[string]any, error) {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// checkLimitAmps refuses a task that ends up a "limit" with nothing to limit to,
|
||||
// which would fire and ask the charger for 0 A. Both halves are read from what
|
||||
// the write leaves behind — the payload where it speaks, the stored record where
|
||||
// it does not — so a PATCH that names only one of them is checked against the
|
||||
// other rather than against nothing.
|
||||
func checkLimitAmps(payload map[string]any, stored chargingTaskRecord) error {
|
||||
action := stored.Action
|
||||
if v, ok := payload["action"].(string); ok {
|
||||
action = v
|
||||
}
|
||||
amps := stored.Amps
|
||||
if v, ok := payload["amps"].(float64); ok {
|
||||
amps = v
|
||||
}
|
||||
if action == "limit" && amps <= 0 {
|
||||
return fmt.Errorf("a limit task needs the amps to limit to")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listChargingTasks returns the caller's own tasks, earliest in the day first —
|
||||
// the order the day runs in, which is the order a schedule is read in.
|
||||
// listChargingTasks returns the caller's own tasks, the one that starts earliest
|
||||
// first — the order the day runs them in, which is the order a schedule is read
|
||||
// in. Ordered here rather than by PocketBase because the time a task starts is
|
||||
// now inside its flow, which is not a column to sort on.
|
||||
func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
|
||||
me := s.currentUserID(r)
|
||||
if me == "" {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
query := func(sort string) url.Values {
|
||||
q := url.Values{
|
||||
"filter": {fmt.Sprintf("owner='%s'", me)},
|
||||
"perPage": {"200"},
|
||||
}
|
||||
if sort != "" {
|
||||
q.Set("sort", sort)
|
||||
}
|
||||
return q
|
||||
}
|
||||
res, err := s.pb.List(r.Context(), colChargingTasks, query("time"))
|
||||
if err != nil {
|
||||
// Same fallback the charger list makes: an order is a nicety, the list is
|
||||
// not, so a collection that predates the sort field still answers.
|
||||
res, err = s.pb.List(r.Context(), colChargingTasks, query(""))
|
||||
}
|
||||
res, err := s.pb.List(r.Context(), colChargingTasks, url.Values{
|
||||
"filter": {fmt.Sprintf("owner='%s'", me)},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
@@ -273,6 +274,9 @@ func (s *Server) listChargingTasks(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sort.SliceStable(recs, func(i, j int) bool {
|
||||
return recs[i].firstStepTime() < recs[j].firstStepTime()
|
||||
})
|
||||
out := make([]models.ChargingTask, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, rec.toModel())
|
||||
@@ -318,10 +322,6 @@ func (s *Server) createChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := checkLimitAmps(payload, chargingTaskRecord{}); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
payload["owner"] = me
|
||||
var rec chargingTaskRecord
|
||||
if err := s.pb.Create(r.Context(), colChargingTasks, payload, &rec); err != nil {
|
||||
@@ -346,10 +346,6 @@ func (s *Server) updateChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := checkLimitAmps(payload, rec); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": rec.toModel()})
|
||||
return
|
||||
@@ -374,25 +370,37 @@ func (s *Server) deleteChargingTask(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// runChargingTaskNow fires a task on demand — the "Run now" button beside it. It
|
||||
// takes the same path the ticker does, so what comes back is exactly what the
|
||||
// task will do at its own time, errors included. A switched-off task still runs
|
||||
// from here: the switch says whether the clock fires it, not whether the button
|
||||
// does.
|
||||
func (s *Server) runChargingTaskNow(w http.ResponseWriter, r *http.Request) {
|
||||
// runChargingStepNow fires one step of a task on demand — the "Run now" beside
|
||||
// it. One step rather than the whole flow, because a flow is not a thing that
|
||||
// can happen at once: running a start and the stop that closes it back to back
|
||||
// would leave the charger where it began and prove nothing.
|
||||
//
|
||||
// It takes the same path the ticker does, so what comes back is exactly what
|
||||
// that step will do at its own time, errors included. A switched-off task still
|
||||
// runs from here: the switch says whether the clock fires it, not whether the
|
||||
// button does.
|
||||
func (s *Server) runChargingStepNow(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := s.ownedChargingTask(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(r.PathValue("step")))
|
||||
if err != nil || n < 0 || n >= len(rec.Steps) {
|
||||
writeError(w, http.StatusNotFound, "this task has no such step")
|
||||
return
|
||||
}
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
results := s.fireChargingTask(r.Context(), who, rec)
|
||||
s.recordTaskRun(r.Context(), rec, results)
|
||||
step := rec.Steps[n]
|
||||
results := s.fireChargingSteps(r.Context(), who, rec, []models.ChargingStep{step})
|
||||
s.recordTaskRun(r.Context(), rec, step, results)
|
||||
// The same line that was just stored, so the row the caller updates from this
|
||||
// answer reads identically to the one a page reload would fetch.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"results": results,
|
||||
"summary": summarizeTaskRun(results),
|
||||
"summary": taskRunLine(step, results),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // tasks are timed in the user's own zone; hosts without a zone database are common on Windows
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// How often the clock is looked at. Tasks are timed to the minute, so a sweep
|
||||
@@ -97,7 +99,8 @@ func (s *Server) sweepChargingTasks(ctx context.Context) {
|
||||
}
|
||||
now := time.Now()
|
||||
for _, rec := range recs {
|
||||
if !taskIsDue(rec, now) {
|
||||
due := dueSteps(rec, now)
|
||||
if len(due) == 0 {
|
||||
continue
|
||||
}
|
||||
who, _, err := s.callerForUser(ctx, rec.Owner)
|
||||
@@ -105,29 +108,31 @@ func (s *Server) sweepChargingTasks(ctx context.Context) {
|
||||
log.Printf("scheduler: task %s (%s): owner unavailable: %v", rec.ID, rec.Name, err)
|
||||
continue
|
||||
}
|
||||
results := s.fireChargingTask(ctx, who, rec)
|
||||
s.recordTaskRun(ctx, rec, results)
|
||||
log.Printf("scheduler: task %s (%s) fired: %s", rec.ID, rec.Name, summarizeTaskRun(results))
|
||||
// Two steps of one task timed to the same minute contradict each other,
|
||||
// but nothing stops somebody writing them, so both are sent in the order
|
||||
// the flow holds them rather than one silently winning.
|
||||
for _, step := range due {
|
||||
results := s.fireChargingSteps(ctx, who, rec, []models.ChargingStep{step})
|
||||
s.recordTaskRun(ctx, rec, step, results)
|
||||
log.Printf("scheduler: task %s (%s) step %s %s: %s",
|
||||
rec.ID, rec.Name, step.Time, step.Action, summarizeTaskRun(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// taskIsDue reports whether a task's minute has come and it has not already been
|
||||
// fired in it.
|
||||
// dueSteps returns the steps of a task whose minute has come — usually one, and
|
||||
// none at all for the rest of the day.
|
||||
//
|
||||
// The time is read in the task's own zone: the browser that wrote it said which,
|
||||
// and the server's clock is not the one the user set 23:00 by. A zone the host
|
||||
// has no database for falls back to the server's own rather than silently
|
||||
// The times are read in the task's own zone: the browser that wrote it said
|
||||
// which, and the server's clock is not the one the user set 23:00 by. A zone the
|
||||
// host has no database for falls back to the server's own rather than silently
|
||||
// shifting the schedule to UTC.
|
||||
//
|
||||
// A minute that passed while the server was down is not caught up afterwards. A
|
||||
// charging window that opened an hour ago is not a window anyone still wants
|
||||
// opened, and firing a backlog on boot would be the surprising half of the
|
||||
// choice.
|
||||
func taskIsDue(rec chargingTaskRecord, now time.Time) bool {
|
||||
h, m, ok := parseHHMM(rec.Time)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
func dueSteps(rec chargingTaskRecord, now time.Time) []models.ChargingStep {
|
||||
loc := time.Local
|
||||
if rec.Zone != "" {
|
||||
if l, err := time.LoadLocation(rec.Zone); err == nil {
|
||||
@@ -135,20 +140,29 @@ func taskIsDue(rec chargingTaskRecord, now time.Time) bool {
|
||||
}
|
||||
}
|
||||
local := now.In(loc)
|
||||
if local.Hour() != h || local.Minute() != m {
|
||||
return false
|
||||
}
|
||||
if len(rec.Days) > 0 && !containsDay(rec.Days, int(local.Weekday())) {
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
// The guard against firing twice: the sweep runs more often than once a
|
||||
// minute, so a task that has already run inside this minute is done.
|
||||
// minute, so a task that has already run inside this minute is done. It is
|
||||
// per task rather than per step because two steps never share a minute in
|
||||
// any flow that means anything — and when they do, they are sent together.
|
||||
if last, err := time.Parse(time.RFC3339, rec.LastRun); err == nil {
|
||||
if !last.Before(local.Truncate(time.Minute)) {
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return true
|
||||
var due []models.ChargingStep
|
||||
for _, step := range rec.Steps {
|
||||
h, m, ok := parseHHMM(step.Time)
|
||||
if !ok {
|
||||
continue // a step whose time is not a time of day fires nothing
|
||||
}
|
||||
if local.Hour() == h && local.Minute() == m {
|
||||
due = append(due, step)
|
||||
}
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
func containsDay(days []int, day int) bool {
|
||||
@@ -160,11 +174,12 @@ func containsDay(days []int, day int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// fireChargingTask sends the task's command to each of its chargers and reports
|
||||
// what each one said. A task naming no chargers acts on every charger its owner
|
||||
// has — "all of them" is a standing wish, so a charger imported after the task
|
||||
// was written is covered by it too.
|
||||
func (s *Server) fireChargingTask(ctx context.Context, who *callerIdentity, rec chargingTaskRecord) []taskRunResult {
|
||||
// fireChargingSteps sends the given steps to each of the task's chargers and
|
||||
// reports what each one said. A task naming no chargers acts on every charger
|
||||
// its owner has — "all of them" is a standing wish, so a charger imported after
|
||||
// the task was written is covered by it too.
|
||||
func (s *Server) fireChargingSteps(ctx context.Context, who *callerIdentity,
|
||||
rec chargingTaskRecord, steps []models.ChargingStep) []taskRunResult {
|
||||
chargers, err := s.ownerChargers(ctx, rec.Owner)
|
||||
if err != nil {
|
||||
return []taskRunResult{{Error: err.Error()}}
|
||||
@@ -183,13 +198,16 @@ func (s *Server) fireChargingTask(ctx context.Context, who *callerIdentity, rec
|
||||
results = append(results, out)
|
||||
continue
|
||||
}
|
||||
status, err := s.sendTaskAction(ctx, who, c.Serial, rec)
|
||||
if err != nil {
|
||||
out.Error = err.Error()
|
||||
} else {
|
||||
out.Status = status
|
||||
for _, step := range steps {
|
||||
one := out
|
||||
status, err := s.sendStep(ctx, who, c.Serial, step)
|
||||
if err != nil {
|
||||
one.Error = err.Error()
|
||||
} else {
|
||||
one.Status = status
|
||||
}
|
||||
results = append(results, one)
|
||||
}
|
||||
results = append(results, out)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
results = append(results, taskRunResult{Error: "this task names no charger that still exists"})
|
||||
@@ -224,23 +242,23 @@ func containsID(ids []string, id string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// sendTaskAction sends one task's command to one charger, through the control
|
||||
// endpoint the page's own buttons use.
|
||||
// sendStep sends one step's command to one charger, through the control endpoint
|
||||
// the page's own buttons use.
|
||||
//
|
||||
// The request is synthesised rather than the transports being called directly,
|
||||
// so a scheduled command cannot end up on a different footing from a pressed
|
||||
// one: the cascade, the ownership gate, the rate limit and the audit line are
|
||||
// the endpoint's, and there is no second copy of them here to drift.
|
||||
func (s *Server) sendTaskAction(ctx context.Context, who *callerIdentity, serial string, rec chargingTaskRecord) (string, error) {
|
||||
func (s *Server) sendStep(ctx context.Context, who *callerIdentity, serial string, step models.ChargingStep) (string, error) {
|
||||
body := map[string]any{}
|
||||
if rec.Action == "limit" {
|
||||
body["amps"] = rec.Amps
|
||||
if step.Action == "limit" {
|
||||
body["amps"] = step.Amps
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + rec.Action
|
||||
path := "/api/integrations/anker-solix/chargers/" + url.PathEscape(serial) + "/" + step.Action
|
||||
req, err := http.NewRequestWithContext(
|
||||
context.WithValue(ctx, ctxCaller, who), http.MethodPost, path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
@@ -250,7 +268,7 @@ func (s *Server) sendTaskAction(ctx context.Context, who *callerIdentity, serial
|
||||
// The endpoint reads its charger and its verb from the route, which nothing
|
||||
// matched here — this request never went through a mux.
|
||||
req.SetPathValue("sn", serial)
|
||||
req.SetPathValue("action", rec.Action)
|
||||
req.SetPathValue("action", step.Action)
|
||||
|
||||
rw := &captureWriter{header: http.Header{}}
|
||||
s.handleAnkerControlAction(rw, req)
|
||||
@@ -332,14 +350,28 @@ func summarizeTaskRun(results []taskRunResult) string {
|
||||
}
|
||||
}
|
||||
|
||||
// recordTaskRun stamps a task with when it last fired and how it went. The stamp
|
||||
// is also the guard that keeps a task from firing twice inside its minute, so a
|
||||
// firing that could not be written down is worth a log line: without it the next
|
||||
// sweep would send the command again.
|
||||
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord, results []taskRunResult) {
|
||||
// taskRunLine is the outcome of one firing as the list shows it: which step, and
|
||||
// how it went. A task holds several steps now, and "2 of 2 sent" says nothing
|
||||
// about which of them it was.
|
||||
//
|
||||
// The step is named by its time rather than its action, because everything this
|
||||
// server writes into last_result is in its own words — charger names, the
|
||||
// control gate's refusals — while the page has a translation for every action.
|
||||
// A clock time reads the same in all three languages.
|
||||
func taskRunLine(step models.ChargingStep, results []taskRunResult) string {
|
||||
return step.Time + " — " + summarizeTaskRun(results)
|
||||
}
|
||||
|
||||
// recordTaskRun stamps a task with when it last fired and how it went.
|
||||
//
|
||||
// The stamp is also the guard that keeps a task from firing twice inside its
|
||||
// minute, so a firing that could not be written down is worth a log line:
|
||||
// without it the next sweep would send the command again.
|
||||
func (s *Server) recordTaskRun(ctx context.Context, rec chargingTaskRecord,
|
||||
step models.ChargingStep, results []taskRunResult) {
|
||||
payload := map[string]any{
|
||||
"last_run": time.Now().UTC().Format(time.RFC3339),
|
||||
"last_result": summarizeTaskRun(results),
|
||||
"last_result": taskRunLine(step, results),
|
||||
}
|
||||
if err := s.pb.Update(ctx, colChargingTasks, rec.ID, payload, nil); err != nil {
|
||||
log.Printf("scheduler: task %s fired but its outcome could not be saved: %v", rec.ID, err)
|
||||
|
||||
@@ -4,14 +4,20 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// The scheduler's clock is the part worth pinning down: it decides on its own,
|
||||
// with nobody watching, whether to send a car a command. A task that fires in
|
||||
// with nobody watching, whether to send a car a command. A step that fires in
|
||||
// the wrong hour is worse than one that does not fire at all, so the zone, the
|
||||
// weekday and the not-twice-in-one-minute guard are each checked here.
|
||||
|
||||
func TestTaskIsDue(t *testing.T) {
|
||||
func step(action, at string, amps float64) models.ChargingStep {
|
||||
return models.ChargingStep{Action: action, Time: at, Amps: amps}
|
||||
}
|
||||
|
||||
func TestDueSteps(t *testing.T) {
|
||||
warsaw, err := time.LoadLocation("Europe/Warsaw")
|
||||
if err != nil {
|
||||
t.Fatalf("Europe/Warsaw: %v", err)
|
||||
@@ -19,32 +25,52 @@ func TestTaskIsDue(t *testing.T) {
|
||||
// A Wednesday, 23:00 in Warsaw — 22:00 UTC.
|
||||
at2300 := time.Date(2026, 9, 2, 23, 0, 30, 0, warsaw)
|
||||
|
||||
base := chargingTaskRecord{Time: "23:00", Zone: "Europe/Warsaw", Enabled: true}
|
||||
|
||||
if !taskIsDue(base, at2300) {
|
||||
t.Error("a task set for 23:00 is not due at 23:00 in its own zone")
|
||||
}
|
||||
// The same instant, handed over as UTC. The zone on the task is what the
|
||||
// time is read in, so where the server thinks it is must not matter.
|
||||
if !taskIsDue(base, at2300.UTC()) {
|
||||
t.Error("the task stopped being due when the same instant arrived as UTC")
|
||||
}
|
||||
if taskIsDue(base, at2300.Add(time.Minute)) {
|
||||
t.Error("a task fired a minute after its time")
|
||||
}
|
||||
if taskIsDue(base, at2300.Add(-time.Minute)) {
|
||||
t.Error("a task fired a minute before its time")
|
||||
// A whole night in one task, which is the point of a flow: it opens, it
|
||||
// eases off, it closes.
|
||||
base := chargingTaskRecord{
|
||||
Zone: "Europe/Warsaw",
|
||||
Enabled: true,
|
||||
Steps: []models.ChargingStep{
|
||||
step("start", "23:00", 0),
|
||||
step("limit", "01:00", 10),
|
||||
step("stop", "06:30", 0),
|
||||
},
|
||||
}
|
||||
|
||||
// Weekdays. 2026-09-02 is a Wednesday (3).
|
||||
due := dueSteps(base, at2300)
|
||||
if len(due) != 1 || due[0].Action != "start" {
|
||||
t.Fatalf("at 23:00 got %+v, want just the start step", due)
|
||||
}
|
||||
// The same instant handed over as UTC. The zone on the task is what the
|
||||
// times are read in, so where the server thinks it is must not matter.
|
||||
if got := dueSteps(base, at2300.UTC()); len(got) != 1 || got[0].Action != "start" {
|
||||
t.Errorf("the step stopped being due when the same instant arrived as UTC: %+v", got)
|
||||
}
|
||||
// The other two steps, each in its own minute and no other.
|
||||
if got := dueSteps(base, time.Date(2026, 9, 3, 1, 0, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "limit" {
|
||||
t.Errorf("at 01:00 got %+v, want the limit step", got)
|
||||
}
|
||||
if got := dueSteps(base, time.Date(2026, 9, 3, 6, 30, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "stop" {
|
||||
t.Errorf("at 06:30 got %+v, want the stop step", got)
|
||||
}
|
||||
// A minute the flow says nothing about fires nothing — the point being that
|
||||
// a task with a step at 23:00 is not "on" from 23:00 onwards.
|
||||
for _, at := range []time.Time{at2300.Add(time.Minute), at2300.Add(-time.Minute),
|
||||
time.Date(2026, 9, 3, 3, 0, 0, 0, warsaw)} {
|
||||
if got := dueSteps(base, at); len(got) != 0 {
|
||||
t.Errorf("at %s got %+v, want nothing due", at.Format("15:04"), got)
|
||||
}
|
||||
}
|
||||
|
||||
// Weekdays gate the whole task. 2026-09-02 is a Wednesday (3).
|
||||
weeknights := base
|
||||
weeknights.Days = []int{1, 2, 3, 4, 5}
|
||||
if !taskIsDue(weeknights, at2300) {
|
||||
t.Error("a weeknight task is not due on a Wednesday")
|
||||
if len(dueSteps(weeknights, at2300)) != 1 {
|
||||
t.Error("a weeknight task did not fire on a Wednesday")
|
||||
}
|
||||
weekends := base
|
||||
weekends.Days = []int{0, 6}
|
||||
if taskIsDue(weekends, at2300) {
|
||||
if len(dueSteps(weekends, at2300)) != 0 {
|
||||
t.Error("a weekend task fired on a Wednesday")
|
||||
}
|
||||
|
||||
@@ -52,34 +78,45 @@ func TestTaskIsDue(t *testing.T) {
|
||||
// and the second sweep must not send the command again.
|
||||
fired := base
|
||||
fired.LastRun = at2300.UTC().Format(time.RFC3339)
|
||||
if taskIsDue(fired, at2300.Add(20*time.Second)) {
|
||||
t.Error("a task fired twice inside its own minute")
|
||||
if len(dueSteps(fired, at2300.Add(20*time.Second))) != 0 {
|
||||
t.Error("a step fired twice inside its own minute")
|
||||
}
|
||||
// Yesterday's firing is not this minute's.
|
||||
yesterday := base
|
||||
yesterday.LastRun = at2300.Add(-24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
if !taskIsDue(yesterday, at2300) {
|
||||
if len(dueSteps(yesterday, at2300)) != 1 {
|
||||
t.Error("yesterday's run stopped today's from firing")
|
||||
}
|
||||
|
||||
// A time that is not a time of day fires nothing rather than firing at
|
||||
// midnight, which is what a zero hour and minute would have meant.
|
||||
// A step whose time is not a time of day fires nothing rather than firing at
|
||||
// midnight, which is what a zero hour and minute would have meant — and it
|
||||
// does not take the rest of the flow down with it.
|
||||
broken := base
|
||||
broken.Time = "later"
|
||||
if taskIsDue(broken, at2300) {
|
||||
t.Error("a task with an unreadable time fired")
|
||||
broken.Steps = []models.ChargingStep{step("start", "later", 0), step("stop", "23:00", 0)}
|
||||
got := dueSteps(broken, at2300)
|
||||
if len(got) != 1 || got[0].Action != "stop" {
|
||||
t.Errorf("got %+v, want only the readable step", got)
|
||||
}
|
||||
|
||||
// Two steps timed to the same minute contradict each other, but nothing
|
||||
// stops somebody writing them, so both are returned rather than one
|
||||
// silently winning.
|
||||
clash := base
|
||||
clash.Steps = []models.ChargingStep{step("start", "23:00", 0), step("boost", "23:00", 0)}
|
||||
if got := dueSteps(clash, at2300); len(got) != 2 {
|
||||
t.Errorf("got %+v, want both steps sharing the minute", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A zone the host has no database for falls back to the server's own clock
|
||||
// rather than silently shifting the schedule to UTC.
|
||||
func TestTaskIsDueUnknownZone(t *testing.T) {
|
||||
func TestDueStepsUnknownZone(t *testing.T) {
|
||||
now := time.Now()
|
||||
rec := chargingTaskRecord{
|
||||
Time: now.Format("15:04"),
|
||||
Zone: "Mars/Olympus_Mons",
|
||||
Zone: "Mars/Olympus_Mons",
|
||||
Steps: []models.ChargingStep{step("start", now.Format("15:04"), 0)},
|
||||
}
|
||||
if !taskIsDue(rec, now) {
|
||||
if len(dueSteps(rec, now)) != 1 {
|
||||
t.Error("a task in an unknown zone did not fall back to the server's clock")
|
||||
}
|
||||
}
|
||||
@@ -130,16 +167,58 @@ func TestNormalizeDays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSteps(t *testing.T) {
|
||||
// A night, written the way it is meant: it opens, it eases off, it closes.
|
||||
// The half-written times are the client's business to send and this
|
||||
// function's business to pad.
|
||||
got, err := normalizeSteps([]models.ChargingStep{
|
||||
step("start", "23:00", 0),
|
||||
step("limit", "1:00", 10),
|
||||
step("stop", "6:30", 0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeSteps: %v", err)
|
||||
}
|
||||
// Kept in the order it was written. Sorting by the clock would file the
|
||||
// 23:00 start last, behind the stop that closes it, which is not the flow
|
||||
// anybody described — and firing does not depend on the order at all.
|
||||
wantTimes := []string{"23:00", "01:00", "06:30"}
|
||||
for i, want := range wantTimes {
|
||||
if got[i].Time != want {
|
||||
t.Errorf("step %d time = %q, want %q (whole flow: %+v)", i, got[i].Time, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
for name, steps := range map[string][]models.ChargingStep{
|
||||
"no steps at all": {},
|
||||
"an unknown action": {step("melt", "23:00", 0)},
|
||||
"a time that is not one": {step("start", "half past", 0)},
|
||||
"a limit with no amps": {step("limit", "23:00", 0)},
|
||||
"amps below the floor": {step("limit", "23:00", 3)},
|
||||
"amps above the rating": {step("limit", "23:00", 40)},
|
||||
} {
|
||||
if _, err := normalizeSteps(steps); err == nil {
|
||||
t.Errorf("normalizeSteps accepted %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// The cap bounds what the sweep re-reads every thirty seconds.
|
||||
tooMany := make([]models.ChargingStep, maxTaskSteps+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = step("start", "23:00", 0)
|
||||
}
|
||||
if _, err := normalizeSteps(tooMany); err == nil {
|
||||
t.Errorf("normalizeSteps accepted %d steps, past the cap of %d", len(tooMany), maxTaskSteps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskPayloadValidation(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
num := func(f float64) *float64 { return &f }
|
||||
steps := func(s ...models.ChargingStep) *[]models.ChargingStep { return &s }
|
||||
|
||||
// A whole task, as a create sends it.
|
||||
full := chargingTaskBody{
|
||||
Name: str(" Night rate "),
|
||||
Action: str("limit"),
|
||||
Time: str("7:05"),
|
||||
Amps: num(10),
|
||||
Name: str(" Night rate "),
|
||||
Steps: steps(step("start", "23:00", 0), step("stop", "6:30", 0)),
|
||||
}
|
||||
payload, err := taskPayload(full, true)
|
||||
if err != nil {
|
||||
@@ -148,36 +227,26 @@ func TestTaskPayloadValidation(t *testing.T) {
|
||||
if payload["name"] != "Night rate" {
|
||||
t.Errorf("name = %v, want it trimmed", payload["name"])
|
||||
}
|
||||
if payload["time"] != "07:05" {
|
||||
t.Errorf("time = %v, want the padded 07:05", payload["time"])
|
||||
}
|
||||
if payload["enabled"] != true {
|
||||
t.Error("a new task was written switched off; nobody fills in a schedule to leave it off")
|
||||
}
|
||||
if flow, _ := payload["steps"].([]models.ChargingStep); len(flow) != 2 || flow[0].Time != "23:00" {
|
||||
t.Errorf("steps = %+v, want both, as written", payload["steps"])
|
||||
}
|
||||
|
||||
// The fields a task cannot exist without.
|
||||
// The two things a task cannot exist without.
|
||||
for _, missing := range []chargingTaskBody{
|
||||
{Action: str("start"), Time: str("23:00")},
|
||||
{Name: str("x"), Time: str("23:00")},
|
||||
{Name: str("x"), Action: str("start")},
|
||||
{Name: str(" "), Action: str("start"), Time: str("23:00")},
|
||||
{Name: str("x"), Action: str("melt"), Time: str("23:00")},
|
||||
{Name: str("x"), Action: str("start"), Time: str("half past")},
|
||||
{Steps: steps(step("start", "23:00", 0))},
|
||||
{Name: str("x")},
|
||||
{Name: str(" "), Steps: steps(step("start", "23:00", 0))},
|
||||
} {
|
||||
if _, err := taskPayload(missing, true); err == nil {
|
||||
t.Errorf("taskPayload accepted an incomplete task: %+v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// The charger's own floor and ceiling.
|
||||
for _, amps := range []float64{1, 5, 33} {
|
||||
if _, err := taskPayload(chargingTaskBody{Amps: num(amps)}, false); err == nil {
|
||||
t.Errorf("taskPayload accepted %g A, outside what the charger will take", amps)
|
||||
}
|
||||
}
|
||||
|
||||
// A partial write says only what it names, so a task switched off from the
|
||||
// list keeps its days and its time.
|
||||
// list keeps its flow, its chargers and its days.
|
||||
on := true
|
||||
partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false)
|
||||
if err != nil {
|
||||
@@ -188,26 +257,17 @@ func TestTaskPayloadValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A "limit" with no ceiling would fire and ask the charger for 0 A. Both halves
|
||||
// are read from whatever the write leaves behind, so a PATCH naming one of them
|
||||
// is checked against the stored other.
|
||||
func TestCheckLimitAmps(t *testing.T) {
|
||||
stored := chargingTaskRecord{Action: "start", Amps: 0}
|
||||
if err := checkLimitAmps(map[string]any{"action": "limit"}, stored); err == nil {
|
||||
t.Error("a task switched to limit with no amps was accepted")
|
||||
// The list is ordered by the time a task begins, which lives inside its flow
|
||||
// rather than in a column PocketBase could sort on.
|
||||
func TestFirstStepTime(t *testing.T) {
|
||||
rec := chargingTaskRecord{Steps: []models.ChargingStep{step("start", "23:00", 0), step("stop", "06:30", 0)}}
|
||||
if got := rec.firstStepTime(); got != "23:00" {
|
||||
t.Errorf("firstStepTime = %q, want the first step's 23:00", got)
|
||||
}
|
||||
if err := checkLimitAmps(map[string]any{"action": "limit", "amps": 16.0}, stored); err != nil {
|
||||
t.Errorf("a complete limit task was refused: %v", err)
|
||||
}
|
||||
// The amps are already on the record; the PATCH only changes the action.
|
||||
hasAmps := chargingTaskRecord{Action: "start", Amps: 16}
|
||||
if err := checkLimitAmps(map[string]any{"action": "limit"}, hasAmps); err != nil {
|
||||
t.Errorf("a limit task with stored amps was refused: %v", err)
|
||||
}
|
||||
// And the other way round: clearing the amps on a task that limits.
|
||||
limits := chargingTaskRecord{Action: "limit", Amps: 16}
|
||||
if err := checkLimitAmps(map[string]any{"amps": 0.0}, limits); err == nil {
|
||||
t.Error("the amps were cleared off a limit task")
|
||||
// A task with no steps cannot be written, but sorting must not depend on
|
||||
// that — it sorts last rather than first.
|
||||
if got := (chargingTaskRecord{}).firstStepTime(); got < "23:59" {
|
||||
t.Errorf("an empty task sorted to %q, want it last", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ type userRecord struct {
|
||||
Locale string `json:"locale"`
|
||||
DateFormat string `json:"date_format"`
|
||||
TimeFormat string `json:"time_format"`
|
||||
WeekStart string `json:"week_start"`
|
||||
Currency string `json:"currency"`
|
||||
FontSize string `json:"font_size"`
|
||||
DragLocked bool `json:"drag_locked"`
|
||||
@@ -99,6 +100,7 @@ func (rec userRecord) toModel() models.User {
|
||||
Locale: orDefault(rec.Locale, "en-US"),
|
||||
DateFormat: orDefault(rec.DateFormat, "YMD"),
|
||||
TimeFormat: orDefault(rec.TimeFormat, "auto"),
|
||||
WeekStart: orDefault(rec.WeekStart, "auto"),
|
||||
Currency: orDefault(rec.Currency, "USD"),
|
||||
FontSize: orDefault(rec.FontSize, "medium"),
|
||||
DragLocked: rec.DragLocked,
|
||||
@@ -164,6 +166,7 @@ type updateMeRequest struct {
|
||||
Locale *string `json:"locale"`
|
||||
DateFormat *string `json:"dateFormat"`
|
||||
TimeFormat *string `json:"timeFormat"`
|
||||
WeekStart *string `json:"weekStart"`
|
||||
Currency *string `json:"currency"`
|
||||
FontSize *string `json:"fontSize"`
|
||||
DragLocked *bool `json:"dragLocked"`
|
||||
@@ -264,6 +267,12 @@ var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true
|
||||
// the app did before there was a setting; the other two say it outright, for
|
||||
// the people whose region and habit disagree.
|
||||
var validTimeFormats = map[string]bool{"auto": true, "24": true, "12": true}
|
||||
|
||||
// Which day a week is drawn as starting on. "auto" is the chosen region's own
|
||||
// convention — Monday across most of Europe, Sunday in the US — and is what
|
||||
// every weekday row read before there was a setting; the other two say it
|
||||
// outright, for the people whose region and habit disagree.
|
||||
var validWeekStarts = map[string]bool{"auto": true, "monday": true, "sunday": true}
|
||||
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}
|
||||
|
||||
// Kept in step with the users.currency select options in setup-pocketbase.mjs:
|
||||
@@ -333,6 +342,13 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
payload["time_format"] = *in.TimeFormat
|
||||
}
|
||||
if in.WeekStart != nil {
|
||||
if !validWeekStarts[*in.WeekStart] {
|
||||
writeError(w, http.StatusBadRequest, "weekStart must be auto, monday, or sunday")
|
||||
return
|
||||
}
|
||||
payload["week_start"] = *in.WeekStart
|
||||
}
|
||||
if in.Currency != nil {
|
||||
if !validCurrencies[*in.Currency] {
|
||||
writeError(w, http.StatusBadRequest, "currency must be a supported ISO 4217 code")
|
||||
|
||||
@@ -502,7 +502,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/charging-tasks", s.createChargingTask)
|
||||
mux.HandleFunc("PATCH /api/charging-tasks/{id}", s.updateChargingTask)
|
||||
mux.HandleFunc("DELETE /api/charging-tasks/{id}", s.deleteChargingTask)
|
||||
mux.HandleFunc("POST /api/charging-tasks/{id}/run", s.runChargingTaskNow)
|
||||
mux.HandleFunc("POST /api/charging-tasks/{id}/steps/{step}/run", s.runChargingStepNow)
|
||||
|
||||
// Cars + sharing.
|
||||
mux.HandleFunc("GET /api/cars", s.listCars)
|
||||
|
||||
@@ -236,11 +236,12 @@ var collectionsSchema = map[string][]fieldDef{
|
||||
// standing wish that keeps covering chargers imported later — and a
|
||||
// multi-relation would have to be rewritten on every import to say it.
|
||||
fJSON("chargers", 2000),
|
||||
fSelect("action", []string{"start", "stop", "limit", "boost"}, true),
|
||||
fNumber("amps"), // the ceiling, for the "limit" action
|
||||
// 24-hour "HH:MM", read in the IANA zone the task was written in. The
|
||||
// server's clock is not the one the user set the time by.
|
||||
fText("time", true),
|
||||
// The flow: [{action, amps, time}, …] in the order it runs. A whole
|
||||
// charging window is one task rather than the two that would otherwise
|
||||
// open and close it, so it is named once and switched off once. Times are
|
||||
// 24-hour "HH:MM" read in the zone below — the server's clock is not the
|
||||
// one the user set 23:00 by. Validated in internal/api/chargingtasks.go.
|
||||
fJSON("steps", 4000),
|
||||
fText("zone", false),
|
||||
fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day
|
||||
fBool("enabled"),
|
||||
@@ -262,6 +263,10 @@ var collectionsSchema = map[string][]fieldDef{
|
||||
// "auto" is the region's own convention, which is what every clock in the
|
||||
// app read before this field existed.
|
||||
fSelect("time_format", []string{"auto", "24", "12"}, false),
|
||||
// The day a week is drawn as starting on, wherever weekdays are laid out
|
||||
// in a row. "auto" is the region's own convention, which is what the
|
||||
// scheduler's day picker read before this field existed.
|
||||
fSelect("week_start", []string{"auto", "monday", "sunday"}, false),
|
||||
fSelect("currency", []string{
|
||||
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
||||
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
||||
|
||||
@@ -152,14 +152,26 @@ type HomeCharger struct {
|
||||
Created string `json:"created,omitempty"`
|
||||
}
|
||||
|
||||
// ChargingTask is one line of the home-charger scheduler: an action, a time of
|
||||
// day, the days it repeats on, and the chargers it acts on. It belongs to the
|
||||
// person, like the chargers themselves — one list covering every charger they
|
||||
// own, rather than a separate schedule inside each one.
|
||||
// ChargingStep is one command in a task's flow: what to do, and at what time
|
||||
// of day. A step is the smallest thing the scheduler sends.
|
||||
type ChargingStep struct {
|
||||
// start, stop, limit (to Amps) or boost.
|
||||
Action string `json:"action"`
|
||||
Amps float64 `json:"amps,omitempty"`
|
||||
|
||||
// A 24-hour "HH:MM", read in the task's zone.
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
// ChargingTask is one entry in the home-charger scheduler: a flow of steps, the
|
||||
// days it repeats on, and the chargers it acts on. It belongs to the person,
|
||||
// like the chargers themselves — one list covering every charger they own,
|
||||
// rather than a separate schedule inside each one.
|
||||
//
|
||||
// The charger's own cloud schedule can only say "charge between these hours,
|
||||
// every day, on this one box". This says "at 23:00 on weeknights, cap these two
|
||||
// chargers at 10 A" — several tasks, each naming its own chargers.
|
||||
// every day, on this one box". This says "start at 23:00, cap to 10 A at 01:00,
|
||||
// stop at 06:30 — on weeknights, on these two chargers", as one named thing that
|
||||
// is switched on and off as one.
|
||||
type ChargingTask struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -169,16 +181,14 @@ type ChargingTask struct {
|
||||
// them" is a standing wish, not the list that happened to exist that day.
|
||||
Chargers []string `json:"chargers"`
|
||||
|
||||
// What to do: start, stop, limit (to Amps) or boost. One action per task; a
|
||||
// charging window is the two tasks that open and close it, which is also how
|
||||
// it is edited and how it is switched off.
|
||||
Action string `json:"action"`
|
||||
Amps float64 `json:"amps,omitempty"`
|
||||
// The flow, in the order it runs. A whole charging window lives in one task
|
||||
// rather than in the two that used to open and close it: it is named once,
|
||||
// switched off once, and reads as the one intention it is.
|
||||
Steps []ChargingStep `json:"steps"`
|
||||
|
||||
// When, as a 24-hour "HH:MM" read in Zone — the IANA zone the browser was in
|
||||
// The IANA zone the steps' times are read in — the one the browser was in
|
||||
// when the task was written. The server's own clock is not the one the user
|
||||
// set the time by, and a laptop that travels must not move the schedule.
|
||||
Time string `json:"time"`
|
||||
// set 23:00 by, and a laptop that travels must not move the schedule.
|
||||
Zone string `json:"zone,omitempty"`
|
||||
|
||||
// The weekdays it repeats on, 0=Sunday … 6=Saturday. Empty means every day.
|
||||
@@ -186,8 +196,9 @@ type ChargingTask struct {
|
||||
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// What happened the last time it fired, so a task that has been failing
|
||||
// quietly for a week says so in the list rather than in a log nobody reads.
|
||||
// What happened the last time a step of it fired, so a task that has been
|
||||
// failing quietly for a week says so in the list rather than in a log nobody
|
||||
// reads.
|
||||
LastRun string `json:"lastRun,omitempty"` // RFC3339, UTC
|
||||
LastResult string `json:"lastResult,omitempty"`
|
||||
|
||||
@@ -519,9 +530,12 @@ type User struct {
|
||||
Locale string `json:"locale"` // e.g. "en-US"
|
||||
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
|
||||
TimeFormat string `json:"timeFormat"` // auto (the region's own) | 24 | 12
|
||||
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
|
||||
FontSize string `json:"fontSize"` // small | medium | large
|
||||
Role string `json:"role"` // user | admin
|
||||
// The day a week is drawn as starting on, wherever a client lays weekdays
|
||||
// out in a row — the scheduler's day picker today.
|
||||
WeekStart string `json:"weekStart"` // auto (the region's own) | monday | sunday
|
||||
Currency string `json:"currency"` // ISO 4217 code, e.g. "EUR"
|
||||
FontSize string `json:"fontSize"` // small | medium | large
|
||||
Role string `json:"role"` // user | admin
|
||||
|
||||
// DragLocked holds every arrangement on this account's pages still: the
|
||||
// garage, a car's tabs, its Information rows, the provider's readings. A
|
||||
|
||||
@@ -473,10 +473,11 @@ const DESIRED = {
|
||||
// relation because empty has to mean "every charger I own" — a standing wish
|
||||
// that keeps covering chargers imported later.
|
||||
F.json("chargers", 2000),
|
||||
F.select("action", ["start", "stop", "limit", "boost"], true),
|
||||
F.number("amps"), // the ceiling, for the "limit" action
|
||||
// 24-hour "HH:MM", read in the IANA zone the task was written in.
|
||||
F.text("time", true),
|
||||
// The flow: [{action, amps, time}, …] in the order it runs. A whole charging
|
||||
// window is one task rather than the two that would otherwise open and close
|
||||
// it, so it is named once and switched off once. Times are 24-hour "HH:MM"
|
||||
// read in the zone below.
|
||||
F.json("steps", 4000),
|
||||
F.text("zone"),
|
||||
F.json("days", 200), // 0=Sunday … 6=Saturday; empty means every day
|
||||
F.bool("enabled"),
|
||||
@@ -517,6 +518,10 @@ const DESIRED = {
|
||||
// "auto" is the region's own convention. Kept in step with
|
||||
// validTimeFormats in internal/api/me.go.
|
||||
F.select("time_format", ["auto", "24", "12"]),
|
||||
// The day a week is drawn as starting on, wherever a client lays weekdays
|
||||
// out in a row. "auto" is the region's own convention. Kept in step with
|
||||
// validWeekStarts in internal/api/me.go.
|
||||
F.select("week_start", ["auto", "monday", "sunday"]),
|
||||
// European currencies plus the non-European ones the panel already offered.
|
||||
// Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
|
||||
// in the web app's Settings.vue.
|
||||
|
||||
@@ -319,8 +319,9 @@ export const api = {
|
||||
// The home-charger scheduler: one list of charging tasks per user, covering
|
||||
// every charger they own. The server holds the clock — a schedule that only
|
||||
// fires while this page is open would be a reminder, not a schedule — so the
|
||||
// page only writes tasks and reads back how each one last went. runChargingTask
|
||||
// fires one now, whatever its time and whether or not it is switched on.
|
||||
// page only writes tasks and reads back how each one last went.
|
||||
// runChargingStep fires one step now, whatever its time and whether or not
|
||||
// the task it belongs to is switched on.
|
||||
listChargingTasks: () => request("/charging-tasks").then((r) => r.tasks),
|
||||
createChargingTask: (body) =>
|
||||
request("/charging-tasks", { method: "POST", body: JSON.stringify(body) }).then((r) => r.task),
|
||||
@@ -330,8 +331,11 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => r.task),
|
||||
deleteChargingTask: (id) => request(`/charging-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
runChargingTask: (id) =>
|
||||
request(`/charging-tasks/${encodeURIComponent(id)}/run`, { method: "POST" }),
|
||||
// One step of a flow, fired now: running a start and the stop that closes it
|
||||
// back to back would leave the charger where it began and prove nothing.
|
||||
runChargingStep: (id, step) =>
|
||||
request(`/charging-tasks/${encodeURIComponent(id)}/steps/${encodeURIComponent(step)}/run`,
|
||||
{ method: "POST" }),
|
||||
|
||||
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
|
||||
// returns the resolved view (effective/own/locked per field, secrets and
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
// of them" is a standing wish rather than the list that happened to exist that
|
||||
// day.
|
||||
//
|
||||
// One action per task. A charging window is the two tasks that open and close
|
||||
// it, which is also how it is read back, edited and switched off; folding both
|
||||
// ends into one row would have made the common case shorter and every other case
|
||||
// impossible.
|
||||
// A task holds a flow rather than a single command: start at 23:00, ease down to
|
||||
// 10 A at 01:00, stop at 06:30. That is one intention, so it is one named thing
|
||||
// with one switch — splitting a charging window across two tasks meant naming it
|
||||
// twice and remembering to switch off both ends.
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { api } from "../api";
|
||||
import { t } from "../i18n";
|
||||
import { weekdaysInOrder, weekdayShortName } from "../lib/format.js";
|
||||
import Modal from "./Modal.vue";
|
||||
import TimeField from "./TimeField.vue";
|
||||
|
||||
@@ -30,9 +31,26 @@ const emit = defineEmits(["saved", "close"]);
|
||||
const editing = computed(() => !!props.task?.id);
|
||||
|
||||
const name = ref(props.task?.name || "");
|
||||
const action = ref(props.task?.action || "start");
|
||||
const at = ref(props.task?.time || "23:00");
|
||||
const amps = ref(props.task?.amps || 16);
|
||||
|
||||
// The flow, as rows the form edits in place. A new task opens with the one step
|
||||
// most schedules start from, so the common case is a name and a time rather than
|
||||
// a decision about how many rows to add.
|
||||
const steps = ref(
|
||||
(props.task?.steps || []).length
|
||||
? props.task.steps.map((s) => ({ action: s.action, time: s.time, amps: s.amps || 16 }))
|
||||
: [{ action: "start", time: "23:00", amps: 16 }]
|
||||
);
|
||||
|
||||
// A flow of one is a flow, so the last row cannot be removed — an empty task
|
||||
// would have nothing to fire and the server refuses it anyway.
|
||||
function addStep() {
|
||||
steps.value = [...steps.value, { action: "stop", time: "06:30", amps: 16 }];
|
||||
}
|
||||
|
||||
function removeStep(i) {
|
||||
if (steps.value.length <= 1) return;
|
||||
steps.value = steps.value.filter((_, n) => n !== i);
|
||||
}
|
||||
// The chargers this task acts on. Empty is meaningful — it means all of them —
|
||||
// so the picker has a switch of its own rather than leaving an empty list
|
||||
// looking like an unfinished form.
|
||||
@@ -51,19 +69,13 @@ const error = ref("");
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// The row starts on whichever day this account reads a week as starting on —
|
||||
// Settings › Appearance › First day of the week, following the region unless it
|
||||
// was answered outright. A computed rather than a constant, so changing the
|
||||
// setting in another tab re-lays the row out instead of leaving it on the old
|
||||
// week. Both of these come from lib/format.js, which owns the rule for every
|
||||
// weekday row in the app.
|
||||
const weekdays = computed(() => weekdaysInOrder());
|
||||
|
||||
function toggleDay(day) {
|
||||
everyDay.value = false;
|
||||
@@ -95,8 +107,12 @@ watch(allChargers, (on) => {
|
||||
});
|
||||
|
||||
// A time still being typed is not a time — TimeField says so with an empty
|
||||
// value, and a task cannot be saved without one.
|
||||
const canSave = computed(() => !!name.value.trim() && !!at.value && !saving.value);
|
||||
// value — and one unfinished row is enough to make the whole flow unsaveable,
|
||||
// because the server would otherwise refuse it with a step number the form does
|
||||
// not show.
|
||||
const canSave = computed(
|
||||
() => !!name.value.trim() && steps.value.every((s) => !!s.time) && !saving.value
|
||||
);
|
||||
|
||||
function chargerSubtitle(c) {
|
||||
return [c.serial, c.model].filter(Boolean).join(" · ");
|
||||
@@ -108,9 +124,14 @@ async function submit() {
|
||||
error.value = "";
|
||||
const body = {
|
||||
name: name.value.trim(),
|
||||
action: action.value,
|
||||
time: at.value,
|
||||
amps: Number(amps.value) || 0,
|
||||
// The amps ride along on every step so switching one to "limit" and back
|
||||
// does not lose the number that was typed; the server keeps them for the
|
||||
// same reason and ignores them on the actions that have no ceiling.
|
||||
steps: steps.value.map((s) => ({
|
||||
action: s.action,
|
||||
time: s.time,
|
||||
amps: s.action === "limit" ? Number(s.amps) || 0 : 0,
|
||||
})),
|
||||
chargers: allChargers.value ? [] : [...picked.value],
|
||||
days: everyDay.value ? [] : [...days.value],
|
||||
// The time is a wall clock, and the server's is not the one it was set by.
|
||||
@@ -152,29 +173,46 @@ function browserZone() {
|
||||
<input v-model="name" required class="dh-input" :placeholder="t('forms.chargingTask.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<!-- What and when. Side by side because they are read as one sentence:
|
||||
"start, at 23:00". -->
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.action") }}</label>
|
||||
<select v-model="action" class="dh-input">
|
||||
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.time") }}</label>
|
||||
<TimeField v-model="at" :aria-label="t('forms.chargingTask.time')" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- The flow. One row per step, each an action and the time it fires —
|
||||
read down, they are the night: start at 23:00, ease off at 01:00, stop
|
||||
at 06:30. -->
|
||||
<div>
|
||||
<label class="dh-label">{{ t("forms.chargingTask.flow") }}</label>
|
||||
|
||||
<!-- The ceiling, for the one action that takes one. -->
|
||||
<div v-if="action === 'limit'">
|
||||
<label class="dh-label">
|
||||
{{ t("forms.chargingTask.amps") }}
|
||||
<span class="data float-right font-semibold text-strong">{{ amps }} A</span>
|
||||
</label>
|
||||
<input v-model.number="amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
|
||||
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
|
||||
<div v-for="(s, i) in steps" :key="i" class="mb-2 rounded-control bg-sunken p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<select v-model="s.action" class="dh-input min-w-0 flex-1">
|
||||
<option v-for="a in ACTIONS" :key="a" :value="a">{{ t(`charging.scheduler.actions.${a}`) }}</option>
|
||||
</select>
|
||||
<TimeField v-model="s.time" :aria-label="t('forms.chargingTask.time')" />
|
||||
<!-- The last step cannot go: a task with no steps has nothing to
|
||||
fire, so the control is absent rather than there and refusing. -->
|
||||
<button
|
||||
v-if="steps.length > 1"
|
||||
type="button"
|
||||
class="shrink-0 rounded-control px-2 py-2 text-xs font-medium text-muted transition-colors hover:text-danger"
|
||||
:title="t('forms.chargingTask.removeStep')"
|
||||
@click="removeStep(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The ceiling, under the one action that takes one. -->
|
||||
<div v-if="s.action === 'limit'" class="mt-3">
|
||||
<label class="dh-label">
|
||||
{{ t("forms.chargingTask.amps") }}
|
||||
<span class="data float-right font-semibold text-strong">{{ s.amps }} A</span>
|
||||
</label>
|
||||
<input v-model.number="s.amps" type="range" min="6" max="32" step="1" class="w-full accent-brand-600" />
|
||||
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.ampsHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="dh-btn dh-btn-ghost w-full !py-2 text-xs" @click="addStep">
|
||||
{{ t("forms.chargingTask.addStep") }}
|
||||
</button>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("forms.chargingTask.flowHint") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Which chargers. The point of one scheduler for all of them. -->
|
||||
@@ -209,7 +247,7 @@ function browserZone() {
|
||||
</label>
|
||||
<div class="mt-1 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="d in WEEKDAYS"
|
||||
v-for="d in weekdays"
|
||||
:key="d"
|
||||
type="button"
|
||||
class="rounded-pill border px-3 py-1.5 text-xs font-semibold transition-colors"
|
||||
@@ -218,7 +256,7 @@ function browserZone() {
|
||||
: 'border-subtle text-muted hover:bg-sunken'"
|
||||
@click="toggleDay(d)"
|
||||
>
|
||||
{{ weekdayLabel(d) }}
|
||||
{{ weekdayShortName(d) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -389,9 +389,9 @@
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Ladeopgaver",
|
||||
"subtitle": "Én plan for alle dine ladere. Hver opgave sender én kommando på ét tidspunkt, på de dage du vælger, til de ladere du vælger.",
|
||||
"subtitle": "Én plan for alle dine ladere. En opgave er et forløb — start, grænse, stop — der kører på de dage du vælger, på de ladere du vælger.",
|
||||
"add": "Ny opgave",
|
||||
"empty": "Ingen opgaver endnu. En opgave er én kommando på ét tidspunkt — start kl. 23:00 på hverdage, begræns til 10 A når taksten skifter.",
|
||||
"empty": "Ingen opgaver endnu. En opgave er et helt forløb: start kl. 23:00, begræns til 10 A kl. 01:00, stop kl. 06:30.",
|
||||
"needCharger": "Importér først en lader under Hjemmeladere — en opgave skal have noget at handle på.",
|
||||
"serverHint": "Opgaverne kører på serveren, så de udføres uanset om denne side er åben. Tidspunkter læses i den tidszone, du skrev dem i.",
|
||||
"allChargers": "Alle ladere",
|
||||
@@ -555,6 +555,11 @@
|
||||
"time24": "24-timers",
|
||||
"time12": "12-timers",
|
||||
"timeExample": "Eksempel: {example}",
|
||||
"weekStart": "Første dag i ugen",
|
||||
"weekAuto": "Følg regionen",
|
||||
"weekMonday": "Mandag",
|
||||
"weekSunday": "Søndag",
|
||||
"weekExample": "Eksempel: {example}",
|
||||
"currency": "Valuta",
|
||||
"currencyExample": "Eksempel: {example} — kun visning, ingen beløb omregnes.",
|
||||
"fontSize": "Skriftstørrelse",
|
||||
@@ -1273,8 +1278,11 @@
|
||||
"editTitle": "Rediger ladeopgave",
|
||||
"name": "Navn",
|
||||
"namePlaceholder": "Nattakst",
|
||||
"action": "Gør dette",
|
||||
"time": "Kl.",
|
||||
"flow": "Forløbet",
|
||||
"flowHint": "Hvert trin udføres på sit eget tidspunkt, hver dag opgaven kører. En hel nat er én opgave: start kl. 23:00, stop kl. 06:30.",
|
||||
"addStep": "+ Tilføj et trin",
|
||||
"removeStep": "Fjern dette trin",
|
||||
"amps": "Strømgrænse",
|
||||
"ampsHint": "6 A er bundgrænsen — derunder sætter laderen på pause i stedet for at lade langsomt.",
|
||||
"chargers": "På disse ladere",
|
||||
|
||||
@@ -388,9 +388,9 @@
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Charging tasks",
|
||||
"subtitle": "One schedule for every charger you own. Each task sends one command at one time, on the days you pick, to the chargers you pick.",
|
||||
"subtitle": "One schedule for every charger you own. A task is a flow — start, limit, stop — running on the days you pick, on the chargers you pick.",
|
||||
"add": "New task",
|
||||
"empty": "No tasks yet. A task is one command at one time — start at 23:00 on weeknights, cap at 10 A when the tariff changes.",
|
||||
"empty": "No tasks yet. A task is a whole flow: start at 23:00, cap to 10 A at 01:00, stop at 06:30.",
|
||||
"needCharger": "Import a charger under Home chargers first — a task needs something to act on.",
|
||||
"serverHint": "Tasks run on the server, so they fire whether or not this page is open. Times are read in the time zone you wrote them in.",
|
||||
"allChargers": "All chargers",
|
||||
@@ -554,6 +554,11 @@
|
||||
"time24": "24-hour",
|
||||
"time12": "12-hour",
|
||||
"timeExample": "Example: {example}",
|
||||
"weekStart": "First day of the week",
|
||||
"weekAuto": "Follow the region",
|
||||
"weekMonday": "Monday",
|
||||
"weekSunday": "Sunday",
|
||||
"weekExample": "Example: {example}",
|
||||
"currency": "Currency",
|
||||
"currencyExample": "Example: {example} — display only, no amounts are converted.",
|
||||
"fontSize": "Font size",
|
||||
@@ -1272,8 +1277,11 @@
|
||||
"editTitle": "Edit charging task",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Night rate",
|
||||
"action": "Do this",
|
||||
"time": "At",
|
||||
"flow": "The flow",
|
||||
"flowHint": "Each step fires at its own time, every day the task runs. A whole night is one task: start at 23:00, stop at 06:30.",
|
||||
"addStep": "+ Add a step",
|
||||
"removeStep": "Remove this step",
|
||||
"amps": "Current limit",
|
||||
"ampsHint": "6 A is the floor — below it the charger pauses rather than charging slowly.",
|
||||
"chargers": "On these chargers",
|
||||
|
||||
@@ -391,9 +391,9 @@
|
||||
},
|
||||
"scheduler": {
|
||||
"title": "Zadania ładowania",
|
||||
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Każde zadanie wysyła jedno polecenie o jednej godzinie, w wybrane dni, do wybranych ładowarek.",
|
||||
"subtitle": "Jeden harmonogram dla wszystkich Twoich ładowarek. Zadanie to przebieg — start, limit, stop — wykonywany w wybrane dni, na wybranych ładowarkach.",
|
||||
"add": "Nowe zadanie",
|
||||
"empty": "Brak zadań. Zadanie to jedno polecenie o jednej godzinie — start o 23:00 w dni robocze, ograniczenie do 10 A po zmianie taryfy.",
|
||||
"empty": "Brak zadań. Zadanie to cały przebieg: start o 23:00, ograniczenie do 10 A o 01:00, stop o 06:30.",
|
||||
"needCharger": "Najpierw zaimportuj ładowarkę w zakładce Ładowarki domowe — zadanie musi mieć na czym działać.",
|
||||
"serverHint": "Zadania działają na serwerze, więc uruchamiają się niezależnie od tego, czy ta strona jest otwarta. Godziny są odczytywane w strefie czasowej, w której je zapisano.",
|
||||
"allChargers": "Wszystkie ładowarki",
|
||||
@@ -561,6 +561,11 @@
|
||||
"time24": "24-godzinny",
|
||||
"time12": "12-godzinny",
|
||||
"timeExample": "Przykład: {example}",
|
||||
"weekStart": "Pierwszy dzień tygodnia",
|
||||
"weekAuto": "Zgodnie z regionem",
|
||||
"weekMonday": "Poniedziałek",
|
||||
"weekSunday": "Niedziela",
|
||||
"weekExample": "Przykład: {example}",
|
||||
"currency": "Waluta",
|
||||
"currencyExample": "Przykład: {example} — tylko wyświetlanie, kwoty nie są przeliczane.",
|
||||
"fontSize": "Rozmiar czcionki",
|
||||
@@ -1289,8 +1294,11 @@
|
||||
"editTitle": "Edytuj zadanie ładowania",
|
||||
"name": "Nazwa",
|
||||
"namePlaceholder": "Taryfa nocna",
|
||||
"action": "Zrób to",
|
||||
"time": "O godzinie",
|
||||
"flow": "Przebieg",
|
||||
"flowHint": "Każdy krok uruchamia się o własnej godzinie, w każdy dzień działania zadania. Cała noc to jedno zadanie: start o 23:00, stop o 06:30.",
|
||||
"addStep": "+ Dodaj krok",
|
||||
"removeStep": "Usuń ten krok",
|
||||
"amps": "Limit prądu",
|
||||
"ampsHint": "6 A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
|
||||
"chargers": "Na tych ładowarkach",
|
||||
|
||||
@@ -151,6 +151,78 @@ function regionReadsTwelveHour() {
|
||||
return twelveHourRegions.get(locale);
|
||||
}
|
||||
|
||||
// --- Weekdays --------------------------------------------------------------
|
||||
//
|
||||
// A week does not start on the same day everywhere: Monday across most of
|
||||
// Europe, Sunday in the US and a good deal of Asia. A row of weekday buttons
|
||||
// that always begins on Sunday reads wrong to half the people looking at it,
|
||||
// and reads wrong in a way that is easy to misclick — Settings › Appearance ›
|
||||
// First day of the week is the answer, with "auto" following the chosen region
|
||||
// the way the clock setting does.
|
||||
//
|
||||
// Everything that lays weekdays out in a row goes through these two, so there
|
||||
// is one answer to "which day comes first" rather than one per screen. Days are
|
||||
// numbered the way Date.getDay() and the scheduler's stored tasks number them:
|
||||
// 0 = Sunday … 6 = Saturday.
|
||||
|
||||
// Whether weeks are drawn as starting on Monday right now: what the setting
|
||||
// says outright, or what the region says when it is left on auto.
|
||||
export function weekStartsOnMonday() {
|
||||
const mode = prefs.weekStart;
|
||||
if (mode === "monday") return true;
|
||||
if (mode === "sunday") return false;
|
||||
return regionStartsOnMonday();
|
||||
}
|
||||
|
||||
// The one question "auto" asks the region. Cached per locale like the clock's,
|
||||
// and for the same reason — it is asked once per weekday button.
|
||||
const mondayRegions = new Map();
|
||||
|
||||
function regionStartsOnMonday() {
|
||||
const locale = prefs.locale || "";
|
||||
if (!mondayRegions.has(locale)) {
|
||||
// ISO 8601 numbers the days 1 = Monday … 7 = Sunday, which is what weekInfo
|
||||
// reports. Browsers expose it as a method on some engines and a property on
|
||||
// others, hence both.
|
||||
let monday = true;
|
||||
try {
|
||||
const info = new Intl.Locale(locale || "en-US");
|
||||
const first = (info.getWeekInfo?.() || info.weekInfo)?.firstDay;
|
||||
if (first) monday = first === 1;
|
||||
} catch {
|
||||
// An engine without week information, or an unusable locale. Monday is
|
||||
// the safer default: it is ISO 8601's, and the convention in every region
|
||||
// this app's own currency list covers bar one.
|
||||
}
|
||||
mondayRegions.set(locale, monday);
|
||||
}
|
||||
return mondayRegions.get(locale);
|
||||
}
|
||||
|
||||
// The seven days in the order they should be drawn, as day numbers.
|
||||
export function weekdaysInOrder() {
|
||||
return weekStartsOnMonday() ? [1, 2, 3, 4, 5, 6, 0] : [0, 1, 2, 3, 4, 5, 6];
|
||||
}
|
||||
|
||||
// One day's short name in the user's own language, so a row reads Pn Wt Śr in
|
||||
// Polish without a table here. 2024-01-07 was a Sunday, which is where day 0
|
||||
// sits, so the offset lands each number on its own day.
|
||||
export function weekdayShortName(day) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(prefs.locale || undefined, { weekday: "short", timeZone: "UTC" })
|
||||
.format(new Date(Date.UTC(2024, 0, 7 + day)));
|
||||
} catch {
|
||||
return String(day);
|
||||
}
|
||||
}
|
||||
|
||||
// A set of days, listed in the order this account reads a week in — so the same
|
||||
// three days always come out in the same order wherever they are shown.
|
||||
export function sortWeekdays(days) {
|
||||
const order = weekdaysInOrder();
|
||||
return [...(days || [])].sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
||||
}
|
||||
|
||||
// Every number we render goes through here so the grouping separator follows
|
||||
// the user's chosen region rather than the browser's own locale — otherwise the
|
||||
// odometer disagrees with the dates and costs beside it.
|
||||
|
||||
@@ -7,6 +7,10 @@ export const prefs = reactive({
|
||||
locale: "en-US",
|
||||
dateFormat: "YMD", // YMD | DMY | MDY
|
||||
timeFormat: "auto", // auto (the region's own convention) | 24 | 12
|
||||
// The day a week is drawn as starting on, wherever weekdays are laid out in a
|
||||
// row — the charging scheduler's day picker today. See lib/format.js, which
|
||||
// owns the rule so every such row reads the same.
|
||||
weekStart: "auto", // auto (the region's own convention) | monday | sunday
|
||||
currency: "USD", // ISO 4217 code
|
||||
fontSize: "medium", // small | medium | large
|
||||
// Holds every arrangement still: the garage, a car's tabs, its Information
|
||||
@@ -58,6 +62,7 @@ export function applyProfilePrefs(profile) {
|
||||
prefs.locale = profile.locale || "en-US";
|
||||
prefs.dateFormat = profile.dateFormat || "YMD";
|
||||
prefs.timeFormat = profile.timeFormat || "auto";
|
||||
prefs.weekStart = profile.weekStart || "auto";
|
||||
prefs.currency = profile.currency || "USD";
|
||||
prefs.fontSize = profile.fontSize || "medium";
|
||||
prefs.dragLocked = !!profile.dragLocked;
|
||||
|
||||
@@ -4,7 +4,8 @@ import { t } from "../i18n";
|
||||
import { prefs } from "../prefs";
|
||||
import { askConfirm } from "../lib/confirm.js";
|
||||
import { api } from "../api";
|
||||
import { formatDateTime, clockIsTwelveHour } from "../lib/format.js";
|
||||
import { formatDateTime, clockIsTwelveHour, weekdayShortName, sortWeekdays }
|
||||
from "../lib/format.js";
|
||||
import TimeField from "../components/TimeField.vue";
|
||||
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.js";
|
||||
import ChargerImportModal from "../components/ChargerImportModal.vue";
|
||||
@@ -1942,7 +1943,7 @@ const tasksError = ref("");
|
||||
const tasksLoaded = ref(false);
|
||||
const showTaskForm = ref(false);
|
||||
const editingTask = ref(null); // the task being edited, or null for a new one
|
||||
const runningTask = ref(""); // the task whose "Run now" is in flight
|
||||
const runningTask = ref(""); // "<task id>:<step index>" of the step being fired
|
||||
const togglingTask = ref("");
|
||||
|
||||
async function loadChargingTasks() {
|
||||
@@ -1973,7 +1974,13 @@ function onTaskSaved(task) {
|
||||
editingTask.value = null;
|
||||
const i = tasks.value.findIndex((x) => x.id === task.id);
|
||||
if (i >= 0) tasks.value.splice(i, 1, task);
|
||||
else tasks.value = [...tasks.value, task].sort((a, b) => a.time.localeCompare(b.time));
|
||||
else {
|
||||
// Ordered by the time each task begins, which is what the server sends back
|
||||
// and what the day runs them in.
|
||||
tasks.value = [...tasks.value, task].sort(
|
||||
(a, b) => (a.steps?.[0]?.time || "").localeCompare(b.steps?.[0]?.time || "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The switch in the row. Written straight through rather than optimistically:
|
||||
@@ -1993,15 +2000,18 @@ async function toggleTask(task) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fire a task now, without waiting for its time — the only way to find out
|
||||
// whether it will actually reach the charger before the night it matters. The
|
||||
// server takes the same path the clock takes, so what comes back is what will
|
||||
// happen then, errors included.
|
||||
async function runTask(task) {
|
||||
// Fire one step now, without waiting for its time — the only way to find out
|
||||
// whether it will actually reach the charger before the night it matters. One
|
||||
// step rather than the whole flow: running a start and the stop that closes it
|
||||
// back to back would leave the charger where it began and prove nothing.
|
||||
//
|
||||
// The server takes the same path the clock takes, so what comes back is what
|
||||
// will happen then, errors included.
|
||||
async function runStep(task, index) {
|
||||
tasksError.value = "";
|
||||
runningTask.value = task.id;
|
||||
runningTask.value = `${task.id}:${index}`;
|
||||
try {
|
||||
const res = await api.runChargingTask(task.id);
|
||||
const res = await api.runChargingStep(task.id, index);
|
||||
// The row's own "last run" line is what reports this, so the answer is
|
||||
// folded into the record rather than announced somewhere else.
|
||||
const i = tasks.value.findIndex((x) => x.id === task.id);
|
||||
@@ -2030,12 +2040,11 @@ async function removeTask(task) {
|
||||
}
|
||||
}
|
||||
|
||||
// What a task says it will do, in one line: the action, the chargers, the days.
|
||||
// Built here rather than in the template because all three have an "everything"
|
||||
// case that reads as a word rather than as a list.
|
||||
function taskActionLabel(task) {
|
||||
const label = t(`charging.scheduler.actions.${task.action}`);
|
||||
return task.action === "limit" ? `${label} · ${task.amps} A` : label;
|
||||
// What one step of a flow does. The ceiling is part of the sentence for the one
|
||||
// action that has one — "Set current limit" alone does not say to what.
|
||||
function stepActionLabel(step) {
|
||||
const label = t(`charging.scheduler.actions.${step.action}`);
|
||||
return step.action === "limit" ? `${label} · ${step.amps} A` : label;
|
||||
}
|
||||
|
||||
function taskChargersLabel(task) {
|
||||
@@ -2053,28 +2062,19 @@ function taskChargersLabel(task) {
|
||||
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(" ");
|
||||
// Listed in the order this account reads a week in, so "Mon Fri" and the
|
||||
// picker that wrote it agree about which end of the week comes first.
|
||||
return sortWeekdays(days).map(weekdayShortName).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
|
||||
// A step's time, on the clock the user chose. It is stored as 24-hour "HH:MM"
|
||||
// — a schedule is a wall clock, not a moment, so there is no date to hand
|
||||
// formatTime — and this is the same reading TimeField offers when editing it.
|
||||
function taskClock(at) {
|
||||
const parts = /^(\d{1,2}):(\d{2})$/.exec(at?.time || "");
|
||||
if (!parts) return at?.time || "";
|
||||
function stepClock(step) {
|
||||
const parts = /^(\d{1,2}):(\d{2})$/.exec(step?.time || "");
|
||||
if (!parts) return step?.time || "";
|
||||
const h = Number(parts[1]);
|
||||
if (!clockIsTwelveHour()) return at.time;
|
||||
if (!clockIsTwelveHour()) return step.time;
|
||||
return `${String(h % 12 || 12).padStart(2, "0")}:${parts[2]} ${h < 12 ? "am" : "pm"}`;
|
||||
}
|
||||
|
||||
@@ -2091,9 +2091,10 @@ function tasksFor(charger) {
|
||||
// it has fired once — a task written this afternoon has nothing to report.
|
||||
function taskRunTone(task) {
|
||||
if (!task.lastRun) return "var(--text-muted)";
|
||||
// The server words the outcome as "n of m sent", plus the reasons when the
|
||||
// two numbers differ. Every charger answering is the only green case.
|
||||
const m = /^(\d+) of (\d+) sent$/.exec(task.lastResult || "");
|
||||
// The server words the outcome as the step it fired and then "n of m sent",
|
||||
// plus the reasons when the two numbers differ. Every charger answering is
|
||||
// the only green case.
|
||||
const m = /(\d+) of (\d+) sent$/.exec(task.lastResult || "");
|
||||
return m && m[1] === m[2] ? TONE.good.fg : TONE.due.fg;
|
||||
}
|
||||
|
||||
@@ -3469,21 +3470,15 @@ onUnmounted(() => {
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span class="data text-lg font-medium tracking-[-0.02em] text-strong">{{ taskClock(task) }}</span>
|
||||
<span class="truncate text-sm font-semibold text-strong">{{ task.name }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ taskActionLabel(task) }} · {{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
|
||||
</p>
|
||||
<p v-if="task.lastRun" class="mt-1 text-[11px]" :style="{ color: taskRunTone(task) }">
|
||||
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} —
|
||||
{{ task.lastResult || t("charging.scheduler.noResult") }}
|
||||
<p class="truncate text-sm font-semibold text-strong">{{ task.name }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ taskChargersLabel(task) }} · {{ taskDaysLabel(task) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- The switch. Written straight through, so what it shows is
|
||||
what the server will act on. -->
|
||||
what the server will act on. It governs the whole flow:
|
||||
the task is one intention and is switched off as one. -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-pill px-3 py-1 text-xs font-semibold transition-colors disabled:opacity-50"
|
||||
@@ -3496,15 +3491,39 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
|
||||
:disabled="runningTask === task.id"
|
||||
@click="runTask(task)"
|
||||
<!-- The flow, a line per step. Read down, they are the night —
|
||||
which is the whole reason a task holds more than one. -->
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<div
|
||||
v-for="(s, i) in task.steps"
|
||||
:key="i"
|
||||
class="flex items-baseline gap-2 rounded-control px-2 py-1 transition-colors hover:bg-card"
|
||||
>
|
||||
{{ runningTask === task.id ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
|
||||
</button>
|
||||
<span class="data w-16 shrink-0 text-sm font-medium text-strong">{{ stepClock(s) }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-xs text-body">{{ stepActionLabel(s) }}</span>
|
||||
<!-- Per step, because a flow is not a thing that can happen at
|
||||
once: firing a start and the stop that closes it back to
|
||||
back would leave the charger where it began. -->
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-[11px] font-medium text-muted transition-colors hover:text-body disabled:opacity-50"
|
||||
:disabled="runningTask === `${task.id}:${i}`"
|
||||
@click="runStep(task, i)"
|
||||
>
|
||||
{{ runningTask === `${task.id}:${i}` ? t("charging.scheduler.running") : t("charging.scheduler.runNow") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="task.lastRun" class="mt-2 text-[11px]" :style="{ color: taskRunTone(task) }">
|
||||
<!-- A middle dot rather than a dash: the outcome opens with the
|
||||
step's own time and a dash of its own, and three dashes in a
|
||||
row read as one long smudge. -->
|
||||
{{ t("charging.scheduler.lastRun", { when: formatDateTime(task.lastRun) }) }} ·
|
||||
{{ task.lastResult || t("charging.scheduler.noResult") }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-3 border-t border-subtle pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-muted transition-colors hover:text-body"
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { api } from "../api";
|
||||
import { state, isAdmin, logout, refreshProfile } from "../auth";
|
||||
import { prefs, applyProfilePrefs } from "../prefs";
|
||||
import { formatDate, formatMoney, formatTime } from "../lib/format.js";
|
||||
import { formatDate, formatMoney, formatTime, weekdaysInOrder, weekdayShortName }
|
||||
from "../lib/format.js";
|
||||
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
|
||||
import { TAB_SURFACES, SETTINGS_TABS, defaultTabFor } from "../lib/tabs.js";
|
||||
import { askConfirm } from "../lib/confirm.js";
|
||||
@@ -197,6 +198,9 @@ const timeFormatExample = computed(() => {
|
||||
return formatTime(d);
|
||||
});
|
||||
const currencyExample = computed(() => formatMoney(1234.5));
|
||||
// The week as this account will now see it drawn — the clearest possible
|
||||
// example, because the setting has no other visible effect on this page.
|
||||
const weekStartExample = computed(() => weekdaysInOrder().map(weekdayShortName).join(" "));
|
||||
|
||||
// Language and region are two controls over the one stored BCP-47 locale, so
|
||||
// the pair can be mixed freely (English in Poland, say) rather than being
|
||||
@@ -1161,6 +1165,18 @@ onBeforeUnmount(() => {
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.timeExample", { example: timeFormatExample }) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Under the clock, as the last of the three questions a region is
|
||||
asked and the one it is least often asked out loud. -->
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.appearance.weekStart") }}</label>
|
||||
<select :value="prefs.weekStart" class="dh-input" @change="saveAppearance({ weekStart: $event.target.value })">
|
||||
<option value="auto">{{ t("settings.appearance.weekAuto") }}</option>
|
||||
<option value="monday">{{ t("settings.appearance.weekMonday") }}</option>
|
||||
<option value="sunday">{{ t("settings.appearance.weekSunday") }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">{{ t("settings.appearance.weekExample", { example: weekStartExample }) }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.appearance.currency") }}</label>
|
||||
<select :value="prefs.currency" class="dh-input" @change="saveAppearance({ currency: $event.target.value })">
|
||||
|
||||
Reference in New Issue
Block a user