package ankersolix // The cards that open the charger, and the two writes that change them. // // get_device_cards is well behaved: every card comes back as alias_name, // card_number and create_time, and nothing else. There is no card id anywhere in // that payload, which is why both writes below address a card by its number — // it is the only handle the account ever gives out. // // save_device_card and delete_device_card are a different matter. Neither is // documented, by Anker or by the reference implementation: both names were read // out of the app package, and the bodies here are inferred from the field names // the read view answers with. That is a guess, and it is treated as one: // // - a write never reports its own success. After the call the card list is // read again and the answer says whether the card is on the charger now, so // a caller never has to take an ack's word for what happened; // - the cloud's own response is relayed alongside it, because an endpoint // nobody has documented is one whose reply is worth reading; // - nothing here deletes by pattern, by index, or in bulk. One card, named in // full, per call. import ( "context" "encoding/json" "fmt" "strings" ) const ( epRfidSaveCard = "power_service/v1/rfid/save_device_card" // add or rename one card — payload inferred, see above epRfidDeleteCard = "power_service/v1/rfid/delete_device_card" // remove one card — payload inferred, see above ) // rfidCard is one authorised card, under the names get_device_cards uses for it. // create_time is relayed rather than parsed: it is upstream's own epoch and the // UI already knows how to read one. type rfidCard struct { Name string `json:"alias_name,omitempty"` Number string `json:"card_number,omitempty"` Added json.RawMessage `json:"create_time,omitempty"` } // rfidWriteResult is what a write answers with: what was asked, what the cloud // said, and — the part that matters — the list as it stands afterwards. type rfidWriteResult struct { SN string `json:"sn"` Action string `json:"action"` // save | delete Number string `json:"cardNumber"` // as it was sent, normalized Present bool `json:"present"` // whether the account holds that card now Cards []rfidCard `json:"cards"` // What the charger itself did, when it could be reached: the write the Anker // app makes, and the only one of the two that is not inferred. Charger *cardWriteResult `json:"charger,omitempty"` Response json.RawMessage `json:"response,omitempty"` Detail string `json:"detail,omitempty"` // why the list could not be read back } // normalizeCardNumber puts a card number in the form the account stores it in. // The numbers arrive as bare uppercase hex; people type them with spaces, dashes // or colons between the bytes, and a number that differs from the stored one // only in punctuation would delete nothing and add a duplicate. func normalizeCardNumber(s string) string { var b strings.Builder for _, r := range s { switch { case r >= '0' && r <= '9', r >= 'A' && r <= 'Z': b.WriteRune(r) case r >= 'a' && r <= 'z': b.WriteRune(r - 'a' + 'A') } } return b.String() } // rfidCardLabel is the name a card gets when it is added without one — the same // shape the Anker app writes, so a card added here does not stand out in it. func rfidCardLabel(number string) string { if len(number) > 4 { number = number[len(number)-4:] } return "RFID " + number } // parseRfidCards reads the card list out of get_device_cards' envelope. A // response that carries no list is an empty charger, not an error: an account // with no cards answers exactly that way. func parseRfidCards(body []byte) ([]rfidCard, error) { var env struct { Data struct { List []rfidCard `json:"list"` } `json:"data"` } if err := json.Unmarshal(body, &env); err != nil { return nil, err } return env.Data.List, nil } // cardPresent says whether a number is among the cards, comparing them the way // normalizeCardNumber writes them so punctuation cannot answer for the account. func cardPresent(cards []rfidCard, number string) bool { want := normalizeCardNumber(number) for _, c := range cards { if normalizeCardNumber(c.Number) == want { return true } } return false } // rfidList is the cards authorised on one charger, as the account holds them. func (p *Plugin) rfidList(ctx context.Context, sn string) ([]rfidCard, error) { body, err := p.apiRequest(ctx, epRfidCards, map[string]any{"device_sn": sn}) if err != nil { return nil, err } return parseRfidCards(body) } // rfidSaveCard adds a card, both places it has to be added. // // The charger is written first, with the app's own message: it is the device // that decides who may start a charge, and that write is the one this connector // watched the app make rather than inferred. The account write follows because // it is the half that carries a name — the charger's message has no name field — // and because the list people read is the account's. Either may fail on its own // and the answer says which; only both failing is an error. func (p *Plugin) rfidSaveCard(ctx context.Context, sn, number, name string) (json.RawMessage, error) { number = normalizeCardNumber(number) if number == "" { return nil, fmt.Errorf("anker-solix: a card number is required") } name = strings.TrimSpace(name) if name == "" { name = rfidCardLabel(number) } dev, devErr := p.mqttWriteCard(ctx, sn, number, true) body, err := p.apiRequest(ctx, epRfidSaveCard, map[string]any{ "device_sn": sn, "card_number": number, "alias_name": name, }) if err != nil && devErr != nil { return nil, fmt.Errorf("anker-solix: saving card %s: charger: %v; account: %v", number, devErr, err) } return p.rfidAfterWrite(ctx, sn, "save", number, body, dev, devErr) } // rfidDeleteCard removes one card from the charger. The caller names the whole // number: there is no "delete the third one" here, because an index into a list // that was read a minute ago is not a card. func (p *Plugin) rfidDeleteCard(ctx context.Context, sn, number string) (json.RawMessage, error) { number = normalizeCardNumber(number) if number == "" { return nil, fmt.Errorf("anker-solix: a card number is required") } dev, devErr := p.mqttWriteCard(ctx, sn, number, false) body, err := p.apiRequest(ctx, epRfidDeleteCard, map[string]any{ "device_sn": sn, "card_number": number, }) if err != nil && devErr != nil { return nil, fmt.Errorf("anker-solix: deleting card %s: charger: %v; account: %v", number, devErr, err) } return p.rfidAfterWrite(ctx, sn, "delete", number, body, dev, devErr) } // rfidAfterWrite reads the list back and answers with it. A list that cannot be // read is not a failed write — the write already happened — so it is reported as // the detail beside an answer that says nothing about presence rather than // guessing at one. func (p *Plugin) rfidAfterWrite(ctx context.Context, sn, action, number string, response []byte, dev *cardWriteResult, devErr error) (json.RawMessage, error) { out := rfidWriteResult{SN: sn, Action: action, Number: number, Response: json.RawMessage(response)} if dev != nil { out.Charger = dev } else if devErr != nil { // The charger could not be reached or would not answer. The account write // may still have landed, so this is said beside the answer rather than // instead of it. out.Charger = &cardWriteResult{SN: sn, Action: action, Number: number, Via: "charger", Detail: devErr.Error()} } cards, err := p.rfidList(ctx, sn) if err != nil { out.Detail = err.Error() } else { out.Cards = cards out.Present = cardPresent(cards, number) } return json.Marshal(out) }