diff --git a/.changeset/box-page-cars.md b/.changeset/box-page-cars.md new file mode 100644 index 000000000..7e79d29d5 --- /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 only: `POST /api/cars`, `PATCH` and `DELETE /api/cars/{id}`, and `POST /api/loadpoints/{id}/car`, the tap. A phone keeps sending the ops. `GET /api/cars` now leaves out each car's VIN, charge tags and sources for a request that has not proved itself as a protected one must, such as one through a public name without the API token, and says so with `redacted: true`. diff --git a/.changeset/persistent-lan-login.md b/.changeset/persistent-lan-login.md new file mode 100644 index 000000000..5159e90e2 --- /dev/null +++ b/.changeset/persistent-lan-login.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Keep house-password browser sessions for 30 days across Core updates and restarts. Save only token digests in the database, and keep logout, expiry and password changes effective after a restart. diff --git a/docs/cars.md b/docs/cars.md index 304886acc..aaa49c50a 100644 --- a/docs/cars.md +++ b/docs/cars.md @@ -44,7 +44,7 @@ The Go types are in [`go/protocol/cars.go`](../go/protocol/cars.go). | Read | Who | What | |---|---|---| | `GET /api/cars` with `ftw.cars.read` | The owner role | Every field | -| `GET /api/cars` without it | Viewers and watches | `id`, `name`, the level fields, `goal`, `surplusOnly` | +| `GET /api/cars` without it | Viewers, watches and agents | `id`, `name`, the level fields, `goal`, `surplusOnly`, and `redacted: true` | | FTW Cloud's `car` class | Phones with `ftw.live.read` | Opaque `id`, the level fields, `goal`, `surplusOnly` | | `cars.get` | Agents with `ftw.live.read` | The car class under the agent key ([agents.md](agents.md)) | @@ -52,6 +52,14 @@ An agent asks for a car's goal or level with `car.goal.set` and `car.soc.set` as `agent.request`, which waits for an owner, naming the car by the id `cars.get` gave it. It never taps and never keeps the list. +On the box's own listener every device is the owner while the house +password is off. There `GET /api/cars` gives every field only to a request +that has proved itself as a protected one must: from a local address, from +the box itself, with the house password, or with `FTW_API_TOKEN` through a +public name. Any other request gets the shorter list with `redacted: true`, +so a box published under a public name does not hand out its owners' VINs +and charge tags. + `car.add`, `car.update` and `car.remove` need `ftw.cars.write`, which only the owner role carries. They change no hardware, so they need no passkey step-up. No car op waits for the dispatch gate: a stale meter must not stop @@ -205,6 +213,21 @@ for what the op acts on, a level as a fraction from 0 to 1. | `car.update` | `id`, the fields that change; `new_id` for a `boxMinted` car | Changes a car | | `car.remove` | `id` | Removes a car and keeps its tombstone. A session on it becomes a visitor's | +The box's own page speaks HTTP, so it sends the same ops through routes +that hand the same arguments to the same checks and answer with the same +code and reason: + +| Route | Op | +|---|---| +| `POST /api/cars`, the op's arguments as the body | `car.add` | +| `PATCH /api/cars/{id}`, the fields that change | `car.update` | +| `DELETE /api/cars/{id}` | `car.remove` | +| `POST /api/loadpoints/{id}/car` with `{session, car_id}` | `session.car.set` | + +The first three are served only on the box's own page: a phone sends the ops, +with their id and expiry, and the session refuses these routes. The tap is +actuation, and a phone is told to send `session.car.set`. + `cmd.result` carries `car`, the car's id, level, goal and surplus choice as the box holds them after the op, or `charger`, the charger's car after `session.car.set`; `car.soc.set` also reads the level back in `observed`, as @@ -232,6 +255,10 @@ by default: until someone taps, the car charges as a visitor. ## Where cars appear +- The box's own page shows each car on its overview, plugged in or not, + with its level, source and age, its goal and the charger it is on. A + charger's notice asks which car it is while the box asks, and Settings + has a Cars tab that keeps the list. - `GET /api/cars` lists the cars, as above, and `removed`. `GET /api/loadpoints` gives each charger `session`, `car_id`, `car_identified_by`, `car_corrected`, `car_corrected_from` and `choose`. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index c732193ea..0a435ca1b 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -312,6 +312,7 @@ type Server struct { // New creates a new API server. func New(deps *Deps) *Server { + restoreLANSessions(deps.State) if deps.Version == "" { deps.Version = "dev" } @@ -528,6 +529,15 @@ 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 with the same ops as the owner's + // app (docs/cars.md). Local: a phone sends the ops as cmd frames, with + // their id and expiry, and never these routes through the passthrough. + s.handle("POST /api/cars", Local, s.handleCarAdd) + s.handle("PATCH /api/cars/{id}", Local, s.handleCarUpdate) + s.handle("DELETE /api/cars/{id}", Local, s.handleCarRemove) + // 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. @@ -3837,21 +3847,31 @@ type CarViews interface { // handleCars is GET /api/cars: every car the box keeps, in the registry's // names spelled in snake_case, and the ids an owner removed. A caller -// without ftw.cars.read (a viewer, a watch) sees each car's id, name, level, -// goal and surplus choice, and nothing that says which car it is. +// without ftw.cars.read (a viewer, a watch, an agent) sees each car's id, +// name, level, goal and surplus choice, and nothing that says which car it +// is, and the answer says so with redacted. So does a request off the LAN +// listener that has not proved itself as a protected one must: the LAN +// listener names every caller there an owner while the house password is +// off, and a box published under a public name would otherwise hand any +// visitor its owners' VINs and charge tags. func (s *Server) handleCars(w http.ResponseWriter, r *http.Request) { list, removed := []protocol.Car{}, []cars.Tombstone{} if s.deps.Cars != nil { list, removed = s.deps.Cars.Cars(), s.deps.Cars.Removed() } - if caller, ok := apiauth.FromRequest(r); !ok || !caller.Scopes.Has(protocol.ScopeCarsRead) { - for i, c := range list { - list[i] = protocol.Car{ID: c.ID, Name: c.Name, SocPermille: c.SocPermille, SocSource: c.SocSource, - SocAgeS: c.SocAgeS, EnergySinceWh: c.EnergySinceWh, SocEstimate: c.SocEstimate, - ChargeLimitPermille: c.ChargeLimitPermille, Goal: c.Goal, SurplusOnly: c.SurplusOnly} - } + caller, ok := apiauth.FromRequest(r) + full := ok && caller.Scopes.Has(protocol.ScopeCarsRead) && + (caller.Kind != apiauth.KindLAN || remoteProved(r, s.deps.MutationPolicy)) + if full { + writeJSON(w, http.StatusOK, map[string]any{"cars": list, "removed": removed}) + return + } + for i, c := range list { + list[i] = protocol.Car{ID: c.ID, Name: c.Name, SocPermille: c.SocPermille, SocSource: c.SocSource, + SocAgeS: c.SocAgeS, EnergySinceWh: c.EnergySinceWh, SocEstimate: c.SocEstimate, + ChargeLimitPermille: c.ChargeLimitPermille, Goal: c.Goal, SurplusOnly: c.SurplusOnly} } - writeJSON(w, http.StatusOK, map[string]any{"cars": list, "removed": removed}) + writeJSON(w, http.StatusOK, map[string]any{"cars": list, "removed": removed, "redacted": true}) } // decorateLoadpointsWithCar shows each plugged-in charger's level as its diff --git a/go/internal/api/api_cars.go b/go/internal/api/api_cars.go new file mode 100644 index 000000000..0f6a5efc4 --- /dev/null +++ b/go/internal/api/api_cars.go @@ -0,0 +1,190 @@ +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}) +} + +// carArgs reads a route's body as the op's arguments: the map a cmd frame +// carries, so the op's own checks read it. +func carArgs(w http.ResponseWriter, r *http.Request) (map[string]any, bool) { + var args map[string]any + if err := readJSON(r, &args); err != nil || args == nil { + msg := "body must be a JSON object" + if err != nil { + msg = err.Error() + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + return nil, false + } + return args, true +} + +// writeBadCarArg refuses one argument as the op does: E_UNKNOWN_OP naming +// it, with the reason the page reads as "check what you typed". +func writeBadCarArg(w http.ResponseWriter, arg string) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad argument: " + arg, + "code": appproto.ErrUnknownOp, "reason": "invalid", "arg": arg}) +} + +// POST /api/cars is car.add. The body is car.add's arguments: the id the +// page minted and the car's fields, in the names of GET /api/cars. +func (s *Server) handleCarAdd(w http.ResponseWriter, r *http.Request) { + if !mayKeepCars(w, r) { + return + } + edits, ok := s.carEdits(w) + if !ok { + return + } + args, ok := carArgs(w, r) + if !ok { + return + } + car, bad := appproto.CarAddArgs(args) + if bad != "" { + writeBadCarArg(w, bad) + 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. The car is the one the path names. +func (s *Server) handleCarUpdate(w http.ResponseWriter, r *http.Request) { + if !mayKeepCars(w, r) { + return + } + edits, ok := s.carEdits(w) + if !ok { + return + } + args, ok := carArgs(w, r) + if !ok { + return + } + id := r.PathValue("id") + args["id"] = id + u, bad := appproto.CarUpdateArgs(args) + if bad != "" { + writeBadCarArg(w, bad) + return + } + car, err := edits.UpdateCar(id, u) + 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..3704b263a --- /dev/null +++ b/go/internal/api/api_cars_test.go @@ -0,0 +1,370 @@ +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 routes that keep the list are the box page's own: a phone sends the +// ops as cmd frames, with their id and expiry, so the passthrough refuses +// these. 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.TierLocal, ""}, + {http.MethodPatch, "/api/cars/" + carA, apiauth.TierLocal, ""}, + {http.MethodDelete, "/api/cars/" + carA, apiauth.TierLocal, ""}, + {http.MethodPost, "/api/loadpoints/garage/car", apiauth.TierActuate, protocol.OpSessionCarSet}, + {http.MethodGet, "/api/cars", apiauth.TierRead, ""}, + } { + facts := srv.Route(httptest.NewRequest(tc.method, tc.path, nil)) + if facts.Tier != tc.tier || facts.CmdOp != tc.op { + t.Errorf("%s %s: %+v", tc.method, tc.path, facts) + } + } +} + +// The routes read their bodies with the ops' own checks, so the page and +// a phone are refused alike. +func TestTheCarRoutesCheckArgumentsAsTheOpsDo(t *testing.T) { + srv, _, rt := carsServer(t) + if code, body := carRequest(srv, http.MethodPost, "/api/cars", `{"id":"`+carA+`","name":"Volvo","capacity_wh":-69000}`); code != http.StatusBadRequest || + body["arg"] != "capacity_wh" || body["code"] != protocol.ErrUnknownOp { + t.Fatalf("a negative size: %d %v", code, body) + } + if code, body := carRequest(srv, http.MethodPost, "/api/cars", `["not","a","car"]`); code != http.StatusBadRequest { + t.Fatalf("a body that is not an object: %d %v", code, body) + } + if code, body := carRequest(srv, http.MethodPost, "/api/cars", + `{"id":"`+carA+`","name":"Volvo","capacity_wh":69000,"identifiers":["RFID-1"]}`); code != http.StatusOK { + t.Fatalf("add: %d %v", code, body) + } + if code, body := carRequest(srv, http.MethodPatch, "/api/cars/"+carA, `{"identifiers":["RFID-1",2]}`); code != http.StatusBadRequest || + body["arg"] != "identifiers" { + t.Fatalf("an identifier that is not text: %d %v", code, body) + } + // null clears the list, as car.update does. + if code, body := carRequest(srv, http.MethodPatch, "/api/cars/"+carA, `{"identifiers":null}`); code != http.StatusOK { + t.Fatalf("clear identifiers: %d %v", code, body) + } + if got, _ := rt.Car(carA); len(got.Identifiers) != 0 { + t.Fatalf("identifiers after null: %v", got.Identifiers) + } + // The path names the car; an id in the body does not move the edit. + if code, body := carRequest(srv, http.MethodPatch, "/api/cars/"+carA, `{"id":"`+carB+`","name":"Family Volvo"}`); code != http.StatusOK { + t.Fatalf("rename: %d %v", code, body) + } + if got, _ := rt.Car(carA); got.Name != "Family Volvo" { + t.Fatalf("the path's car was not renamed: %+v", got) + } +} + +// A request that carries a caller, as the app session's passthrough or a +// test puts one on the context. +func carsAs(srv *Server, method, path, body string, caller apiauth.Caller) int { + rr := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + srv.Handler().ServeHTTP(rr, req.WithContext(apiauth.WithCaller(req.Context(), caller))) + return rr.Code +} + +func roleCaller(kind, role string) apiauth.Caller { + return apiauth.Caller{Subject: kind + ":x", Kind: kind, Role: role, Scopes: apiauth.NewScopeSet(apiauth.RoleScopes[role]...)} +} + +// Only the owner keeps the list: a viewer phone, a watch and an agent are +// refused even where a route reaches the handler. +func TestOnlyAnOwnersCallerKeepsTheCars(t *testing.T) { + srv, _, _ := carsServer(t) + for _, c := range []apiauth.Caller{ + roleCaller(apiauth.KindApp, apiauth.RoleViewer), + roleCaller(apiauth.KindApp, apiauth.RoleWatch), + roleCaller(apiauth.KindAgent, apiauth.RoleAgent), + } { + if code := carsAs(srv, http.MethodPost, "/api/cars", `{"id":"`+carA+`","name":"Volvo","capacity_wh":69000}`, c); code != http.StatusForbidden { + t.Errorf("%s %s added a car: %d", c.Kind, c.Role, code) + } + if code := carsAs(srv, http.MethodDelete, "/api/cars/"+carA, "", c); code != http.StatusForbidden { + t.Errorf("%s %s removed a car: %d", c.Kind, c.Role, code) + } + } + if code := carsAs(srv, http.MethodPost, "/api/cars", `{"id":"`+carA+`","name":"Volvo","capacity_wh":69000}`, + roleCaller(apiauth.KindApp, apiauth.RoleOwner)); code != http.StatusOK { + t.Fatalf("the owner: %d", code) + } +} + +// With the house password on, a device on the LAN keeps the cars only with +// it, and a page on another site never does. +func TestTheHousePasswordGuardsTheCarRoutes(t *testing.T) { + resetLANGuesses(t) + srv, _, _ := carsServer(t) + srv.deps.MutationPolicy = lanAuthPolicy(testHousePassword) + post := func(auth, origin string) int { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "http://ftw.local:8080/api/cars", + strings.NewReader(`{"id":"`+carA+`","name":"Volvo","capacity_wh":69000}`)) + req.RemoteAddr = "192.168.1.10:43210" + req.Header.Set("Content-Type", "application/json") + if auth != "" { + req.Header.Set("Authorization", auth) + } + if origin != "" { + req.Header.Set("Origin", origin) + req.Header.Set("Sec-Fetch-Site", "cross-site") + } + srv.Handler().ServeHTTP(rr, req) + return rr.Code + } + if code := post("", ""); code != http.StatusUnauthorized { + t.Fatalf("without the house password: %d", code) + } + if code := post("Bearer "+testHousePassword, "https://evil.example"); code != http.StatusForbidden { + t.Fatalf("from another site: %d", code) + } + if code := post("Bearer "+testHousePassword, ""); code != http.StatusOK { + t.Fatalf("with the house password: %d", code) + } +} + +// GET /api/cars names a car by its VIN and charge tags only to a caller +// that may see them and has proved itself as a protected request must. +func TestTheCarsListHidesWhoTheCarsAreFromAnUnprovedCaller(t *testing.T) { + resetLANGuesses(t) + srv, _, _ := carsServer(t) + if code, body := carRequest(srv, http.MethodPost, "/api/cars", + `{"id":"`+carA+`","name":"Volvo","capacity_wh":69000,"vin":"YV1AB0CD9EF123456","identifiers":["RFID-1"]}`); code != http.StatusOK { + t.Fatalf("add: %d %v", code, body) + } + read := func(policy MutationPolicy, url, remote, auth string, caller *apiauth.Caller) map[string]any { + t.Helper() + srv.deps.MutationPolicy = policy + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, url, nil) + req.RemoteAddr = remote + if auth != "" { + req.Header.Set("Authorization", auth) + } + if caller != nil { + req = req.WithContext(apiauth.WithCaller(req.Context(), *caller)) + } + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("%s from %s: %d %s", url, remote, rr.Code, rr.Body.String()) + } + var out map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &out) + return out + } + volvo := func(list map[string]any) map[string]any { + for _, c := range list["cars"].([]any) { + if m := c.(map[string]any); m["id"] == carA { + return m + } + } + t.Fatalf("no Volvo in %v", list) + return nil + } + shows := func(name string, list map[string]any, full bool) { + t.Helper() + c := volvo(list) + if got := c["vin"] != nil && c["identifiers"] != nil && list["redacted"] == nil; got != full { + t.Errorf("%s: full = %v, want %v: %v", name, got, full, list) + } + if c["name"] != "Volvo" { + t.Errorf("%s: the name is part of every view: %v", name, c) + } + } + public := MutationPolicy{RequireTokenForRemote: true, Token: testMutationToken} + shows("a public name without the token", + read(public, "http://box.example.com/api/cars", "203.0.113.9:443", "", nil), false) + shows("a public name with the token", + read(public, "http://box.example.com/api/cars", "203.0.113.9:443", "Bearer "+testMutationToken, nil), true) + shows("the LAN with that policy", + read(public, "http://192.168.1.2:8080/api/cars", "192.168.1.10:43210", "", nil), true) + shows("the LAN with the house password off", + read(MutationPolicy{}, "http://ftw.local:8080/api/cars", "192.168.1.10:43210", "", nil), true) + house := lanAuthPolicy(testHousePassword) + shows("the LAN without the house password", + read(house, "http://ftw.local:8080/api/cars", "192.168.1.10:43210", "", nil), false) + shows("the LAN with the house password", + read(house, "http://ftw.local:8080/api/cars", "192.168.1.10:43210", "Bearer "+testHousePassword, nil), true) + shows("the box itself", + read(house, "http://127.0.0.1:8080/api/cars", "127.0.0.1:43210", "", nil), true) + for _, c := range []apiauth.Caller{roleCaller(apiauth.KindApp, apiauth.RoleWatch), roleCaller(apiauth.KindAgent, apiauth.RoleAgent), + roleCaller(apiauth.KindApp, apiauth.RoleViewer)} { + shows(c.Kind+" "+c.Role, read(public, "http://box.example.com/api/cars", "203.0.113.9:443", "", &c), false) + } + owner := roleCaller(apiauth.KindApp, apiauth.RoleOwner) + shows("the owner's phone", read(public, "http://box.example.com/api/cars", "203.0.113.9:443", "", &owner), true) +} diff --git a/go/internal/api/api_lan_auth_test.go b/go/internal/api/api_lan_auth_test.go index 274cd8069..6a43c40c4 100644 --- a/go/internal/api/api_lan_auth_test.go +++ b/go/internal/api/api_lan_auth_test.go @@ -418,7 +418,11 @@ func TestLANAuthNilVerifyIsOff(t *testing.T) { func newLANAuthServer(t *testing.T) *Server { t.Helper() - statePath := filepath.Join(t.TempDir(), "state.db") + return newLANAuthServerAt(t, filepath.Join(t.TempDir(), "state.db")) +} + +func newLANAuthServerAt(t *testing.T, statePath string) *Server { + t.Helper() st, err := state.Open(statePath) if err != nil { t.Fatal(err) diff --git a/go/internal/api/lan_auth.go b/go/internal/api/lan_auth.go index 99e68e4ae..845aef828 100644 --- a/go/internal/api/lan_auth.go +++ b/go/internal/api/lan_auth.go @@ -3,10 +3,12 @@ package api import ( "context" "crypto/rand" + "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "fmt" + "log/slog" "net/http" "strconv" "strings" @@ -27,7 +29,7 @@ const ( lanSessionCookieName = "ftw_lan" lanSessionBytes = 32 - lanSessionTTL = 12 * time.Hour + lanSessionTTL = 30 * 24 * time.Hour // Encoded in the stored hash so a later bump still verifies old rows. lanArgonTime uint32 = 3 @@ -81,12 +83,13 @@ func lanSessionValid(token string) bool { } lanSessionMu.Lock() defer lanSessionMu.Unlock() - sess, ok := lanSessions[token] + digest := lanSessionDigest(token) + sess, ok := lanSessions[digest] if !ok { return false } if !sess.expires.After(lanSessionNow()) { - delete(lanSessions, token) + delete(lanSessions, digest) return false } return true @@ -99,7 +102,7 @@ func issueLANSession() (string, error) { } token := hex.EncodeToString(raw) lanSessionMu.Lock() - lanSessions[token] = lanSession{expires: lanSessionNow().Add(lanSessionTTL)} + lanSessions[lanSessionDigest(token)] = lanSession{expires: lanSessionNow().Add(lanSessionTTL)} lanSessionMu.Unlock() return token, nil } @@ -109,10 +112,31 @@ func dropLANSession(token string) { return } lanSessionMu.Lock() - delete(lanSessions, token) + delete(lanSessions, lanSessionDigest(token)) lanSessionMu.Unlock() } +func lanSessionDigest(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func restoreLANSessions(st *state.Store) { + if st == nil { + return + } + lanSessionMu.Lock() + defer lanSessionMu.Unlock() + sessions, err := st.LoadLANSessions(lanSessionNow().UnixMilli()) + if err != nil { + slog.Warn("LAN sessions could not be restored") + return + } + for _, session := range sessions { + lanSessions[session.Digest] = lanSession{expires: time.UnixMilli(session.ExpiresMS)} + } +} + func dropAllLANSessions() { lanSessionMu.Lock() lanSessions = map[string]lanSession{} @@ -275,6 +299,16 @@ type lanAuthLoginRequest struct { } func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + var req lanAuthLoginRequest + if err := readJSON(r, &req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: " + err.Error()}) + return + } + // Serialize admission with password changes, so an old-password login + // cannot add a session after revocation. Do not hold this lock while + // reading an unauthenticated client's body: it also guards Settings. + s.configWriteMu.Lock() + defer s.configWriteMu.Unlock() if s.deps.State == nil || s.deps.Cfg == nil || s.deps.CfgMu == nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "config store unavailable"}) return @@ -290,14 +324,14 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "LAN password is not configured"}) return } - var req lanAuthLoginRequest - if err := readJSON(r, &req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: " + err.Error()}) + st := s.deps.State + encoded, found, err := st.ConfigValue(lanAuthPasswordKey) + if err != nil || !found { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "password store unavailable"}) return } - st := s.deps.State if !admitLANSecret(func(secret string) bool { - return VerifyStoredLANSecret(st, secret) + return verifyLANPassword(encoded, secret) }, req.Password) { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid password"}) return @@ -307,12 +341,24 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create session"}) return } + lanSessionMu.Lock() + now := lanSessionNow() + lanSessionMu.Unlock() + if err := st.SaveLANSession(lanSessionDigest(token), now.Add(lanSessionTTL).UnixMilli(), now.UnixMilli(), encoded); err != nil { + dropLANSession(token) + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "could not save session"}) + return + } setLANSessionCookie(w, token, int(lanSessionTTL/time.Second)) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { if token, ok := lanSessionCookieValue(r); ok { + if s.deps.State == nil || s.deps.State.DeleteLANSession(lanSessionDigest(token)) != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "could not revoke session"}) + return + } dropLANSession(token) } // MaxAge < 0 emits Max-Age=0 so the browser drops ftw_lan. @@ -409,6 +455,10 @@ func (s *Server) handleAuthPassword(w http.ResponseWriter, r *http.Request) { *s.deps.Cfg = cfgCopy s.deps.CfgMu.Unlock() dropAllLANSessions() + if err := s.deps.State.DeleteAllLANSessions(); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "could not revoke sessions"}) + return + } writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", diff --git a/go/internal/api/lan_auth_login_body_test.go b/go/internal/api/lan_auth_login_body_test.go new file mode 100644 index 000000000..137b67996 --- /dev/null +++ b/go/internal/api/lan_auth_login_body_test.go @@ -0,0 +1,67 @@ +package api + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type stalledLoginBody struct { + entered chan struct{} + release chan struct{} +} + +func (b *stalledLoginBody) Read(p []byte) (int, error) { + close(b.entered) + <-b.release + return 0, io.EOF +} +func (b *stalledLoginBody) Close() error { return nil } + +func TestLoginBodyDoesNotHoldConfigLock(t *testing.T) { + resetLANGuesses(t) + srv := newLANAuthServer(t) + enableStoredLANAuth(t, srv) + body := &stalledLoginBody{make(chan struct{}), make(chan struct{})} + req := lanAuthRequest(http.MethodPost, "http://ftw.local:8080/api/auth/login", "192.168.1.10:43210", "") + req.Header.Set("Content-Type", "application/json") + req.ContentLength = 100 + req.Body = body + done := make(chan struct{}) + go func() { srv.Handler().ServeHTTP(httptest.NewRecorder(), req); close(done) }() + writerDone := make(chan struct{}) + writerStatus := 0 + defer func() { + close(body.release) + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("login cleanup timed out") + } + select { + case <-writerDone: + case <-time.After(2 * time.Second): + t.Error("writer cleanup timed out") + } + }() + select { + case <-body.entered: + case <-time.After(time.Second): + t.Fatal("login did not start reading body") + } + go func() { + rr := postLANAuthJSON(srv, http.MethodPost, "http://ftw.local:8080/api/auth/password", "127.0.0.1:43210", `{"enabled":true}`) + writerStatus = rr.Code + close(writerDone) + }() + select { + case <-writerDone: + if writerStatus != http.StatusOK { + t.Fatalf("owner settings write status=%d", writerStatus) + } + case <-time.After(time.Second): + t.Fatal("unauthenticated login body blocks owner settings write") + } +} diff --git a/go/internal/api/lan_auth_persistence_test.go b/go/internal/api/lan_auth_persistence_test.go new file mode 100644 index 000000000..27ad9dc55 --- /dev/null +++ b/go/internal/api/lan_auth_persistence_test.go @@ -0,0 +1,165 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" +) + +func TestLANPasswordKeepsExplicitRemoteAPITokenOwner(t *testing.T) { + resetLANGuesses(t) + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + t.Fatal("create local test token failed") + } + policy := lanAuthPolicy(testHousePassword) + policy.RequireTokenForRemote = true + policy.Token = hex.EncodeToString(raw) + for _, valid := range []bool{true, false} { + bearer := policy.Token + if !valid { + bearer = "invalid-" + bearer + } + req := lanAuthRequest(http.MethodGet, "http://ftw.example.com/api/cars", "203.0.113.1:5123", "Bearer "+bearer) + var caller apiauth.Caller + serveLANAuth(policy, req, func(r *http.Request) { caller, _ = apiauth.FromRequest(r) }) + want := apiauth.RoleViewer + if valid { + want = apiauth.RoleOwner + } + if caller.Role != want { + t.Fatal("LAN password setting changed explicit API-token authority") + } + } +} + +func reopenLANServer(t *testing.T, server *Server, path string) *Server { + t.Helper() + if err := server.deps.State.Close(); err != nil { + t.Fatal("close session database failed") + } + resetLANSessions() // a new Core process starts without the cache + st, err := state.Open(path) + if err != nil { + t.Fatal("reopen session database failed") + } + t.Cleanup(func() { _ = st.Close() }) + deps := *server.deps + deps.State = st + deps.SaveConfig = func(path string, cfg *config.Config) error { return config.SaveStored(st, path, cfg) } + deps.MutationPolicy.VerifyLANSecret = func(secret string) bool { return VerifyStoredLANSecret(st, secret) } + return New(&deps) +} + +func cookieConfigStatus(server *Server, cookie *http.Cookie) int { + req := lanAuthRequest(http.MethodGet, "http://ftw.local:8080/api/config", "192.168.1.10:43210", "") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + server.Handler().ServeHTTP(rr, req) + return rr.Code +} + +func loginBrowser(t *testing.T, server *Server) *http.Cookie { + t.Helper() + rr := postLANAuthJSON(server, http.MethodPost, "http://ftw.local:8080/api/auth/login", "192.168.1.10:43210", `{"password":"`+testHousePassword+`"}`) + if rr.Code != http.StatusOK { + t.Fatal("browser login failed") + } + for _, cookie := range rr.Result().Cookies() { + if cookie.Name == lanSessionCookieName && cookie.Value != "" { + return cookie + } + } + t.Fatal("session cookie missing") + return nil +} + +func TestLANSessionSurvivesDatabaseReopen(t *testing.T) { + resetLANGuesses(t) + path := filepath.Join(t.TempDir(), "state.db") + server := newLANAuthServerAt(t, path) + enableStoredLANAuth(t, server) + cookie := loginBrowser(t, server) + server = reopenLANServer(t, server, path) + if cookieConfigStatus(server, cookie) != http.StatusOK { + t.Fatal("browser lost its login after a Core restart") + } +} + +func TestLANSessionCookieLastsThirtyDays(t *testing.T) { + resetLANGuesses(t) + server := newLANAuthServer(t) + enableStoredLANAuth(t, server) + start := time.Now() + lanSessionMu.Lock() + lanSessionNow = func() time.Time { return start } + lanSessionMu.Unlock() + cookie := loginBrowser(t, server) + if cookie.MaxAge != 30*24*60*60 || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { + t.Fatal("browser cookie must last 30 days and retain its protections") + } + lanSessionMu.Lock() + lanSessionNow = func() time.Time { return start.Add(29 * 24 * time.Hour) } + lanSessionMu.Unlock() + if cookieConfigStatus(server, cookie) != http.StatusOK { + t.Fatal("session expired before 30 days") + } + lanSessionMu.Lock() + lanSessionNow = func() time.Time { return start.Add(30 * 24 * time.Hour) } + lanSessionMu.Unlock() + if cookieConfigStatus(server, cookie) != http.StatusUnauthorized { + t.Fatal("expired session remained authorized") + } +} + +func TestLANSessionLogoutStaysRevokedAfterReopen(t *testing.T) { + resetLANGuesses(t) + path := filepath.Join(t.TempDir(), "state.db") + server := newLANAuthServerAt(t, path) + enableStoredLANAuth(t, server) + cookie := loginBrowser(t, server) + req := lanAuthRequest(http.MethodPost, "http://ftw.local:8080/api/auth/logout", "192.168.1.10:43210", "") + req.Header.Set("Content-Type", "application/json") + req.Body = http.NoBody + req.AddCookie(cookie) + rr := httptest.NewRecorder() + server.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatal("logout failed") + } + server = reopenLANServer(t, server, path) + if cookieConfigStatus(server, cookie) != http.StatusUnauthorized { + t.Fatal("logged-out session returned after restart") + } +} + +func TestLANSessionPasswordChangeStaysRevokedAfterReopen(t *testing.T) { + resetLANGuesses(t) + path := filepath.Join(t.TempDir(), "state.db") + server := newLANAuthServerAt(t, path) + enableStoredLANAuth(t, server) + cookie := loginBrowser(t, server) + req := lanAuthRequest(http.MethodPost, "http://ftw.local:8080/api/auth/password", "127.0.0.1:43210", "") + req.Header.Set("Content-Type", "application/json") + req.Body = http.NoBody + req.Body = io.NopCloser(strings.NewReader(`{"enabled":true,"password":"` + testHousePassword + `-changed"}`)) + rr := httptest.NewRecorder() + server.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatal("password change failed") + } + server = reopenLANServer(t, server, path) + if cookieConfigStatus(server, cookie) != http.StatusUnauthorized { + t.Fatal("old-password session returned after restart") + } +} diff --git a/go/internal/api/security.go b/go/internal/api/security.go index db1b5cda0..20848e9cb 100644 --- a/go/internal/api/security.go +++ b/go/internal/api/security.go @@ -120,6 +120,26 @@ func Authenticate(next http.Handler, policy MutationPolicy) http.Handler { }) } +// remoteProved reports whether a request has shown what a protected request +// must: it came from a local client to a local host name, or it carries +// FTW_API_TOKEN where the policy asks remote callers for it. A read that is +// open to anyone uses it to leave out what identifies a person's property, +// such as a car's VIN, which a protected request would have had to prove +// itself to see. +func remoteProved(r *http.Request, policy MutationPolicy) bool { + if !policy.RequireTokenForRemote { + return true + } + reqAuthority, err := parseAuthority(r.Host) + if err != nil { + return false + } + if isLocalAuthority(reqAuthority) && isLocalClient(r.RemoteAddr) { + return true + } + return strings.TrimSpace(policy.Token) != "" && validBearerToken(r.Header.Get("Authorization"), policy.Token) +} + func lanAuthOn(policy MutationPolicy) bool { return policy.LANAuthEnabled != nil && policy.LANAuthEnabled() } @@ -134,7 +154,8 @@ func lanAuthExempt(path string) bool { } func decideLANCaller(r *http.Request, policy MutationPolicy, houseOK bool) apiauth.Caller { - if !lanAuthOn(policy) || isLoopbackClient(r.RemoteAddr) || houseOK { + if !lanAuthOn(policy) || isLoopbackClient(r.RemoteAddr) || houseOK || + validBearerToken(r.Header.Get("Authorization"), policy.Token) { return localCaller(r) } return lanViewerCaller(r) diff --git a/go/internal/appproto/cars.go b/go/internal/appproto/cars.go index 653dc3d5e..ecbc7fc74 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 @@ -335,6 +343,12 @@ func optBool(args map[string]any, k string) (*bool, string) { return &b, "" } +// CarAddArgs and CarUpdateArgs read car.add's and car.update's arguments as +// the ops do, for the box page's routes. bad names an argument they refuse. +func CarAddArgs(args map[string]any) (car protocol.Car, bad string) { return carArgs(args) } + +func CarUpdateArgs(args map[string]any) (u cars.Update, bad string) { return carUpdateArgs(args) } + // carArgs reads car.add: id, which the owner's app mints, and the car's own // fields without the level. func carArgs(args map[string]any) (protocol.Car, string) { diff --git a/go/internal/state/lan_sessions.go b/go/internal/state/lan_sessions.go new file mode 100644 index 000000000..54acb2306 --- /dev/null +++ b/go/internal/state/lan_sessions.go @@ -0,0 +1,103 @@ +package state + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "time" +) + +// LANSession stores a digest of an opaque browser token, never the token. +// Bind it to the password that admitted it, so a password change invalidates +// old rows even if the process stops before their cleanup commits. +type LANSession struct { + Digest string + ExpiresMS int64 +} + +func lanPasswordBinding(encoded string) string { + sum := sha256.Sum256([]byte(encoded)) + return hex.EncodeToString(sum[:]) +} + +func (s *Store) SaveLANSession(digest string, expiresMS, nowMS int64, verifiedPassword string) error { + if len(digest) != 64 || verifiedPassword == "" || expiresMS <= nowMS { + return errors.New("invalid LAN session") + } + return s.durableConfigWrite(func(tx *sql.Tx) error { + var current string + if err := tx.QueryRow(`SELECT value FROM config WHERE key='lan_auth_password'`).Scan(¤t); err != nil { + return err + } + if current != verifiedPassword { + return errors.New("house password changed during login") + } + if _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS lan_auth_sessions ( + token_digest TEXT PRIMARY KEY, expires_ms INTEGER NOT NULL, password_binding TEXT NOT NULL)`); err != nil { + return err + } + binding := lanPasswordBinding(current) + if _, err := tx.Exec(`DELETE FROM lan_auth_sessions WHERE expires_ms <= ? OR password_binding <> ?`, nowMS, binding); err != nil { + return err + } + _, err := tx.Exec(`INSERT INTO lan_auth_sessions(token_digest,expires_ms,password_binding) VALUES(?,?,?)`, digest, expiresMS, binding) + return err + }) +} + +func (s *Store) LoadLANSessions(nowMS int64) ([]LANSession, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var exists int + if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_master WHERE type='table' AND name='lan_auth_sessions'`).Scan(&exists); err != nil || exists == 0 { + return nil, err + } + var password string + if err := s.db.QueryRowContext(ctx, `SELECT value FROM config WHERE key='lan_auth_password'`).Scan(&password); errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + if password == "" { + return nil, nil + } + rows, err := s.db.QueryContext(ctx, `SELECT token_digest,expires_ms FROM lan_auth_sessions WHERE expires_ms > ? AND password_binding = ?`, nowMS, lanPasswordBinding(password)) + if err != nil { + return nil, err + } + defer rows.Close() + var sessions []LANSession + for rows.Next() { + var session LANSession + if err := rows.Scan(&session.Digest, &session.ExpiresMS); err != nil { + return nil, err + } + sessions = append(sessions, session) + } + return sessions, rows.Err() +} + +func (s *Store) DeleteLANSession(digest string) error { + return s.deleteLANSessions(&digest) +} + +func (s *Store) DeleteAllLANSessions() error { + return s.deleteLANSessions(nil) +} + +func (s *Store) deleteLANSessions(digest *string) error { + return s.durableConfigWrite(func(tx *sql.Tx) error { + var exists int + if err := tx.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name='lan_auth_sessions'`).Scan(&exists); err != nil || exists == 0 { + return err + } + if digest == nil { + _, err := tx.Exec(`DELETE FROM lan_auth_sessions`) + return err + } + _, err := tx.Exec(`DELETE FROM lan_auth_sessions WHERE token_digest=?`, *digest) + return err + }) +} diff --git a/go/internal/state/lan_sessions_test.go b/go/internal/state/lan_sessions_test.go new file mode 100644 index 000000000..3c5a61c93 --- /dev/null +++ b/go/internal/state/lan_sessions_test.go @@ -0,0 +1,65 @@ +package state + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "path/filepath" + "testing" + "time" +) + +func TestLANSessionStorageKeepsOnlyDigestAndSurvivesReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + s, err := Open(path) + if err != nil { + t.Fatal("open database failed") + } + passwordVersion := hex.EncodeToString(make([]byte, 32)) + if err := s.SaveConfig("lan_auth_password", passwordVersion); err != nil { + t.Fatal("save password version failed") + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + t.Fatal("make opaque token failed") + } + token := hex.EncodeToString(raw) + sum := sha256.Sum256([]byte(token)) + digest := hex.EncodeToString(sum[:]) + now := time.Now().UnixMilli() + expires := now + (30 * 24 * time.Hour).Milliseconds() + if err := s.SaveLANSession(digest, expires, now, passwordVersion); err != nil { + t.Fatal("save session failed") + } + var rawCount int + if err := s.db.QueryRow(`SELECT count(*) FROM lan_auth_sessions WHERE token_digest=? OR password_binding=?`, token, token).Scan(&rawCount); err != nil || rawCount != 0 { + t.Fatal("raw token must not be stored") + } + if err := s.Close(); err != nil { + t.Fatal("close database failed") + } + s, err = Open(path) + if err != nil { + t.Fatal("reopen database failed") + } + defer s.Close() + loaded, err := s.LoadLANSessions(now) + if err != nil || len(loaded) != 1 || loaded[0].Digest != digest || loaded[0].ExpiresMS != expires { + t.Fatal("saved session changed across reopen") + } + loaded, err = s.LoadLANSessions(expires) + if err != nil || len(loaded) != 0 { + t.Fatal("expired session must not load") + } + // Model a crash after the password commits but before session cleanup. + if err := s.SaveConfig("lan_auth_password", passwordVersion+"-changed"); err != nil { + t.Fatal("change password version failed") + } + loaded, err = s.LoadLANSessions(now) + if err != nil || len(loaded) != 0 { + t.Fatal("old-password session survived an interrupted revocation") + } + if err := s.SaveLANSession(digest, expires, now, passwordVersion); err == nil { + t.Fatal("old-password login raced through a password change") + } +} diff --git a/web/app.css b/web/app.css index 9ba175b66..7eb947ef2 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; @@ -2534,6 +2658,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 2122eca90..5220659b2 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); @@ -2164,6 +2165,36 @@ // 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; + // Where a car is comes from the chargers' read, which can fail while the + // cars' read answers: each list has its own time. + var loadpointsFetchedAt = 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, + chargersFresh: !document.hidden && Date.now() - loadpointsFetchedAt < 15000, + }); + }).catch(function (err) { console.error("cars:", err); }); + } var chargingNoticePoints = []; var chargingNoticeRows = new Map(); var chargingNoticeTimer = null; @@ -2203,11 +2234,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" : ""); @@ -2219,6 +2261,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); @@ -2243,6 +2286,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) { @@ -2253,19 +2298,25 @@ .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. try { updateStorageBanner(health && health.storage); } catch (eSb) { /* silent */ } if (lp && Array.isArray(lp.loadpoints)) { + loadpointsFetchedAt = Date.now(); var idx = {}; lp.loadpoints.forEach(function (l) { if (l && l.driver_name) idx[l.driver_name] = l; }); 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..425fe181b --- /dev/null +++ b/web/cars.js @@ -0,0 +1,277 @@ +// 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'; +} + +// tapRefusedText says why a tap did not land. status is the HTTP status: +// a 401 is a device that has not signed in with the house password (the +// page asked and the person cancelled), not a refusal of the car. +export function tapRefusedText(body, status) { + if (status === 401) return 'Choosing the car needs the house password. Sign in and try again.'; + if (status === 403) return 'This device may not choose the car.'; + return (body && TAP_REFUSED[body.reason]) || 'The box did not take that. Try again.'; +} + +// TAP_TIMEOUT_MS is how long a tap waits for the box, as the EV modal's +// writes do. +export const TAP_TIMEOUT_MS = 30000; + +// tap sends session.car.set for the plug-in the page shows. An empty carId +// is "None of these", a visitor's car. It gives up after TAP_TIMEOUT_MS. +export async function tap(fetchFn, lp, carId, timeoutMs = TAP_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + 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 }), signal: controller.signal, + }); + if (r.ok) return { ok: true }; + const body = await r.json().catch(() => null); + return { ok: false, text: tapRefusedText(body, r.status) }; + } catch { + return { ok: false, text: 'The box did not answer. Try again.' }; + } finally { + clearTimeout(timer); + } +} + +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. chargersFresh is false while the chargers' own +// read is old: the car's level still stands, but where it is does not. +export function carTiles(cars = [], loadpoints = [], sinceS = 0, chargersFresh = true) { + const names = Object.fromEntries(cars.map(c => [c.id, c.name])); + return cars.map(car => { + const lp = chargersFresh ? chargerOf(car, loadpoints) : null; + const level = levelView(car, sinceS); + const facts = []; + // The limit is the car's, read with its level; only beside that reading + // does it say how old it is. + if (car.soc_source === 'vehicle' && 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 || !chargersFresh ? [] : loadpoints.filter(o => asking(o) && Array.isArray(o.choose) && o.choose.includes(car.id)); + // A charger that has gone quiet cannot say the car is still on it. + const quiet = !!(lp && lp.charger && lp.charger.available === false); + const where = !chargersFresh || quiet ? 'Charger status unknown' + : lp ? `On ${lp.id}` : maybe.length ? `On ${maybe.map(o => o.id).join(' or ')}?` : 'Not plugged in'; + const on = !!lp && !quiet; + return { + id: car.id, name: car.name, where, + level: level.value, estimate: level.estimate, source: level.source, facts: facts.join(' · '), + charger: on ? { id: lp.id, driver_name: lp.driver_name } : null, knownBy: on ? 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, chargersFresh = 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, fresh && chargersFresh); + // 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..7eb4fdb62 --- /dev/null +++ b/web/cars.test.mjs @@ -0,0 +1,316 @@ +// 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, TAP_TIMEOUT_MS, +} 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('says the charger status is unknown when the chargers’ read is old', () => { + // The cars answered, the chargers did not: the level stands, the place does not. + const [, v] = carTiles([tesla, volvo], [garage], 0, false); + assert.equal(v.where, 'Charger status unknown'); + assert.equal(v.charger, null); + assert.equal(v.knownBy, ''); + const [, quiet] = carTiles([tesla, volvo], [{ ...garage, charger: { available: false } }]); + assert.equal(quiet.where, 'Charger status unknown', 'a charger that went quiet cannot say the car is on it'); + assert.equal(quiet.charger, null); + }); + + it('shows the charge limit only beside the car’s own reading', () => { + assert.match(carTiles([tesla])[0].facts, /^Limit 80%/); + assert.doesNotMatch(carTiles([{ ...tesla, soc_source: 'manual' }])[0].facts, /Limit/); + assert.doesNotMatch(carTiles([{ ...tesla, soc_source: 'assumed' }])[0].facts, /Limit/); + }); + + 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 a tap needs the house password rather than that the box refused it', () => { + assert.match(tapRefusedText({ error: 'valid LAN password required' }, 401), /house password/); + assert.equal(tapRefusedText({ error: 'only an owner' }, 403), 'This device may not choose the car.'); + }); + + it('gives up on a tap the box does not answer', async () => { + const fetchFn = (path, opts) => new Promise((_, reject) => { + opts.signal.addEventListener('abort', () => reject(new Error('aborted'))); + }); + assert.deepEqual(await tap(fetchFn, { id: 'garage', session: 's1' }, kia.id, 10), + { ok: false, text: 'The box did not answer. Try again.' }); + assert.equal(TAP_TIMEOUT_MS, 30000, 'as long as the EV modal waits'); + }); + + 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, status: 409, 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('dates the chargers’ read apart from the cars’', () => { + assert.match(app, /loadpointsFetchedAt = Date\.now\(\)/); + assert.match(app, /chargersFresh: !document\.hidden && Date\.now\(\) - loadpointsFetchedAt < 15000/); + }); + + 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 a0fe4d7ef..9e6c066fc 100644 --- a/web/index.html +++ b/web/index.html @@ -95,6 +95,7 @@ Settings Control Devices Chargers + Cars Price Weather Batteries @@ -217,6 +218,9 @@ Power now + + + @@ -998,6 +1002,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..377f0a116 --- /dev/null +++ b/web/settings/tabs/cars.js @@ -0,0 +1,318 @@ +// 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, redacted: false, 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. redacted is the + // box's word that it left these out for this device, which has not signed + // in; then they are hidden, not missing. + function knownBy(car, redacted) { + if (redacted) { + var hidden = "Hidden until this device signs in with the house password"; + return [["VIN", hidden], ["Level from", hidden], ["Charger tags", hidden]]; + } + 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.redacted = !!(data && data.redacted); + 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, state.redacted).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" + + '' + + 'Name' + + 'Battery, kWh' + + 'VIN, optional' + + 'Add 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..85997c274 --- /dev/null +++ b/web/settings/tabs/cars.test.mjs @@ -0,0 +1,121 @@ +// 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("says they are hidden when the box left them out for this device", () => { + const rows = P.knownBy({ name: "Model Y" }, true); + assert.deepEqual(rows.map((r) => r[0]), ["VIN", "Level from", "Charger tags"]); + for (const [, v] of rows) assert.match(v, /^Hidden/); + assert.match(source, /state\.redacted = !!\(data && data\.redacted\)/); + }); + + 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*Cars<\/button>/); + assert.match(index, /
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…
The VIN lets a source that reports it, such as Home Assistant or the maker\'s ' + + "service, give this car its level.