Anker Solix: what upstream v3.9.0 changed for a charger

anker-solix-api v3.9.0 (2026-09-29) is mostly PPS work — time-of-use plans
over the cloud API, device energy queries, a condensed V2 command map — and
none of that reaches an A5191; its MQTT map and command definitions are
unchanged from v3.8.1. Three things do:

- Singapore accounts live on the EU server (ha-anker-solix #593), so SG
  leaves the .com list.
- A charger still showing a finished session can be started again. The app
  offers Start while status is completed; the mode options did not
  (ha-anker-solix #629, #630).
- A re-login after a rejected token drops the payload handshake with the
  token, as upstream now does on a failed login. It was never reused — a
  successful login cleared it and ensureCipher refuses without a token —
  but it no longer outlives the token it was run under.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-19 17:22:35 +02:00
1 parent e7d2ef84cd
commit 8849fcfb40
3 files changed
+23 -15

No files matched your search

@@ -2,14 +2,16 @@
// scoped to the Anker Solix V1 Smart EV Charger (product A5191). It is a Go
// re-implementation of the authentication and read-only data flow from the
// anker-solix-api project (https://github.com/thomluther/anker-solix-api),
// adapted to DriverVault's plugin contract. It tracks upstream v3.8.2
// (ha-anker-solix 3.8.2); the login exchange and every endpoint used here are
// unchanged since v3.7.0. What v3.8.2 added that reaches a charger is the app's
// REST payload encryption, ported in payloadcrypto.go and off by default as
// upstream leaves it; its other changes (Solarbank expansion sizing, the Gen 2
// smart meter's MQTT map, new site parameter types) touch devices this
// connector does not address, and the A5191 MQTT map is byte-for-byte the one
// v3.8.1 had.
// adapted to DriverVault's plugin contract. It tracks upstream v3.9.0
// (ha-anker-solix 3.9.0); the login exchange and every endpoint used here are
// unchanged since v3.7.0. v3.8.2 added the app's REST payload encryption,
// ported in payloadcrypto.go and off by default as upstream leaves it. v3.9.0
// reaches a charger in three small places: Singapore accounts log in on the
// EU server, Start is offered while a finished session still shows completed,
// and a failed login drops the payload handshake with the token. The rest of
// v3.9.0 (PPS time-of-use plans, device energy queries, the condensed V2
// command map) touches devices this connector does not address, and the A5191
// MQTT map is byte-for-byte the one v3.8.1 had.
//
// Scope & limitations:
// - The REST half is read-only. Every endpoint below retrieves EV-charger
@@ -203,12 +205,14 @@ var ocppConnStatus = map[int]string{0: "disconnected", 1: "connecting", 2: "conn
const tokenExpiryMargin = 5 * time.Minute
// comCountries are the ISO country IDs served by the global (".com") server.
// Any country not listed here is served by the EU server.
// Any country not listed here is served by the EU server — Singapore among
// them since upstream v3.9.0 (ha-anker-solix #593), where SG accounts turned
// out to live.
var comCountries = map[string]bool{
"DZ": true, "LB": true, "SY": true, "EG": true, "LY": true, "TN": true,
"MA": true, "JO": true, "PS": true, "AR": true, "AU": true, "BR": true,
"HK": true, "IN": true, "MX": true, "NG": true, "NZ": true, "RU": true,
"SG": true, "ZA": true, "KR": true, "TW": true, "US": true, "CA": true,
"ZA": true, "KR": true, "TW": true, "US": true, "CA": true,
}
func init() {
@@ -1034,8 +1038,10 @@ func chargerModeOptions(mode, statusDesc string) []string {
case modeBoostCharge:
set[modeStopCharge] = true
default:
// Only a charger sitting in standby can be told to start.
if statusDesc == stateStandby {
// A charger sitting in standby can be told to start, and so can one still
// showing a finished session: completed can last until the cable is pulled,
// and the app offers Start throughout (upstream v3.9.0, ha-anker-solix #629).
if statusDesc == stateStandby || statusDesc == stateCompleted {
set[modeStartCharge] = true
}
}
@@ -1357,6 +1363,7 @@ func (p *Plugin) apiCall(ctx context.Context, endpoint string, send func(tokenIn
if status == http.StatusUnauthorized || status == http.StatusForbidden || isAuthCode(body) {
p.sess.mu.Lock()
p.sess.tok = nil
p.sess.enc = nil // the handshake goes with the token it was run under
lerr := p.attemptLogin(ctx)
var newTok tokenInfo
if lerr == nil {
@@ -111,6 +111,7 @@ func TestServerSelection(t *testing.T) {
"GB": serverEU, // not in the COM list → EU default
"US": serverCOM,
"AU": serverCOM,
"SG": serverEU, // moved off the COM list in upstream v3.9.0
"": serverEU, // empty → defaults to DE → EU
}
for country, want := range cases {
@@ -348,9 +349,9 @@ func TestChargerModeOptions(t *testing.T) {
{modeWaitStart, statePreparing, []string{modeSkipDelay, modeStopCharge, modeWaitStart}},
{modeWaitPlug, statePreparing, []string{modeStopCharge, modeWaitPlug}},
{modeBoostCharge, stateCharging, []string{modeBoostCharge, modeStopCharge}},
// Stopped: startable only from standby.
// Stopped: startable from standby, and from completed since v3.9.0.
{modeStopCharge, stateStandby, []string{modeStartCharge, modeStopCharge}},
{modeStopCharge, stateCompleted, []string{modeStopCharge}},
{modeStopCharge, stateCompleted, []string{modeStartCharge, modeStopCharge}},
{modeStopCharge, stateError, []string{modeStopCharge}},
{"", stateUnknown, []string{}},
}
@@ -7,7 +7,7 @@ package ankersolix
// That frame is the same one the app sends, so a command is not a documented API
// call but a byte layout, reproduced here from the message maps in
// anker-solix-api (src/anker_solix_api/mqtttypes.py, mqttmap.py, mqttcmdmap.py at
// v3.8.1, unchanged for the A5191 in v3.8.2) and checked against a live A5191.
// v3.8.1, unchanged for the A5191 through v3.9.0) and checked against a live A5191.
//
// ff 09 2-byte marker on every Anker Solix frame
// xx xx total length in bytes, little endian, counting the checksum