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"` Firmware string `json:"firmware,omitempty"` SiteID string `json:"siteId,omitempty"` SiteName string `json:"siteName,omitempty"` Status string `json:"status,omitempty"` // the service's own word for its state Online *bool `json:"online,omitempty"` // How the charger is registered on the account — standalone, inside a // system (site), or merely bound to it. A charger can be several at once, // and which ones it is decides how much the service says about it. Sources []string `json:"sources,omitempty"` // What it is doing right now, when the service knows: the charge power as // the service words it (the unit is upstream's, so it is relayed verbatim) // and the charger's OCPP connector state as the service sees it. Power string `json:"power,omitempty"` OcppStatus *int `json:"ocppStatus,omitempty"` OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` // What the service knows about the box on the wall rather than the charging: // the networks it is on, where it thinks it is, when the account bound it, // and the picture the service shows for the model. WifiName string `json:"wifiName,omitempty"` WifiMac string `json:"wifiMac,omitempty"` WifiRSSI *int `json:"wifiRssi,omitempty"` BleMac string `json:"bleMac,omitempty"` TimeZone string `json:"timeZone,omitempty"` LinkedAt *float64 `json:"linkedAt,omitempty"` // unix seconds ImageURL string `json:"imageUrl,omitempty"` RelatedBy []string `json:"relatedBy,omitempty"` // Attrs is everything else the service said about this charger, under the // service's own field names. The fields above are the ones DriverVault has a // name for; this is the remainder, relayed so a card can show what the // account actually knows rather than only the part we modelled. Attrs map[string]string `json:"attrs,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"` Firmware string `json:"firmware"` SiteID string `json:"siteId"` SiteName string `json:"siteName"` Sources []string `json:"sources"` StatusDesc string `json:"statusDesc"` Online *bool `json:"online"` Power string `json:"power"` OcppStatus *int `json:"ocppStatus"` OcppStatusDesc string `json:"ocppStatusDesc"` WifiName string `json:"wifiName"` WifiMac string `json:"wifiMac"` WifiRSSI *int `json:"wifiRssi"` BleMac string `json:"bleMac"` TimeZone string `json:"timeZone"` LinkedAt *float64 `json:"linkedAt"` ImageURL string `json:"imageUrl"` RelatedBy []string `json:"relatedBy"` Attrs map[string]string `json:"attrs"` } `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, Firmware: c.Firmware, SiteID: c.SiteID, SiteName: c.SiteName, Sources: c.Sources, Status: c.StatusDesc, Online: c.Online, Power: c.Power, OcppStatus: c.OcppStatus, OcppStatusDesc: c.OcppStatusDesc, WifiName: c.WifiName, WifiMac: c.WifiMac, WifiRSSI: c.WifiRSSI, BleMac: c.BleMac, TimeZone: c.TimeZone, LinkedAt: c.LinkedAt, ImageURL: c.ImageURL, RelatedBy: c.RelatedBy, Attrs: c.Attrs, }) } 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 }