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