diff --git a/.changeset/sim-ocpp-evify.md b/.changeset/sim-ocpp-evify.md new file mode 100644 index 00000000..c5283c14 --- /dev/null +++ b/.changeset/sim-ocpp-evify.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Add `sim-ocpp`, an OCPP 1.6J / 2.0.1 charge-point simulator covering the recorded Evify charger range. Tesla Wall Connector is listed but skipped: it has no OCPP. diff --git a/Makefile b/Makefile index d984078c..968555dc 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ # make clean — remove all build artifacts .PHONY: help test compose-migration-test container-boundary-test release-workflow-test build build-arm64 build-amd64 build-windows-amd64 release release-linux release-windows \ - run-sim dev fmt vet clean e2e ci ci-ui ci-hw-pi docs \ + run-sim sim-ocpp dev fmt vet clean e2e ci ci-ui ci-hw-pi docs \ verify verify-all install-hooks driver-repository-validate driver-versions \ drivers drivers-present driver-versions-across-pin @@ -37,7 +37,8 @@ help: @echo " release-linux linux tarballs in release/" @echo " release-windows Windows zip in release/ (UCRT64 compiler)" @echo " release all archives (all target compilers required)" - @echo " run-sim start Ferroamp + Sungrow simulators" + @echo " run-sim start Ferroamp + Sungrow + PCS simulators" + @echo " sim-ocpp dial Evify OCPP chargers at a running FTW" @echo " dev start sims + main app against config.local.yaml" @echo " e2e run the full-stack e2e test" @echo " verify fast pre-commit: test + compose + vet + build" @@ -169,6 +170,7 @@ build: cd go && go build -tags=$(GO_TAGS) -ldflags="$(LDFLAGS)" -o ../bin/sim-ferroamp ./cmd/sim-ferroamp cd go && go build -tags=$(GO_TAGS) -ldflags="$(LDFLAGS)" -o ../bin/sim-sungrow ./cmd/sim-sungrow cd go && go build -tags=$(GO_TAGS) -ldflags="$(LDFLAGS)" -o ../bin/sim-pcs ./cmd/sim-pcs + cd go && go build -tags=$(GO_TAGS) -ldflags="$(LDFLAGS)" -o ../bin/sim-ocpp ./cmd/sim-ocpp @ls -la bin/ build-arm64: @@ -251,6 +253,12 @@ run-sim: (cd go && go run ./cmd/sim-pcs) & \ wait +# Charge-point client: needs a running FTW with ocpp.enabled (see +# config.local.example.yaml). Tesla Wall Connector is in the catalog but has +# no OCPP and is skipped. +sim-ocpp: + cd go && go run ./cmd/sim-ocpp -all -plug + dev: config.local.yaml @mkdir -p dev-data @echo "Starting sims + main app (Ctrl+C to stop)..." diff --git a/config.local.example.yaml b/config.local.example.yaml index 5741d378..2e5f36f8 100644 --- a/config.local.example.yaml +++ b/config.local.example.yaml @@ -42,6 +42,18 @@ drivers: api: port: 8080 +# Built-in OCPP Central System. sim-ocpp dials this with the same credentials. +# Chargers appear pending on Settings → Chargers until a charger entry adopts +# them. Tesla Wall Connector is not OCPP — it uses tesla_wall_connector.lua. +ocpp: + enabled: true + bind: 127.0.0.1 + port: 8887 + port_v201: 8888 + username: ftw + password: sim-ocpp + heartbeat_interval_s: 60 + state: # `make dev` starts the Go process from go/, so keep disposable state in # the repo's ignored dev-data/ directory. diff --git a/docs/development.md b/docs/development.md index 908fbfb2..46116b6d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,6 +8,7 @@ them. ```bash make dev # simulators + core, creates config.local.yaml when missing +make sim-ocpp # Evify OCPP charge points against a running FTW make test # Go and Python suites make e2e # explicit full-stack simulator test npm test # web tests diff --git a/docs/ocpp.md b/docs/ocpp.md index 2bc006f2..0b400173 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -273,6 +273,32 @@ Two traps worth knowing: For the full commissioning and factory-reset detail per model, see the bench guide in the device-drivers repository. +## Local simulation + +`go/cmd/sim-ocpp` dials the built-in Central System as every OCPP charger +in the Evify catalogue recorded on 11 September 2026 (Easee Charge Up/Max, Zaptec Go/Go 2, NexBlue Edge 2, +go-e Gemini Flex 2.0, Charge Amps Luna/Halo/Aura/Dawn, Wallbox Pulsar Max, +DEFA Power). Tesla Wall Connector is in that catalog but has no OCPP — FTW +already talks to it over local HTTP. + +```bash +make dev # enable ocpp in config.local.yaml (the example template does) +make sim-ocpp # all OCPP models plug in and start metering +``` + +Vendor quirks FTW already defends against are encoded: Charge Amps ACK a +remote stop and keep charging, Aura refuses a connector-0 profile, Zaptec +dials as its serial. Run the Core integration test explicitly: + +```bash +cd go +FTW_E2E=1 go test ./test/e2e -run 'Test(EvifyOCPPInventoryE2E|PendingEvifyChargerIsQuarantined)' -count=1 -timeout 120s +``` + +The test covers simulated boot, adoption, current limits and pause through +Core. It does not prove behavior on those physical charger models. Ordinary +unit tests keep the protocol sequence and response-lag regressions. + ## Can this charger be steered? Not every OCPP charger accepts control. FTW asks each one, once, shortly after diff --git a/go/cmd/sim-ocpp/main.go b/go/cmd/sim-ocpp/main.go new file mode 100644 index 00000000..bef67bd4 --- /dev/null +++ b/go/cmd/sim-ocpp/main.go @@ -0,0 +1,198 @@ +// sim-ocpp: OCPP 1.6J / 2.0.1 charge-point simulator for Evify's in-stock +// home chargers. It dials FTW's built-in Central System the same way the +// hardware does: BootNotification, status, meter values, charging profiles. +// +// go run ./cmd/sim-ocpp -list +// go run ./cmd/sim-ocpp -all -plug +// go run ./cmd/sim-ocpp -model charge-amps-aura -plug +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/srcfl/ftw/go/cmd/sim-ocpp/ocppcp" +) + +func main() { + cs := flag.String("cs", "ws://127.0.0.1:8887", "OCPP 1.6J Central System URL") + cs201 := flag.String("cs-v201", "ws://127.0.0.1:8888", "OCPP 2.0.1 Central System URL") + user := flag.String("user", "ftw", "basic-auth username (empty to skip)") + pass := flag.String("pass", "sim-ocpp", "basic-auth password") + model := flag.String("model", "", "catalog slug (see -list)") + all := flag.Bool("all", false, "dial every OCPP model in the Evify inventory") + plug := flag.Bool("plug", false, "plug a car in after boot") + list := flag.Bool("list", false, "print the catalog and exit") + control := flag.String("control", "127.0.0.1:8890", "control HTTP bind; empty to disable") + tau := flag.Duration("tau", 500*time.Millisecond, "current-response lag") + tick := flag.Duration("tick", time.Second, "meter interval") + flag.Parse() + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + if *list { + printCatalog() + return + } + + var models []ocppcp.Model + switch { + case *all: + models = ocppcp.OCPPModels() + case *model != "": + m, ok := ocppcp.Lookup(*model) + if !ok { + slog.Error("unknown model", "model", *model) + os.Exit(2) + } + if !m.SpeaksOCPP() { + slog.Error("this charger has no OCPP; FTW talks to it over local HTTP", + "model", m.ID, "driver", "tesla_wall_connector") + os.Exit(2) + } + models = []ocppcp.Model{m} + default: + fmt.Fprintln(os.Stderr, "need -all or -model; use -list to see the catalog") + os.Exit(2) + } + + opts := ocppcp.DialOpts{ + URL16: *cs, + URL201: *cs201, + Username: *user, + Password: *pass, + TauS: tau.Seconds(), + } + + sims := make([]*ocppcp.Sim, 0, len(models)) + for _, m := range models { + sim := ocppcp.New(m) + if err := sim.Dial(opts); err != nil { + slog.Error("dial", "charger", m.ID, "err", err) + os.Exit(1) + } + if err := sim.Boot(); err != nil { + slog.Error("boot", "charger", m.ID, "err", err) + os.Exit(1) + } + slog.Info("booted", "id", sim.DialID(), "vendor", m.Vendor, "model", m.Name, "protocol", m.Protocol) + if *plug { + if err := sim.Plug(); err != nil { + slog.Error("plug", "charger", m.ID, "err", err) + os.Exit(1) + } + } + sims = append(sims, sim) + } + + if *control != "" { + mux := http.NewServeMux() + mux.HandleFunc("GET /state", func(w http.ResponseWriter, _ *http.Request) { + marshalState(w, sims) + }) + mux.HandleFunc("POST /charger/{id}/plug", func(w http.ResponseWriter, r *http.Request) { + sim := findSim(sims, r.PathValue("id")) + if sim == nil { + http.NotFound(w, r) + return + } + if err := sim.Plug(); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(204) + }) + mux.HandleFunc("POST /charger/{id}/unplug", func(w http.ResponseWriter, r *http.Request) { + sim := findSim(sims, r.PathValue("id")) + if sim == nil { + http.NotFound(w, r) + return + } + if err := sim.Unplug(); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(204) + }) + go func() { + slog.Info("control listening", "addr", *control) + if err := http.ListenAndServe(*control, mux); err != nil { + slog.Error("control server", "err", err) + } + }() + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + tk := time.NewTicker(*tick) + defer tk.Stop() + last := time.Now() + for { + select { + case <-stop: + for _, sim := range sims { + sim.Close() + } + return + case now := <-tk.C: + dt := now.Sub(last) + last = now + for _, sim := range sims { + sim.Tick(dt) + if err := sim.Report(); err != nil { + slog.Warn("meter", "charger", sim.Model.ID, "err", err) + } + } + } + } +} + +func findSim(sims []*ocppcp.Sim, id string) *ocppcp.Sim { + for _, sim := range sims { + if sim.Model.ID == id || sim.DialID() == id || sim.Model.Serial == id { + return sim + } + } + return nil +} + +func marshalState(w http.ResponseWriter, sims []*ocppcp.Sim) { + type row struct { + ID string `json:"id"` + DialID string `json:"dial_id"` + Vendor string `json:"vendor"` + Model string `json:"model"` + Protocol string `json:"protocol"` + Plugged bool `json:"plugged"` + PowerW float64 `json:"power_w"` + LimitA float64 `json:"limit_a"` + } + out := make([]row, 0, len(sims)) + for _, sim := range sims { + out = append(out, row{ + ID: sim.Model.ID, DialID: sim.DialID(), + Vendor: sim.Model.Vendor, Model: sim.Model.Name, + Protocol: string(sim.Model.Protocol), + Plugged: sim.Plugged(), PowerW: sim.PowerW(), LimitA: sim.LimitA(), + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +func printCatalog() { + fmt.Printf("%-24s %-14s %-18s %-8s %6s %s\n", "ID", "VENDOR", "MODEL", "OCPP", "kW", "IDENTITY") + for _, m := range ocppcp.Inventory() { + kw := m.MaxW / 1000 + fmt.Printf("%-24s %-14s %-18s %-8s %5.0f %s\n", m.ID, m.Vendor, m.Name, m.Protocol, kw, m.DialID()) + } + fmt.Println() + fmt.Println("Tesla Wall Connector has no OCPP; skip it or use the tesla_wall_connector driver.") +} diff --git a/go/cmd/sim-ocpp/ocppcp/catalog.go b/go/cmd/sim-ocpp/ocppcp/catalog.go new file mode 100644 index 00000000..93b17954 --- /dev/null +++ b/go/cmd/sim-ocpp/ocppcp/catalog.go @@ -0,0 +1,228 @@ +// Package ocppcp is an OCPP charge-point simulator. +// +// The catalog is Evify's published home-charger range as of 2026-09-11 +// (https://www.evify.se/jämför-laddboxar/ and each product page, all marked +// in stock). Every model that speaks OCPP can dial FTW's built-in Central +// System; Tesla Wall Connector is listed so the inventory is complete and +// skipped, because it has no OCPP and already has a local HTTP driver. +// +// Quirks are only the ones FTW has already had to defend against in +// go/internal/ocpp — Charge Amps RemoteStop, connector-0 refusal on dual- +// socket Aura, Absolute profiles with no start, Zaptec appending its serial +// as the identity. Other vendors run as spec-compliant 1.6J (DEFA Power as +// 2.0.1, which is what Evify advertises for it). +package ocppcp + +import "strings" + +// Protocol is how a catalog model talks to FTW. +type Protocol string + +const ( + ProtocolOCPP16 Protocol = "ocpp1.6" + ProtocolOCPP201 Protocol = "ocpp2.0.1" + ProtocolHTTP Protocol = "http-local" +) + +const ( + // SiteVoltage is the voltage FTW's EV commands assume. + SiteVoltage = 230.0 +) + +// Quirks are vendor disagreements with the specification that change what +// the simulator does with a real OCPP message. Unset means spec-compliant. +type Quirks struct { + // RejectConnectorZero refuses a TxDefaultProfile on connector 0. Dual- + // socket Charge Amps Aura units (and some others) read connector 0 as + // ChargePointMaxProfile-only; FTW retries on connector 1. + RejectConnectorZero bool + // IgnoreRemoteStop acknowledges RemoteStopTransaction and keeps charging. + // Charge Amps hardware does this in the field, which is why FTW pauses + // with a 0 A profile instead. + IgnoreRemoteStop bool + // IgnoreAbsoluteWithoutStart answers Accepted to an Absolute schedule + // that has no startSchedule, then does not apply it. A charger that + // parses the missing timestamp strictly treats the profile as not yet + // active and charges on at full rate. + IgnoreAbsoluteWithoutStart bool + // IdentityIsSerial makes the charge-point id the hardware serial. + // Zaptec appends the serial to the backend URL; operators must enter + // the URL without it. + IdentityIsSerial bool +} + +// Model is one charger in the Evify inventory. +type Model struct { + // ID is the CLI slug and, unless IdentityIsSerial, the OCPP identity. + ID string + Vendor string + Name string + Serial string + Firmware string + Protocol Protocol + // MaxW is the advertised maximum charge power. + MaxW float64 + // Phases is the installed supply. Evify's range is 1-or-3; the sim + // runs 3-phase, which is the common Swedish install. + Phases int + Connectors int + RFID bool + Tethered bool + SourceURL string + Quirks Quirks +} + +// DialID is the last URL segment this model connects with. +func (m Model) DialID() string { + if m.Quirks.IdentityIsSerial { + return m.Serial + } + return m.ID +} + +// MaxAmps is the per-phase current at MaxW on SiteVoltage. +func (m Model) MaxAmps() float64 { + phases := m.Phases + if phases <= 0 { + phases = 3 + } + return m.MaxW / (SiteVoltage * float64(phases)) +} + +// SpeaksOCPP reports whether this model can dial FTW's OCPP server. +func (m Model) SpeaksOCPP() bool { + return m.Protocol == ProtocolOCPP16 || m.Protocol == ProtocolOCPP201 +} + +func chargeAmpsQuirks(aura bool) Quirks { + q := Quirks{ + IgnoreRemoteStop: true, + IgnoreAbsoluteWithoutStart: true, + } + if aura { + q.RejectConnectorZero = true + } + return q +} + +func zaptecQuirks() Quirks { + return Quirks{IdentityIsSerial: true} +} + +// Inventory is Evify's published home-charger range. Tesla is included so +// callers that ask "every charger in the warehouse" see the one that cannot +// speak OCPP, rather than silently dropping it. +func Inventory() []Model { + return []Model{ + { + ID: "easee-charge-up", Vendor: "Easee", Name: "Charge Up", + Serial: "EH-UP-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, + SourceURL: "https://www.evify.se/produkter/easee-charge-up/", + }, + { + ID: "easee-charge-max", Vendor: "Easee", Name: "Charge Max", + Serial: "EH-MAX-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, + SourceURL: "https://www.evify.se/produkter/easee-charge-max/", + }, + { + ID: "zaptec-go", Vendor: "Zaptec", Name: "Go", + Serial: "ZAPGO22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, + Quirks: zaptecQuirks(), + SourceURL: "https://www.evify.se/produkter/zaptec-go/", + }, + { + ID: "zaptec-go-2", Vendor: "Zaptec", Name: "Go 2", + Serial: "ZAPGO222001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, + Quirks: zaptecQuirks(), + SourceURL: "https://www.evify.se/produkter/zaptec-go-2/", + }, + { + ID: "nexblue-edge-2", Vendor: "NexBlue", Name: "Edge 2", + Serial: "NB-EDGE2-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, + SourceURL: "https://www.evify.se/produkter/nexblue-edge-2/", + }, + { + ID: "go-e-gemini-flex", Vendor: "go-e", Name: "Gemini Flex 2.0", + Serial: "GOE-GF-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, + SourceURL: "https://www.evify.se/produkter/go-e-gemini-flex/", + }, + { + ID: "charge-amps-luna", Vendor: "Charge Amps", Name: "Luna", + Serial: "CA-LUNA-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, + Quirks: chargeAmpsQuirks(false), + SourceURL: "https://www.evify.se/produkter/charge-amps-luna/", + }, + { + ID: "charge-amps-halo", Vendor: "Charge Amps", Name: "Halo", + Serial: "CA-HALO-11001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 11000, Phases: 3, Connectors: 1, RFID: true, Tethered: true, + Quirks: chargeAmpsQuirks(false), + SourceURL: "https://www.evify.se/produkter/charge-amps-halo/", + }, + { + ID: "charge-amps-aura", Vendor: "Charge Amps", Name: "Aura", + Serial: "CA-AURA-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 2, RFID: true, + Quirks: chargeAmpsQuirks(true), + SourceURL: "https://www.evify.se/produkter/charge-amps-aura/", + }, + { + ID: "charge-amps-dawn", Vendor: "Charge Amps", Name: "Dawn", + Serial: "CA-DAWN-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, + Quirks: chargeAmpsQuirks(false), + SourceURL: "https://www.evify.se/produkter/charge-amps-dawn/", + }, + { + ID: "wallbox-pulsar-max", Vendor: "Wallbox", Name: "Pulsar Max", + Serial: "WB-PM-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP16, MaxW: 22000, Phases: 3, Connectors: 1, Tethered: true, + SourceURL: "https://www.evify.se/produkter/wallbox-pulsar-max/", + }, + { + ID: "defa-power", Vendor: "DEFA", Name: "Power", + Serial: "DEFA-PWR-22001", Firmware: "sim-1.0", + Protocol: ProtocolOCPP201, MaxW: 22000, Phases: 3, Connectors: 1, RFID: true, Tethered: true, + SourceURL: "https://www.evify.se/produkter/defa-power/", + }, + { + ID: "tesla-wall-connector", Vendor: "Tesla", Name: "Wall Connector", + Serial: "TWC-22001", Firmware: "sim-1.0", + Protocol: ProtocolHTTP, MaxW: 22000, Phases: 3, Connectors: 1, Tethered: true, + SourceURL: "https://www.evify.se/produkter/tesla-wall-connector/", + }, + } +} + +// OCPPModels is the subset that can dial FTW over OCPP. +func OCPPModels() []Model { + all := Inventory() + out := make([]Model, 0, len(all)) + for _, m := range all { + if m.SpeaksOCPP() { + out = append(out, m) + } + } + return out +} + +// Lookup finds a model by CLI slug, DialID, or serial. Case-insensitive. +func Lookup(id string) (Model, bool) { + want := strings.ToLower(strings.TrimSpace(id)) + if want == "" { + return Model{}, false + } + for _, m := range Inventory() { + if strings.ToLower(m.ID) == want || strings.ToLower(m.DialID()) == want || strings.ToLower(m.Serial) == want { + return m, true + } + } + return Model{}, false +} diff --git a/go/cmd/sim-ocpp/ocppcp/catalog_test.go b/go/cmd/sim-ocpp/ocppcp/catalog_test.go new file mode 100644 index 00000000..8b18a764 --- /dev/null +++ b/go/cmd/sim-ocpp/ocppcp/catalog_test.go @@ -0,0 +1,141 @@ +package ocppcp + +import ( + "testing" + "time" +) + +func TestInventoryCoversEvifyRange(t *testing.T) { + want := []string{ + "easee-charge-up", "easee-charge-max", + "zaptec-go", "zaptec-go-2", + "nexblue-edge-2", "go-e-gemini-flex", + "charge-amps-luna", "charge-amps-halo", "charge-amps-aura", "charge-amps-dawn", + "wallbox-pulsar-max", "defa-power", + "tesla-wall-connector", + } + got := Inventory() + if len(got) != len(want) { + t.Fatalf("inventory len=%d, want %d", len(got), len(want)) + } + seen := map[string]bool{} + dials := map[string]bool{} + serials := map[string]bool{} + for i, m := range got { + if m.ID != want[i] { + t.Errorf("inventory[%d]=%s, want %s", i, m.ID, want[i]) + } + if seen[m.ID] { + t.Errorf("duplicate id %s", m.ID) + } + seen[m.ID] = true + if m.Vendor == "" || m.Name == "" || m.Serial == "" || m.MaxW <= 0 || m.Phases != 3 { + t.Errorf("%s incomplete: %+v", m.ID, m) + } + if m.SpeaksOCPP() { + if dials[m.DialID()] { + t.Errorf("duplicate DialID %s", m.DialID()) + } + dials[m.DialID()] = true + } + if serials[m.Serial] { + t.Errorf("duplicate serial %s", m.Serial) + } + serials[m.Serial] = true + } + + ocpp := OCPPModels() + if len(ocpp) != len(got)-1 { + t.Fatalf("OCPP models=%d, want inventory minus Tesla", len(ocpp)) + } + for _, m := range ocpp { + if !m.SpeaksOCPP() { + t.Errorf("%s listed as OCPP but protocol=%s", m.ID, m.Protocol) + } + } + + tesla, ok := Lookup("tesla-wall-connector") + if !ok || tesla.SpeaksOCPP() || tesla.Protocol != ProtocolHTTP { + t.Fatalf("Tesla must be catalogued as local HTTP, got %+v ok=%v", tesla, ok) + } + + zap, ok := Lookup("zaptec-go") + if !ok || zap.DialID() != zap.Serial || zap.DialID() == zap.ID { + t.Fatalf("Zaptec must dial as its serial, got DialID=%s id=%s serial=%s", zap.DialID(), zap.ID, zap.Serial) + } + + defa, ok := Lookup("defa-power") + if !ok || defa.Protocol != ProtocolOCPP201 { + t.Fatalf("DEFA Power should speak 2.0.1 as advertised, got %+v", defa) + } + + for _, m := range OCPPModels() { + if n := len(idTagFor(m)); n > 20 { + t.Errorf("%s idTag %q is %d runes, OCPP 1.6 max is 20", m.ID, idTagFor(m), n) + } + } + + halo, _ := Lookup("charge-amps-halo") + if halo.MaxW != 11000 || !halo.Tethered { + t.Errorf("Halo is the 11 kW tethered unit, got %+v", halo) + } + aura, _ := Lookup("charge-amps-aura") + if aura.Connectors != 2 || !aura.Quirks.RejectConnectorZero { + t.Errorf("Aura is the dual-socket unit that refuses connector 0, got %+v", aura) + } +} + +func TestPhysicsSettlesInstantlyWhenTauZero(t *testing.T) { + p := newPhysics(Model{MaxW: 22000, Phases: 3}, 0) + p.Plugged = true + p.LimitA = 16 + p.Tick(time.Second) + want := 16 * SiteVoltage * 3 + if p.PowerW() != want { + t.Fatalf("power=%v, want %v", p.PowerW(), want) + } + p.LimitA = 0 + p.Tick(time.Second) + if p.PowerW() != 0 { + t.Fatalf("paused power=%v, want 0", p.PowerW()) + } +} + +func TestChargeAmpsRemoteStopKeepsCharging(t *testing.T) { + m, _ := Lookup("charge-amps-luna") + s := New(m) + s.mu.Lock() + s.physics.Plugged = true + s.txID = 9 + s.mu.Unlock() + if _, err := s.OnRemoteStopTransaction(nil); err != nil { + t.Fatal(err) + } + if !s.Plugged() || s.txID != 9 { + t.Fatal("Charge Amps RemoteStop must ACK and leave the transaction open") + } +} + +func TestAuraRejectsConnectorZero(t *testing.T) { + m, _ := Lookup("charge-amps-aura") + d0 := decideProfile(m.Quirks, 0, "Relative", false, 16) + if d0.applied || d0.status != "Rejected" { + t.Fatalf("connector 0: %+v", d0) + } + d1 := decideProfile(m.Quirks, 1, "Relative", false, 16) + if !d1.applied || d1.status != "Accepted" { + t.Fatalf("connector 1: %+v", d1) + } +} + +func TestAbsoluteWithoutStartIsAcceptedAndIgnored(t *testing.T) { + m, _ := Lookup("charge-amps-dawn") + d := decideProfile(m.Quirks, 1, "Absolute", false, 16) + if d.status != "Accepted" || d.applied { + t.Fatalf("Absolute without start must Accept and not apply, got %+v", d) + } + rel := decideProfile(m.Quirks, 1, "Relative", false, 16) + if !rel.applied { + t.Fatal("Relative must apply") + } +} diff --git a/go/cmd/sim-ocpp/ocppcp/handlers16.go b/go/cmd/sim-ocpp/ocppcp/handlers16.go new file mode 100644 index 00000000..02c95c2a --- /dev/null +++ b/go/cmd/sim-ocpp/ocppcp/handlers16.go @@ -0,0 +1,105 @@ +package ocppcp + +import ( + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" +) + +const featureProfiles = "Core,SmartCharging,RemoteTrigger" + +func (s *Sim) OnChangeAvailability(*core.ChangeAvailabilityRequest) (*core.ChangeAvailabilityConfirmation, error) { + return core.NewChangeAvailabilityConfirmation(core.AvailabilityStatusAccepted), nil +} + +func (s *Sim) OnChangeConfiguration(*core.ChangeConfigurationRequest) (*core.ChangeConfigurationConfirmation, error) { + return core.NewChangeConfigurationConfirmation(core.ConfigurationStatusAccepted), nil +} + +func (s *Sim) OnClearCache(*core.ClearCacheRequest) (*core.ClearCacheConfirmation, error) { + return core.NewClearCacheConfirmation(core.ClearCacheStatusAccepted), nil +} + +func (s *Sim) OnDataTransfer(*core.DataTransferRequest) (*core.DataTransferConfirmation, error) { + return core.NewDataTransferConfirmation(core.DataTransferStatusAccepted), nil +} + +func (s *Sim) OnGetConfiguration(req *core.GetConfigurationRequest) (*core.GetConfigurationConfirmation, error) { + v := featureProfiles + key := core.ConfigurationKey{Key: "SupportedFeatureProfiles", Readonly: true, Value: &v} + if req == nil || len(req.Key) == 0 { + return core.NewGetConfigurationConfirmation([]core.ConfigurationKey{key}), nil + } + var found []core.ConfigurationKey + var unknown []string + for _, k := range req.Key { + if k == "SupportedFeatureProfiles" { + found = append(found, key) + } else { + unknown = append(unknown, k) + } + } + conf := core.NewGetConfigurationConfirmation(found) + conf.UnknownKey = unknown + return conf, nil +} + +func (s *Sim) OnRemoteStartTransaction(*core.RemoteStartTransactionRequest) (*core.RemoteStartTransactionConfirmation, error) { + return core.NewRemoteStartTransactionConfirmation(types.RemoteStartStopStatusAccepted), nil +} + +func (s *Sim) OnRemoteStopTransaction(*core.RemoteStopTransactionRequest) (*core.RemoteStopTransactionConfirmation, error) { + if s.Model.Quirks.IgnoreRemoteStop { + // Charge Amps: ACK and keep the transaction open. FTW never sends + // this (it pauses at 0 A), but the quirk has to be here so a test + // that does send it sees the same lie the hardware tells. + return core.NewRemoteStopTransactionConfirmation(types.RemoteStartStopStatusAccepted), nil + } + go func() { _ = s.stopTx() }() + return core.NewRemoteStopTransactionConfirmation(types.RemoteStartStopStatusAccepted), nil +} + +func (s *Sim) OnReset(*core.ResetRequest) (*core.ResetConfirmation, error) { + return core.NewResetConfirmation(core.ResetStatusAccepted), nil +} + +func (s *Sim) OnUnlockConnector(*core.UnlockConnectorRequest) (*core.UnlockConnectorConfirmation, error) { + return core.NewUnlockConnectorConfirmation(core.UnlockStatusUnlocked), nil +} + +func (s *Sim) OnSetChargingProfile(req *smartcharging.SetChargingProfileRequest) (*smartcharging.SetChargingProfileConfirmation, error) { + d := decide16(s.Model.Quirks, req) + connector := 0 + kind := "" + if req != nil { + connector = req.ConnectorId + if req.ChargingProfile != nil { + kind = string(req.ChargingProfile.ChargingProfileKind) + } + } + s.recordAttempt(ProfileAttempt{ + ConnectorID: connector, + Kind: kind, + LimitA: d.limitA, + Applied: d.applied, + Status: d.status, + }) + return smartcharging.NewSetChargingProfileConfirmation(smartcharging.ChargingProfileStatus(d.status)), nil +} + +func (s *Sim) OnClearChargingProfile(*smartcharging.ClearChargingProfileRequest) (*smartcharging.ClearChargingProfileConfirmation, error) { + return smartcharging.NewClearChargingProfileConfirmation(smartcharging.ClearChargingProfileStatusAccepted), nil +} + +func (s *Sim) OnGetCompositeSchedule(*smartcharging.GetCompositeScheduleRequest) (*smartcharging.GetCompositeScheduleConfirmation, error) { + return smartcharging.NewGetCompositeScheduleConfirmation(smartcharging.GetCompositeScheduleStatusRejected), nil +} + +func (s *Sim) OnTriggerMessage(req *remotetrigger.TriggerMessageRequest) (*remotetrigger.TriggerMessageConfirmation, error) { + if req != nil && string(req.RequestedMessage) == core.BootNotificationFeatureName { + go func() { _ = s.Boot() }() + return remotetrigger.NewTriggerMessageConfirmation(remotetrigger.TriggerMessageStatusAccepted), nil + } + return remotetrigger.NewTriggerMessageConfirmation(remotetrigger.TriggerMessageStatusNotImplemented), nil +} diff --git a/go/cmd/sim-ocpp/ocppcp/handlers201.go b/go/cmd/sim-ocpp/ocppcp/handlers201.go new file mode 100644 index 00000000..0cc3e642 --- /dev/null +++ b/go/cmd/sim-ocpp/ocppcp/handlers201.go @@ -0,0 +1,120 @@ +package ocppcp + +import ( + "strings" + + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/remotecontrol" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +// handlers201 is the 2.0.1 station-side of Sim. Separate type because the +// method signatures do not match 1.6. +type handlers201 struct{ sim *Sim } + +func (h *handlers201) OnGetBaseReport(*provisioning.GetBaseReportRequest) (*provisioning.GetBaseReportResponse, error) { + return provisioning.NewGetBaseReportResponse(types201.GenericDeviceModelStatusRejected), nil +} + +func (h *handlers201) OnGetReport(*provisioning.GetReportRequest) (*provisioning.GetReportResponse, error) { + return provisioning.NewGetReportResponse(types201.GenericDeviceModelStatusRejected), nil +} + +func (h *handlers201) OnGetVariables(req *provisioning.GetVariablesRequest) (*provisioning.GetVariablesResponse, error) { + if req == nil { + return provisioning.NewGetVariablesResponse(nil), nil + } + results := make([]provisioning.GetVariableResult, 0, len(req.GetVariableData)) + for _, d := range req.GetVariableData { + r := provisioning.GetVariableResult{Component: d.Component, Variable: d.Variable} + if strings.EqualFold(d.Component.Name, "SmartChargingCtrlr") && strings.EqualFold(d.Variable.Name, "Available") { + r.AttributeStatus = provisioning.GetVariableStatusAccepted + r.AttributeValue = "true" + } else { + r.AttributeStatus = provisioning.GetVariableStatusUnknownVariable + } + results = append(results, r) + } + return provisioning.NewGetVariablesResponse(results), nil +} + +func (h *handlers201) OnReset(*provisioning.ResetRequest) (*provisioning.ResetResponse, error) { + return provisioning.NewResetResponse(provisioning.ResetStatusAccepted), nil +} + +func (h *handlers201) OnSetNetworkProfile(*provisioning.SetNetworkProfileRequest) (*provisioning.SetNetworkProfileResponse, error) { + return provisioning.NewSetNetworkProfileResponse(provisioning.SetNetworkProfileStatusRejected), nil +} + +func (h *handlers201) OnSetVariables(req *provisioning.SetVariablesRequest) (*provisioning.SetVariablesResponse, error) { + results := make([]provisioning.SetVariableResult, 0, len(req.SetVariableData)) + for _, d := range req.SetVariableData { + results = append(results, provisioning.SetVariableResult{ + AttributeStatus: provisioning.SetVariableStatusRejected, + Component: d.Component, + Variable: d.Variable, + }) + } + return provisioning.NewSetVariablesResponse(results), nil +} + +func (h *handlers201) OnClearChargingProfile(*smartcharging201.ClearChargingProfileRequest) (*smartcharging201.ClearChargingProfileResponse, error) { + return smartcharging201.NewClearChargingProfileResponse(smartcharging201.ClearChargingProfileStatusAccepted), nil +} + +func (h *handlers201) OnGetChargingProfiles(*smartcharging201.GetChargingProfilesRequest) (*smartcharging201.GetChargingProfilesResponse, error) { + return smartcharging201.NewGetChargingProfilesResponse(smartcharging201.GetChargingProfileStatusNoProfiles), nil +} + +func (h *handlers201) OnGetCompositeSchedule(req *smartcharging201.GetCompositeScheduleRequest) (*smartcharging201.GetCompositeScheduleResponse, error) { + evse := 0 + if req != nil { + evse = req.EvseID + } + return smartcharging201.NewGetCompositeScheduleResponse(smartcharging201.GetCompositeScheduleStatusRejected, evse), nil +} + +func (h *handlers201) OnSetChargingProfile(req *smartcharging201.SetChargingProfileRequest) (*smartcharging201.SetChargingProfileResponse, error) { + d := decide201(h.sim.Model.Quirks, req) + evse := 0 + kind := "" + if req != nil { + evse = req.EvseID + if req.ChargingProfile != nil { + kind = string(req.ChargingProfile.ChargingProfileKind) + } + } + h.sim.recordAttempt(ProfileAttempt{ + EVSEID: evse, + Kind: kind, + LimitA: d.limitA, + Applied: d.applied, + Status: d.status, + }) + return smartcharging201.NewSetChargingProfileResponse(smartcharging201.ChargingProfileStatus(d.status)), nil +} + +func (h *handlers201) OnRequestStartTransaction(*remotecontrol.RequestStartTransactionRequest) (*remotecontrol.RequestStartTransactionResponse, error) { + return remotecontrol.NewRequestStartTransactionResponse(remotecontrol.RequestStartStopStatusAccepted), nil +} + +func (h *handlers201) OnRequestStopTransaction(*remotecontrol.RequestStopTransactionRequest) (*remotecontrol.RequestStopTransactionResponse, error) { + if h.sim.Model.Quirks.IgnoreRemoteStop { + return remotecontrol.NewRequestStopTransactionResponse(remotecontrol.RequestStartStopStatusAccepted), nil + } + go func() { _ = h.sim.stopTx() }() + return remotecontrol.NewRequestStopTransactionResponse(remotecontrol.RequestStartStopStatusAccepted), nil +} + +func (h *handlers201) OnTriggerMessage(req *remotecontrol.TriggerMessageRequest) (*remotecontrol.TriggerMessageResponse, error) { + if req != nil && req.RequestedMessage == remotecontrol.MessageTriggerBootNotification { + go func() { _ = h.sim.Boot() }() + return remotecontrol.NewTriggerMessageResponse(remotecontrol.TriggerMessageStatusAccepted), nil + } + return remotecontrol.NewTriggerMessageResponse(remotecontrol.TriggerMessageStatusNotImplemented), nil +} + +func (h *handlers201) OnUnlockConnector(*remotecontrol.UnlockConnectorRequest) (*remotecontrol.UnlockConnectorResponse, error) { + return remotecontrol.NewUnlockConnectorResponse(remotecontrol.UnlockStatusUnlocked), nil +} diff --git a/go/cmd/sim-ocpp/ocppcp/physics.go b/go/cmd/sim-ocpp/ocppcp/physics.go new file mode 100644 index 00000000..40377afa --- /dev/null +++ b/go/cmd/sim-ocpp/ocppcp/physics.go @@ -0,0 +1,64 @@ +package ocppcp + +import ( + "math" + "time" +) + +const minChargeAmps = 6.0 + +// Physics is a first-order per-phase current model. LimitA is what the +// charging profile granted; DrawA lags toward it so a CLI demo does not +// jump. Tests set TauS to 0 so a Tick settles instantly. +type Physics struct { + Voltage float64 + Phases int + MaxAmps float64 + TauS float64 + LimitA float64 + DrawA float64 + EnergyWh float64 + Plugged bool +} + +func newPhysics(m Model, tauS float64) Physics { + maxA := m.MaxAmps() + return Physics{ + Voltage: SiteVoltage, + Phases: m.Phases, + MaxAmps: maxA, + TauS: tauS, + LimitA: maxA, // unsteered charger runs at its hardware maximum + EnergyWh: 1000, + } +} + +// PowerW is site-convention EV load: positive watts into the car. +func (p Physics) PowerW() float64 { + return p.DrawA * p.Voltage * float64(p.Phases) +} + +func (p Physics) targetA() float64 { + if !p.Plugged || p.LimitA < minChargeAmps { + return 0 + } + if p.LimitA > p.MaxAmps { + return p.MaxAmps + } + return p.LimitA +} + +// Tick advances DrawA toward the granted limit and integrates energy. +func (p *Physics) Tick(dt time.Duration) { + target := p.targetA() + if p.TauS <= 0 { + p.DrawA = target + } else if dt > 0 { + alpha := 1 - math.Exp(-dt.Seconds()/p.TauS) + p.DrawA += (target - p.DrawA) * alpha + } + hours := dt.Hours() + if hours > 0 { + p.EnergyWh += p.PowerW() * hours + } +} diff --git a/go/cmd/sim-ocpp/ocppcp/profile.go b/go/cmd/sim-ocpp/ocppcp/profile.go new file mode 100644 index 00000000..86faee26 --- /dev/null +++ b/go/cmd/sim-ocpp/ocppcp/profile.go @@ -0,0 +1,65 @@ +package ocppcp + +import ( + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +// ProfileAttempt is one SetChargingProfile the simulator received, so tests +// can see the connector-0 retry without reading the OCPP library's guts. +type ProfileAttempt struct { + ConnectorID int + EVSEID int + Kind string + LimitA float64 + Applied bool + Status string +} + +type profileDecision struct { + status string + applied bool + limitA float64 +} + +func decideProfile(q Quirks, connectorOrEVSE int, kind string, hasStart bool, limitA float64) profileDecision { + if q.IgnoreAbsoluteWithoutStart && kind == string(types.ChargingProfileKindAbsolute) && !hasStart { + return profileDecision{status: string(smartcharging.ChargingProfileStatusAccepted), applied: false, limitA: limitA} + } + if q.RejectConnectorZero && connectorOrEVSE == 0 { + return profileDecision{status: string(smartcharging.ChargingProfileStatusRejected), applied: false, limitA: limitA} + } + return profileDecision{status: string(smartcharging.ChargingProfileStatusAccepted), applied: true, limitA: limitA} +} + +func decide16(q Quirks, req *smartcharging.SetChargingProfileRequest) profileDecision { + if req == nil || req.ChargingProfile == nil || req.ChargingProfile.ChargingSchedule == nil { + return profileDecision{status: string(smartcharging.ChargingProfileStatusRejected)} + } + limit := 0.0 + periods := req.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) > 0 { + limit = periods[0].Limit + } + hasStart := req.ChargingProfile.ChargingSchedule.StartSchedule != nil + return decideProfile(q, req.ConnectorId, string(req.ChargingProfile.ChargingProfileKind), hasStart, limit) +} + +func decide201(q Quirks, req *smartcharging201.SetChargingProfileRequest) profileDecision { + if req == nil || req.ChargingProfile == nil || len(req.ChargingProfile.ChargingSchedule) == 0 { + return profileDecision{status: string(smartcharging201.ChargingProfileStatusRejected)} + } + sched := req.ChargingProfile.ChargingSchedule[0] + limit := 0.0 + if len(sched.ChargingSchedulePeriod) > 0 { + limit = sched.ChargingSchedulePeriod[0].Limit + } + hasStart := sched.StartSchedule != nil + kind := string(req.ChargingProfile.ChargingProfileKind) + if kind == string(types201.ChargingProfileKindAbsolute) { + kind = string(types.ChargingProfileKindAbsolute) + } + return decideProfile(q, req.EvseID, kind, hasStart, limit) +} diff --git a/go/cmd/sim-ocpp/ocppcp/response_test.go b/go/cmd/sim-ocpp/ocppcp/response_test.go new file mode 100644 index 00000000..ef8bfdb8 --- /dev/null +++ b/go/cmd/sim-ocpp/ocppcp/response_test.go @@ -0,0 +1,67 @@ +package ocppcp + +import ( + "math" + "testing" + "time" + + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/transactions" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +type eventRecorder struct { + ocpp201.ChargingStation + sequences []int +} + +func (r *eventRecorder) TransactionEvent(_ transactions.TransactionEvent, _ *types201.DateTime, _ transactions.TriggerReason, seq int, _ transactions.Transaction, _ ...func(*transactions.TransactionEventRequest)) (*transactions.TransactionEventResponse, error) { + r.sequences = append(r.sequences, seq) + return &transactions.TransactionEventResponse{}, nil +} + +func TestTransactionEventsAdvanceSequence(t *testing.T) { + model, _ := Lookup("defa-power") + sim := New(model) + recorder := &eventRecorder{} + sim.cs = recorder + for _, send := range []func() error{sim.startTx, sim.Report, sim.Report, sim.stopTx} { + if err := send(); err != nil { + t.Fatal(err) + } + } + if len(recorder.sequences) != 4 { + t.Fatal(recorder.sequences) + } + for i := 1; i < len(recorder.sequences); i++ { + if recorder.sequences[i] != recorder.sequences[i-1]+1 { + t.Fatalf("transaction event sequences must advance once: %v", recorder.sequences) + } + } +} + +func TestProfileResponsePreservesLag(t *testing.T) { + model, _ := Lookup("easee-charge-up") + sim := New(model) + sim.physics = newPhysics(model, 0.5) + sim.physics.Plugged = true + energy := sim.physics.EnergyWh + sim.recordAttempt(ProfileAttempt{Applied: true, LimitA: 10}) + if sim.PowerW() != 0 || sim.physics.EnergyWh != energy { + t.Fatal("accepting a profile advanced physics without elapsed time") + } + sim.Tick(500 * time.Millisecond) + want := 10 * (1 - math.Exp(-1)) * SiteVoltage * 3 + if math.Abs(sim.PowerW()-want) > 0.001 { + t.Fatalf("power=%v, want %v", sim.PowerW(), want) + } + before := sim.PowerW() + sim.recordAttempt(ProfileAttempt{Applied: true, LimitA: 0}) + if sim.PowerW() != before { + t.Fatal("pause bypassed the configured response lag") + } + sim.Tick(500 * time.Millisecond) + if sim.PowerW() <= 0 || sim.PowerW() >= before { + t.Fatal("pause did not approach zero") + } +} diff --git a/go/cmd/sim-ocpp/ocppcp/sim.go b/go/cmd/sim-ocpp/ocppcp/sim.go new file mode 100644 index 00000000..685135a0 --- /dev/null +++ b/go/cmd/sim-ocpp/ocppcp/sim.go @@ -0,0 +1,382 @@ +package ocppcp + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/availability" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/transactions" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" + "github.com/lorenzodonini/ocpp-go/ws" +) + +// DialOpts is how a simulated charge point reaches FTW. +type DialOpts struct { + URL16 string + URL201 string + Username string + Password string + // TauS is physics lag. Zero is instant, which is what tests want. + TauS float64 +} + +// Sim is one charge point speaking either OCPP 1.6J or 2.0.1. +type Sim struct { + Model Model + mu sync.Mutex + physics Physics + attempts []ProfileAttempt + txID int + txRef string + seq int + idTag string + + cp ocpp16.ChargePoint + cs ocpp201.ChargingStation + + stopOnce sync.Once + stopped atomic.Bool +} + +// New builds a disconnected simulator for m. +func New(m Model) *Sim { + return &Sim{ + Model: m, + physics: newPhysics(m, 0), + idTag: idTagFor(m), + } +} + +func idTagFor(m Model) string { + if !m.RFID { + return "AUTO" + } + // OCPP 1.6 idTag is CiString20. The catalog slug is often longer. + tag := "RFID-" + m.ID + if len(tag) <= 20 { + return tag + } + return m.Serial +} + +// DialID is the identity this sim presents on the wire. +func (s *Sim) DialID() string { return s.Model.DialID() } + +// Dial connects to FTW. Boot is separate so tests can observe pending vs booted. +func (s *Sim) Dial(opts DialOpts) error { + if !s.Model.SpeaksOCPP() { + return fmt.Errorf("%s does not speak OCPP", s.Model.ID) + } + s.physics = newPhysics(s.Model, opts.TauS) + + client := ws.NewClient() + if opts.Username != "" || opts.Password != "" { + client.SetBasicAuth(opts.Username, opts.Password) + } + + id := s.DialID() + switch s.Model.Protocol { + case ProtocolOCPP201: + if opts.URL201 == "" { + return fmt.Errorf("%s needs a 2.0.1 URL", s.Model.ID) + } + cs := ocpp201.NewChargingStation(id, nil, client) + h := &handlers201{sim: s} + cs.SetProvisioningHandler(h) + cs.SetSmartChargingHandler(h) + cs.SetRemoteControlHandler(h) + if err := cs.Start(opts.URL201); err != nil { + return fmt.Errorf("connect %s: %w", id, err) + } + s.cs = cs + default: + if opts.URL16 == "" { + return fmt.Errorf("%s needs a 1.6 URL", s.Model.ID) + } + cp := ocpp16.NewChargePoint(id, nil, client) + cp.SetCoreHandler(s) + cp.SetSmartChargingHandler(s) + cp.SetRemoteTriggerHandler(s) + if err := cp.Start(opts.URL16); err != nil { + return fmt.Errorf("connect %s: %w", id, err) + } + s.cp = cp + } + return nil +} + +// Close drops the WebSocket. Idempotent: ocpp-go panics on a second Stop. +func (s *Sim) Close() { + s.stopOnce.Do(func() { + s.stopped.Store(true) + if s.cp != nil { + s.cp.Stop() + } + if s.cs != nil { + s.cs.Stop() + } + }) +} + +// Boot sends BootNotification with the catalog vendor/model/serial. +func (s *Sim) Boot() error { + m := s.Model + if s.cp != nil { + _, err := s.cp.BootNotification(m.Name, m.Vendor, func(req *core.BootNotificationRequest) { + req.ChargePointSerialNumber = m.Serial + req.FirmwareVersion = m.Firmware + }) + return err + } + if s.cs != nil { + _, err := s.cs.BootNotification(provisioning.BootReasonPowerUp, m.Name, m.Vendor, func(req *provisioning.BootNotificationRequest) { + req.ChargingStation.SerialNumber = m.Serial + req.ChargingStation.FirmwareVersion = m.Firmware + }) + return err + } + return fmt.Errorf("%s is not connected", m.ID) +} + +// Plug puts a car on connector 1, starts a transaction, and reports Charging. +func (s *Sim) Plug() error { + s.mu.Lock() + s.physics.Plugged = true + s.mu.Unlock() + if err := s.statusPlugged(true); err != nil { + return err + } + return s.startTx() +} + +// Unplug ends the transaction and reports Available. Charge Amps IgnoreRemoteStop +// does not apply here: this is the cable coming out, not a remote stop. +func (s *Sim) Unplug() error { + if err := s.stopTx(); err != nil { + return err + } + s.mu.Lock() + s.physics.Plugged = false + s.physics.DrawA = 0 + s.mu.Unlock() + return s.statusPlugged(false) +} + +// Tick advances physics. Tests call this with a positive dt and TauS=0 to settle. +func (s *Sim) Tick(dt time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.physics.Tick(dt) +} + +// Report pushes the current power and energy as MeterValues / TransactionEvent. +func (s *Sim) Report() error { + s.mu.Lock() + w := s.physics.PowerW() + wh := s.physics.EnergyWh + txID := s.txID + txRef := s.txRef + s.seq++ + seq := s.seq + s.mu.Unlock() + + if s.cp != nil { + mv := []types.MeterValue{{ + Timestamp: types.NewDateTime(time.Now()), + SampledValue: []types.SampledValue{ + {Value: fmt.Sprintf("%.1f", w), Measurand: types.MeasurandPowerActiveImport, Unit: types.UnitOfMeasureW}, + {Value: fmt.Sprintf("%.1f", wh), Measurand: types.MeasurandEnergyActiveImportRegister, Unit: types.UnitOfMeasureWh}, + }, + }} + _, err := s.cp.MeterValues(1, mv, func(req *core.MeterValuesRequest) { + if txID > 0 { + req.TransactionId = &txID + } + }) + return err + } + if s.cs != nil { + now := types201.NewDateTime(time.Now()) + mv := []types201.MeterValue{{ + Timestamp: *now, + SampledValue: []types201.SampledValue{ + {Value: w, Measurand: types201.MeasurandPowerActiveImport}, + {Value: wh, Measurand: types201.MeasurandEnergyActiveImportRegister}, + }, + }} + if txRef != "" { + _, err := s.cs.TransactionEvent( + transactions.TransactionEventUpdated, + now, + transactions.TriggerReasonMeterValuePeriodic, + seq, + transactions.Transaction{TransactionID: txRef}, + func(req *transactions.TransactionEventRequest) { + req.MeterValue = mv + req.Evse = &types201.EVSE{ID: 1, ConnectorID: intp(1)} + }, + ) + return err + } + _, err := s.cs.MeterValues(1, mv) + return err + } + return fmt.Errorf("%s is not connected", s.Model.ID) +} + +// PowerW is the instantaneous EV load. +func (s *Sim) PowerW() float64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.physics.PowerW() +} + +// Plugged reports whether a cable is in. +func (s *Sim) Plugged() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.physics.Plugged +} + +// LimitA is the last applied charging-profile limit. +func (s *Sim) LimitA() float64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.physics.LimitA +} + +// Attempts is the SetChargingProfile log, oldest first. +func (s *Sim) Attempts() []ProfileAttempt { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]ProfileAttempt, len(s.attempts)) + copy(out, s.attempts) + return out +} + +func (s *Sim) recordAttempt(a ProfileAttempt) { + s.mu.Lock() + s.attempts = append(s.attempts, a) + if a.Applied { + s.physics.LimitA = a.LimitA + s.physics.Tick(0) + } + s.mu.Unlock() +} + +func (s *Sim) statusPlugged(plugged bool) error { + if s.cp != nil { + st := core.ChargePointStatusAvailable + if plugged { + st = core.ChargePointStatusCharging + } + _, err := s.cp.StatusNotification(1, core.NoError, st) + return err + } + if s.cs != nil { + st := availability.ConnectorStatusAvailable + if plugged { + st = availability.ConnectorStatusOccupied + } + _, err := s.cs.StatusNotification(types201.NewDateTime(time.Now()), st, 1, 1) + return err + } + return fmt.Errorf("%s is not connected", s.Model.ID) +} + +func (s *Sim) startTx() error { + s.mu.Lock() + wh := int(s.physics.EnergyWh) + tag := s.idTag + s.mu.Unlock() + if s.cp != nil { + conf, err := s.cp.StartTransaction(1, tag, wh, types.NewDateTime(time.Now())) + if err != nil { + return err + } + s.mu.Lock() + s.txID = conf.TransactionId + s.mu.Unlock() + return nil + } + if s.cs != nil { + s.mu.Lock() + s.seq++ + seq := s.seq + ref := s.Model.Serial + "-tx" + s.txRef = ref + energy := s.physics.EnergyWh + s.mu.Unlock() + now := types201.NewDateTime(time.Now()) + _, err := s.cs.TransactionEvent( + transactions.TransactionEventStarted, + now, + transactions.TriggerReasonCablePluggedIn, + seq, + transactions.Transaction{TransactionID: ref}, + func(req *transactions.TransactionEventRequest) { + req.IDToken = &types201.IdToken{IdToken: tag, Type: types201.IdTokenTypeISO14443} + req.Evse = &types201.EVSE{ID: 1, ConnectorID: intp(1)} + req.MeterValue = []types201.MeterValue{{ + Timestamp: *now, + SampledValue: []types201.SampledValue{{Value: energy, Measurand: types201.MeasurandEnergyActiveImportRegister}}, + }} + }, + ) + return err + } + return fmt.Errorf("%s is not connected", s.Model.ID) +} + +func (s *Sim) stopTx() error { + s.mu.Lock() + txID := s.txID + txRef := s.txRef + wh := int(s.physics.EnergyWh) + energy := s.physics.EnergyWh + s.txID = 0 + s.txRef = "" + s.mu.Unlock() + if s.cp != nil { + if txID == 0 { + return nil + } + _, err := s.cp.StopTransaction(wh, types.NewDateTime(time.Now()), txID) + return err + } + if s.cs != nil { + if txRef == "" { + return nil + } + s.mu.Lock() + s.seq++ + seq := s.seq + s.mu.Unlock() + now := types201.NewDateTime(time.Now()) + _, err := s.cs.TransactionEvent( + transactions.TransactionEventEnded, + now, + transactions.TriggerReasonEVCommunicationLost, + seq, + transactions.Transaction{TransactionID: txRef}, + func(req *transactions.TransactionEventRequest) { + req.Evse = &types201.EVSE{ID: 1, ConnectorID: intp(1)} + req.MeterValue = []types201.MeterValue{{ + Timestamp: *now, + SampledValue: []types201.SampledValue{{Value: energy, Measurand: types201.MeasurandEnergyActiveImportRegister}}, + }} + }, + ) + return err + } + return nil +} + +func intp(v int) *int { return &v } diff --git a/go/test/e2e/ocpp_evify_test.go b/go/test/e2e/ocpp_evify_test.go new file mode 100644 index 00000000..b1aaec58 --- /dev/null +++ b/go/test/e2e/ocpp_evify_test.go @@ -0,0 +1,305 @@ +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "net" + "os" + + "github.com/srcfl/ftw/go/cmd/sim-ocpp/ocppcp" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/ocpp" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func evifyWaitBound(t *testing.T, port int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 50*time.Millisecond) + if err == nil { + c.Close() + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("listener never bound on %d", port) +} + +func evifyStartCS(t *testing.T, approved []string) (url16, url201 string, srv *ocpp.Server) { + t.Helper() + p16, p201 := freePort(t), freePort(t) + cfg := &ocpp.Config{ + Enabled: true, + Bind: "127.0.0.1", + Port: p16, + PortV201: p201, + HeartbeatIntervalS: 60, + ApprovedIDs: approved, + } + s, err := ocpp.Start(context.Background(), cfg, telemetry.NewStore()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + evifyWaitBound(t, p16) + evifyWaitBound(t, p201) + return fmt.Sprintf("ws://127.0.0.1:%d", p16), fmt.Sprintf("ws://127.0.0.1:%d", p201), s +} + +func evifyPayload(t *testing.T, m map[string]any) []byte { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return b +} + +func evifyWaitOnline(t *testing.T, srv *ocpp.Server, id string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("%s never came online", id) +} + +func evifyWaitSteerable(t *testing.T, srv *ocpp.Server, id string) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + v := srv.Handler().Snapshot()[id] + if v.Steerable != nil && *v.Steerable { + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("%s never reported SmartCharging: %+v", id, srv.Handler().Snapshot()[id]) +} + +// evifyWaitPower reports until FTW telemetry shows want watts. +// +// ocpp-go serializes MeterValues timestamps as RFC3339 (second resolution). +// FTW's recordPower treats a sample whose measured Unix milli is not +// strictly greater than the last accepted one as a replay, so a pause +// that lands in the same second as the previous meter value never shows +// up. Retrying after the next UTC second is what a 1 Hz charge point +// already does on the wire. +func evifyWaitPower(t *testing.T, tel *telemetry.Store, sim *ocppcp.Sim, want float64) { + t.Helper() + id := sim.DialID() + deadline := time.Now().Add(5 * time.Second) + var last float64 + for attempt := 0; time.Now().Before(deadline); attempt++ { + if attempt > 0 { + next := time.Now().UTC().Truncate(time.Second).Add(time.Second) + time.Sleep(time.Until(next) + 15*time.Millisecond) + } + sim.Tick(0) + if err := sim.Report(); err != nil { + t.Fatalf("meter %s: %v", id, err) + } + r := tel.Get(id, telemetry.DerEV) + if r != nil { + last = r.RawW + if evifyAbs(r.RawW-want) < 1 { + return + } + } + } + t.Fatalf("%s power=%v, want %v (sim=%.0f W limit=%.1f A)", id, last, want, sim.PowerW(), sim.LimitA()) +} + +func evifyAbs(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +func evifyDialAll(t *testing.T, url16, url201 string) []*ocppcp.Sim { + t.Helper() + models := ocppcp.OCPPModels() + sims := make([]*ocppcp.Sim, 0, len(models)) + for _, m := range models { + sim := ocppcp.New(m) + if err := sim.Dial(ocppcp.DialOpts{URL16: url16, URL201: url201}); err != nil { + t.Fatalf("dial %s: %v", m.ID, err) + } + t.Cleanup(sim.Close) + if err := sim.Boot(); err != nil { + t.Fatalf("boot %s: %v", m.ID, err) + } + sims = append(sims, sim) + } + return sims +} + +// TestEvifyOCPPInventoryE2E connects every OCPP charger Evify currently +// stocks to one FTW Central System, adopts them, plugs a car in, steers +// current, and pauses. Tesla is excluded because it has no OCPP. +func TestEvifyOCPPInventoryE2E(t *testing.T) { + if os.Getenv("FTW_E2E") != "1" { + t.Skip("set FTW_E2E=1 to run the OCPP integration test") + } + models := ocppcp.OCPPModels() + approved := make([]string, 0, len(models)) + for _, m := range models { + approved = append(approved, m.DialID()) + } + + tel := telemetry.NewStore() + p16, p201 := freePort(t), freePort(t) + srv, err := ocpp.Start(context.Background(), &ocpp.Config{ + Enabled: true, + Bind: "127.0.0.1", + Port: p16, + PortV201: p201, + HeartbeatIntervalS: 60, + ApprovedIDs: approved, + }, tel) + if err != nil { + t.Fatal(err) + } + t.Cleanup(srv.Stop) + evifyWaitBound(t, p16) + evifyWaitBound(t, p201) + url16 := fmt.Sprintf("ws://127.0.0.1:%d", p16) + url201 := fmt.Sprintf("ws://127.0.0.1:%d", p201) + + sims := evifyDialAll(t, url16, url201) + + for _, sim := range sims { + id := sim.DialID() + evifyWaitOnline(t, srv, id) + evifyWaitSteerable(t, srv, id) + view := srv.Handler().Snapshot()[id] + if view.Pending { + t.Errorf("%s stayed pending after adoption", id) + } + if view.Vendor != sim.Model.Vendor { + t.Errorf("%s vendor=%q, want %q", id, view.Vendor, sim.Model.Vendor) + } + if view.Serial != sim.Model.Serial { + t.Errorf("%s serial=%q, want %q", id, view.Serial, sim.Model.Serial) + } + if sim.Model.Protocol == ocppcp.ProtocolOCPP201 && view.Version != string(ocpp.Version201) { + t.Errorf("%s version=%q, want 2.0.1", id, view.Version) + } + if sim.Model.Protocol == ocppcp.ProtocolOCPP16 && view.Version != string(ocpp.Version16) { + t.Errorf("%s version=%q, want 1.6", id, view.Version) + } + } + + const setW = 6900.0 // 10 A × 230 V × 3 — under Halo's 11 kW ceiling + cmd := evifyPayload(t, map[string]any{ + "action": "ev_set_current", "power_w": setW, "voltage": ocppcp.SiteVoltage, "site_phases": 3, + }) + pause := evifyPayload(t, map[string]any{"action": "ev_pause"}) + + for _, sim := range sims { + sim := sim + id := sim.DialID() + t.Run(sim.Model.ID, func(t *testing.T) { + if err := sim.Plug(); err != nil { + t.Fatalf("plug: %v", err) + } + if err := srv.Command(context.Background(), id, cmd); err != nil { + t.Fatalf("set current: %v", err) + } + if got, want := sim.LimitA(), 10.0; evifyAbs(got-want) > 0.05 { + t.Fatalf("limit=%v A after set, want %v", got, want) + } + evifyWaitPower(t, tel, sim, setW) + + if err := srv.Command(context.Background(), id, pause); err != nil { + t.Fatalf("pause: %v", err) + } + if got := sim.LimitA(); got != 0 { + t.Fatalf("limit=%v A after pause, want 0", got) + } + evifyWaitPower(t, tel, sim, 0) + }) + } + + var aura *ocppcp.Sim + var zap *ocppcp.Sim + for _, sim := range sims { + switch sim.Model.ID { + case "charge-amps-aura": + aura = sim + case "zaptec-go": + zap = sim + } + } + if aura == nil || zap == nil { + t.Fatal("missing Aura or Zaptec Go in the connected set") + } + + var refused0, retried1 bool + for _, a := range aura.Attempts() { + if a.ConnectorID == 0 && a.Status == "Rejected" { + refused0 = true + } + if refused0 && a.ConnectorID == 1 && a.Status == "Accepted" && a.Applied { + retried1 = true + break + } + } + if !refused0 || !retried1 { + t.Errorf("Aura should refuse connector 0 then accept 1, got %+v", aura.Attempts()) + } + + if zap.DialID() != zap.Model.Serial { + t.Errorf("Zaptec dialled as %s, want serial %s", zap.DialID(), zap.Model.Serial) + } + if _, ok := srv.Handler().Snapshot()[zap.Model.Serial]; !ok { + t.Errorf("FTW keyed Zaptec on something other than the serial: %v", srv.Handler().Snapshot()) + } +} + +func TestPendingEvifyChargerIsQuarantined(t *testing.T) { + if os.Getenv("FTW_E2E") != "1" { + t.Skip("set FTW_E2E=1 to run the OCPP integration test") + } + m, _ := ocppcp.Lookup("easee-charge-up") + url16, _, srv := evifyStartCS(t, nil) + sim := ocppcp.New(m) + if err := sim.Dial(ocppcp.DialOpts{URL16: url16}); err != nil { + t.Fatal(err) + } + t.Cleanup(sim.Close) + if err := sim.Boot(); err != nil { + t.Fatal(err) + } + evifyWaitOnline(t, srv, m.DialID()) + if err := sim.Plug(); err != nil { + t.Fatal(err) + } + sim.Tick(time.Second) + if err := sim.Report(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + v := srv.Handler().Snapshot()[m.DialID()] + if v.PowerW > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + v := srv.Handler().Snapshot()[m.DialID()] + if !v.Pending { + t.Fatalf("unadopted charger must stay pending: %+v", v) + } + if v.Vendor != m.Vendor { + t.Errorf("pending charger should still show vendor, got %+v", v) + } +}