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:
co-authored by
Claude Opus 5
parent
8e4c22cfcf
commit
5a4515978f
@@ -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(),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`)",
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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" +
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user