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 // 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 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 ( 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 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 ) // 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 } // 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}, 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"}, 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"}, 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"}, } // 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())) } // 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] 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 } } return msgType, values, nil } // 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 } 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 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 } } // 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))) }