package ocpp import ( "context" "encoding/json" "net/http" "net/http/httptest" "path" "testing" "time" ) // ownModeServer stands up a CSMS behind an httptest server that upgrades every // /ocpp/{serial} connection and registers it in own mode. func ownModeServer(t *testing.T) (*CSMS, *httptest.Server, <-chan *Session) { t.Helper() csms := NewCSMS(nil) sessCh := make(chan *Session, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := Upgrade(w, r) if err != nil { t.Errorf("upgrade: %v", err) return } sess, err := csms.Accept(path.Base(r.URL.Path), ModeOwn, conn, nil) if err != nil { t.Errorf("accept: %v", err) _ = conn.Close() return } sessCh <- sess })) return csms, srv, sessCh } func TestOwnModeEndToEnd(t *testing.T) { csms, srv, sessCh := ownModeServer(t) defer srv.Close() cp := dialSimCP(t, wsURL(srv.URL, "/ocpp/CP-A5191"), nil) defer cp.close() ctx := context.Background() // Boot: the CSMS accepts and the charger appears connected. boot := cp.call(t, "BootNotification", map[string]any{ "chargePointVendor": "Anker", "chargePointModel": "A5191", "firmwareVersion": "1.2.3", }) assertStatus(t, boot, "Accepted") var sess *Session select { case sess = <-sessCh: case <-time.After(2 * time.Second): t.Fatal("CSMS never registered the session") } if _, ok := csms.SessionFor("CP-A5191"); !ok { t.Fatal("SessionFor should find the connected charger") } // StatusNotification + MeterValues update the tapped snapshot. cp.call(t, "StatusNotification", map[string]any{"connectorId": 1, "status": "Charging", "errorCode": "NoError"}) cp.call(t, "MeterValues", map[string]any{ "connectorId": 1, "meterValue": []any{map[string]any{ "timestamp": time.Now().UTC().Format(time.RFC3339), "sampledValue": []any{map[string]any{"value": "1500", "measurand": "Energy.Active.Import.Register", "unit": "Wh"}}, }}, }) snap := sess.Snapshot() if snap.Vendor != "Anker" || snap.Firmware != "1.2.3" { t.Errorf("boot fields not tapped: %+v", snap) } if snap.ConnectorStatus != "Charging" { t.Errorf("connector status = %q, want Charging", snap.ConnectorStatus) } if snap.MeterWh != 1500 { t.Errorf("meter = %d Wh, want 1500", snap.MeterWh) } // StartTransaction: the CSMS assigns a transaction id. st := cp.call(t, "StartTransaction", map[string]any{"connectorId": 1, "idTag": "TAG", "meterStart": 0, "timestamp": time.Now().UTC().Format(time.RFC3339)}) var startResp struct { TransactionID int `json:"transactionId"` } _ = json.Unmarshal(st.Payload, &startResp) if startResp.TransactionID == 0 { t.Fatal("StartTransaction should return a non-zero transaction id") } // Now drive every control command and confirm the charger sees it + Accepts. cases := []struct { name string run func() (string, error) action string }{ {"RemoteStart", func() (string, error) { return sess.RemoteStartTransaction(ctx, "TAG", 1) }, "RemoteStartTransaction"}, {"SetCurrentLimit", func() (string, error) { return sess.SetCurrentLimit(ctx, 1, 16) }, "SetChargingProfile"}, {"ClearChargingProfile", func() (string, error) { return sess.ClearChargingProfile(ctx, 1) }, "ClearChargingProfile"}, {"ChangeAvailability", func() (string, error) { return sess.ChangeAvailability(ctx, 0, false) }, "ChangeAvailability"}, {"UnlockConnector", func() (string, error) { return sess.UnlockConnector(ctx, 1) }, "UnlockConnector"}, {"TriggerMessage", func() (string, error) { return sess.TriggerMessage(ctx, "StatusNotification", 1) }, "TriggerMessage"}, {"ChangeConfiguration", func() (string, error) { return sess.ChangeConfiguration(ctx, "HeartbeatInterval", "60") }, "ChangeConfiguration"}, {"Reset", func() (string, error) { return sess.Reset(ctx, false) }, "Reset"}, {"RemoteStop", func() (string, error) { return sess.RemoteStopTransaction(ctx, startResp.TransactionID) }, "RemoteStopTransaction"}, } for _, c := range cases { status, err := c.run() if err != nil { t.Fatalf("%s: %v", c.name, err) } if status != "Accepted" { t.Errorf("%s: status = %q, want Accepted", c.name, status) } got := cp.waitRecv(t) if got.Action != c.action { t.Errorf("%s: charger received %q, want %q", c.name, got.Action, c.action) } } // GetConfiguration returns the charger's keys. conf, err := sess.GetConfiguration(ctx, nil) if err != nil { t.Fatalf("GetConfiguration: %v", err) } cp.waitRecv(t) if len(conf.ConfigurationKey) == 0 || conf.ConfigurationKey[0].Key != "HeartbeatInterval" { t.Errorf("GetConfiguration = %+v", conf) } } func TestSessionForUnknownCharger(t *testing.T) { csms := NewCSMS(nil) if _, ok := csms.SessionFor("nope"); ok { t.Fatal("SessionFor should be false for an unconnected charger") } } func assertStatus(t *testing.T, m Message, want string) { t.Helper() var r StatusResponse if err := json.Unmarshal(m.Payload, &r); err != nil { t.Fatalf("decode status: %v", err) } if r.Status != want { t.Fatalf("status = %q, want %q", r.Status, want) } }