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 @@
+
+
+
+ {{ error }} {{ t("common.loading") }} {{ t("forms.importCharger.noProviders") }}
{{ t("charging.home.empty") }}
+{{ t("charging.home.connectFirst") }}
+{{ homeChargersError }}