Files
tajniak81andClaude Opus 5 a3f69fa5ef 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>
2026-08-31 21:07:21 +02:00

115 lines
4.0 KiB
Go

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)
}
}