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>
421 lines
15 KiB
Go
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
|
|
}
|