From 2425a8d3d69b1570858ceb38b6c3d6f20fcf8d37 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:58:15 +0200 Subject: [PATCH] A field we have no name for is still a field it sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cards on the Charging page were answering with a fraction of what the charger and the account actually report, and in both the losses happened quietly, in a parse that kept the fields it recognised and dropped the rest on the floor. Charger information asked three account-wide views and kept fourteen fields. A charger registered on its own is absent from the site view, which is the only one of the three carrying state, charge power and OCPP status — so exactly the charger that stands alone got the column of dashes, and nothing said why. The per-charger station record, get_evcharger_station_info, is what the mobile app opens when you tap a charger, and it is the one view that answers for a charger outside a station; it is now the fourth view, asked per charger, a failure there costing that charger's row and no more. Alongside it, every field each of the four views sent is kept as attrs, under the cloud's own key, nested objects joined with a dot and arrays carrying their index. First view to answer a key wins, which is the rule the named fields already merged by. Two hundred keys and two hundred and forty runes per value keep a station record with a session list from becoming the whole card. Charger readings lost data twice over. The frame decoder skipped any field byte its per-message map could not name, and a message type with no map decoded to nothing at all; those fields are now kept under the message and the byte they arrived in — 0410.c9 — decoded but unscaled, because a factor is half of a meaning and we do not have the other half. Then the projection read forty-odd names into typed fields and dropped the remainder: sessionStartedAt, the per-phase session energies, the three touch modes, the load-balance monitor and its meter flag, the solar monitor. Those land in extra, and the list maintains itself — the four accessors note every key they read, extra is what is left, and a field modelled later stops appearing there without anyone remembering to remove it. Keeping unnamed fields had one consequence worth guarding. An unmapped message now decodes to something rather than nothing, and ingest stamped settingsAt for anything that was not telemetry — the timestamp a control command waits on to say the charger acknowledged it. A frame we cannot read is not an acknowledgement, so the stamp is now conditional on the message type being one we map, while its fields are kept either way. Both cards show the remainder as what it is: the service's own key, no unit, no translation, no renaming, under a heading that says whose words these are. The blocks appear only when there is something in them, so a Modbus charger's readings card and a charger the cloud says nothing more about are unchanged. Naming one of these fields is a later commit, made from evidence; inventing a label for it today would only make a guess look settled. Co-Authored-By: Claude Opus 5 --- API Server/internal/api/chargerproviders.go | 9 + .../plugins/builtin/ankersolix/ankersolix.go | 2 +- .../plugins/builtin/ankersolix/chargers.go | 212 +++++++++++++++++- .../builtin/ankersolix/chargers_test.go | 79 +++++++ .../plugins/builtin/ankersolix/cloudmqtt.go | 9 +- .../builtin/ankersolix/cloudmqtt_test.go | 18 ++ .../plugins/builtin/ankersolix/mqttframe.go | 22 +- .../builtin/ankersolix/mqttframe_test.go | 27 ++- .../builtin/ankersolix/mqttsnapshot.go | 30 +++ .../builtin/ankersolix/mqttsnapshot_test.go | 37 +++ Phone App/assets/i18n/da.json | 6 +- Phone App/assets/i18n/en.json | 6 +- Phone App/assets/i18n/pl.json | 6 +- Phone App/lib/models.dart | 30 +++ Phone App/lib/screens/charging_screen.dart | 62 +++++ Web App/web/src/i18n/da.json | 6 +- Web App/web/src/i18n/en.json | 6 +- Web App/web/src/i18n/pl.json | 6 +- Web App/web/src/views/Charging.vue | 54 +++++ 19 files changed, 609 insertions(+), 18 deletions(-) diff --git a/API Server/internal/api/chargerproviders.go b/API Server/internal/api/chargerproviders.go index a82cd76..8f3c48d 100644 --- a/API Server/internal/api/chargerproviders.go +++ b/API Server/internal/api/chargerproviders.go @@ -87,6 +87,12 @@ type providerCharger struct { OcppStatus *int `json:"ocppStatus,omitempty"` OcppStatusDesc string `json:"ocppStatusDesc,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 + // account actually knows rather than only the part we modelled. + Attrs map[string]string `json:"attrs,omitempty"` + // LinkedChargerID is set when this one is already in DriverVault, so the UI // never offers to import the same charger twice. LinkedChargerID string `json:"linkedChargerId,omitempty"` @@ -131,6 +137,8 @@ func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger { Power string `json:"power"` OcppStatus *int `json:"ocppStatus"` OcppStatusDesc string `json:"ocppStatusDesc"` + + Attrs map[string]string `json:"attrs"` } `json:"chargers"` } if json.Unmarshal(raw, &env) != nil { @@ -146,6 +154,7 @@ 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, + Attrs: c.Attrs, }) } return out diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 4d71b84..311b6d4 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -180,7 +180,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor { Category: plugins.CategoryChargers, 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 and bound-device views (see chargers.go)."}, + {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-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)."}, diff --git a/API Server/internal/plugins/builtin/ankersolix/chargers.go b/API Server/internal/plugins/builtin/ankersolix/chargers.go index f2b0200..2dbc76a 100644 --- a/API Server/internal/plugins/builtin/ankersolix/chargers.go +++ b/API Server/internal/plugins/builtin/ankersolix/chargers.go @@ -14,10 +14,17 @@ package ankersolix // 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. +// - get_evcharger_station_info answers for one charger at a time — the record +// the mobile app opens when you tap a charger. It is the only view that says +// anything live about a charger standing outside a station, which is exactly +// the charger the three list views say least about. // -// accountChargers therefore asks all three and merges the answers by serial, so +// accountChargers therefore asks all four and merges the answers by serial, so // the list matches what the mobile app shows however the chargers were -// registered. +// registered. Merging keeps the fields the rest of DriverVault has names for, +// and keeps every other field the cloud sent as well, under the cloud's own key +// (see attrs): a value nothing here recognises is still a value the owner of the +// charger may want to read. import ( "context" @@ -48,6 +55,89 @@ type accountCharger struct { Power string `json:"power,omitempty"` OcppStatus *int `json:"ocppStatus,omitempty"` OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` + + // 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, + // because Anker documents none of this and a field we have no name for today + // is still the only place some of what the charger knows ever appears. + Attrs map[string]string `json:"attrs,omitempty"` +} + +// Two limits keep one charger's attributes to something a card can hold: a +// station record that carries a session list would otherwise arrive as hundreds +// of indexed keys, and a base64 blob as one unreadable page. +const ( + maxAttrs = 200 + maxAttrsLen = 240 +) + +// note folds one view's raw record into the charger's attributes. A key already +// answered is left alone, so the first view to report a field wins — the same +// rule the typed fields merge by. +func (c *accountCharger) note(m map[string]any) { + if c.Attrs == nil { + c.Attrs = map[string]string{} + } + flattenInto(c.Attrs, "", m) +} + +// flattenInto records every scalar under v in dst, keyed by the path the cloud +// nested it at: objects join with a dot, arrays carry their index. A null is not +// an answer and leaves no key behind. +func flattenInto(dst map[string]string, key string, v any) { + switch t := v.(type) { + case map[string]any: + for k, sub := range t { + flattenInto(dst, joinAttrKey(key, k), sub) + } + case []any: + for i, sub := range t { + flattenInto(dst, fmt.Sprintf("%s[%d]", key, i), sub) + } + case nil: + default: + if key == "" || len(dst) >= maxAttrs { + return + } + if _, seen := dst[key]; seen { + return + } + s := attrString(t) + if s == "" { + return // nothing said is not a value + } + // 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 { + s = string(r[:maxAttrsLen]) + "…" + } + dst[key] = s + } +} + +func joinAttrKey(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +// attrString words a scalar the way the wire did: a number keeps every digit it +// arrived with rather than gaining an exponent, a bool stays a bool. +func attrString(v any) string { + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case bool: + return strconv.FormatBool(t) + case float64: + return strconv.FormatFloat(t, 'f', -1, 64) + case json.Number: + return t.String() + default: + return strings.TrimSpace(fmt.Sprint(t)) + } } // chargerInventory merges chargers by serial, keeping the order they were first @@ -103,6 +193,7 @@ func (inv *chargerInventory) addStandalone(body []byte) { continue } 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")) @@ -150,6 +241,7 @@ func (inv *chargerInventory) addBound(body []byte) { continue // some other Anker device on the same account } 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")) @@ -160,6 +252,59 @@ func (inv *chargerInventory) addBound(body []byte) { } } +// noteRecords folds a view's raw records into the chargers already found. The +// site views reach the inventory as normalized state (addStates), which is the +// right shape for the fields we name and drops the rest — so the raw list is +// walked once more here, purely for what those records also carried. A serial +// nothing has found yet is skipped: this pass enriches chargers, it does not +// discover them. +func (inv *chargerInventory) noteRecords(records []map[string]any, snKeys ...string) { + for _, m := range records { + sn := pickString(m, snKeys...) + if sn == "" { + continue + } + if c, ok := inv.byID[sn]; ok { + c.note(m) + } + } +} + +// addStation folds the per-charger station record into one charger: everything +// it carries as attributes, and the fields we have names for where no earlier +// view answered. Anker does not document this record, so each field is looked up +// by candidate key the way the list views are. +func (inv *chargerInventory) addStation(sn string, body []byte) { + c, ok := inv.byID[sn] + if !ok { + return + } + m := dataObject(body) + if m == nil { + 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")) + 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. type siteRef struct{ ID, Name string } @@ -254,11 +399,13 @@ func (p *Plugin) chargerInventory(ctx context.Context) (chargersDoc, error) { 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) + inv.noteRecords(rawRecords(body, "charging_pile_info", "charging_pile_list"), "device_sn", "deviceSn", "sn") } 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) + inv.noteRecords(rawRecords(body, "evChargerInfos"), "evChargerSn", "device_sn", "sn") } } } @@ -271,6 +418,26 @@ func (p *Plugin) chargerInventory(ctx context.Context) (chargersDoc, error) { inv.addBound(body) } + // 4. The station record, one charger at a time. The three list views above + // answer for the account; this one answers for a charger, and for a charger + // registered on its own it is the only view that says what it is doing. Each + // call is its own request, so a charger the cloud will not talk about costs + // that charger's row and no more — a station view that fails everywhere is + // one warning, not one per charger. + views++ + stationFailed, stationErr := 0, error(nil) + for _, sn := range inv.order { + body, err := p.apiRequest(ctx, epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1}) + if err != nil { + stationFailed, stationErr = stationFailed+1, err + continue + } + inv.addStation(sn, body) + } + if stationErr != nil && stationFailed == len(inv.order) { + fail("station info", stationErr) + } + if failed == views { return chargersDoc{}, fmt.Errorf("anker-solix: chargers: every cloud view failed: %s", strings.Join(warnings, "; ")) } @@ -326,6 +493,47 @@ func dataList(body []byte, keys ...string) []map[string]any { return nil } +// dataObject returns a response's "data" object, or nil when it carries +// something else — a list endpoint's data is an array, and the per-charger views +// have been known to answer with a bare value. +func dataObject(body []byte) map[string]any { + var env struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + return nil + } + return env.Data +} + +// rawRecords returns the objects in the array at path inside a response's "data" +// object, descending through the objects named before the last element. It is +// dataList's answer for a list the cloud nests rather than puts at the top. +func rawRecords(body []byte, path ...string) []map[string]any { + node := any(dataObject(body)) + for i, key := range path { + obj, ok := node.(map[string]any) + if !ok { + return nil + } + node = obj[key] + if i == len(path)-1 { + break + } + } + raw, ok := node.([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + // 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 { diff --git a/API Server/internal/plugins/builtin/ankersolix/chargers_test.go b/API Server/internal/plugins/builtin/ankersolix/chargers_test.go index de04a79..00a718e 100644 --- a/API Server/internal/plugins/builtin/ankersolix/chargers_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/chargers_test.go @@ -155,3 +155,82 @@ func TestFillString(t *testing.T) { t.Fatalf("s = %q, want first", s) } } + +// Everything a view sent is kept, not only the fields the merge has a name for: +// nested objects and arrays arrive under the path the cloud nested them at, and +// a field that says nothing leaves no row behind. +func TestAttrsKeepEveryFieldTheCloudSent(t *testing.T) { + inv := newInventory() + inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage", + "wifi_online":true,"rssi":-52,"grid":{"tariff":"day"},"tags":["a","b"],"blank":" ","absent":null}]}}`)) + + attrs := inv.list()[0].Attrs + want := map[string]string{ + "evChargerSn": "EVSN1", "evChargerName": "Garage", "wifi_online": "true", + "rssi": "-52", "grid.tariff": "day", "tags[0]": "a", "tags[1]": "b", + } + for k, v := range want { + if attrs[k] != v { + t.Fatalf("attrs[%q] = %q, want %q", k, attrs[k], v) + } + } + for _, k := range []string{"blank", "absent"} { + if _, ok := attrs[k]; ok { + t.Fatalf("attrs[%q] = %q; a field that says nothing must not make a row", k, attrs[k]) + } + } + + // A second view fills gaps without overwriting what the first one answered. + inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","rssi":-70,"bt_mac":"AA:BB"}]}}`)) + attrs = inv.list()[0].Attrs + if attrs["rssi"] != "-52" { + t.Fatalf("rssi = %q, want the first view's -52", attrs["rssi"]) + } + if attrs["bt_mac"] != "AA:BB" { + t.Fatalf("bt_mac = %q, want AA:BB", attrs["bt_mac"]) + } +} + +// The station record is the only view that answers for a charger standing +// outside a station, so it fills the live fields the list views left empty — +// without claiming the charger is registered anywhere new. +func TestAddStationFillsGapsWithoutAddingASource(t *testing.T) { + inv := newInventory() + inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`)) + inv.addStation("EVSN1", []byte(`{"code":0,"data":{"evChargerSn":"EVSN1","evChargerStatus":2,"power":"7.4", + "ocpp_connect_status":2,"station_name":"Home-DK","device_sw_version":"v1.0.6.1","plug_temperature":41}}`)) + + c := inv.list()[0] + if c.StatusDesc != stateCharging || c.Power != "7.4" { + t.Fatalf("station view did not fill the live fields: %+v", c) + } + if c.OcppStatusDesc != "connected" || c.SiteName != "Home-DK" || c.Firmware != "v1.0.6.1" { + t.Fatalf("station view did not fill the named fields: %+v", c) + } + if c.Attrs["plug_temperature"] != "41" { + t.Fatalf("plug_temperature = %q, want 41", c.Attrs["plug_temperature"]) + } + if len(c.Sources) != 1 || c.Sources[0] != "standalone" { + t.Fatalf("sources = %v; the station record is a view, not a registration", c.Sources) + } + + // A serial no view found is not a charger this pass may invent. + inv.addStation("EVSN9", []byte(`{"code":0,"data":{"evChargerSn":"EVSN9"}}`)) + if len(inv.list()) != 1 { + t.Fatalf("got %d chargers, want 1", len(inv.list())) + } +} + +func TestRawRecordsDescendsToANestedList(t *testing.T) { + body := []byte(`{"data":{"charging_pile_info":{"charging_pile_list":[{"device_sn":"EVSN1","power":"7.4"}]}}}`) + got := rawRecords(body, "charging_pile_info", "charging_pile_list") + if len(got) != 1 || got[0]["power"] != "7.4" { + t.Fatalf("rawRecords = %v", got) + } + if rawRecords(body, "charging_pile_info") != nil { + t.Fatal("an object where a list was asked for should yield nothing") + } + if rawRecords([]byte(`{"data":[]}`), "missing") != nil { + t.Fatal("a data that is not an object should yield nothing") + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go index e24e345..56ccfa0 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go @@ -478,9 +478,14 @@ func (c *mqttConn) ingest(msg mqtt.Message) { st.values[k] = v } now := time.Now() - if msgType == msgEVTelemetry { + // Only a message this package can name counts as a report. An unnamed one + // still leaves its fields behind, but it must not stamp settingsAt: that + // timestamp is what a control command waits on to say the charger + // acknowledged, and a frame we cannot read is not an acknowledgement. + switch { + case msgType == msgEVTelemetry: st.telemetryAt = now - } else { + case evMessages[msgType] != nil: st.settingsAt = now } c.wakeLocked() diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go index 30cd282..74699dd 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go @@ -146,6 +146,24 @@ func TestIngestMergesTelemetryAndSettings(t *testing.T) { } } +// A message type we have no map for still carries its fields into the state, +// but it must not pass for a settings report: settingsAt is what a control +// command waits on to say the charger acknowledged it, and a frame we cannot +// read is not an acknowledgement. +func TestIngestOfAnUnmappedMessageKeepsFieldsButNotTheStamp(t *testing.T) { + c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} + + c.ingest(envelope(t, "SN1", buildInbound(t, "0400", field(0xa2, typeUint8, 0x01)))) + + values, telemetryAt, settingsAt, _ := c.snapshotOf("SN1") + if v, _ := values["0400.a2"].(float64); v != 1 { + t.Errorf("values = %v, want the unnamed field kept as 0400.a2", values) + } + if !settingsAt.IsZero() || !telemetryAt.IsZero() { + t.Errorf("an unmapped message stamped the state: telemetry %v, settings %v", telemetryAt, settingsAt) + } +} + func TestWaitForReturnsWhenTheStateArrives(t *testing.T) { c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} go func() { diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go index 3c1f8ba..9bea3c7 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go @@ -294,8 +294,12 @@ func xorChecksum(b []byte) byte { // ---- inbound frames ---------------------------------------------------------- // decodeFrame parses one device frame and returns its message type together with -// the values its field map names. A field the map does not know is skipped -// rather than guessed at, and a frame whose checksum does not add up is +// the values it carries: the ones its field map names, under those names, and +// the ones no map names, under the message and name byte they arrived with (see +// rawFieldName). Nothing is guessed at — an unnamed field keeps its raw number +// and gains no unit, scaling or meaning — but nothing is thrown away either, +// because a field this package cannot name is still the only place some of what +// the charger knows ever appears. A frame whose checksum does not add up is // rejected: these arrive over a cloud connection we do not control, so a // truncated one must not be read as a charger reporting zeros. func decodeFrame(data []byte) (string, map[string]any, error) { @@ -331,6 +335,11 @@ func decodeFrame(data []byte) (string, map[string]any, error) { for _, r := range raw { f, known := fields[r.name] if !known || f.name == "" { + // No name for it: keep the value under the message and name byte, with + // no scaling — a factor is part of a field's meaning, and we have none. + if v, ok := decodeValue(r.typ, r.value, mqttField{}); ok { + values[rawFieldName(msgType, r.name)] = v + } continue } if v, ok := decodeValue(r.typ, r.value, f); ok { @@ -340,6 +349,15 @@ func decodeFrame(data []byte) (string, map[string]any, error) { return msgType, values, nil } +// rawFieldName keys a field no map names, by the message it arrived in and the +// name byte the charger gave it — "0410.b6". The message type belongs in the key +// because a name byte means whatever its message says it means: the same b6 is a +// different quantity in the telemetry stream and in the settings group, and one +// key for both would merge two readings into one wrong number. +func rawFieldName(msgType string, name byte) string { + return msgType + "." + encodeHex([]byte{name}) +} + // rawFieldBytes is one data field as it sat in the frame, before its map entry // decides what it means. type rawFieldBytes struct { diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go index c92481b..224ba62 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go @@ -202,15 +202,32 @@ func TestDecodeFrameSkipsTheCounterByte(t *testing.T) { } } -// A message type we have no map for still has to parse, so an unknown frame is -// an empty answer rather than an error that hides the ones we can read. -func TestDecodeFrameOfAnUnmappedTypeIsEmpty(t *testing.T) { +// A message type we have no map for still has to parse, and what it carried is +// kept under the message and name byte it arrived with rather than dropped: an +// unnamed field is the only place some of what the charger knows appears. +func TestDecodeFrameOfAnUnmappedTypeKeepsRawFields(t *testing.T) { _, values, err := decodeFrame(buildInbound(t, "0400", field(0xa2, typeUint8, 0x01))) if err != nil { t.Fatalf("decodeFrame: %v", err) } - if len(values) != 0 { - t.Errorf("values = %v, want none for an unmapped message type", values) + if v, _ := values["0400.a2"].(float64); v != 1 { + t.Errorf("values = %v, want 0400.a2 = 1", values) + } +} + +// A field a mapped message does not name is kept the same way, beside the ones +// it does — and unscaled, since a factor is part of a meaning we do not have. +func TestDecodeFrameKeepsUnnamedFieldsBesideNamedOnes(t *testing.T) { + body := append(field(0xbb, typeUint8, 0x05), field(0xc9, typeInt16LE, 0x2c, 0x01)...) + _, values, err := decodeFrame(buildInbound(t, msgEVTelemetry, body)) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if v, _ := values["status"].(float64); v != 5 { + t.Errorf("status = %v, want 5", values["status"]) + } + if v, _ := values[rawFieldName(msgEVTelemetry, 0xc9)].(float64); v != 300 { + t.Errorf("0410.c9 = %v, want 300 unscaled", values[rawFieldName(msgEVTelemetry, 0xc9)]) } } diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go index d0f332b..ce9dbb9 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go @@ -81,6 +81,14 @@ type MqttSnapshot struct { // mode's setup screen otherwise has to be given by hand. Local *MqttLocalAccess `json:"local,omitempty"` + // Extra is everything else the charger sent: the values its messages carry + // that the fields above have no home for, and the fields no message map can + // name at all, keyed by the message and name byte they arrived with. They + // have no unit and no scaling here — naming one would be claiming to know + // what it means — but they are what the charger actually said, so they are + // carried rather than dropped. + Extra map[string]any `json:"extra,omitempty"` + // TelemetryAt and SettingsAt are when each half of the snapshot last arrived; // Live says the fast stream is currently flowing. TelemetryAt string `json:"telemetryAt,omitempty"` @@ -272,7 +280,13 @@ func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { snap := MqttSnapshot{Serial: sn, Model: model} + // Every read goes through one of the four below, and each one notes the key + // it took. What is left over at the end is what this projection has no field + // for — which is exactly what Extra is, and keeping the list that way means a + // field added above stops appearing there without anyone having to remember. + read := map[string]bool{} num := func(key string) *float64 { + read[key] = true f, ok := v[key].(float64) if !ok { return nil @@ -280,6 +294,7 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { return &f } whole := func(key string) *int { + read[key] = true f, ok := v[key].(float64) if !ok { return nil @@ -288,6 +303,7 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { return &n } flag := func(key string) *bool { + read[key] = true f, ok := v[key].(float64) if !ok { return nil @@ -296,6 +312,7 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { return &b } text := func(key string) string { + read[key] = true s, _ := v[key].(string) return strings.TrimSpace(s) } @@ -374,6 +391,19 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { if *local != (MqttLocalAccess{}) { snap.Local = local } + + // Whatever the projection did not take. The charger sent it, so it is part of + // the reading — under the charger's own name for it, since this package has + // none. + for key, val := range v { + if read[key] { + continue + } + if snap.Extra == nil { + snap.Extra = map[string]any{} + } + snap.Extra[key] = val + } return snap } diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go index 3baa502..c52e551 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go @@ -144,3 +144,40 @@ func TestProjectSnapshotOfNothingIsEmpty(t *testing.T) { t.Errorf("an empty message set produced state: %+v", snap) } } + +// A value the projection has a field for belongs in that field; everything else +// the charger sent belongs in extra — the named values this package has not +// modelled, and the fields no message map can name at all. +func TestProjectSnapshotKeepsWhatItHasNoFieldFor(t *testing.T) { + snap := projectMqttSnapshot("SN1", "A5191", map[string]any{ + "powerTotal": 3680.0, + "maxCurrentSetA": 16.0, + "sessionStartedAt": 1756800000.0, + "sessionWhL1": 4100.0, + "swipeUpMode": 2.0, + "loadBalanceMonitorSN": "METER1", + rawFieldName(msgEVTelemetry, 0xc9): 300.0, + }) + + for _, key := range []string{"powerTotal", "maxCurrentSetA"} { + if _, ok := snap.Extra[key]; ok { + t.Errorf("extra[%q] is set; a value with a field of its own must not be repeated there", key) + } + } + want := map[string]any{ + "sessionStartedAt": 1756800000.0, "sessionWhL1": 4100.0, "swipeUpMode": 2.0, + "loadBalanceMonitorSN": "METER1", "0410.c9": 300.0, + } + for key, value := range want { + if snap.Extra[key] != value { + t.Errorf("extra[%q] = %v, want %v", key, snap.Extra[key], value) + } + } + + // A charger whose every value has a field of its own reports no extra at all, + // so a view can hide the block rather than draw an empty one. + bare := projectMqttSnapshot("SN1", "A5191", map[string]any{"powerTotal": 0.0}) + if bare.Extra != nil { + t.Errorf("extra = %v, want none when nothing is left over", bare.Extra) + } +} diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 9c35961..6b21125 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -210,6 +210,8 @@ "mqttLink": "MQTT", "alarmWord": "Ord {n}", "alarmsHint": "Laderen melder en alarm. Anker offentliggør ikke, hvad de enkelte bit betyder, så ordene vises, som de kommer.", + "extra": "Som laderen sender det", + "extraHint": "Værdier, laderen sender, som DriverVault ikke har et navn til — vist råt og uden omregning. En nøgle som 0410.c9 angiver beskeden og den feltbyte, den kom i; Anker offentliggør ikke deres betydning.", "phaseMode0": "Automatisk", "phaseMode1": "Enfaset", "phaseMode3": "Trefaset", @@ -251,7 +253,9 @@ "added": "Tilføjet", "online": "Online", "offline": "Offline", - "refresh": "Opdater" + "refresh": "Opdater", + "rawTitle": "Som tjenesten melder det", + "rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt." }, "home": { "count": { diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 4d4c09d..e8cecee 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -210,6 +210,8 @@ "mqttLink": "MQTT", "alarmWord": "Word {n}", "alarmsHint": "The charger reports an alarm. Anker does not publish what the individual bits mean, so the words are shown as they arrive.", + "extra": "As the charger sends it", + "extraHint": "Values the charger sends that DriverVault has no name for, shown raw and unscaled. A key like 0410.c9 names the message and the field byte it arrived in; Anker publishes no meaning for them.", "phaseMode0": "Automatic", "phaseMode1": "Single-phase", "phaseMode3": "Three-phase", @@ -251,7 +253,9 @@ "added": "Added", "online": "Online", "offline": "Offline", - "refresh": "Refresh" + "refresh": "Refresh", + "rawTitle": "As the service reports it", + "rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed." }, "home": { "count": { diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 9f0e82f..8cb0486 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -212,6 +212,8 @@ "mqttLink": "MQTT", "alarmWord": "Słowo {n}", "alarmsHint": "Ładowarka zgłasza alarm. Anker nie publikuje znaczenia poszczególnych bitów, więc słowa pokazane są tak, jak przychodzą.", + "extra": "Tak, jak przysyła to ładowarka", + "extraHint": "Wartości, które ładowarka przysyła, a dla których DriverVault nie ma nazwy — pokazane surowo, bez przeliczeń. Klucz w rodzaju 0410.c9 wskazuje wiadomość i bajt pola, w którym przyszła; Anker nie publikuje ich znaczenia.", "phaseMode0": "Automatycznie", "phaseMode1": "Jednofazowo", "phaseMode3": "Trójfazowo", @@ -253,7 +255,9 @@ "added": "Dodano", "online": "Online", "offline": "Offline", - "refresh": "Odśwież" + "refresh": "Odśwież", + "rawTitle": "Tak, jak podaje to usługa", + "rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw." }, "home": { "count": { diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 59366af..61aad6d 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -20,6 +20,19 @@ DateTime? _asDate(dynamic v) => List _asStrList(dynamic v) => v is List ? v.map(_asStr).where((s) => s.isNotEmpty).toList() : const []; +/// A JSON object of plain values, read as strings — a service's own fields, +/// relayed under its own keys. A value that says nothing leaves no entry, so a +/// caller can take a key's presence to mean the service answered it. +Map _asStrMap(dynamic v) { + if (v is! Map) return const {}; + final out = {}; + v.forEach((key, value) { + final s = _asStr(value); + if (s.isNotEmpty) out[key.toString()] = s; + }); + return out; +} + /// The single optional file a record carries — a receipt, a scan, a photo of a /// part's box. Mirrors the API's embedded Attachment: the bytes are never in the /// JSON, only whether there are any and what they were stored as. Fetch them @@ -1190,6 +1203,15 @@ class ChargerStatus { return v is Map ? Map.from(v) : const {}; } + /// Everything the charger sent that no field above has a name for: the values + /// a message carries that the integration has not modelled, and the fields no + /// published map names at all, keyed by the message and field byte they + /// arrived in. Raw and unscaled — a unit would be a meaning we do not have. + Map get extra { + final v = raw["extra"]; + return v is Map ? Map.from(v) : const {}; + } + /// The alarm words, as they arrive: the spec defers what the individual bits /// mean to a list Anker does not publish, so which word is set is still the /// thing to report. @@ -1377,6 +1399,12 @@ class ProviderCharger { final int? ocppStatus; final String ocppStatusDesc; + /// 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, + /// so its key is the only honest label these values have. + final Map attrs; + /// Set when this charger is already in DriverVault, so the import never /// offers the same one twice. final String linkedChargerId; @@ -1395,6 +1423,7 @@ class ProviderCharger { this.power = "", this.ocppStatus, this.ocppStatusDesc = "", + this.attrs = const {}, this.linkedChargerId = "", }); @@ -1412,6 +1441,7 @@ class ProviderCharger { power: _asStr(j["power"]), ocppStatus: _asIntOrNull(j["ocppStatus"]), ocppStatusDesc: _asStr(j["ocppStatusDesc"]), + attrs: _asStrMap(j["attrs"]), linkedChargerId: _asStr(j["linkedChargerId"]), ); diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index 0aef771..755b16a 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -1878,6 +1878,7 @@ class _HomeTabState extends State<_HomeTab> { final live = _liveRows(s); final settings = _settingRows(s); final device = _deviceRows(s); + final extra = _extraRows(s); final alarms = _alarmWords(s); return [ @@ -1936,6 +1937,24 @@ class _HomeTabState extends State<_HomeTab> { _ReadingSection(heading: t("charging.modbus.device"), child: _PairList(rows: device)), const SizedBox(height: 8), ], + // What the charger sends beyond what we model. It appears only when there + // is something in it, so a transport that reports nothing unnamed draws no + // empty block. + if (extra.isNotEmpty) ...[ + _ReadingSection( + heading: t("charging.modbus.extra"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PairList(rows: extra, breakLong: true), + const SizedBox(height: 6), + Text(t("charging.modbus.extraHint"), + style: TextStyle(fontSize: 11, color: DriverVault.muted(context))), + ], + ), + ), + const SizedBox(height: 8), + ], // Alarms, when any word is non-zero. Which register is set is reportable // even though the bit list is not published. if (alarms.isNotEmpty) @@ -2095,6 +2114,23 @@ class _HomeTabState extends State<_HomeTab> { ]); } + /// Everything the charger sends that none of the blocks above has a name for. + /// Over the cloud a message carries more fields than this integration models, + /// and some of them no published map names at all — those arrive keyed by the + /// message and the field byte they came in ("0410.c9"). Shown raw: no unit, no + /// scaling, no translation, because a factor and a label are part of a meaning + /// Anker does not publish. Named leftovers sort first, the byte-keyed ones + /// after, so the readable half is not buried under hex. + List<(String, String)> _extraRows(ChargerStatus s) { + final extra = s.extra; + final keys = extra.keys.toList() + ..sort((a, b) { + final raw = (a.contains(".") ? 1 : 0) - (b.contains(".") ? 1 : 0); + return raw != 0 ? raw : a.compareTo(b); + }); + return [for (final key in keys) (key, "${extra[key]}")]; + } + /// Reactive and apparent power are registers of their own, and the cloud has /// no message carrying either — so on that transport the two columns could /// only ever be three dashes each. They appear when the charger actually @@ -2198,6 +2234,21 @@ class _HomeTabState extends State<_HomeTab> { ]), const SizedBox(height: 8), _PairList(rows: _infoRows(charger), breakLong: true), + // The rest of what the service knows, in the service's own + // words. It appears only when there is something in it, so a + // charger the cloud says nothing more about stays quiet. + if (_attrRows(charger).isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + t("charging.info.rawTitle"), + style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted) + .copyWith(letterSpacing: 1.4), + ), + const SizedBox(height: 4), + _PairList(rows: _attrRows(charger), breakLong: true), + const SizedBox(height: 4), + Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)), + ], ], ), ), @@ -2250,6 +2301,17 @@ class _HomeTabState extends State<_HomeTab> { ]; } + /// 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 honest label, and renaming one here would be inventing a meaning for + /// it. Sorted so the same charger reads the same way on every refresh. + List<(String, String)> _attrRows(HomeCharger c) { + final attrs = _liveFor(c)?.attrs ?? const {}; + final keys = attrs.keys.toList()..sort(); + return [for (final key in keys) (key, attrs[key]!)]; + } + /// The user's own chargers, and the import that fills the list. Widget _chargerListCard(BuildContext context) { final muted = DriverVault.muted(context); diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index a8229d1..6bb656f 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -171,6 +171,8 @@ "mqttLink": "MQTT", "alarmWord": "Ord {n}", "alarmsHint": "Laderen melder en alarm. Anker offentliggør ikke, hvad de enkelte bit betyder, så ordene vises, som de kommer.", + "extra": "Som laderen sender det", + "extraHint": "Værdier, laderen sender, som DriverVault ikke har et navn til — vist råt og uden omregning. En nøgle som 0410.c9 angiver beskeden og den feltbyte, den kom i; Anker offentliggør ikke deres betydning.", "phaseMode0": "Automatisk", "phaseMode1": "Enfaset", "phaseMode3": "Trefaset", @@ -212,7 +214,9 @@ "added": "Tilføjet", "online": "Online", "offline": "Offline", - "refresh": "Opdater" + "refresh": "Opdater", + "rawTitle": "Som tjenesten melder det", + "rawHint": "Alle øvrige felter, tjenesten sendte om denne lader, under Ankers egne navne. De er udokumenterede, så de vises, som de kommer, i stedet for at blive omdøbt." } }, diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 0162e69..6727178 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -157,6 +157,8 @@ "mqttLink": "MQTT", "alarmWord": "Word {n}", "alarmsHint": "The charger reports an alarm. Anker does not publish what the individual bits mean, so the words are shown as they arrive.", + "extra": "As the charger sends it", + "extraHint": "Values the charger sends that DriverVault has no name for, shown raw and unscaled. A key like 0410.c9 names the message and the field byte it arrived in; Anker publishes no meaning for them.", "phaseMode0": "Automatic", "phaseMode1": "Single-phase", "phaseMode3": "Three-phase", @@ -198,7 +200,9 @@ "added": "Added", "online": "Online", "offline": "Offline", - "refresh": "Refresh" + "refresh": "Refresh", + "rawTitle": "As the service reports it", + "rawHint": "Every other field the service sent about this charger, under its own field names. They are undocumented, so they are shown as they arrive rather than renamed." }, "stations": { "heading": "Nearby", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 9530618..1539964 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -173,6 +173,8 @@ "mqttLink": "MQTT", "alarmWord": "Słowo {n}", "alarmsHint": "Ładowarka zgłasza alarm. Anker nie publikuje znaczenia poszczególnych bitów, więc słowa pokazane są tak, jak przychodzą.", + "extra": "Tak, jak przysyła to ładowarka", + "extraHint": "Wartości, które ładowarka przysyła, a dla których DriverVault nie ma nazwy — pokazane surowo, bez przeliczeń. Klucz w rodzaju 0410.c9 wskazuje wiadomość i bajt pola, w którym przyszła; Anker nie publikuje ich znaczenia.", "phaseMode0": "Automatycznie", "phaseMode1": "Jednofazowo", "phaseMode3": "Trójfazowo", @@ -214,7 +216,9 @@ "added": "Dodano", "online": "Online", "offline": "Offline", - "refresh": "Odśwież" + "refresh": "Odśwież", + "rawTitle": "Tak, jak podaje to usługa", + "rawHint": "Wszystkie pozostałe pola, które usługa przysłała o tej ładowarce, pod jej własnymi nazwami. Nie są udokumentowane, więc pokazujemy je tak, jak przychodzą, bez zmiany nazw." } }, diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 14f7783..f0c9db0 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -519,6 +519,20 @@ const deviceIdentity = computed(() => { ]); }); +// Everything the charger sends that none of the blocks above has a name for. +// Over the cloud a message carries more fields than this integration models, and +// some of them no published map names at all — those arrive keyed by the message +// and the field byte they came in ("0410.c9"). They are shown raw: no unit, no +// scaling, no translation, because a factor and a label are part of a meaning +// Anker does not publish. Named leftovers sort first, the byte-keyed ones after, +// so the readable half is not buried under hex. +const deviceExtra = computed(() => { + const extra = dev.value.extra || {}; + return Object.keys(extra) + .sort((a, b) => a.includes(".") - b.includes(".") || a.localeCompare(b)) + .map((key) => ({ key, value: String(extra[key]) })); +}); + // The per-phase readings are a matrix, not a list: three phases against five // measurements. A table says that; twenty labelled pairs hide it. const devicePhases = computed(() => { @@ -819,6 +833,18 @@ function chargerInfoRows(c) { })); } +// 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 +// honest label, and translating or renaming one would be inventing a meaning +// for it. Sorted so the same charger reads the same way on every refresh. +function chargerAttrRows(c) { + const attrs = liveFor(c)?.attrs || {}; + return Object.keys(attrs) + .sort() + .map((key) => ({ key, value: attrs[key] })); +} + async function refreshCtl() { const sn = ctlSerial.value.trim(); if (!sn) { @@ -1620,6 +1646,20 @@ onMounted(async () => { + +
+

{{ t("charging.modbus.extra") }}

+
+ +
+

{{ t("charging.modbus.extraHint") }}

+
+
@@ -1701,6 +1741,20 @@ onMounted(async () => {
{{ row.value }}
+ + +