Anker: every charger on the account, not just the ones outside a station

get_user_bind_and_not_in_station_evchargers is the only list the connector ever
asked for, and its name says exactly what it withholds. A charger that belongs to
a system is not in it. Its userBindEvChargersCount, though, counts every charger
bound to the account — so an owner with two chargers in a system got "authenticated;
2 EV charger(s) bound to account" from the health probe and an empty list from the
capability that is supposed to show them. A working login that finds nothing.

So the capability now asks every view the cloud has and merges them by serial. The
standalone list still answers for chargers standing on their own; get_site_list
walks the systems and reads each one through get_scen_info, falling back to
get_system_running_info where that is silent — the power-service / HES split
charger-state already knows; and get_relate_and_bind_devices contributes model,
firmware and the Wi-Fi flag, and discovers anything in the A519 family that the
first two missed. Whichever way a charger was registered, one of the three has it.

The merge is first-writer-wins per field rather than last view overwriting: the
standalone record knows the name, the site record knows the live state, and neither
should blank what the other established. A view that fails is a warning on the
document instead of an error on the call, because one dead endpoint should not
cost the chargers the other two found. Only losing all three is a failure. When
nothing comes back at all the response says so in its own words and names the
remaining suspect — country picks the regional server, and the wrong one
authenticates happily and shows an empty account.

