package ankersolix // Cards, at the charger rather than at the account. // // The Anker app offers two ways to add an RFID card: type its number, which is // the account write in rfidcards.go, or hold the card against the reader inside // a twenty-second window. The second one never touches the REST API. It is three // messages on the charger's own MQTT topics, captured from the app's traffic: // // 0108 a2=7 open the reader -> 0908, with the UID when a card is // tapped and without one when the // window closes empty // 0103 a2=1 write the card -> 0903, then 0904 // 0103 a2=2 remove it -> the same pair // 0104 ask for the card list -> 0904 // // Nothing here is inferred: every frame above is one this connector watched the // app send and the charger answer. What is inferred is the account write those // three replace, which is why a card written here is checked by reading the // charger's own list back rather than by trusting the write. import ( "context" "encoding/hex" "encoding/json" "fmt" "time" ) const ( // cardReadWindow is how long the reader stays open. The app counts twenty // seconds down; a couple more allow for the answer's trip back through the // cloud, and the charger closes the window on its own either way. cardReadWindow = 24 * time.Second // cardWriteWait is how long a write waits for the charger to republish its // list. The answer arrived within a second in every capture. cardWriteWait = 8 * time.Second // cardWriteAdd and cardWriteRemove are the two values the write takes. cardWriteAdd uint8 = 1 cardWriteRemove uint8 = 2 ) // cardReadResult is what a scan answers with: the card that was tapped, or the // plain fact that nothing was. type cardReadResult struct { SN string `json:"sn"` Card string `json:"card,omitempty"` Tapped bool `json:"tapped"` Seconds int `json:"windowSeconds"` } // mqttReadCard opens the charger's card reader and waits for a card. The // charger answers either way — with a UID when one is tapped, without when the // window closes — so a scan that finds nothing is an answer rather than a // timeout, and says so. func (p *Plugin) mqttReadCard(ctx context.Context, sn string) (json.RawMessage, error) { model, err := p.chargerModel(ctx, sn) if err != nil { return nil, err } conn, err := p.mqttClient(ctx) if err != nil { return nil, err } // Listen before asking: the charger answers in under a second, and a // subscription made afterwards would miss it. if err := conn.listen(ctx, model, sn); err != nil { return nil, err } since := conn.cardReadAt(sn) // Field for field what the app sends, timestamp included — which is to say // not included: the app's own reader-open frame carries none, and this // command is copied rather than composed. frame, err := encodeFrame(msgEVPowerMode, []cmdField{ rawField(0xa1, 0x22), uintField(0xa2, powerModeReadCard), }) if err != nil { return nil, err } // The same encoding the other power-mode command carries; this is that // command with a different value, and the charger expects the field on it. if err := conn.publishFrame(ctx, model, sn, frame, mqttEncodingMode); err != nil { return nil, fmt.Errorf("anker-solix: opening the card reader on %s: %w", sn, err) } ok, err := conn.waitFor(ctx, sn, func(st *deviceState) bool { return st.cardReadAt.After(since) }, cardReadWindow) if err != nil { return nil, err } out := cardReadResult{SN: sn, Seconds: int(cardReadWindow / time.Second)} if ok { out.Card = conn.cardRead(sn) out.Tapped = out.Card != "" } return json.Marshal(out) } // cardWriteResult is what a device write answers with: what was asked, and the // charger's own list afterwards. Present is read from that list — the charger // says 0903 to everything, and a write is judged by what it changed. type cardWriteResult struct { SN string `json:"sn"` Action string `json:"action"` // save | delete Number string `json:"cardNumber"` Present bool `json:"present"` Cards []string `json:"cards"` Via string `json:"via"` // charger Detail string `json:"detail,omitempty"` } // mqttWriteCard adds a card to the charger or removes one, and then reads the // charger's list back. Both halves are the app's own messages. func (p *Plugin) mqttWriteCard(ctx context.Context, sn, number string, add bool) (*cardWriteResult, error) { number = normalizeCardNumber(number) uid, err := hex.DecodeString(number) if err != nil || len(uid) == 0 { return nil, fmt.Errorf("anker-solix: %q is not a card number: the charger takes the UID as hex", number) } model, err := p.chargerModel(ctx, sn) if err != nil { return nil, err } conn, err := p.mqttClient(ctx) if err != nil { return nil, err } if err := conn.listen(ctx, model, sn); err != nil { return nil, err } since := conn.cardsAt(sn) action := cardWriteAdd name := "save" if !add { action, name = cardWriteRemove, "delete" } frame, err := encodeFrame(msgEVCardWrite, []cmdField{ rawField(0xa1, 0x22), uintField(0xa2, action), uintField(0xa3, 1), bytesField(0xa4, uid), }) if err != nil { return nil, err } if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil { return nil, fmt.Errorf("anker-solix: writing card %s to %s: %w", number, sn, err) } out := &cardWriteResult{SN: sn, Action: name, Number: number, Via: "charger"} cards, err := p.mqttCardList(ctx, conn, model, sn, since) if err != nil { // The write went out; what it did is simply unknown, which is not the // same as it having failed and must not be reported as either. out.Detail = err.Error() return out, nil } out.Cards = cards for _, c := range cards { if normalizeCardNumber(c) == number { out.Present = true break } } return out, nil } // mqttCardList is the charger's own list of the cards it will open for. A write // republishes it unasked; when it does not, it is asked for. func (p *Plugin) mqttCardList(ctx context.Context, conn *mqttConn, model, sn string, since time.Time) ([]string, error) { ok, err := conn.waitFor(ctx, sn, func(st *deviceState) bool { return st.cardsAt.After(since) }, cardWriteWait) if err != nil { return nil, err } if !ok { // Nothing came unasked, so ask. frame, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)}) if err != nil { return nil, err } if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil { return nil, err } ok, err = conn.waitFor(ctx, sn, func(st *deviceState) bool { return st.cardsAt.After(since) }, cardWriteWait) if err != nil { return nil, err } } if !ok { return nil, fmt.Errorf("anker-solix: charger %s did not answer with its card list", sn) } return conn.cards(sn), nil } // mqttChargerCards asks the charger for its list on its own, for a caller that // wants to know what the device holds rather than what the account does. func (p *Plugin) mqttChargerCards(ctx context.Context, sn string) (json.RawMessage, error) { model, err := p.chargerModel(ctx, sn) if err != nil { return nil, err } conn, err := p.mqttClient(ctx) if err != nil { return nil, err } if err := conn.listen(ctx, model, sn); err != nil { return nil, err } since := conn.cardsAt(sn) frame, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)}) if err != nil { return nil, err } if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil { return nil, err } cards, err := p.mqttCardList(ctx, conn, model, sn, since) if err != nil { return nil, err } return json.Marshal(map[string]any{"sn": sn, "cards": cards, "via": "charger"}) } // ---- the card half of a device's state, read out from under the lock -------- func (c *mqttConn) cardReadAt(sn string) time.Time { c.mu.Lock() defer c.mu.Unlock() if st := c.devices[sn]; st != nil { return st.cardReadAt } return time.Time{} } func (c *mqttConn) cardRead(sn string) string { c.mu.Lock() defer c.mu.Unlock() if st := c.devices[sn]; st != nil { return st.cardRead } return "" } func (c *mqttConn) cardsAt(sn string) time.Time { c.mu.Lock() defer c.mu.Unlock() if st := c.devices[sn]; st != nil { return st.cardsAt } return time.Time{} } func (c *mqttConn) cards(sn string) []string { c.mu.Lock() defer c.mu.Unlock() if st := c.devices[sn]; st != nil { return append([]string(nil), st.cards...) } return nil }