Compare commits

...
2 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 1a7f04cba0 A dash printed above the value it was missing
The Charger information card read "—" beside State and OCPP status while the raw
block three rows below it printed chargerStatus 1 and ocpp_connect_status 2. The
account had answered both. The merge asked for the state as evChargerStatus,
operating_state or status, which is how the standalone and station views spell
it, and the bound-device view — the one this account actually answers from —
spells it chargerStatus. The OCPP state it never asked that view for at all. All
three views now read through one fillDevice, which tries every spelling a view is
known to use, so a value any of them sends reaches the row that was drawing a
dash for want of it.

The same views were carrying the whole box-on-the-wall half unread: the Wi-Fi
network and its MAC, the signal strength, the Bluetooth MAC, the time zone, when
the account bound the charger, how the app can reach it — BLE, Wi-Fi — and the
product shot for the model, which now sits beside the charger's name in both
apps. Named rows, in three languages, the way the register map's readings are
named.

One field wanted the opposite treatment. The device record carries blue_password,
the charger's own Bluetooth pairing password, and the card was printing it in
clear into every screenshot anyone takes of that page. Any leaf key holding a
password, secret, token, private key or certificate is now masked in the raw
block: that the field exists is worth reporting, its value is not.

Four endpoints answer only when a serial is named, so none of them could belong
to the list the card is drawn from, and nothing had ever called them. The station
record, the charging totals, the OCPP backend and the RFID cards now arrive
through a charger-details capability behind
GET …/anker-solix/chargers/{sn}/details, asked for the charger being looked at,
best effort, each view reporting its own failure — an account that is not the
owner cannot read the cards, which is a fact about the account rather than an
error in the read.

Those four are shown under the cloud's own keys, and that is not an oversight.
The REST map documents which endpoints exist and what each is for; it does not
document a single one of their payloads. Naming those fields is the next commit,
made from what actually comes back, now that there is somewhere to see it.

Not touched: the endpoints the map marks ready but unwired — session history,
site price, OTA, sharing, notifications — each a feature rather than a row on this
card; and the unmapped ones, which the map warns delete sessions and unbind
devices with payloads nobody has ever seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:45:08 +02:00
tajniak81andClaude Opus 5 8e2073fc4c The map knew the names the card was showing as hex
Every field in the cloud MQTT map that has a documented meaning now reads as a
named row, on the same labels the register map uses for the same quantities. The
raw block stays, and shrinks to what genuinely nobody has identified — which is
the only honest reason for a key like 0410.b9 to be on screen at all.

Three fields the reference decodes for nobody are decoded here. ac is where the
charge is coming from — off or paused, grid, solar — and it is called
chargingSource rather than chargingMode, because that name already belongs to a
Modbus register and the last time a cloud field borrowed one, d9 spent a release
reporting the wrong thing under the right name. b6 is the session's order id.
f1, f2 and f3 are the identity fields the reference marks multi-value: four bytes
read as the parts of a version, in the order they arrive, which is what the
account view's own firmware string looks like. If the panel shows those parts
reversed, the order is the thing to flip — it is the one assumption here that the
wire has not yet confirmed.

The rest was already decoded and simply never drawn. The readings card now shows
the session's start, its id, the charging source, whether a cable is in, the
charging window, and — since a reading is worth what its age is — the live-stream
flag and both stream clocks, because telemetry and settings arrive on different
messages with different triggers. Per-phase session energy joins the phase matrix
as a fourth column, appearing on the transport that counts a session and staying
away from the one that does not, exactly as the reactive and apparent pair does.
The settings block gains the fourteen the register map has no address for: plug
lock, auto restart, random delay, the schedule and its mode, the weekend window
and how the weekend is handled, the light-off schedule and window, the breaker
limit, the solar mode and its minimum current, automatic phase switching, the
three panel gestures, and what the two balancing features are watching — the
meter and monitor serials by name, their two unpinned numbers as the numbers they
are. A local network block says whether the charger's own Modbus server is on and
where, which is the answer the Modbus mode's setup screen otherwise has to be
given by hand. The device block gains the controller version.

A test now holds the line the projection quietly drew: every name in the message
maps must reach a snapshot field. A name added to a map without a field to land
in would otherwise surface in the raw block looking like something we understood.

