From f62376dec9b8fff5c778d52ba838ed1a11497bc1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 11:26:23 +0200 Subject: [PATCH 1/6] feat(api): car routes for the box's own page The car ops were cmd frames only, so the box page, which speaks HTTP, could list cars but not keep them or answer which car is on a charger. Each op now has a route that hands the same arguments to the same runtime and answers with the same code and reason: - POST /api/cars is car.add, PATCH /api/cars/{id} car.update and DELETE /api/cars/{id} car.remove. Configure, no step-up, and only a caller with ftw.cars.write. - POST /api/loadpoints/{id}/car is session.car.set, the tap, with {session, car_id}. Actuate, Via the op, so a phone is told to send it. appproto.CarRefusal is the refusal table both use. Co-Authored-By: Claude Opus 5.5 --- go/internal/api/api.go | 8 ++ go/internal/api/api_cars.go | 171 +++++++++++++++++++++++++++ go/internal/api/api_cars_test.go | 193 +++++++++++++++++++++++++++++++ go/internal/appproto/cars.go | 38 +++--- 4 files changed, 395 insertions(+), 15 deletions(-) create mode 100644 go/internal/api/api_cars.go create mode 100644 go/internal/api/api_cars_test.go diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 7baaac9c2..06f640de5 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -528,6 +528,14 @@ func (s *Server) routes() { s.handle("GET /api/ev/providers", Read, s.handleEVProviders) s.handle("GET /api/loadpoints", Read, s.handleLoadpoints) s.handle("GET /api/cars", Read, s.handleCars) + // The box page keeps the owner's cars as the owner's app does, with the + // same ops (docs/cars.md). They change no hardware, so no step-up. + s.handle("POST /api/cars", Configure, s.handleCarAdd, NoStepUp) + s.handle("PATCH /api/cars/{id}", Configure, s.handleCarUpdate, NoStepUp) + s.handle("DELETE /api/cars/{id}", Configure, s.handleCarRemove, NoStepUp) + // The one tap. It changes what the charger plans for, so it is + // actuation; a phone sends the op. + s.handle("POST /api/loadpoints/{id}/car", Actuate, s.handleSessionCarSet, Via(protocol.OpSessionCarSet)) // Via names the one field of this body the session can set. The target // level and its deadline still have no command; the passthrough refuses // the whole route either way. diff --git a/go/internal/api/api_cars.go b/go/internal/api/api_cars.go new file mode 100644 index 000000000..50dc45197 --- /dev/null +++ b/go/internal/api/api_cars.go @@ -0,0 +1,171 @@ +package api + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/cars" + "github.com/srcfl/ftw/go/protocol" +) + +// The car ops for the box's own page (docs/cars.md, Ops). A phone sends them +// as cmd frames; the page speaks HTTP, so each op has a route that hands the +// same arguments to the same runtime and answers with the same refusals. + +// CarEdits is the part of the cars runtime that changes cars. It is apart +// from CarViews so a fake that only shows cars need not grow these. +type CarEdits interface { + Add(protocol.Car) (protocol.Car, error) + UpdateCar(id string, u cars.Update) (protocol.Car, error) + Remove(id string) error + SetSessionCar(lpID, sessionID, carID string) (protocol.ChargerCar, error) +} + +// carEdits returns the runtime's edits, or answers 503 as the ops answer +// E_UNAVAILABLE {subsystem: cars} on a box without cars. +func (s *Server) carEdits(w http.ResponseWriter) (CarEdits, bool) { + edits, ok := s.deps.Cars.(CarEdits) + if !ok || edits == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "this box keeps no cars", "code": appproto.ErrUnavailable, "reason": "cars"}) + return nil, false + } + return edits, true +} + +// mayKeepCars refuses a caller without ftw.cars.write, which only the owner +// role carries. The LAN's own page has every scope. +func mayKeepCars(w http.ResponseWriter, r *http.Request) bool { + if caller, ok := apiauth.FromRequest(r); ok && caller.Scopes.Has(protocol.ScopeCarsWrite) { + return true + } + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only an owner keeps the list of cars"}) + return false +} + +// writeCarError answers a car op's error with the op's code and reason, so +// the page can say what went wrong in words. +func writeCarError(w http.ResponseWriter, op string, err error) { + code, reason, ok := appproto.CarRefusal(err) + if !ok { + // A save the box could not confirm: it may hold the change or not. + slog.Warn("car op not saved", "op", op, "err", err) + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "the box could not confirm the change was saved", "code": appproto.ErrUnavailable, "reason": "persistence_unconfirmed"}) + return + } + status := http.StatusConflict + switch { + case code == appproto.ErrNotFound: + status = http.StatusNotFound + case errors.Is(err, cars.ErrInvalid): + status = http.StatusBadRequest + } + writeJSON(w, status, map[string]string{"error": err.Error(), "code": code, "reason": reason}) +} + +// POST /api/cars is car.add. The body is the car in the names of GET +// /api/cars, with the id the page minted. +func (s *Server) handleCarAdd(w http.ResponseWriter, r *http.Request) { + if !mayKeepCars(w, r) { + return + } + edits, ok := s.carEdits(w) + if !ok { + return + } + var car protocol.Car + if err := readJSON(r, &car); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + added, err := edits.Add(car) + if err != nil { + writeCarError(w, protocol.OpCarAdd, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"car": added}) +} + +// PATCH /api/cars/{id} is car.update: the fields present change, the rest +// stay as they were. +func (s *Server) handleCarUpdate(w http.ResponseWriter, r *http.Request) { + if !mayKeepCars(w, r) { + return + } + edits, ok := s.carEdits(w) + if !ok { + return + } + var req struct { + NewID *string `json:"new_id"` + Name *string `json:"name"` + Make *string `json:"make"` + VIN *string `json:"vin"` + CapacityWh *float64 `json:"capacity_wh"` + MaxW *float64 `json:"max_w"` + Phases *int `json:"phases"` + Identifiers *[]string `json:"identifiers"` + Sources *[]string `json:"sources"` + } + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + car, err := edits.UpdateCar(r.PathValue("id"), cars.Update{ + NewID: req.NewID, Name: req.Name, Make: req.Make, VIN: req.VIN, CapacityWh: req.CapacityWh, + MaxW: req.MaxW, Phases: req.Phases, Identifiers: req.Identifiers, Sources: req.Sources, + }) + if err != nil { + writeCarError(w, protocol.OpCarUpdate, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"car": car}) +} + +// DELETE /api/cars/{id} is car.remove. The box keeps the id as removed. +func (s *Server) handleCarRemove(w http.ResponseWriter, r *http.Request) { + if !mayKeepCars(w, r) { + return + } + edits, ok := s.carEdits(w) + if !ok { + return + } + if err := edits.Remove(r.PathValue("id")); err != nil { + writeCarError(w, protocol.OpCarRemove, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// POST /api/loadpoints/{id}/car is session.car.set, the one tap. The body +// names the plug-in it answers, {session, car_id}; an empty car_id makes the +// session a visitor's. +func (s *Server) handleSessionCarSet(w http.ResponseWriter, r *http.Request) { + edits, ok := s.carEdits(w) + if !ok { + return + } + var req struct { + Session string `json:"session"` + CarID *string `json:"car_id"` + } + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + // The same arguments the op requires: a tap names its plug-in, and says + // which car or none. + if req.Session == "" || req.CarID == nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session and car_id required"}) + return + } + held, err := edits.SetSessionCar(r.PathValue("id"), req.Session, *req.CarID) + if err != nil { + writeCarError(w, protocol.OpSessionCarSet, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"charger": held}) +} diff --git a/go/internal/api/api_cars_test.go b/go/internal/api/api_cars_test.go new file mode 100644 index 000000000..9aa2110a4 --- /dev/null +++ b/go/internal/api/api_cars_test.go @@ -0,0 +1,193 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/cars" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/protocol" +) + +// The runtime is what main.go hands the routes. +var _ CarEdits = (*cars.Runtime)(nil) + +type carRows struct { + mu sync.Mutex + rows map[string]string +} + +func (k *carRows) LoadConfig(key string) (string, bool) { + k.mu.Lock() + defer k.mu.Unlock() + v, ok := k.rows[key] + return v, ok +} + +func (k *carRows) SaveConfig(key, value string) error { + k.mu.Lock() + defer k.mu.Unlock() + k.rows[key] = value + return nil +} + +// carsServer is a box with one charger and the cars runtime, as main.go +// wires them; on first start the box makes Car 1 from the charger. +func carsServer(t *testing.T) (*Server, *loadpoint.Manager, *cars.Runtime) { + t.Helper() + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", VehicleCapacityWh: 60000, MaxChargeW: 11000}}) + rt := cars.New(cars.Deps{Store: &carRows{rows: map[string]string{}}, Loadpoints: mgr}) + if err := rt.Start(nil); err != nil { + t.Fatal(err) + } + return New(&Deps{Cars: rt, Loadpoints: mgr}), mgr, rt +} + +func carRequest(srv *Server, method, path, body string) (int, map[string]any) { + rr := httptest.NewRecorder() + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } + srv.Handler().ServeHTTP(rr, req) + var out map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &out) + return rr.Code, out +} + +const ( + carA = "0123456789abcdef0123456789abcdef" + carB = "fedcba9876543210fedcba9876543210" +) + +// The box page keeps the owner's list as the owner's app does: add, rename, +// resize and remove, with the ops' refusals. +func TestTheBoxPageKeepsTheCars(t *testing.T) { + srv, _, rt := carsServer(t) + + if code, body := carRequest(srv, http.MethodPost, "/api/cars", + `{"id":"`+carA+`","name":"Volvo","capacity_wh":69000,"vin":"YV1AB0CD9EF123456"}`); code != http.StatusOK { + t.Fatalf("add: %d %v", code, body) + } + if code, body := carRequest(srv, http.MethodPost, "/api/cars", + `{"id":"`+carB+`","name":"Kia","capacity_wh":64000,"vin":"YV1AB0CD9EF123456"}`); code != http.StatusConflict || + body["code"] != protocol.ErrDuplicate || body["reason"] != "duplicate" { + t.Fatalf("a VIN another car has: %d %v", code, body) + } + if code, body := carRequest(srv, http.MethodPost, "/api/cars", `{"id":"short","name":"Kia"}`); code != http.StatusBadRequest || + body["reason"] != "invalid" { + t.Fatalf("a bad id: %d %v", code, body) + } + + if code, body := carRequest(srv, http.MethodPatch, "/api/cars/"+carA, `{"name":"Family Volvo","capacity_wh":70000}`); code != http.StatusOK { + t.Fatalf("update: %d %v", code, body) + } + got, err := rt.Car(carA) + if err != nil || got.Name != "Family Volvo" || got.CapacityWh != 70000 || got.VIN != "YV1AB0CD9EF123456" { + t.Fatalf("after the edit: %+v %v", got, err) + } + if code, body := carRequest(srv, http.MethodPatch, "/api/cars/"+carA, `{"capacity_wh":10}`); code != http.StatusBadRequest { + t.Fatalf("a capacity the box refuses: %d %v", code, body) + } + if code, body := carRequest(srv, http.MethodPatch, "/api/cars/"+carB, `{"name":"Kia"}`); code != http.StatusNotFound || + body["reason"] != "car" { + t.Fatalf("an unknown car: %d %v", code, body) + } + + if code, body := carRequest(srv, http.MethodDelete, "/api/cars/"+carA, ""); code != http.StatusOK { + t.Fatalf("remove: %d %v", code, body) + } + if code, _ := carRequest(srv, http.MethodDelete, "/api/cars/"+carA, ""); code != http.StatusNotFound { + t.Fatalf("removed twice: %d", code) + } + _, list := carRequest(srv, http.MethodGet, "/api/cars", "") + removed, _ := list["removed"].([]any) + if cs, _ := list["cars"].([]any); len(cs) != 1 || len(removed) != 1 { + t.Fatalf("after the remove: %v", list) + } +} + +// A viewer may see the cars but not keep them. +func TestOnlyAnOwnerKeepsTheCars(t *testing.T) { + srv, _, _ := carsServer(t) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/cars", strings.NewReader(`{"id":"`+carA+`","name":"Volvo"}`)) + req.Header.Set("Content-Type", "application/json") + req = req.WithContext(apiauth.WithCaller(req.Context(), apiauth.Caller{Role: apiauth.RoleViewer, + Scopes: apiauth.NewScopeSet(apiauth.RoleScopes[apiauth.RoleViewer]...)})) + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("a viewer added a car: %d %s", rr.Code, rr.Body.String()) + } +} + +// The one tap from the box page names the plug-in it answers, and the box +// refuses a tap for one that has ended. +func TestTheBoxPageTapsWhichCar(t *testing.T) { + srv, mgr, rt := carsServer(t) + if code, body := carRequest(srv, http.MethodPost, "/api/cars", `{"id":"`+carA+`","name":"Volvo","capacity_wh":69000}`); code != http.StatusOK { + t.Fatalf("add: %d %v", code, body) + } + mgr.ObserveSession("garage", true, 0, 0, true, "easee:EH1", "s1") + rt.Tick() + session := rt.ChargerCar("garage").Session + if session == "" { + t.Fatal("no session after the plug-in") + } + + if code, body := carRequest(srv, http.MethodPost, "/api/loadpoints/garage/car", `{"session":"`+session+`"}`); code != http.StatusBadRequest { + t.Fatalf("a tap that names no car: %d %v", code, body) + } + if code, body := carRequest(srv, http.MethodPost, "/api/loadpoints/garage/car", + `{"session":"old","car_id":"`+carA+`"}`); code != http.StatusConflict || body["reason"] != "session" { + t.Fatalf("a tap for an ended plug-in: %d %v", code, body) + } + code, body := carRequest(srv, http.MethodPost, "/api/loadpoints/garage/car", `{"session":"`+session+`","car_id":"`+carA+`"}`) + charger, _ := body["charger"].(map[string]any) + if code != http.StatusOK || charger["car_id"] != carA || charger["car_identified_by"] != protocol.CarIdentifiedByTap { + t.Fatalf("the tap: %d %v", code, body) + } + if st, _ := mgr.State("garage"); st.CarID != carA || st.VehicleCapacityWh != 69000 { + t.Fatalf("the charger does not plan for the Volvo: %+v", st) + } + // None of these: a visitor's. + code, body = carRequest(srv, http.MethodPost, "/api/loadpoints/garage/car", `{"session":"`+session+`","car_id":""}`) + charger, _ = body["charger"].(map[string]any) + if code != http.StatusOK || charger["car_id"] != nil || charger["car_identified_by"] != protocol.CarIdentifiedByNone { + t.Fatalf("none of these: %d %v", code, body) + } + if code, body := carRequest(srv, http.MethodPost, "/api/loadpoints/nowhere/car", `{"session":"s","car_id":""}`); code != http.StatusNotFound { + t.Fatalf("an unknown charger: %d %v", code, body) + } +} + +// The car routes are priced like the ops: keeping the list is the owner's +// configuration with no step-up, and the tap is actuation a phone sends as +// session.car.set. +func TestTheCarRoutesArePricedLikeTheOps(t *testing.T) { + srv, _, _ := carsServer(t) + for _, tc := range []struct { + method, path string + tier apiauth.Tier + op string + }{ + {http.MethodPost, "/api/cars", apiauth.TierConfigure, ""}, + {http.MethodPatch, "/api/cars/" + carA, apiauth.TierConfigure, ""}, + {http.MethodDelete, "/api/cars/" + carA, apiauth.TierConfigure, ""}, + {http.MethodPost, "/api/loadpoints/garage/car", apiauth.TierActuate, protocol.OpSessionCarSet}, + } { + facts := srv.Route(httptest.NewRequest(tc.method, tc.path, nil)) + if facts.Tier != tc.tier || facts.CmdOp != tc.op || (tc.tier == apiauth.TierConfigure && !facts.NoStepUp) { + t.Errorf("%s %s: %+v", tc.method, tc.path, facts) + } + } +} diff --git a/go/internal/appproto/cars.go b/go/internal/appproto/cars.go index 653dc3d5e..6296db743 100644 --- a/go/internal/appproto/cars.go +++ b/go/internal/appproto/cars.go @@ -167,31 +167,39 @@ func chargerResult(cmd Cmd, held protocol.ChargerCar, err error) (CmdResult, err // carRefusal names why, in machine form, after the ack, as the loadpoint ops // do (docs/cars.md, Ops). ok is false for an error that is not a refusal. func carRefusal(cmd Cmd, err error) (CmdResult, bool) { - var code, reason string + code, reason, ok := CarRefusal(err) + if !ok { + return CmdResult{}, false + } + return CmdResult{ + CmdID: cmd.CmdID, State: CmdRejected, + Error: &ErrorBody{Code: code, Retryable: ErrorRetryable[code], Args: map[string]any{"op": cmd.Op, "reason": reason}}, + }, true +} + +// CarRefusal is the code and reason a car op's error is refused with. The +// box page's car routes answer with the same pair. ok is false for an error +// that is not a refusal, such as a save that failed. +func CarRefusal(err error) (code, reason string, ok bool) { switch { case errors.Is(err, cars.ErrUnknownCar): - code, reason = ErrNotFound, "car" + return ErrNotFound, "car", true case errors.Is(err, cars.ErrUnknownCharger): - code, reason = ErrNotFound, "charger" + return ErrNotFound, "charger", true case errors.Is(err, cars.ErrSessionEnded): - code, reason = ErrPrecondition, "session" + return ErrPrecondition, "session", true case errors.Is(err, cars.ErrUnplugged): - code, reason = ErrUnavailable, "unplugged" + return ErrUnavailable, "unplugged", true case errors.Is(err, cars.ErrDuplicate): - code, reason = ErrDuplicate, "duplicate" + return ErrDuplicate, "duplicate", true case errors.Is(err, cars.ErrElsewhere): - code, reason = ErrPrecondition, "elsewhere" + return ErrPrecondition, "elsewhere", true case errors.Is(err, cars.ErrTooMany): - code, reason = protocol.ErrCapExceeded, "cars" + return protocol.ErrCapExceeded, "cars", true case errors.Is(err, cars.ErrInvalid): - code, reason = ErrPrecondition, "invalid" - default: - return CmdResult{}, false + return ErrPrecondition, "invalid", true } - return CmdResult{ - CmdID: cmd.CmdID, State: CmdRejected, - Error: &ErrorBody{Code: code, Retryable: ErrorRetryable[code], Args: map[string]any{"op": cmd.Op, "reason": reason}}, - }, true + return "", "", false } // carIntentArgs checks car.goal.set's and car.soc.set's arguments as carOp From ccdc7403e8d9c2f7d8b41592a6b0adb21a691296 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 11:26:23 +0200 Subject: [PATCH 2/6] feat(web): the box page shows the owner's cars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview gets a card per car, plugged in or not: its level with where it came from and how old it is, an estimate marked with ≈, its charge limit, its goal and the charger it is on, with how the box knows. The level ages between reads, and a list that stopped updating says since when. While a charger asks which car it is, its notice offers one button per car and None of these, and the cars it may be say "On garage?" rather than "Not plugged in". A car the box named itself can be corrected behind "Not this car?". The buttons are rebuilt only when what they show changes, so a poll never takes one from under a tap. Settings gets a Cars tab: each car with its VIN, level sources and charger tags; rename, battery size, add and remove through the car routes. Adding a VIN a car already has is stopped on the page: the box would take that as the app adopting the car it made, and quietly rename it. The EV modal stays as it was; a car on a charger opens it with "Check charging". Co-Authored-By: Claude Opus 5.5 --- .changeset/box-page-cars.md | 7 + web/app.css | 125 ++++++++++ web/app.js | 50 +++- web/cars.js | 251 ++++++++++++++++++++ web/cars.test.mjs | 280 ++++++++++++++++++++++ web/dashboard-poll-visibility.test.mjs | 28 +-- web/index.html | 5 + web/settings.js | 5 +- web/settings/tabs/cars.js | 311 +++++++++++++++++++++++++ web/settings/tabs/cars.test.mjs | 114 +++++++++ web/style.css | 67 ++++++ 11 files changed, 1225 insertions(+), 18 deletions(-) create mode 100644 .changeset/box-page-cars.md create mode 100644 web/cars.js create mode 100644 web/cars.test.mjs create mode 100644 web/settings/tabs/cars.js create mode 100644 web/settings/tabs/cars.test.mjs diff --git a/.changeset/box-page-cars.md b/.changeset/box-page-cars.md new file mode 100644 index 000000000..fd347c071 --- /dev/null +++ b/.changeset/box-page-cars.md @@ -0,0 +1,7 @@ +--- +"ftw": minor +--- + +The box's own page shows the owner's cars. The overview has a card for each car, plugged in or not, with its level, where the level came from and how old it is ("72% · From the car · 1 h ago", or "≈" once it is an estimate), its charge limit, its goal and the charger it is on. When a charger is in use and the box cannot tell which car it is, the charger's notice asks "Which car is on garage?" with one button per car and "None of these"; a car the box named itself can be corrected the same way. Settings has a Cars tab that lists each car with its VIN, its level sources and the tags a charger knows it by, and lets the owner rename a car, set its battery size, add one and remove one. + +New routes carry the car ops for the page: `POST /api/cars`, `PATCH` and `DELETE /api/cars/{id}` (owner only), and `POST /api/loadpoints/{id}/car`, the tap (`session.car.set`). diff --git a/web/app.css b/web/app.css index c7eddb646..b13151100 100644 --- a/web/app.css +++ b/web/app.css @@ -295,6 +295,130 @@ body.ftw-app #charging-notices .overview-card button { text-decoration: underline; cursor: pointer; } +/* A charger whose car the box cannot name asks which car it is: one + button per car, on its own line under the notice. */ +body.ftw-app #charging-notices .car-which { + flex: 1 1 100%; +} +body.ftw-app #charging-notices .car-which[hidden], +body.ftw-app .car-which-row[hidden] { + display: none; +} +body.ftw-app .car-which-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 4px; +} +body.ftw-app #charging-notices .overview-card .car-which-pick { + min-height: 40px; + padding: 0.4rem 0.9rem; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--ink); + color: var(--fg); + text-decoration: none; +} +body.ftw-app #charging-notices .overview-card .car-which-pick:hover:not(:disabled) { + border-color: var(--accent-e); +} +body.ftw-app #charging-notices .overview-card .car-which-pick[aria-pressed="true"] { + border-color: var(--accent-e); + color: var(--accent-e); +} +body.ftw-app #charging-notices .overview-card .car-which-pick:disabled { + cursor: default; +} +body.ftw-app .car-which-note { + margin: 6px 0 0; + font-size: 0.85rem; + color: var(--fg-dim); +} +body.ftw-app .car-which-note:empty { + display: none; +} + +/* The owner's cars: one tile per car, plugged in or not. */ +body.ftw-app #overview-cars { + grid-column: 1 / -1; +} +body.ftw-app #overview-cars[hidden] { + display: none !important; +} +body.ftw-app .overview-cars-age { + color: var(--amber); + font-family: var(--mono); + font-size: 11px; +} +body.ftw-app .car-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); + gap: 12px; +} +body.ftw-app .car-tile { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--ink); +} +body.ftw-app .car-tile-top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} +body.ftw-app .car-tile-name { + min-width: 0; + overflow: hidden; + color: var(--fg); + text-overflow: ellipsis; + white-space: nowrap; +} +body.ftw-app .car-tile-where { + flex: none; + color: var(--fg-muted); + font-family: var(--mono); + font-size: 11px; +} +body.ftw-app .car-tile-where.is-plugged { + color: var(--accent-e); +} +body.ftw-app .car-tile-level { + color: var(--fg); + font-family: var(--mono); + font-size: 26px; + font-weight: 700; + letter-spacing: -0.03em; +} +body.ftw-app .car-tile-level.is-estimate { + color: var(--fg-dim); +} +body.ftw-app .car-tile-source, +body.ftw-app .car-tile-facts, +body.ftw-app .car-tile-foot { + color: var(--fg-dim); + font-size: 12px; +} +body.ftw-app .car-tile-foot { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-top: 4px; +} +body.ftw-app .car-tile-open { + padding: 0.3rem 0; + border: 0; + background: none; + color: var(--accent-e); + font: inherit; + text-decoration: underline; + cursor: pointer; +} body.ftw-app .overview-health { display: inline-flex; align-items: center; @@ -2529,6 +2653,7 @@ body.ftw-app .diagnose-detail { } body.ftw-app .power-now, body.ftw-app #charging-notices, + body.ftw-app #overview-cars, body.ftw-app .overview-outlook, body.ftw-app .overview-today, body.ftw-app .fuse-row { diff --git a/web/app.js b/web/app.js index 589250457..c572ae68c 100644 --- a/web/app.js +++ b/web/app.js @@ -8,6 +8,7 @@ const POLL_INTERVAL = 2000; // status poll cadence — snappier cards const historyMigrationUI = import("/history-migration.js").catch(function () { return null; }); const energyTodayUI = import("/energy-today.js").catch(function () { return null; }); + const carsUI = import("/cars.js").catch(function () { return null; }); function updateHistoryMigration(health) { historyMigrationUI.then(function (ui) { if (ui) ui.updateMigrationBanner(health); }).catch(function () {}); if (updateBadge && typeof updateBadge.setBootHealth === "function") updateBadge.setBootHealth(health); @@ -2162,6 +2163,32 @@ // entries mean "no loadpoint for this driver" and the planet // falls back to legacy kW-only rendering. var loadpointsByDriver = null; + // The owner's cars (GET /api/cars), apart from the chargers: a car shows + // while unplugged too. carsFetchedAt dates the list, so a level's age stays + // true between reads and an old list says how old it is. + var lastCars = []; + var carsFetchedAt = 0; + var carsCard = null; + function openChargerOf(lp) { + if (energyFlowEl) energyFlowEl.dispatchEvent(new CustomEvent("ftw-planet-click", { detail: { role: "ev", name: lp.driver_name } })); + } + function tapCar(ui, lp, carId) { + return ui.tap(apiFetch, lp, carId).then(function (res) { + if (res.ok) fetchStatus(); + return res; + }); + } + function updateCars() { + carsUI.then(function (ui) { + var host = document.getElementById("overview-cars"); + if (!ui || !host) return; + if (!carsCard) carsCard = ui.createCarsCard(document, host, { onOpenCharger: openChargerOf }); + carsCard.update({ + cars: lastCars, loadpoints: chargingNoticePoints, fetchedAt: carsFetchedAt, now: Date.now(), + fresh: !document.hidden && Date.now() - carsFetchedAt < 15000, + }); + }).catch(function (err) { console.error("cars:", err); }); + } var chargingNoticePoints = []; var chargingNoticeRows = new Map(); var chargingNoticeTimer = null; @@ -2201,11 +2228,22 @@ button.type = "button"; section.append(title, status, button); host.appendChild(section); - row = { section: section, title: title, status: status, button: button }; + row = { section: section, title: title, status: status, button: button, which: null }; chargingNoticeRows.set(lp.id, row); } var available = fresh && (!lp.charger || lp.charger.available); row.title.textContent = available ? "Car connected" : "Car status is out of date"; + // The car on this charger by name, and the one tap while the box + // cannot say which car it is. + carsUI.then(function (ui) { + if (!ui || !row.section.isConnected) return; + if (!row.which) { + row.which = ui.createWhichCar(document, { onTap: function (point, carId) { return tapCar(ui, point, carId); } }); + row.section.appendChild(row.which.el); + } + if (available) row.title.textContent = ui.noticeTitle(lp, lastCars); + row.which.update(lp, lastCars, chargingNoticePoints, available); + }).catch(function (err) { console.error("cars:", err); }); var message = available ? renderEvPlanStatus(lp, null) : null; row.status.textContent = message ? message.textContent : "Waiting for current charger status. The last reading cannot confirm charging."; row.button.textContent = "Check charging" + (lp.soc_source !== "vehicle" ? " and battery level" : ""); @@ -2217,6 +2255,7 @@ if (!keep.has(id)) { row.section.remove(); chargingNoticeRows.delete(id); } }); host.hidden = keep.size === 0; + updateCars(); } document.addEventListener("visibilitychange", function () { if (document.hidden) updateChargingNotice(null); @@ -2241,6 +2280,8 @@ }), boundedApiRead("/api/loadpoints", function (r) { return r.ok ? r.json() : null; }) .catch(function () { return null; }), + boundedApiRead("/api/cars", function (r) { return r.ok ? r.json() : null; }) + .catch(function () { return null; }), boundedApiRead("/api/health", function (r) { return r.ok ? r.json() : null; }) .catch(function () { return null; }) .then(function (health) { @@ -2251,7 +2292,8 @@ .then(function (results) { var data = results[0]; var lp = results[1]; - var health = results[2]; + var carList = results[2]; + var health = results[3]; lastStatusPayload = data; // Surface DB corruption-recovery events (boot-time, immutable per // process) as a top banner. Tolerant: health may be null. @@ -2264,6 +2306,10 @@ }); loadpointsByDriver = idx; } + if (carList && Array.isArray(carList.cars)) { + lastCars = carList.cars; + carsFetchedAt = Date.now(); + } updateChargingNotice(lp); setConnected(true); if (firstLoad) { firstLoad = false; } diff --git a/web/cars.js b/web/cars.js new file mode 100644 index 000000000..0741fdfae --- /dev/null +++ b/web/cars.js @@ -0,0 +1,251 @@ +// The owner's cars on the dashboard (docs/cars.md). A car is not the charger: +// it shows while unplugged, with its level, where the level came from and how +// old it is. A charger in use whose car the box cannot name asks which car it +// is, and a tap answers with session.car.set's route. +// +// Names that come from outside, a car's name or a charger's id, only ever go +// into the page as text. + +const SOURCE_WORDS = { vehicle: 'From the car', manual: 'Set by hand', assumed: 'Assumed at plug-in' }; + +// How the box knows which car is on a charger (car_identified_by). +const KNOWN_BY = { charger: 'the charger named it', car: 'the car said so', only: 'the only car', tap: 'you chose it' }; + +const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +// A tap the box refused, in words, by the op's reason. +const TAP_REFUSED = { + session: 'That plug-in has ended.', + elsewhere: 'That car is on another charger.', + unplugged: 'Nothing is plugged in now.', + car: 'The box no longer has that car.', + charger: 'The box no longer has that charger.', +}; + +const pct = permille => `${Math.round(permille / 10)}%`; + +export function ageText(s) { + if (!Number.isFinite(s) || s < 0) return ''; + if (s < 60) return 'just now'; + const min = Math.floor(s / 60); + if (min < 60) return `${min} min ago`; + const h = Math.floor(min / 60); + if (h < 24) return min % 60 && h < 10 ? `${h} h ${min % 60} min ago` : `${h} h ago`; + const d = Math.floor(h / 24); + return d === 1 ? '1 day ago' : `${d} days ago`; +} + +function kWh(wh) { + const v = Math.abs(wh) / 1000; + return `${wh < 0 ? '− ' : '+ '}${v >= 10 ? v.toFixed(0) : v.toFixed(1)} kWh`; +} + +// levelView is the car's level in words. sinceS is the time since the box +// counted soc_age_s, so the age stays true between reads. +export function levelView(car = {}, sinceS = 0) { + const known = Number.isFinite(car.soc_permille); + if (!known) return { value: '—', source: 'No level yet', known: false, estimate: false }; + const estimate = car.soc_estimate === true; + const age = Number.isFinite(car.soc_age_s) ? ageText(car.soc_age_s + Math.max(0, sinceS)) : ''; + let source = SOURCE_WORDS[car.soc_source] || 'Estimated'; + if (car.soc_source !== 'assumed' && age) source += ` · ${age}`; + if (estimate && car.soc_source !== 'assumed' && Number.isFinite(car.energy_since_wh) && car.energy_since_wh !== 0) { + source += ` ${kWh(car.energy_since_wh)}`; + } + return { value: (estimate ? '≈ ' : '') + pct(car.soc_permille), source, known: true, estimate }; +} + +export function daysText(mask) { + if (!mask) return 'once'; + if (mask === 0x7f) return 'every day'; + if (mask === 0x1f) return 'weekdays'; + if (mask === 0x60) return 'weekends'; + return DAY_NAMES.filter((_, i) => mask & (1 << i)).join(', '); +} + +function clock(min) { + return `${String(Math.floor(min / 60)).padStart(2, '0')}:${String(min % 60).padStart(2, '0')}`; +} + +export function goalText(goal) { + if (!goal) return 'No goal'; + const what = goal.finish_at_limit ? 'Car’s limit' : pct(goal.target_permille || 0); + return `${what} by ${clock(goal.ready_by_min || 0)}, ${daysText(goal.days)}`; +} + +// The charger a car is on now, if any. +export function chargerOf(car, loadpoints = []) { + return loadpoints.find(lp => lp && lp.plugged_in && lp.car_id && lp.car_id === car.id) || null; +} + +// How the box knows the car on a charger, and a correction it made, in words. +export function knownByText(lp, names = {}) { + const parts = []; + if (KNOWN_BY[lp.car_identified_by]) parts.push(KNOWN_BY[lp.car_identified_by]); + if (lp.car_corrected && lp.car_corrected_from) parts.push(`not ${names[lp.car_corrected_from] || 'the car first named'}`); + const text = parts.join(' · '); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +// The cars a tap may pick on a charger: the box's own list while it asks, +// otherwise every car not on another charger. +export function choices(lp, cars = [], loadpoints = []) { + if (!lp || !lp.plugged_in || !lp.session) return []; + const byId = new Map(cars.map(c => [c.id, c])); + if (lp.car_identified_by === 'asking' && Array.isArray(lp.choose) && lp.choose.length) { + return lp.choose.map(id => byId.get(id)).filter(Boolean); + } + const elsewhere = new Set(loadpoints.filter(o => o && o.id !== lp.id && o.plugged_in && o.car_id).map(o => o.car_id)); + return cars.filter(c => !elsewhere.has(c.id)); +} + +export function asking(lp) { + return !!(lp && lp.plugged_in && lp.car_identified_by === 'asking'); +} + +// The notice's title for a charger in use. +export function noticeTitle(lp, cars = []) { + if (asking(lp)) return `Which car is on ${lp.id}?`; + const car = lp && lp.car_id && cars.find(c => c.id === lp.car_id); + return car ? `${car.name} connected` : 'Car connected'; +} + +export function tapRefusedText(body) { + return (body && TAP_REFUSED[body.reason]) || 'The box did not take that. Try again.'; +} + +// tap sends session.car.set for the plug-in the page shows. An empty carId +// is "None of these", a visitor's car. +export async function tap(fetchFn, lp, carId) { + const r = await fetchFn(`/api/loadpoints/${encodeURIComponent(lp.id)}/car`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session: lp.session, car_id: carId }), + }); + if (r.ok) return { ok: true }; + const body = await r.json().catch(() => null); + return { ok: false, text: tapRefusedText(body) }; +} + +function node(doc, tag, className, parent, text) { + const el = doc.createElement(tag); + if (className) el.className = className; + if (text != null) el.textContent = text; + if (parent) parent.appendChild(el); + return el; +} + +// createWhichCar is the choice under a charger's notice: one button per car +// and "None of these". While the box asks it shows at once; otherwise a +// "Not this car?" button opens it, since a tap may correct the box. +export function createWhichCar(doc, { onTap }) { + const el = node(doc, 'div', 'car-which'); + const open = node(doc, 'button', 'car-which-open', el); + open.type = 'button'; + const row = node(doc, 'div', 'car-which-row', el); + row.setAttribute('role', 'group'); + const note = node(doc, 'p', 'car-which-note', el); + note.setAttribute('aria-live', 'polite'); + let expanded = false, busy = false, last = null, shown = ''; + open.addEventListener('click', () => { expanded = !expanded; if (last) update(...last); }); + function update(lp, cars, loadpoints, fresh) { + last = [lp, cars, loadpoints, fresh]; + const list = fresh ? choices(lp, cars, loadpoints) : []; + const isAsking = asking(lp); + el.hidden = !list.length; + open.hidden = isAsking; + open.textContent = expanded ? 'Cancel' : (lp.car_id ? 'Not this car?' : 'Which car is this?'); + open.setAttribute('aria-expanded', String(expanded)); + row.hidden = !(isAsking || expanded); + row.setAttribute('aria-label', `Which car is on ${lp.id}`); + const picks = list.map(c => [c.name, c.id, !isAsking && lp.car_id === c.id]); + picks.push(['None of these', '', !isAsking && !lp.car_id && lp.car_identified_by === 'none']); + // Rebuilt only when what it shows changes, so a poll never takes a + // button from under a finger or the keyboard. + const key = JSON.stringify([lp.id, lp.session, busy, picks]); + if (key === shown) return; + shown = key; + row.replaceChildren(); + picks.forEach(([label, id, current]) => { + const b = node(doc, 'button', 'car-which-pick', row, label); + b.type = 'button'; + b.disabled = busy || current; + if (current) b.setAttribute('aria-pressed', 'true'); + b.addEventListener('click', async () => { + busy = true; note.textContent = 'Saving…'; update(...last); + const res = await onTap(lp, id).catch(() => ({ ok: false, text: 'The box did not answer. Try again.' })); + busy = false; expanded = false; + note.textContent = res.ok ? '' : res.text; + update(...last); + }); + }); + } + return { el, update }; +} + +// carTiles is what the card shows of each car, in words. sinceS is the time +// since the list was read. +export function carTiles(cars = [], loadpoints = [], sinceS = 0) { + const names = Object.fromEntries(cars.map(c => [c.id, c.name])); + return cars.map(car => { + const lp = chargerOf(car, loadpoints); + const level = levelView(car, sinceS); + const facts = []; + if (Number.isFinite(car.charge_limit_permille)) facts.push(`Limit ${pct(car.charge_limit_permille)}`); + facts.push(car.goal ? `Goal ${goalText(car.goal)}` : 'No goal'); + if (car.surplus_only) facts.push('Solar only'); + // While a charger asks, a car it may be is not known to be unplugged. + const maybe = lp ? [] : loadpoints.filter(o => asking(o) && Array.isArray(o.choose) && o.choose.includes(car.id)); + const where = lp ? `On ${lp.id}` : maybe.length ? `On ${maybe.map(o => o.id).join(' or ')}?` : 'Not plugged in'; + return { + id: car.id, name: car.name, where, + level: level.value, estimate: level.estimate, source: level.source, facts: facts.join(' · '), + charger: lp ? { id: lp.id, driver_name: lp.driver_name } : null, knownBy: lp ? knownByText(lp, names) : '', + }; + }); +} + +// createCarsCard fills the dashboard's card of cars, el, which the page +// holds in its place in the overview. +export function createCarsCard(doc, el, { onOpenCharger } = {}) { + el.replaceChildren(); + const head = node(doc, 'div', 'overview-card-head', el); + const titles = node(doc, 'div', '', head); + node(doc, 'p', 'section-kicker', titles, 'Cars'); + const h2 = node(doc, 'h2', '', titles, 'Your cars'); + h2.id = 'overview-cars-title'; + const age = node(doc, 'span', 'overview-cars-age', head); + const list = node(doc, 'div', 'car-tiles', el); + let shown = ''; + function update({ cars = [], loadpoints = [], fetchedAt = 0, now = Date.now(), fresh = true } = {}) { + el.hidden = !cars.length; + age.textContent = fresh || !fetchedAt ? '' + : `Not updated since ${new Date(fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })}`; + const tiles = carTiles(cars, loadpoints, fetchedAt ? Math.max(0, (now - fetchedAt) / 1000) : 0); + // Rebuilt only when what it shows changes; ages move once a minute. + const key = JSON.stringify(tiles); + if (key === shown) return; + shown = key; + const focused = doc.activeElement && doc.activeElement.dataset && doc.activeElement.dataset.openCar; + list.replaceChildren(); + tiles.forEach(t => { + const tile = node(doc, 'article', 'car-tile', list); + tile.dataset.carId = t.id; + const top = node(doc, 'div', 'car-tile-top', tile); + node(doc, 'strong', 'car-tile-name', top, t.name); + node(doc, 'span', 'car-tile-where' + (t.charger ? ' is-plugged' : ''), top, t.where); + node(doc, 'div', 'car-tile-level' + (t.estimate ? ' is-estimate' : ''), tile, t.level); + node(doc, 'div', 'car-tile-source', tile, t.source); + node(doc, 'div', 'car-tile-facts', tile, t.facts); + if (!t.charger) return; + const foot = node(doc, 'div', 'car-tile-foot', tile); + node(doc, 'span', '', foot, t.knownBy); + if (!onOpenCharger) return; + const b = node(doc, 'button', 'car-tile-open', foot, 'Check charging'); + b.type = 'button'; + b.dataset.openCar = t.id; + b.addEventListener('click', () => onOpenCharger(t.charger)); + if (focused === t.id) b.focus(); + }); + } + return { el, update }; +} diff --git a/web/cars.test.mjs b/web/cars.test.mjs new file mode 100644 index 000000000..7793d6597 --- /dev/null +++ b/web/cars.test.mjs @@ -0,0 +1,280 @@ +// node --test web/cars.test.mjs +// +// The owner's cars on the dashboard (docs/cars.md): each car shows, plugged +// in or not, with its level, where the level came from and how old it is; +// a charger whose car the box cannot name asks, and a tap answers. + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; + +import { + ageText, asking, carTiles, choices, createCarsCard, createWhichCar, daysText, goalText, + levelView, noticeTitle, tap, tapRefusedText, +} from './cars.js'; + +const read = path => readFileSync(new URL(path, import.meta.url), 'utf8'); + +// Just enough of the DOM for the two builders. +class FakeElement { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.attributes = {}; + this.listeners = {}; + this.dataset = {}; + this.hidden = false; + this.disabled = false; + this.className = ''; + this._text = ''; + } + appendChild(child) { this.children.push(child); child.parentNode = this; return child; } + replaceChildren(...kids) { this.children = []; kids.forEach(k => this.appendChild(k)); } + setAttribute(k, v) { this.attributes[k] = String(v); } + getAttribute(k) { return this.attributes[k]; } + addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); } + click() { return Promise.all((this.listeners.click || []).map(fn => fn({}))); } + focus() { this.ownerDocument.activeElement = this; } + set textContent(v) { this._text = String(v); this.children = []; } + get textContent() { return this._text + this.children.map(c => c.textContent).join(''); } + all(predicate, out = []) { + for (const c of this.children) { if (predicate(c)) out.push(c); c.all(predicate, out); } + return out; + } +} +function fakeDoc() { + const doc = { activeElement: null }; + doc.createElement = tag => Object.assign(new FakeElement(tag), { ownerDocument: doc }); + return doc; +} +const buttons = el => el.all(c => c.tagName === 'BUTTON'); + +const tesla = { + id: 'a'.repeat(32), name: 'Model Y', capacity_wh: 76000, soc_permille: 720, soc_source: 'vehicle', + soc_age_s: 3600, energy_since_wh: 0, soc_estimate: false, charge_limit_permille: 800, + goal: { target_permille: 800, ready_by_min: 420, days: 127, finish_at_limit: false }, surplus_only: false, +}; +const volvo = { id: 'b'.repeat(32), name: 'XC40', capacity_wh: 69000, energy_since_wh: 0, soc_estimate: false, surplus_only: true }; +const kia = { id: 'c'.repeat(32), name: 'EV6', capacity_wh: 77400, energy_since_wh: 0, soc_estimate: false, surplus_only: false }; + +describe('a car level says where it came from and how old it is', () => { + it('keeps an hour-old reading with nothing since as the car’s own number', () => { + // Fredrik's box on 17 October: 72 % from the car an hour ago. + const v = levelView(tesla); + assert.equal(v.value, '72%'); + assert.equal(v.source, 'From the car · 1 h ago'); + assert.equal(v.estimate, false); + }); + + it('ages the reading between reads, never shows it as live', () => { + assert.equal(levelView(tesla, 15 * 60).source, 'From the car · 1 h 15 min ago'); + assert.equal(levelView({ ...tesla, soc_age_s: 20 }).source, 'From the car · just now'); + assert.equal(levelView({ ...tesla, soc_age_s: 3 * 86400 }).source, 'From the car · 3 days ago'); + }); + + it('marks an estimate and the energy since the reading', () => { + const v = levelView({ ...tesla, soc_permille: 750, soc_estimate: true, energy_since_wh: 2400 }); + assert.equal(v.value, '≈ 75%'); + assert.equal(v.source, 'From the car · 1 h ago + 2.4 kWh'); + assert.match(levelView({ ...tesla, soc_estimate: true, energy_since_wh: -1200 }).source, /− 1\.2 kWh$/); + }); + + it('names a level set by hand and an assumed one', () => { + assert.equal(levelView({ ...tesla, soc_source: 'manual', soc_age_s: 300 }).source, 'Set by hand · 5 min ago'); + const assumed = levelView({ ...tesla, soc_permille: 300, soc_source: 'assumed', soc_estimate: true }); + assert.deepEqual([assumed.value, assumed.source], ['≈ 30%', 'Assumed at plug-in']); + }); + + it('shows no number for a car with no level', () => { + assert.deepEqual([levelView(volvo).value, levelView(volvo).source], ['—', 'No level yet']); + }); + + it('counts ages in words', () => { + assert.equal(ageText(59), 'just now'); + assert.equal(ageText(60), '1 min ago'); + assert.equal(ageText(3599), '59 min ago'); + assert.equal(ageText(10 * 3600 + 120), '10 h ago'); + assert.equal(ageText(86400), '1 day ago'); + assert.equal(ageText(undefined), ''); + }); +}); + +describe('a goal in words', () => { + it('says what, by when and on which days', () => { + assert.equal(goalText(tesla.goal), '80% by 07:00, every day'); + assert.equal(goalText({ target_permille: 600, ready_by_min: 390, days: 0x1f }), '60% by 06:30, weekdays'); + assert.equal(goalText({ target_permille: 0, ready_by_min: 1200, days: 0, finish_at_limit: true }), 'Car’s limit by 20:00, once'); + assert.equal(goalText(null), 'No goal'); + assert.equal(daysText(0x60), 'weekends'); + assert.equal(daysText(0b0000101), 'Mon, Wed'); + }); +}); + +describe('each car shows, plugged in or not', () => { + const garage = { id: 'garage', driver_name: 'easee', plugged_in: true, session: 's1', car_id: volvo.id, car_identified_by: 'car' }; + it('shows an unplugged car with its level, limit and goal', () => { + const [t] = carTiles([tesla], []); + assert.equal(t.where, 'Not plugged in'); + assert.equal(t.charger, null); + assert.equal(t.facts, 'Limit 80% · Goal 80% by 07:00, every day'); + }); + + it('shows the charger a car is on and how the box knows', () => { + const [, v] = carTiles([tesla, volvo], [garage]); + assert.equal(v.where, 'On garage'); + assert.equal(v.knownBy, 'The car said so'); + assert.deepEqual(v.charger, { id: 'garage', driver_name: 'easee' }); + assert.match(v.facts, /Solar only/); + const corrected = carTiles([tesla, volvo], [{ ...garage, car_corrected: true, car_corrected_from: tesla.id }])[1]; + assert.equal(corrected.knownBy, 'The car said so · not Model Y'); + }); + + it('does not call a car unplugged while a charger asks whether it is that car', () => { + const asks = { id: 'garage', plugged_in: true, session: 's1', car_identified_by: 'asking', choose: [tesla.id, kia.id] }; + const carport = { ...asks, id: 'carport', session: 's2', choose: [tesla.id] }; + const tiles = carTiles([tesla, volvo, kia], [asks, carport]); + assert.deepEqual(tiles.map(t => t.where), ['On garage or carport?', 'Not plugged in', 'On garage?']); + assert.equal(tiles[0].charger, null, 'a car the box asks about is on no charger yet'); + }); + + it('does not put a car on a charger that has been unplugged', () => { + assert.equal(carTiles([volvo], [{ ...garage, plugged_in: false }])[0].where, 'Not plugged in'); + }); +}); + +describe('a charger whose car the box cannot name asks', () => { + const askingLp = { id: 'garage', plugged_in: true, session: 's1', car_identified_by: 'asking', choose: [tesla.id, kia.id] }; + const carport = { id: 'carport', plugged_in: true, session: 's2', car_id: volvo.id, car_identified_by: 'only' }; + const cars = [tesla, volvo, kia]; + + it('titles the notice by the car, or with the question', () => { + assert.equal(noticeTitle(askingLp, cars), 'Which car is on garage?'); + assert.equal(noticeTitle(carport, cars), 'XC40 connected'); + assert.equal(noticeTitle({ ...carport, car_id: '', car_identified_by: 'none' }, cars), 'Car connected'); + assert.ok(asking(askingLp) && !asking(carport)); + }); + + it('offers the box’s own choices while it asks, else every car not on another charger', () => { + assert.deepEqual(choices(askingLp, cars, [askingLp, carport]).map(c => c.name), ['Model Y', 'EV6']); + assert.deepEqual(choices(carport, cars, [askingLp, carport]).map(c => c.name), ['Model Y', 'XC40', 'EV6']); + assert.deepEqual(choices({ ...askingLp, plugged_in: false }, cars, []), []); + assert.deepEqual(choices({ ...askingLp, session: '' }, cars, []), [], 'a tap must name its plug-in'); + }); + + it('shows one button per car and None of these, and a tap names the plug-in', async () => { + const doc = fakeDoc(); + const taps = []; + const which = createWhichCar(doc, { onTap: async (lp, id) => { taps.push([lp.session, id]); return { ok: true }; } }); + which.update(askingLp, cars, [askingLp, carport], true); + const picks = buttons(which.el).filter(b => b.className === 'car-which-pick'); + assert.deepEqual(picks.map(b => b.textContent), ['Model Y', 'EV6', 'None of these']); + assert.equal(which.el.hidden, false); + await picks[1].click(); + await picks[2].click(); + assert.deepEqual(taps, [['s1', kia.id], ['s1', '']]); + }); + + it('keeps its buttons across polls that change nothing', () => { + const doc = fakeDoc(); + const which = createWhichCar(doc, { onTap: async () => ({ ok: true }) }); + which.update(askingLp, cars, [askingLp], true); + const before = buttons(which.el)[1]; + which.update({ ...askingLp }, cars.map(c => ({ ...c })), [askingLp], true); + assert.equal(buttons(which.el)[1], before, 'a poll took the button from under the finger'); + which.update({ ...askingLp, choose: [tesla.id] }, cars, [askingLp], true); + assert.notEqual(buttons(which.el)[1], before); + }); + + it('hides the choice when the charger’s data is out of date', () => { + const which = createWhichCar(fakeDoc(), { onTap: async () => ({ ok: true }) }); + which.update(askingLp, cars, [askingLp], false); + assert.equal(which.el.hidden, true); + }); + + it('lets an automatic answer be corrected, behind one button', async () => { + const which = createWhichCar(fakeDoc(), { onTap: async () => ({ ok: true }) }); + which.update(carport, cars, [carport], true); + const [open] = buttons(which.el); + assert.equal(open.textContent, 'Not this car?'); + const row = which.el.children.find(c => c.className === 'car-which-row'); + assert.equal(row.hidden, true); + await open.click(); + assert.equal(row.hidden, false); + const current = buttons(which.el).find(b => b.textContent === 'XC40'); + assert.equal(current.getAttribute('aria-pressed'), 'true'); + }); + + it('says why the box refused a tap', async () => { + assert.equal(tapRefusedText({ reason: 'session' }), 'That plug-in has ended.'); + assert.equal(tapRefusedText({ reason: 'elsewhere' }), 'That car is on another charger.'); + assert.equal(tapRefusedText(null), 'The box did not take that. Try again.'); + const sent = []; + const fetchFn = async (path, opts) => { + sent.push([path, opts.method, JSON.parse(opts.body)]); + return { ok: false, json: async () => ({ code: 'E_PRECONDITION', reason: 'session' }) }; + }; + const res = await tap(fetchFn, { id: 'gar age', session: 's1' }, kia.id); + assert.deepEqual(sent, [['/api/loadpoints/gar%20age/car', 'POST', { session: 's1', car_id: kia.id }]]); + assert.deepEqual(res, { ok: false, text: 'That plug-in has ended.' }); + }); +}); + +describe('the card of cars', () => { + it('hides with no cars and says when its list is old', () => { + const doc = fakeDoc(); + const el = doc.createElement('section'); + const card = createCarsCard(doc, el); + card.update({ cars: [] }); + assert.equal(el.hidden, true); + const fetchedAt = Date.UTC(2026, 9, 17, 9, 0); + card.update({ cars: [tesla], fetchedAt, now: fetchedAt + 60000, fresh: false }); + assert.equal(el.hidden, false); + assert.match(el.textContent, /Not updated since/); + assert.match(el.textContent, /1 h 1 min ago/, 'the age counts the time since the read'); + }); + + it('puts a car’s name in as text', () => { + const doc = fakeDoc(); + const el = doc.createElement('section'); + createCarsCard(doc, el).update({ cars: [{ ...kia, name: '' }], fetchedAt: 1, now: 1 }); + const name = el.all(c => c.className === 'car-tile-name')[0]; + assert.equal(name.textContent, ''); + assert.doesNotMatch(read('./cars.js'), /innerHTML/); + }); + + it('opens the charger’s controls from the car on it', async () => { + const doc = fakeDoc(); + const el = doc.createElement('section'); + const opened = []; + const card = createCarsCard(doc, el, { onOpenCharger: lp => opened.push(lp.driver_name) }); + const lp = { id: 'garage', driver_name: 'easee', plugged_in: true, session: 's1', car_id: tesla.id, car_identified_by: 'tap' }; + card.update({ cars: [tesla, volvo], loadpoints: [lp], fetchedAt: 1, now: 1 }); + const open = buttons(el); + assert.equal(open.length, 1); + assert.equal(open[0].textContent, 'Check charging'); + await open[0].click(); + assert.deepEqual(opened, ['easee']); + assert.match(el.textContent, /You chose it/); + }); +}); + +describe('the dashboard wiring', () => { + const index = read('./index.html'); + const app = read('./app.js'); + const css = read('./app.css'); + const overview = index.slice(index.indexOf('
')); + + it('reads the cars with the chargers on every poll', () => { + assert.match(app, /boundedApiRead\("\/api\/cars"/); + assert.match(app, /import\("\/cars\.js"\)/); + assert.match(app, /ui\.noticeTitle\(lp, lastCars\)/); + assert.match(app, /row\.which\.update\(lp, lastCars, chargingNoticePoints, available\)/); + }); + + it('places the cars after Power now and spans the row', () => { + assert.ok(overview.indexOf('id="power-now"') < overview.indexOf('id="overview-cars"')); + assert.ok(overview.indexOf('id="overview-cars"') < overview.indexOf('id="overview-price"')); + assert.match(css, /body\.ftw-app #overview-cars\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/); + assert.match(css, /body\.ftw-app #charging-notices,\s*body\.ftw-app #overview-cars,/); + }); +}); diff --git a/web/dashboard-poll-visibility.test.mjs b/web/dashboard-poll-visibility.test.mjs index 8d5a1f60b..dbab2fc2a 100644 --- a/web/dashboard-poll-visibility.test.mjs +++ b/web/dashboard-poll-visibility.test.mjs @@ -4,7 +4,7 @@ import { test } from "node:test"; import vm from "node:vm"; const source = readFileSync(new URL("./app.js", import.meta.url), "utf8"); -const statusPaths = new Set(["/api/status", "/api/loadpoints", "/api/health"]); +const statusPaths = new Set(["/api/status", "/api/loadpoints", "/api/cars", "/api/health"]); const liveHistoryPath = "/api/history?range=24h&points=288"; const isHistoryPath = (path) => path.startsWith("/api/history?"); const apiReadTimeoutMs = 15_000; @@ -188,7 +188,7 @@ function rig({ hidden = false } = {}) { test("dashboard polling stays single-flight while visible", async () => { const app = rig(); - assert.equal(app.statusFetches().length, 3, "startup should make the three status GETs once"); + assert.equal(app.statusFetches().length, 4, "startup should make the four status GETs once"); assert.equal(app.historyFetches().length, 2, "startup should load each distinct history view once"); assert.equal(app.liveHistoryFetches().length, 1, "startup should fetch live history once"); assert.equal(app.statusTimers().length, 1, "startup should own one status timer"); @@ -198,20 +198,20 @@ test("dashboard polling stays single-flight while visible", async () => { app.runStatusTimers(); app.runLiveHistoryTimers(); app.runLiveHistoryTimers(); - assert.equal(app.statusFetches().length, 3, "timer ticks must not overlap an unresolved status poll"); + assert.equal(app.statusFetches().length, 4, "timer ticks must not overlap an unresolved status poll"); assert.equal(app.historyFetches().length, 2, "timer ticks must not duplicate either unresolved history request"); assert.equal(app.liveHistoryFetches().length, 1, "forced timer ticks must reuse unresolved live history"); await app.settleStatusFetches(); app.runStatusTimers(); - assert.equal(app.statusFetches().length, 6, "a future tick should poll after the prior request settles"); + assert.equal(app.statusFetches().length, 8, "a future tick should poll after the prior request settles"); app.runStatusTimers(); - assert.equal(app.statusFetches().length, 6, "the replacement poll should also stay single-flight"); + assert.equal(app.statusFetches().length, 8, "the replacement poll should also stay single-flight"); app.setHidden(true); app.runStatusTimers(); app.runLiveHistoryTimers(); - assert.equal(app.statusFetches().length, 6, "hidden dashboard should make no more status GETs"); + assert.equal(app.statusFetches().length, 8, "hidden dashboard should make no more status GETs"); assert.equal(app.liveHistoryFetches().length, 1, "hidden dashboard should make no more live-history GETs"); assert.equal(app.statusTimers().length, 0, "hidden dashboard should clear its status timer"); assert.equal(app.liveHistoryTimers().length, 0, "hidden dashboard should clear its live-history timer"); @@ -219,10 +219,10 @@ test("dashboard polling stays single-flight while visible", async () => { await app.settleStatusFetches(); await app.settleLiveHistoryFetches(); assert.equal(app.liveHistoryFetches().length, 1, "settling while hidden must not start live-history catch-up"); - assert.equal(app.statusFetches().length, 6, "settling while hidden must not start a catch-up poll"); + assert.equal(app.statusFetches().length, 8, "settling while hidden must not start a catch-up poll"); app.setHidden(false); - assert.equal(app.statusFetches().length, 9, "visible dashboard should refresh all three endpoints at once"); + assert.equal(app.statusFetches().length, 12, "visible dashboard should refresh all four endpoints at once"); assert.equal(app.historyFetches().length, 3, "visible catch-up should reuse the unresolved chart load"); assert.equal(app.liveHistoryFetches().length, 2, "visible dashboard should refresh live history at once"); assert.equal(app.statusTimers().length, 1, "visible dashboard should restore only one status timer"); @@ -232,7 +232,7 @@ test("dashboard polling stays single-flight while visible", async () => { app.runStatusTimers(); app.runLiveHistoryTimers(); app.runLiveHistoryTimers(); - assert.equal(app.statusFetches().length, 9, "timer ticks must not overlap the visible catch-up poll"); + assert.equal(app.statusFetches().length, 12, "timer ticks must not overlap the visible catch-up poll"); assert.equal(app.liveHistoryFetches().length, 2, "timer and visibility refreshes must share one live-history request"); await app.settleLiveHistoryFetches(); @@ -251,7 +251,7 @@ test("dashboard starts dormant when loaded in a hidden document", () => { assert.equal(app.liveHistoryTimers().length, 0); app.setHidden(false); - assert.equal(app.statusFetches().length, 3); + assert.equal(app.statusFetches().length, 4); assert.equal(app.historyFetches().length, 2, "first visible event should load both history views at once"); assert.equal(app.liveHistoryFetches().length, 1); assert.equal(app.statusTimers().length, 1); @@ -267,24 +267,24 @@ test("dashboard starts dormant when loaded in a hidden document", () => { test("a timed-out read releases both single-flight gates", async () => { const app = rig(); - assert.equal(app.statusFetches().length, 3); + assert.equal(app.statusFetches().length, 4); assert.equal(app.liveHistoryFetches().length, 1); // None of the startup reads settle. Expire their controlled deadlines in one // step, then let promise rejection and finally handlers drain. await app.expireApiReads(); - assert.equal(app.statusFetches().filter((entry) => entry.aborted).length, 3); + assert.equal(app.statusFetches().filter((entry) => entry.aborted).length, 4); assert.equal(app.historyFetches().filter((entry) => entry.aborted).length, 2); app.runStatusTimers(); app.runLiveHistoryTimers(); - assert.equal(app.statusFetches().length, 6, "status should retry after its timed-out poll"); + assert.equal(app.statusFetches().length, 8, "status should retry after its timed-out poll"); assert.equal(app.liveHistoryFetches().length, 2, "forced history should retry after its timed-out read"); // The replacement reads are still unresolved, so normal single-flight still // coalesces later timer ticks. app.runStatusTimers(); app.runLiveHistoryTimers(); - assert.equal(app.statusFetches().length, 6); + assert.equal(app.statusFetches().length, 8); assert.equal(app.liveHistoryFetches().length, 2); }); diff --git a/web/index.html b/web/index.html index a954343f1..8e5892848 100644 --- a/web/index.html +++ b/web/index.html @@ -95,6 +95,7 @@

