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") }}