A field we have no name for is still a field it sent
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1dc20461de
commit
2425a8d3d6
@@ -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
|
||||
|
||||
@@ -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)."},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -20,6 +20,19 @@ DateTime? _asDate(dynamic v) =>
|
||||
List<String> _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<String, String> _asStrMap(dynamic v) {
|
||||
if (v is! Map) return const {};
|
||||
final out = <String, String>{};
|
||||
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<String, dynamic>.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<String, dynamic> get extra {
|
||||
final v = raw["extra"];
|
||||
return v is Map ? Map<String, dynamic>.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<String, String> 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"]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 <String, String>{};
|
||||
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);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- 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. -->
|
||||
<section v-if="deviceExtra.length" class="rounded-control bg-sunken p-3">
|
||||
<h4 class="eyebrow">{{ t("charging.modbus.extra") }}</h4>
|
||||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<template v-for="r in deviceExtra" :key="r.key">
|
||||
<dt class="data break-all text-xs text-muted">{{ r.key }}</dt>
|
||||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<p class="mt-2 text-[11px] text-muted">{{ t("charging.modbus.extraHint") }}</p>
|
||||
</section>
|
||||
|
||||
<!-- Alarms, when any word is non-zero. Which register is set is
|
||||
reportable even though the bit list is not published. -->
|
||||
<section v-if="modbusAlarms.length" class="rounded-control border border-warning/40 bg-warning-soft p-3">
|
||||
@@ -1701,6 +1741,20 @@ onMounted(async () => {
|
||||
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<!-- The rest of what the service knows, in the service's own words.
|
||||
It only appears when there is something in it, which is also how
|
||||
a charger the cloud says nothing more about stays quiet. -->
|
||||
<template v-if="chargerAttrRows(selectedHomeCharger).length">
|
||||
<p class="eyebrow mt-3">{{ t("charging.info.rawTitle") }}</p>
|
||||
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||
<template v-for="row in chargerAttrRows(selectedHomeCharger)" :key="row.key">
|
||||
<dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt>
|
||||
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<p class="mt-1 text-[11px] text-muted">{{ t("charging.info.rawHint") }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="homeChargers.length === 0" class="mt-2 text-xs text-muted">
|
||||
|
||||
Reference in New Issue
Block a user