The Charger information card read "—" beside State and OCPP status while the raw
block three rows below it printed chargerStatus 1 and ocpp_connect_status 2. The
account had answered both. The merge asked for the state as evChargerStatus,
operating_state or status, which is how the standalone and station views spell
it, and the bound-device view — the one this account actually answers from —
spells it chargerStatus. The OCPP state it never asked that view for at all. All
three views now read through one fillDevice, which tries every spelling a view is
known to use, so a value any of them sends reaches the row that was drawing a
dash for want of it.
The same views were carrying the whole box-on-the-wall half unread: the Wi-Fi
network and its MAC, the signal strength, the Bluetooth MAC, the time zone, when
the account bound the charger, how the app can reach it — BLE, Wi-Fi — and the
product shot for the model, which now sits beside the charger's name in both
apps. Named rows, in three languages, the way the register map's readings are
named.
One field wanted the opposite treatment. The device record carries blue_password,
the charger's own Bluetooth pairing password, and the card was printing it in
clear into every screenshot anyone takes of that page. Any leaf key holding a
password, secret, token, private key or certificate is now masked in the raw
block: that the field exists is worth reporting, its value is not.
Four endpoints answer only when a serial is named, so none of them could belong
to the list the card is drawn from, and nothing had ever called them. The station
record, the charging totals, the OCPP backend and the RFID cards now arrive
through a charger-details capability behind
GET …/anker-solix/chargers/{sn}/details, asked for the charger being looked at,
best effort, each view reporting its own failure — an account that is not the
owner cannot read the cards, which is a fact about the account rather than an
error in the read.
Those four are shown under the cloud's own keys, and that is not an oversight.
The REST map documents which endpoints exist and what each is for; it does not
document a single one of their payloads. Naming those fields is the next commit,
made from what actually comes back, now that there is somewhere to see it.
Not touched: the endpoints the map marks ready but unwired — session history,
site price, OTA, sharing, notifications — each a feature rather than a row on this
card; and the unmapped ones, which the map warns delete sessions and unbind
devices with payloads nobody has ever seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
425 lines
16 KiB
Go
425 lines
16 KiB
Go
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
|
|
}
|