Home chargers: your own wallbox as a record, imported the way a car is
The Home chargers tab has been showing a hardcoded "Home charger · 11 kW · NACS" since it was drawn, and the control card asked for a serial as free text — a number printed on a box hanging in a garage, typed in by hand while the connected account already knew it. The garage solved the same problem for cars a while ago, so this is that solution aimed at the wall: pick the charger off a service you have connected, press Import, and it becomes a record of yours. A charger is a record rather than a live listing because it has to outlive the account it came from. Disconnect Anker and the wallbox is still on the wall; the integration is how the charger was found, not what it is. Hence home_chargers, owned by a person and not related to any car — it charges whichever car is plugged into it, and it outlives all of them — and hence no sharing: a charger is one household's business in a way a car shared with a partner is not. The provider layer is vehicleproviders.go's shape on purpose, down to the soft gate: a listing answers 200 with an empty list and the sentence that says what to do about a closed gate, a write answers 400, because there the caller asked for something that did not happen. Anker and Greencell are two adapters over plugins that already exist, so the next charger service is an adapter appended to chargerSources() and nothing else. What is deliberately absent is the car import's checkbox panel: a charger is a name, a serial and the hardware behind it, all of which the list already carries, so there is nothing to choose and the whole screen is pick one, press Import. Only the name is editable afterwards. The rest describes hardware and came from the service, and the provider link is written by the import endpoint alone, so renaming a charger cannot quietly orphan it from the account it tracks. Deleting one says as much in its confirmation: the charger is untouched, and importing it again brings the record straight back. The Anker gate moved into ankerGate() beside greencellGate(), because the same four-case switch was about to exist in a third place. Behaviour is unchanged — the same sentences, and the probe still skips the personal opt-in, since checking credentials is what you do before switching the integration on. The phone app still has the old tab; parity there is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a809980d8b
commit
a3f69fa5ef
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user