Compare commits
2
Commits
62b0a29598
...
7176867eb3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7176867eb3 | ||
|
|
4c73d4ac05 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -122,3 +122,81 @@ func TestCardReadWithoutACard(t *testing.T) {
|
||||
t.Errorf("rfidCardRead = %v, want it absent", values["rfidCardRead"])
|
||||
}
|
||||
}
|
||||
|
||||
// The two writes the Anker app sent, caught on the charger's own command topic:
|
||||
// the same card removed and then added back, the action the only difference.
|
||||
func TestDecodeCardWriteCommands(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, hex string
|
||||
action float64
|
||||
}{
|
||||
{"delete", "ff091f0003000f0103a10122a2020102a3020101a4080404dfe672151a901f", 2},
|
||||
{"add", "ff091f0003000f0103a10122a2020101a3020101a4080404dfe672151a901c", 1},
|
||||
} {
|
||||
data, err := hex.DecodeString(tc.hex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgType, values, err := decodeFrame(data)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: decodeFrame: %v", tc.name, err)
|
||||
}
|
||||
if msgType != msgEVCardWrite {
|
||||
t.Fatalf("%s: msgType = %q, want %q", tc.name, msgType, msgEVCardWrite)
|
||||
}
|
||||
if got, _ := values["cardWriteAction"].(float64); got != tc.action {
|
||||
t.Errorf("%s: cardWriteAction = %v, want %v", tc.name, values["cardWriteAction"], tc.action)
|
||||
}
|
||||
if got := values["cardWritten"]; got != "04DFE672151A90" {
|
||||
t.Errorf("%s: cardWritten = %v, want the UID", tc.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,20 @@ const (
|
||||
// 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
|
||||
@@ -99,7 +113,14 @@ const (
|
||||
// closes with nothing tapped, which is what makes the UID field the event.
|
||||
msgEVCardRead = "0908"
|
||||
|
||||
// powerModeRestart is the only value the power-mode command is known to take.
|
||||
// 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
|
||||
)
|
||||
@@ -232,6 +253,13 @@ 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"},
|
||||
@@ -244,9 +272,10 @@ var evOcppInfo = map[byte]mqttField{
|
||||
// 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,
|
||||
msgEVCards: evCards,
|
||||
msgEVOcppInfo: evOcppInfo,
|
||||
msgEVCardRead: evCardRead,
|
||||
msgEVCardWrite: evCardWrite,
|
||||
}
|
||||
|
||||
// evMessages selects a field map by message type. A type absent from here is one
|
||||
@@ -294,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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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") }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- The other way to fill that field in: hold the card against the
|
||||
charger. The reader opens for twenty seconds and the number
|
||||
arrives on its own, which beats reading it off the card. -->
|
||||
<button
|
||||
type="button"
|
||||
class="dh-btn dh-btn-ghost mt-2 w-full text-xs"
|
||||
:disabled="rfidBusy !== ''"
|
||||
@click="scanRfidCard"
|
||||
>
|
||||
{{ rfidBusy === "scan" ? t("charging.rfid.tapping", { n: rfidCountdown }) : t("charging.rfid.tap") }}
|
||||
</button>
|
||||
<p v-if="rfidBusy === 'scan'" class="mt-1 text-[11px] text-muted">{{ t("charging.rfid.tapHint") }}</p>
|
||||
</form>
|
||||
<p class="mt-3 text-[11px] text-muted">{{ t("charging.rfid.inferred") }}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user