From 7176867eb34fab85c69782db73758088cf760ebc Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:45:00 +0200 Subject: [PATCH] Tap the card on the charger and the number fills itself in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/api/integrations_ankersolix.go | 12 + API Server/internal/api/server.go | 2 + .../plugins/builtin/ankersolix/ankersolix.go | 17 +- .../plugins/builtin/ankersolix/cloudmqtt.go | 22 ++ .../plugins/builtin/ankersolix/mqttcards.go | 270 ++++++++++++++++++ .../builtin/ankersolix/mqttcards_test.go | 48 ++++ .../plugins/builtin/ankersolix/mqttframe.go | 6 + .../plugins/builtin/ankersolix/rfidcards.go | 52 ++-- Web App/web/src/api.js | 7 + Web App/web/src/i18n/da.json | 4 + Web App/web/src/i18n/en.json | 4 + Web App/web/src/i18n/pl.json | 4 + Web App/web/src/views/Charging.vue | 44 ++- 13 files changed, 473 insertions(+), 19 deletions(-) create mode 100644 API Server/internal/plugins/builtin/ankersolix/mqttcards.go diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go index 1130df4..ff41cf3 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -636,6 +636,18 @@ func (s *Server) handleAnkerCardDelete(w http.ResponseWriter, r *http.Request) { }) } +// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan — open the +// charger's card reader and answer with the card someone taps on it. Takes as +// long as the window does, about twenty seconds, and answers either way: a +// window that closed with nothing tapped is an answer, not a timeout. +func (s *Server) handleAnkerCardScan(w http.ResponseWriter, r *http.Request) { + who, sn, cfg, ok := s.ankerCardGate(w, r) + if !ok { + return + } + s.ankerCardWrite(w, r, who, cfg, "rfid-card-scan", sn, map[string]any{"sn": sn}) +} + // ankerCardGate is everything both writes need before they may run: a caller, a // serial, an integration that is on and has credentials, and a rate limit. A // gate that is off answers 409 rather than the reads' 200-with-a-reason: a write diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 5f7b61b..8b66026 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -53,6 +53,7 @@ // GET /api/integrations/anker-solix/chargers // GET /api/integrations/anker-solix/chargers/{sn}/details // POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards +// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan // DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number} // GET /api/integrations/greencell PUT /api/integrations/greencell // POST /api/integrations/greencell/health @@ -444,6 +445,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers) mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails) mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards", s.handleAnkerCardSave) + mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan", s.handleAnkerCardScan) mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}", s.handleAnkerCardDelete) mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell) mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell) diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index bbdb676..48d0165 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -253,8 +253,10 @@ func (p *Plugin) Descriptor() plugins.Descriptor { {ID: "charge-orders", Method: "POST", Endpoint: epChargeStatsList, Description: "Per-session charging history for one charger (needs sn)."}, {ID: "charge-energy", Method: "POST", Endpoint: epEnergyAnalysis, Description: "Interval charging energy for a site's EV charger (needs siteId; optional sn, range, startDate, endDate)."}, {ID: "rfid-cards", Method: "POST", Endpoint: epRfidCards, Description: "RFID cards authorised on one charger (needs sn)."}, - {ID: "rfid-card-save", Method: "POST", Endpoint: epRfidSaveCard, Description: "Add a card to one charger, or rename one already on it, and answer with the list as it stands afterwards (needs sn, cardNumber; optional cardName). WRITES; the payload is inferred from the read view rather than documented (see rfidcards.go)."}, - {ID: "rfid-card-delete", Method: "POST", Endpoint: epRfidDeleteCard, Description: "Remove one card from a charger and answer with the list as it stands afterwards (needs sn, cardNumber). WRITES; the payload is inferred from the read view rather than documented (see rfidcards.go)."}, + {ID: "rfid-card-save", Method: "POST", Endpoint: epRfidSaveCard, Description: "Add a card, at the charger over MQTT and on the account over REST, and answer with the list as it stands afterwards (needs sn, cardNumber; optional cardName). WRITES; the charger's half is the app's own message, the account's is inferred (see rfidcards.go, mqttcards.go)."}, + {ID: "rfid-card-delete", Method: "POST", Endpoint: epRfidDeleteCard, Description: "Remove one card, both places it is held, and answer with the list afterwards (needs sn, cardNumber). WRITES; same two halves."}, + {ID: "rfid-card-scan", Method: "MQTT", Endpoint: "0108 a2=7", Description: "Open the charger's card reader for twenty seconds and answer with the card tapped, or with the fact that none was (needs sn). This is what \"add through the charger\" in the Anker app does."}, + {ID: "rfid-cards-charger", Method: "MQTT", Endpoint: "0104", Description: "The cards the charger itself holds, asked of the device rather than of the account (needs sn)."}, {ID: "ocpp-info", Method: "POST", Endpoint: epOcppInfo, Description: "OCPP endpoint source info for one charger (needs sn)."}, {ID: "ocpp-endpoints", Method: "POST", Endpoint: epOcppEndpoints, Description: "The OCPP endpoints Anker itself uses, with their source numbers."}, {ID: "devices", Method: "POST", Endpoint: epBindDevices, Description: "Bound devices on the account, incl. firmware version."}, @@ -468,6 +470,17 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa } return p.chargerState(ctx, pp.SiteID, pp.SN) } + // Reading a card at the charger and asking the charger for its list are the + // device's own messages, not endpoints (see mqttcards.go). + if action == "rfid-card-scan" || action == "rfid-cards-charger" { + if pp.SN == "" { + return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action) + } + if action == "rfid-card-scan" { + return p.mqttReadCard(ctx, pp.SN) + } + return p.mqttChargerCards(ctx, pp.SN) + } // The two card writes are a write followed by the read that checks it, so // neither fits the single-endpoint dispatch below either. if action == "rfid-card-save" || action == "rfid-card-delete" { diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go index 0b92e32..de5c4ca 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go @@ -275,6 +275,16 @@ type deviceState struct { settingsAt time.Time triggeredUntil time.Time + // The card half, kept beside the readings rather than in them: when the + // reader last reported a card and which, and when the charger last published + // its card list and what was in it. Each carries its own time because both + // are waited on — a value that was already there before the question was + // asked is not an answer to it. + cardReadAt time.Time + cardRead string + cardsAt time.Time + cards []string + // How many status requests this charger has been asked and not answered. // The request is cheap to send and is sent regardless; what it buys is the // right to wait a few seconds for the reply, and a charger whose firmware @@ -527,6 +537,18 @@ func (c *mqttConn) ingest(msg mqtt.Message) { st.values[k] = v } now := time.Now() + // The card frames are stamped separately: a tap and a card list are answers + // to questions this package asks one at a time, and each waits for its own. + if s, ok := values["rfidCardRead"].(string); ok && s != "" { + st.cardRead, st.cardReadAt = s, now + } else if msgType == msgEVCardRead { + // The window closed with nothing tapped. That is an answer too, and the + // wait must end on it rather than run to its own timeout. + st.cardRead, st.cardReadAt = "", now + } + if cards, ok := values["rfidCards"].([]string); ok { + st.cards, st.cardsAt = cards, now + } // Only a message this package can name counts as a report. An unnamed one // still leaves its fields behind, but it must not stamp settingsAt: that // timestamp is what a control command waits on to say the charger diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttcards.go b/API Server/internal/plugins/builtin/ankersolix/mqttcards.go new file mode 100644 index 0000000..9ae90c9 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/mqttcards.go @@ -0,0 +1,270 @@ +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 +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttcards_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttcards_test.go index cc0d846..fbad6b7 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttcards_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttcards_test.go @@ -152,3 +152,51 @@ func TestDecodeCardWriteCommands(t *testing.T) { } } } + +// The frames this connector sends to enrol a card, against the ones the Anker +// app was captured sending. They are compared byte for byte: neither carries a +// timestamp, so there is nothing in them that can differ between two senders, +// and anything that does differ is a difference the charger would see. +func TestCardCommandsMatchTheApp(t *testing.T) { + open, err := encodeFrame(msgEVPowerMode, []cmdField{ + rawField(0xa1, 0x22), + uintField(0xa2, powerModeReadCard), + }) + if err != nil { + t.Fatal(err) + } + if got, want := hex.EncodeToString(open), "ff09110003000f0108a10122a2020107c6"; got != want { + t.Errorf("reader-open frame:\n got %s\nwant %s (the app's own)", got, want) + } + + list, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)}) + if err != nil { + t.Fatal(err) + } + if got, want := hex.EncodeToString(list), "ff090d0003000f0104a1012270"; got != want { + t.Errorf("list-request frame:\n got %s\nwant %s (the app's own)", got, want) + } + + uid, _ := hex.DecodeString("04DFE672151A90") + for _, tc := range []struct { + name string + action uint8 + want string + }{ + {"add", cardWriteAdd, "ff091f0003000f0103a10122a2020101a3020101a4080404dfe672151a901c"}, + {"delete", cardWriteRemove, "ff091f0003000f0103a10122a2020102a3020101a4080404dfe672151a901f"}, + } { + frame, err := encodeFrame(msgEVCardWrite, []cmdField{ + rawField(0xa1, 0x22), + uintField(0xa2, tc.action), + uintField(0xa3, 1), + bytesField(0xa4, uid), + }) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if got := hex.EncodeToString(frame); got != tc.want { + t.Errorf("%s frame:\n got %s\nwant %s (the app's own)", tc.name, got, tc.want) + } + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go index ed08982..f3427fe 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go @@ -323,6 +323,12 @@ func varField(name byte, v uint32) cmdField { 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 { diff --git a/API Server/internal/plugins/builtin/ankersolix/rfidcards.go b/API Server/internal/plugins/builtin/ankersolix/rfidcards.go index c87ee81..42a6a5b 100644 --- a/API Server/internal/plugins/builtin/ankersolix/rfidcards.go +++ b/API Server/internal/plugins/builtin/ankersolix/rfidcards.go @@ -44,13 +44,16 @@ type rfidCard struct { // 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 that card is on the charger now - Cards []rfidCard `json:"cards"` - Response json.RawMessage `json:"response,omitempty"` - Detail string `json:"detail,omitempty"` // why the list could not be read back + 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. @@ -115,8 +118,14 @@ func (p *Plugin) rfidList(ctx context.Context, sn string) ([]rfidCard, error) { return parseRfidCards(body) } -// rfidSaveCard adds a card to the charger, or renames one already on it — the -// same endpoint does both, addressed by the number either way. +// 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 == "" { @@ -126,15 +135,16 @@ func (p *Plugin) rfidSaveCard(ctx context.Context, sn, number, name string) (jso 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 { - return nil, fmt.Errorf("anker-solix: saving card %s: %w", number, err) + 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) + return p.rfidAfterWrite(ctx, sn, "save", number, body, dev, devErr) } // rfidDeleteCard removes one card from the charger. The caller names the whole @@ -145,22 +155,32 @@ func (p *Plugin) rfidDeleteCard(ctx context.Context, sn, number string) (json.Ra 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 { - return nil, fmt.Errorf("anker-solix: deleting card %s: %w", number, err) + 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) + 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) (json.RawMessage, error) { +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() diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 9454511..2a2809c 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -345,6 +345,13 @@ export const api = { method: "POST", body: JSON.stringify({ cardNumber, cardName }), }), + // Opens the charger's own card reader and waits for a tap — the request is in + // flight for the whole twenty-second window, and answers whether or not a card + // arrived. + scanAnkerRfidCard: (sn) => + request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/scan`, { + method: "POST", + }), deleteAnkerRfidCard: (sn, cardNumber) => request( `/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/${encodeURIComponent(cardNumber)}`, diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 01f9960..c1599b8 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -69,6 +69,10 @@ "none": "Ingen kort er godkendt til denne lader.", "unsupported": "Tjenesten, som denne lader kommer fra, rapporterer ikke RFID-kort.", "add": "Tilføj kort", + "tap": "Hold kortet mod laderen", + "tapping": "Hold kortet mod læseren… {n}s", + "tapHint": "Læseren er åben. Hold kortet mod laderen.", + "tapNone": "Der blev ikke holdt et kort mod læseren, før den lukkede.", "addTitle": "Tilføj et kort", "remove": "Fjern", "removeConfirm": "Fjern {name} fra denne lader?", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index dfd2e20..11308e5 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -338,6 +338,10 @@ "none": "No cards are authorised on this charger.", "unsupported": "The service this charger came from does not report RFID cards.", "add": "Add card", + "tap": "Tap card at the charger", + "tapping": "Hold the card against the reader… {n}s", + "tapHint": "The reader is open. Hold the card against the charger.", + "tapNone": "No card was tapped before the reader closed.", "addTitle": "Add a card", "remove": "Remove", "removeConfirm": "Remove {name} from this charger?", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index adfd938..c07e9de 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -69,6 +69,10 @@ "none": "Na tej ładowarce nie autoryzowano żadnej karty.", "unsupported": "Usługa, z której pochodzi ta ładowarka, nie zgłasza kart RFID.", "add": "Dodaj kartę", + "tap": "Przyłóż kartę do ładowarki", + "tapping": "Przytrzymaj kartę przy czytniku… {n}s", + "tapHint": "Czytnik jest otwarty. Przytrzymaj kartę przy ładowarce.", + "tapNone": "Nie przyłożono karty, zanim czytnik się zamknął.", "addTitle": "Dodaj kartę", "remove": "Usuń", "removeConfirm": "Usunąć {name} z tej ładowarki?", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 5f823c9..f1c7b7d 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -1242,7 +1242,8 @@ const rfidCards = computed(() => { return rfidCardsFrom(view?.attrs); }); -const rfidBusy = ref(""); // the card being written, or "new" while one is added +const rfidBusy = ref(""); // the card being written, "new", or "scan" while the reader is open +const rfidCountdown = ref(0); // seconds left of the reader's window, while it is open const rfidError = ref(""); const newCardNumber = ref(""); const newCardName = ref(""); @@ -1272,6 +1273,35 @@ async function addRfidCard() { } } +// Asking the charger to read a card, which is what the Anker app's second way of +// adding one does: the reader opens for twenty seconds, and whatever is held +// against it comes back as a number. Nothing is written by this — the card lands +// in the form, and adding it is still a decision. +async function scanRfidCard() { + const sn = detailSn.value; + if (!sn || rfidBusy.value) return; + rfidBusy.value = "scan"; + rfidError.value = ""; + rfidCountdown.value = 20; + const tick = setInterval(() => { + rfidCountdown.value = Math.max(0, rfidCountdown.value - 1); + }, 1000); + try { + const res = await api.scanAnkerRfidCard(sn); + if (res?.tapped && res.card) { + newCardNumber.value = res.card; + } else { + rfidError.value = t("charging.rfid.tapNone"); + } + } catch (e) { + rfidError.value = e.message; + } finally { + clearInterval(tick); + rfidCountdown.value = 0; + rfidBusy.value = ""; + } +} + // Removing one asks first — a card that is gone can only be put back by whoever // still has it in their hand. async function removeRfidCard(card) { @@ -1877,6 +1907,18 @@ onMounted(async () => { {{ rfidBusy === "new" ? t("common.loading") : t("charging.rfid.add") }} + + +

{{ t("charging.rfid.tapHint") }}

{{ t("charging.rfid.inferred") }}