From b2d333a63f009d85513e576cfca5123c11ade9d5 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:05:40 +0200 Subject: [PATCH] The charger was never asked what it is set to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger buys telemetry and only telemetry, so a charger that has been read a hundred times and commanded none reports amps, volts and nothing else: no schedule, no balancing, no Modbus server, not even its firmware. The message that asks for that half is 0040, and the reference keeps it commented out because the app sends its timestamp without a value type. The app is what the charger answers, so the oddity is reproduced rather than corrected — sent when the settings half is missing or older than ten minutes, waited four seconds for, and after three unanswered requests still sent but no longer waited on. The three settings the panel has and the writer did not — swipe up, swipe down, smart touch — are writable now, which is all eleven of the 0100 commands. Nothing else in the map was missing: every named field of every message was already decoded, and the raw keys the card shows are fields the reference does not name either. Both cards drop a row with nothing in it, which turned a charger that reports only its ceiling into a charger that reports no current range at all. Half a range is still a bound. Co-Authored-By: Claude Opus 5 --- .../plugins/builtin/ankersolix/cloudmqtt.go | 51 +++++++++++++++++++ .../plugins/builtin/ankersolix/mqttframe.go | 12 +++++ .../builtin/ankersolix/mqttframe_test.go | 29 +++++++++++ .../builtin/ankersolix/mqttsettings.go | 12 +++++ .../builtin/ankersolix/mqttsnapshot.go | 29 ++++++++++- Phone App/assets/i18n/da.json | 2 + Phone App/assets/i18n/en.json | 2 + Phone App/assets/i18n/pl.json | 2 + Phone App/lib/screens/charging_screen.dart | 14 ++++- Web App/web/src/i18n/da.json | 2 + Web App/web/src/i18n/en.json | 2 + Web App/web/src/i18n/pl.json | 2 + Web App/web/src/views/Charging.vue | 16 +++++- 13 files changed, 171 insertions(+), 4 deletions(-) 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)], ]);