Files
DriverVault/API Server/internal/api/chargerproviders.go
T
tajniak81andClaude Opus 5 f8b39fa0a3 Anker Solix: the encrypted transport the app uses, ported from upstream v3.8.2
anker-solix-api v3.8.2 (2026-09-13) worked out the mobile app's REST payload
encryption end to end — a second ECDH handshake after login, then every body
AES-sealed and HMAC-signed both ways. Anker's servers still accept the plain
JSON this connector has always sent, and upstream leaves the mode off for that
reason; this ports it so that the day they stop is a config change rather than
an outage.

payloadcrypto.go holds the handshake (openapi/oauth/key/exchange, both public
keys wrapped under the app's presetKey) and the per-request envelope
(AES-128-CBC under secret[:16], x-signature keyed on its ascii hex). The
handshake lives beside the token in the shared session and is dropped with it,
so a fresh login is a fresh handshake before the retry. The switch is a
global-layer field, payloadEncryption, default off: it is how the connector
talks to Anker rather than whose account it talks as, so it does not cascade —
ankerCloudConfig copies it into every per-user config, and the six places that
built that map by hand now go through it.

Verified against a fake cloud that implements the server half from upstream's
description, not against Anker's; the switch should be tried on a test account
before it is relied on. Nothing else in v3.8.2 reaches an EV charger: the
Solarbank expansion sizing, the Gen 2 smart meter and the new site parameter
types are other devices, and the A5191 MQTT block is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:40:43 +02:00

421 lines
15 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 ankerCloudConfig(res), 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
}