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