The charger was never asked what it is set to

The trigger buys telemetry and only telemetry, so a charger that has been
read a hundred times and commanded none reports amps, volts and nothing
else: no schedule, no balancing, no Modbus server, not even its firmware.
The message that asks for that half is 0040, and the reference keeps it
commented out because the app sends its timestamp without a value type.
The app is what the charger answers, so the oddity is reproduced rather
than corrected — sent when the settings half is missing or older than ten
minutes, waited four seconds for, and after three unanswered requests
still sent but no longer waited on.

The three settings the panel has and the writer did not — swipe up, swipe
down, smart touch — are writable now, which is all eleven of the 0100
commands. Nothing else in the map was missing: every named field of every
message was already decoded, and the raw keys the card shows are fields
the reference does not name either.

Both cards drop a row with nothing in it, which turned a charger that
reports only its ceiling into a charger that reports no current range at
all. Half a range is still a bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-02 23:05:40 +02:00
co-authored by Claude Opus 5
parent 3f9d5b943f
commit b2d333a63f
13 changed files with 171 additions and 4 deletions
@@ -90,6 +90,15 @@ const (
// having to reach it first.
statusWait = 12 * time.Second
// settingsMaxAge is how old the settings half may be before a status read
// 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.
settingsMaxAge = 10 * time.Minute
settingsWait = 4 * time.Second
statusReqTries = 3
// commandWait is how long a command waits for the charger's confirmation
// message. A command is fire-and-forget on the wire, so this only decides
// whether we can say the charger answered — not whether it was sent.
@@ -262,6 +271,12 @@ type deviceState struct {
telemetryAt time.Time
settingsAt time.Time
triggeredUntil time.Time
// How many status requests this charger has been asked and not answered.
// The request is cheap to send and is sent regardless; what it buys is the
// right to wait a few seconds for the reply, and a charger whose firmware
// ignores the message should not cost every later read that wait.
statusReqMisses int
}
// mqttClient returns the account's broker connection, opening one if there is
@@ -671,6 +686,28 @@ func (c *mqttConn) snapshotOf(sn string) (map[string]any, time.Time, time.Time,
return out, st.telemetryAt, st.settingsAt, st.triggeredUntil
}
// noteStatusMiss records that a status request went unanswered, and reports how
// many have now in a row.
func (c *mqttConn) noteStatusMiss(sn string) int {
c.mu.Lock()
defer c.mu.Unlock()
st := c.devices[sn]
if st == nil {
return 0
}
st.statusReqMisses++
return st.statusReqMisses
}
// statusReqAnswered reports whether this charger has answered a status request
// recently enough to be worth waiting for again.
func (c *mqttConn) statusReqAnswered(sn string) bool {
c.mu.Lock()
defer c.mu.Unlock()
st := c.devices[sn]
return st == nil || st.statusReqMisses < statusReqTries
}
// noteTrigger records how long the charger has been asked to keep streaming.
func (c *mqttConn) noteTrigger(sn string, until time.Time) {
c.mu.Lock()
@@ -704,6 +741,20 @@ func (p *Plugin) mqttTrigger(ctx context.Context, c *mqttConn, model, sn string,
return nil
}
// mqttStatusRequest asks the charger to publish what it is set to. The trigger
// above buys telemetry and nothing else: the settings half arrives on its own
// message, and otherwise only after a command, which is why a charger that has
// been read but never commanded reports live amps and knows nothing about its
// own schedule, its Modbus server or its firmware. This is the message the app
// uses for that — a status request the charger answers with 0840.
func (p *Plugin) mqttStatusRequest(ctx context.Context, c *mqttConn, model, sn string) error {
frame, err := encodeFrame(msgEVStatusReq, []cmdField{bareTimestampField(time.Now())})
if err != nil {
return err
}
return c.publishFrame(ctx, model, sn, frame, 0)
}
// mqttSetMode sends the start / stop / skip-delay / boost command.
func (p *Plugin) mqttSetMode(ctx context.Context, c *mqttConn, model, sn, mode string) error {
v, ok := mqttModeValues[mode]
@@ -68,6 +68,7 @@ const typeByteMax byte = 0x31
// Message types this package speaks, for the A5191 (V1 Smart EV Charger).
// Outbound ones are commands, inbound ones are what the charger publishes back.
const (
msgEVStatusReq = "0040" // ask the charger to report its parameters
msgRealtimeTrigger = "0057" // ask for the fast telemetry stream
msgEVSettings = "0100" // the settings group: current limit, brightness, …
msgEVMode = "0105" // start / stop / skip delay / boost
@@ -247,6 +248,17 @@ func timestampField(now time.Time) cmdField {
return varField(0xfe, uint32(now.Unix()))
}
// bareTimestampField is the timestamp the status request carries: the same
// clock as every other command, sent without its value type. The app sends it
// that way — the reference reads it as an Anker bug and keeps the whole command
// commented out because of it — and the charger answers what the app sends, so
// the oddity is reproduced rather than corrected.
func bareTimestampField(now time.Time) cmdField {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, uint32(now.Unix()))
return cmdField{name: 0xfe, typ: typeNone, value: b}
}
// clockField builds the two-byte field the charger carries a time of day in:
// the hour and the minute, least significant byte first, which is the same
// layout the decoder reads back as "HH:MM".
@@ -42,6 +42,35 @@ func TestEncodeFrameMatchesTheDocumentedTrigger(t *testing.T) {
}
}
// The status request is the one command whose timestamp travels without a value
// type — the app sends it that way, and the charger answers what the app sends.
func TestStatusRequestSendsItsClockWithoutAValueType(t *testing.T) {
got, err := encodeFrame(msgEVStatusReq, []cmdField{bareTimestampField(time.Unix(1756813256, 0))})
if err != nil {
t.Fatalf("encodeFrame: %v", err)
}
want := "ff09100003000f0040" + // header: marker, length 16, send pattern, type 0040
"fe04c8d7b668" // fe: four clock bytes, no value type between the length and them
if h := encodeHex(got); h[:len(want)] != want {
t.Fatalf("frame = %s\nwant %s + checksum", h, want)
}
if len(got) != 16 {
t.Errorf("frame is %d bytes, want 16", len(got))
}
// The field's length byte counts the value alone, since there is no type byte
// to count — the whole point of the oddity.
if got[10] != 4 {
t.Errorf("fe length byte is %d, want 4", got[10])
}
var sum byte
for _, b := range got {
sum ^= b
}
if sum != 0 {
t.Errorf("checksum does not close the frame: %02x", sum)
}
}
// A frame is only self-consistent if XORing every byte, checksum included,
// comes to zero — which is exactly what the decoder checks.
func TestEncodeFrameChecksumClosesToZero(t *testing.T) {
@@ -103,6 +103,18 @@ var settingCmds = []settingCmd{
// mode: the charger stops serving the register map on the LAN.
{wire: 0xb7, key: "modbusEnabled", state: "modbusSwitch", encode: switchOnOff(1, 0)},
}},
// The panel's own three: what a swipe up, a swipe down and a touch do. The
// charger has always reported them and the card has always shown them; they
// are settings like any other on this message, so they are writable here too.
{msgType: msgEVSettings, fields: []settingField{
{wire: 0xaf, key: "swipeUpMode", state: "swipeUpMode", encode: optionValue(0, 1, 2, 3)},
}},
{msgType: msgEVSettings, fields: []settingField{
{wire: 0xb0, key: "swipeDownMode", state: "swipeDownMode", encode: optionValue(0, 1, 2, 3)},
}},
{msgType: msgEVSettings, fields: []settingField{
{wire: 0xb2, key: "smartTouchMode", state: "smartTouchMode", encode: optionValue(0, 1)},
}},
{msgType: msgEVSchedule, fields: []settingField{
{wire: 0xa2, key: "scheduleEnabled", state: "scheduleSwitch", encode: switchOnOff(1, 2)},
{wire: 0xa8, key: "scheduleMode", state: "scheduleMode", encode: optionValue(0, 1)},
@@ -189,13 +189,27 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er
// Re-arm whenever the window is spent or close to it, so a poll never lands
// in the gap between the last frame and the trigger expiring.
_, _, _, triggered := conn.snapshotOf(sn)
_, _, settingsAt, triggered := conn.snapshotOf(sn)
if time.Until(triggered) < triggerRenew {
if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil {
return nil, err
}
}
// The trigger buys telemetry only. What the charger is set to — its schedule,
// its balancing, its Modbus server, its firmware — travels on its own message,
// and without asking it never comes: a charger that has been read a hundred
// times and commanded none reports amps and nothing else. So the read asks for
// that half too whenever it is missing or has gone stale.
askedSettings := false
if time.Since(settingsAt) > settingsMaxAge {
// A charger that will not answer this is not a failed read: the telemetry
// half is still the answer, and the settings half is what it was.
if err := p.mqttStatusRequest(ctx, conn, model, sn); err == nil {
askedSettings = true
}
}
// Anything older than the trigger's own interval is stale; wait for the next.
cutoff := time.Now().Add(-triggerRenew)
live, err := conn.waitFor(ctx, sn, func(st *deviceState) bool {
@@ -205,6 +219,19 @@ func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, er
return nil, err
}
// Give the parameter message a moment of its own, but only while this charger
// still looks like one that answers: the reference reads the status request as
// 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) {
settled, werr := conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.settingsAt.After(cutoff)
}, settingsWait)
if werr == nil && !settled {
conn.noteStatusMiss(sn)
}
}
values, telemetryAt, settingsAt, _ := conn.snapshotOf(sn)
if len(values) == 0 {
return nil, fmt.Errorf("anker-solix: charger %s did not answer over the cloud; it may be offline", sn)
+2
View File
@@ -206,6 +206,8 @@
"productNumber": "Produktnummer",
"ratedPower": "Nominel effekt",
"currentRange": "Strømområde",
"upTo": "Op til",
"from": "Fra",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Sessionsenergi",
+2
View File
@@ -206,6 +206,8 @@
"productNumber": "Product number",
"ratedPower": "Rated power",
"currentRange": "Current range",
"upTo": "Up to",
"from": "From",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Session energy",
+2
View File
@@ -208,6 +208,8 @@
"productNumber": "Numer produktu",
"ratedPower": "Moc znamionowa",
"currentRange": "Zakres prądu",
"upTo": "Do",
"from": "Od",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"sessionEnergy": "Energia sesji",
+13 -1
View File
@@ -2226,6 +2226,16 @@ class _HomeTabState extends State<_HomeTab> {
]);
}
/// The current range as the charger reports it: both bounds when it sends
/// both, and the one it does send otherwise — "up to 32 A" is a fact, and
/// dropping the row because the other half is missing hides it.
String? _currentRange(int? min, int? max) {
if (min != null && max != null) return "$min$max A";
if (max != null) return "${t("charging.modbus.upTo")} $max A";
if (min != null) return "${t("charging.modbus.from")} $min A";
return null;
}
List<(String, String)> _deviceRows(ChargerStatus s) {
final product = s.integer("productNumber");
final min = s.integer("minCurrentA");
@@ -2238,7 +2248,9 @@ class _HomeTabState extends State<_HomeTab> {
("hardware", s.text("hardware")),
("productNumber", product == null ? null : "$product"),
("ratedPower", _unit(s.number("ratedPowerW"), 0, "W")),
("currentRange", min != null && max != null ? "$min$max A" : null),
// Either bound on its own is still a bound worth reading; only a charger
// that reports neither has nothing to say here.
("currentRange", _currentRange(min, max)),
("ocppLink", _enumLabel("ocpp", s.integer("ocppStatus"))),
("mqttLink", _enumLabel("mqtt", s.integer("mqttStatus"))),
]);
+2
View File
@@ -167,6 +167,8 @@
"productNumber": "Produktnummer",
"ratedPower": "Nominel effekt",
"currentRange": "Strømområde",
"upTo": "Op til",
"from": "Fra",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Kabel tilsluttet",
+2
View File
@@ -153,6 +153,8 @@
"productNumber": "Product number",
"ratedPower": "Rated power",
"currentRange": "Current range",
"upTo": "Up to",
"from": "From",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Cable plugged in",
+2
View File
@@ -169,6 +169,8 @@
"productNumber": "Numer produktu",
"ratedPower": "Moc znamionowa",
"currentRange": "Zakres prądu",
"upTo": "Do",
"from": "Od",
"ocppLink": "OCPP",
"mqttLink": "MQTT",
"plugged": "Kabel podłączony",
+14 -2
View File
@@ -366,6 +366,16 @@ function countdown(sec) {
return m > 0 ? `${m} min ${r} s` : `${r} s`;
}
// The current range as the charger reports it: both bounds when it sends both,
// and the one it does send otherwise — "up to 32 A" is a fact, and dropping the
// row because the other half is missing hides it.
function currentRange(min, max) {
if (isSet(min) && isSet(max)) return `${min}\u2013${max} A`;
if (isSet(max)) return `${t("charging.modbus.upTo")} ${max} A`;
if (isSet(min)) return `${t("charging.modbus.from")} ${min} A`;
return null;
}
function sessionLength(sec) {
if (!isSet(sec)) return null;
const h = Math.floor(sec / 3600);
@@ -566,8 +576,10 @@ const deviceIdentity = computed(() => {
["hardware", s.hardware],
["productNumber", isSet(s.productNumber) ? String(s.productNumber) : null],
["ratedPower", unit(s.ratedPowerW, 0, "W")],
["currentRange",
isSet(s.minCurrentA) && isSet(s.maxCurrentA) ? `${s.minCurrentA}${s.maxCurrentA} A` : null],
// The charger's own floor and ceiling. Either half on its own is still a
// bound the number beside the slider has to respect, so a charger that
// reports one and not the other says the one.
["currentRange", currentRange(s.minCurrentA, s.maxCurrentA)],
["ocppLink", enumLabel("ocpp", s.ocppStatus)],
["mqttLink", enumLabel("mqtt", s.mqttStatus)],
]);