Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sim-ocpp-evify.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 10 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)..."
Expand Down
12 changes: 12 additions & 0 deletions config.local.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/ocpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
198 changes: 198 additions & 0 deletions go/cmd/sim-ocpp/main.go
Original file line number Diff line number Diff line change
@@ -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.")
}
Loading