From 8e2073fc4cc879a6bd0f3767eb8b95d9c56012b4 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:31:43 +0200 Subject: [PATCH] The map knew the names the card was showing as hex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plugins/builtin/ankersolix/mqttframe.go | 34 +++++++ .../builtin/ankersolix/mqttframe_test.go | 18 ++++ .../builtin/ankersolix/mqttsnapshot.go | 48 ++++++++++ .../builtin/ankersolix/mqttsnapshot_test.go | 38 +++++--- Phone App/assets/i18n/da.json | 51 ++++++++++ Phone App/assets/i18n/en.json | 51 ++++++++++ Phone App/assets/i18n/pl.json | 51 ++++++++++ Phone App/lib/screens/charging_screen.dart | 93 +++++++++++++++++++ Web App/web/src/i18n/da.json | 50 ++++++++++ Web App/web/src/i18n/en.json | 50 ++++++++++ Web App/web/src/i18n/pl.json | 50 ++++++++++ Web App/web/src/views/Charging.vue | 77 +++++++++++++++ 12 files changed, 597 insertions(+), 14 deletions(-) diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go index 9bea3c7..37d37a0 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go @@ -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 { diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go index 224ba62..c758d9e 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go @@ -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) + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go index ce9dbb9..55fcdb1 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go @@ -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") diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go index c52e551..b49e545 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go @@ -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) + } +} diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 6b21125..00f25f8 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -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", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index e8cecee..f2a53d9 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -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", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 8cb0486..b607634 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -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", diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index 601ec0a..481a5e5 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -1884,6 +1884,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 +1911,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 +1942,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 +2088,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 +2134,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 +2196,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 +2238,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 +2248,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 _lineVoltages(ChargerStatus s) { diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 6bb656f..6c3db89 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -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", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 6727178..ee460b4 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -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", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 1539964..27d5885 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -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", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index fa159ad..b36065f 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -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, @@ -1597,6 +1660,7 @@ onMounted(async () => { {{ t("charging.modbus.activePower") }} {{ t("charging.modbus.reactivePower") }} {{ t("charging.modbus.apparentPower") }} + {{ t("charging.modbus.sessionEnergy") }} @@ -1607,6 +1671,7 @@ onMounted(async () => { {{ p.watts }} {{ p.reactive }} {{ p.apparent }} + {{ p.sessionWh }} @@ -1636,6 +1701,18 @@ onMounted(async () => { + +
+

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

+
+ +
+
+

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