diff --git a/API Server/README.md b/API Server/README.md index 63e6cb3..7ba5939 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -119,11 +119,13 @@ other users `read` or `write` access. Every car/service/part handler is gated by | `reminders` | date/odometer reminders (some auto-derived) | car, title, type, due_date, due_km, repeat_days, repeat_km, done, done_at, notes | | `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) | | `organizations` | tenants | name (unique) | +| `home_chargers` | the chargers a user owns, imported from a connected charger service | name, serial, vendor, model, site_name, power_kw, connector, `provider`, `provider_charger_id`, owner | | `control_audit` | OCPP control-command audit trail | user, charger, action, result, timestamp | | `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin \| superadmin), `organization`, bio, theme, locale, date_format, currency, font_size, deletion_requested_at | Every record collection except `car_shares` / `organizations` / `control_audit` -carries **one optional file attachment**, served only through the API Server +/ `home_chargers` carries **one optional file attachment**, served only through +the API Server (`GET /api/{records}/{id}/file`) — never a public PocketBase URL. **Spreadsheet formulas** (from the original `Car Service.xlsx`), reproduced by @@ -194,6 +196,12 @@ GET /api/vehicle-providers GET /api/vehicle-providers/{provider}/vehicles POST /api/vehicle-providers/{provider}/import +# charger providers — create a home charger from a connected charger service +GET /api/charger-providers +GET /api/charger-providers/{provider}/chargers +POST /api/charger-providers/{provider}/import +GET /api/home-chargers PATCH /api/home-chargers/{id} DELETE /api/home-chargers/{id} + # cars + sharing (GET /api/cars returns the garage in the user's saved order, # which PATCH /api/me {carOrder} sets) GET /api/cars POST /api/cars diff --git a/API Server/internal/api/chargerproviders.go b/API Server/internal/api/chargerproviders.go new file mode 100644 index 0000000..972e4fe --- /dev/null +++ b/API Server/internal/api/chargerproviders.go @@ -0,0 +1,370 @@ +package api + +// Charger providers are the garage's import aimed at the wall instead of the +// driveway: a charger already on a service the user has connected becomes a +// charger in DriverVault, without anyone copying a serial off a label. +// +// GET /api/charger-providers — providers, with connect state +// GET /api/charger-providers/{provider}/chargers — the caller's chargers there +// POST /api/charger-providers/{provider}/import — create a home charger from one +// +// The shape deliberately follows vehicleproviders.go — a chargerSource is a +// small adapter over an existing plugin plus its per-user credential cascade, so +// the next charger service is one adapter appended to chargerSources() and +// nothing else. What differs is how little is imported: a car pulls identity, +// dates and an odometer from several capabilities, whereas a charger is a name, +// a serial and the hardware behind it, all of which the list already carries. So +// there is no include selection here and no second round of calls. +// +// Credentials are the caller's own, resolved through the same global → org → +// user cascade the Settings page edits. Nothing borrows another user's account. + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + + "drivervault/apiserver/internal/models" +) + +// chargerSource adapts one plugin that can enumerate the caller's chargers. +type chargerSource interface { + // id is the URL segment and the value persisted on home_charger.provider. + id() string + // label names the provider to the user ("Anker Solix"). + label() string + // service is the upstream service behind it ("Anker Solix cloud"). + service() string + // gate resolves the caller's effective config from the integration cascade. + // When ok is false nothing is called and detail says, in one sentence, what + // the user has to do about it. userRaw is the caller's pluginSettings blob, + // passed in so one request reads it once. + gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (cfg map[string]string, ok bool, detail string) + // listAction is the plugin capability that enumerates chargers. + listAction() string + // chargers maps that capability's payload onto normalized entries. + chargers(raw json.RawMessage) []providerCharger +} + +// chargerSources are the registered providers, in menu order. +func chargerSources() []chargerSource { + return []chargerSource{ankerChargerSource{}, greencellChargerSource{}} +} + +func chargerSourceByID(id string) (chargerSource, bool) { + for _, src := range chargerSources() { + if src.id() == id { + return src, true + } + } + return nil, false +} + +// providerCharger is one charger on the caller's provider account, normalized +// into the fields a home charger is built from. +type providerCharger struct { + ID string `json:"id"` // the provider's own id — the serial, for both + Name string `json:"name"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + SiteName string `json:"siteName,omitempty"` + Status string `json:"status,omitempty"` // the service's own word for its state + Online *bool `json:"online,omitempty"` + + // LinkedChargerID is set when this one is already in DriverVault, so the UI + // never offers to import the same charger twice. + LinkedChargerID string `json:"linkedChargerId,omitempty"` +} + +// --- the providers ------------------------------------------------------------ + +// ankerChargerSource imports from the Anker Solix cloud. The chargers capability +// merges the cloud's several views of an account (see the plugin's chargers.go), +// so a charger arrives here whether it stands alone or belongs to a system. +type ankerChargerSource struct{} + +func (ankerChargerSource) id() string { return ankerPlugin } +func (ankerChargerSource) label() string { return "Anker Solix" } +func (ankerChargerSource) service() string { return "Anker Solix cloud" } +func (ankerChargerSource) listAction() string { return "chargers" } + +func (ankerChargerSource) gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (map[string]string, bool, string) { + res := s.resolveAnker(ctx, who, userRaw) + if reason := ankerGate(res, true); reason != "" { + return nil, false, reason + } + return map[string]string{ + "email": res.eff.Email, + "password": res.eff.Password, + "country": res.eff.Country, + }, true, "" +} + +func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger { + var env struct { + Chargers []struct { + SN string `json:"sn"` + Name string `json:"name"` + Model string `json:"model"` + SiteName string `json:"siteName"` + StatusDesc string `json:"statusDesc"` + Online *bool `json:"online"` + } `json:"chargers"` + } + if json.Unmarshal(raw, &env) != nil { + return nil + } + out := make([]providerCharger, 0, len(env.Chargers)) + for _, c := range env.Chargers { + if c.SN == "" { + continue + } + out = append(out, providerCharger{ + ID: c.SN, Name: c.Name, Vendor: "Anker Solix", Model: c.Model, + SiteName: c.SiteName, Status: c.StatusDesc, Online: c.Online, + }) + } + return out +} + +// greencellChargerSource imports from a Greencell wallbox on the user's own MQTT +// broker. There is no cloud account behind it — the charger announces itself on +// the broker, which is why this provider can be connected while offering nothing +// until a charger answers. +type greencellChargerSource struct{} + +func (greencellChargerSource) id() string { return greencellPlugin } +func (greencellChargerSource) label() string { return "Greencell" } +func (greencellChargerSource) service() string { return "Greencell (MQTT broker)" } +func (greencellChargerSource) listAction() string { return "chargers" } + +func (greencellChargerSource) gate(ctx context.Context, s *Server, who *callerIdentity, userRaw json.RawMessage) (map[string]string, bool, string) { + res := s.resolveGreencell(ctx, who, userRaw) + if reason := greencellGate(res, true); reason != "" { + return nil, false, reason + } + return greencellPluginConfig(res), true, "" +} + +func (greencellChargerSource) chargers(raw json.RawMessage) []providerCharger { + var env struct { + Chargers []struct { + SN string `json:"sn"` + Name string `json:"name"` + Model string `json:"model"` + } `json:"chargers"` + } + if json.Unmarshal(raw, &env) != nil { + return nil + } + out := make([]providerCharger, 0, len(env.Chargers)) + for _, c := range env.Chargers { + if c.SN == "" { + continue + } + out = append(out, providerCharger{ID: c.SN, Name: c.Name, Vendor: "Greencell", Model: c.Model}) + } + return out +} + +// --- handlers ----------------------------------------------------------------- + +// GET /api/charger-providers — every provider with whether the caller can use it +// and, when they cannot, the one sentence that says what to do about it. Never +// an error: an unconnected provider is a normal state with an answer. +func (s *Server) handleListChargerProviders(w http.ResponseWriter, r *http.Request) { + who := caller(r) + if who == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + userRaw := s.userPluginSettings(r.Context(), who.ID) + + out := make([]map[string]any, 0, len(chargerSources())) + for _, src := range chargerSources() { + _, ok, detail := src.gate(r.Context(), s, who, userRaw) + out = append(out, map[string]any{ + "id": src.id(), + "label": src.label(), + "service": src.service(), + "connected": ok, + "detail": detail, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"providers": out}) +} + +// resolveChargerSource looks up the provider named in the path and gates it for +// the caller. On any failure it writes the response and returns ok=false. +// +// softGate picks how a closed gate is reported, exactly as the vehicle side does: +// a listing answers 200 with an empty list and a reason, so the UI can say +// "connect this in Settings"; a write answers 400, because there the caller asked +// for something that did not happen. +func (s *Server) resolveChargerSource(w http.ResponseWriter, r *http.Request, softGate bool) (chargerSource, map[string]string, bool) { + who := caller(r) + if who == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return nil, nil, false + } + src, found := chargerSourceByID(r.PathValue("provider")) + if !found { + writeError(w, http.StatusNotFound, "unknown charger provider") + return nil, nil, false + } + userRaw := s.userPluginSettings(r.Context(), who.ID) + cfg, ok, detail := src.gate(r.Context(), s, who, userRaw) + if !ok { + if softGate { + writeJSON(w, http.StatusOK, map[string]any{ + "provider": src.id(), + "label": src.label(), + "service": src.service(), + "chargers": []any{}, + "unavailable": true, + "detail": detail, + }) + } else { + writeError(w, http.StatusBadRequest, detail) + } + return nil, nil, false + } + return src, cfg, true +} + +// GET /api/charger-providers/{provider}/chargers — the chargers on that account, +// each annotated with the DriverVault charger it is already linked to. +func (s *Server) handleProviderChargers(w http.ResponseWriter, r *http.Request) { + src, cfg, ok := s.resolveChargerSource(w, r, true) + if !ok { + return + } + chargers, err := s.fetchProviderChargers(r.Context(), src, cfg) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + linked := s.linkedChargers(r.Context(), s.currentUserID(r), src.id()) + for i := range chargers { + chargers[i].LinkedChargerID = linked[chargers[i].ID] + } + writeJSON(w, http.StatusOK, map[string]any{ + "provider": src.id(), + "label": src.label(), + "service": src.service(), + "chargers": chargers, + }) +} + +// fetchProviderChargers invokes the provider's list capability and normalizes it. +func (s *Server) fetchProviderChargers(ctx context.Context, src chargerSource, cfg map[string]string) ([]providerCharger, error) { + raw, err := s.plugins.InvokeWith(ctx, src.id(), cfg, src.listAction(), nil) + if err != nil { + return nil, err + } + return src.chargers(raw), nil +} + +// linkedChargers maps provider charger id -> home charger id for one user and +// provider. Best effort: an unreachable PocketBase yields an empty map and the +// UI simply shows nothing as linked. +func (s *Server) linkedChargers(ctx context.Context, userID, provider string) map[string]string { + out := map[string]string{} + if userID == "" || provider == "" { + return out + } + res, err := s.pb.List(ctx, colHomeChargers, url.Values{ + "filter": {"owner='" + userID + "' && provider='" + provider + "'"}, + "perPage": {"200"}, + }) + if err != nil { + return out + } + var recs []homeChargerRecord + if json.Unmarshal(res.Items, &recs) != nil { + return out + } + for _, rec := range recs { + if rec.ProviderChargerID != "" { + out[rec.ProviderChargerID] = rec.ID + } + } + return out +} + +// POST /api/charger-providers/{provider}/import — create a home charger from one +// on the account. Body: {chargerId, name?}. The name defaults to what the service +// calls it, then to the provider's own label, so a charger is never nameless. +func (s *Server) handleChargerImport(w http.ResponseWriter, r *http.Request) { + var body struct { + ChargerID string `json:"chargerId"` + Name string `json:"name"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + src, cfg, ok := s.resolveChargerSource(w, r, false) + if !ok { + return + } + chargers, err := s.fetchProviderChargers(r.Context(), src, cfg) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + charger, found := findProviderCharger(chargers, body.ChargerID) + if !found { + writeError(w, http.StatusNotFound, "that charger is not on your "+src.label()+" account") + return + } + + me := s.currentUserID(r) + if existing := s.linkedChargers(r.Context(), me, src.id())[charger.ID]; existing != "" { + writeJSON(w, http.StatusConflict, map[string]any{ + "error": "this charger is already in DriverVault", + "chargerId": existing, + }) + return + } + + hc := models.HomeCharger{ + Name: strings.TrimSpace(body.Name), + Serial: charger.ID, + Vendor: charger.Vendor, + Model: charger.Model, + SiteName: charger.SiteName, + } + if hc.Name == "" { + hc.Name = strings.TrimSpace(charger.Name) + } + if hc.Name == "" { + hc.Name = src.label() + " charger" + } + + payload := homeChargerPayload(hc) + payload["owner"] = me + payload["provider"] = src.id() + payload["provider_charger_id"] = charger.ID + + var rec homeChargerRecord + if err := s.pb.Create(r.Context(), colHomeChargers, payload, &rec); err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"charger": rec.toModel()}) +} + +// findProviderCharger locates a charger by the provider's id, matched case +// insensitively — a serial is often typed or pasted in the case it was printed. +func findProviderCharger(list []providerCharger, id string) (providerCharger, bool) { + id = strings.TrimSpace(id) + for _, c := range list { + if strings.EqualFold(c.ID, id) { + return c, true + } + } + return providerCharger{}, false +} diff --git a/API Server/internal/api/chargerproviders_test.go b/API Server/internal/api/chargerproviders_test.go new file mode 100644 index 0000000..8545b7c --- /dev/null +++ b/API Server/internal/api/chargerproviders_test.go @@ -0,0 +1,114 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestChargerSourceByID(t *testing.T) { + for _, id := range []string{ankerPlugin, greencellPlugin} { + src, ok := chargerSourceByID(id) + if !ok { + t.Fatalf("charger provider %q is not registered", id) + } + if src.id() != id || src.label() == "" || src.service() == "" { + t.Fatalf("provider %q describes itself badly: %+v", id, src) + } + } + if _, ok := chargerSourceByID("toyota"); ok { + t.Fatal("a vehicle provider must not resolve as a charger provider") + } +} + +// The Anker mapping reads the merged inventory the plugin's chargers capability +// answers with — the same document the Settings list renders. +func TestAnkerChargerSourceMapping(t *testing.T) { + raw := json.RawMessage(`{"chargers":[ + {"sn":"AT2DK1","name":"Garage","model":"A5191","siteName":"Home","statusDesc":"charging","online":true,"sources":["site"]}, + {"sn":"AT2DK2","model":"A5191","online":false}, + {"sn":"","name":"nameless"}],"count":3}`) + got := ankerChargerSource{}.chargers(raw) + if len(got) != 2 { + t.Fatalf("got %d chargers, want 2 (the serial-less one is skipped)", len(got)) + } + first := got[0] + if first.ID != "AT2DK1" || first.Name != "Garage" || first.Model != "A5191" { + t.Fatalf("first charger = %+v", first) + } + if first.Vendor != "Anker Solix" || first.SiteName != "Home" || first.Status != "charging" { + t.Fatalf("first charger not fully mapped: %+v", first) + } + if first.Online == nil || !*first.Online { + t.Fatalf("online = %v, want true", first.Online) + } + if got[1].Online == nil || *got[1].Online { + t.Fatalf("second charger online = %v, want false", got[1].Online) + } + if (ankerChargerSource{}).chargers(json.RawMessage(`not json`)) != nil { + t.Fatal("a malformed payload should map to no chargers, not a panic") + } +} + +func TestGreencellChargerSourceMapping(t *testing.T) { + raw := json.RawMessage(`{"chargers":[ + {"sn":"GC-1","name":"Wallbox","model":"HabuDen"}, + {"name":"no serial"}]}`) + got := greencellChargerSource{}.chargers(raw) + if len(got) != 1 { + t.Fatalf("got %d chargers, want 1", len(got)) + } + if got[0].ID != "GC-1" || got[0].Name != "Wallbox" || got[0].Vendor != "Greencell" { + t.Fatalf("charger = %+v", got[0]) + } +} + +func TestFindProviderCharger(t *testing.T) { + list := []providerCharger{{ID: "AT2DK1"}, {ID: "GC-1"}} + if _, ok := findProviderCharger(list, " at2dk1 "); !ok { + t.Fatal("a serial should match whatever case and padding it arrives in") + } + if _, ok := findProviderCharger(list, "AT2DK9"); ok { + t.Fatal("a serial not on the account must not match") + } +} + +// The import payload carries the provider link and the owner; a later rename +// only ever touches the name, so the link cannot be broken by editing. +func TestHomeChargerPayloadOmitsProviderLink(t *testing.T) { + payload := homeChargerPayload(homeChargerRecord{Name: "Garage", Serial: "AT2DK1"}.toModel()) + for _, key := range []string{"provider", "provider_charger_id", "owner"} { + if _, present := payload[key]; present { + t.Fatalf("payload must not carry %q — only the import endpoint sets it", key) + } + } + if payload["name"] != "Garage" || payload["serial"] != "AT2DK1" { + t.Fatalf("payload = %+v", payload) + } +} + +func TestHomeChargerRecordToModel(t *testing.T) { + rec := homeChargerRecord{ + ID: "rec1", Name: "Garage", Serial: "AT2DK1", Vendor: "Anker Solix", Model: "A5191", + SiteName: "Home", PowerKw: 11, Connector: "Type 2", + Provider: ankerPlugin, ProviderChargerID: "AT2DK1", Owner: "u1", Created: "2026-08-31 10:00:00.000Z", + } + m := rec.toModel() + if m.ID != "rec1" || m.Name != "Garage" || m.Serial != "AT2DK1" || m.PowerKw != 11 { + t.Fatalf("model = %+v", m) + } + if m.Provider != ankerPlugin || m.ProviderChargerID != "AT2DK1" { + t.Fatalf("provider link lost: %+v", m) + } + // The owner is not part of what a client reads back. + b, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + if _, present := out["owner"]; present { + t.Fatalf("home charger JSON should not carry an owner: %s", b) + } +} diff --git a/API Server/internal/api/homechargers.go b/API Server/internal/api/homechargers.go new file mode 100644 index 0000000..2fc0d9f --- /dev/null +++ b/API Server/internal/api/homechargers.go @@ -0,0 +1,164 @@ +package api + +// A home charger is the wallbox a user owns, stored the way a car is: a record +// of their own, listed on the Charging page, kept after the account it came from +// is disconnected. It belongs to the person rather than to a car — it charges +// whichever car is plugged into it, and it outlives any of them — so there is no +// car relation here and no sharing: everyone sees their own chargers only. +// +// GET /api/home-chargers — the caller's chargers +// PATCH /api/home-chargers/{id} — rename one +// DELETE /api/home-chargers/{id} — forget one +// +// Creating one is the business of chargerproviders.go: a charger is imported +// from a service the user has connected, the same move the garage makes for a +// car. Nothing here writes the provider link, so a rename cannot silently break +// it. + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "drivervault/apiserver/internal/models" +) + +// homeChargerRecord is the PocketBase-facing shape of a home charger. +type homeChargerRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Serial string `json:"serial"` + Vendor string `json:"vendor"` + Model string `json:"model"` + SiteName string `json:"site_name"` + PowerKw float64 `json:"power_kw"` + Connector string `json:"connector"` + Provider string `json:"provider"` + ProviderChargerID string `json:"provider_charger_id"` + Owner string `json:"owner"` + Created string `json:"created"` +} + +func (rec homeChargerRecord) toModel() models.HomeCharger { + return models.HomeCharger{ + ID: rec.ID, + Name: rec.Name, + Serial: rec.Serial, + Vendor: rec.Vendor, + Model: rec.Model, + SiteName: rec.SiteName, + PowerKw: rec.PowerKw, + Connector: rec.Connector, + Provider: rec.Provider, + ProviderChargerID: rec.ProviderChargerID, + Created: rec.Created, + } +} + +// homeChargerPayload is the write shape. The provider link is not part of it: +// only the import endpoint sets that, and it adds those two fields itself. +func homeChargerPayload(c models.HomeCharger) map[string]any { + return map[string]any{ + "name": c.Name, + "serial": c.Serial, + "vendor": c.Vendor, + "model": c.Model, + "site_name": c.SiteName, + "power_kw": c.PowerKw, + "connector": c.Connector, + } +} + +// listHomeChargers returns the caller's own chargers, newest first — the order +// they were imported in, which is the order they were installed in often enough. +func (s *Server) listHomeChargers(w http.ResponseWriter, r *http.Request) { + me := s.currentUserID(r) + if me == "" { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + res, err := s.pb.List(r.Context(), colHomeChargers, url.Values{ + "filter": {fmt.Sprintf("owner='%s'", me)}, + "sort": {"created"}, + "perPage": {"200"}, + }) + if err != nil { + writePBError(w, err) + return + } + var recs []homeChargerRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]models.HomeCharger, 0, len(recs)) + for _, rec := range recs { + out = append(out, rec.toModel()) + } + writeJSON(w, http.StatusOK, map[string]any{"chargers": out}) +} + +// ownedHomeCharger fetches one charger and checks it is the caller's. It writes +// the error response itself and returns ok=false when it is not. +func (s *Server) ownedHomeCharger(w http.ResponseWriter, r *http.Request) (homeChargerRecord, bool) { + var rec homeChargerRecord + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, "charger id is required") + return rec, false + } + if err := s.pb.GetOne(r.Context(), colHomeChargers, id, &rec); err != nil { + writePBError(w, err) + return rec, false + } + // Someone else's charger is not found rather than forbidden: whether a record + // exists is not this caller's business either. + if rec.Owner != s.currentUserID(r) { + writeError(w, http.StatusNotFound, "charger not found") + return rec, false + } + return rec, true +} + +// updateHomeCharger renames a charger. Only the name is editable — everything +// else describes the hardware and comes from the service it was imported from. +func (s *Server) updateHomeCharger(w http.ResponseWriter, r *http.Request) { + rec, ok := s.ownedHomeCharger(w, r) + if !ok { + return + } + var body struct { + Name string `json:"name"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + var updated homeChargerRecord + if err := s.pb.Update(r.Context(), colHomeChargers, rec.ID, map[string]any{"name": name}, &updated); err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"charger": updated.toModel()}) +} + +// deleteHomeCharger forgets a charger. The charger itself is untouched — this is +// a record about it, and importing it again brings it straight back. +func (s *Server) deleteHomeCharger(w http.ResponseWriter, r *http.Request) { + rec, ok := s.ownedHomeCharger(w, r) + if !ok { + return + } + if err := s.pb.Delete(r.Context(), colHomeChargers, rec.ID); err != nil { + writePBError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go index e5df8df..55c6f0b 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -317,6 +317,24 @@ func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string] return out } +// ankerGate returns the reason the integration cannot run for this caller, or "" +// when it can. requireOptIn additionally demands the personal enable flag, which +// a live probe deliberately does not: the probe is how you check credentials +// before switching the integration on. +func ankerGate(res ankerResolution, requireOptIn bool) string { + switch { + case !res.available: + return "The Anker Solix integration is disabled by the administrator" + case !res.orgEnabled: + return "The Anker Solix integration is disabled for your organization" + case requireOptIn && !res.enabled: + return "Enable the Anker Solix integration in Settings to load your chargers" + case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "": + return "Enter your Anker account email and password to connect" + } + return "" +} + // GET /api/integrations/anker-solix — resolved Anker Solix view for the caller. func (s *Server) handleGetAnker(w http.ResponseWriter, r *http.Request) { who := caller(r) @@ -454,18 +472,8 @@ func (s *Server) handleAnkerHealth(w http.ResponseWriter, r *http.Request) { userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveAnker(r.Context(), who, userRaw) - down := func(detail string) { - writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": detail}}) - } - switch { - case !res.available: - down("The Anker Solix integration is disabled by the administrator") - return - case !res.orgEnabled: - down("The Anker Solix integration is disabled for your organization") - return - case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "": - down("Enter your Anker account email and password to connect") + if reason := ankerGate(res, false); reason != "" { + writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": reason}}) return } @@ -496,21 +504,8 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) { userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveAnker(r.Context(), who, userRaw) - unavailable := func(detail string) { - writeJSON(w, http.StatusOK, map[string]any{"chargers": []any{}, "unavailable": true, "detail": detail}) - } - switch { - case !res.available: - unavailable("The Anker Solix integration is disabled by the administrator") - return - case !res.orgEnabled: - unavailable("The Anker Solix integration is disabled for your organization") - return - case !res.enabled: - unavailable("Enable the Anker Solix integration in Settings to load your chargers") - return - case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "": - unavailable("Enter your Anker account email and password to connect") + if reason := ankerGate(res, true); reason != "" { + writeJSON(w, http.StatusOK, map[string]any{"chargers": []any{}, "unavailable": true, "detail": reason}) return } diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 08763f5..f493430 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -153,6 +153,7 @@ const ( colMaintenance = "maintenance_entries" colDocuments = "car_documents" colReminders = "reminders" + colHomeChargers = "home_chargers" colControlAudit = "control_audit" colAppSettings = "app_settings" ) @@ -464,6 +465,17 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/vehicle-providers/{provider}/vehicles", s.handleProviderVehicles) mux.HandleFunc("POST /api/vehicle-providers/{provider}/import", s.handleProviderImport) + // Charger providers — the same move for the wallbox: a charger on a connected + // service becomes one of the caller's home chargers. See chargerproviders.go. + mux.HandleFunc("GET /api/charger-providers", s.handleListChargerProviders) + mux.HandleFunc("GET /api/charger-providers/{provider}/chargers", s.handleProviderChargers) + mux.HandleFunc("POST /api/charger-providers/{provider}/import", s.handleChargerImport) + + // The caller's own chargers (homechargers.go). + mux.HandleFunc("GET /api/home-chargers", s.listHomeChargers) + mux.HandleFunc("PATCH /api/home-chargers/{id}", s.updateHomeCharger) + mux.HandleFunc("DELETE /api/home-chargers/{id}", s.deleteHomeCharger) + // Cars + sharing. mux.HandleFunc("GET /api/cars", s.listCars) mux.HandleFunc("POST /api/cars", s.createCar) diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go index 046ba61..b43d524 100644 --- a/API Server/internal/bootstrap/schema.go +++ b/API Server/internal/bootstrap/schema.go @@ -200,6 +200,26 @@ var collectionsSchema = map[string][]fieldDef{ // Per-organization plugin/integration config (middle layer of the cascade). fJSON("pluginSettings", 100000), }, + // The chargers a user owns — their own wallbox, not the public network. A + // charger belongs to a person rather than to a car: it charges whichever car + // is plugged into it, and it outlives any of them. + "home_chargers": { + fText("name", true), + fText("serial", false), + fText("vendor", false), // "Anker Solix", "Greencell" — who makes it + fText("model", false), // "A5191" + fText("site_name", false), + fNumber("power_kw"), + fText("connector", false), + // Where this charger came from: the charger-provider id plus that + // provider's own id for it (the serial, for both providers we speak to). + // Blank for one added by hand. See internal/api/chargerproviders.go. + fText("provider", false), + fText("provider_charger_id", false), + // Owner. Non-cascading, like a car's: deleting a user must not silently + // wipe the records they own. + fRelation("owner", "users", false, false), + }, // Custom fields layered onto the built-in "users" auth collection. "users": { fText("bio", false), @@ -246,6 +266,7 @@ var createOrder = []string{ "car_documents", "reminders", "control_audit", + "home_chargers", } // reconcileOrder additionally includes "users" so its custom fields (role, @@ -265,6 +286,7 @@ var reconcileOrder = []string{ "car_documents", "reminders", "control_audit", + "home_chargers", } // indexes are extra SQL indexes applied at collection-create time. @@ -278,6 +300,9 @@ var indexes = map[string][]string{ "car_documents": {"CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"}, "reminders": {"CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"}, "technical_checks": {"CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"}, + // A charger is looked up by its owner, and by serial when checking whether + // the account it came from has already been imported. + "home_chargers": {"CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"}, "control_audit": { "CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)", "CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)", diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go index 1442236..461e3eb 100644 --- a/API Server/internal/models/models.go +++ b/API Server/internal/models/models.go @@ -129,6 +129,29 @@ type Car struct { Updated string `json:"updated,omitempty"` } +// HomeCharger is a charger the user owns — their own wallbox, not a station on +// the public network. It belongs to the person rather than to a car: it charges +// whichever car is plugged into it, and it outlives any of them. +type HomeCharger struct { + ID string `json:"id"` + Name string `json:"name"` + Serial string `json:"serial,omitempty"` + Vendor string `json:"vendor,omitempty"` // "Anker Solix", "Greencell" + Model string `json:"model,omitempty"` // "A5191" + SiteName string `json:"siteName,omitempty"` // the system it belongs to, where it has one + PowerKw float64 `json:"powerKw,omitempty"` + Connector string `json:"connector,omitempty"` + + // Provider links this charger to the service it was imported from — the + // charger-provider name ("anker-solix") plus that provider's own id for it + // (the serial). Both blank for one added by hand. Set by the import endpoint + // only, never by an ordinary edit, so a rename cannot break the link. + Provider string `json:"provider,omitempty"` + ProviderChargerID string `json:"providerChargerId,omitempty"` + + Created string `json:"created,omitempty"` +} + // ServiceRecord is one row of the Service log for a car. type ServiceRecord struct { ID string `json:"id"` diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 2c3e30e..1c6d7c2 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -438,6 +438,26 @@ const DESIRED = { F.json("params", 10000), F.autodate("created", true, false), ], + // The chargers a user owns — their own wallbox, not the public network. A + // charger belongs to a person rather than to a car: it charges whichever car + // is plugged into it, and it outlives any of them. Created by importing from a + // connected charger service (Anker Solix, Greencell); see + // internal/api/chargerproviders.go. + home_chargers: [ + F.text("name", true), + F.text("serial"), + F.text("vendor"), // "Anker Solix", "Greencell" — who makes it + F.text("model"), // "A5191" + F.text("site_name"), + F.number("power_kw"), + F.text("connector"), + // Where this charger came from: the provider id plus that provider's own id + // for it (the serial, for both providers we speak to). Blank when added by hand. + F.text("provider"), + F.text("provider_charger_id"), + // Owner. Non-cascading, like a car's: deleting a user must not wipe their records. + F.relation("owner", "users", false, false), + ], // Server-wide settings as a single record, keyed "global". Today it holds // pluginSettings: the top (L1) layer of the integration cascade — every // plugin's enable state, its global config, and the registration of any @@ -513,6 +533,9 @@ const INDEXES = { reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"], // Read as "this car's checks, newest first" every time. technical_checks: ["CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"], + // A charger is looked up by its owner, and by serial when checking whether the + // account it came from has already been imported. + home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"], // Audit is queried "this charger's events, newest first" and "this user's events". control_audit: [ "CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)", @@ -549,6 +572,7 @@ async function main() { "car_documents", "reminders", "control_audit", + "home_chargers", ]) { if (collections.some((c) => c.name === name)) continue; await createCollection(token, name, DESIRED[name], format, idByName); @@ -576,6 +600,7 @@ async function main() { "car_documents", "reminders", "control_audit", + "home_chargers", ]) { await reconcileFields(token, name, DESIRED[name], format, idByName); } @@ -584,7 +609,7 @@ async function main() { "\nDone. Collections ready: app_settings, organizations, users, cars,\n" + "service_records,\n" + "technical_checks, parts, car_shares, fuel_entries, charging_sessions,\n" + - "maintenance_entries, car_documents, reminders, control_audit.", + "maintenance_entries, car_documents, reminders, control_audit, home_chargers.", ); console.log( "Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" + diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index fd6c223..c347e37 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -293,6 +293,29 @@ export const api = { syncCarProvider: (carId, body = {}) => request(`/cars/${carId}/provider/sync`, { method: "POST", body: JSON.stringify(body) }), + // Charger providers — the garage's import, aimed at the wall: a charger on a + // connected service (Anker Solix, Greencell) becomes one of the caller's own + // home chargers. listChargerProviders reports each with a `connected` flag and, + // when it isn't, a `detail` sentence saying what to do about it. + listChargerProviders: () => request("/charger-providers").then((r) => r.providers), + listProviderChargers: (provider) => + request(`/charger-providers/${encodeURIComponent(provider)}/chargers`), + importProviderCharger: (provider, body) => + request(`/charger-providers/${encodeURIComponent(provider)}/import`, { + method: "POST", + body: JSON.stringify(body), + }), + + // The caller's own chargers. Only the name is editable — everything else + // describes the hardware and comes from the service it was imported from. + listHomeChargers: () => request("/home-chargers").then((r) => r.chargers), + renameHomeCharger: (id, name) => + request(`/home-chargers/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify({ name }), + }), + deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }), + // Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix // returns the resolved view (effective/own/locked per field, secrets and // inherited emails masked); saveAnkerSolix writes the caller's editable layer; diff --git a/Web App/web/src/components/ChargerImportModal.vue b/Web App/web/src/components/ChargerImportModal.vue new file mode 100644 index 0000000..ee6e0e8 --- /dev/null +++ b/Web App/web/src/components/ChargerImportModal.vue @@ -0,0 +1,184 @@ + + + diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 95d7f0b..83b6393 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -37,6 +37,14 @@ }, "charging": { + "home": { + "count": { "one": "{n} lader", "other": "{n} ladere" }, + "import": "Importer fra tjeneste", + "remove": "Fjern", + "removeConfirm": "Fjern {name} fra dine ladere? Selve laderen røres ikke — importerer du den igen, er den tilbage.", + "empty": "Ingen ladere endnu. Importer den fra din Anker- eller Greencell-konto i stedet for at taste serienummeret.", + "connectFirst": "Tilslut en ladertjeneste under Indstillinger › Integrationer for at importere." + }, "eyebrow": "Opladning og kort", "title": "Ladere i nærheden", "tabs": { @@ -690,6 +698,17 @@ }, "forms": { + "importCharger": { + "title": "Importer en lader", + "subtitle": "Tilføj en lader fra en tjeneste, du har tilsluttet. Navn, serienummer og model følger med.", + "noProviders": "Ingen ladertjeneste er tilsluttet. Tilføj en under Indstillinger › Integrationer.", + "selectCharger": "Lader", + "loadingChargers": "Indlæser dine ladere…", + "noChargers": "Ingen ladere på denne konto.", + "alreadyImported": "Allerede importeret", + "name": "Ladernavn", + "submit": "Importer lader" + }, "common": { "pickDate": "Åbn kalender", "dateInvalid": "Indtast en gyldig dato i dette format." diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 2db5167..6aa24eb 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -37,6 +37,14 @@ }, "charging": { + "home": { + "count": { "one": "{n} charger", "other": "{n} chargers" }, + "import": "Import from service", + "remove": "Remove", + "removeConfirm": "Remove {name} from your chargers? The charger itself is untouched — importing it again brings it back.", + "empty": "No chargers yet. Import the one on your Anker or Greencell account instead of typing its serial.", + "connectFirst": "Connect a charger service in Settings › Integrations to import one." + }, "eyebrow": "Charging & map", "title": "Nearby chargers", "tabs": { @@ -689,6 +697,17 @@ }, "forms": { + "importCharger": { + "title": "Import a charger", + "subtitle": "Add a charger from a service you have connected. Its name, serial and model come with it.", + "noProviders": "No charger service is connected. Add one in Settings › Integrations.", + "selectCharger": "Charger", + "loadingChargers": "Loading your chargers…", + "noChargers": "No chargers on this account.", + "alreadyImported": "Already imported", + "name": "Charger name", + "submit": "Import charger" + }, "common": { "pickDate": "Open calendar", "dateInvalid": "Enter a real date in this format." diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 6da3a23..36c36ff 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -37,6 +37,14 @@ }, "charging": { + "home": { + "count": { "one": "{n} ładowarka", "few": "{n} ładowarki", "many": "{n} ładowarek", "other": "{n} ładowarki" }, + "import": "Importuj z usługi", + "remove": "Usuń", + "removeConfirm": "Usunąć {name} z Twoich ładowarek? Sama ładowarka pozostaje nietknięta — ponowny import ją przywróci.", + "empty": "Nie masz jeszcze ładowarek. Zaimportuj tę z konta Anker lub Greencell, zamiast przepisywać numer seryjny.", + "connectFirst": "Podłącz usługę ładowarki w Ustawienia › Integracje, aby zaimportować." + }, "eyebrow": "Ładowanie i mapa", "title": "Ładowarki w pobliżu", "tabs": { @@ -704,6 +712,17 @@ }, "forms": { + "importCharger": { + "title": "Importuj ładowarkę", + "subtitle": "Dodaj ładowarkę z podłączonej usługi. Nazwa, numer seryjny i model przychodzą razem z nią.", + "noProviders": "Nie podłączono żadnej usługi ładowarki. Dodaj ją w Ustawienia › Integracje.", + "selectCharger": "Ładowarka", + "loadingChargers": "Wczytywanie ładowarek…", + "noChargers": "Brak ładowarek na tym koncie.", + "alreadyImported": "Już zaimportowana", + "name": "Nazwa ładowarki", + "submit": "Importuj ładowarkę" + }, "common": { "pickDate": "Otwórz kalendarz", "dateInvalid": "Wpisz istniejącą datę w tym formacie." diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index e974778..ccef7e6 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted } from "vue"; import { t } from "../i18n"; import { api } from "../api"; +import ChargerImportModal from "../components/ChargerImportModal.vue"; // Charging & map screen, mirroring the web-dashboard UI kit. There is no live // charging API yet (only the Anker Solix credential cascade in Settings), so the @@ -17,14 +18,14 @@ const stations = [ { id: "sc", name: "DriverVault Supercharge", dist: "0.4 km", kw: 250, conn: "CCS · NACS", avail: 6, total: 8, price: "0,34 €", tone: "good", x: "47%", y: "34%" }, { id: "evgo", name: "EVgo · Market St", dist: "1.2 km", kw: 150, conn: "CCS", avail: 2, total: 6, price: "0,41 €", tone: "due", x: "26%", y: "60%" }, { id: "cp", name: "ChargePoint Garage", dist: "2.1 km", kw: 62, conn: "J1772", avail: 0, total: 4, price: "0,29 €", tone: "fault", x: "70%", y: "64%" }, - { id: "home", name: t("charging.stations.homeCharger"), dist: "—", kw: 11, conn: "NACS", avail: 1, total: 1, price: t("charging.stations.offPeak"), tone: "good", x: "60%", y: "19%", home: true }, ]; // Two tabs split the public charging network (discovery map + nearby stations) -// from the user's own home charger(s) and their real OCPP control. +// from the user's own chargers and their real OCPP control. The public half is +// still placeholder data; the home half is not — those are records the user +// imported from a service they connected. const chargerTab = ref("public"); // "public" | "home" -const publicStations = stations.filter((s) => !s.home); -const homeStations = stations.filter((s) => s.home); +const publicStations = stations; const selected = ref("sc"); const charging = ref(true); @@ -96,6 +97,54 @@ function chargerLabel(c) { return c.name ? `${c.name} · ${c.sn}` : c.sn; } +// --- The user's own chargers, imported from a connected service --- +// The same move the garage makes for a car: a charger on a connected account +// becomes a record here, and stays one after the account is disconnected. +const homeChargers = ref([]); +const homeChargersError = ref(""); +const showChargerImport = ref(false); +// Importing only makes sense once a charger service is connected, so the button +// appears only then — same rule the garage's import button follows. +const canImportCharger = ref(false); + +async function loadHomeChargers() { + homeChargersError.value = ""; + try { + homeChargers.value = await api.listHomeChargers(); + } catch (e) { + homeChargersError.value = e.message; + } +} + +// A charger picked here becomes the one the control card drives. +function selectHomeCharger(c) { + selected.value = c.id; + if (!c.serial) return; + ctlSerial.value = c.serial; + refreshCtl(); +} + +function onChargerImported(charger) { + showChargerImport.value = false; + homeChargers.value = [...homeChargers.value, charger]; + selectHomeCharger(charger); +} + +async function removeHomeCharger(c) { + if (!confirm(t("charging.home.removeConfirm", { name: c.name }))) return; + homeChargersError.value = ""; + try { + await api.deleteHomeCharger(c.id); + homeChargers.value = homeChargers.value.filter((x) => x.id !== c.id); + } catch (e) { + homeChargersError.value = e.message; + } +} + +function homeChargerSubtitle(c) { + return [c.serial, c.model, c.siteName].filter(Boolean).join(" · "); +} + async function refreshCtl() { const sn = ctlSerial.value.trim(); if (!sn) { @@ -152,6 +201,11 @@ function cancelReset() { } onMounted(async () => { + await loadHomeChargers(); + api + .listChargerProviders() + .then((list) => (canImportCharger.value = list.some((p) => p.connected))) + .catch(() => (canImportCharger.value = false)); await loadCtlMode(); if (ctlActive.value) await loadChargers(); await refreshCtl(); @@ -395,37 +449,74 @@ onMounted(async () => { - +
-
- {{ t("charging.stations.homeHeading") }} · {{ t("charging.stations.count", { n: homeStations.length }) }} -
-
-
-
{{ s.name }}
-
{{ s.dist }} · {{ s.kw }} kW · {{ s.conn }}
-
-
-
{{ stationStatus(s) }}
-
{{ s.price }}
-
- + {{ t("charging.home.import") }} + +
+ +
+ + +
+ + +
+

{{ t("charging.home.empty") }}

+ +

{{ t("charging.home.connectFirst") }}

+
+ +

{{ homeChargersError }}

+ +