The settings card stops emptying when one message is late

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 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 20:26:38 +02:00
co-authored by Claude Opus 5
parent c65ce0c081
commit 4d51a34c44
4 changed files with 68 additions and 6 deletions
@@ -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()
}
@@ -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")
}
}
@@ -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)
}