Left raw: a1, the frame opener the charger echoes back; b7, which the map itself
calls unidentified; b9, bc and bd, which appear in no map; the five-minute 0400;
and 0857 — a message type the reference's closed inventory of fourteen does not
contain and this charger publishes anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:31:43 +02:00
22 changed files with 1267 additions and 50 deletions
@@ -87,6 +87,18 @@ type providerCharger struct {
OcppStatus *int `json:"ocppStatus,omitempty"`
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
// What the service knows about the box on the wall rather than the charging:
// the networks it is on, where it thinks it is, when the account bound it,
// and the picture the service shows for the model.
WifiName string `json:"wifiName,omitempty"`
WifiMac string `json:"wifiMac,omitempty"`
WifiRSSI *int `json:"wifiRssi,omitempty"`
BleMac string `json:"bleMac,omitempty"`
TimeZone string `json:"timeZone,omitempty"`
LinkedAt *float64 `json:"linkedAt,omitempty"` // unix seconds
ImageURL string `json:"imageUrl,omitempty"`
RelatedBy []string `json:"relatedBy,omitempty"`
// Attrs is everything else the service said about this charger, under the
// service's own field names. The fields above are the ones DriverVault has a
// name for; this is the remainder, relayed so a card can show what the
@@ -138,6 +150,15 @@ func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger {
OcppStatus *int `json:"ocppStatus"`
OcppStatusDesc string `json:"ocppStatusDesc"`
WifiName string `json:"wifiName"`
WifiMac string `json:"wifiMac"`
WifiRSSI *int `json:"wifiRssi"`
BleMac string `json:"bleMac"`
TimeZone string `json:"timeZone"`
LinkedAt *float64 `json:"linkedAt"`
ImageURL string `json:"imageUrl"`
RelatedBy []string `json:"relatedBy"`
Attrs map[string]string `json:"attrs"`
} `json:"chargers"`
}
@@ -154,6 +175,8 @@ func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger {
Firmware: c.Firmware, SiteID: c.SiteID, SiteName: c.SiteName,
Sources: c.Sources, Status: c.StatusDesc, Online: c.Online,
Power: c.Power, OcppStatus: c.OcppStatus, OcppStatusDesc: c.OcppStatusDesc,
WifiName: c.WifiName, WifiMac: c.WifiMac, WifiRSSI: c.WifiRSSI, BleMac: c.BleMac,
TimeZone: c.TimeZone, LinkedAt: c.LinkedAt, ImageURL: c.ImageURL, RelatedBy: c.RelatedBy,
Attrs: c.Attrs,
})
}
@@ -544,3 +544,41 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
// that document as-is so the UI sees exactly what the capability produced.
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
// GET /api/integrations/anker-solix/chargers/{sn}/details — every per-charger
// view the account holds: the station record, the charging totals, the OCPP
// backend and the RFID cards. Gated exactly like the charger list, and answered
// the same way when a gate is off: 200 with nothing and the reason, because a
// disconnected integration is a normal state with an answer.
func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
sn := strings.TrimSpace(r.PathValue("sn"))
if sn == "" {
writeError(w, http.StatusBadRequest, "charger serial required")
return
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
if reason := ankerGate(res, true); reason != "" {
writeJSON(w, http.StatusOK, map[string]any{"sn": sn, "views": []any{}, "unavailable": true, "detail": reason})
return
}
cfg := map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "charger-details",
mustJSON(map[string]any{"sn": sn}))
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
+2
View File
@@ -51,6 +51,7 @@
// GET /api/integrations/anker-solix PUT /api/integrations/anker-solix
// POST /api/integrations/anker-solix/health
// GET /api/integrations/anker-solix/chargers
// GET /api/integrations/anker-solix/chargers/{sn}/details
// GET /api/integrations/greencell PUT /api/integrations/greencell
// POST /api/integrations/greencell/health
// GET /api/integrations/greencell/chargers
@@ -439,6 +440,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("PUT /api/integrations/anker-solix", s.handlePutAnker)
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails)
mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell)
mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell)
mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth)
@@ -181,6 +181,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site, bound-device and per-charger station views, each charger carrying every field those views reported (see chargers.go)."},
{ID: "charger-details", Method: "POST", Endpoint: epStationInfo, Description: "Every per-charger view the account holds — the station record, the charging totals, the OCPP backend and the RFID cards — each relayed as the fields it sent (needs sn)."},
{ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn; optional featuretype 1 or 2)."},
{ID: "charger-state", Method: "POST", Endpoint: epSceneInfo, Description: "Normalized live state of a site's EV chargers: status, operational mode and the modes it can be switched to (needs siteId; optional sn)."},
{ID: "site-status", Method: "POST", Endpoint: epSceneInfo, Description: "Live site view; EV chargers appear under charging_pile_info (needs siteId)."},
@@ -320,6 +321,12 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
if action == "chargers" {
return p.accountChargers(ctx)
}
if action == "charger-details" {
if pp.SN == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
}
return p.chargerDetails(ctx, pp.SN)
}
if action == "charger-state" {
if pp.SiteID == "" {
return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
@@ -0,0 +1,89 @@
package ankersolix
// The account's other views of one charger.
//
// The merged inventory (chargers.go) answers "what chargers are there", and it
// asks the views that list them. Four more endpoints answer only when a serial
// is named: the station record the app opens on a charger, its cumulative
// charging totals, which OCPP backend it is pointed at, and the RFID cards
// authorised on it. None of them lists a charger, so none belongs in the merge —
// and none of them was reachable from the app at all until this capability.
//
// What they answer with is not documented, by Anker or by the reference: the
// endpoints are known, their payloads are not. So each view is relayed as the
// fields it actually sent, flattened under the cloud's own keys, rather than
// projected onto names invented here. A field that turns out to matter can be
// named later, from evidence.
import (
"context"
"encoding/json"
"fmt"
)
// chargerDetailView is one endpoint's answer about one charger. A view that
// fails carries its reason instead of its fields: an account that is not the
// owner cannot read the cards, which is a fact about the account rather than an
// error in the read.
type chargerDetailView struct {
ID string `json:"id"`
Attrs map[string]string `json:"attrs,omitempty"`
Error string `json:"error,omitempty"`
}
type chargerDetailsDoc struct {
SN string `json:"sn"`
Views []chargerDetailView `json:"views"`
}
// chargerDetails asks every per-charger endpoint and returns what each answered.
// One failing view is reported in place; only losing all of them is an error,
// for the same reason the inventory works that way — a charger the cloud will
// half talk about is still worth showing.
func (p *Plugin) chargerDetails(ctx context.Context, sn string) (json.RawMessage, error) {
if _, err := p.ensureToken(ctx); err != nil {
return nil, err
}
views := []struct {
id string
endpoint string
payload map[string]any
}{
{"station", epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1}},
{"totals", epChargeStats, map[string]any{
"device_sn": sn, "date_type": "all", "start_date": "", "end_date": ""}},
{"ocpp", epOcppInfo, map[string]any{"device_sn": sn}},
{"rfid", epRfidCards, map[string]any{"device_sn": sn}},
}
doc := chargerDetailsDoc{SN: sn, Views: make([]chargerDetailView, 0, len(views))}
failed := 0
for _, v := range views {
out := chargerDetailView{ID: v.id}
body, err := p.apiRequest(ctx, v.endpoint, v.payload)
if err != nil {
out.Error, failed = shorten(err.Error()), failed+1
} else {
out.Attrs = map[string]string{}
flattenInto(out.Attrs, "", dataValue(body))
}
doc.Views = append(doc.Views, out)
}
if failed == len(views) {
return nil, fmt.Errorf("anker-solix: charger %s: no per-charger view answered", sn)
}
return json.Marshal(doc)
}
// dataValue returns a response's "data", whatever shape it came in: these views
// answer with an object, and the card list answers with an array.
func dataValue(body []byte) any {
var env struct {
Data any `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil
}
return env.Data
}
@@ -56,6 +56,19 @@ type accountCharger struct {
OcppStatus *int `json:"ocppStatus,omitempty"`
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
// What the account knows about the box on the wall, as opposed to the
// charging: which networks it is on, where it thinks it is, when it was
// bound, and the picture the app shows for it. Every one of these arrives
// with the device views and none of them was ever read.
WifiName string `json:"wifiName,omitempty"`
WifiMac string `json:"wifiMac,omitempty"`
WifiRSSI *int `json:"wifiRssi,omitempty"`
BleMac string `json:"bleMac,omitempty"`
TimeZone string `json:"timeZone,omitempty"`
LinkedAt *float64 `json:"linkedAt,omitempty"` // unix seconds
ImageURL string `json:"imageUrl,omitempty"`
RelatedBy []string `json:"relatedBy,omitempty"` // ble, wifi — how the app reaches it
// Attrs is everything each view reported about this charger, under the key
// the cloud used for it. The fields above are the ones DriverVault gives a
// name and a meaning to; Attrs is the rest, relayed rather than dropped,
@@ -107,6 +120,13 @@ func flattenInto(dst map[string]string, key string, v any) {
if s == "" {
return // nothing said is not a value
}
// A device record carries the charger's own Bluetooth pairing password,
// and the account views hand out more than one credential besides. That a
// field exists is worth reporting; its value is not something a card
// should put on a screen, or a screenshot should carry off one.
if isSecretKey(key) {
s = "\u2022\u2022\u2022\u2022\u2022\u2022"
}
// Cut by rune, not by byte: half a character is not shorter, it is
// broken, and it would reach the UI as a replacement glyph.
if r := []rune(s); len(r) > maxAttrsLen {
@@ -116,6 +136,23 @@ func flattenInto(dst map[string]string, key string, v any) {
}
}
// secretKeyWords are the field names whose values are credentials rather than
// readings. Matched on the leaf name, so a nested one is caught too.
var secretKeyWords = []string{"password", "passwd", "secret", "token", "private_key", "privatekey", "certificate", "auth_key", "authkey"}
func isSecretKey(key string) bool {
leaf := strings.ToLower(key)
if i := strings.LastIndex(leaf, "."); i >= 0 {
leaf = leaf[i+1:]
}
for _, word := range secretKeyWords {
if strings.Contains(leaf, word) {
return true
}
}
return false
}
func joinAttrKey(prefix, key string) string {
if prefix == "" {
return key
@@ -194,16 +231,49 @@ func (inv *chargerInventory) addStandalone(body []byte) {
}
c := inv.get(sn, "standalone")
c.note(m)
fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "name"))
fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn"))
fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId"))
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version"))
if n, ok := pickInt(m, "evChargerStatus", "operating_state", "status"); ok {
setChargerStatus(c, n)
}
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
c.Online = &b
fillDevice(c, m)
}
}
// fillDevice reads the fields every device view shares. The three views name
// them alike where they can and differently where they cannot — the same status
// is evChargerStatus in one record and chargerStatus in the next — so each is
// looked up by candidate key, first answer winning, the way the rest of the
// merge works.
func fillDevice(c *accountCharger, m map[string]any) {
fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "deviceName", "name"))
fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn", "pn"))
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId"))
fillString(&c.SiteName, pickString(m, "site_name", "siteName", "station_name", "stationName"))
fillString(&c.WifiName, pickString(m, "wifi_name", "wifiName", "ssid"))
fillString(&c.WifiMac, pickString(m, "wifi_mac", "wifiMac"))
fillString(&c.BleMac, pickString(m, "bt_ble_mac", "bleMac", "bt_mac"))
fillString(&c.TimeZone, pickString(m, "time_zone", "timeZone", "timezone"))
fillString(&c.ImageURL, pickString(m, "img_url", "imgUrl", "image_url"))
if n, ok := pickInt(m, "rssi", "wifi_rssi", "signal"); ok && c.WifiRSSI == nil {
c.WifiRSSI = &n
}
if t, ok := pickFloat(m, "link_time", "linkTime", "bind_time", "create_time"); ok && c.LinkedAt == nil {
c.LinkedAt = &t
}
if len(c.RelatedBy) == 0 {
c.RelatedBy = pickStrings(m, "relate_type", "relateType", "connect_type")
}
// The state, however the view spells it. A charger that answers one view and
// not another is the normal case, which is why this is tried on all of them.
if n, ok := pickInt(m, "evChargerStatus", "chargerStatus", "operating_state", "status"); ok {
setChargerStatus(c, n)
}
if n, ok := pickInt(m, "ocpp_connect_status", "ocppConnectStatus"); ok && c.OcppStatus == nil {
desc, named := ocppConnStatus[n]
if !named {
desc = stateUnknown
}
c.OcppStatus, c.OcppStatusDesc = &n, desc
}
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
c.Online = &b
}
}
@@ -242,13 +312,7 @@ func (inv *chargerInventory) addBound(body []byte) {
}
c := inv.get(sn, "bound")
c.note(m)
fillString(&c.Name, pickString(m, "device_name", "alias_name", "name"))
fillString(&c.Model, pn)
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
fillString(&c.SiteID, pickString(m, "site_id", "siteId"))
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
c.Online = &b
}
fillDevice(c, m)
}
}
@@ -284,25 +348,8 @@ func (inv *chargerInventory) addStation(sn string, body []byte) {
return
}
c.note(m)
fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "name"))
fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn"))
fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
fillString(&c.SiteID, pickString(m, "station_id", "stationId", "site_id", "siteId"))
fillString(&c.SiteName, pickString(m, "station_name", "stationName", "site_name", "siteName"))
fillDevice(c, m)
fillString(&c.Power, pickString(m, "power", "charging_power", "chargingPower"))
if n, ok := pickInt(m, "evChargerStatus", "operating_state", "status"); ok {
setChargerStatus(c, n)
}
if n, ok := pickInt(m, "ocpp_connect_status", "ocppConnectStatus"); ok && c.OcppStatus == nil {
desc, named := ocppConnStatus[n]
if !named {
desc = stateUnknown
}
c.OcppStatus, c.OcppStatusDesc = &n, desc
}
if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok && c.Online == nil {
c.Online = &b
}
}
// siteRef is one system (site) registered on the account.
@@ -549,6 +596,44 @@ func pickString(m map[string]any, keys ...string) string {
return ""
}
// pickFloat returns the first numeric value among keys, unrounded — a unix
// timestamp does not fit an int on every platform and does not want scaling.
func pickFloat(m map[string]any, keys ...string) (float64, bool) {
for _, k := range keys {
switch v := m[k].(type) {
case float64:
return v, true
case string:
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
return f, true
}
}
}
return 0, false
}
// pickStrings returns the first list of strings among keys. The device views
// carry the ways the app can reach a charger this way — ble, wifi — as a plain
// array of names.
func pickStrings(m map[string]any, keys ...string) []string {
for _, k := range keys {
raw, ok := m[k].([]any)
if !ok {
continue
}
out := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
if len(out) > 0 {
return out
}
}
return nil
}
// pickInt returns the first numeric value among keys.
func pickInt(m map[string]any, keys ...string) (int, bool) {
for _, k := range keys {
@@ -1,6 +1,9 @@
package ankersolix
import "testing"
import (
"strings"
"testing"
)
// A charger that sits in a station is absent from the standalone list even
// though the account count includes it — the case that made a good login look
@@ -234,3 +237,58 @@ func TestRawRecordsDescendsToANestedList(t *testing.T) {
t.Fatal("a data that is not an object should yield nothing")
}
}
// The bound-device view carries the charger's state and its OCPP link under
// names of its own — chargerStatus, not evChargerStatus — which is how a card
// ends up printing a dash beside a value the same response contains.
func TestBoundViewNamesTheStateItsOwnWay(t *testing.T) {
inv := newInventory()
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","alias_name":"Home-DK",
"chargerStatus":2,"ocpp_connect_status":2,"wifi_online":true,"wifi_name":"AP_1_IOT",
"wifi_mac":"7CE91373C236","bt_ble_mac":"7CE91373C238","rssi":-63,"time_zone":"Europe/Copenhagen",
"link_time":1788186620,"img_url":"https://example.invalid/a5191.png","relate_type":["ble","wifi"]}]}}`))
c := inv.list()[0]
if c.StatusDesc != stateCharging {
t.Errorf("statusDesc = %q, want %q — the bound view spells it chargerStatus", c.StatusDesc, stateCharging)
}
if c.OcppStatusDesc != "connected" {
t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc)
}
if c.WifiName != "AP_1_IOT" || c.WifiMac != "7CE91373C236" || c.BleMac != "7CE91373C238" {
t.Errorf("networks = %+v", c)
}
if c.WifiRSSI == nil || *c.WifiRSSI != -63 {
t.Errorf("wifiRssi = %v, want -63", c.WifiRSSI)
}
if c.TimeZone != "Europe/Copenhagen" || c.ImageURL == "" {
t.Errorf("timeZone/imageUrl = %q / %q", c.TimeZone, c.ImageURL)
}
if c.LinkedAt == nil || *c.LinkedAt != 1788186620 {
t.Errorf("linkedAt = %v, want 1788186620", c.LinkedAt)
}
if len(c.RelatedBy) != 2 || c.RelatedBy[0] != "ble" || c.RelatedBy[1] != "wifi" {
t.Errorf("relatedBy = %v, want [ble wifi]", c.RelatedBy)
}
}
// A device record carries the charger's Bluetooth pairing password. That the
// field exists is reportable; its value is not something a card should carry.
func TestAttrsMaskCredentials(t *testing.T) {
inv := newInventory()
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191",
"blue_password":"AT200025277*!","wifi_name":"AP_1_IOT","cloud":{"auth_token":"abc"}}]}}`))
attrs := inv.list()[0].Attrs
for _, key := range []string{"blue_password", "cloud.auth_token"} {
if attrs[key] == "" {
t.Fatalf("attrs[%q] is absent; the field should still be reported", key)
}
if strings.ContainsAny(attrs[key], "ATabc") {
t.Errorf("attrs[%q] = %q, want it masked", key, attrs[key])
}
}
if attrs["wifi_name"] != "AP_1_IOT" {
t.Errorf("wifi_name = %q; masking must not reach a value that is not a credential", attrs["wifi_name"])
}
}
@@ -91,6 +91,10 @@ type mqttField struct {
factor float64
unsigned bool
clock bool
// version marks the three identity fields, which the reference calls
// multi-value: four bytes that are the parts of a version rather than one
// number, in the order they arrive.
version bool
}
// evTelemetry decodes the 0410 message: the charger's live electrical state,
@@ -108,6 +112,11 @@ var evTelemetry = map[byte]mqttField{
0xa9: {name: "sessionSeconds"},
0xaa: {name: "sessionWh"},
0xab: {name: "sessionStartedAt", unsigned: true},
// Where the charge is coming from: 0 off or paused, 1 grid, 7 solar. The
// reference marks the reading uncertain, and it is not the Modbus map's
// chargingMode — a third name for a fourth thing would be the same collision
// the cloud's d9 already caused once.
0xac: {name: "chargingSource"},
0xad: {name: "plugCountdownSeconds"},
0xae: {name: "startCountdownSeconds"},
0xaf: {name: "chargingWindowSeconds"},
@@ -117,6 +126,7 @@ var evTelemetry = map[byte]mqttField{
0xb3: {name: "sessionWhL1"},
0xb4: {name: "sessionWhL2"},
0xb5: {name: "sessionWhL3"},
0xb6: {name: "orderId", unsigned: true}, // the session's id upstream; uncertain in the reference
0xb8: {name: "ocppStatus"},
0xba: {name: "phaseMode"},
0xbb: {name: "status"},
@@ -169,6 +179,13 @@ var evParams = map[byte]mqttField{
0xea: {name: "weekendEnd", unsigned: true, clock: true},
0xeb: {name: "weekendMode"},
0xec: {name: "scheduleMode"},
// The three identity fields. The reference decodes none of them, marking them
// multi-value; four bytes read as the parts of a version is what the account
// view's own firmware string looks like, so they are read that way and shown
// as they arrive rather than rearranged into an order we cannot check.
0xf1: {name: "softwareVersion", version: true},
0xf2: {name: "controllerVersion", version: true},
0xf3: {name: "hardwareVersion", version: true},
0xfe: {name: "minCurrentA"},
}
@@ -401,6 +418,9 @@ func decodeValue(typ byte, b []byte, f mqttField) (any, bool) {
if len(b) == 0 {
return nil, false
}
if f.version {
return versionString(b), true
}
factor := f.factor
if factor == 0 {
factor = 1
@@ -452,6 +472,20 @@ func decodeValue(typ byte, b []byte, f mqttField) (any, bool) {
}
}
// versionString reads an identity field. Four bytes are the parts of a version,
// in wire order; anything else is text the charger padded, or — failing that —
// the bytes themselves, because a version we cannot shape is still better shown
// than dropped.
func versionString(b []byte) string {
if len(b) == 4 {
return fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
}
if s := printable(b); s != "" {
return s
}
return encodeHex(b)
}
// round trims the floating-point noise a factor introduces, to the precision the
// factor itself implies — 0.1 keeps one decimal, 0.001 keeps three.
func round(v, factor float64) float64 {
@@ -278,3 +278,21 @@ func TestDecodeHexRejectsRubbish(t *testing.T) {
t.Errorf("encodeHex is not lowercase hex")
}
}
// The three identity fields the reference leaves alone: four bytes that are the
// parts of a version, kept in the order they arrived rather than rearranged into
// one we cannot check.
func TestDecodeVersionFields(t *testing.T) {
v, ok := decodeValue(typeInt32LE, []byte{1, 0, 6, 1}, mqttField{name: "softwareVersion", version: true})
if !ok || v != "1.0.6.1" {
t.Errorf("softwareVersion = %v (ok=%v), want 1.0.6.1", v, ok)
}
// A field that is not four bytes is text where it can be read as text, and
// the bytes themselves where it cannot — never nothing.
if v, ok := decodeValue(typeString, []byte("V1.2\x00"), mqttField{version: true}); !ok || v != "V1.2" {
t.Errorf("text version = %v (ok=%v), want V1.2", v, ok)
}
if v, ok := decodeValue(typeInt32LE, []byte{0x01, 0x02}, mqttField{version: true}); !ok || v != "0102" {
t.Errorf("unreadable version = %v (ok=%v), want its bytes", v, ok)
}
}
@@ -26,6 +26,13 @@ type MqttSnapshot struct {
Serial string `json:"serial"`
Model string `json:"model,omitempty"`
// What the charger says it is, on the same names the register map uses for
// the same three answers. The reference decodes none of them; see the
// version fields in mqttframe.go for how they are read.
Firmware string `json:"firmware,omitempty"`
ControllerVersion string `json:"controllerVersion,omitempty"`
Hardware string `json:"hardware,omitempty"`
Status *int `json:"status,omitempty"`
StatusDesc string `json:"statusDesc,omitempty"`
@@ -51,6 +58,14 @@ type MqttSnapshot struct {
SessionSeconds *float64 `json:"sessionSeconds,omitempty"`
SessionWh *float64 `json:"sessionWh,omitempty"`
// The session's own three energies and when it began. The register map has
// neither: a session is a cloud idea, and only this transport counts it.
SessionWhL1 *float64 `json:"sessionWhL1,omitempty"`
SessionWhL2 *float64 `json:"sessionWhL2,omitempty"`
SessionWhL3 *float64 `json:"sessionWhL3,omitempty"`
SessionStartedAt *float64 `json:"sessionStartedAt,omitempty"` // unix seconds
OrderID *float64 `json:"orderId,omitempty"`
// The countdowns the charger runs before a session: how long it will wait for
// a plug, and how long a start delay still has to go. They are why a charger
// that has been told to start can sit in "preparing" without being broken.
@@ -62,6 +77,11 @@ type MqttSnapshot struct {
BoostMode *bool `json:"boostMode,omitempty"`
Plugged *bool `json:"plugged,omitempty"`
// Where the charge is coming from — 0 off or paused, 1 grid, 7 solar. The
// reference marks this reading uncertain, so it is reported as the number it
// is and named for what it distinguishes rather than folded into a mode.
ChargingSource *int `json:"chargingSource,omitempty"`
CPSignal *int `json:"cpSignal,omitempty"`
CPSignalDesc string `json:"cpSignalDesc,omitempty"`
@@ -74,6 +94,21 @@ type MqttSnapshot struct {
MinCurrentA *float64 `json:"minCurrentA,omitempty"`
MaxCurrentA *float64 `json:"maxCurrentA,omitempty"`
// The panel's three gestures: what a swipe up, a swipe down and a touch do.
SwipeUpMode *int `json:"swipeUpMode,omitempty"`
SwipeDownMode *int `json:"swipeDownMode,omitempty"`
SmartTouchMode *int `json:"smartTouchMode,omitempty"`
// What the two balancing features are watching. The reference has not pinned
// down what the two modes and the flag select, so they are reported as the
// numbers they are; the serials name the meter and the monitor themselves,
// and nothing outside the charger knows them.
LoadBalanceMonitorMode *int `json:"loadBalanceMonitorMode,omitempty"`
LoadBalanceMeterFlag *int `json:"loadBalanceMeterFlag,omitempty"`
LoadBalanceMonitorSN string `json:"loadBalanceMonitorSN,omitempty"`
SolarMonitoringMode *int `json:"solarMonitoringMode,omitempty"`
SolarMonitorSN string `json:"solarMonitorSN,omitempty"`
Settings *MqttSettings `json:"settings,omitempty"`
// Local reports what the charger says about its own LAN side: whether Modbus
@@ -322,6 +357,19 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot {
snap.PowerL1, snap.PowerL2, snap.PowerL3 = num("powerL1"), num("powerL2"), num("powerL3")
snap.PowerTotal = num("powerTotal")
snap.SessionSeconds, snap.SessionWh = num("sessionSeconds"), num("sessionWh")
snap.SessionWhL1, snap.SessionWhL2 = num("sessionWhL1"), num("sessionWhL2")
snap.SessionWhL3 = num("sessionWhL3")
snap.SessionStartedAt, snap.OrderID = num("sessionStartedAt"), num("orderId")
snap.ChargingSource = whole("chargingSource")
snap.Firmware, snap.Hardware = text("softwareVersion"), text("hardwareVersion")
snap.ControllerVersion = text("controllerVersion")
snap.SwipeUpMode, snap.SwipeDownMode = whole("swipeUpMode"), whole("swipeDownMode")
snap.SmartTouchMode = whole("smartTouchMode")
snap.LoadBalanceMonitorMode = whole("loadBalanceMonitorMode")
snap.LoadBalanceMeterFlag = whole("loadBalanceMeterFlag")
snap.LoadBalanceMonitorSN = text("loadBalanceMonitorSN")
snap.SolarMonitoringMode = whole("solarMonitoringMode")
snap.SolarMonitorSN = text("solarMonitorSN")
snap.PlugCountdownSeconds = num("plugCountdownSeconds")
snap.StartCountdownSeconds = num("startCountdownSeconds")
snap.ChargingWindowSeconds = num("chargingWindowSeconds")
@@ -145,32 +145,26 @@ func TestProjectSnapshotOfNothingIsEmpty(t *testing.T) {
}
}
// 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.
// A value the projection has a field for belongs in that field; extra is what is
// left, which — now that every name in the message maps is projected — is the
// fields no map names 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,
rawFieldName("0400", 0xa2): 7.0,
})
for _, key := range []string{"powerTotal", "maxCurrentSetA"} {
for _, key := range []string{"powerTotal", "maxCurrentSetA", "sessionWhL1"} {
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)
for key, want := range map[string]any{"0410.c9": 300.0, "0400.a2": 7.0} {
if snap.Extra[key] != want {
t.Errorf("extra[%q] = %v, want %v", key, snap.Extra[key], want)
}
}
@@ -181,3 +175,19 @@ func TestProjectSnapshotKeepsWhatItHasNoFieldFor(t *testing.T) {
t.Errorf("extra = %v, want none when nothing is left over", bare.Extra)
}
}
// Every field the message maps name has somewhere to land. A name added to a map
// without a field to project it into would otherwise show up in the raw block
// under a name that reads as if it were understood.
func TestEveryNamedFieldIsProjected(t *testing.T) {
values := map[string]any{}
for _, fields := range []map[byte]mqttField{evTelemetry, evParams, evCharging} {
for _, f := range fields {
values[f.name] = 1.0
}
}
snap := projectMqttSnapshot("SN1", "A5191", values)
if len(snap.Extra) != 0 {
t.Errorf("these named fields reach no snapshot field: %v", snap.Extra)
}
}
+64
View File
@@ -208,6 +208,57 @@
"currentRange": "Strømområde",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Sessionsenergi",
"plugged": "Kabel tilsluttet",
"chargingSource": "Lader fra",
"chargingWindow": "Ladevindue",
"sessionStarted": "Session startet",
"orderId": "Sessions-id",
"liveStream": "Live-strøm",
"telemetryAt": "Aflæsninger opdateret",
"settingsAt": "Indstillinger opdateret",
"plugLock": "Stiklås",
"autoRestart": "Automatisk genstart",
"randomDelay": "Tilfældig startforsinkelse",
"scheduleEnabled": "Ladeplan",
"scheduleMode": "Plantilstand",
"weekendWindow": "Weekendvindue",
"weekendMode": "Weekendhåndtering",
"lightOff": "Lysslukningsplan",
"lightOffWindow": "Lysslukningsvindue",
"mainBreakerLimit": "Hovedsikringsgrænse",
"solarChargeMode": "Solcelletilstand",
"solarMinCurrent": "Mindste solcellestrøm",
"autoPhaseSwitching": "Automatisk faseskift",
"swipeUp": "Stryg op",
"swipeDown": "Stryg ned",
"smartTouch": "Berøringstilstand",
"loadBalanceMeter": "Belastningsmåler",
"loadBalanceMonitorMode": "Overvågningstilstand for belastning",
"loadBalanceMeterFlag": "Målerflag for belastning",
"solarMonitor": "Solcelleovervågning",
"solarMonitoringMode": "Tilstand for solcelleovervågning",
"controllerVersion": "Controllerversion",
"localTitle": "Lokalt netværk",
"modbusServer": "Modbus TCP-server",
"modbusAddress": "Adresse",
"modbusPort": "Port",
"modbusTimeout": "Styringstimeout",
"chargingSource0": "Slukket eller pauset",
"chargingSource1": "Nettet",
"chargingSource7": "Solceller",
"gesture0": "Fra",
"gesture1": "Start opladning",
"gesture2": "Stop opladning",
"gesture3": "Boost",
"touch0": "Enkel",
"touch1": "Beskyt mod fejlberøring",
"scheduleMode0": "Normal",
"scheduleMode1": "Smart",
"weekendMode1": "Samme som hverdage",
"weekendMode2": "Eget vindue",
"solarMode0": "Solceller med netstøtte",
"solarMode1": "Kun solceller",
"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",
@@ -252,6 +303,19 @@
"ocpp": "OCPP-status",
"added": "Tilføjet",
"online": "Online",
"wifiName": "Wi-Fi-netværk",
"wifiMac": "Wi-Fi-MAC",
"signal": "Signal",
"bleMac": "Bluetooth-MAC",
"relatedBy": "Kan nås via",
"timeZone": "Tidszone",
"linked": "Tilknyttet",
"views": {
"station": "Stationspost",
"totals": "Ladetotaler",
"ocpp": "OCPP-backend",
"rfid": "RFID-kort"
},
"offline": "Offline",
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
+64
View File
@@ -208,6 +208,57 @@
"currentRange": "Current range",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Session energy",
"plugged": "Cable plugged in",
"chargingSource": "Charging from",
"chargingWindow": "Charging window",
"sessionStarted": "Session started",
"orderId": "Session id",
"liveStream": "Live stream",
"telemetryAt": "Readings updated",
"settingsAt": "Settings updated",
"plugLock": "Plug lock",
"autoRestart": "Auto restart",
"randomDelay": "Random start delay",
"scheduleEnabled": "Charging schedule",
"scheduleMode": "Schedule mode",
"weekendWindow": "Weekend window",
"weekendMode": "Weekend handling",
"lightOff": "Light-off schedule",
"lightOffWindow": "Light-off window",
"mainBreakerLimit": "Main breaker limit",
"solarChargeMode": "Solar charging mode",
"solarMinCurrent": "Solar minimum current",
"autoPhaseSwitching": "Automatic phase switching",
"swipeUp": "Swipe up",
"swipeDown": "Swipe down",
"smartTouch": "Touch mode",
"loadBalanceMeter": "Load-balance meter",
"loadBalanceMonitorMode": "Load-balance monitoring mode",
"loadBalanceMeterFlag": "Load-balance meter flag",
"solarMonitor": "Solar monitor",
"solarMonitoringMode": "Solar monitoring mode",
"controllerVersion": "Controller version",
"localTitle": "Local network",
"modbusServer": "Modbus TCP server",
"modbusAddress": "Address",
"modbusPort": "Port",
"modbusTimeout": "Control timeout",
"chargingSource0": "Off or paused",
"chargingSource1": "Grid",
"chargingSource7": "Solar",
"gesture0": "Off",
"gesture1": "Start charging",
"gesture2": "Stop charging",
"gesture3": "Boost",
"touch0": "Simple",
"touch1": "Anti-mistouch",
"scheduleMode0": "Normal",
"scheduleMode1": "Smart",
"weekendMode1": "Same as weekdays",
"weekendMode2": "Its own window",
"solarMode0": "Solar with grid support",
"solarMode1": "Solar only",
"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",
@@ -252,6 +303,19 @@
"ocpp": "OCPP status",
"added": "Added",
"online": "Online",
"wifiName": "Wi-Fi network",
"wifiMac": "Wi-Fi MAC",
"signal": "Signal",
"bleMac": "Bluetooth MAC",
"relatedBy": "Reachable by",
"timeZone": "Time zone",
"linked": "Linked",
"views": {
"station": "Station record",
"totals": "Charging totals",
"ocpp": "OCPP backend",
"rfid": "RFID cards"
},
"offline": "Offline",
"refresh": "Refresh",
"rawTitle": "As the service reports it",
+64
View File
@@ -210,6 +210,57 @@
"currentRange": "Zakres prądu",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Energia sesji",
"plugged": "Kabel podłączony",
"chargingSource": "Ładowanie z",
"chargingWindow": "Okno ładowania",
"sessionStarted": "Sesja rozpoczęta",
"orderId": "Id sesji",
"liveStream": "Strumień na żywo",
"telemetryAt": "Odczyty zaktualizowane",
"settingsAt": "Ustawienia zaktualizowane",
"plugLock": "Blokada wtyczki",
"autoRestart": "Automatyczne wznowienie",
"randomDelay": "Losowe opóźnienie startu",
"scheduleEnabled": "Harmonogram ładowania",
"scheduleMode": "Tryb harmonogramu",
"weekendWindow": "Okno weekendowe",
"weekendMode": "Obsługa weekendu",
"lightOff": "Harmonogram wygaszania",
"lightOffWindow": "Okno wygaszania",
"mainBreakerLimit": "Limit bezpiecznika głównego",
"solarChargeMode": "Tryb ładowania solarnego",
"solarMinCurrent": "Minimalny prąd solarny",
"autoPhaseSwitching": "Automatyczne przełączanie faz",
"swipeUp": "Przesunięcie w górę",
"swipeDown": "Przesunięcie w dół",
"smartTouch": "Tryb dotyku",
"loadBalanceMeter": "Licznik balansowania",
"loadBalanceMonitorMode": "Tryb monitorowania balansowania",
"loadBalanceMeterFlag": "Flaga licznika balansowania",
"solarMonitor": "Monitor solarny",
"solarMonitoringMode": "Tryb monitorowania solarnego",
"controllerVersion": "Wersja sterownika",
"localTitle": "Sieć lokalna",
"modbusServer": "Serwer Modbus TCP",
"modbusAddress": "Adres",
"modbusPort": "Port",
"modbusTimeout": "Limit czasu sterowania",
"chargingSource0": "Wyłączone lub wstrzymane",
"chargingSource1": "Sieć",
"chargingSource7": "Fotowoltaika",
"gesture0": "Wyłączone",
"gesture1": "Rozpocznij ładowanie",
"gesture2": "Zatrzymaj ładowanie",
"gesture3": "Boost",
"touch0": "Prosty",
"touch1": "Zabezpieczenie przed dotknięciem",
"scheduleMode0": "Normalny",
"scheduleMode1": "Inteligentny",
"weekendMode1": "Tak jak w dni robocze",
"weekendMode2": "Własne okno",
"solarMode0": "Fotowoltaika ze wsparciem sieci",
"solarMode1": "Tylko fotowoltaika",
"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",
@@ -254,6 +305,19 @@
"ocpp": "Status OCPP",
"added": "Dodano",
"online": "Online",
"wifiName": "Sieć Wi-Fi",
"wifiMac": "MAC Wi-Fi",
"signal": "Sygnał",
"bleMac": "MAC Bluetooth",
"relatedBy": "Dostępna przez",
"timeZone": "Strefa czasowa",
"linked": "Powiązano",
"views": {
"station": "Rekord stacji",
"totals": "Sumy ładowania",
"ocpp": "Backend OCPP",
"rfid": "Karty RFID"
},
"offline": "Offline",
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
+9
View File
@@ -641,6 +641,15 @@ class ApiClient {
return AnkerChargerList.fromJson(Map<String, dynamic>.from(data));
}
/// Every per-charger view the account holds — the station record, the charging
/// totals, the OCPP backend and the RFID cards — asked for one charger at a
/// time, because none of those endpoints lists chargers.
Future<ChargerDetails> getAnkerChargerDetails(String sn) async {
final data = await _send("GET", "/integrations/anker-solix/chargers/${_sn(sn)}/details");
if (data is! Map) return const ChargerDetails();
return ChargerDetails.fromJson(Map<String, dynamic>.from(data));
}
// Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT
// broker rather than a cloud account — the charger publishes to a broker the
// owner runs and the server joins it. testGreencell connects to that broker and
+75
View File
@@ -1399,6 +1399,18 @@ class ProviderCharger {
final int? ocppStatus;
final String ocppStatusDesc;
/// What the account knows about the box on the wall, as opposed to the
/// charging: which networks it is on, where it thinks it is, when it was
/// bound, and the picture the app shows for it.
final String wifiName;
final String wifiMac;
final int? wifiRssi;
final String bleMac;
final String timeZone;
final double? linkedAt; // unix seconds
final String imageUrl;
final List<String> relatedBy; // ble, wifi — how the app reaches it
/// Everything else the service said about this charger, under its own field
/// names. The fields above are the ones DriverVault has a name for; this is
/// the remainder, kept rather than dropped — the service documents none of it,
@@ -1423,6 +1435,14 @@ class ProviderCharger {
this.power = "",
this.ocppStatus,
this.ocppStatusDesc = "",
this.wifiName = "",
this.wifiMac = "",
this.wifiRssi,
this.bleMac = "",
this.timeZone = "",
this.linkedAt,
this.imageUrl = "",
this.relatedBy = const [],
this.attrs = const {},
this.linkedChargerId = "",
});
@@ -1441,6 +1461,14 @@ class ProviderCharger {
power: _asStr(j["power"]),
ocppStatus: _asIntOrNull(j["ocppStatus"]),
ocppStatusDesc: _asStr(j["ocppStatusDesc"]),
wifiName: _asStr(j["wifiName"]),
wifiMac: _asStr(j["wifiMac"]),
wifiRssi: _asIntOrNull(j["wifiRssi"]),
bleMac: _asStr(j["bleMac"]),
timeZone: _asStr(j["timeZone"]),
linkedAt: _asDoubleOrNull(j["linkedAt"]),
imageUrl: _asStr(j["imageUrl"]),
relatedBy: _asStrList(j["relatedBy"]),
attrs: _asStrMap(j["attrs"]),
linkedChargerId: _asStr(j["linkedChargerId"]),
);
@@ -1457,6 +1485,53 @@ class ProviderCharger {
}
}
/// One of the account's per-charger views — the station record, the charging
/// totals, the OCPP backend, the RFID cards. Anker documents none of these
/// payloads, so the fields arrive under the cloud's own keys; a view the account
/// cannot read carries its reason instead.
class ChargerDetailView {
final String id;
final Map<String, String> attrs;
final String error;
const ChargerDetailView({required this.id, this.attrs = const {}, this.error = ""});
factory ChargerDetailView.fromJson(Map<String, dynamic> j) => ChargerDetailView(
id: _asStr(j["id"]),
attrs: _asStrMap(j["attrs"]),
error: _asStr(j["error"]),
);
/// The fields, sorted, so the same charger reads the same way every time.
List<(String, String)> get rows {
final keys = attrs.keys.toList()..sort();
return [for (final key in keys) (key, attrs[key]!)];
}
}
/// Every per-charger view for one charger, from
/// …/anker-solix/chargers/{sn}/details.
class ChargerDetails {
final String sn;
final List<ChargerDetailView> views;
const ChargerDetails({this.sn = "", this.views = const []});
factory ChargerDetails.fromJson(Map<String, dynamic> j) {
final raw = j["views"];
return ChargerDetails(
sn: _asStr(j["sn"]),
views: raw is List
? raw
.whereType<Map>()
.map((v) => ChargerDetailView.fromJson(Map<String, dynamic>.from(v)))
.where((v) => v.attrs.isNotEmpty || v.error.isNotEmpty)
.toList()
: const [],
);
}
}
/// The chargers on one provider account, plus the reason the list may be empty:
/// a gate that is off answers 200 with nothing and a sentence, so the UI can say
/// "connect this in Settings" rather than show a blank panel.
+175
View File
@@ -1052,6 +1052,12 @@ class _HomeTabState extends State<_HomeTab> {
String _selected = ""; // the charger the cards are about
String _removing = "";
/// The account's per-charger views, kept by serial: four endpoints answer only
/// when a serial is named, so they are asked for the charger being looked at
/// and remembered, rather than asked again on every glance.
final Map<String, ChargerDetails> _details = {};
String _detailsLoading = "";
/// Which cards are folded, read once and written on every toggle.
Set<String> _collapsed = <String>{};
@@ -1216,6 +1222,37 @@ class _HomeTabState extends State<_HomeTab> {
_liveLoaded = true;
_liveLoading = false;
});
// The card's other half: the views that answer per charger rather than per
// account. Refreshing the card refreshes both.
_loadDetails(force: force);
}
/// The per-charger views for the charger on screen. A view the account cannot
/// read is not an error to put on the page — the rows above still say
/// everything the inventory knew.
Future<void> _loadDetails({bool force = false}) async {
final c = _selectedCharger;
final sn = c == null
? ""
: (c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial);
if (sn.isEmpty || c!.provider != "anker-solix") return;
if (_detailsLoading == sn || (_details.containsKey(sn) && !force)) return;
_detailsLoading = sn;
try {
final res = await apiClient.getAnkerChargerDetails(sn);
if (mounted) setState(() => _details[sn] = res);
} catch (_) {
// Left absent rather than shown as a failure.
} finally {
_detailsLoading = "";
}
}
/// The views to draw for the charger on screen, in the order the account
/// answered them.
List<ChargerDetailView> _detailViews(HomeCharger c) {
final sn = c.providerChargerId.isNotEmpty ? c.providerChargerId : c.serial;
return _details[sn]?.views ?? const [];
}
Future<void> _refresh() async {
@@ -1368,6 +1405,7 @@ class _HomeTabState extends State<_HomeTab> {
/// A charger picked here becomes the one the control cards drive.
void _select(HomeCharger c) {
setState(() => _selected = c.id);
_loadDetails();
if (c.serial.isEmpty) return;
_serial.text = c.serial;
_refresh();
@@ -1884,6 +1922,7 @@ class _HomeTabState extends State<_HomeTab> {
final phases = _phaseRows(s);
final live = _liveRows(s);
final settings = _settingRows(s);
final local = _localRows(s);
final device = _deviceRows(s);
final extra = _extraRows(s);
final alarms = _alarmWords(s);
@@ -1910,6 +1949,7 @@ class _HomeTabState extends State<_HomeTab> {
_th(context, t("charging.modbus.activePower")),
if (_phasesHaveVA(s)) _th(context, t("charging.modbus.reactivePower")),
if (_phasesHaveVA(s)) _th(context, t("charging.modbus.apparentPower")),
if (_phasesHaveSessionWh(s)) _th(context, t("charging.modbus.sessionEnergy")),
]),
for (final row in phases)
TableRow(children: [
@@ -1940,6 +1980,12 @@ class _HomeTabState extends State<_HomeTab> {
_ReadingSection(heading: t("charging.modbus.settings"), child: _PairList(rows: settings)),
const SizedBox(height: 8),
],
// The charger's own LAN side, which only the cloud transport can report:
// whether its Modbus server is on, and where.
if (local.isNotEmpty) ...[
_ReadingSection(heading: t("charging.modbus.localTitle"), child: _PairList(rows: local)),
const SizedBox(height: 8),
],
if (device.isNotEmpty) ...[
_ReadingSection(heading: t("charging.modbus.device"), child: _PairList(rows: device)),
const SizedBox(height: 8),
@@ -2080,9 +2126,34 @@ class _HomeTabState extends State<_HomeTab> {
: _unit(r1, 1, "°C")
),
("pwm", _yesNo(s.flag("pwmEnabled"))),
("plugged", _yesNo(s.flag("plugged"))),
// Where the charge is coming from. The reference marks this reading
// uncertain, and an unlisted value falls back to its number rather than
// borrowing the name of a neighbouring one.
("chargingSource", _enumLabel("chargingSource", s.integer("chargingSource"))),
("chargingWindow", _sessionLength(s.integer("chargingWindowSeconds"))),
("sessionStarted", _unixTime(s.number("sessionStartedAt"))),
("orderId", _plain(s.integer("orderId"))),
// Two streams, two clocks: telemetry flows only inside a trigger window,
// the settings arrive with a command. A reading is worth as much as its
// age, so each half says when it last spoke.
("liveStream", _yesNo(s.flag("live"))),
("telemetryAt", _stamp(s.text("telemetryAt"))),
("settingsAt", _stamp(s.text("settingsAt"))),
]);
}
String? _plain(int? v) => v == null ? null : "$v";
/// A cloud timestamp, as the charger sends it: an ISO instant on the two
/// stream clocks, whole unix seconds on the session's start.
String? _stamp(String iso) =>
iso.isEmpty ? null : formatDateTime(DateTime.tryParse(iso)?.toLocal());
String? _unixTime(double? seconds) => seconds == null || seconds <= 0
? null
: formatDateTime(DateTime.fromMillisecondsSinceEpoch((seconds * 1000).round()));
List<(String, String)> _settingRows(ChargerStatus s) {
final timeout = s.settingInt("timeoutSeconds");
final led = s.integer("ledBrightness");
@@ -2101,6 +2172,57 @@ class _HomeTabState extends State<_HomeTab> {
("loadBalancing", _yesNo(s.flag("loadBalancing"))),
("solarBalancing", _yesNo(s.flag("solarBalancing"))),
("ledBrightness", led == null ? null : "$led %"),
// The rest of the settings group, which only the cloud transport reports:
// the register map has no address for any of them.
("plugLock", _yesNo(s.settingFlag("plugLock"))),
("autoRestart", _yesNo(s.settingFlag("autoRestart"))),
("randomDelay", _yesNo(s.settingFlag("randomDelay"))),
("scheduleEnabled", _yesNo(s.settingFlag("scheduleEnabled"))),
("scheduleMode", _enumLabel("scheduleMode", s.settingInt("scheduleMode"))),
("weekendWindow", _clockWindow(s, "weekendStart", "weekendEnd")),
("weekendMode", _enumLabel("weekendMode", s.settingInt("weekendMode"))),
("lightOff", _yesNo(s.settingFlag("lightOffSchedule"))),
("lightOffWindow", _clockWindow(s, "lightOffStart", "lightOffEnd")),
("mainBreakerLimit", _unit(s.setting("mainBreakerLimitA"), 0, "A")),
("solarChargeMode", _enumLabel("solarMode", s.settingInt("solarChargeMode"))),
("solarMinCurrent", _unit(s.setting("solarMinCurrentA"), 0, "A")),
("autoPhaseSwitching", _yesNo(s.settingFlag("autoPhaseSwitching"))),
("swipeUp", _enumLabel("gesture", s.integer("swipeUpMode"))),
("swipeDown", _enumLabel("gesture", s.integer("swipeDownMode"))),
("smartTouch", _enumLabel("touch", s.integer("smartTouchMode"))),
// What the two balancing features watch. The reference has not pinned
// down what the two modes and the flag select, so they are shown as the
// numbers they are rather than under names that would imply we knew.
("loadBalanceMeter", s.text("loadBalanceMonitorSN")),
("loadBalanceMonitorMode", _plain(s.integer("loadBalanceMonitorMode"))),
("loadBalanceMeterFlag", _plain(s.integer("loadBalanceMeterFlag"))),
("solarMonitor", s.text("solarMonitorSN")),
("solarMonitoringMode", _plain(s.integer("solarMonitoringMode"))),
]);
}
/// One of the charger's four time windows, when both of its ends arrived.
String? _clockWindow(ChargerStatus s, String fromKey, String toKey) {
final from = s.settings[fromKey];
final to = s.settings[toKey];
if (from is! String || to is! String || from.isEmpty || to.isEmpty) return null;
return "$from$to";
}
/// What the charger says about its own LAN side. The cloud transport is the
/// only one that can answer it — a charger whose Modbus server is off is a
/// charger the Modbus transport cannot ask.
List<(String, String)> _localRows(ChargerStatus s) {
final local = s.local;
final enabled = local["modbusEnabled"];
final host = local["host"];
final port = local["port"];
final timeout = local["timeoutSeconds"];
return _rows([
("modbusServer", _yesNo(enabled is bool ? enabled : null)),
("modbusAddress", host is String ? host : null),
("modbusPort", port == null ? null : "$port"),
("modbusTimeout", timeout == null ? null : "$timeout s"),
]);
}
@@ -2112,6 +2234,7 @@ class _HomeTabState extends State<_HomeTab> {
("model", s.text("model")),
("serial", s.text("serial")),
("firmware", s.text("firmware")),
("controllerVersion", s.text("controllerVersion")),
("hardware", s.text("hardware")),
("productNumber", product == null ? null : "$product"),
("ratedPower", _unit(s.number("ratedPowerW"), 0, "W")),
@@ -2153,6 +2276,7 @@ class _HomeTabState extends State<_HomeTab> {
final any = ["voltageL1", "currentL1", "powerL1"].any((k) => s.raw[k] != null);
if (!any) return const [];
final va = _phasesHaveVA(s);
final wh = _phasesHaveSessionWh(s);
return [
for (final n in [1, 2, 3])
[
@@ -2162,10 +2286,17 @@ class _HomeTabState extends State<_HomeTab> {
cell(s.number("powerL$n"), 0, "W"),
if (va) cell(s.number("reactiveL$n"), 0, "var"),
if (va) cell(s.number("apparentL$n"), 0, "VA"),
if (wh) cell(s.number("sessionWhL$n"), 0, "Wh"),
],
];
}
/// The session's energy per phase is a cloud reading — a session is a cloud
/// idea, and no register counts one — so the column joins the matrix when the
/// charger reports it rather than standing as three dashes on Modbus.
bool _phasesHaveSessionWh(ChargerStatus s) =>
[1, 2, 3].any((n) => s.raw["sessionWhL$n"] != null);
/// Line-to-line voltages only mean anything on a three-phase supply, so they
/// are shown when the charger reports one rather than as three more zeroes.
List<String> _lineVoltages(ChargerStatus s) {
@@ -2221,6 +2352,17 @@ class _HomeTabState extends State<_HomeTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
// The product shot the app shows for this model, when the
// account sent one. Decorative: the name beside it says
// everything the picture does.
if ((_liveFor(charger)?.imageUrl ?? "").isNotEmpty) ...[
Image.network(_liveFor(charger)!.imageUrl,
width: 28,
height: 28,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
const SizedBox(width: 8),
],
Expanded(
child: Text(charger.name,
maxLines: 1,
@@ -2256,6 +2398,24 @@ class _HomeTabState extends State<_HomeTab> {
const SizedBox(height: 4),
Text(t("charging.info.rawHint"), style: TextStyle(fontSize: 11, color: muted)),
],
// The views that answer per charger rather than per account.
// Each says what it knows, or why it could not be read — an
// account that is not the charger's owner cannot read the
// cards, which is a fact about the account rather than a
// failure.
for (final view in _detailViews(charger)) ...[
const SizedBox(height: 10),
Text(
t("charging.info.views.${view.id}"),
style: DriverVault.mono(context, size: 10, weight: FontWeight.w500, color: muted)
.copyWith(letterSpacing: 1.4),
),
const SizedBox(height: 4),
if (view.error.isNotEmpty)
Text(view.error, style: TextStyle(fontSize: 11, color: muted))
else
_PairList(rows: view.rows, breakLong: true),
],
],
),
),
@@ -2299,6 +2459,21 @@ class _HomeTabState extends State<_HomeTab> {
// on it here would be inventing it.
("chargePower", live?.power ?? ""),
("ocpp", live?.ocppLabel ?? ""),
// The box on the wall, as opposed to the charging: the networks it is on,
// where it thinks it is, and when the account first saw it.
("wifiName", live?.wifiName ?? ""),
("wifiMac", live?.wifiMac ?? ""),
("signal", live?.wifiRssi == null ? "" : "${live!.wifiRssi} dBm"),
("bleMac", live?.bleMac ?? ""),
("relatedBy", (live?.relatedBy ?? const []).join(" · ")),
("timeZone", live?.timeZone ?? ""),
(
"linked",
live?.linkedAt == null
? ""
: formatDateTime(
DateTime.fromMillisecondsSinceEpoch((live!.linkedAt! * 1000).round()))
),
("providerId", c.providerChargerId),
("added", c.created.isEmpty ? "" : formatDateTime(DateTime.tryParse(c.created)?.toLocal())),
];
+5
View File
@@ -328,6 +328,11 @@ export const api = {
// with an empty list and a reason when a gate is off, so the caller can show
// the reason rather than an error.
listAnkerChargers: () => request("/integrations/anker-solix/chargers"),
// Every per-charger view the account holds — the station record, the charging
// totals, the OCPP backend and the RFID cards — asked for one charger at a
// time, because none of those endpoints lists chargers.
getAnkerChargerDetails: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/details`),
// Anker Solix control (per charger), over whichever transport the user's
// control mode selects. getAnkerControl returns the control mode, connection
+63
View File
@@ -169,6 +169,56 @@
"currentRange": "Strømområde",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Kabel tilsluttet",
"chargingSource": "Lader fra",
"chargingWindow": "Ladevindue",
"sessionStarted": "Session startet",
"orderId": "Sessions-id",
"liveStream": "Live-strøm",
"telemetryAt": "Aflæsninger opdateret",
"settingsAt": "Indstillinger opdateret",
"plugLock": "Stiklås",
"autoRestart": "Automatisk genstart",
"randomDelay": "Tilfældig startforsinkelse",
"scheduleEnabled": "Ladeplan",
"scheduleMode": "Plantilstand",
"weekendWindow": "Weekendvindue",
"weekendMode": "Weekendhåndtering",
"lightOff": "Lysslukningsplan",
"lightOffWindow": "Lysslukningsvindue",
"mainBreakerLimit": "Hovedsikringsgrænse",
"solarChargeMode": "Solcelletilstand",
"solarMinCurrent": "Mindste solcellestrøm",
"autoPhaseSwitching": "Automatisk faseskift",
"swipeUp": "Stryg op",
"swipeDown": "Stryg ned",
"smartTouch": "Berøringstilstand",
"loadBalanceMeter": "Belastningsmåler",
"loadBalanceMonitorMode": "Overvågningstilstand for belastning",
"loadBalanceMeterFlag": "Målerflag for belastning",
"solarMonitor": "Solcelleovervågning",
"solarMonitoringMode": "Tilstand for solcelleovervågning",
"controllerVersion": "Controllerversion",
"localTitle": "Lokalt netværk",
"modbusServer": "Modbus TCP-server",
"modbusAddress": "Adresse",
"modbusPort": "Port",
"modbusTimeout": "Styringstimeout",
"chargingSource0": "Slukket eller pauset",
"chargingSource1": "Nettet",
"chargingSource7": "Solceller",
"gesture0": "Fra",
"gesture1": "Start opladning",
"gesture2": "Stop opladning",
"gesture3": "Boost",
"touch0": "Enkel",
"touch1": "Beskyt mod fejlberøring",
"scheduleMode0": "Normal",
"scheduleMode1": "Smart",
"weekendMode1": "Samme som hverdage",
"weekendMode2": "Eget vindue",
"solarMode0": "Solceller med netstøtte",
"solarMode1": "Kun solceller",
"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",
@@ -213,6 +263,19 @@
"ocpp": "OCPP-status",
"added": "Tilføjet",
"online": "Online",
"wifiName": "Wi-Fi-netværk",
"wifiMac": "Wi-Fi-MAC",
"signal": "Signal",
"bleMac": "Bluetooth-MAC",
"relatedBy": "Kan nås via",
"timeZone": "Tidszone",
"linked": "Tilknyttet",
"views": {
"station": "Stationspost",
"totals": "Ladetotaler",
"ocpp": "OCPP-backend",
"rfid": "RFID-kort"
},
"offline": "Offline",
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
+63
View File
@@ -155,6 +155,56 @@
"currentRange": "Current range",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Cable plugged in",
"chargingSource": "Charging from",
"chargingWindow": "Charging window",
"sessionStarted": "Session started",
"orderId": "Session id",
"liveStream": "Live stream",
"telemetryAt": "Readings updated",
"settingsAt": "Settings updated",
"plugLock": "Plug lock",
"autoRestart": "Auto restart",
"randomDelay": "Random start delay",
"scheduleEnabled": "Charging schedule",
"scheduleMode": "Schedule mode",
"weekendWindow": "Weekend window",
"weekendMode": "Weekend handling",
"lightOff": "Light-off schedule",
"lightOffWindow": "Light-off window",
"mainBreakerLimit": "Main breaker limit",
"solarChargeMode": "Solar charging mode",
"solarMinCurrent": "Solar minimum current",
"autoPhaseSwitching": "Automatic phase switching",
"swipeUp": "Swipe up",
"swipeDown": "Swipe down",
"smartTouch": "Touch mode",
"loadBalanceMeter": "Load-balance meter",
"loadBalanceMonitorMode": "Load-balance monitoring mode",
"loadBalanceMeterFlag": "Load-balance meter flag",
"solarMonitor": "Solar monitor",
"solarMonitoringMode": "Solar monitoring mode",
"controllerVersion": "Controller version",
"localTitle": "Local network",
"modbusServer": "Modbus TCP server",
"modbusAddress": "Address",
"modbusPort": "Port",
"modbusTimeout": "Control timeout",
"chargingSource0": "Off or paused",
"chargingSource1": "Grid",
"chargingSource7": "Solar",
"gesture0": "Off",
"gesture1": "Start charging",
"gesture2": "Stop charging",
"gesture3": "Boost",
"touch0": "Simple",
"touch1": "Anti-mistouch",
"scheduleMode0": "Normal",
"scheduleMode1": "Smart",
"weekendMode1": "Same as weekdays",
"weekendMode2": "Its own window",
"solarMode0": "Solar with grid support",
"solarMode1": "Solar only",
"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",
@@ -199,6 +249,19 @@
"ocpp": "OCPP status",
"added": "Added",
"online": "Online",
"wifiName": "Wi-Fi network",
"wifiMac": "Wi-Fi MAC",
"signal": "Signal",
"bleMac": "Bluetooth MAC",
"relatedBy": "Reachable by",
"timeZone": "Time zone",
"linked": "Linked",
"views": {
"station": "Station record",
"totals": "Charging totals",
"ocpp": "OCPP backend",
"rfid": "RFID cards"
},
"offline": "Offline",
"refresh": "Refresh",
"rawTitle": "As the service reports it",
+63
View File
@@ -171,6 +171,56 @@
"currentRange": "Zakres prądu",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Kabel podłączony",
"chargingSource": "Ładowanie z",
"chargingWindow": "Okno ładowania",
"sessionStarted": "Sesja rozpoczęta",
"orderId": "Id sesji",
"liveStream": "Strumień na żywo",
"telemetryAt": "Odczyty zaktualizowane",
"settingsAt": "Ustawienia zaktualizowane",
"plugLock": "Blokada wtyczki",
"autoRestart": "Automatyczne wznowienie",
"randomDelay": "Losowe opóźnienie startu",
"scheduleEnabled": "Harmonogram ładowania",
"scheduleMode": "Tryb harmonogramu",
"weekendWindow": "Okno weekendowe",
"weekendMode": "Obsługa weekendu",
"lightOff": "Harmonogram wygaszania",
"lightOffWindow": "Okno wygaszania",
"mainBreakerLimit": "Limit bezpiecznika głównego",
"solarChargeMode": "Tryb ładowania solarnego",
"solarMinCurrent": "Minimalny prąd solarny",
"autoPhaseSwitching": "Automatyczne przełączanie faz",
"swipeUp": "Przesunięcie w górę",
"swipeDown": "Przesunięcie w dół",
"smartTouch": "Tryb dotyku",
"loadBalanceMeter": "Licznik balansowania",
"loadBalanceMonitorMode": "Tryb monitorowania balansowania",
"loadBalanceMeterFlag": "Flaga licznika balansowania",
"solarMonitor": "Monitor solarny",
"solarMonitoringMode": "Tryb monitorowania solarnego",
"controllerVersion": "Wersja sterownika",
"localTitle": "Sieć lokalna",
"modbusServer": "Serwer Modbus TCP",
"modbusAddress": "Adres",
"modbusPort": "Port",
"modbusTimeout": "Limit czasu sterowania",
"chargingSource0": "Wyłączone lub wstrzymane",
"chargingSource1": "Sieć",
"chargingSource7": "Fotowoltaika",
"gesture0": "Wyłączone",
"gesture1": "Rozpocznij ładowanie",
"gesture2": "Zatrzymaj ładowanie",
"gesture3": "Boost",
"touch0": "Prosty",
"touch1": "Zabezpieczenie przed dotknięciem",
"scheduleMode0": "Normalny",
"scheduleMode1": "Inteligentny",
"weekendMode1": "Tak jak w dni robocze",
"weekendMode2": "Własne okno",
"solarMode0": "Fotowoltaika ze wsparciem sieci",
"solarMode1": "Tylko fotowoltaika",
"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",
@@ -215,6 +265,19 @@
"ocpp": "Status OCPP",
"added": "Dodano",
"online": "Online",
"wifiName": "Sieć Wi-Fi",
"wifiMac": "MAC Wi-Fi",
"signal": "Sygnał",
"bleMac": "MAC Bluetooth",
"relatedBy": "Dostępna przez",
"timeZone": "Strefa czasowa",
"linked": "Powiązano",
"views": {
"station": "Rekord stacji",
"totals": "Sumy ładowania",
"ocpp": "Backend OCPP",
"rfid": "Karty RFID"
},
"offline": "Offline",
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
+161 -1
View File
@@ -402,12 +402,26 @@ const deviceLive = computed(() => {
["startCountdown", countdown(s.startCountdownSeconds)],
["cpSignal", s.cpSignalDesc],
["cpVoltage", unit(s.cpVoltage, 2, "V")],
["plugged", yesNo(s.plugged)],
// Where the charge is coming from. The reference marks this reading
// uncertain, and an unlisted value falls back to its number rather than
// borrowing the name of a neighbouring one.
["chargingSource", enumLabel("chargingSource", s.chargingSource)],
["chargingWindow", sessionLength(s.chargingWindowSeconds)],
["sessionStarted", s.sessionStartedAt ? formatDateTime(new Date(s.sessionStartedAt * 1000)) : null],
["orderId", isSet(s.orderId) ? String(s.orderId) : null],
["phaseMode", enumLabel("phaseMode", s.phaseMode)],
["relayTemps",
isSet(s.relay1TempC) && isSet(s.relay2TempC)
? `${s.relay1TempC.toFixed(1)} / ${s.relay2TempC.toFixed(1)} °C`
: unit(s.relay1TempC, 1, "°C")],
["pwm", yesNo(s.pwmEnabled)],
// Two streams, two clocks: telemetry flows only inside a trigger window,
// the settings arrive with a command. A reading is worth as much as its
// age, so each half says when it last spoke.
["liveStream", yesNo(s.live)],
["telemetryAt", s.telemetryAt ? formatDateTime(s.telemetryAt) : null],
["settingsAt", s.settingsAt ? formatDateTime(s.settingsAt) : null],
]);
});
@@ -429,6 +443,45 @@ const deviceSettings = computed(() => {
["loadBalancing", yesNo(s.loadBalancing)],
["solarBalancing", yesNo(s.solarBalancing)],
["ledBrightness", isSet(s.ledBrightness) ? `${s.ledBrightness} %` : null],
// The rest of the settings group, which only the cloud transport reports:
// the register map has no address for any of them.
["plugLock", yesNo(set.plugLock)],
["autoRestart", yesNo(set.autoRestart)],
["randomDelay", yesNo(set.randomDelay)],
["scheduleEnabled", yesNo(set.scheduleEnabled)],
["scheduleMode", enumLabel("scheduleMode", set.scheduleMode)],
["weekendWindow", set.weekendStart && set.weekendEnd ? `${set.weekendStart}${set.weekendEnd}` : null],
["weekendMode", enumLabel("weekendMode", set.weekendMode)],
["lightOff", yesNo(set.lightOffSchedule)],
["lightOffWindow", set.lightOffStart && set.lightOffEnd ? `${set.lightOffStart}${set.lightOffEnd}` : null],
["mainBreakerLimit", unit(set.mainBreakerLimitA, 0, "A")],
["solarChargeMode", enumLabel("solarMode", set.solarChargeMode)],
["solarMinCurrent", unit(set.solarMinCurrentA, 0, "A")],
["autoPhaseSwitching", yesNo(set.autoPhaseSwitching)],
["swipeUp", enumLabel("gesture", s.swipeUpMode)],
["swipeDown", enumLabel("gesture", s.swipeDownMode)],
["smartTouch", enumLabel("touch", s.smartTouchMode)],
// What the two balancing features watch. The reference has not pinned down
// what the two modes and the flag select, so they are shown as the numbers
// they are rather than under names that would imply we knew.
["loadBalanceMeter", s.loadBalanceMonitorSN],
["loadBalanceMonitorMode", isSet(s.loadBalanceMonitorMode) ? String(s.loadBalanceMonitorMode) : null],
["loadBalanceMeterFlag", isSet(s.loadBalanceMeterFlag) ? String(s.loadBalanceMeterFlag) : null],
["solarMonitor", s.solarMonitorSN],
["solarMonitoringMode", isSet(s.solarMonitoringMode) ? String(s.solarMonitoringMode) : null],
]);
});
// What the charger says about its own LAN side. The cloud transport is the only
// one that can answer it — a charger whose Modbus server is off is a charger the
// Modbus transport cannot ask.
const deviceLocal = computed(() => {
const local = dev.value.local || {};
return rows([
["modbusServer", yesNo(local.modbusEnabled)],
["modbusAddress", local.host],
["modbusPort", isSet(local.port) ? String(local.port) : null],
["modbusTimeout", isSet(local.timeoutSeconds) ? `${local.timeoutSeconds} s` : null],
]);
});
@@ -509,6 +562,7 @@ const deviceIdentity = computed(() => {
["model", s.model],
["serial", s.serial],
["firmware", s.firmware],
["controllerVersion", s.controllerVersion],
["hardware", s.hardware],
["productNumber", isSet(s.productNumber) ? String(s.productNumber) : null],
["ratedPower", unit(s.ratedPowerW, 0, "W")],
@@ -547,9 +601,18 @@ const devicePhases = computed(() => {
watts: cell(s[`powerL${n}`], 0, "W"),
reactive: cell(s[`reactiveL${n}`], 0, "var"),
apparent: cell(s[`apparentL${n}`], 0, "VA"),
sessionWh: cell(s[`sessionWhL${n}`], 0, "Wh"),
}));
});
// The session's energy per phase is a cloud reading — a session is a cloud idea,
// and no register counts one — so the column joins the matrix when the charger
// reports it rather than standing as three dashes on the other transport.
const devicePhasesHaveSessionWh = computed(() => {
const s = dev.value;
return [1, 2, 3].some((n) => isSet(s[`sessionWhL${n}`]));
});
// 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 reports them,
@@ -692,6 +755,9 @@ async function loadChargerLive(force = false) {
chargerLive.value = live;
chargerLiveLoaded.value = true;
chargerLiveLoading.value = false;
// The card's other half: the views that answer per charger rather than per
// account. Refreshing the card refreshes both.
loadChargerDetails(force);
}
// The live half for one imported charger, or null when the service it came from
@@ -767,6 +833,7 @@ const selectedHomeCharger = computed(
// A charger picked here becomes the one the control card drives.
function selectHomeCharger(c) {
selected.value = c.id;
loadChargerDetails();
if (!c.serial) return;
ctlSerial.value = c.serial;
refreshCtl();
@@ -823,6 +890,15 @@ function chargerInfoRows(c) {
// on it here would be inventing it.
["chargePower", live.power],
["ocpp", ocppStatusLabel(live)],
// The box on the wall, as opposed to the charging: the networks it is on,
// where it thinks it is, and when the account first saw it.
["wifiName", live.wifiName],
["wifiMac", live.wifiMac],
["signal", isSet(live.wifiRssi) ? `${live.wifiRssi} dBm` : ""],
["bleMac", live.bleMac],
["relatedBy", (live.relatedBy || []).join(" · ")],
["timeZone", live.timeZone],
["linked", live.linkedAt ? formatDateTime(new Date(live.linkedAt * 1000)) : ""],
["providerId", c.providerChargerId],
["added", c.created ? formatDateTime(c.created) : ""],
];
@@ -833,6 +909,50 @@ function chargerInfoRows(c) {
}));
}
// --- The account's other views of one charger ---
//
// Four endpoints answer only when a serial is named — the station record, the
// charging totals, the OCPP backend, the RFID cards — so none of them can be
// part of the list the card is drawn from. They are asked for the charger being
// looked at, when it is being looked at, and the answers are kept per serial so
// switching back to a charger does not ask again.
const chargerDetails = ref({}); // serial → the views document
const chargerDetailsLoading = ref("");
async function loadChargerDetails(force = false) {
const c = selectedHomeCharger.value;
const sn = c?.providerChargerId || c?.serial || "";
if (!sn || c.provider !== "anker-solix") return;
if (chargerDetailsLoading.value === sn) return;
if (chargerDetails.value[sn] && !force) return;
chargerDetailsLoading.value = sn;
try {
const res = await api.getAnkerChargerDetails(sn);
chargerDetails.value = { ...chargerDetails.value, [sn]: res };
} catch {
// A view the account cannot read is not an error to put on the page: the
// rows above still say everything the inventory knew.
} finally {
chargerDetailsLoading.value = "";
}
}
// One view's fields, sorted, or the reason it could not be read. The keys are
// the cloud's own: Anker documents none of these payloads, so a name invented
// here would be a meaning invented here.
const chargerDetailViews = computed(() => {
const c = selectedHomeCharger.value;
const sn = c?.providerChargerId || c?.serial || "";
const doc = chargerDetails.value[sn];
return (doc?.views || []).map((view) => ({
id: view.id,
error: view.error || "",
rows: Object.keys(view.attrs || {})
.sort()
.map((key) => ({ key, value: view.attrs[key] })),
})).filter((view) => view.rows.length || view.error);
});
// Everything else the service said about this charger, under its own field
// names. The rows above are the ones DriverVault has a name for; these are the
// remainder — the service documents none of them, so its own key is the only
@@ -1597,6 +1717,7 @@ onMounted(async () => {
<th class="py-1 text-right font-medium">{{ t("charging.modbus.activePower") }}</th>
<th v-if="devicePhasesHaveVA" class="py-1 text-right font-medium">{{ t("charging.modbus.reactivePower") }}</th>
<th v-if="devicePhasesHaveVA" class="py-1 text-right font-medium">{{ t("charging.modbus.apparentPower") }}</th>
<th v-if="devicePhasesHaveSessionWh" class="py-1 text-right font-medium">{{ t("charging.modbus.sessionEnergy") }}</th>
</tr>
</thead>
<tbody class="data">
@@ -1607,6 +1728,7 @@ onMounted(async () => {
<td class="py-1 text-right text-strong">{{ p.watts }}</td>
<td v-if="devicePhasesHaveVA" class="py-1 text-right text-strong">{{ p.reactive }}</td>
<td v-if="devicePhasesHaveVA" class="py-1 text-right text-strong">{{ p.apparent }}</td>
<td v-if="devicePhasesHaveSessionWh" class="py-1 text-right text-strong">{{ p.sessionWh }}</td>
</tr>
</tbody>
</table>
@@ -1636,6 +1758,18 @@ onMounted(async () => {
</dl>
</section>
<!-- The charger's own LAN side, which only the cloud transport can
report: whether its Modbus server is on, and where. -->
<section v-if="deviceLocal.length" class="rounded-control bg-sunken p-3">
<h4 class="eyebrow">{{ t("charging.modbus.localTitle") }}</h4>
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
<template v-for="r in deviceLocal" :key="r.label">
<dt class="text-xs text-muted">{{ r.label }}</dt>
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
</template>
</dl>
</section>
<section v-if="deviceIdentity.length" class="rounded-control bg-sunken p-3">
<h4 class="eyebrow">{{ t("charging.modbus.device") }}</h4>
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
@@ -1734,7 +1868,18 @@ onMounted(async () => {
<!-- The charger picked in the list beside this card, and only it. -->
<div v-if="selectedHomeCharger" class="mt-3 rounded-control bg-sunken p-3">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-semibold text-strong">{{ selectedHomeCharger.name }}</p>
<div class="flex min-w-0 items-center gap-2">
<!-- The product shot the app shows for this model, when the
account sent one. Decorative: the name beside it says
everything the picture does. -->
<img
v-if="liveFor(selectedHomeCharger)?.imageUrl"
:src="liveFor(selectedHomeCharger).imageUrl"
alt=""
class="h-8 w-8 shrink-0 rounded object-contain"
/>
<p class="truncate text-sm font-semibold text-strong">{{ selectedHomeCharger.name }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<!-- Reachability, said either way. A charger the service says
nothing about stays silent: unknown is not offline. -->
@@ -1769,6 +1914,21 @@ onMounted(async () => {
</dl>
<p class="mt-1 text-[11px] text-muted">{{ t("charging.info.rawHint") }}</p>
</template>
<!-- The views that answer per charger rather than per account. Each
says what it knows, or why it could not be read an account
that is not the charger's owner cannot read the cards, which is
a fact about the account rather than a failure. -->
<template v-for="view in chargerDetailViews" :key="view.id">
<p class="eyebrow mt-3">{{ t(`charging.info.views.${view.id}`) }}</p>
<p v-if="view.error" class="mt-1 text-[11px] text-muted">{{ view.error }}</p>
<dl v-else class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in view.rows" :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>
</template>
</div>
<p v-if="homeChargers.length === 0" class="mt-2 text-xs text-muted">