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>
165 lines
5.4 KiB
Go
165 lines
5.4 KiB
Go
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)
|
|
}
|