Compare commits
2
Commits
197ff73a39
...
1a7f04cba0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7f04cba0 | ||
|
|
8e2073fc4c |
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user