The enrolment the Anker app does, done here: 0108 a2=7 opens the reader, 0908 brings back the UID. The frames this sends are byte-for-byte the ones the app was captured sending — checksum included — which is what the new test asserts. Adding and removing now write the charger as well as the account: the device write is the app's own message, the account write is the inferred one that carries the name, and either may fail without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
679 lines
25 KiB
Go
679 lines
25 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 three commands the app sends about cards, all captured from its own
|
|
// traffic on the charger's command topic. The pattern is the connector's
|
|
// everywhere: a command 01xx is answered by the data frame 09xx.
|
|
//
|
|
// 0103 write one card: a2 = 1 add, 2 delete; a4 = the UID -> 0903, then 0904
|
|
// 0104 ask for the list -> 0904
|
|
// 0111 ask which OCPP backend -> 0911
|
|
//
|
|
// The fourth is not its own message: opening the reader is the power-mode
|
|
// command with powerModeReadCard, which is answered by 0908.
|
|
msgEVCardWrite = "0103"
|
|
msgEVCardListReq = "0104"
|
|
msgEVOcppInfoReq = "0111"
|
|
|
|
// 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"
|
|
|
|
// powerModeReadCard opens the card reader for a tap: the app sends the
|
|
// power-mode command with this value and the charger answers 0908 — with the
|
|
// UID if a card was held against it inside the twenty seconds, and without one
|
|
// if the window closed empty. Captured from the app on an A5191; the same 7
|
|
// comes back in the answer.
|
|
powerModeReadCard uint8 = 7
|
|
|
|
// powerModeRestart is the only other 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"},
|
|
}
|
|
|
|
// evCardWrite names the write the app sends. a3 was 1 on both the add and the
|
|
// delete, so it keeps its own key rather than a guessed name.
|
|
var evCardWrite = map[byte]mqttField{
|
|
0xa2: {name: "cardWriteAction"}, // 1 add, 2 delete
|
|
0xa4: {name: "cardWritten"},
|
|
}
|
|
|
|
// 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,
|
|
msgEVCardWrite: evCardWrite,
|
|
}
|
|
|
|
// 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}
|
|
}
|
|
|
|
// bytesField builds a field holding bytes that are an identifier rather than a
|
|
// number — an RFID UID, four bytes or seven, exactly as the reader reported it.
|
|
func bytesField(name byte, b []byte) cmdField {
|
|
return cmdField{name: name, typ: typeBytes, value: append([]byte(nil), 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)))
|
|
}
|