One refused sign-in, not five: the chargers poll no longer locks the account

A chargers poll asks four cloud views. Each called apiRequest, each found no
token, and each ran its own login — so a login Anker refuses was offered four
times in one poll, and the next poll spent the fifth. Five is what disables the
account for ten minutes, which is how "code 26161: Failed to request." turned
into "your account has been disabled" on the very next attempt.

The plugin now remembers a refused login instead of repeating it: the failure is
cached and replayed to every caller until a backoff window passes — a minute at
first, doubling to fifteen, or the full ten minutes when Anker says it has
already locked the account (code 10019). New credentials clear it, so a fixed
password is tried at once.

chargerInventory signs in once up front. A login the cloud refuses is not four
views failing, so it is reported as itself rather than as three warnings with the
lockout notice buried in the last one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-31 22:54:24 +02:00
co-authored by Claude Opus 5
parent 423bc2ab16
commit c1aee0fac1
3 changed files with 211 additions and 5 deletions
@@ -158,10 +158,19 @@ type Plugin struct {
password string
countryId string
mu sync.Mutex // guards tok and the login flow
mu sync.Mutex // guards tok, the login backoff and the login flow
tok *tokenInfo
apiBase string
client *http.Client
// A refused login is remembered rather than repeated. Anker disables an
// account for ten minutes after five failed sign-ins, and a single chargers
// poll asks four cloud views — each of which, holding no token, would try to
// sign in for itself. Without this the poll that discovers a bad login is
// also the one that locks the account.
loginErr error
loginRetryAt time.Time
loginFails int
}
// Descriptor returns the plugin's static metadata for the admin panel.
@@ -232,8 +241,10 @@ func (p *Plugin) Init(_ context.Context, config map[string]string) error {
} else {
p.apiBase = serverEU
}
// Config change invalidates any cached token.
// Config change invalidates any cached token, and clears the login backoff:
// new credentials deserve an attempt straight away.
p.tok = nil
p.loginErr, p.loginRetryAt, p.loginFails = nil, time.Time{}, 0
p.client = &http.Client{Timeout: 30 * time.Second}
return nil
}
@@ -622,6 +633,57 @@ func chargerModeOptions(mode, statusDesc string) []string {
// ---- token management --------------------------------------------------------
// Login backoff. A refused sign-in is cached and replayed to every caller until
// its window passes, so credentials Anker rejects are offered once, not once per
// request.
const (
loginRetryBase = 1 * time.Minute
loginRetryMax = 15 * time.Minute
// codeSignInLocked is what Anker answers once it has already disabled the
// account for repeated failures; it states a ten-minute penalty, so sit the
// penalty out rather than spending attempts against a door that is shut.
codeSignInLocked = 10019
loginLockoutWait = 10 * time.Minute
)
// loginRejected is the login endpoint answering with a non-zero API code. The
// code is kept so an already-locked account can be told from an ordinary
// refusal.
type loginRejected struct {
code int
msg string
}
func (e *loginRejected) Error() string {
return fmt.Sprintf("anker-solix: login rejected (code %d): %s", e.code, e.msg)
}
// loginFailure wraps whatever made a login attempt fail, together with the time
// before which no further attempt is made.
type loginFailure struct {
err error
retryAt time.Time
}
func (e *loginFailure) Error() string {
wait := time.Until(e.retryAt).Round(time.Second)
if wait < 0 {
wait = 0
}
return fmt.Sprintf("%s (not retrying for %s)", e.err, wait)
}
func (e *loginFailure) Unwrap() error { return e.err }
// isLoginFailure reports whether err came from the login exchange rather than
// from the endpoint the caller was asking for — the difference between "this
// account cannot sign in" and "this one view said no".
func isLoginFailure(err error) bool {
var lf *loginFailure
return errors.As(err, &lf)
}
// ensureToken guarantees a non-expired auth token, logging in as needed.
// Serialized by p.mu so concurrent requests trigger at most one login.
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
@@ -631,12 +693,43 @@ func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
if p.tok != nil && time.Now().Before(p.tok.expiration) {
return *p.tok, nil
}
if err := p.login(ctx); err != nil {
if err := p.attemptLogin(ctx); err != nil {
return tokenInfo{}, err
}
return *p.tok, nil
}
// attemptLogin logs in at most once per backoff window, handing every caller in
// between the failure that opened it. Caller holds p.mu.
func (p *Plugin) attemptLogin(ctx context.Context) error {
if p.loginErr != nil && time.Now().Before(p.loginRetryAt) {
return p.loginErr
}
p.noteLogin(p.login(ctx))
return p.loginErr
}
// noteLogin records the outcome of a login attempt and, on failure, how long to
// leave the account alone: one Anker has already disabled gets the full penalty,
// anything else backs off exponentially up to loginRetryMax. Caller holds p.mu.
func (p *Plugin) noteLogin(err error) {
if err == nil {
p.loginErr, p.loginRetryAt, p.loginFails = nil, time.Time{}, 0
return
}
p.loginFails++
wait := loginRetryBase << min(p.loginFails-1, 8)
if wait > loginRetryMax {
wait = loginRetryMax
}
var rejected *loginRejected
if errors.As(err, &rejected) && rejected.code == codeSignInLocked && wait < loginLockoutWait {
wait = loginLockoutWait
}
p.loginRetryAt = time.Now().Add(wait)
p.loginErr = &loginFailure{err: err, retryAt: p.loginRetryAt}
}
// login performs the ECDH + AES password exchange against passport/login and
// stores the returned token. Caller holds p.mu.
func (p *Plugin) login(ctx context.Context) error {
@@ -704,7 +797,7 @@ func (p *Plugin) storeToken(body []byte) error {
return fmt.Errorf("anker-solix: decode login response: %w", err)
}
if env.Code != 0 {
return fmt.Errorf("anker-solix: login rejected (code %d): %s", env.Code, shorten(env.Msg))
return &loginRejected{code: env.Code, msg: shorten(env.Msg)}
}
if env.Data.AuthToken == "" || env.Data.UserID == "" {
return errors.New("anker-solix: login response missing auth_token or user_id")
@@ -749,7 +842,7 @@ func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[st
if status == http.StatusUnauthorized || status == http.StatusForbidden || isAuthCode(body) {
p.mu.Lock()
p.tok = nil
lerr := p.login(ctx)
lerr := p.attemptLogin(ctx)
var newTok tokenInfo
if lerr == nil {
newTok = *p.tok