diff --git a/API Server/internal/api/chargerproviders.go b/API Server/internal/api/chargerproviders.go index 8f3c48d..49bceec 100644 --- a/API Server/internal/api/chargerproviders.go +++ b/API Server/internal/api/chargerproviders.go @@ -87,6 +87,18 @@ type providerCharger struct { OcppStatus *int `json:"ocppStatus,omitempty"` OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` + // What the service knows about the box on the wall rather than the charging: + // the networks it is on, where it thinks it is, when the account bound it, + // and the picture the service shows for the model. + WifiName string `json:"wifiName,omitempty"` + WifiMac string `json:"wifiMac,omitempty"` + WifiRSSI *int `json:"wifiRssi,omitempty"` + BleMac string `json:"bleMac,omitempty"` + TimeZone string `json:"timeZone,omitempty"` + LinkedAt *float64 `json:"linkedAt,omitempty"` // unix seconds + ImageURL string `json:"imageUrl,omitempty"` + RelatedBy []string `json:"relatedBy,omitempty"` + // Attrs is everything else the service said about this charger, under the // service's own field names. The fields above are the ones DriverVault has a // name for; this is the remainder, relayed so a card can show what the @@ -138,6 +150,15 @@ func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger { OcppStatus *int `json:"ocppStatus"` OcppStatusDesc string `json:"ocppStatusDesc"` + WifiName string `json:"wifiName"` + WifiMac string `json:"wifiMac"` + WifiRSSI *int `json:"wifiRssi"` + BleMac string `json:"bleMac"` + TimeZone string `json:"timeZone"` + LinkedAt *float64 `json:"linkedAt"` + ImageURL string `json:"imageUrl"` + RelatedBy []string `json:"relatedBy"` + Attrs map[string]string `json:"attrs"` } `json:"chargers"` } @@ -154,6 +175,8 @@ func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger { Firmware: c.Firmware, SiteID: c.SiteID, SiteName: c.SiteName, Sources: c.Sources, Status: c.StatusDesc, Online: c.Online, Power: c.Power, OcppStatus: c.OcppStatus, OcppStatusDesc: c.OcppStatusDesc, + WifiName: c.WifiName, WifiMac: c.WifiMac, WifiRSSI: c.WifiRSSI, BleMac: c.BleMac, + TimeZone: c.TimeZone, LinkedAt: c.LinkedAt, ImageURL: c.ImageURL, RelatedBy: c.RelatedBy, Attrs: c.Attrs, }) } diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go index 33fcff5..d97a45f 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -544,3 +544,41 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) { // that document as-is so the UI sees exactly what the capability produced. writeJSON(w, http.StatusOK, json.RawMessage(raw)) } + +// GET /api/integrations/anker-solix/chargers/{sn}/details — every per-charger +// view the account holds: the station record, the charging totals, the OCPP +// backend and the RFID cards. Gated exactly like the charger list, and answered +// the same way when a gate is off: 200 with nothing and the reason, because a +// disconnected integration is a normal state with an answer. +func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Request) { + who := caller(r) + if who == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + sn := strings.TrimSpace(r.PathValue("sn")) + if sn == "" { + writeError(w, http.StatusBadRequest, "charger serial required") + return + } + userRaw := s.userPluginSettings(r.Context(), who.ID) + res := s.resolveAnker(r.Context(), who, userRaw) + + if reason := ankerGate(res, true); reason != "" { + writeJSON(w, http.StatusOK, map[string]any{"sn": sn, "views": []any{}, "unavailable": true, "detail": reason}) + return + } + + cfg := map[string]string{ + "email": res.eff.Email, + "password": res.eff.Password, + "country": res.eff.Country, + } + raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "charger-details", + mustJSON(map[string]any{"sn": sn})) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, json.RawMessage(raw)) +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 7e9ea7d..e62995b 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -51,6 +51,7 @@ // GET /api/integrations/anker-solix PUT /api/integrations/anker-solix // POST /api/integrations/anker-solix/health // GET /api/integrations/anker-solix/chargers +// GET /api/integrations/anker-solix/chargers/{sn}/details // GET /api/integrations/greencell PUT /api/integrations/greencell // POST /api/integrations/greencell/health // GET /api/integrations/greencell/chargers @@ -439,6 +440,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/integrations/anker-solix", s.handlePutAnker) mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth) mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers) + mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails) mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell) mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell) mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth) diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 311b6d4..09b99a5 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -181,6 +181,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor { AuthType: plugins.AuthBasic, Capabilities: []plugins.Capability{ {ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site, bound-device and per-charger station views, each charger carrying every field those views reported (see chargers.go)."}, + {ID: "charger-details", Method: "POST", Endpoint: epStationInfo, Description: "Every per-charger view the account holds — the station record, the charging totals, the OCPP backend and the RFID cards — each relayed as the fields it sent (needs sn)."}, {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)."}, @@ -320,6 +321,12 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa if action == "chargers" { return p.accountChargers(ctx) } + if action == "charger-details" { + if pp.SN == "" { + return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action) + } + return p.chargerDetails(ctx, pp.SN) + } if action == "charger-state" { if pp.SiteID == "" { return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action) diff --git a/API Server/internal/plugins/builtin/ankersolix/chargerdetails.go b/API Server/internal/plugins/builtin/ankersolix/chargerdetails.go new file mode 100644 index 0000000..791e0b4 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/chargerdetails.go @@ -0,0 +1,89 @@ +package ankersolix + +// The account's other views of one charger. +// +// The merged inventory (chargers.go) answers "what chargers are there", and it +// asks the views that list them. Four more endpoints answer only when a serial +// is named: the station record the app opens on a charger, its cumulative +// charging totals, which OCPP backend it is pointed at, and the RFID cards +// authorised on it. None of them lists a charger, so none belongs in the merge — +// and none of them was reachable from the app at all until this capability. +// +// What they answer with is not documented, by Anker or by the reference: the +// endpoints are known, their payloads are not. So each view is relayed as the +// fields it actually sent, flattened under the cloud's own keys, rather than +// projected onto names invented here. A field that turns out to matter can be +// named later, from evidence. + +import ( + "context" + "encoding/json" + "fmt" +) + +// chargerDetailView is one endpoint's answer about one charger. A view that +// fails carries its reason instead of its fields: an account that is not the +// owner cannot read the cards, which is a fact about the account rather than an +// error in the read. +type chargerDetailView struct { + ID string `json:"id"` + Attrs map[string]string `json:"attrs,omitempty"` + Error string `json:"error,omitempty"` +} + +type chargerDetailsDoc struct { + SN string `json:"sn"` + Views []chargerDetailView `json:"views"` +} + +// chargerDetails asks every per-charger endpoint and returns what each answered. +// One failing view is reported in place; only losing all of them is an error, +// for the same reason the inventory works that way — a charger the cloud will +// half talk about is still worth showing. +func (p *Plugin) chargerDetails(ctx context.Context, sn string) (json.RawMessage, error) { + if _, err := p.ensureToken(ctx); err != nil { + return nil, err + } + + views := []struct { + id string + endpoint string + payload map[string]any + }{ + {"station", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1}}, + {"totals", epChargeStats, map[string]any{ + "device_sn": sn, "date_type": "all", "start_date": "", "end_date": ""}}, + {"ocpp", epOcppInfo, map[string]any{"device_sn": sn}}, + {"rfid", epRfidCards, map[string]any{"device_sn": sn}}, + } + + doc := chargerDetailsDoc{SN: sn, Views: make([]chargerDetailView, 0, len(views))} + failed := 0 + for _, v := range views { + out := chargerDetailView{ID: v.id} + body, err := p.apiRequest(ctx, v.endpoint, v.payload) + if err != nil { + out.Error, failed = shorten(err.Error()), failed+1 + } else { + out.Attrs = map[string]string{} + flattenInto(out.Attrs, "", dataValue(body)) + } + doc.Views = append(doc.Views, out) + } + if failed == len(views) { + return nil, fmt.Errorf("anker-solix: charger %s: no per-charger view answered", sn) + } + return json.Marshal(doc) +} + +// dataValue returns a response's "data", whatever shape it came in: these views +// answer with an object, and the card list answers with an array. +func dataValue(body []byte) any { + var env struct { + Data any `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + return nil + } + return env.Data +} diff --git a/API Server/internal/plugins/builtin/ankersolix/chargers.go b/API Server/internal/plugins/builtin/ankersolix/chargers.go index 2dbc76a..4915514 100644 --- a/API Server/internal/plugins/builtin/ankersolix/chargers.go +++ b/API Server/internal/plugins/builtin/ankersolix/chargers.go @@ -56,6 +56,19 @@ type accountCharger struct { OcppStatus *int `json:"ocppStatus,omitempty"` OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` + // What the account knows about the box on the wall, as opposed to the + // charging: which networks it is on, where it thinks it is, when it was + // bound, and the picture the app shows for it. Every one of these arrives + // with the device views and none of them was ever read. + WifiName string `json:"wifiName,omitempty"` + WifiMac string `json:"wifiMac,omitempty"` + WifiRSSI *int `json:"wifiRssi,omitempty"` + BleMac string `json:"bleMac,omitempty"` + TimeZone string `json:"timeZone,omitempty"` + LinkedAt *float64 `json:"linkedAt,omitempty"` // unix seconds + ImageURL string `json:"imageUrl,omitempty"` + RelatedBy []string `json:"relatedBy,omitempty"` // ble, wifi — how the app reaches it + // Attrs is everything each view reported about this charger, under the key // the cloud used for it. The fields above are the ones DriverVault gives a // name and a meaning to; Attrs is the rest, relayed rather than dropped, @@ -107,6 +120,13 @@ func flattenInto(dst map[string]string, key string, v any) { if s == "" { return // nothing said is not a value } + // A device record carries the charger's own Bluetooth pairing password, + // and the account views hand out more than one credential besides. That a + // field exists is worth reporting; its value is not something a card + // should put on a screen, or a screenshot should carry off one. + if isSecretKey(key) { + s = "\u2022\u2022\u2022\u2022\u2022\u2022" + } // Cut by rune, not by byte: half a character is not shorter, it is // broken, and it would reach the UI as a replacement glyph. if r := []rune(s); len(r) > maxAttrsLen { @@ -116,6 +136,23 @@ func flattenInto(dst map[string]string, key string, v any) { } } +// secretKeyWords are the field names whose values are credentials rather than +// readings. Matched on the leaf name, so a nested one is caught too. +var secretKeyWords = []string{"password", "passwd", "secret", "token", "private_key", "privatekey", "certificate", "auth_key", "authkey"} + +func isSecretKey(key string) bool { + leaf := strings.ToLower(key) + if i := strings.LastIndex(leaf, "."); i >= 0 { + leaf = leaf[i+1:] + } + for _, word := range secretKeyWords { + if strings.Contains(leaf, word) { + return true + } + } + return false +} + func joinAttrKey(prefix, key string) string { if prefix == "" { return key @@ -194,16 +231,49 @@ func (inv *chargerInventory) addStandalone(body []byte) { } c := inv.get(sn, "standalone") c.note(m) - 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 + fillDevice(c, m) + } +} + +// fillDevice reads the fields every device view shares. The three views name +// them alike where they can and differently where they cannot — the same status +// is evChargerStatus in one record and chargerStatus in the next — so each is +// looked up by candidate key, first answer winning, the way the rest of the +// merge works. +func fillDevice(c *accountCharger, m map[string]any) { + fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "deviceName", "name")) + fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn", "pn")) + fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version")) + fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId")) + fillString(&c.SiteName, pickString(m, "site_name", "siteName", "station_name", "stationName")) + fillString(&c.WifiName, pickString(m, "wifi_name", "wifiName", "ssid")) + fillString(&c.WifiMac, pickString(m, "wifi_mac", "wifiMac")) + fillString(&c.BleMac, pickString(m, "bt_ble_mac", "bleMac", "bt_mac")) + fillString(&c.TimeZone, pickString(m, "time_zone", "timeZone", "timezone")) + fillString(&c.ImageURL, pickString(m, "img_url", "imgUrl", "image_url")) + if n, ok := pickInt(m, "rssi", "wifi_rssi", "signal"); ok && c.WifiRSSI == nil { + c.WifiRSSI = &n + } + if t, ok := pickFloat(m, "link_time", "linkTime", "bind_time", "create_time"); ok && c.LinkedAt == nil { + c.LinkedAt = &t + } + if len(c.RelatedBy) == 0 { + c.RelatedBy = pickStrings(m, "relate_type", "relateType", "connect_type") + } + // The state, however the view spells it. A charger that answers one view and + // not another is the normal case, which is why this is tried on all of them. + if n, ok := pickInt(m, "evChargerStatus", "chargerStatus", "operating_state", "status"); ok { + setChargerStatus(c, n) + } + if n, ok := pickInt(m, "ocpp_connect_status", "ocppConnectStatus"); ok && c.OcppStatus == nil { + desc, named := ocppConnStatus[n] + if !named { + desc = stateUnknown } + c.OcppStatus, c.OcppStatusDesc = &n, desc + } + if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok { + c.Online = &b } } @@ -242,13 +312,7 @@ func (inv *chargerInventory) addBound(body []byte) { } c := inv.get(sn, "bound") c.note(m) - 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 - } + fillDevice(c, m) } } @@ -284,25 +348,8 @@ func (inv *chargerInventory) addStation(sn string, body []byte) { return } c.note(m) - fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "name")) - fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn")) - fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version")) - fillString(&c.SiteID, pickString(m, "station_id", "stationId", "site_id", "siteId")) - fillString(&c.SiteName, pickString(m, "station_name", "stationName", "site_name", "siteName")) + fillDevice(c, m) fillString(&c.Power, pickString(m, "power", "charging_power", "chargingPower")) - if n, ok := pickInt(m, "evChargerStatus", "operating_state", "status"); ok { - setChargerStatus(c, n) - } - if n, ok := pickInt(m, "ocpp_connect_status", "ocppConnectStatus"); ok && c.OcppStatus == nil { - desc, named := ocppConnStatus[n] - if !named { - desc = stateUnknown - } - c.OcppStatus, c.OcppStatusDesc = &n, desc - } - if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok && c.Online == nil { - c.Online = &b - } } // siteRef is one system (site) registered on the account. @@ -549,6 +596,44 @@ func pickString(m map[string]any, keys ...string) string { return "" } +// pickFloat returns the first numeric value among keys, unrounded — a unix +// timestamp does not fit an int on every platform and does not want scaling. +func pickFloat(m map[string]any, keys ...string) (float64, bool) { + for _, k := range keys { + switch v := m[k].(type) { + case float64: + return v, true + case string: + if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + return f, true + } + } + } + return 0, false +} + +// pickStrings returns the first list of strings among keys. The device views +// carry the ways the app can reach a charger this way — ble, wifi — as a plain +// array of names. +func pickStrings(m map[string]any, keys ...string) []string { + for _, k := range keys { + raw, ok := m[k].([]any) + if !ok { + continue + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + if len(out) > 0 { + return out + } + } + return nil +} + // pickInt returns the first numeric value among keys. func pickInt(m map[string]any, keys ...string) (int, bool) { for _, k := range keys { diff --git a/API Server/internal/plugins/builtin/ankersolix/chargers_test.go b/API Server/internal/plugins/builtin/ankersolix/chargers_test.go index 00a718e..f3c90cd 100644 --- a/API Server/internal/plugins/builtin/ankersolix/chargers_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/chargers_test.go @@ -1,6 +1,9 @@ package ankersolix -import "testing" +import ( + "strings" + "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 @@ -234,3 +237,58 @@ func TestRawRecordsDescendsToANestedList(t *testing.T) { t.Fatal("a data that is not an object should yield nothing") } } + +// The bound-device view carries the charger's state and its OCPP link under +// names of its own — chargerStatus, not evChargerStatus — which is how a card +// ends up printing a dash beside a value the same response contains. +func TestBoundViewNamesTheStateItsOwnWay(t *testing.T) { + inv := newInventory() + inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","alias_name":"Home-DK", + "chargerStatus":2,"ocpp_connect_status":2,"wifi_online":true,"wifi_name":"AP_1_IOT", + "wifi_mac":"7CE91373C236","bt_ble_mac":"7CE91373C238","rssi":-63,"time_zone":"Europe/Copenhagen", + "link_time":1788186620,"img_url":"https://example.invalid/a5191.png","relate_type":["ble","wifi"]}]}}`)) + + c := inv.list()[0] + if c.StatusDesc != stateCharging { + t.Errorf("statusDesc = %q, want %q — the bound view spells it chargerStatus", c.StatusDesc, stateCharging) + } + if c.OcppStatusDesc != "connected" { + t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc) + } + if c.WifiName != "AP_1_IOT" || c.WifiMac != "7CE91373C236" || c.BleMac != "7CE91373C238" { + t.Errorf("networks = %+v", c) + } + if c.WifiRSSI == nil || *c.WifiRSSI != -63 { + t.Errorf("wifiRssi = %v, want -63", c.WifiRSSI) + } + if c.TimeZone != "Europe/Copenhagen" || c.ImageURL == "" { + t.Errorf("timeZone/imageUrl = %q / %q", c.TimeZone, c.ImageURL) + } + if c.LinkedAt == nil || *c.LinkedAt != 1788186620 { + t.Errorf("linkedAt = %v, want 1788186620", c.LinkedAt) + } + if len(c.RelatedBy) != 2 || c.RelatedBy[0] != "ble" || c.RelatedBy[1] != "wifi" { + t.Errorf("relatedBy = %v, want [ble wifi]", c.RelatedBy) + } +} + +// A device record carries the charger's Bluetooth pairing password. That the +// field exists is reportable; its value is not something a card should carry. +func TestAttrsMaskCredentials(t *testing.T) { + inv := newInventory() + inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191", + "blue_password":"AT200025277*!","wifi_name":"AP_1_IOT","cloud":{"auth_token":"abc"}}]}}`)) + + attrs := inv.list()[0].Attrs + for _, key := range []string{"blue_password", "cloud.auth_token"} { + if attrs[key] == "" { + t.Fatalf("attrs[%q] is absent; the field should still be reported", key) + } + if strings.ContainsAny(attrs[key], "ATabc") { + t.Errorf("attrs[%q] = %q, want it masked", key, attrs[key]) + } + } + if attrs["wifi_name"] != "AP_1_IOT" { + t.Errorf("wifi_name = %q; masking must not reach a value that is not a credential", attrs["wifi_name"]) + } +} diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 00f25f8..03c7b24 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -303,6 +303,19 @@ "ocpp": "OCPP-status", "added": "Tilføjet", "online": "Online", + "wifiName": "Wi-Fi-netværk", + "wifiMac": "Wi-Fi-MAC", + "signal": "Signal", + "bleMac": "Bluetooth-MAC", + "relatedBy": "Kan nås via", + "timeZone": "Tidszone", + "linked": "Tilknyttet", + "views": { + "station": "Stationspost", + "totals": "Ladetotaler", + "ocpp": "OCPP-backend", + "rfid": "RFID-kort" + }, "offline": "Offline", "refresh": "Opdater", "rawTitle": "Som tjenesten melder det", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index f2a53d9..f451188 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -303,6 +303,19 @@ "ocpp": "OCPP status", "added": "Added", "online": "Online", + "wifiName": "Wi-Fi network", + "wifiMac": "Wi-Fi MAC", + "signal": "Signal", + "bleMac": "Bluetooth MAC", + "relatedBy": "Reachable by", + "timeZone": "Time zone", + "linked": "Linked", + "views": { + "station": "Station record", + "totals": "Charging totals", + "ocpp": "OCPP backend", + "rfid": "RFID cards" + }, "offline": "Offline", "refresh": "Refresh", "rawTitle": "As the service reports it", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index b607634..87641e7 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -305,6 +305,19 @@ "ocpp": "Status OCPP", "added": "Dodano", "online": "Online", + "wifiName": "Sieć Wi-Fi", + "wifiMac": "MAC Wi-Fi", + "signal": "Sygnał", + "bleMac": "MAC Bluetooth", + "relatedBy": "Dostępna przez", + "timeZone": "Strefa czasowa", + "linked": "Powiązano", + "views": { + "station": "Rekord stacji", + "totals": "Sumy ładowania", + "ocpp": "Backend OCPP", + "rfid": "Karty RFID" + }, "offline": "Offline", "refresh": "Odśwież", "rawTitle": "Tak, jak podaje to usługa", diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index 730d502..7cd919a 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -641,6 +641,15 @@ class ApiClient { return AnkerChargerList.fromJson(Map.from(data)); } + /// Every per-charger view the account holds — the station record, the charging + /// totals, the OCPP backend and the RFID cards — asked for one charger at a + /// time, because none of those endpoints lists chargers. + Future getAnkerChargerDetails(String sn) async { + final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/details"); + if (data is! Map) return const ChargerDetails(); + return ChargerDetails.fromJson(Map.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 diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 61aad6d..99f7986 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -1399,6 +1399,18 @@ class ProviderCharger { final int? ocppStatus; final String ocppStatusDesc; + /// What the account knows about the box on the wall, as opposed to the + /// charging: which networks it is on, where it thinks it is, when it was + /// bound, and the picture the app shows for it. + final String wifiName; + final String wifiMac; + final int? wifiRssi; + final String bleMac; + final String timeZone; + final double? linkedAt; // unix seconds + final String imageUrl; + final List relatedBy; // ble, wifi — how the app reaches it + /// Everything else the service said about this charger, under its own field /// names. The fields above are the ones DriverVault has a name for; this is /// the remainder, kept rather than dropped — the service documents none of it, @@ -1423,6 +1435,14 @@ class ProviderCharger { this.power = "", this.ocppStatus, this.ocppStatusDesc = "", + this.wifiName = "", + this.wifiMac = "", + this.wifiRssi, + this.bleMac = "", + this.timeZone = "", + this.linkedAt, + this.imageUrl = "", + this.relatedBy = const [], this.attrs = const {}, this.linkedChargerId = "", }); @@ -1441,6 +1461,14 @@ class ProviderCharger { power: _asStr(j["power"]), ocppStatus: _asIntOrNull(j["ocppStatus"]), ocppStatusDesc: _asStr(j["ocppStatusDesc"]), + wifiName: _asStr(j["wifiName"]), + wifiMac: _asStr(j["wifiMac"]), + wifiRssi: _asIntOrNull(j["wifiRssi"]), + bleMac: _asStr(j["bleMac"]), + timeZone: _asStr(j["timeZone"]), + linkedAt: _asDoubleOrNull(j["linkedAt"]), + imageUrl: _asStr(j["imageUrl"]), + relatedBy: _asStrList(j["relatedBy"]), attrs: _asStrMap(j["attrs"]), linkedChargerId: _asStr(j["linkedChargerId"]), ); @@ -1457,6 +1485,53 @@ class ProviderCharger { } } +/// One of the account's per-charger views — the station record, the charging +/// totals, the OCPP backend, the RFID cards. Anker documents none of these +/// payloads, so the fields arrive under the cloud's own keys; a view the account +/// cannot read carries its reason instead. +class ChargerDetailView { + final String id; + final Map attrs; + final String error; + + const ChargerDetailView({required this.id, this.attrs = const {}, this.error = ""}); + + factory ChargerDetailView.fromJson(Map j) => ChargerDetailView( + id: _asStr(j["id"]), + attrs: _asStrMap(j["attrs"]), + error: _asStr(j["error"]), + ); + + /// The fields, sorted, so the same charger reads the same way every time. + List<(String, String)> get rows { + final keys = attrs.keys.toList()..sort(); + return [for (final key in keys) (key, attrs[key]!)]; + } +} + +/// Every per-charger view for one charger, from +/// …/anker-solix/chargers/{sn}/details. +class ChargerDetails { + final String sn; + final List views; + + const ChargerDetails({this.sn = "", this.views = const []}); + + factory ChargerDetails.fromJson(Map j) { + final raw = j["views"]; + return ChargerDetails( + sn: _asStr(j["sn"]), + views: raw is List + ? raw + .whereType() + .map((v) => ChargerDetailView.fromJson(Map.from(v))) + .where((v) => v.attrs.isNotEmpty || v.error.isNotEmpty) + .toList() + : const [], + ); + } +} + /// The chargers on one provider account, plus the reason the list may be empty: /// a gate that is off answers 200 with nothing and a sentence, so the UI can say /// "connect this in Settings" rather than show a blank panel. diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index 481a5e5..d475b2f 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -1052,6 +1052,12 @@ class _HomeTabState extends State<_HomeTab> { String _selected = ""; // the charger the cards are about String _removing = ""; + /// The account's per-charger views, kept by serial: four endpoints answer only + /// when a serial is named, so they are asked for the charger being looked at + /// and remembered, rather than asked again on every glance. + final Map _details = {}; + String _detailsLoading = ""; + /// Which cards are folded, read once and written on every toggle. Set _collapsed = {}; @@ -1216,6 +1222,37 @@ class _HomeTabState extends State<_HomeTab> { _liveLoaded = true; _liveLoading = false; }); + // The card's other half: the views that answer per charger rather than per + // account. Refreshing the card refreshes both. + _loadDetails(force: force); + } + + /// The per-charger views for the charger on screen. A view the account cannot + /// read is not an error to put on the page — the rows above still say + /// everything the inventory knew. + Future _loadDetails({bool force = false}) async { + final c = _selectedCharger; + final sn = c == null + ? "" + : (c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial); + if (sn.isEmpty || c!.provider != "anker-solix") return; + if (_detailsLoading == sn || (_details.containsKey(sn) && !force)) return; + _detailsLoading = sn; + try { + final res = await apiClient.getAnkerChargerDetails(sn); + if (mounted) setState(() => _details[sn] = res); + } catch (_) { + // Left absent rather than shown as a failure. + } finally { + _detailsLoading = ""; + } + } + + /// The views to draw for the charger on screen, in the order the account + /// answered them. + List _detailViews(HomeCharger c) { + final sn = c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial; + return _details[sn]?.views ?? const []; } Future _refresh() async { @@ -1368,6 +1405,7 @@ class _HomeTabState extends State<_HomeTab> { /// A charger picked here becomes the one the control cards drive. void _select(HomeCharger c) { setState(() => _selected = c.id); + _loadDetails(); if (c.serial.isEmpty) return; _serial.text = c.serial; _refresh(); @@ -2314,6 +2352,17 @@ class _HomeTabState extends State<_HomeTab> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ + // The product shot the app shows for this model, when the + // account sent one. Decorative: the name beside it says + // everything the picture does. + if ((_liveFor(charger)?.imageUrl ?? "").isNotEmpty) ...[ + Image.network(_liveFor(charger)!.imageUrl, + width: 28, + height: 28, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const SizedBox.shrink()), + const SizedBox(width: 8), + ], Expanded( child: Text(charger.name, maxLines: 1, @@ -2349,6 +2398,24 @@ class _HomeTabState extends State<_HomeTab> { const SizedBox(height: 4), Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)), ], + // The views that answer per charger rather than per account. + // Each says what it knows, or why it could not be read — an + // account that is not the charger's owner cannot read the + // cards, which is a fact about the account rather than a + // failure. + for (final view in _detailViews(charger)) ...[ + const SizedBox(height: 10), + Text( + t("charging.info.views.${view.id}"), + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) + .copyWith(letterSpacing: 1.4), + ), + const SizedBox(height: 4), + if (view.error.isNotEmpty) + Text(view.error, style: TextStyle(fontSize: 11, color: muted)) + else + _PairList(rows: view.rows, breakLong: true), + ], ], ), ), @@ -2392,6 +2459,21 @@ class _HomeTabState extends State<_HomeTab> { // on it here would be inventing it. ("chargePower", live?.power ?? ""), ("ocpp", live?.ocppLabel ?? ""), + // The box on the wall, as opposed to the charging: the networks it is on, + // where it thinks it is, and when the account first saw it. + ("wifiName", live?.wifiName ?? ""), + ("wifiMac", live?.wifiMac ?? ""), + ("signal", live?.wifiRssi == null ? "" : "${live!.wifiRssi} dBm"), + ("bleMac", live?.bleMac ?? ""), + ("relatedBy", (live?.relatedBy ?? const []).join(" · ")), + ("timeZone", live?.timeZone ?? ""), + ( + "linked", + live?.linkedAt == null + ? "" + : formatDateTime( + DateTime.fromMillisecondsSinceEpoch((live!.linkedAt! * 1000).round())) + ), ("providerId", c.providerChargerId), ("added", c.created.isEmpty ? "" : formatDateTime(DateTime.tryParse(c.created)?.toLocal())), ]; diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 8dad465..ce7eca0 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -328,6 +328,11 @@ export const api = { // 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"), + // Every per-charger view the account holds — the station record, the charging + // totals, the OCPP backend and the RFID cards — asked for one charger at a + // time, because none of those endpoints lists chargers. + getAnkerChargerDetails: (sn) => + request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/details`), // Anker Solix control (per charger), over whichever transport the user's // control mode selects. getAnkerControl returns the control mode, connection diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 6c3db89..c6e77d4 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -263,6 +263,19 @@ "ocpp": "OCPP-status", "added": "Tilføjet", "online": "Online", + "wifiName": "Wi-Fi-netværk", + "wifiMac": "Wi-Fi-MAC", + "signal": "Signal", + "bleMac": "Bluetooth-MAC", + "relatedBy": "Kan nås via", + "timeZone": "Tidszone", + "linked": "Tilknyttet", + "views": { + "station": "Stationspost", + "totals": "Ladetotaler", + "ocpp": "OCPP-backend", + "rfid": "RFID-kort" + }, "offline": "Offline", "refresh": "Opdater", "rawTitle": "Som tjenesten melder det", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index ee460b4..93d6fea 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -249,6 +249,19 @@ "ocpp": "OCPP status", "added": "Added", "online": "Online", + "wifiName": "Wi-Fi network", + "wifiMac": "Wi-Fi MAC", + "signal": "Signal", + "bleMac": "Bluetooth MAC", + "relatedBy": "Reachable by", + "timeZone": "Time zone", + "linked": "Linked", + "views": { + "station": "Station record", + "totals": "Charging totals", + "ocpp": "OCPP backend", + "rfid": "RFID cards" + }, "offline": "Offline", "refresh": "Refresh", "rawTitle": "As the service reports it", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 27d5885..af70ada 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -265,6 +265,19 @@ "ocpp": "Status OCPP", "added": "Dodano", "online": "Online", + "wifiName": "Sieć Wi-Fi", + "wifiMac": "MAC Wi-Fi", + "signal": "Sygnał", + "bleMac": "MAC Bluetooth", + "relatedBy": "Dostępna przez", + "timeZone": "Strefa czasowa", + "linked": "Powiązano", + "views": { + "station": "Rekord stacji", + "totals": "Sumy ładowania", + "ocpp": "Backend OCPP", + "rfid": "Karty RFID" + }, "offline": "Offline", "refresh": "Odśwież", "rawTitle": "Tak, jak podaje to usługa", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index b36065f..05346c6 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -755,6 +755,9 @@ async function loadChargerLive(force = false) { chargerLive.value = live; chargerLiveLoaded.value = true; chargerLiveLoading.value = false; + // The card's other half: the views that answer per charger rather than per + // account. Refreshing the card refreshes both. + loadChargerDetails(force); } // The live half for one imported charger, or null when the service it came from @@ -830,6 +833,7 @@ const selectedHomeCharger = computed( // A charger picked here becomes the one the control card drives. function selectHomeCharger(c) { selected.value = c.id; + loadChargerDetails(); if (!c.serial) return; ctlSerial.value = c.serial; refreshCtl(); @@ -886,6 +890,15 @@ function chargerInfoRows(c) { // on it here would be inventing it. ["chargePower", live.power], ["ocpp", ocppStatusLabel(live)], + // The box on the wall, as opposed to the charging: the networks it is on, + // where it thinks it is, and when the account first saw it. + ["wifiName", live.wifiName], + ["wifiMac", live.wifiMac], + ["signal", isSet(live.wifiRssi) ? `${live.wifiRssi} dBm` : ""], + ["bleMac", live.bleMac], + ["relatedBy", (live.relatedBy || []).join(" · ")], + ["timeZone", live.timeZone], + ["linked", live.linkedAt ? formatDateTime(new Date(live.linkedAt * 1000)) : ""], ["providerId", c.providerChargerId], ["added", c.created ? formatDateTime(c.created) : ""], ]; @@ -896,6 +909,50 @@ function chargerInfoRows(c) { })); } +// --- The account's other views of one charger --- +// +// Four endpoints answer only when a serial is named — the station record, the +// charging totals, the OCPP backend, the RFID cards — so none of them can be +// part of the list the card is drawn from. They are asked for the charger being +// looked at, when it is being looked at, and the answers are kept per serial so +// switching back to a charger does not ask again. +const chargerDetails = ref({}); // serial → the views document +const chargerDetailsLoading = ref(""); + +async function loadChargerDetails(force = false) { + const c = selectedHomeCharger.value; + const sn = c?.providerChargerId || c?.serial || ""; + if (!sn || c.provider !== "anker-solix") return; + if (chargerDetailsLoading.value === sn) return; + if (chargerDetails.value[sn] && !force) return; + chargerDetailsLoading.value = sn; + try { + const res = await api.getAnkerChargerDetails(sn); + chargerDetails.value = { ...chargerDetails.value, [sn]: res }; + } catch { + // A view the account cannot read is not an error to put on the page: the + // rows above still say everything the inventory knew. + } finally { + chargerDetailsLoading.value = ""; + } +} + +// One view's fields, sorted, or the reason it could not be read. The keys are +// the cloud's own: Anker documents none of these payloads, so a name invented +// here would be a meaning invented here. +const chargerDetailViews = computed(() => { + const c = selectedHomeCharger.value; + const sn = c?.providerChargerId || c?.serial || ""; + const doc = chargerDetails.value[sn]; + return (doc?.views || []).map((view) => ({ + id: view.id, + error: view.error || "", + rows: Object.keys(view.attrs || {}) + .sort() + .map((key) => ({ key, value: view.attrs[key] })), + })).filter((view) => view.rows.length || view.error); +}); + // Everything else the service said about this charger, under its own field // names. The rows above are the ones DriverVault has a name for; these are the // remainder — the service documents none of them, so its own key is the only @@ -1811,7 +1868,18 @@ onMounted(async () => {
-

{{ selectedHomeCharger.name }}

+
+ + +

{{ selectedHomeCharger.name }}

+
@@ -1846,6 +1914,21 @@ onMounted(async () => {

{{ t("charging.info.rawHint") }}

+ + +