From 4d51a34c4488ccb81c44b1d6179a1d103f632adb Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:26:38 +0200 Subject: [PATCH] The settings card stops emptying when one message is late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A snapshot has two halves and they travel separately: telemetry comes from the trigger, the settings only when the charger has something to say about itself. A read can land with the first and not the second — most often the first read after a reconnect — and the card was seeded from that answer alone, so it collapsed to the one control telemetry happens to carry. That is the state of one message, not the state of the charger. Three things, from the outside in. The card keeps what the charger has reported, per serial, across reads. A value stays until another replaces it. They are its own last word either way, and the same ones the server fills a grouped command's siblings from when a caller leaves them out. A charger that goes quiet is no longer written off for good. The miss counter decides whether a read waits for the settings frame at all, and it only ever rose: three unanswered requests early on and no later read waited again, however freely the charger answered afterwards. The comment said "recently enough"; the code said "ever". Answering clears it now. And the first settings are worth the wait a settings write already gives them. Stale settings and never-reported settings were both allowed four seconds. Stale has something to fall back on; never-reported is the empty card, so it gets the full wait — still bounded by the miss counter, so a charger that truly never answers costs it three times and no more. Co-Authored-By: Claude Opus 5 --- .../plugins/builtin/ankersolix/cloudmqtt.go | 10 +++++++- .../builtin/ankersolix/cloudmqtt_test.go | 24 ++++++++++++++++++ .../builtin/ankersolix/mqttsnapshot.go | 15 ++++++++++- Web App/web/src/views/Charging.vue | 25 ++++++++++++++++--- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go index de5c4ca..1635e67 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go @@ -97,7 +97,9 @@ const ( // 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. + // the request still goes out, but no read pays the wait for it. That verdict + // is undone by the charger answering (see ingest): it describes a spell of + // silence, not a permanent property of the firmware. settingsMaxAge = 10 * time.Minute settingsWait = 4 * time.Second statusReqTries = 3 @@ -558,6 +560,12 @@ func (c *mqttConn) ingest(msg mqtt.Message) { st.telemetryAt = now case evMessages[msgType] != nil: st.settingsAt = now + // It does report its settings after all, so whatever made it look like a + // firmware that ignores the request has passed. Without this the verdict + // was permanent — the counter only ever went up — and a charger that + // missed three requests early on was never waited for again, however + // freely it answered afterwards. + st.statusReqMisses = 0 } c.wakeLocked() } diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go index 74699dd..6305862 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go @@ -265,3 +265,27 @@ func TestClientIDDoesNotCollideWithTheApp(t *testing.T) { t.Errorf("client id %q does not fall back to the user id", got) } } + +// A charger that goes quiet long enough to be written off is not written off for +// good. The miss counter decides whether a status read waits for the settings +// frame at all, so a counter that only ever rose meant one early patch of +// silence cost every later read its settings — which is the half the settings +// card is drawn from. +func TestAnsweringClearsTheStatusRequestMisses(t *testing.T) { + c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} + + // Three unanswered requests: the reads stop paying the wait. + c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02)))) + for i := 0; i < statusReqTries; i++ { + c.noteStatusMiss("SN1") + } + if c.statusReqAnswered("SN1") { + t.Fatalf("after %d misses the charger should not be waited for", statusReqTries) + } + + // Then it answers one. + c.ingest(envelope(t, "SN1", buildInbound(t, msgEVParams, field(0xa8, typeInt16LE, 0x40, 0x01)))) + if !c.statusReqAnswered("SN1") { + t.Error("a charger that reported its settings is still being written off") + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go index 19b3357..f033d39 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go @@ -224,9 +224,22 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er // 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) { + // A charger that has never reported its settings is a different case from + // one whose settings have merely gone stale. Stale has something to fall + // back on — the values are still there, and a read that misses costs the + // caller nothing but their age. Never-reported has nothing: the settings + // are absent from the answer entirely, and the card that reads them draws + // almost nothing. So the first ones are given the same wait a settings + // write gives them rather than the short one that only has to catch a + // refresh. It is still bounded by statusReqTries, so a charger that truly + // never answers costs that wait three times and then stops being asked to. + wait := settingsWait + if settingsAt.IsZero() { + wait = statusWait + } settled, werr := conn.waitFor(ctx, sn, func(st *deviceState) bool { return st.settingsAt.After(cutoff) - }, settingsWait) + }, wait) if werr == nil && !settled { conn.noteStatusMiss(sn) } diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 81313b3..821044a 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -706,18 +706,35 @@ const mqttDraft = ref({}); const mqttBase = ref({}); const mqttBusy = ref(""); +// The settings the charger has reported, kept per serial across reads. +// +// The two halves of a snapshot arrive on different messages: the telemetry comes +// from the trigger, the settings only when the charger has something to say +// about itself. A read can land with the first and not the second — most often +// the first read after a reconnect, before any settings frame has arrived — and +// seeding the controls from that answer alone emptied the card of everything the +// charger had already told us. Which is not the state of the charger; it is the +// state of one message. +// +// So a value the charger has reported stays until it reports another. The values +// are its own last word either way, and the same ones the server fills a grouped +// command's siblings from when a caller leaves them out. +const mqttSeen = ref({}); // serial → the last value each setting was reported with + function syncMqttSettings() { - const draft = {}; + const sn = ctlSerial.value.trim(); + const seen = { ...(mqttSeen.value[sn] || {}) }; for (const block of MQTT_SETTING_BLOCKS) { for (const f of block.fields) { for (const [key, at] of fieldEntries(f)) { const v = snapshotValue(at); - if (isSet(v) && v !== "") draft[key] = v; + if (isSet(v) && v !== "") seen[key] = v; } } } - mqttDraft.value = draft; - mqttBase.value = { ...draft }; + mqttSeen.value = { ...mqttSeen.value, [sn]: seen }; + mqttDraft.value = { ...seen }; + mqttBase.value = { ...seen }; } // Only the settings the charger has actually reported get a control. A value it