Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/mqttframe.go
T
tajniak81andClaude Opus 5 62b0a29598 The reader speaks: 0904 is the card list, 0908 is the tap
Named from a live capture on an A5191: 0904 carried the nine UIDs the
account's own card list answers with, and 0908 arrived the moment a card
touched the reader, carrying its UID — and arrived without one when the
window closed empty. 0911 names the OCPP backend the charger is pointed
at. A UID is bytes, not a number, so type 0x04 now reads as hex.

With the frame log on, the command topic is subscribed too: the app's own
commands are the half no capture has seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:04:52 +02:00

644 lines
24 KiB
Go

package ankersolix
// The wire format Anker's cloud carries between the mobile app and the charger.
//
// An MQTT message is JSON, but only as an envelope: the part that means anything
// is a base64 field inside it holding a binary frame the device itself speaks.
// 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) 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
// 03 00 0f fixed pattern; the middle byte is 00 outbound, 01 inbound
// xx xx message type — what the frame is, per device model
// [xx] an optional counter, present on some inbound frames
// <fields> one or more data fields
// xx XOR of every preceding byte
//
// and each data field is
//
// xx field name (a1, a2, … — the model's message map names it)
// xx length of everything that follows in this field
// [xx] value type, present when the field carries more than one byte
// xx … the value
//
// The frame has no version number and no field-type registry: which name means
// what depends on the message type, which is why the maps below are per message
// type rather than one table.
import (
"encoding/binary"
"fmt"
"math"
"strings"
"time"
)
// Frame markers. patternSend is what the app puts in an outbound frame; a device
// answers with 03 01 0f, which is not checked — the message type is what selects
// a decoder.
var (
frameMarker = []byte{0xff, 0x09}
patternSend = []byte{0x03, 0x00, 0x0f}
)
// frameHeaderLen is the marker, length, pattern and message type — everything
// before the first data field, and before any inbound counter byte.
const frameHeaderLen = 9
// Value types. A field longer than one byte starts with one of these; a
// single-byte field carries its value directly with no type at all.
const (
typeString byte = 0x00
typeUint8 byte = 0x01
typeInt16LE byte = 0x02
typeInt32LE byte = 0x03 // "var": four bytes, though not always one value
typeBytes byte = 0x04 // bytes that are an identifier, not a number — an RFID UID
typeFloat32 byte = 0x05
typeNone byte = 0xff // this package's marker for "no type byte"
)
// typeByteMax is the largest first-value-byte still read as a value type. Field
// names start at 0xa1 and value types stop at 0x06, so the gap is wide; the
// bound matches the reference implementation's rather than narrowing it, since a
// message type we have not seen may use a type we have not seen either.
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
msgEVSchedule = "0106" // the charging schedule: switch, mode and times
msgEVBalancing = "010c" // load balancing and the main breaker limit
msgEVSolar = "010e" // solar charging
msgEVPowerMode = "0108" // the device power mode: the one value restarts it
msgEVTelemetry = "0410" // fast telemetry, only while a trigger is live
msgEVParams = "0405" // settings and identity, sent after a command
msgEVParamsAlt = "0840" // the same fields, in answer to a status request
msgEVConfirm = "0900" // the same fields again, confirming a control change
msgEVCharging = "0403" // a couple of charging parameters
// The charger's own copy of the RFID card list, published when the account
// asks it for one. Named from a live capture: nine UIDs in one frame, the
// same nine the cloud's get_device_cards answered with, in reverse order —
// newest first. The count is a field; the cards are one field each, so they
// are collected rather than mapped (see decodeFrame).
msgEVCards = "0904"
// Which OCPP backend the charger is pointed at, from the charger rather than
// from the account: the same address get_ocpp_info reports, and the source
// number that view leaves as a bare integer.
msgEVOcppInfo = "0911"
// The reader, reporting a card held against it. Captured during an
// enrol-at-the-charger: the frame arrives the moment the card is tapped,
// carrying the UID, and the charger publishes its updated card list a second
// later. The same frame arrives without a UID when the twenty-second window
// closes with nothing tapped, which is what makes the UID field the event.
msgEVCardRead = "0908"
// powerModeRestart is the only value the power-mode command is known to take.
// The map documents 5 and nothing else, so nothing else is sent.
powerModeRestart uint8 = 5
)
// mqttField is one named value inside a device message. factor scales the raw
// integer (0.1 for a decivolt, 0.001 for a watt-hour reported in kWh); unsigned
// marks the fields whose two- and four-byte types are *not* signed, which is the
// exception rather than the rule; clock marks the two-byte fields that hold a
// minute and an hour rather than a number.
type mqttField struct {
name string
factor float64
unsigned bool
clock bool
// version marks the three identity fields, which the reference calls
// multi-value: four bytes that are the parts of a version rather than one
// number, in the order they arrive.
version bool
}
// evTelemetry decodes the 0410 message: the charger's live electrical state,
// published every few seconds but only while a realtime trigger is live. It is
// where the two signals the Modbus map has no register for — the plug and start
// countdowns — actually come from.
var evTelemetry = map[byte]mqttField{
0xa2: {name: "voltageL1", factor: 0.1},
0xa3: {name: "voltageL2", factor: 0.1},
0xa4: {name: "voltageL3", factor: 0.1},
0xa5: {name: "currentL1", factor: 0.1},
0xa6: {name: "currentL2", factor: 0.1},
0xa7: {name: "currentL3", factor: 0.1},
0xa8: {name: "powerTotal"},
0xa9: {name: "sessionSeconds"},
0xaa: {name: "sessionWh"},
0xab: {name: "sessionStartedAt", unsigned: true},
// Where the charge is coming from: 0 off or paused, 1 grid, 7 solar. The
// reference marks the reading uncertain, and it is not the Modbus map's
// chargingMode — a third name for a fourth thing would be the same collision
// the cloud's d9 already caused once.
0xac: {name: "chargingSource"},
0xad: {name: "plugCountdownSeconds"},
0xae: {name: "startCountdownSeconds"},
0xaf: {name: "chargingWindowSeconds"},
0xb0: {name: "powerL1"},
0xb1: {name: "powerL2"},
0xb2: {name: "powerL3"},
0xb3: {name: "sessionWhL1"},
0xb4: {name: "sessionWhL2"},
0xb5: {name: "sessionWhL3"},
0xb6: {name: "orderId", unsigned: true}, // the session's id upstream; uncertain in the reference
0xb8: {name: "ocppStatus"},
0xba: {name: "phaseMode"},
0xbb: {name: "status"},
}
// evParams decodes the 0405 message (and the 0840 and 0900 that carry the same
// fields): what the charger is *set* to, plus its identity. The charger sends it
// after a control change rather than on a schedule, so these values arrive with
// a command rather than with the telemetry stream.
var evParams = map[byte]mqttField{
0xa3: {name: "plugLockSwitch"},
0xa4: {name: "autoStartSwitch"},
0xa8: {name: "maxCurrentSetA", factor: 0.1},
0xaa: {name: "ledBrightness"},
0xac: {name: "autoRestartSwitch"},
0xad: {name: "randomDelaySwitch"},
0xaf: {name: "swipeUpMode"},
0xb0: {name: "swipeDownMode"},
0xb2: {name: "smartTouchMode"},
0xb4: {name: "lightOffScheduleSwitch"},
0xb5: {name: "lightOffStart", unsigned: true, clock: true},
0xb6: {name: "lightOffEnd", unsigned: true, clock: true},
0xb7: {name: "modbusSwitch"},
0xcc: {name: "modbusTimeoutSeconds"},
0xce: {name: "maxCurrentA", factor: 0.1},
0xcf: {name: "modbusPort"},
0xd0: {name: "ipAddress"},
0xd3: {name: "loadBalancing"},
0xd4: {name: "mainBreakerLimitA"},
0xd5: {name: "loadBalanceMonitorMode"},
0xd6: {name: "loadBalanceMeterFlag"},
0xd7: {name: "loadBalanceMonitorSN"},
0xd8: {name: "solarBalancing"},
// The charger's solar charging mode (grid-assisted or solar only), not the
// charging mode the Modbus map means by that name.
0xd9: {name: "solarChargeMode"},
0xda: {name: "solarMinCurrentA"},
0xdb: {name: "phaseMode"},
0xdc: {name: "solarMonitoringMode"},
0xdd: {name: "autoPhaseSwitch"},
0xde: {name: "solarMonitorSN"},
0xdf: {name: "boostMode"},
0xe0: {name: "cpSignal"},
0xe2: {name: "plugged"},
0xe3: {name: "status"},
0xe6: {name: "scheduleSwitch"},
0xe7: {name: "weekStart", unsigned: true, clock: true},
0xe8: {name: "weekEnd", unsigned: true, clock: true},
0xe9: {name: "weekendStart", unsigned: true, clock: true},
0xea: {name: "weekendEnd", unsigned: true, clock: true},
0xeb: {name: "weekendMode"},
0xec: {name: "scheduleMode"},
// The three identity fields. The reference decodes none of them, marking them
// multi-value; four bytes read as the parts of a version is what the account
// view's own firmware string looks like, so they are read that way and shown
// as they arrive rather than rearranged into an order we cannot check.
0xf1: {name: "softwareVersion", version: true},
0xf2: {name: "controllerVersion", version: true},
0xf3: {name: "hardwareVersion", version: true},
0xfe: {name: "minCurrentA"},
}
// evCharging decodes the 0403 message, a pair of charging parameters the charger
// sends around a mode change.
var evCharging = map[byte]mqttField{
0xa5: {name: "chargingWindowSeconds"},
0xa6: {name: "solarMinCurrentA"},
}
// evCards names what is not a card in the card-list frame. The cards themselves
// start at 0xa3 and run one per field for as many as the charger holds, so they
// cannot be a map — decodeFrame collects them.
var evCards = map[byte]mqttField{
0xa2: {name: "rfidCardCount"},
}
// evCardRead names the reader's own event. a2 was 7 in every capture, with and
// without a card, so it is relayed under its own key rather than guessed at.
var evCardRead = map[byte]mqttField{
0xa3: {name: "rfidCardRead"},
}
// evOcppInfo names the charger's own view of its OCPP backend.
var evOcppInfo = map[byte]mqttField{
0xa2: {name: "ocppBackendName"},
0xa3: {name: "ocppBackendUrl"},
0xa4: {name: "ocppSource"},
0xa5: {name: "ocppChargePointId"},
}
// evInfoMessages are messages this package can name but must not treat as a
// settings report: evMessages is what stamps the timestamp a control command
// waits on, and a card list is not an acknowledgement of anything.
var evInfoMessages = map[string]map[byte]mqttField{
msgEVCards: evCards,
msgEVOcppInfo: evOcppInfo,
msgEVCardRead: evCardRead,
}
// evMessages selects a field map by message type. A type absent from here is one
// we have no map for; its frame is still parsed, but nothing is named.
var evMessages = map[string]map[byte]mqttField{
msgEVTelemetry: evTelemetry,
msgEVParams: evParams,
msgEVParamsAlt: evParams,
msgEVConfirm: evParams,
msgEVCharging: evCharging,
}
// ---- outbound frames ---------------------------------------------------------
// cmdField is one field of a command frame. typ is typeNone for the single-byte
// fields that carry no value type.
type cmdField struct {
name byte
typ byte
value []byte
}
// rawField builds a field with no value type — the `a1 01 22` pattern that opens
// every command.
func rawField(name byte, value ...byte) cmdField {
return cmdField{name: name, typ: typeNone, value: value}
}
// uintField builds a one-byte unsigned field.
func uintField(name byte, v uint8) cmdField {
return cmdField{name: name, typ: typeUint8, value: []byte{v}}
}
// intField builds a two-byte little-endian signed field.
func intField(name byte, v int16) cmdField {
b := make([]byte, 2)
binary.LittleEndian.PutUint16(b, uint16(v))
return cmdField{name: name, typ: typeInt16LE, value: b}
}
// varField builds a four-byte little-endian field.
func varField(name byte, v uint32) cmdField {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, v)
return cmdField{name: name, typ: typeInt32LE, value: b}
}
// timestampField is the `fe` field every command ends with: the sender's clock,
// in whole seconds.
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".
func clockField(name byte, hour, minute int) cmdField {
return intField(name, int16(uint16(hour)<<8|uint16(minute)))
}
// stringField builds a fixed-length text field, zero padded. The length is the
// charger's, not the value's: a serial field is sixteen bytes whether or not the
// serial fills them.
func stringField(name byte, s string, length int) cmdField {
b := make([]byte, length)
copy(b, s)
return cmdField{name: name, typ: typeString, value: b}
}
// encodeFrame builds one command frame for a message type. The caller supplies
// every field in wire order, starting with the `a1 01 22` opener each command in
// the reference maps carries and ending with the timestamp.
func encodeFrame(msgType string, fields []cmdField) ([]byte, error) {
mt, err := decodeHex(msgType)
if err != nil || len(mt) < 2 || len(mt) > 3 {
return nil, fmt.Errorf("anker-solix: %q is not a message type", msgType)
}
body := make([]byte, 0, 32)
for _, f := range fields {
n := len(f.value)
if f.typ != typeNone {
n++
}
if n < 1 || n > 255 {
return nil, fmt.Errorf("anker-solix: field %02x does not fit one frame field", f.name)
}
body = append(body, f.name, byte(n))
if f.typ != typeNone {
body = append(body, f.typ)
}
body = append(body, f.value...)
}
// The length counts the whole frame, checksum byte included.
total := frameHeaderLen + (len(mt) - 2) + len(body) + 1
out := make([]byte, 0, total)
out = append(out, frameMarker...)
out = binary.LittleEndian.AppendUint16(out, uint16(total))
out = append(out, patternSend...)
out = append(out, mt...)
out = append(out, body...)
return append(out, xorChecksum(out)), nil
}
// xorChecksum is the frame's only integrity check: every byte XORed together.
func xorChecksum(b []byte) byte {
var sum byte
for _, x := range b {
sum ^= x
}
return sum
}
// ---- inbound frames ----------------------------------------------------------
// decodeFrame parses one device frame and returns its message type together with
// the values it carries: the ones its field map names, under those names, and
// the ones no map names, under the message and name byte they arrived with (see
// rawFieldName). Nothing is guessed at — an unnamed field keeps its raw number
// and gains no unit, scaling or meaning — but nothing is thrown away either,
// because a field this package cannot name is still the only place some of what
// the charger knows ever appears. A frame whose checksum does not add up is
// rejected: these arrive over a cloud connection we do not control, so a
// truncated one must not be read as a charger reporting zeros.
func decodeFrame(data []byte) (string, map[string]any, error) {
if len(data) < frameHeaderLen+2 {
return "", nil, fmt.Errorf("anker-solix: device frame is %d bytes, too short to hold a header", len(data))
}
if data[0] != frameMarker[0] || data[1] != frameMarker[1] {
return "", nil, fmt.Errorf("anker-solix: device frame does not start with the Anker marker (%02x%02x)", data[0], data[1])
}
if n := int(binary.LittleEndian.Uint16(data[2:4])); n != len(data) {
return "", nil, fmt.Errorf("anker-solix: device frame says it is %d bytes but %d arrived", n, len(data))
}
if xorChecksum(data) != 0 {
return "", nil, fmt.Errorf("anker-solix: device frame checksum does not match")
}
msgType := encodeHex(data[7:9])
// Some inbound frames carry a counter byte between the header and the first
// data field, and nothing in the frame says which kind this is. Rather than
// guess from the byte's value — field names run high enough to be mistaken for
// a counter — try both and keep the reading whose fields tile the frame
// exactly, from the first field to the checksum with nothing left over.
raw, ok := splitFields(data, frameHeaderLen)
if !ok {
if raw, ok = splitFields(data, frameHeaderLen+1); !ok {
return "", nil, fmt.Errorf("anker-solix: device frame %s does not divide into whole data fields", msgType)
}
}
fields := evMessages[msgType]
if fields == nil {
fields = evInfoMessages[msgType]
}
values := map[string]any{}
for _, r := range raw {
f, known := fields[r.name]
if !known || f.name == "" {
// No name for it: keep the value under the message and name byte, with
// no scaling — a factor is part of a field's meaning, and we have none.
if v, ok := decodeValue(r.typ, r.value, mqttField{}); ok {
values[rawFieldName(msgType, r.name)] = v
}
continue
}
if v, ok := decodeValue(r.typ, r.value, f); ok {
values[f.name] = v
}
}
if msgType == msgEVCards {
values["rfidCards"] = cardsFromFields(raw)
}
return msgType, values, nil
}
// cardsFromFields reads the card UIDs out of a card-list frame. Every field from
// 0xa3 up is one card, in the order the charger sent them, and a UID is however
// many bytes the card has — four for the older tags, seven for the newer ones —
// so the bytes are taken as they are and read as hex, which is how the account
// prints them and how a person reads one off a card.
func cardsFromFields(raw []rawFieldBytes) []string {
out := []string{}
for _, r := range raw {
if r.name < 0xa3 || len(r.value) == 0 {
continue
}
out = append(out, strings.ToUpper(encodeHex(r.value)))
}
return out
}
// rawFieldName keys a field no map names, by the message it arrived in and the
// name byte the charger gave it — "0410.b6". The message type belongs in the key
// because a name byte means whatever its message says it means: the same b6 is a
// different quantity in the telemetry stream and in the settings group, and one
// key for both would merge two readings into one wrong number.
func rawFieldName(msgType string, name byte) string {
return msgType + "." + encodeHex([]byte{name})
}
// rawFieldBytes is one data field as it sat in the frame, before its map entry
// decides what it means.
type rawFieldBytes struct {
name byte
typ byte
value []byte
}
// splitFields walks the data fields from start and reports them only if they end
// exactly at the checksum byte. A frame read from the wrong offset runs off the
// end or stops short, which is what makes the exact fit a usable test.
func splitFields(data []byte, start int) ([]rawFieldBytes, bool) {
end := len(data) - 1 // the last byte is the checksum
var out []rawFieldBytes
for idx := start; idx < end; {
if idx+2 > end {
return nil, false
}
name := data[idx]
flen := int(data[idx+1])
if flen == 0 || idx+2+flen > end {
return nil, false
}
body := data[idx+2 : idx+2+flen]
idx += 2 + flen
typ, value := typeNone, body
if flen > 1 && body[0] <= typeByteMax {
typ, value = body[0], body[1:]
}
out = append(out, rawFieldBytes{name: name, typ: typ, value: value})
}
return out, len(out) > 0
}
// decodeValue turns one field's bytes into a value, following the type byte the
// field carries and the scaling its map entry gives. It reports false for a
// field whose bytes do not fit its type, so a short value is dropped rather than
// read as a smaller number.
func decodeValue(typ byte, b []byte, f mqttField) (any, bool) {
if len(b) == 0 {
return nil, false
}
if f.version {
return versionString(b), true
}
factor := f.factor
if factor == 0 {
factor = 1
}
scale := func(n int64) any {
if factor == 1 {
return float64(n)
}
return round(float64(n)*factor, factor)
}
switch typ {
case typeString:
return printable(b), true
case typeUint8:
return scale(int64(b[0])), true
case typeInt16LE:
if len(b) < 2 {
return nil, false
}
if f.clock {
// Two bytes holding a minute and an hour, least significant first.
return fmt.Sprintf("%02d:%02d", b[1], b[0]), true
}
if f.unsigned {
return scale(int64(binary.LittleEndian.Uint16(b))), true
}
return scale(int64(int16(binary.LittleEndian.Uint16(b)))), true
case typeInt32LE:
if len(b) < 4 {
return nil, false
}
if f.unsigned {
return scale(int64(binary.LittleEndian.Uint32(b))), true
}
return scale(int64(int32(binary.LittleEndian.Uint32(b)))), true
case typeBytes:
// An identifier rather than a quantity: a 4- or 7-byte card UID, which is
// the same hex the account prints on the card list. Read as a number it
// would be a different string on every screen it reached.
return strings.ToUpper(encodeHex(b)), true
case typeFloat32:
if len(b) < 4 {
return nil, false
}
return float64(math.Float32frombits(binary.LittleEndian.Uint32(b))), true
default:
// No value type: the bytes are the number, most significant first.
var n int64
for _, x := range b {
n = n<<8 | int64(x)
}
return scale(n), true
}
}
// versionString reads an identity field. Four bytes are the parts of a version,
// in wire order; anything else is text the charger padded, or — failing that —
// the bytes themselves, because a version we cannot shape is still better shown
// than dropped.
func versionString(b []byte) string {
if len(b) == 4 {
return fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
}
if s := printable(b); s != "" {
return s
}
return encodeHex(b)
}
// round trims the floating-point noise a factor introduces, to the precision the
// factor itself implies — 0.1 keeps one decimal, 0.001 keeps three.
func round(v, factor float64) float64 {
digits := 0
for f := factor; f < 1 && digits < 6; f *= 10 {
digits++
}
p := math.Pow(10, float64(digits))
return math.Round(v*p) / p
}
// printable keeps the readable part of a string field; the charger pads some of
// them with control bytes.
func printable(b []byte) string {
var sb strings.Builder
for _, r := range string(b) {
if r >= 0x20 && r != 0x7f {
sb.WriteRune(r)
}
}
return strings.TrimSpace(sb.String())
}
// ---- small hex helpers -------------------------------------------------------
const hexDigits = "0123456789abcdef"
// encodeHex renders bytes as lowercase hex, which is how message types are keyed.
func encodeHex(b []byte) string {
out := make([]byte, 0, len(b)*2)
for _, x := range b {
out = append(out, hexDigits[x>>4], hexDigits[x&0x0f])
}
return string(out)
}
// decodeHex parses a lowercase or uppercase hex string.
func decodeHex(s string) ([]byte, error) {
if len(s)%2 != 0 {
return nil, fmt.Errorf("hex string %q has an odd length", s)
}
out := make([]byte, 0, len(s)/2)
for i := 0; i < len(s); i += 2 {
hi, err1 := hexNibble(s[i])
lo, err2 := hexNibble(s[i+1])
if err1 != nil || err2 != nil {
return nil, fmt.Errorf("hex string %q holds a non-hex character", s)
}
out = append(out, hi<<4|lo)
}
return out, nil
}
func hexNibble(c byte) (byte, error) {
switch {
case c >= '0' && c <= '9':
return c - '0', nil
case c >= 'a' && c <= 'f':
return c - 'a' + 10, nil
case c >= 'A' && c <= 'F':
return c - 'A' + 10, nil
}
return 0, fmt.Errorf("not a hex digit: %q", string(rune(c)))
}