The other half of "not showing any chargers" was that neither client ever showed a
list. The serial was a text box, and the number is printed on a charger hanging on
a wall. Both apps now list what the account holds — name, serial, model, site,
state, an offline badge — and hand the serial to the OCPP control card instead of
asking anyone to go and read it. Where control is off the list still stands on its
own, as the answer to the first question an owner has after entering credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-31 20:13:36 +02:00
co-authored by Claude Opus 5
parent a7cab50e06
commit e138fad3f4
17 changed files with 1062 additions and 17 deletions
@@ -524,6 +524,7 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
// Relay the upstream JSON verbatim under "chargers".
writeJSON(w, http.StatusOK, map[string]any{"chargers": json.RawMessage(raw)})
// The plugin already answers {chargers, count, boundCount?, warnings?}; relay
// that document as-is so the UI sees exactly what the capability produced.
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
@@ -174,7 +174,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
Category: plugins.CategoryChargers,
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "List EV chargers bound to the account."},
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site and bound-device views (see chargers.go)."},
{ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn; optional featuretype 1 or 2)."},
{ID: "charger-state", Method: "POST", Endpoint: epSceneInfo, Description: "Normalized live state of a site's EV chargers: status, operational mode and the modes it can be switched to (needs siteId; optional sn)."},
{ID: "site-status", Method: "POST", Endpoint: epSceneInfo, Description: "Live site view; EV chargers appear under charging_pile_info (needs siteId)."},
@@ -280,8 +280,11 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
pp.SiteID = strings.TrimSpace(pp.SiteID)
pp.VehicleID = strings.TrimSpace(pp.VehicleID)
// charger-state fans out over two endpoints and returns a derived document,
// so it does not fit the single-endpoint dispatch below.
// chargers and charger-state both fan out over several endpoints and return
// a derived document, so they do not fit the single-endpoint dispatch below.
if action == "chargers" {
return p.accountChargers(ctx)
}
if action == "charger-state" {
if pp.SiteID == "" {
return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
@@ -296,8 +299,6 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
needSite bool
)
switch action {
case "chargers":
endpoint, payload = epStandaloneChargers, map[string]any{}
case "devices":
endpoint, payload = epBindDevices, map[string]any{}
case "sites":
@@ -0,0 +1,358 @@
package ankersolix
// The account's chargers do not all live in one place: which cloud view a
// charger shows up in depends on how it was set up in the Anker app, and no
// single endpoint sees all of them.
//
// - get_user_bind_and_not_in_station_evchargers lists only the chargers that
// are NOT part of a station/system. Its userBindEvChargersCount, though,
// counts every charger bound to the account — so an account whose chargers
// all sit in a station answers "2 chargers bound" with an empty list, which
// is how a perfectly good login ends up showing nothing.
// - a charger that belongs to a system appears in that system's view instead:
// get_scen_info for power-service sites, get_system_running_info for HES
// ones (the same split chargerState handles).
// - get_relate_and_bind_devices knows every bound device with its model,
// firmware and Wi-Fi state, but nothing about charging.
//
// accountChargers therefore asks all three and merges the answers by serial, so
// the list matches what the mobile app shows however the chargers were
// registered.
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// evChargerPNPrefix matches the EV-charger product family (A5191 is the V1 Smart
// EV Charger); a bound device outside it is other Anker hardware on the account.
const evChargerPNPrefix = "A519"
// accountCharger is one charger, merged from every view that reported it. A
// field no view supplied stays empty rather than guessed.
type accountCharger struct {
SN string `json:"sn"`
Name string `json:"name,omitempty"`
Model string `json:"model,omitempty"`
Firmware string `json:"firmware,omitempty"`
SiteID string `json:"siteId,omitempty"`
SiteName string `json:"siteName,omitempty"`
Sources []string `json:"sources"` // standalone | site | bound
Online *bool `json:"online,omitempty"`
Status *int `json:"status,omitempty"`
StatusDesc string `json:"statusDesc,omitempty"`
Power string `json:"power,omitempty"`
OcppStatus *int `json:"ocppStatus,omitempty"`
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
}
// chargerInventory merges chargers by serial, keeping the order they were first
// seen in so the output is stable across polls.
type chargerInventory struct {
order []string
byID map[string]*accountCharger
}
func newInventory() *chargerInventory {
return &chargerInventory{byID: map[string]*accountCharger{}}
}
// get returns the record for sn, creating it on first sight, and notes the view
// it was seen in.
func (inv *chargerInventory) get(sn, source string) *accountCharger {
c, ok := inv.byID[sn]
if !ok {
c = &accountCharger{SN: sn, Sources: []string{}}
inv.byID[sn] = c
inv.order = append(inv.order, sn)
}
for _, s := range c.Sources {
if s == source {
return c
}
}
c.Sources = append(c.Sources, source)
return c
}
// has reports whether a serial is already known — a bound device enriches a
// charger we have already found even when its product code is unfamiliar.
func (inv *chargerInventory) has(sn string) bool {
_, ok := inv.byID[sn]
return ok
}
// list returns the merged chargers in discovery order.
func (inv *chargerInventory) list() []accountCharger {
out := make([]accountCharger, 0, len(inv.order))
for _, sn := range inv.order {
out = append(out, *inv.byID[sn])
}
return out
}
// addStandalone reads the chargers that sit outside any station.
func (inv *chargerInventory) addStandalone(body []byte) {
for _, m := range dataList(body, "evChargers", "evChargerList", "chargerList", "list") {
sn := pickString(m, "evChargerSn", "device_sn", "deviceSn", "sn")
if sn == "" {
continue
}
c := inv.get(sn, "standalone")
fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "name"))
fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn"))
fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId"))
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version"))
if n, ok := pickInt(m, "evChargerStatus", "operating_state", "status"); ok {
setChargerStatus(c, n)
}
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
c.Online = &b
}
}
}
// addStates folds a site view's normalized chargers into the inventory and
// reports how many that view carried.
func (inv *chargerInventory) addStates(states []evChargerState, siteName string) int {
for _, st := range states {
c := inv.get(st.SN, "site")
fillString(&c.Name, st.Name)
fillString(&c.SiteID, st.SiteID)
fillString(&c.SiteName, siteName)
fillString(&c.Power, st.Power)
if st.Status != nil && c.Status == nil {
s := *st.Status
c.Status, c.StatusDesc = &s, st.StatusDesc
}
if st.OcppStatus != nil && c.OcppStatus == nil {
o := *st.OcppStatus
c.OcppStatus, c.OcppStatusDesc = &o, st.OcppStatusDesc
}
}
return len(states)
}
// addBound enriches known chargers from the account's bound-device list, and
// discovers any device whose product code is in the EV-charger family.
func (inv *chargerInventory) addBound(body []byte) {
for _, m := range dataList(body, "data", "device_list", "devices", "list") {
sn := pickString(m, "device_sn", "deviceSn", "sn")
if sn == "" {
continue
}
pn := pickString(m, "device_pn", "product_code", "pn")
if !inv.has(sn) && !strings.HasPrefix(strings.ToUpper(pn), evChargerPNPrefix) {
continue // some other Anker device on the same account
}
c := inv.get(sn, "bound")
fillString(&c.Name, pickString(m, "device_name", "alias_name", "name"))
fillString(&c.Model, pn)
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
fillString(&c.SiteID, pickString(m, "site_id", "siteId"))
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
c.Online = &b
}
}
}
// siteRef is one system (site) registered on the account.
type siteRef struct{ ID, Name string }
// siteList returns the account's systems — the entry point for every
// site-scoped view.
func (p *Plugin) siteList(ctx context.Context) ([]siteRef, error) {
body, err := p.apiRequest(ctx, epSiteList, map[string]any{})
if err != nil {
return nil, err
}
var env struct {
Data struct {
SiteList []struct {
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
} `json:"site_list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, fmt.Errorf("anker-solix: decode site list: %w", err)
}
out := make([]siteRef, 0, len(env.Data.SiteList))
for _, s := range env.Data.SiteList {
if s.SiteID != "" {
out = append(out, siteRef{ID: s.SiteID, Name: s.SiteName})
}
}
return out, nil
}
// accountChargers lists every EV charger on the account by merging the views
// described at the top of this file. A view that fails is recorded as a warning
// and the others still answer; only losing all of them is an error.
func (p *Plugin) accountChargers(ctx context.Context) (json.RawMessage, error) {
inv := newInventory()
var warnings []string
views, failed := 0, 0
fail := func(what string, err error) {
failed++
warnings = append(warnings, what+": "+shorten(err.Error()))
}
// 1. Chargers registered on their own, outside any station.
views++
boundCount := -1
if body, err := p.apiRequest(ctx, epStandaloneChargers, map[string]any{}); err != nil {
fail("standalone chargers", err)
} else {
if n, ok := countChargers(body); ok {
boundCount = n
}
inv.addStandalone(body)
}
// 2. Chargers that belong to a system, one site at a time.
views++
sites, err := p.siteList(ctx)
if err != nil {
fail("sites", err)
}
for _, st := range sites {
found := 0
if body, err := p.apiRequest(ctx, epSceneInfo, map[string]any{"site_id": st.ID}); err == nil {
found = inv.addStates(parseScenePiles(body, st.ID), st.Name)
}
if found == 0 {
// Not a power-service site, or it reported no pile: try the HES view.
if body, err := p.apiRequest(ctx, epSystemRunInfo, map[string]any{"siteId": st.ID}); err == nil {
inv.addStates(parseHesChargers(body, st.ID), st.Name)
}
}
}
// 3. Bound devices: model, firmware and the Wi-Fi online flag.
views++
if body, err := p.apiRequest(ctx, epBindDevices, map[string]any{}); err != nil {
fail("bound devices", err)
} else {
inv.addBound(body)
}
if failed == views {
return nil, fmt.Errorf("anker-solix: chargers: every cloud view failed: %s", strings.Join(warnings, "; "))
}
out := inv.list()
doc := map[string]any{"chargers": out, "count": len(out)}
if boundCount >= 0 {
doc["boundCount"] = boundCount
}
if len(warnings) > 0 {
doc["warnings"] = warnings
}
if len(out) == 0 {
// An account that owns chargers but exposes none through any view is
// almost always pointed at the wrong regional server.
doc["detail"] = "No EV charger was returned by any view. If the account does own one, check the country setting — it selects the Anker server, and the wrong one logs in but shows nothing."
}
return json.Marshal(doc)
}
// setChargerStatus records a raw operating-state code and its name; the first
// view to report one wins.
func setChargerStatus(c *accountCharger, code int) {
if c.Status != nil {
return
}
c.Status, c.StatusDesc = &code, statusName(code)
}
// dataList returns the first array of objects found under one of keys inside a
// response's "data" object. The charger endpoints do not share a field name and
// Anker has renamed them before, so the lookup is by candidate key rather than
// by a fixed struct.
func dataList(body []byte, keys ...string) []map[string]any {
var env struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil || env.Data == nil {
return nil
}
for _, k := range keys {
raw, ok := env.Data[k].([]any)
if !ok {
continue
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
if len(out) > 0 {
return out
}
}
return nil
}
// pickString returns the first non-empty value among keys, as a string.
func pickString(m map[string]any, keys ...string) string {
for _, k := range keys {
switch v := m[k].(type) {
case string:
if s := strings.TrimSpace(v); s != "" {
return s
}
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
}
}
return ""
}
// pickInt returns the first numeric value among keys.
func pickInt(m map[string]any, keys ...string) (int, bool) {
for _, k := range keys {
switch v := m[k].(type) {
case float64:
return int(v), true
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return n, true
}
}
}
return 0, false
}
// pickBool returns the first boolean-ish value among keys.
func pickBool(m map[string]any, keys ...string) (bool, bool) {
for _, k := range keys {
switch v := m[k].(type) {
case bool:
return v, true
case float64:
return v != 0, true
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes":
return true, true
case "false", "0", "no":
return false, true
}
}
}
return false, false
}
// fillString sets dst only when it is still empty, so the first view that knows
// a value wins and later ones only fill gaps.
func fillString(dst *string, v string) {
if *dst == "" {
*dst = strings.TrimSpace(v)
}
}
@@ -0,0 +1,157 @@
package ankersolix
import "testing"
// A charger that sits in a station is absent from the standalone list even
// though the account count includes it — the case that made a good login look
// like an account with no chargers.
func TestStandaloneListMissesStationChargers(t *testing.T) {
body := []byte(`{"code":0,"data":{"evChargers":[],"userBindEvChargersCount":2}}`)
inv := newInventory()
inv.addStandalone(body)
if got := len(inv.list()); got != 0 {
t.Fatalf("standalone view: got %d chargers, want 0", got)
}
if n, ok := countChargers(body); !ok || n != 2 {
t.Fatalf("countChargers = %d, %v; want 2, true", n, ok)
}
// The site view is where those two actually live.
scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
{"device_sn":"EVSN1","device_name":"Garage","operating_state":2,"power":"7.4","ocpp_connect_status":2},
{"device_sn":"EVSN2","device_name":"Carport","operating_state":0}]}}}`)
inv.addStates(parseScenePiles(scene, "site-1"), "Home")
list := inv.list()
if len(list) != 2 {
t.Fatalf("after site view: got %d chargers, want 2", len(list))
}
if list[0].SN != "EVSN1" || list[0].SiteID != "site-1" || list[0].SiteName != "Home" {
t.Fatalf("first charger = %+v", list[0])
}
if list[0].StatusDesc != stateCharging || list[0].Power != "7.4" {
t.Fatalf("status/power not carried over: %+v", list[0])
}
if list[0].OcppStatusDesc != "connected" {
t.Fatalf("ocpp status = %q, want connected", list[0].OcppStatusDesc)
}
}
func TestAddStandaloneReadsHesFieldNames(t *testing.T) {
body := []byte(`{"code":0,"data":{"evChargers":[
{"evChargerSn":"EVSN1","evChargerName":"Garage","evChargerStatus":2,"wifi_online":true},
{"evChargerName":"nameless"}]}}`)
inv := newInventory()
inv.addStandalone(body)
list := inv.list()
if len(list) != 1 {
t.Fatalf("got %d chargers, want 1 (the serial-less one is skipped)", len(list))
}
c := list[0]
if c.SN != "EVSN1" || c.Name != "Garage" || c.StatusDesc != stateCharging {
t.Fatalf("charger = %+v", c)
}
if c.Online == nil || !*c.Online {
t.Fatalf("online = %v, want true", c.Online)
}
if len(c.Sources) != 1 || c.Sources[0] != "standalone" {
t.Fatalf("sources = %v", c.Sources)
}
}
// Bound devices discover EV chargers by product family and enrich chargers the
// other views already found — without dragging in the rest of the account.
func TestAddBoundFiltersAndEnriches(t *testing.T) {
inv := newInventory()
scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
{"device_sn":"EVSN1","operating_state":0}]}}}`)
inv.addStates(parseScenePiles(scene, "site-1"), "Home")
bound := []byte(`{"code":0,"data":{"data":[
{"device_sn":"EVSN1","device_pn":"A5191","device_name":"Garage","device_sw_version":"1.2.3","wifi_online":true},
{"device_sn":"EVSN9","device_pn":"a5191","alias_name":"Carport"},
{"device_sn":"SOLAR1","device_pn":"A17C0","device_name":"Solarbank"}]}}`)
inv.addBound(bound)
list := inv.list()
if len(list) != 2 {
t.Fatalf("got %d chargers, want 2 (the solarbank is not one)", len(list))
}
if list[0].Name != "Garage" || list[0].Firmware != "1.2.3" || list[0].Model != "A5191" {
t.Fatalf("enriched charger = %+v", list[0])
}
if len(list[0].Sources) != 2 || list[0].Sources[1] != "bound" {
t.Fatalf("sources = %v, want [site bound]", list[0].Sources)
}
if list[1].SN != "EVSN9" || list[1].Name != "Carport" {
t.Fatalf("discovered charger = %+v", list[1])
}
}
// The site view knows the live state; the standalone view saw the charger
// first. Merging must not let the later view blank what the earlier one knew.
func TestMergeKeepsFirstKnownValues(t *testing.T) {
inv := newInventory()
inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`))
inv.addStates(parseScenePiles([]byte(`{"data":{"charging_pile_info":{"charging_pile_list":[
{"device_sn":"EVSN1","operating_state":2,"power":"7.4"}]}}}`), "site-1"), "Home")
list := inv.list()
if len(list) != 1 {
t.Fatalf("got %d chargers, want 1", len(list))
}
c := list[0]
if c.Name != "Garage" {
t.Fatalf("name = %q, want Garage (the nameless site record must not clear it)", c.Name)
}
if c.StatusDesc != stateCharging || c.Power != "7.4" || c.SiteID != "site-1" {
t.Fatalf("site view did not fill the gaps: %+v", c)
}
}
func TestDataListCandidateKeys(t *testing.T) {
body := []byte(`{"data":{"evChargers":null,"chargerList":[{"sn":"A"},{"sn":"B"},"junk"]}}`)
got := dataList(body, "evChargers", "chargerList")
if len(got) != 2 {
t.Fatalf("got %d objects, want 2 (the non-object is dropped)", len(got))
}
if dataList([]byte(`not json`), "data") != nil {
t.Fatal("malformed body should yield no list")
}
if dataList([]byte(`{"data":{}}`), "missing") != nil {
t.Fatal("absent key should yield no list")
}
}
func TestPickers(t *testing.T) {
m := map[string]any{"a": " x ", "b": "", "n": float64(7), "ns": "42", "t": true, "z": float64(0)}
if got := pickString(m, "b", "a"); got != "x" {
t.Fatalf("pickString = %q, want x", got)
}
if got := pickString(m, "n"); got != "7" {
t.Fatalf("pickString(number) = %q, want 7", got)
}
if got := pickString(m, "missing"); got != "" {
t.Fatalf("pickString(missing) = %q, want empty", got)
}
if n, ok := pickInt(m, "missing", "ns"); !ok || n != 42 {
t.Fatalf("pickInt = %d, %v; want 42, true", n, ok)
}
if b, ok := pickBool(m, "t"); !ok || !b {
t.Fatalf("pickBool = %v, %v; want true, true", b, ok)
}
if b, ok := pickBool(m, "z"); !ok || b {
t.Fatalf("pickBool(0) = %v, %v; want false, true", b, ok)
}
if _, ok := pickBool(m, "a"); ok {
t.Fatal("a non-boolean string must not read as a bool")
}
}
func TestFillString(t *testing.T) {
s := ""
fillString(&s, " first ")
fillString(&s, "second")
if s != "first" {
t.Fatalf("s = %q, want first", s)
}
}
+20
View File
@@ -339,6 +339,26 @@
"controlRevokeConfirm": "Tilbagekald denne laders styringstoken? Den bliver afbrudt og kan ikke forbinde igen, før du genererer et nyt.",
"controlConnected": "Forbundet til styrings-backend",
"controlDisconnected": "Ikke forbundet",
"chargersTitle": "Dine ladere",
"chargersHint": "Alle EV-ladere på den tilknyttede Anker-konto — både dem, der står for sig selv, og dem i et system.",
"chargersRefresh": "Opdater",
"chargersLoading": "Indlæser…",
"chargersEmpty": "Der blev ikke fundet nogen EV-lader på denne konto.",
"chargerUnnamed": "Lader uden navn",
"chargerOffline": "Offline",
"chargerUse": "Brug denne",
"states": {
"standby": "Standby",
"preparing": "Forbereder",
"charging": "Lader",
"charger_paused": "Sat på pause af laderen",
"vehicle_paused": "Sat på pause af bilen",
"completed": "Fuldført",
"reserving": "Reserveret",
"disabled": "Deaktiveret",
"error": "Fejl",
"unknown": "Ukendt"
},
"greencell": "Greencell (HabuDen EV-lader)",
"greencellDesc": "Læs din Greencell-lader via den MQTT-broker, den publicerer til. Kun lokalt — ingen Greencell-skykonto er involveret.",
"greencellBroker": "MQTT-broker",
+20
View File
@@ -219,6 +219,26 @@
"controlRevokeConfirm": "Revoke this charger's control token? It will disconnect and can't reconnect until you generate a new one.",
"controlConnected": "Connected to control backend",
"controlDisconnected": "Not connected",
"chargersTitle": "Your chargers",
"chargersHint": "Every EV charger on the linked Anker account — the ones standing on their own and the ones inside a system.",
"chargersRefresh": "Refresh",
"chargersLoading": "Loading…",
"chargersEmpty": "No EV charger was found on this account.",
"chargerUnnamed": "Unnamed charger",
"chargerOffline": "Offline",
"chargerUse": "Use this one",
"states": {
"standby": "Standby",
"preparing": "Preparing",
"charging": "Charging",
"charger_paused": "Paused by charger",
"vehicle_paused": "Paused by car",
"completed": "Completed",
"reserving": "Reserved",
"disabled": "Disabled",
"error": "Error",
"unknown": "Unknown"
},
"greencell": "Greencell (HabuDen EV charger)",
"greencellDesc": "Read your Greencell wallbox over the MQTT broker it publishes to. Local only — no Greencell cloud account is involved.",
"greencellBroker": "MQTT broker",
+20
View File
@@ -343,6 +343,26 @@
"controlRevokeConfirm": "Unieważnić token sterowania tej ładowarki? Rozłączy się i nie połączy ponownie, dopóki nie wygenerujesz nowego.",
"controlConnected": "Połączono z backendem sterowania",
"controlDisconnected": "Nie połączono",
"chargersTitle": "Twoje ładowarki",
"chargersHint": "Wszystkie ładowarki EV na połączonym koncie Anker — te wolnostojące i te należące do systemu.",
"chargersRefresh": "Odśwież",
"chargersLoading": "Wczytywanie…",
"chargersEmpty": "Na tym koncie nie znaleziono żadnej ładowarki EV.",
"chargerUnnamed": "Ładowarka bez nazwy",
"chargerOffline": "Offline",
"chargerUse": "Użyj tej",
"states": {
"standby": "Czuwanie",
"preparing": "Przygotowanie",
"charging": "Ładowanie",
"charger_paused": "Wstrzymane przez ładowarkę",
"vehicle_paused": "Wstrzymane przez auto",
"completed": "Zakończone",
"reserving": "Zarezerwowana",
"disabled": "Wyłączona",
"error": "Błąd",
"unknown": "Nieznany"
},
"greencell": "Greencell (ładowarka EV HabuDen)",
"greencellDesc": "Odczytuj swoją ładowarkę Greencell przez brokera MQTT, do którego publikuje. Tylko lokalnie — konto w chmurze Greencell nie jest potrzebne.",
"greencellBroker": "Broker MQTT",
+9
View File
@@ -632,6 +632,15 @@ class ApiClient {
return IntegrationHealth.fromJson(Map<String, dynamic>.from(h));
}
/// The chargers on the linked Anker account, fetched server-side under the
/// resolved credentials. A closed gate answers 200 with an empty list and the
/// reason in `detail`, so this is not an error path.
Future<AnkerChargerList> listAnkerChargers() async {
final data = await _send("GET", "/integrations/anker-solix/chargers");
if (data is! Map) return const AnkerChargerList();
return AnkerChargerList.fromJson(Map<String, dynamic>.from(data));
}
// Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT
// broker rather than a cloud account — the charger publishes to a broker the
// owner runs and the server joins it. testGreencell connects to that broker and
+59
View File
@@ -1080,6 +1080,65 @@ class AnkerControl {
double get meterKwh => meterWh / 1000.0;
}
/// One EV charger on the linked Anker account, from …/anker-solix/chargers. The
/// server merges the cloud's several views of an account, so a charger reads the
/// same here whether it stands on its own or belongs to a system; a field no
/// view supplied simply stays empty.
class AnkerCharger {
final String sn;
final String name;
final String model;
final String siteName;
final String statusDesc; // charging | standby | … as the cloud names it
final bool? online; // null when no view reported a connection state
const AnkerCharger({
required this.sn,
this.name = "",
this.model = "",
this.siteName = "",
this.statusDesc = "",
this.online,
});
factory AnkerCharger.fromJson(Map<String, dynamic> j) => AnkerCharger(
sn: _asStr(j["sn"]),
name: _asStr(j["name"]),
model: _asStr(j["model"]),
siteName: _asStr(j["siteName"]),
statusDesc: _asStr(j["statusDesc"]),
online: j["online"] is bool ? j["online"] as bool : null,
);
/// What to call the charger in a list: its name when it has one, its serial
/// otherwise — never an empty row.
String get label => name.isNotEmpty ? name : sn;
}
/// The charger list plus the reason it may be empty (a gate that is off, or an
/// account that exposed nothing), so a caller can say why rather than show a
/// blank panel.
class AnkerChargerList {
final List<AnkerCharger> chargers;
final String detail;
const AnkerChargerList({this.chargers = const [], this.detail = ""});
factory AnkerChargerList.fromJson(Map<String, dynamic> j) {
final raw = j["chargers"];
return AnkerChargerList(
chargers: raw is List
? raw
.whereType<Map>()
.map((c) => AnkerCharger.fromJson(Map<String, dynamic>.from(c)))
.where((c) => c.sn.isNotEmpty)
.toList()
: const [],
detail: _asStr(j["detail"]),
);
}
}
// --- vehicle providers (the connected-service tab) ---------------------------
//
// A provider is a manufacturer service a car can be linked to (MyToyota today).
+49 -9
View File
@@ -705,6 +705,7 @@ class _ControlCard extends StatefulWidget {
class _ControlCardState extends State<_ControlCard> {
final _serial = TextEditingController();
List<AnkerCharger> _chargers = const []; // the account's chargers, when known
String _mode = "off"; // effective control mode (off | own | proxy)
AnkerControl? _ctl;
String? _error;
@@ -736,9 +737,29 @@ class _ControlCardState extends State<_ControlCard> {
} catch (_) {
if (mounted) setState(() => _mode = "off");
}
if (_active) await _loadChargers();
await _refresh();
}
/// The account's chargers turn the serial into a pick from a list. Without
/// them (nothing linked, or the cloud unreachable) the field stays a text box
/// so a serial can still be typed in by hand.
Future<void> _loadChargers() async {
try {
final res = await apiClient.listAnkerChargers();
if (!mounted) return;
setState(() {
_chargers = res.chargers;
// Nothing chosen yet: start on the first charger the account reports.
if (_serial.text.trim().isEmpty && _chargers.isNotEmpty) {
_serial.text = _chargers.first.sn;
}
});
} catch (_) {
if (mounted) setState(() => _chargers = const []);
}
}
Future<void> _refresh() async {
final sn = _serial.text.trim();
if (sn.isEmpty) {
@@ -819,15 +840,34 @@ class _ControlCardState extends State<_ControlCard> {
const SizedBox(height: 12),
Row(children: [
Expanded(
child: TextField(
controller: _serial,
autocorrect: false,
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
hintText: t("charging.control.serialPlaceholder"),
),
),
child: _chargers.isNotEmpty
? DropdownButtonFormField<String>(
initialValue: _serial.text.trim().isEmpty ? null : _serial.text.trim(),
isExpanded: true,
decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final c in _chargers)
DropdownMenuItem(
value: c.sn,
child: Text(c.name.isNotEmpty ? "${c.name} · ${c.sn}" : c.sn,
overflow: TextOverflow.ellipsis),
),
],
onChanged: (sn) {
if (sn == null) return;
setState(() => _serial.text = sn);
_refresh();
},
)
: TextField(
controller: _serial,
autocorrect: false,
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
hintText: t("charging.control.serialPlaceholder"),
),
),
),
const SizedBox(width: 8),
OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))),
+165
View File
@@ -2008,6 +2008,11 @@ class _IntegrationCardState extends State<_IntegrationCard> {
),
),
// The chargers on the account. With a control mode active the list lives
// inside the provisioning card below, where picking one fills the serial.
if (_c.anker && view.enabled && view.controlMode == "off")
const Padding(padding: EdgeInsets.only(top: 16), child: _AnkerChargers()),
// OCPP control provisioning (Anker only, when a control mode is active).
if (_c.anker && view.controlMode != "off")
Padding(
@@ -2112,6 +2117,161 @@ class _ConnBadge extends StatelessWidget {
}
}
/// The chargers on the linked Anker account. It answers the first question an
/// owner has after entering their credentials — did it find my chargers? — and,
/// where a control mode is active, hands the provisioning card the serial rather
/// than asking anyone to read it off the wall.
class _AnkerChargers extends StatefulWidget {
/// Called with a serial when a charger is picked; null hides the pick action
/// (nothing to fill in when control is off).
final void Function(String sn)? onPick;
const _AnkerChargers({this.onPick});
@override
State<_AnkerChargers> createState() => _AnkerChargersState();
}
class _AnkerChargersState extends State<_AnkerChargers> {
List<AnkerCharger> _chargers = const [];
String _detail = ""; // the server's reason for an empty list
bool _loading = false;
bool _loaded = false;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_error = null;
_loading = true;
});
try {
final res = await apiClient.listAnkerChargers();
if (mounted) {
setState(() {
_chargers = res.chargers;
_detail = res.detail;
_loaded = true;
});
}
} catch (e) {
if (mounted) {
setState(() {
_chargers = const [];
_error = "$e";
});
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// The cloud names a state in its own words (charging, standby, …); translate
/// it, falling back to the readable slug for a state we have no wording for.
String _stateLabel(String slug) {
final key = "settings.integrations.states.$slug";
final label = t(key);
return label == key ? slug.replaceAll("_", " ") : label;
}
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
final dark = DriverVault.isDark(context);
final sunken = dark ? DriverVault.darkSunken : DriverVault.ink25;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: sunken,
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Text(t("settings.integrations.chargersTitle"),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
),
TextButton(
onPressed: _loading ? null : _load,
child: Text(_loading
? t("settings.integrations.chargersLoading")
: t("settings.integrations.chargersRefresh")),
),
]),
Text(t("settings.integrations.chargersHint"), style: TextStyle(fontSize: 12, color: muted)),
for (final c in _chargers)
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: dark ? DriverVault.darkCard : Colors.white,
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(c.name.isNotEmpty ? c.name : t("settings.integrations.chargerUnnamed"),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
Text(
[
c.sn,
if (c.model.isNotEmpty) c.model,
if (c.siteName.isNotEmpty) c.siteName,
].join(" · "),
style: DriverVault.mono(context, size: 11).copyWith(color: muted),
),
if (c.statusDesc.isNotEmpty || c.online == false)
Text(
c.online == false
? t("settings.integrations.chargerOffline")
: _stateLabel(c.statusDesc),
style: TextStyle(
fontSize: 12,
color: c.online == false
? DriverVault.warning
: c.statusDesc == "charging"
? DriverVault.success
: muted,
),
),
],
),
),
if (widget.onPick != null)
TextButton(
onPressed: () => widget.onPick!(c.sn),
child: Text(t("settings.integrations.chargerUse")),
),
],
),
),
if (_chargers.isEmpty && _loaded)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_detail.isNotEmpty ? _detail : t("settings.integrations.chargersEmpty"),
style: TextStyle(fontSize: 13, color: muted)),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)),
),
],
),
);
}
}
/// Anker Solix OCPP control provisioning — per-charger token + connection status.
/// Only shown when a control mode (own/proxy) is active for the user.
class _AnkerControlProvision extends StatefulWidget {
@@ -2223,6 +2383,11 @@ class _AnkerControlProvisionState extends State<_AnkerControlProvision> {
style: TextStyle(fontSize: 12, color: muted),
),
const SizedBox(height: 12),
_AnkerChargers(onPick: (sn) {
setState(() => _serial.text = sn);
_loadControl();
}),
const SizedBox(height: 12),
Text(t("settings.integrations.chargerSerial"),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
+5
View File
@@ -300,6 +300,11 @@ export const api = {
getAnkerSolix: () => request("/integrations/anker-solix"),
saveAnkerSolix: (body) => request("/integrations/anker-solix", { method: "PUT", body: JSON.stringify(body) }),
testAnkerSolix: () => request("/integrations/anker-solix/health", { method: "POST" }),
// The chargers on the linked Anker account, fetched server-side under the
// resolved credentials: {chargers, count, boundCount?, detail?}. Answers 200
// with an empty list and a reason when a gate is off, so the caller can show
// the reason rather than an error.
listAnkerChargers: () => request("/integrations/anker-solix/chargers"),
// Anker Solix OCPP control (per charger). getAnkerControl returns the control
// mode, connection status, provisioning endpoint + token, and a live status
+20
View File
@@ -297,6 +297,26 @@
"controlRevokeConfirm": "Tilbagekald denne laders styringstoken? Den bliver afbrudt og kan ikke forbinde igen, før du genererer et nyt.",
"controlConnected": "Forbundet til styrings-backend",
"controlDisconnected": "Ikke forbundet",
"chargersTitle": "Dine ladere",
"chargersHint": "Alle EV-ladere på den tilknyttede Anker-konto — både dem, der står for sig selv, og dem i et system.",
"chargersRefresh": "Opdater",
"chargersLoading": "Indlæser…",
"chargersEmpty": "Der blev ikke fundet nogen EV-lader på denne konto.",
"chargerUnnamed": "Lader uden navn",
"chargerOffline": "Offline",
"chargerUse": "Brug denne",
"states": {
"standby": "Standby",
"preparing": "Forbereder",
"charging": "Lader",
"charger_paused": "Sat på pause af laderen",
"vehicle_paused": "Sat på pause af bilen",
"completed": "Fuldført",
"reserving": "Reserveret",
"disabled": "Deaktiveret",
"error": "Fejl",
"unknown": "Ukendt"
},
"greencell": "Greencell (HabuDen EV-lader)",
"greencellDesc": "Læs din Greencell-lader via den MQTT-broker, den publicerer til. Kun lokalt — ingen Greencell-skykonto er involveret.",
"greencellBroker": "MQTT-broker",
+20
View File
@@ -296,6 +296,26 @@
"controlRevokeConfirm": "Revoke this charger's control token? It will disconnect and can't reconnect until you generate a new one.",
"controlConnected": "Connected to control backend",
"controlDisconnected": "Not connected",
"chargersTitle": "Your chargers",
"chargersHint": "Every EV charger on the linked Anker account — the ones standing on their own and the ones inside a system.",
"chargersRefresh": "Refresh",
"chargersLoading": "Loading…",
"chargersEmpty": "No EV charger was found on this account.",
"chargerUnnamed": "Unnamed charger",
"chargerOffline": "Offline",
"chargerUse": "Use this one",
"states": {
"standby": "Standby",
"preparing": "Preparing",
"charging": "Charging",
"charger_paused": "Paused by charger",
"vehicle_paused": "Paused by car",
"completed": "Completed",
"reserving": "Reserved",
"disabled": "Disabled",
"error": "Error",
"unknown": "Unknown"
},
"greencell": "Greencell (HabuDen EV charger)",
"greencellDesc": "Read your Greencell wallbox over the MQTT broker it publishes to. Local only — no Greencell cloud account is involved.",
"greencellBroker": "MQTT broker",
+20
View File
@@ -301,6 +301,26 @@
"controlRevokeConfirm": "Unieważnić token sterowania tej ładowarki? Rozłączy się i nie połączy ponownie, dopóki nie wygenerujesz nowego.",
"controlConnected": "Połączono z backendem sterowania",
"controlDisconnected": "Nie połączono",
"chargersTitle": "Twoje ładowarki",
"chargersHint": "Wszystkie ładowarki EV na połączonym koncie Anker — te wolnostojące i te należące do systemu.",
"chargersRefresh": "Odśwież",
"chargersLoading": "Wczytywanie…",
"chargersEmpty": "Na tym koncie nie znaleziono żadnej ładowarki EV.",
"chargerUnnamed": "Ładowarka bez nazwy",
"chargerOffline": "Offline",
"chargerUse": "Użyj tej",
"states": {
"standby": "Czuwanie",
"preparing": "Przygotowanie",
"charging": "Ładowanie",
"charger_paused": "Wstrzymane przez ładowarkę",
"vehicle_paused": "Wstrzymane przez auto",
"completed": "Zakończone",
"reserving": "Zarezerwowana",
"disabled": "Wyłączona",
"error": "Błąd",
"unknown": "Nieznany"
},
"greencell": "Greencell (ładowarka EV HabuDen)",
"greencellDesc": "Odczytuj swoją ładowarkę Greencell przez brokera MQTT, do którego publikuje. Tylko lokalnie — konto w chmurze Greencell nie jest potrzebne.",
"greencellBroker": "Broker MQTT",
+33 -1
View File
@@ -74,6 +74,28 @@ async function loadCtlMode() {
}
}
// The chargers on the linked Anker account. With them the serial is a pick from
// a list; without them (account not linked, or the cloud unreachable) the field
// stays a plain text box so a serial can still be typed in by hand.
const chargers = ref([]);
async function loadChargers() {
try {
const res = await api.listAnkerChargers();
chargers.value = res?.chargers || [];
} catch {
chargers.value = [];
}
// Nothing chosen yet: start on the first charger the account reports.
if (!ctlSerial.value.trim() && chargers.value.length) {
ctlSerial.value = chargers.value[0].sn;
}
}
function chargerLabel(c) {
return c.name ? `${c.name} · ${c.sn}` : c.sn;
}
async function refreshCtl() {
const sn = ctlSerial.value.trim();
if (!sn) {
@@ -131,6 +153,7 @@ function cancelReset() {
onMounted(async () => {
await loadCtlMode();
if (ctlActive.value) await loadChargers();
await refreshCtl();
});
</script>
@@ -290,7 +313,16 @@ onMounted(async () => {
</div>
<div class="mt-3 flex gap-2">
<input v-model="ctlSerial" class="dh-input" :placeholder="t('charging.control.serialPlaceholder')" autocomplete="off" />
<select v-if="chargers.length" v-model="ctlSerial" class="dh-input" @change="refreshCtl">
<option v-for="c in chargers" :key="c.sn" :value="c.sn">{{ chargerLabel(c) }}</option>
</select>
<input
v-else
v-model="ctlSerial"
class="dh-input"
:placeholder="t('charging.control.serialPlaceholder')"
autocomplete="off"
/>
<button class="dh-btn dh-btn-ghost shrink-0" @click="refreshCtl">{{ t("charging.control.refresh") }}</button>
</div>
+98
View File
@@ -529,6 +529,57 @@ watch(ankerCtlSerial, () => {
ankerNewToken.value = "";
});
// --- The chargers on the linked Anker account ---
// Proof that the login found something, and the source of the serial the control
// block needs — the serial is printed on the charger, but nobody wants to go and
// read it off the wall when the account already knows it.
const ankerChargers = ref([]);
const ankerChargersLoading = ref(false);
const ankerChargersError = ref("");
const ankerChargersDetail = ref(""); // why the list is empty, when the server says
const ankerChargersLoaded = ref(false);
async function loadAnkerChargers() {
ankerChargersError.value = "";
ankerChargersLoading.value = true;
try {
const res = await api.listAnkerChargers();
ankerChargers.value = res?.chargers || [];
ankerChargersDetail.value = res?.detail || "";
ankerChargersLoaded.value = true;
} catch (e) {
ankerChargers.value = [];
ankerChargersError.value = e.message;
} finally {
ankerChargersLoading.value = false;
}
}
// Load once the card is open and the integration is on — opening the card is the
// moment the user is asking "what is on my account?", and every other visit to
// Settings should not spend an Anker round trip.
watch([ankerOpen, () => anker.value?.enabled], ([open, enabled]) => {
if (open && enabled && !ankerChargersLoaded.value && !ankerChargersLoading.value) {
loadAnkerChargers();
}
});
// A charger's operating state arrives as the cloud's own slug (charging,
// standby, …); translate it, and fall back to the readable slug for a state we
// have no wording for yet.
function ankerStateLabel(slug) {
if (!slug) return "";
const key = `settings.integrations.states.${slug}`;
const label = t(key);
return label === key ? slug.replace(/_/g, " ") : label;
}
// Point the control block at a charger picked from the list.
function useAnkerCharger(sn) {
ankerCtlSerial.value = sn;
loadAnkerControl();
}
function applyAnkerView(body) {
anker.value = body;
if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user";
@@ -1428,6 +1479,53 @@ onBeforeUnmount(() => {
{{ ankerHealth.detail || ankerHealth.status }}
</p>
<!-- The chargers found on the account -->
<div v-if="anker.enabled" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.chargersTitle") }}</p>
<button class="dh-btn dh-btn-ghost" :disabled="ankerChargersLoading" @click="loadAnkerChargers">
{{ ankerChargersLoading ? t("settings.integrations.chargersLoading") : t("settings.integrations.chargersRefresh") }}
</button>
</div>
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.chargersHint") }}</p>
<ul v-if="ankerChargers.length" class="mt-3 grid gap-2">
<li
v-for="c in ankerChargers"
:key="c.sn"
class="flex flex-wrap items-center justify-between gap-2 rounded-control border border-subtle bg-card px-3 py-2"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-strong">
{{ c.name || t("settings.integrations.chargerUnnamed") }}
</p>
<p class="data truncate text-xs text-muted">
{{ c.sn }}<span v-if="c.model"> · {{ c.model }}</span><span v-if="c.siteName"> · {{ c.siteName }}</span>
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span v-if="c.online === false" class="dh-badge dh-badge-warning">
{{ t("settings.integrations.chargerOffline") }}
</span>
<span v-if="c.statusDesc" class="dh-badge" :class="c.statusDesc === 'charging' ? 'dh-badge-success' : 'dh-badge-neutral'">
{{ ankerStateLabel(c.statusDesc) }}
</span>
<button
v-if="ankerControlMode !== 'off'"
class="dh-btn dh-btn-ghost"
@click="useAnkerCharger(c.sn)"
>
{{ t("settings.integrations.chargerUse") }}
</button>
</div>
</li>
</ul>
<p v-else-if="ankerChargersLoaded" class="mt-3 text-sm text-muted">
{{ ankerChargersDetail || t("settings.integrations.chargersEmpty") }}
</p>
<p v-if="ankerChargersError" class="mt-2 text-sm text-danger">{{ ankerChargersError }}</p>
</div>
<!-- OCPP control provisioning (only when a control mode is active) -->
<div v-if="ankerControlMode !== 'off'" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.controlTitle") }}</p>