Settings

+ @@ -217,6 +218,9 @@

Power now

+ + +
@@ -997,6 +1001,7 @@

Price bars (top of the chart)

+ diff --git a/web/settings.js b/web/settings.js index b096a9e94..901b2b4be 100644 --- a/web/settings.js +++ b/web/settings.js @@ -400,8 +400,9 @@ function renderTab(tab) { var hadBodyFocus = bodyEl.contains(document.activeElement); - // Access changes nothing in the config: each of its buttons asks the box. - saveBtn.hidden = tab === "loadpoints" || tab === "access" || (tab === "devices" && !!S.chargerSetup); + // Access and Cars change nothing in the config: each of their buttons + // asks the box. + saveBtn.hidden = tab === "loadpoints" || tab === "access" || tab === "cars" || (tab === "devices" && !!S.chargerSetup); saveBtn.style.display = saveBtn.hidden ? "none" : ""; var def = S.tabs[tab]; if (!def) { diff --git a/web/settings/tabs/cars.js b/web/settings/tabs/cars.js new file mode 100644 index 000000000..141916b2d --- /dev/null +++ b/web/settings/tabs/cars.js @@ -0,0 +1,311 @@ +// Settings → Cars: the owner's cars on this box (docs/cars.md). +// +// A car belongs to its owner, not to a charger. This tab lists the cars the +// box keeps, with how the box knows each one: its VIN, which ties a level +// source such as Home Assistant to it, the tags a charger may name it by, +// and those sources. The owner renames a car, sets its battery size, adds +// one and removes one. Each button asks the box at once with the car routes; +// nothing here is part of the settings the Save button writes. +// +// Names come from the box and the owner, and only ever go into the page as +// text. +(function () { + var S = (window.FTWSettings = window.FTWSettings || { tabs: {} }); + S.tabs = S.tabs || {}; + + // A refused op, in words, by its reason. + var REFUSED = { + duplicate: "Another car already has that VIN or tag.", + invalid: "Check the name (up to 64 characters), the battery size (1–300 kWh) and the VIN (17 characters).", + cars: "This box keeps at most 32 cars. Remove one first.", + car: "The box no longer has this car. The list is read again.", + persistence_unconfirmed: "The box could not confirm it saved this. Check the list before trying again.", + }; + + var state = { cars: null, problem: "" }; + + // newCarId mints a car's id as the owner's app does: 128 random bits as 32 + // lowercase hex digits. + function newCarId(random) { + var bytes = new Uint8Array(16); + (random || function (b) { return crypto.getRandomValues(b); })(bytes); + return Array.prototype.map.call(bytes, function (b) { return (b < 16 ? "0" : "") + b.toString(16); }).join(""); + } + + // kWh reads a battery size typed in kWh, with a comma or a point, as Wh. + // null for anything the box would refuse. + function kWh(text) { + var v = parseFloat(String(text == null ? "" : text).trim().replace(",", ".")); + if (!isFinite(v) || v < 1 || v > 300) return null; + return Math.round(v * 1000); + } + + function kWhText(wh) { + if (!(wh > 0)) return ""; + var v = wh / 1000; + return String(Math.round(v * 10) / 10); + } + + function refusalText(body) { + return (body && REFUSED[body.reason]) || (body && body.error) || "The box did not take that. Try again."; + } + + // The add form's body. A VIN is optional; an empty one is left out. + function addBody(name, capacity, vin, random) { + var body = { id: newCarId(random), name: String(name || "").trim(), capacity_wh: kWh(capacity) }; + var v = String(vin || "").trim().toUpperCase(); + if (v) body.vin = v; + return body; + } + + // The car that already has a VIN. Adding a car with a VIN the box has is + // how the owner's app takes over a car the box made, so here it would + // quietly replace that car; the page says so instead. + function vinTakenBy(cars, vin) { + var v = String(vin || "").trim().toUpperCase(); + if (!v) return null; + for (var i = 0; i < (cars || []).length; i++) { + if (cars[i].vin && cars[i].vin.toUpperCase() === v) return cars[i]; + } + return null; + } + + // What changed in a car's edit form, or null when nothing did. + function updateBody(car, name, capacity) { + var body = {}; + var n = String(name || "").trim(); + if (n && n !== car.name) body.name = n; + var wh = kWh(capacity); + if (wh != null && wh !== car.capacity_wh) body.capacity_wh = wh; + return Object.keys(body).length ? body : null; + } + + // How the box knows a car, as label and value pairs. + function knownBy(car) { + var rows = []; + rows.push(["VIN", car.vin || "Not set"]); + if (car.make) rows.push(["Make", car.make]); + rows.push(["Level from", car.sources && car.sources.length ? car.sources.join(", ") + : "No source here. The level comes from the charger or from you."]); + rows.push(["Charger tags", car.identifiers && car.identifiers.length ? car.identifiers.join(", ") : "None"]); + return rows; + } + + function el(tag, attrs, children) { + var node = document.createElement(tag); + Object.keys(attrs || {}).forEach(function (k) { + if (k === "text") node.textContent = attrs[k]; + else if (k === "className") node.className = attrs[k]; + else if (k.indexOf("on") === 0) node.addEventListener(k.slice(2), attrs[k]); + else node.setAttribute(k, attrs[k]); + }); + (children || []).forEach(function (c) { if (c) node.appendChild(c); }); + return node; + } + + function send(ctx, method, path, body) { + var opts = { method: method }; + if (body) { + opts.headers = { "Content-Type": "application/json" }; + opts.body = JSON.stringify(body); + } + return ctx.apiFetch(path, opts).then(function (r) { + return r.json().catch(function () { return null; }).then(function (data) { + return { ok: r.ok, data: data }; + }); + }); + } + + function refresh(ctx) { + return ctx.apiFetch("/api/cars") + .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) + .then(function (data) { + state.cars = (data && data.cars) || []; + state.problem = ""; + }) + .catch(function () { state.problem = "The box did not answer. Try again in a moment."; }) + .then(function () { drawList(ctx); }); + } + + function drawList(ctx) { + var host = document.getElementById("cars-list"); + if (!host) return; + host.textContent = ""; + if (state.problem) { + host.appendChild(el("p", { className: "cars-error", role: "alert", text: state.problem })); + return; + } + if (!state.cars) { + host.appendChild(el("p", { className: "cars-quiet", text: "Checking the box…" })); + return; + } + if (!state.cars.length) { + host.appendChild(el("p", { className: "cars-quiet", text: "No cars yet. Add the cars you charge here below." })); + return; + } + state.cars.forEach(function (car) { host.appendChild(carRow(ctx, car)); }); + } + + function carRow(ctx, car) { + var note = el("p", { className: "cars-note", "aria-live": "polite" }); + var name = el("input", { type: "text", maxlength: "64", value: car.name, "aria-label": "Name of " + car.name }); + var size = el("input", { type: "text", inputmode: "decimal", value: kWhText(car.capacity_wh), + "aria-label": "Battery size of " + car.name + " in kWh" }); + var save = el("button", { type: "submit", className: "btn-send", text: "Save" }); + var form = el("form", { className: "cars-edit" }, [ + el("label", {}, [el("span", { text: "Name" }), name]), + el("label", {}, [el("span", { text: "Battery, kWh" }), size]), + save, + ]); + form.addEventListener("submit", function (e) { + e.preventDefault(); + if (kWh(size.value) == null && String(size.value).trim() !== "") { + note.className = "cars-error"; + note.textContent = "Battery size is 1–300 kWh."; + return; + } + var body = updateBody(car, name.value, size.value); + if (!body) { + note.className = "cars-note"; + note.textContent = "Nothing changed."; + return; + } + save.disabled = true; + send(ctx, "PATCH", "/api/cars/" + encodeURIComponent(car.id), body).then(function (res) { + save.disabled = false; + if (!res.ok) { + note.className = "cars-error"; + note.textContent = refusalText(res.data); + if (res.data && res.data.reason === "car") refresh(ctx); + return; + } + Object.assign(car, res.data && res.data.car); + note.className = "cars-note"; + note.textContent = "Saved."; + }).catch(function () { + save.disabled = false; + note.className = "cars-error"; + note.textContent = "The box did not answer. Try again."; + }); + }); + + var facts = el("dl", { className: "cars-facts" }); + knownBy(car).forEach(function (row) { + facts.appendChild(el("dt", { text: row[0] })); + facts.appendChild(el("dd", { text: row[1] })); + }); + + var remove = el("button", { type: "button", className: "cars-remove", text: "Remove car" }); + remove.addEventListener("click", function () { + if (!window.confirm("Remove " + car.name + " from this box? If it is charging now, the box charges it as a visitor's car.")) return; + remove.disabled = true; + send(ctx, "DELETE", "/api/cars/" + encodeURIComponent(car.id)).then(function (res) { + remove.disabled = false; + if (!res.ok && !(res.data && res.data.reason === "car")) { + note.className = "cars-error"; + note.textContent = refusalText(res.data); + return; + } + refresh(ctx); + }).catch(function () { + remove.disabled = false; + note.className = "cars-error"; + note.textContent = "The box did not answer. Try again."; + }); + }); + + return el("div", { className: "cars-row" }, [ + form, + facts, + car.box_minted ? el("p", { className: "cars-quiet", + text: "Made by this box from a charger's settings. The FTW app takes it over when it adds the same car." }) : null, + el("div", { className: "cars-actions" }, [remove]), + note, + ]); + } + + function wireAdd(ctx) { + var form = document.getElementById("cars-add"); + if (!form) return; + var note = document.getElementById("cars-add-note"); + form.addEventListener("submit", function (e) { + e.preventDefault(); + var name = form.querySelector('[name="name"]').value; + var size = form.querySelector('[name="capacity"]').value; + var vin = form.querySelector('[name="vin"]').value; + if (!String(name).trim() || kWh(size) == null) { + note.className = "cars-error"; + note.textContent = "Give the car a name and a battery size of 1–300 kWh."; + return; + } + var taken = vinTakenBy(state.cars, vin); + if (taken) { + note.className = "cars-error"; + note.textContent = taken.name + " already has that VIN. Change that car above instead."; + return; + } + var button = form.querySelector('button[type="submit"]'); + button.disabled = true; + send(ctx, "POST", "/api/cars", addBody(name, size, vin)).then(function (res) { + button.disabled = false; + if (!res.ok) { + note.className = "cars-error"; + note.textContent = refusalText(res.data); + return; + } + form.reset(); + note.className = "cars-note"; + note.textContent = "Added " + ((res.data && res.data.car && res.data.car.name) || "the car") + "."; + refresh(ctx); + }).catch(function () { + button.disabled = false; + note.className = "cars-error"; + note.textContent = "The box did not answer. Try again."; + }); + }); + } + + S.tabs.cars = { + render: function () { + return ( + '
' + + "Cars" + + '

The cars you charge here. A car is yours, not the charger\'s: ' + + "FTW plans for the car that is plugged in, with its own level, battery and goal. " + + "With more than one car, the box asks which car it is when it cannot tell.

" + + '

Checking the box…

' + + "
" + + "
" + + "Add a car" + + '
' + + '' + + '' + + '' + + '' + + "
" + + '

The VIN lets a source that reports it, such as Home Assistant or the maker\'s ' + + "service, give this car its level.

" + + '

' + + "
" + ); + }, + after: function (ctx) { + wireAdd(ctx); + drawList(ctx); + refresh(ctx); + }, + }; + + S.tabs.cars._pure = { + REFUSED: REFUSED, + newCarId: newCarId, + kWh: kWh, + kWhText: kWhText, + refusalText: refusalText, + addBody: addBody, + updateBody: updateBody, + vinTakenBy: vinTakenBy, + knownBy: knownBy, + }; +})(); diff --git a/web/settings/tabs/cars.test.mjs b/web/settings/tabs/cars.test.mjs new file mode 100644 index 000000000..ace30cff8 --- /dev/null +++ b/web/settings/tabs/cars.test.mjs @@ -0,0 +1,114 @@ +// node --test web/settings/tabs/cars.test.mjs +// +// Settings → Cars keeps the owner's cars on this box with the car routes: +// the list with how the box knows each car, rename, battery size, add and +// remove. Nothing here goes through the Save button's config write. + +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +globalThis.window = {}; +await import("./cars.js"); +const tab = globalThis.window.FTWSettings.tabs.cars; +const P = tab._pure; + +const read = (path) => readFileSync(new URL(path, import.meta.url), "utf8"); +const source = read("./cars.js"); +const index = read("../../index.html"); +const shell = read("../../settings.js"); + +describe("a new car", () => { + it("gets an id as the owner's app mints one", () => { + const id = P.newCarId((b) => b.fill(0xab)); + assert.equal(id, "ab".repeat(16)); + assert.match(P.newCarId(), /^[0-9a-f]{32}$/); + assert.notEqual(P.newCarId(), P.newCarId()); + }); + + it("sends its name, size in Wh and an optional VIN", () => { + const body = P.addBody(" EV6 ", "77,4", "", (b) => b.fill(1)); + assert.deepEqual(body, { id: "01".repeat(16), name: "EV6", capacity_wh: 77400 }); + assert.equal(P.addBody("Model Y", "75", " 5yjygdee0mf000001 ").vin, "5YJYGDEE0MF000001"); + }); + + it("reads a battery size the box takes, and refuses one it would not", () => { + assert.equal(P.kWh("76"), 76000); + assert.equal(P.kWh("77.4"), 77400); + assert.equal(P.kWh("0.5"), null); + assert.equal(P.kWh("301"), null); + assert.equal(P.kWh("big"), null); + assert.equal(P.kWhText(77400), "77.4"); + assert.equal(P.kWhText(0), ""); + }); +}); + +describe("a VIN the box already has", () => { + // Adding a car with a VIN a car the box made has is how the owner's app + // takes that car over. From this page it would rename and resize the car + // the owner already sees, so the page stops it and says which car. + it("names the car that has it", () => { + const cars = [{ id: "a".repeat(32), name: "Model Y", vin: "5YJYGDEE0MF000001", box_minted: true }]; + assert.equal(P.vinTakenBy(cars, " 5yjygdee0mf000001").name, "Model Y"); + assert.equal(P.vinTakenBy(cars, "YV1AB0CD9EF123456"), null); + assert.equal(P.vinTakenBy(cars, ""), null); + assert.match(source, /already has that VIN/); + }); +}); + +describe("a car's edit", () => { + const car = { id: "a".repeat(32), name: "Car 1", capacity_wh: 60000 }; + it("sends only what changed", () => { + assert.deepEqual(P.updateBody(car, "Model Y", "76"), { name: "Model Y", capacity_wh: 76000 }); + assert.deepEqual(P.updateBody(car, "Car 1", "77"), { capacity_wh: 77000 }); + assert.equal(P.updateBody(car, "Car 1", "60"), null); + assert.equal(P.updateBody(car, " ", ""), null, "an empty name or size is not a change"); + }); + + it("goes to the car's own route and never to the config", () => { + assert.match(source, /"PATCH", "\/api\/cars\/" \+ encodeURIComponent\(car\.id\)/); + assert.match(source, /"DELETE", "\/api\/cars\/" \+ encodeURIComponent\(car\.id\)/); + assert.match(source, /"POST", "\/api\/cars"/); + assert.doesNotMatch(source, /\/api\/config/); + assert.match(shell, /tab === "cars"/, "the Save button hides on Cars"); + }); +}); + +describe("how the box knows a car", () => { + it("lists the VIN, the level sources and the charger tags", () => { + const rows = P.knownBy({ vin: "5YJYGDEE0MF000001", make: "Tesla", sources: ["tesla-ha"], identifiers: ["RFID-1", "MAC-2"] }); + assert.deepEqual(rows, [ + ["VIN", "5YJYGDEE0MF000001"], + ["Make", "Tesla"], + ["Level from", "tesla-ha"], + ["Charger tags", "RFID-1, MAC-2"], + ]); + const bare = P.knownBy({}); + assert.equal(bare[0][1], "Not set"); + assert.match(bare[1][1], /No source here/); + assert.equal(bare[2][1], "None"); + }); + + it("puts names and sources in as text", () => { + assert.doesNotMatch(source.slice(source.indexOf("function carRow")), /innerHTML/); + }); +}); + +describe("a refusal in words", () => { + it("names each reason the car ops give", () => { + for (const reason of ["duplicate", "invalid", "cars", "car", "persistence_unconfirmed"]) { + assert.equal(P.refusalText({ reason }), P.REFUSED[reason]); + } + assert.equal(P.refusalText({ error: "something else" }), "something else"); + assert.equal(P.refusalText(null), "The box did not take that. Try again."); + }); +}); + +describe("the tab", () => { + it("sits beside Chargers and loads", () => { + assert.match(index, /data-tab="loadpoints">Chargers<\/button>\s*