diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go index 56ccfa0..af99af1 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go @@ -90,6 +90,15 @@ const ( // having to reach it first. statusWait = 12 * time.Second + // settingsMaxAge is how old the settings half may be before a status read + // asks for it again; settingsWait is how long that read then waits for the + // answer, and statusReqTries how many unanswered requests it takes before a + // charger is treated as one whose firmware ignores the message — after which + // the request still goes out, but no read pays the wait for it. + settingsMaxAge = 10 * time.Minute + settingsWait = 4 * time.Second + statusReqTries = 3 + // commandWait is how long a command waits for the charger's confirmation // message. A command is fire-and-forget on the wire, so this only decides // whether we can say the charger answered — not whether it was sent. @@ -262,6 +271,12 @@ type deviceState struct { telemetryAt time.Time settingsAt time.Time triggeredUntil time.Time + + // How many status requests this charger has been asked and not answered. + // The request is cheap to send and is sent regardless; what it buys is the + // right to wait a few seconds for the reply, and a charger whose firmware + // ignores the message should not cost every later read that wait. + statusReqMisses int } // mqttClient returns the account's broker connection, opening one if there is @@ -671,6 +686,28 @@ func (c *mqttConn) snapshotOf(sn string) (map[string]any, time.Time, time.Time, return out, st.telemetryAt, st.settingsAt, st.triggeredUntil } +// noteStatusMiss records that a status request went unanswered, and reports how +// many have now in a row. +func (c *mqttConn) noteStatusMiss(sn string) int { + c.mu.Lock() + defer c.mu.Unlock() + st := c.devices[sn] + if st == nil { + return 0 + } + st.statusReqMisses++ + return st.statusReqMisses +} + +// statusReqAnswered reports whether this charger has answered a status request +// recently enough to be worth waiting for again. +func (c *mqttConn) statusReqAnswered(sn string) bool { + c.mu.Lock() + defer c.mu.Unlock() + st := c.devices[sn] + return st == nil || st.statusReqMisses < statusReqTries +} + // noteTrigger records how long the charger has been asked to keep streaming. func (c *mqttConn) noteTrigger(sn string, until time.Time) { c.mu.Lock() @@ -704,6 +741,20 @@ func (p *Plugin) mqttTrigger(ctx context.Context, c *mqttConn, model, sn string, return nil } +// mqttStatusRequest asks the charger to publish what it is set to. The trigger +// above buys telemetry and nothing else: the settings half arrives on its own +// message, and otherwise only after a command, which is why a charger that has +// been read but never commanded reports live amps and knows nothing about its +// own schedule, its Modbus server or its firmware. This is the message the app +// uses for that — a status request the charger answers with 0840. +func (p *Plugin) mqttStatusRequest(ctx context.Context, c *mqttConn, model, sn string) error { + frame, err := encodeFrame(msgEVStatusReq, []cmdField{bareTimestampField(time.Now())}) + if err != nil { + return err + } + return c.publishFrame(ctx, model, sn, frame, 0) +} + // mqttSetMode sends the start / stop / skip-delay / boost command. func (p *Plugin) mqttSetMode(ctx context.Context, c *mqttConn, model, sn, mode string) error { v, ok := mqttModeValues[mode] diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go index 37d37a0..680e904 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go @@ -68,6 +68,7 @@ const typeByteMax byte = 0x31 // Message types this package speaks, for the A5191 (V1 Smart EV Charger). // Outbound ones are commands, inbound ones are what the charger publishes back. const ( + msgEVStatusReq = "0040" // ask the charger to report its parameters msgRealtimeTrigger = "0057" // ask for the fast telemetry stream msgEVSettings = "0100" // the settings group: current limit, brightness, … msgEVMode = "0105" // start / stop / skip delay / boost @@ -247,6 +248,17 @@ func timestampField(now time.Time) cmdField { return varField(0xfe, uint32(now.Unix())) } +// bareTimestampField is the timestamp the status request carries: the same +// clock as every other command, sent without its value type. The app sends it +// that way — the reference reads it as an Anker bug and keeps the whole command +// commented out because of it — and the charger answers what the app sends, so +// the oddity is reproduced rather than corrected. +func bareTimestampField(now time.Time) cmdField { + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, uint32(now.Unix())) + return cmdField{name: 0xfe, typ: typeNone, value: b} +} + // clockField builds the two-byte field the charger carries a time of day in: // the hour and the minute, least significant byte first, which is the same // layout the decoder reads back as "HH:MM". diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go index c758d9e..00b9d2a 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go @@ -42,6 +42,35 @@ func TestEncodeFrameMatchesTheDocumentedTrigger(t *testing.T) { } } +// The status request is the one command whose timestamp travels without a value +// type — the app sends it that way, and the charger answers what the app sends. +func TestStatusRequestSendsItsClockWithoutAValueType(t *testing.T) { + got, err := encodeFrame(msgEVStatusReq, []cmdField{bareTimestampField(time.Unix(1756813256, 0))}) + if err != nil { + t.Fatalf("encodeFrame: %v", err) + } + want := "ff09100003000f0040" + // header: marker, length 16, send pattern, type 0040 + "fe04c8d7b668" // fe: four clock bytes, no value type between the length and them + if h := encodeHex(got); h[:len(want)] != want { + t.Fatalf("frame = %s\nwant %s + checksum", h, want) + } + if len(got) != 16 { + t.Errorf("frame is %d bytes, want 16", len(got)) + } + // The field's length byte counts the value alone, since there is no type byte + // to count — the whole point of the oddity. + if got[10] != 4 { + t.Errorf("fe length byte is %d, want 4", got[10]) + } + var sum byte + for _, b := range got { + sum ^= b + } + if sum != 0 { + t.Errorf("checksum does not close the frame: %02x", sum) + } +} + // A frame is only self-consistent if XORing every byte, checksum included, // comes to zero — which is exactly what the decoder checks. func TestEncodeFrameChecksumClosesToZero(t *testing.T) { diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsettings.go b/API Server/internal/plugins/builtin/ankersolix/mqttsettings.go index a22885a..67e0af1 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsettings.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsettings.go @@ -103,6 +103,18 @@ var settingCmds = []settingCmd{ // mode: the charger stops serving the register map on the LAN. {wire: 0xb7, key: "modbusEnabled", state: "modbusSwitch", encode: switchOnOff(1, 0)}, }}, + // The panel's own three: what a swipe up, a swipe down and a touch do. The + // charger has always reported them and the card has always shown them; they + // are settings like any other on this message, so they are writable here too. + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xaf, key: "swipeUpMode", state: "swipeUpMode", encode: optionValue(0, 1, 2, 3)}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xb0, key: "swipeDownMode", state: "swipeDownMode", encode: optionValue(0, 1, 2, 3)}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xb2, key: "smartTouchMode", state: "smartTouchMode", encode: optionValue(0, 1)}, + }}, {msgType: msgEVSchedule, fields: []settingField{ {wire: 0xa2, key: "scheduleEnabled", state: "scheduleSwitch", encode: switchOnOff(1, 2)}, {wire: 0xa8, key: "scheduleMode", state: "scheduleMode", encode: optionValue(0, 1)}, diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go index 55fcdb1..c4a20cf 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go @@ -189,13 +189,27 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er // Re-arm whenever the window is spent or close to it, so a poll never lands // in the gap between the last frame and the trigger expiring. - _, _, _, triggered := conn.snapshotOf(sn) + _, _, settingsAt, triggered := conn.snapshotOf(sn) if time.Until(triggered) < triggerRenew { if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil { return nil, err } } + // The trigger buys telemetry only. What the charger is set to — its schedule, + // its balancing, its Modbus server, its firmware — travels on its own message, + // and without asking it never comes: a charger that has been read a hundred + // times and commanded none reports amps and nothing else. So the read asks for + // that half too whenever it is missing or has gone stale. + askedSettings := false + if time.Since(settingsAt) > settingsMaxAge { + // A charger that will not answer this is not a failed read: the telemetry + // half is still the answer, and the settings half is what it was. + if err := p.mqttStatusRequest(ctx, conn, model, sn); err == nil { + askedSettings = true + } + } + // Anything older than the trigger's own interval is stale; wait for the next. cutoff := time.Now().Add(-triggerRenew) live, err := conn.waitFor(ctx, sn, func(st *deviceState) bool { @@ -205,6 +219,19 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er return nil, err } + // Give the parameter message a moment of its own, but only while this charger + // still looks like one that answers: the reference reads the status request as + // carrying an Anker bug, so a firmware that ignores it must not tax every read + // with the same wait forever. + if askedSettings && conn.statusReqAnswered(sn) { + settled, werr := conn.waitFor(ctx, sn, func(st *deviceState) bool { + return st.settingsAt.After(cutoff) + }, settingsWait) + if werr == nil && !settled { + conn.noteStatusMiss(sn) + } + } + values, telemetryAt, settingsAt, _ := conn.snapshotOf(sn) if len(values) == 0 { return nil, fmt.Errorf("anker-solix: charger %s did not answer over the cloud; it may be offline", sn) diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 60c105c..a083613 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -206,6 +206,8 @@ "productNumber": "Produktnummer", "ratedPower": "Nominel effekt", "currentRange": "Strømområde", + "upTo": "Op til", + "from": "Fra", "ocppLink": "OCPP", "mqttLink": "MQTT", "sessionEnergy": "Sessionsenergi", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 6e89d64..e765f7f 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -206,6 +206,8 @@ "productNumber": "Product number", "ratedPower": "Rated power", "currentRange": "Current range", + "upTo": "Up to", + "from": "From", "ocppLink": "OCPP", "mqttLink": "MQTT", "sessionEnergy": "Session energy", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index ee0ca74..f645e82 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -208,6 +208,8 @@ "productNumber": "Numer produktu", "ratedPower": "Moc znamionowa", "currentRange": "Zakres prądu", + "upTo": "Do", + "from": "Od", "ocppLink": "OCPP", "mqttLink": "MQTT", "sessionEnergy": "Energia sesji", diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index 7f16d32..63af1b1 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -2226,6 +2226,16 @@ class _HomeTabState extends State<_HomeTab> { ]); } + /// The current range as the charger reports it: both bounds when it sends + /// both, and the one it does send otherwise — "up to 32 A" is a fact, and + /// dropping the row because the other half is missing hides it. + String? _currentRange(int? min, int? max) { + if (min != null && max != null) return "$min–$max A"; + if (max != null) return "${t("charging.modbus.upTo")} $max A"; + if (min != null) return "${t("charging.modbus.from")} $min A"; + return null; + } + List<(String, String)> _deviceRows(ChargerStatus s) { final product = s.integer("productNumber"); final min = s.integer("minCurrentA"); @@ -2238,7 +2248,9 @@ class _HomeTabState extends State<_HomeTab> { ("hardware", s.text("hardware")), ("productNumber", product == null ? null : "$product"), ("ratedPower", _unit(s.number("ratedPowerW"), 0, "W")), - ("currentRange", min != null && max != null ? "$min–$max A" : null), + // Either bound on its own is still a bound worth reading; only a charger + // that reports neither has nothing to say here. + ("currentRange", _currentRange(min, max)), ("ocppLink", _enumLabel("ocpp", s.integer("ocppStatus"))), ("mqttLink", _enumLabel("mqtt", s.integer("mqttStatus"))), ]); diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index cff068a..62d13ba 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -167,6 +167,8 @@ "productNumber": "Produktnummer", "ratedPower": "Nominel effekt", "currentRange": "Strømområde", + "upTo": "Op til", + "from": "Fra", "ocppLink": "OCPP", "mqttLink": "MQTT", "plugged": "Kabel tilsluttet", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 258de29..83c2d08 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -153,6 +153,8 @@ "productNumber": "Product number", "ratedPower": "Rated power", "currentRange": "Current range", + "upTo": "Up to", + "from": "From", "ocppLink": "OCPP", "mqttLink": "MQTT", "plugged": "Cable plugged in", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 0890055..e18984a 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -169,6 +169,8 @@ "productNumber": "Numer produktu", "ratedPower": "Moc znamionowa", "currentRange": "Zakres prądu", + "upTo": "Do", + "from": "Od", "ocppLink": "OCPP", "mqttLink": "MQTT", "plugged": "Kabel podłączony", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 5337636..f6617c7 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -366,6 +366,16 @@ function countdown(sec) { return m > 0 ? `${m} min ${r} s` : `${r} s`; } +// The current range as the charger reports it: both bounds when it sends both, +// and the one it does send otherwise — "up to 32 A" is a fact, and dropping the +// row because the other half is missing hides it. +function currentRange(min, max) { + if (isSet(min) && isSet(max)) return `${min}\u2013${max} A`; + if (isSet(max)) return `${t("charging.modbus.upTo")} ${max} A`; + if (isSet(min)) return `${t("charging.modbus.from")} ${min} A`; + return null; +} + function sessionLength(sec) { if (!isSet(sec)) return null; const h = Math.floor(sec / 3600); @@ -566,8 +576,10 @@ const deviceIdentity = computed(() => { ["hardware", s.hardware], ["productNumber", isSet(s.productNumber) ? String(s.productNumber) : null], ["ratedPower", unit(s.ratedPowerW, 0, "W")], - ["currentRange", - isSet(s.minCurrentA) && isSet(s.maxCurrentA) ? `${s.minCurrentA}–${s.maxCurrentA} A` : null], + // The charger's own floor and ceiling. Either half on its own is still a + // bound the number beside the slider has to respect, so a charger that + // reports one and not the other says the one. + ["currentRange", currentRange(s.minCurrentA, s.maxCurrentA)], ["ocppLink", enumLabel("ocpp", s.ocppStatus)], ["mqttLink", enumLabel("mqtt", s.mqttStatus)], ]);