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:
tajniak81andClaude Opus 5 committed 2026-09-03 22:47:30 +02:00
1 parent 8e4c22cfcf
commit 5a4515978f
15 files changed
+1856 -7

No files matched your search

+33
View File
@@ -225,6 +225,34 @@ var collectionsSchema = map[string][]fieldDef{
// audit trail's.
fAutodate("created", true, false),
},
// The home-charger scheduler: the user's own list of charging tasks, one list
// covering every charger they own. The charger's own cloud schedule holds one
// window per box; this holds as many tasks as they like, each naming its own
// chargers, days and action. Run by the ticker in internal/api/chargingtasks_run.go.
"charging_tasks": {
fText("name", true),
// The home_chargers rows this task acts on. Stored as a list of ids rather
// than a relation because empty has to mean "every charger I own" — a
// standing wish that keeps covering chargers imported later — and a
// multi-relation would have to be rewritten on every import to say it.
fJSON("chargers", 2000),
fSelect("action", []string{"start", "stop", "limit", "boost"}, true),
fNumber("amps"), // the ceiling, for the "limit" action
// 24-hour "HH:MM", read in the IANA zone the task was written in. The
// server's clock is not the one the user set the time by.
fText("time", true),
fText("zone", false),
fJSON("days", 200), // 0=Sunday … 6=Saturday; empty means every day
fBool("enabled"),
// The outcome of the last firing, so a task that has been failing quietly
// says so in the list. last_run is also the guard against firing twice in
// the same minute.
fText("last_run", false), // RFC3339, UTC
fText("last_result", false),
// Owner. Non-cascading, like a charger's.
fRelation("owner", "users", false, false),
fAutodate("created", true, false),
},
// Custom fields layered onto the built-in "users" auth collection.
"users": {
fText("bio", false),
@@ -280,6 +308,7 @@ var createOrder = []string{
"reminders",
"control_audit",
"home_chargers",
"charging_tasks",
}
// reconcileOrder additionally includes "users" so its custom fields (role,
@@ -300,6 +329,7 @@ var reconcileOrder = []string{
"reminders",
"control_audit",
"home_chargers",
"charging_tasks",
}
// indexes are extra SQL indexes applied at collection-create time.
@@ -316,6 +346,9 @@ var indexes = map[string][]string{
// A charger is looked up by its owner, and by serial when checking whether
// the account it came from has already been imported.
"home_chargers": {"CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"},
// The runner sweeps every enabled task on every tick, and the page reads one
// owner's; both go through these two columns.
"charging_tasks": {"CREATE INDEX `idx_charging_tasks_owner_enabled` ON `charging_tasks` (`owner`, `enabled`)"},
"control_audit": {
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",