From 61cb9607cd6209960f6d2227a70573909fe9c4c0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 06:57:55 +0200 Subject: [PATCH 1/9] fix(ocpp): send Basic auth from user:password in the OCPP URL A Central System that requires OCPP 1.6 security profile 1 (FTW does) refused the simulated charger: it dialled without credentials, and the WebSocket dialer rejects user:password inside the URL. Move them to an Authorization header and keep the password out of the logs. Co-Authored-By: Claude Opus 5.5 --- cmd/simulator/main.go | 2 +- internal/ocpp/client_test.go | 164 +++++++++++++++++++++++++++++++++++ internal/ocpp/server.go | 49 +++++++++-- 3 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 internal/ocpp/client_test.go diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 91228cb..001a471 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -1744,7 +1744,7 @@ func handleSimulatorConfig(w http.ResponseWriter, r *http.Request, sim *Simulato if config.OCPPURL != nil && sim.Protocol == "ocpp" { if sim.OCPPServer != nil { sim.OCPPServer.SetOCPPURL(*config.OCPPURL) - log.Printf("[%s] OCPP URL set to %s", sim.Serial, *config.OCPPURL) + log.Printf("[%s] OCPP URL set to %s", sim.Serial, ocpp.RedactURL(*config.OCPPURL)) } } if config.BatteryCapacityKWh != nil && *config.BatteryCapacityKWh > 0 { diff --git a/internal/ocpp/client_test.go b/internal/ocpp/client_test.go new file mode 100644 index 0000000..c731d24 --- /dev/null +++ b/internal/ocpp/client_test.go @@ -0,0 +1,164 @@ +package ocpp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// fakeCSMS is a Central System for client-mode tests. It accepts every +// BootNotification, answers every other CALL with an empty result, and +// records what the charge point sent. +type fakeCSMS struct { + t *testing.T + srv *httptest.Server + + mu sync.Mutex + paths []string + auths []string + actions []string + conn *websocket.Conn + seq int +} + +func newFakeCSMS(t *testing.T) *fakeCSMS { + f := &fakeCSMS{t: t} + up := websocket.Upgrader{Subprotocols: []string{"ocpp1.6"}} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.paths = append(f.paths, r.URL.Path) + f.auths = append(f.auths, r.Header.Get("Authorization")) + f.mu.Unlock() + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + f.mu.Lock() + f.conn = c + f.mu.Unlock() + for { + _, b, err := c.ReadMessage() + if err != nil { + return + } + var frame []json.RawMessage + if json.Unmarshal(b, &frame) != nil || len(frame) < 3 { + continue + } + var typ int + var id, action string + json.Unmarshal(frame[0], &typ) + json.Unmarshal(frame[1], &id) + if typ != MessageTypeCall { + continue + } + json.Unmarshal(frame[2], &action) + f.mu.Lock() + f.actions = append(f.actions, action) + f.mu.Unlock() + var result interface{} = map[string]interface{}{} + switch action { + case ActionBootNotification: + result = map[string]interface{}{"status": "Accepted", "currentTime": FormatTimestamp(time.Now()), "interval": 60} + case ActionStartTransaction: + result = map[string]interface{}{"transactionId": 42, "idTagInfo": map[string]string{"status": "Accepted"}} + } + out, _ := json.Marshal([]interface{}{MessageTypeCallResult, id, result}) + f.mu.Lock() + c.WriteMessage(websocket.TextMessage, out) + f.mu.Unlock() + } + })) + t.Cleanup(f.srv.Close) + return f +} + +// url is the Central System's WebSocket URL with an optional user:pass. +func (f *fakeCSMS) url(userinfo string) string { + u := strings.Replace(f.srv.URL, "http://", "ws://", 1) + if userinfo != "" { + u = strings.Replace(u, "ws://", "ws://"+userinfo+"@", 1) + } + return u + "/ocpp" +} + +// call sends a CALL to the charge point. +func (f *fakeCSMS) call(action string, payload interface{}) { + f.mu.Lock() + defer f.mu.Unlock() + f.seq++ + out, _ := json.Marshal([]interface{}{MessageTypeCall, "csms-" + string(rune('0'+f.seq)), action, payload}) + if err := f.conn.WriteMessage(websocket.TextMessage, out); err != nil { + f.t.Fatalf("send %s: %v", action, err) + } +} + +func (f *fakeCSMS) sent(action string) int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, a := range f.actions { + if a == action { + n++ + } + } + return n +} + +func waitFor(t *testing.T, what string, timeout time.Duration, ok func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for !ok() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(20 * time.Millisecond) + } +} + +func startClient(t *testing.T, url string) *Server { + t.Helper() + s := newTestServer() + s.SetOCPPURL(url) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Stop() }) + return s +} + +// A Central System that asks for Basic auth gets it from the URL, and the +// charge point identity is still the last path segment. +func TestClientSendsBasicAuthFromURL(t *testing.T) { + csms := newFakeCSMS(t) + startClient(t, csms.url("ftw:s3cret")) + waitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.sent(ActionBootNotification) > 0 }) + + csms.mu.Lock() + defer csms.mu.Unlock() + if csms.paths[0] != "/ocpp/OCPPSIM01" { + t.Errorf("path = %q, want /ocpp/OCPPSIM01", csms.paths[0]) + } + // "ftw:s3cret" in base64. + if csms.auths[0] != "Basic ZnR3OnMzY3JldA==" { + t.Errorf("Authorization = %q, want Basic auth for ftw:s3cret", csms.auths[0]) + } +} + +func TestClientWithoutCredentialsSendsNoAuth(t *testing.T) { + csms := newFakeCSMS(t) + startClient(t, csms.url("")) + waitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.sent(ActionBootNotification) > 0 }) + + csms.mu.Lock() + defer csms.mu.Unlock() + if csms.auths[0] != "" { + t.Errorf("Authorization = %q, want none", csms.auths[0]) + } +} diff --git a/internal/ocpp/server.go b/internal/ocpp/server.go index 10ae775..021b52f 100644 --- a/internal/ocpp/server.go +++ b/internal/ocpp/server.go @@ -6,6 +6,7 @@ import ( "log" "math" "net/http" + "net/url" "sort" "strconv" "strings" @@ -202,7 +203,7 @@ func (s *Server) Start() error { // Client mode: connect to Central System go s.clientLoop() s.running = true - log.Printf("[%s] OCPP client connecting to %s", s.serialNumber, s.ocppURL) + log.Printf("[%s] OCPP client connecting to %s", s.serialNumber, RedactURL(s.ocppURL)) return nil } @@ -250,11 +251,14 @@ func (s *Server) clientLoop() { // Build the full WebSocket URL with charge point identity // OCPP 1.6 expects: ws://host/path/ChargePointId - wsURL := url - if !strings.HasSuffix(wsURL, "/") { - wsURL += "/" + wsURL, header, err := dialTarget(url, serial) + if err != nil { + s.addLog("connect_error", map[string]interface{}{ + "error": err.Error(), + }) + log.Printf("[%s] Bad OCPP URL: %v", serial, err) + return } - wsURL += serial s.addLog("connecting", map[string]interface{}{ "url": wsURL, @@ -264,7 +268,7 @@ func (s *Server) clientLoop() { Subprotocols: []string{"ocpp1.6"}, } - conn, _, err := dialer.Dial(wsURL, nil) + conn, _, err := dialer.Dial(wsURL, header) if err != nil { s.addLog("connect_error", map[string]interface{}{ "error": err.Error(), @@ -317,6 +321,39 @@ func (s *Server) clientLoop() { } } +// dialTarget appends the charge point identity to the Central System URL and +// moves any user:password in it to a Basic auth header (OCPP 1.6 security +// profile 1). The WebSocket dialer refuses credentials inside the URL, and +// they must not reach the logs either. +func dialTarget(raw, identity string) (string, http.Header, error) { + u, err := url.Parse(raw) + if err != nil { + return "", nil, err + } + var header http.Header + if u.User != nil { + pass, _ := u.User.Password() + req := http.Request{Header: http.Header{}} + req.SetBasicAuth(u.User.Username(), pass) + header = req.Header + u.User = nil + } + if !strings.HasSuffix(u.Path, "/") { + u.Path += "/" + } + u.Path += identity + return u.String(), header, nil +} + +// RedactURL hides the password in a Central System URL for logs. +func RedactURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + return u.Redacted() +} + // readLoop reads messages from the WebSocket connection func (s *Server) readLoop(conn *websocket.Conn) { for { From 584335fdbe506eb642d41417398ec98506561f0a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 06:59:05 +0200 Subject: [PATCH 2/9] fix(ocpp): deliver StartTransaction once the charger is online A car plugged in before the charger had reached its Central System started a local session whose StartTransaction was lost: sendRequest failed and nothing kept the message. Queue StartTransaction and StopTransaction while there is no connection and send them, in order, after the next BootNotification is accepted. Co-Authored-By: Claude Opus 5.5 --- internal/ocpp/client_test.go | 61 ++++++++++++++++++++++++++ internal/ocpp/server.go | 84 +++++++++++++++++++++++++++++++----- 2 files changed, 134 insertions(+), 11 deletions(-) diff --git a/internal/ocpp/client_test.go b/internal/ocpp/client_test.go index c731d24..b5cf2cb 100644 --- a/internal/ocpp/client_test.go +++ b/internal/ocpp/client_test.go @@ -20,6 +20,7 @@ type fakeCSMS struct { srv *httptest.Server mu sync.Mutex + closed bool // refuse connections, like a Central System that is down paths []string auths []string actions []string @@ -32,6 +33,11 @@ func newFakeCSMS(t *testing.T) *fakeCSMS { up := websocket.Upgrader{Subprotocols: []string{"ocpp1.6"}} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() + if f.closed { + f.mu.Unlock() + http.Error(w, "down", http.StatusServiceUnavailable) + return + } f.paths = append(f.paths, r.URL.Path) f.auths = append(f.auths, r.Header.Get("Authorization")) f.mu.Unlock() @@ -162,3 +168,58 @@ func TestClientWithoutCredentialsSendsNoAuth(t *testing.T) { t.Errorf("Authorization = %q, want none", csms.auths[0]) } } + +// A car plugged in while the charger cannot reach its Central System still +// starts a session the Central System hears about once the charger is back. +func TestStartTransactionWaitsForTheConnection(t *testing.T) { + csms := newFakeCSMS(t) + csms.closed = true + s := newTestServer() + s.reconnectDelay = 50 * time.Millisecond + s.SetOCPPURL(csms.url("")) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Stop() }) + + if err := s.SetValue("evConnected", true); err != nil { + t.Fatal(err) + } + // The charger starts the session locally 2 s after plug-in. + waitFor(t, "local transaction", 5*time.Second, func() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.transactions) == 1 + }) + s.txQueueMu.Lock() + queued := len(s.txQueue) + s.txQueueMu.Unlock() + if queued != 1 { + t.Fatalf("%d messages queued while offline, want the StartTransaction", queued) + } + + csms.mu.Lock() + csms.closed = false + csms.mu.Unlock() + waitFor(t, "StartTransaction", 5*time.Second, func() bool { return csms.sent(ActionStartTransaction) == 1 }) + waitFor(t, "server transaction id", 5*time.Second, func() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.transactions[42] != nil + }) + + csms.mu.Lock() + defer csms.mu.Unlock() + var boot, start int + for i, a := range csms.actions { + switch a { + case ActionBootNotification: + boot = i + case ActionStartTransaction: + start = i + } + } + if start < boot { + t.Errorf("StartTransaction sent before BootNotification: %v", csms.actions) + } +} diff --git a/internal/ocpp/server.go b/internal/ocpp/server.go index 021b52f..0236293 100644 --- a/internal/ocpp/server.go +++ b/internal/ocpp/server.go @@ -2,6 +2,7 @@ package ocpp import ( "encoding/json" + "errors" "fmt" "log" "math" @@ -25,6 +26,15 @@ type storedProfile struct { Profile ChargingProfile } +// queuedTx is a transaction message waiting for a connection. +type queuedTx struct { + action string + payload interface{} + onResult func(json.RawMessage) +} + +var errNotConnected = errors.New("not connected") + // profileKey builds a composite key for the profile map: "connectorId:purpose:stackLevel" func profileKey(connectorId int, purpose string, stackLevel int) string { return fmt.Sprintf("%d:%s:%d", connectorId, purpose, stackLevel) @@ -87,6 +97,11 @@ type Server struct { pendingActions map[string]string // messageId -> action name for logging stopChan chan struct{} + // Transaction messages that found no connection, oldest first. A real + // charge point keeps them and delivers them once it is back online. + txQueueMu sync.Mutex + txQueue []queuedTx + // Client mode fields ocppURL string // Central System URL to connect to (client mode) clientMode bool // true = connect to URL, false = listen for connections @@ -1453,6 +1468,8 @@ func (s *Server) sendBootNotification() { s.mu.RUnlock() s.sendStatusNotification(id, status) } + + s.flushTxQueue() } func (s *Server) getHeartbeatInterval() int { @@ -1525,14 +1542,11 @@ func (s *Server) sendStartTransaction(connectorId int, idTag string, meterStart Timestamp: FormatTimestamp(time.Now()), } - resp, err := s.sendRequest(ActionStartTransaction, req) - if err != nil { - log.Printf("[%s] StartTransaction failed: %v", s.serialNumber, err) - return - } - - var txResp StartTransactionResponse - if err := json.Unmarshal(resp, &txResp); err == nil { + s.sendTxRequest(ActionStartTransaction, req, func(resp json.RawMessage) { + var txResp StartTransactionResponse + if err := json.Unmarshal(resp, &txResp); err != nil { + return + } log.Printf("[%s] Transaction started, server ID: %d", s.serialNumber, txResp.TransactionId) // Update our local transaction with the server's transaction ID @@ -1547,7 +1561,7 @@ func (s *Server) sendStartTransaction(connectorId int, idTag string, meterStart } } s.mu.Unlock() - } + }) } func (s *Server) sendStopTransaction(transactionId int, idTag string, meterStop int) { @@ -1558,7 +1572,55 @@ func (s *Server) sendStopTransaction(transactionId int, idTag string, meterStop Timestamp: FormatTimestamp(time.Now()), } - s.sendRequest(ActionStopTransaction, req) + s.sendTxRequest(ActionStopTransaction, req, nil) +} + +// sendTxRequest sends a transaction message, or queues it while there is no +// connection. Without the queue a car plugged in before the charger reached +// its Central System charged in a session the Central System never heard of. +func (s *Server) sendTxRequest(action string, payload interface{}, onResult func(json.RawMessage)) { + resp, err := s.sendRequest(action, payload) + if errors.Is(err, errNotConnected) { + s.txQueueMu.Lock() + s.txQueue = append(s.txQueue, queuedTx{action: action, payload: payload, onResult: onResult}) + s.txQueueMu.Unlock() + log.Printf("[%s] %s queued until the charger is online", s.serialNumber, action) + return + } + if err != nil { + log.Printf("[%s] %s failed: %v", s.serialNumber, action, err) + return + } + if onResult != nil { + onResult(resp) + } +} + +// flushTxQueue delivers queued transaction messages in order. It runs after +// a BootNotification is accepted. +func (s *Server) flushTxQueue() { + s.txQueueMu.Lock() + queue := s.txQueue + s.txQueue = nil + s.txQueueMu.Unlock() + + for i, m := range queue { + resp, err := s.sendRequest(m.action, m.payload) + if errors.Is(err, errNotConnected) { + // Offline again: keep the rest, in front of anything newer. + s.txQueueMu.Lock() + s.txQueue = append(append([]queuedTx{}, queue[i:]...), s.txQueue...) + s.txQueueMu.Unlock() + return + } + if err != nil { + log.Printf("[%s] queued %s failed: %v", s.serialNumber, m.action, err) + continue + } + if m.onResult != nil { + m.onResult(resp) + } + } } func (s *Server) sendRequest(action string, payload interface{}) (json.RawMessage, error) { @@ -1566,7 +1628,7 @@ func (s *Server) sendRequest(action string, payload interface{}) (json.RawMessag conn := s.wsConn if conn == nil { s.mu.Unlock() - return nil, fmt.Errorf("not connected") + return nil, errNotConnected } messageId := fmt.Sprintf("%d", atomic.AddInt64(&s.messageIdSeq, 1)) From a669fe90db15f8b986e62845cb5815381e3e93cd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 07:00:26 +0200 Subject: [PATCH 3/9] feat(ocpp): profile_delay_s answers Accepted at once and applies later A cloud charger such as Easee accepts a charging profile at once and changes its output a minute or two later. The simulated charger applied every profile at once, so nothing could test how a Central System copes with that. POST /api/simulators/{id}/config {"profile_delay_s": 60} now holds accepted SetChargingProfile and ClearChargingProfile changes for that long, in order. Invalid profiles are still refused at once. Co-Authored-By: Claude Opus 5.5 --- cmd/simulator/main.go | 9 +++++ internal/mcp/server.go | 3 +- internal/ocpp/client_test.go | 59 ++++++++++++++++++++++++++++ internal/ocpp/server.go | 75 +++++++++++++++++++++++++++++++++++- internal/state/state.go | 1 + 5 files changed, 145 insertions(+), 2 deletions(-) diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 001a471..de609ba 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -821,6 +821,7 @@ func restoreSimulator(saved state.SimulatorState) (*Simulator, error) { if saved.OCPPURL != "" { ocppServer.SetOCPPURL(saved.OCPPURL) } + ocppServer.SetProfileDelay(time.Duration(saved.ProfileDelayS * float64(time.Second))) sim.OCPPServer = ocppServer sim.Device = ocppServer if sim.Automation.Scenario == "" { @@ -1690,6 +1691,7 @@ func handleSimulatorStatus(w http.ResponseWriter, r *http.Request, sim *Simulato state := sim.OCPPServer.GetState() status["state"] = state status["ocpp_url"] = sim.OCPPServer.GetOCPPURL() + status["profile_delay_s"] = sim.OCPPServer.ProfileDelay().Seconds() } sim.mu.RUnlock() @@ -1708,6 +1710,9 @@ func handleSimulatorConfig(w http.ResponseWriter, r *http.Request, sim *Simulato Serial *string `json:"serial"` OCPPURL *string `json:"ocpp_url"` BatteryCapacityKWh *float64 `json:"battery_capacity_kwh"` + // ProfileDelayS: an OCPP charger accepts a charging profile at + // once and applies it this many seconds later (a cloud charger). + ProfileDelayS *float64 `json:"profile_delay_s"` } if err := json.NewDecoder(r.Body).Decode(&config); err != nil { @@ -1750,6 +1755,9 @@ func handleSimulatorConfig(w http.ResponseWriter, r *http.Request, sim *Simulato if config.BatteryCapacityKWh != nil && *config.BatteryCapacityKWh > 0 { sim.BatteryCapacityKWh = *config.BatteryCapacityKWh } + if config.ProfileDelayS != nil && sim.OCPPServer != nil { + sim.OCPPServer.SetProfileDelay(time.Duration(*config.ProfileDelayS * float64(time.Second))) + } sim.mu.Unlock() markStateDirty() @@ -3741,6 +3749,7 @@ func buildStateSnapshot() *state.AppState { } if sim.OCPPServer != nil { sState.OCPPURL = sim.OCPPServer.GetOCPPURL() + sState.ProfileDelayS = sim.OCPPServer.ProfileDelay().Seconds() } sim.mu.RUnlock() diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2e246af..4608307 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -402,12 +402,13 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) { "serial": schemaString("Simulator serial"), "ocpp_url": schemaString("OCPP URL"), "battery_capacity_kwh": schemaNumber("Battery capacity in kWh"), + "profile_delay_s": schemaNumber("OCPP charger: seconds between accepting a charging profile and applying it"), }, []string{"id"}), }, Method: http.MethodPost, PathTemplate: "/api/simulators/{id}/config", PathParams: []string{"id"}, - BodyParams: []string{"slave_id", "serial", "ocpp_url", "battery_capacity_kwh"}, + BodyParams: []string{"slave_id", "serial", "ocpp_url", "battery_capacity_kwh", "profile_delay_s"}, }, { Tool: Tool{ diff --git a/internal/ocpp/client_test.go b/internal/ocpp/client_test.go index b5cf2cb..10f1e01 100644 --- a/internal/ocpp/client_test.go +++ b/internal/ocpp/client_test.go @@ -223,3 +223,62 @@ func TestStartTransactionWaitsForTheConnection(t *testing.T) { t.Errorf("StartTransaction sent before BootNotification: %v", csms.actions) } } + +func effectiveW(s *Server) float64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.computeEffectivePowerLocked(1, time.Now()) +} + +func txProfileW(id int64, watts float64) ChargingProfile { + return ChargingProfile{ + ChargingProfileId: id, + ChargingProfilePurpose: "TxProfile", + ChargingProfileKind: "Relative", + ChargingSchedule: ChargingSchedule{ + ChargingRateUnit: "W", + ChargingSchedulePeriod: []ChargingSchedulePeriod{{StartPeriod: 0, Limit: watts}}, + }, + } +} + +// A slow cloud charger answers Accepted at once and changes its output only +// after the delay, in the order the changes were accepted. +func TestProfileDelayAcceptsAtOnceAndAppliesLater(t *testing.T) { + s := newTestServer() // 22 kW + s.transactions[1] = &Transaction{ID: 1, ConnectorID: 1, StartTime: time.Now()} + s.SetProfileDelay(300 * time.Millisecond) + + if got := s.handleSetChargingProfile(SetChargingProfileRequest{ConnectorId: 1, CsChargingProfiles: txProfileW(1, 5000)}); got.Status != "Accepted" { + t.Fatalf("status %q, want Accepted", got.Status) + } + time.Sleep(100 * time.Millisecond) + s.handleSetChargingProfile(SetChargingProfileRequest{ConnectorId: 1, CsChargingProfiles: txProfileW(1, 7000)}) + if got := effectiveW(s); got != 22000 { + t.Fatalf("right after Accepted: %v W, want the old 22000 W", got) + } + waitFor(t, "first profile", time.Second, func() bool { return effectiveW(s) == 5000 }) + waitFor(t, "second profile", time.Second, func() bool { return effectiveW(s) == 7000 }) + + // A connector-0 TxProfile is refused at once, as without a delay. + if got := s.handleSetChargingProfile(SetChargingProfileRequest{ConnectorId: 0, CsChargingProfiles: txProfileW(2, 1000)}); got.Status != "Rejected" { + t.Errorf("connector 0 TxProfile: %q, want Rejected", got.Status) + } + + if got := s.handleClearChargingProfile(ClearChargingProfileRequest{}); got.Status != "Accepted" { + t.Fatalf("clear: %q, want Accepted", got.Status) + } + if got := effectiveW(s); got != 7000 { + t.Fatalf("right after clear: %v W, want 7000 W until the delay passes", got) + } + waitFor(t, "clear", time.Second, func() bool { return effectiveW(s) == 22000 }) +} + +func TestNoProfileDelayAppliesAtOnce(t *testing.T) { + s := newTestServer() + s.transactions[1] = &Transaction{ID: 1, ConnectorID: 1, StartTime: time.Now()} + s.handleSetChargingProfile(SetChargingProfileRequest{ConnectorId: 1, CsChargingProfiles: txProfileW(1, 5000)}) + if got := effectiveW(s); got != 5000 { + t.Fatalf("%v W, want 5000 W at once", got) + } +} diff --git a/internal/ocpp/server.go b/internal/ocpp/server.go index 0236293..f779a18 100644 --- a/internal/ocpp/server.go +++ b/internal/ocpp/server.go @@ -35,6 +35,12 @@ type queuedTx struct { var errNotConnected = errors.New("not connected") +// delayedOp is an accepted profile change waiting to take effect. +type delayedOp struct { + due time.Time + apply func() +} + // profileKey builds a composite key for the profile map: "connectorId:purpose:stackLevel" func profileKey(connectorId int, purpose string, stackLevel int) string { return fmt.Sprintf("%d:%s:%d", connectorId, purpose, stackLevel) @@ -102,6 +108,13 @@ type Server struct { txQueueMu sync.Mutex txQueue []queuedTx + // profileDelay models a cloud charger: it accepts a charging profile at + // once and applies it this much later. Zero applies it at once. + profileDelay time.Duration + laterMu sync.Mutex // guards later + later []delayedOp + laterRunMu sync.Mutex // keeps delayed operations in order + // Client mode fields ocppURL string // Central System URL to connect to (client mode) clientMode bool // true = connect to URL, false = listen for connections @@ -172,6 +185,24 @@ func (s *Server) SetOCPPURL(url string) { s.clientMode = url != "" } +// SetProfileDelay sets how long an accepted SetChargingProfile or +// ClearChargingProfile takes to reach the charger's output. +func (s *Server) SetProfileDelay(d time.Duration) { + if d < 0 { + d = 0 + } + s.mu.Lock() + defer s.mu.Unlock() + s.profileDelay = d +} + +// ProfileDelay returns the profile delay. +func (s *Server) ProfileDelay() time.Duration { + s.mu.RLock() + defer s.mu.RUnlock() + return s.profileDelay +} + // GetOCPPURL returns the configured Central System URL func (s *Server) GetOCPPURL() string { s.mu.RLock() @@ -480,6 +511,11 @@ func (s *Server) Stop() error { close(s.stopChan) + // A charger that is switched off forgets changes it had not applied. + s.laterMu.Lock() + s.later = nil + s.laterMu.Unlock() + if s.wsConn != nil { s.wsConn.Close() s.wsConn = nil @@ -799,11 +835,18 @@ func (s *Server) handleSetChargingProfile(req SetChargingProfileRequest) *SetCha return &SetChargingProfileResponse{Status: "Rejected"} } - s.applyChargingProfile(req.ConnectorId, &req.CsChargingProfiles) + profile := req.CsChargingProfiles + if s.deferProfileChange(func() { s.applyChargingProfile(req.ConnectorId, &profile) }) { + return &SetChargingProfileResponse{Status: "Accepted"} + } + s.applyChargingProfile(req.ConnectorId, &profile) return &SetChargingProfileResponse{Status: "Accepted"} } func (s *Server) handleClearChargingProfile(req ClearChargingProfileRequest) *ClearChargingProfileResponse { + if s.deferProfileChange(func() { s.clearChargingProfiles(req) }) { + return &ClearChargingProfileResponse{Status: "Accepted"} + } cleared := s.clearChargingProfiles(req) if cleared { return &ClearChargingProfileResponse{Status: "Accepted"} @@ -811,6 +854,36 @@ func (s *Server) handleClearChargingProfile(req ClearChargingProfileRequest) *Cl return &ClearChargingProfileResponse{Status: "Unknown"} } +// deferProfileChange schedules apply after the profile delay and reports +// whether it did. Changes take effect in the order they were accepted. +func (s *Server) deferProfileChange(apply func()) bool { + d := s.ProfileDelay() + if d <= 0 { + return false + } + s.laterMu.Lock() + s.later = append(s.later, delayedOp{due: time.Now().Add(d), apply: apply}) + s.laterMu.Unlock() + time.AfterFunc(d, s.runDueProfileChanges) + return true +} + +func (s *Server) runDueProfileChanges() { + s.laterRunMu.Lock() + defer s.laterRunMu.Unlock() + for { + s.laterMu.Lock() + if len(s.later) == 0 || s.later[0].due.After(time.Now()) { + s.laterMu.Unlock() + return + } + op := s.later[0] + s.later = s.later[1:] + s.laterMu.Unlock() + op.apply() + } +} + func (s *Server) handleGetConfiguration(req GetConfigurationRequest) *GetConfigurationResponse { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/state/state.go b/internal/state/state.go index 74884db..542cad1 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -29,6 +29,7 @@ type SimulatorState struct { BatteryCapacityKWh float64 `json:"battery_capacity_kwh"` Profile string `json:"profile"` OCPPURL string `json:"ocpp_url"` + ProfileDelayS float64 `json:"profile_delay_s,omitempty"` Automation AutomationState `json:"automation"` } From 3a12476583ae0c3b06b475bb87727da8316f6bc7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 07:05:27 +0200 Subject: [PATCH 4/9] fix(ocpp): a 1-phase profile loads L1, not a third of each phase The simulated charger always spread its draw evenly over three phases, also under a profile with numberPhases 1, so a site meter never saw the one-phase load a real car puts on L1. A charger that can switch phases (ConnectorSwitch3to1PhaseSupported) now draws a 1-phase profile on L1 only, at most its rated current per phase, and reports it per phase: phasePowers and per-phase currents in its state and MeterValues. The site meter takes a charger's draw per phase instead of through the site's phase factors. Co-Authored-By: Claude Opus 5.5 --- cmd/simulator/main.go | 23 +++- cmd/simulator/site_phases_test.go | 106 ++++++++++++++ internal/ocpp/client_test.go | 222 ++++++++++-------------------- internal/ocpp/ocpptest/csms.go | 165 ++++++++++++++++++++++ internal/ocpp/server.go | 122 +++++++++++----- 5 files changed, 455 insertions(+), 183 deletions(-) create mode 100644 cmd/simulator/site_phases_test.go create mode 100644 internal/ocpp/ocpptest/csms.go diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index de609ba..5af432f 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -4077,6 +4077,20 @@ func getPowerContribution(sim *Simulator) float64 { return 0 } +// getPhaseContribution returns a charger's draw per phase. A car on a +// 1-phase profile loads L1 only, which phase factors cannot express. +func getPhaseContribution(sim *Simulator) ([3]float64, bool) { + sim.mu.RLock() + defer sim.mu.RUnlock() + if sim.Protocol != "ocpp" || sim.OCPPServer == nil { + return [3]float64{}, false + } + if !sim.Running || !sim.DeviceOn { + return [3]float64{}, true + } + return sim.OCPPServer.PhasePowers(), true +} + // startSiteAggregation starts the aggregation loop for a site func startSiteAggregation(site *Site) { ensureSiteMetersRunning(site) @@ -4517,7 +4531,8 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { phasePowers.L3 += pvBatteryNet * phaseFactors.L3 } - // Add contributions from other simulators (distributed using phase factors) + // Add contributions from other simulators (distributed using phase + // factors, except chargers, which say which phases they draw on) var otherPower float64 for _, simID := range simIDs { // Skip meter simulator to avoid double-counting its own power @@ -4528,6 +4543,12 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { if sim == nil { continue } + if p, ok := getPhaseContribution(sim); ok { + phasePowers.L1 += p[0] + phasePowers.L2 += p[1] + phasePowers.L3 += p[2] + continue + } otherPower += getPowerContribution(sim) } diff --git a/cmd/simulator/site_phases_test.go b/cmd/simulator/site_phases_test.go new file mode 100644 index 0000000..cfa59df --- /dev/null +++ b/cmd/simulator/site_phases_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "math" + "testing" + "time" + + "github.com/srcfl/device-simulator/internal/ocpp/ocpptest" +) + +// testSite is a site whose meter is never served over Modbus: the test +// calls calculateAndUpdateMeter itself. +func testSite(t *testing.T, houseW float64, members ...*Simulator) (*Site, *Simulator) { + t.Helper() + meter, err := createSimulator("energy_meter") + if err != nil { + t.Fatal(err) + } + meter.Running = true + ids := make([]int, 0, len(members)) + for _, m := range members { + ids = append(ids, m.ID) + } + t.Cleanup(func() { + // Not deleteSimulator: it expects the servers main() starts. + simulatorsMu.Lock() + defer simulatorsMu.Unlock() + kept := simulators[:0] + for _, sim := range simulators { + if sim != meter && !containsInt(ids, sim.ID) { + kept = append(kept, sim) + } + } + simulators = kept + }) + site := &Site{ + ID: -1, + MeterID: meter.ID, + MeterIDs: []int{meter.ID}, + SimulatorIDs: ids, + FuseSize: 25, + FuseProfile: FuseProfileManual, + PhaseFactors: PhaseFactors{L1: 1.0 / 3, L2: 1.0 / 3, L3: 1.0 / 3}, + SitePhaseLoads: PhaseLoads{L1: houseW, L2: houseW, L3: houseW}, + } + return site, meter +} + +// A car on a 1-phase profile from a charger that can switch phases loads L1 +// on the site meter, not a third of it on each phase. The default charger +// is rated 7.7 kW, so 11 A on one phase. +func TestSiteMeterPutsOnePhaseChargerOnL1(t *testing.T) { + csms := ocpptest.New(t) + charger, err := createSimulator("ocpp_charger") + if err != nil { + t.Fatal(err) + } + cp := charger.OCPPServer + cp.ApplyConfiguration(map[string]string{"ConnectorSwitch3to1PhaseSupported": "true"}) + cp.SetOCPPURL(csms.URL("")) + if err := cp.Start(); err != nil { + t.Fatal(err) + } + defer cp.Stop() + charger.Running = true + site, meter := testSite(t, 230, charger) + + ocpptest.WaitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.Sent("BootNotification") > 0 }) + if err := cp.SetValue("evConnected", true); err != nil { + t.Fatal(err) + } + ocpptest.WaitFor(t, "StartTransaction", 5*time.Second, func() bool { return csms.Sent("StartTransaction") > 0 }) + csms.Call("SetChargingProfile", map[string]interface{}{ + "connectorId": 1, + "csChargingProfiles": map[string]interface{}{ + "chargingProfileId": 1, "stackLevel": 0, + "chargingProfilePurpose": "TxProfile", "chargingProfileKind": "Relative", + "chargingSchedule": map[string]interface{}{ + "chargingRateUnit": "A", + "chargingSchedulePeriod": []map[string]interface{}{{"startPeriod": 0, "limit": 10, "numberPhases": 1}}, + }, + }, + }) + ocpptest.WaitFor(t, "a 1-phase draw", 5*time.Second, func() bool { + p := cp.PhasePowers() + return p[0] > 2000 && p[1] == 0 + }) + + calculateAndUpdateMeter(site, 0) + l1 := getSemanticValue(meter, "meter_l1_power") + l2 := getSemanticValue(meter, "meter_l2_power") + l3 := getSemanticValue(meter, "meter_l3_power") + // 10 A at the charger's 227-230 V. + if math.Abs(l2-230) > 1 || math.Abs(l3-230) > 1 || l1 < 230+2270 || l1 > 230+2300 { + t.Fatalf("meter L1/L2/L3 = %.0f/%.0f/%.0f W, want house 230 W each plus ~2290 W on L1", l1, l2, l3) + } +} + +func containsInt(xs []int, x int) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} diff --git a/internal/ocpp/client_test.go b/internal/ocpp/client_test.go index 10f1e01..8e16978 100644 --- a/internal/ocpp/client_test.go +++ b/internal/ocpp/client_test.go @@ -1,132 +1,14 @@ package ocpp import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync" + "math" "testing" "time" - "github.com/gorilla/websocket" + "github.com/srcfl/device-simulator/internal/ocpp/ocpptest" ) -// fakeCSMS is a Central System for client-mode tests. It accepts every -// BootNotification, answers every other CALL with an empty result, and -// records what the charge point sent. -type fakeCSMS struct { - t *testing.T - srv *httptest.Server - - mu sync.Mutex - closed bool // refuse connections, like a Central System that is down - paths []string - auths []string - actions []string - conn *websocket.Conn - seq int -} - -func newFakeCSMS(t *testing.T) *fakeCSMS { - f := &fakeCSMS{t: t} - up := websocket.Upgrader{Subprotocols: []string{"ocpp1.6"}} - f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f.mu.Lock() - if f.closed { - f.mu.Unlock() - http.Error(w, "down", http.StatusServiceUnavailable) - return - } - f.paths = append(f.paths, r.URL.Path) - f.auths = append(f.auths, r.Header.Get("Authorization")) - f.mu.Unlock() - c, err := up.Upgrade(w, r, nil) - if err != nil { - return - } - f.mu.Lock() - f.conn = c - f.mu.Unlock() - for { - _, b, err := c.ReadMessage() - if err != nil { - return - } - var frame []json.RawMessage - if json.Unmarshal(b, &frame) != nil || len(frame) < 3 { - continue - } - var typ int - var id, action string - json.Unmarshal(frame[0], &typ) - json.Unmarshal(frame[1], &id) - if typ != MessageTypeCall { - continue - } - json.Unmarshal(frame[2], &action) - f.mu.Lock() - f.actions = append(f.actions, action) - f.mu.Unlock() - var result interface{} = map[string]interface{}{} - switch action { - case ActionBootNotification: - result = map[string]interface{}{"status": "Accepted", "currentTime": FormatTimestamp(time.Now()), "interval": 60} - case ActionStartTransaction: - result = map[string]interface{}{"transactionId": 42, "idTagInfo": map[string]string{"status": "Accepted"}} - } - out, _ := json.Marshal([]interface{}{MessageTypeCallResult, id, result}) - f.mu.Lock() - c.WriteMessage(websocket.TextMessage, out) - f.mu.Unlock() - } - })) - t.Cleanup(f.srv.Close) - return f -} - -// url is the Central System's WebSocket URL with an optional user:pass. -func (f *fakeCSMS) url(userinfo string) string { - u := strings.Replace(f.srv.URL, "http://", "ws://", 1) - if userinfo != "" { - u = strings.Replace(u, "ws://", "ws://"+userinfo+"@", 1) - } - return u + "/ocpp" -} - -// call sends a CALL to the charge point. -func (f *fakeCSMS) call(action string, payload interface{}) { - f.mu.Lock() - defer f.mu.Unlock() - f.seq++ - out, _ := json.Marshal([]interface{}{MessageTypeCall, "csms-" + string(rune('0'+f.seq)), action, payload}) - if err := f.conn.WriteMessage(websocket.TextMessage, out); err != nil { - f.t.Fatalf("send %s: %v", action, err) - } -} - -func (f *fakeCSMS) sent(action string) int { - f.mu.Lock() - defer f.mu.Unlock() - n := 0 - for _, a := range f.actions { - if a == action { - n++ - } - } - return n -} - -func waitFor(t *testing.T, what string, timeout time.Duration, ok func() bool) { - t.Helper() - deadline := time.Now().Add(timeout) - for !ok() { - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for %s", what) - } - time.Sleep(20 * time.Millisecond) - } -} +var waitFor = ocpptest.WaitFor func startClient(t *testing.T, url string) *Server { t.Helper() @@ -142,41 +24,37 @@ func startClient(t *testing.T, url string) *Server { // A Central System that asks for Basic auth gets it from the URL, and the // charge point identity is still the last path segment. func TestClientSendsBasicAuthFromURL(t *testing.T) { - csms := newFakeCSMS(t) - startClient(t, csms.url("ftw:s3cret")) - waitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.sent(ActionBootNotification) > 0 }) + csms := ocpptest.New(t) + startClient(t, csms.URL("ftw:s3cret")) + waitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.Sent(ActionBootNotification) > 0 }) - csms.mu.Lock() - defer csms.mu.Unlock() - if csms.paths[0] != "/ocpp/OCPPSIM01" { - t.Errorf("path = %q, want /ocpp/OCPPSIM01", csms.paths[0]) + if got := csms.Paths()[0]; got != "/ocpp/OCPPSIM01" { + t.Errorf("path = %q, want /ocpp/OCPPSIM01", got) } // "ftw:s3cret" in base64. - if csms.auths[0] != "Basic ZnR3OnMzY3JldA==" { - t.Errorf("Authorization = %q, want Basic auth for ftw:s3cret", csms.auths[0]) + if got := csms.Auths()[0]; got != "Basic ZnR3OnMzY3JldA==" { + t.Errorf("Authorization = %q, want Basic auth for ftw:s3cret", got) } } func TestClientWithoutCredentialsSendsNoAuth(t *testing.T) { - csms := newFakeCSMS(t) - startClient(t, csms.url("")) - waitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.sent(ActionBootNotification) > 0 }) + csms := ocpptest.New(t) + startClient(t, csms.URL("")) + waitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.Sent(ActionBootNotification) > 0 }) - csms.mu.Lock() - defer csms.mu.Unlock() - if csms.auths[0] != "" { - t.Errorf("Authorization = %q, want none", csms.auths[0]) + if got := csms.Auths()[0]; got != "" { + t.Errorf("Authorization = %q, want none", got) } } // A car plugged in while the charger cannot reach its Central System still // starts a session the Central System hears about once the charger is back. func TestStartTransactionWaitsForTheConnection(t *testing.T) { - csms := newFakeCSMS(t) - csms.closed = true + csms := ocpptest.New(t) + csms.SetClosed(true) s := newTestServer() s.reconnectDelay = 50 * time.Millisecond - s.SetOCPPURL(csms.url("")) + s.SetOCPPURL(csms.URL("")) if err := s.Start(); err != nil { t.Fatal(err) } @@ -198,20 +76,17 @@ func TestStartTransactionWaitsForTheConnection(t *testing.T) { t.Fatalf("%d messages queued while offline, want the StartTransaction", queued) } - csms.mu.Lock() - csms.closed = false - csms.mu.Unlock() - waitFor(t, "StartTransaction", 5*time.Second, func() bool { return csms.sent(ActionStartTransaction) == 1 }) + csms.SetClosed(false) + waitFor(t, "StartTransaction", 5*time.Second, func() bool { return csms.Sent(ActionStartTransaction) == 1 }) waitFor(t, "server transaction id", 5*time.Second, func() bool { s.mu.RLock() defer s.mu.RUnlock() return s.transactions[42] != nil }) - csms.mu.Lock() - defer csms.mu.Unlock() var boot, start int - for i, a := range csms.actions { + actions := csms.Actions() + for i, a := range actions { switch a { case ActionBootNotification: boot = i @@ -220,7 +95,7 @@ func TestStartTransactionWaitsForTheConnection(t *testing.T) { } } if start < boot { - t.Errorf("StartTransaction sent before BootNotification: %v", csms.actions) + t.Errorf("StartTransaction sent before BootNotification: %v", actions) } } @@ -282,3 +157,54 @@ func TestNoProfileDelayAppliesAtOnce(t *testing.T) { t.Fatalf("%v W, want 5000 W at once", got) } } + +func txProfileA(amps float64, phases int) ChargingProfile { + p := txProfileW(1, 0) + p.ChargingSchedule.ChargingRateUnit = "A" + p.ChargingSchedule.ChargingSchedulePeriod = []ChargingSchedulePeriod{{StartPeriod: 0, Limit: amps, NumberPhases: &phases}} + return p +} + +func charging(t *testing.T, kw float64, phaseSwitch bool, profile ChargingProfile) *Server { + t.Helper() + s := NewServer(1, 0, "TestVendor", "TestModel", 1, kw) + s.configuration["ConnectorSwitch3to1PhaseSupported"] = map[bool]string{true: "true", false: "false"}[phaseSwitch] + s.transactions[1] = &Transaction{ID: 1, ConnectorID: 1, StartTime: time.Now()} + s.connectors[1].Status = StatusCharging + s.applyChargingProfile(1, &profile) + s.GenerateValues(time.Now()) + return s +} + +// A charger that can switch phases puts a 1-phase profile on L1; one that +// cannot keeps drawing on three. +func TestOnePhaseProfileDrawsOnL1(t *testing.T) { + s := charging(t, 22.08, true, txProfileA(16, 1)) + if got := s.PhasePowers(); got != [3]float64{3680, 0, 0} { + t.Fatalf("phase powers %v, want 3680 W on L1", got) + } + if s.currentL2 != 0 || s.currentL3 != 0 || s.currentL1 < 15 { + t.Fatalf("currents %.1f/%.1f/%.1f A, want ~16 A on L1 only", s.currentL1, s.currentL2, s.currentL3) + } + if s.connectors[1].Phases != 1 { + t.Errorf("connector phases %d, want 1", s.connectors[1].Phases) + } + + s = charging(t, 22.08, false, txProfileA(16, 1)) + if got := s.PhasePowers(); math.Abs(got[0]-3680.0/3) > 1e-6 || got[0] != got[1] || got[1] != got[2] { + t.Fatalf("no phase switching: %v, want 3680 W spread on three phases", got) + } + + s = charging(t, 22.08, true, txProfileA(16, 3)) + if got := s.PhasePowers(); got != [3]float64{3680, 3680, 3680} { + t.Fatalf("3-phase profile: %v, want 3680 W per phase", got) + } +} + +// On one phase a charger gives at most its rated current per phase. +func TestOnePhaseDrawIsCappedAtRatedCurrent(t *testing.T) { + s := charging(t, 11.04, true, txProfileA(24, 1)) // a 16 A charger + if got := s.PhasePowers(); got != [3]float64{3680, 0, 0} { + t.Fatalf("phase powers %v, want 16 A (3680 W) on L1", got) + } +} diff --git a/internal/ocpp/ocpptest/csms.go b/internal/ocpp/ocpptest/csms.go new file mode 100644 index 0000000..f396bbd --- /dev/null +++ b/internal/ocpp/ocpptest/csms.go @@ -0,0 +1,165 @@ +// Package ocpptest provides a fake OCPP 1.6 Central System for tests of +// the simulated charger in client mode. +package ocpptest + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// CSMS accepts every BootNotification, gives every StartTransaction +// transaction id 42, answers every other CALL with an empty result, and +// records what the charge point sent. +type CSMS struct { + t testing.TB + srv *httptest.Server + + mu sync.Mutex + closed bool + paths []string + auths []string + actions []string + conn *websocket.Conn + seq int +} + +// New starts a Central System that stops with the test. +func New(t testing.TB) *CSMS { + f := &CSMS{t: t} + up := websocket.Upgrader{Subprotocols: []string{"ocpp1.6"}} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + if f.closed { + f.mu.Unlock() + http.Error(w, "down", http.StatusServiceUnavailable) + return + } + f.paths = append(f.paths, r.URL.Path) + f.auths = append(f.auths, r.Header.Get("Authorization")) + f.mu.Unlock() + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + f.mu.Lock() + f.conn = c + f.mu.Unlock() + for { + _, b, err := c.ReadMessage() + if err != nil { + return + } + var frame []json.RawMessage + if json.Unmarshal(b, &frame) != nil || len(frame) < 3 { + continue + } + var typ int + var id, action string + json.Unmarshal(frame[0], &typ) + json.Unmarshal(frame[1], &id) + if typ != 2 { + continue + } + json.Unmarshal(frame[2], &action) + var result interface{} = map[string]interface{}{} + switch action { + case "BootNotification": + result = map[string]interface{}{"status": "Accepted", "currentTime": time.Now().UTC().Format(time.RFC3339), "interval": 60} + case "StartTransaction": + result = map[string]interface{}{"transactionId": 42, "idTagInfo": map[string]string{"status": "Accepted"}} + } + out, _ := json.Marshal([]interface{}{3, id, result}) + f.mu.Lock() + f.actions = append(f.actions, action) + c.WriteMessage(websocket.TextMessage, out) + f.mu.Unlock() + } + })) + t.Cleanup(f.srv.Close) + return f +} + +// URL is the Central System's WebSocket URL, with userinfo ("user:pass") +// when it is not empty. +func (f *CSMS) URL(userinfo string) string { + u := strings.Replace(f.srv.URL, "http://", "ws://", 1) + if userinfo != "" { + u = strings.Replace(u, "ws://", "ws://"+userinfo+"@", 1) + } + return u + "/ocpp" +} + +// SetClosed makes the Central System refuse connections, as one that is down. +func (f *CSMS) SetClosed(closed bool) { + f.mu.Lock() + defer f.mu.Unlock() + f.closed = closed +} + +// Call sends a CALL to the connected charge point. +func (f *CSMS) Call(action string, payload interface{}) { + f.mu.Lock() + defer f.mu.Unlock() + f.seq++ + out, _ := json.Marshal([]interface{}{2, fmt.Sprintf("csms-%d", f.seq), action, payload}) + if f.conn == nil { + f.t.Fatalf("send %s: no charge point connected", action) + } + if err := f.conn.WriteMessage(websocket.TextMessage, out); err != nil { + f.t.Fatalf("send %s: %v", action, err) + } +} + +// Sent counts the CALLs of one action the charge point has sent. +func (f *CSMS) Sent(action string) int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, a := range f.actions { + if a == action { + n++ + } + } + return n +} + +// Actions lists the charge point's CALLs in the order they arrived. +func (f *CSMS) Actions() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.actions...) +} + +// Paths lists the request path of each connection. +func (f *CSMS) Paths() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.paths...) +} + +// Auths lists the Authorization header of each connection. +func (f *CSMS) Auths() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.auths...) +} + +// WaitFor polls ok until it holds or the timeout passes. +func WaitFor(t testing.TB, what string, timeout time.Duration, ok func() bool) { + t.Helper() + deadline := time.Now().Add(timeout) + for !ok() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/ocpp/server.go b/internal/ocpp/server.go index f779a18..5eb28d9 100644 --- a/internal/ocpp/server.go +++ b/internal/ocpp/server.go @@ -66,6 +66,7 @@ type Connector struct { ErrorCode string `json:"errorCode"` CurrentPower float64 `json:"currentPower"` EnergyWh float64 `json:"energyWh"` + Phases int `json:"phases"` // 1 or 3: phases the car draws on } // Transaction represents an active charging transaction @@ -127,6 +128,7 @@ type Server struct { currentL1 float64 currentL2 float64 currentL3 float64 + phaseW [3]float64 // what the chargers draw on L1, L2, L3 // Simulation control evConnected bool // Whether an EV is plugged in @@ -466,9 +468,13 @@ func (s *Server) sendMeterValues(connectorId, transactionId int) { // IMPORTANT: Use "L1", "L2", "L3" phases (not "L1-N") - matches Ambibox test data format // IMPORTANT: ORDER matters - match Ambibox exactly: Energy, Current.Offered, Current.Import, Voltage // Calculate offered current from power using phase voltage (L-N): - // P = 3 × V_phase × I_phase, so I_phase = P / (3 × V_phase) + // P = phases × V_phase × I_phase, so I_phase = P / (phases × V_phase) avgVoltage := (s.voltageL1 + s.voltageL2 + s.voltageL3) / 3.0 - offeredCurrent := conn.CurrentPower / (3.0 * avgVoltage) + phases := 3.0 + if conn.Phases == 1 { + phases = 1 + } + offeredCurrent := conn.CurrentPower / (phases * avgVoltage) if offeredCurrent < 0 { offeredCurrent = 0 } @@ -1263,13 +1269,20 @@ func (s *Server) clearChargingProfiles(req ClearChargingProfileRequest) bool { // charging profile evaluated at the given time. txStartTime is used for Relative profiles. // Returns (limitW, active) — active=false if the profile has no applicable period at this time. func (s *Server) evaluateProfileLimit(profile *ChargingProfile, now time.Time, txStartTime time.Time) (float64, bool) { + w, _, active := s.evaluateProfile(profile, now, txStartTime) + return w, active +} + +// evaluateProfile is evaluateProfileLimit plus the active period's +// numberPhases (3 when it names none). +func (s *Server) evaluateProfile(profile *ChargingProfile, now time.Time, txStartTime time.Time) (float64, int, bool) { if profile == nil { - return 0, false + return 0, 0, false } schedule := profile.ChargingSchedule if len(schedule.ChargingSchedulePeriod) == 0 { - return 0, false + return 0, 0, false } var elapsed float64 // seconds since schedule reference point @@ -1278,7 +1291,7 @@ func (s *Server) evaluateProfileLimit(profile *ChargingProfile, now time.Time, t case "Recurring": startSchedule, err := time.Parse(time.RFC3339, schedule.StartSchedule) if err != nil { - return 0, false + return 0, 0, false } if profile.RecurrencyKind == "Daily" { // Compute seconds since today's occurrence of startSchedule @@ -1299,23 +1312,23 @@ func (s *Server) evaluateProfileLimit(profile *ChargingProfile, now time.Time, t } elapsed = now.Sub(weekStart).Seconds() } else { - return 0, false + return 0, 0, false } case "Relative": elapsed = now.Sub(txStartTime).Seconds() if elapsed < 0 { - return 0, false + return 0, 0, false } case "Absolute": startSchedule, err := time.Parse(time.RFC3339, schedule.StartSchedule) if err != nil { - return 0, false + return 0, 0, false } elapsed = now.Sub(startSchedule).Seconds() if elapsed < 0 { - return 0, false + return 0, 0, false } default: @@ -1323,11 +1336,11 @@ func (s *Server) evaluateProfileLimit(profile *ChargingProfile, now time.Time, t if schedule.StartSchedule != "" { startSchedule, err := time.Parse(time.RFC3339, schedule.StartSchedule) if err != nil { - return 0, false + return 0, 0, false } elapsed = now.Sub(startSchedule).Seconds() if elapsed < 0 { - return 0, false + return 0, 0, false } } else { elapsed = 0 @@ -1336,7 +1349,7 @@ func (s *Server) evaluateProfileLimit(profile *ChargingProfile, now time.Time, t // Check schedule duration if schedule.Duration != nil && *schedule.Duration > 0 && elapsed >= float64(*schedule.Duration) { - return 0, false + return 0, 0, false } // Find active period: last period where startPeriod <= elapsed @@ -1351,24 +1364,24 @@ func (s *Server) evaluateProfileLimit(profile *ChargingProfile, now time.Time, t } if activePeriod == nil { - return 0, false + return 0, 0, false } limit := activePeriod.Limit + phases := 3 + if activePeriod.NumberPhases != nil && *activePeriod.NumberPhases > 0 { + phases = *activePeriod.NumberPhases + } // Convert to watts unit := strings.ToUpper(strings.TrimSpace(schedule.ChargingRateUnit)) switch unit { case "A", "AMPERE", "CURRENT": - phases := 3 - if activePeriod.NumberPhases != nil && *activePeriod.NumberPhases > 0 { - phases = *activePeriod.NumberPhases - } avgVoltage := s.averagePhaseVoltageLocked() - return limit * float64(phases) * avgVoltage, true + return limit * float64(phases) * avgVoltage, phases, true case "W", "WATT", "POWER": - return limit, true + return limit, phases, true default: - return limit, true + return limit, phases, true } } @@ -1376,7 +1389,18 @@ func (s *Server) evaluateProfileLimit(profile *ChargingProfile, now time.Time, t // Must be called with s.mu held (at least read lock). // Priority: TxProfile > TxDefaultProfile, capped by ChargePointMaxProfile on connector 0. func (s *Server) computeEffectivePowerLocked(connectorId int, now time.Time) float64 { + w, _ := s.computeEffectiveLocked(connectorId, now) + return w +} + +// computeEffectiveLocked is computeEffectivePowerLocked plus the phases the +// car draws on. A charger that can switch phases follows the active Tx or +// TxDefault profile's numberPhases; on one phase it gives at most its rated +// current, a third of its three-phase power. Any other charger draws on +// three phases. +func (s *Server) computeEffectiveLocked(connectorId int, now time.Time) (float64, int) { maxW := s.maxPowerKW * 1000 + phases := 3 // Find the transaction start time for this connector (needed for Relative profiles) var txStartTime time.Time @@ -1390,6 +1414,7 @@ func (s *Server) computeEffectivePowerLocked(connectorId int, now time.Time) flo // Find highest-stackLevel TxProfile for this connector bestLimit := -1.0 bestStack := -1 + bestPhases := 3 for _, sp := range s.chargingProfiles { if sp.ConnectorID != connectorId { continue @@ -1400,9 +1425,10 @@ func (s *Server) computeEffectivePowerLocked(connectorId int, now time.Time) flo if sp.Profile.StackLevel <= bestStack { continue } - if limitW, active := s.evaluateProfileLimit(&sp.Profile, now, txStartTime); active { + if limitW, n, active := s.evaluateProfile(&sp.Profile, now, txStartTime); active { bestLimit = limitW bestStack = sp.Profile.StackLevel + bestPhases = n } } @@ -1419,9 +1445,10 @@ func (s *Server) computeEffectivePowerLocked(connectorId int, now time.Time) flo if sp.Profile.StackLevel <= bestStack { continue } - if limitW, active := s.evaluateProfileLimit(&sp.Profile, now, txStartTime); active { + if limitW, n, active := s.evaluateProfile(&sp.Profile, now, txStartTime); active { bestLimit = limitW bestStack = sp.Profile.StackLevel + bestPhases = n } } } @@ -1454,13 +1481,24 @@ func (s *Server) computeEffectivePowerLocked(connectorId int, now time.Time) flo effectiveW = math.Min(effectiveW, capLimit) } + if bestLimit >= 0 && bestPhases == 1 && s.phaseSwitchingLocked() { + phases = 1 + maxW /= 3 + } + // Cap at hardware max effectiveW = math.Min(effectiveW, maxW) if effectiveW < 0 { effectiveW = 0 } - return effectiveW + return effectiveW, phases +} + +// phaseSwitchingLocked reports ConnectorSwitch3to1PhaseSupported. +func (s *Server) phaseSwitchingLocked() bool { + ok, _ := strconv.ParseBool(s.configuration["ConnectorSwitch3to1PhaseSupported"]) + return ok } func (s *Server) averagePhaseVoltageLocked() float64 { @@ -1837,6 +1875,7 @@ func (s *Server) GetState() map[string]interface{} { "errorCode": c.ErrorCode, "currentPower": c.CurrentPower, "energyWh": c.EnergyWh, + "phases": c.Phases, }) } @@ -1913,6 +1952,7 @@ func (s *Server) GetState() map[string]interface{} { "currentL2": s.currentL2, "currentL3": s.currentL3, "totalPower": totalPower, + "phasePowers": map[string]float64{"l1": s.phaseW[0], "l2": s.phaseW[1], "l3": s.phaseW[2]}, // Control state "evConnected": s.evConnected, "effectivePowerW": s.computeEffectivePowerLocked(1, time.Now()), @@ -2312,10 +2352,12 @@ func (s *Server) GenerateValues(simTime time.Time) { // Simulate power consumption for active transactions var totalPower float64 + var phaseW [3]float64 for _, tx := range s.transactions { conn := s.connectors[tx.ConnectorID] if conn.Status == StatusCharging || conn.Status == StatusSuspendedEVSE { - powerW := s.computeEffectivePowerLocked(tx.ConnectorID, simTime) + powerW, phases := s.computeEffectiveLocked(tx.ConnectorID, simTime) + conn.Phases = phases if powerW <= 0 && conn.Status == StatusCharging { // Power dropped to 0 — transition to SuspendedEVSE @@ -2332,6 +2374,13 @@ func (s *Server) GenerateValues(simTime time.Time) { } totalPower += conn.CurrentPower + if phases == 1 { + phaseW[0] += conn.CurrentPower + } else { + for k := range phaseW { + phaseW[k] += conn.CurrentPower / 3 + } + } // Add energy (Wh) for 1 second at this power energyDelta := conn.CurrentPower / 3600.0 @@ -2340,23 +2389,20 @@ func (s *Server) GenerateValues(simTime time.Time) { } } - // Update 3-phase metering based on total power - // Assume balanced 3-phase load + // Update 3-phase metering from the power on each phase + s.phaseW = phaseW if totalPower > 0 { // Add small random variations to voltages (typical grid variation) s.voltageL1 = 228.0 + (float64(simTime.Second()%10) * 0.2) s.voltageL2 = 227.0 + (float64((simTime.Second()+3)%10) * 0.2) s.voltageL3 = 228.0 + (float64((simTime.Second()+7)%10) * 0.2) - // Calculate current per phase (balanced load) using phase voltage (L-N) - // P = 3 × V_phase × I_phase, so I_phase = P / (3 × V_phase) + // Current per phase using phase voltage (L-N): I = P_phase / V_phase, + // with small variations avgVoltage := (s.voltageL1 + s.voltageL2 + s.voltageL3) / 3.0 - totalCurrent := totalPower / (3.0 * avgVoltage) - - // Distribute with small variations - s.currentL1 = totalCurrent * (0.98 + (float64(simTime.Second()%5) * 0.01)) - s.currentL2 = totalCurrent * (0.99 + (float64((simTime.Second()+2)%5) * 0.01)) - s.currentL3 = totalCurrent * (1.00 + (float64((simTime.Second()+4)%5) * 0.01)) + s.currentL1 = phaseW[0] / avgVoltage * (0.98 + (float64(simTime.Second()%5) * 0.01)) + s.currentL2 = phaseW[1] / avgVoltage * (0.99 + (float64((simTime.Second()+2)%5) * 0.01)) + s.currentL3 = phaseW[2] / avgVoltage * (1.00 + (float64((simTime.Second()+4)%5) * 0.01)) } else { // No charging - idle state s.voltageL1 = 230.0 @@ -2375,6 +2421,14 @@ func (s *Server) GenerateValues(simTime time.Time) { } } +// PhasePowers returns what the chargers draw on L1, L2 and L3, in watts. +// A site meter adds these to its phases. +func (s *Server) PhasePowers() [3]float64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.phaseW +} + // GetConnectors returns all connectors func (s *Server) GetConnectors() map[int]*Connector { s.mu.RLock() From 9fed20baebe7080d1bf282686eb6ce17d2bfefac Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 07:06:10 +0200 Subject: [PATCH 5/9] fix(sungrow): write register defaults in engineering units Register defaults are written through each register's scale, but the Sungrow ones were raw register values: an SH10RT came up rated 100 kW (5000, 0.1 kW) with 500 kW charge and discharge limits (33046/33047, 0.01 kW). Use 10 kW and 5 kW, in the register set, the global defaults and the sungrow-hybrid and residential-5kw profiles. Co-Authored-By: Claude Opus 5.5 --- cmd/simulator/sungrow_defaults_test.go | 50 ++++++++++++++++++++++++++ internal/modbus/devices/sungrow.go | 8 +++-- internal/modbus/registers.go | 6 ++-- internal/profiles/loader.go | 8 ++--- profiles/sungrow-hybrid.json | 4 +-- 5 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 cmd/simulator/sungrow_defaults_test.go diff --git a/cmd/simulator/sungrow_defaults_test.go b/cmd/simulator/sungrow_defaults_test.go new file mode 100644 index 0000000..ad95b41 --- /dev/null +++ b/cmd/simulator/sungrow_defaults_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/srcfl/device-simulator/internal/modbus" + "github.com/srcfl/device-simulator/internal/modbus/devices" + "github.com/srcfl/device-simulator/internal/profiles" +) + +// Register defaults are engineering values. The Sungrow profile once wrote +// raw register values there, which made an SH10RT a 100 kW inverter with +// 500 kW battery limits. +func TestSungrowHybridDefaultsAreAnSH10RT(t *testing.T) { + for _, dir := range []string{t.TempDir(), filepath.Join("..", "..", "profiles")} { + pm := profiles.NewManager(dir) + pm.InitEmbedded() + if err := pm.LoadAll(); err != nil { + t.Fatal(err) + } + profile := pm.Get("sungrow-hybrid") + rs := devices.GetRegisterSet(profile.RegisterSetName) + server := modbus.NewServer(65535) + server.InitDefaultsFromSet(rs) + for addr, value := range profile.Defaults { + if def := rs.Lookup[addr]; def != nil { + server.WriteRegisterValue(def, value) + } + } + for _, c := range []struct { + addr uint16 + want float64 + raw uint16 + }{{5000, 10, 100}, {33046, 5, 500}, {33047, 5, 500}} { + def := rs.Lookup[c.addr] + got, err := server.ReadRegisterValue(def) + if err != nil { + t.Fatal(err) + } + regs := server.GetHoldingRegisters(c.addr, 1) + if !def.UseHolding { + regs = server.GetInputRegisters(c.addr, 1) + } + if got != c.want || len(regs) != 1 || regs[0] != c.raw { + t.Errorf("%s: %s = %v %s (raw %v), want %v (raw %d)", dir, def.Name, got, def.Unit, regs, c.want, c.raw) + } + } + } +} diff --git a/internal/modbus/devices/sungrow.go b/internal/modbus/devices/sungrow.go index fc64eab..7f8c1e9 100644 --- a/internal/modbus/devices/sungrow.go +++ b/internal/modbus/devices/sungrow.go @@ -58,7 +58,9 @@ func SungrowRegisters() *modbus.RegisterSet { // Serial Number (STR at address 4989, 10 registers = 20 chars) {Address: 4989, Name: "Serial Number", SemanticName: "serial_number", Description: "Device serial number", Unit: "", Category: "pv", DataType: modbus.STR, Scale: 1.0, Words: 10, Endianness: modbus.Big}, }, map[uint16]int64{ - 5000: 100, + // Defaults are engineering values, in each register's unit: an + // SH10RT is rated 10 kW and charges and discharges at 5 kW. + 5000: 10, 5241: 50, 5016: 0, 13021: 0, @@ -71,8 +73,8 @@ func SungrowRegisters() *modbus.RegisterSet { 13051: 0, 13073: 10000, 13086: 170, - 33046: 500, - 33047: 500, + 33046: 5, + 33047: 5, 13057: 100, // Battery Max SoC (100.0%) 13058: 0, 13036: 150000, diff --git a/internal/modbus/registers.go b/internal/modbus/registers.go index f17312f..b4e2c42 100644 --- a/internal/modbus/registers.go +++ b/internal/modbus/registers.go @@ -187,7 +187,7 @@ func init() { // DefaultValues contains the default register values var DefaultValues = map[uint16]int64{ // Device Ratings - 5000: 100, + 5000: 10, // Nominal Output Power (10 kW; defaults are engineering values) 5241: 50, // Grid Frequency (50.00 Hz, scaled by 100) // Power Flow Registers @@ -216,8 +216,8 @@ var DefaultValues = map[uint16]int64{ 13051: 0, // Battery Setpoint 13073: 10000, // PV Command Power 13086: 170, // Export Limit Enable (170=Enabled) - 33046: 500, // Max Charge Power (5kW) - 33047: 500, // Max Discharge Power (5kW) + 33046: 5, // Max Charge Power (5kW) + 33047: 5, // Max Discharge Power (5kW) 13057: 100, // Battery Max SoC (100.0%) 13058: 0, // Battery Min SoC (0.0%) diff --git a/internal/profiles/loader.go b/internal/profiles/loader.go index 1eef40a..bee1ebb 100644 --- a/internal/profiles/loader.go +++ b/internal/profiles/loader.go @@ -291,8 +291,8 @@ var EmbeddedProfiles = map[string]*Profile{ 13049: 0, // Grid Mode (Self-consumption) 13050: 204, // Stop command 13073: 10000, // PV Command - 33046: 500, // Max Charge (5kW) - 33047: 500, // Max Discharge (5kW) + 33046: 5, // Max Charge (5kW) + 33047: 5, // Max Discharge (5kW) 5740: 230, // Meter L1 Voltage (230V) 5741: 230, // Meter L2 Voltage (230V) 5742: 230, // Meter L3 Voltage (230V) @@ -524,8 +524,8 @@ var EmbeddedProfiles = map[string]*Profile{ 13022: 50, 5600: 0, 13052: 500, - 33046: 500, - 33047: 500, + 33046: 5, + 33047: 5, }, Generators: map[string]GeneratorConfig{ "solar_radiance": { diff --git a/profiles/sungrow-hybrid.json b/profiles/sungrow-hybrid.json index 90e37d4..b58149a 100644 --- a/profiles/sungrow-hybrid.json +++ b/profiles/sungrow-hybrid.json @@ -17,8 +17,8 @@ "13051": 0, "13073": 10000, "13086": 170, - "33046": 500, - "33047": 500, + "33046": 5, + "33047": 5, "5241": 50, "13036": 150000, "13040": 180000, From e88339cc59597831f364c33665fe30943eb3d15c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 07:08:32 +0200 Subject: [PATCH 6/9] feat(sungrow): ems_control makes the battery follow the EMS registers The Sungrow model stored an EMS's writes to 13049-13051 and never applied them, so an EMS under test commanded a battery that did not move. With POST /api/simulators/{id}/config {"ems_control": true} the battery does what a real SH does: in forced mode (13049 = 2) it follows 13050 (170 charge, 187 discharge, 204 stop) and 13051 watts; in any other mode it runs self-consumption against the site meter. It answers after ems_response_ms (default 2000), stays within 33046/33047 and the SoC limits 13057/13058, and writes the power-flow flags (13000) the Lua driver signs the power by. Off by default: the dashboard sets the battery by writing the registers directly. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 2 +- cmd/simulator/ems.go | 94 +++++++++++++++++++++++ cmd/simulator/ems_test.go | 123 ++++++++++++++++++++++++++++++ cmd/simulator/main.go | 49 ++++++++++++ cmd/simulator/static/openapi.json | 10 ++- internal/mcp/server.go | 4 +- internal/state/state.go | 2 + 8 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 cmd/simulator/ems.go create mode 100644 cmd/simulator/ems_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0226a6b..1fe73c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ GET /api/simulators # List all simulators POST /api/simulators # Create simulator {type: "inverter"|"energy_meter"|"v2x_charger"|"ocpp_charger"} DELETE /api/simulators/{id} # Delete simulator GET /api/simulators/{id} # Get simulator details -POST /api/simulators/{id}/config # Update serial/slave ID +POST /api/simulators/{id}/config # Serial, slave ID, OCPP URL, ems_control, profile_delay_s GET /api/simulators/{id}/registers # Get all registers POST /api/simulators/{id}/registers/{addr} # Write register value GET /api/simulators/{id}/logs # Get protocol operation logs diff --git a/README.md b/README.md index 5a01c63..7a4810d 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ All simulators are accessible through a unified API: ``` GET /api/simulators # List all simulators GET /api/simulators/{id} # Get simulator details -POST /api/simulators/{id}/config # Update serial/slave ID +POST /api/simulators/{id}/config # Serial, slave ID, OCPP URL, ems_control, profile_delay_s GET /api/simulators/{id}/registers # Get all registers POST /api/simulators/{id}/registers/{addr} # Write register value GET /api/simulators/{id}/logs # Get Modbus operation logs diff --git a/cmd/simulator/ems.go b/cmd/simulator/ems.go new file mode 100644 index 0000000..522a07a --- /dev/null +++ b/cmd/simulator/ems.go @@ -0,0 +1,94 @@ +package main + +import ( + "math" + "time" +) + +// EMS control for a Sungrow hybrid's battery. +// +// A real SH applies what an EMS writes to its control block: 13049 is the +// mode, 13050 the forced command (170 charge, 187 discharge, 204 stop) and +// 13051 the forced power in watts. In any mode other than forced it runs +// self-consumption, steering the battery against its own meter. Without +// ems_control the simulator stores those writes and leaves the battery +// where the dashboard or a register write put it. + +const ( + emsModeForced = 2 + emsCmdCharge = 170 + emsCmdDischarge = 187 + defaultEMSResponse = 2 * time.Second + + flagCharging = 0x0002 + flagDischarging = 0x0004 +) + +// emsBattery is what the battery is doing in answer to its EMS registers. +type emsBattery struct { + pending []emsTarget + appliedW float64 // + charging, - discharging +} + +type emsTarget struct { + at time.Time + w float64 +} + +// emsTargetW is the battery power the inverter heads for. siteW is the +// site meter's latest net power (+ import), which includes appliedW. +func emsTargetW(sim *Simulator, siteW, appliedW float64) float64 { + var w float64 + if int(getSemanticValue(sim, "grid_mode")) == emsModeForced { + sp := math.Abs(getSemanticValue(sim, "battery_setpoint")) + switch int(getSemanticValue(sim, "battery_cmd")) { + case emsCmdCharge: + w = sp + case emsCmdDischarge: + w = -sp + } + } else { + w = appliedW - siteW + } + if limit := getSemanticValue(sim, "max_charge_power") * 1000; limit > 0 && w > limit { + w = limit + } + if limit := getSemanticValue(sim, "max_discharge_power") * 1000; limit > 0 && w < -limit { + w = -limit + } + soc := getSemanticValue(sim, "battery_soc") + if maxSoC := getSemanticValue(sim, "battery_max_soc"); maxSoC > 0 && soc >= maxSoC && w > 0 { + w = 0 + } + if soc <= getSemanticValue(sim, "battery_min_soc") && w < 0 { + w = 0 + } + return w +} + +// stepEMSBattery moves an EMS-controlled battery to its target once the +// response time has passed, and writes the power and the power-flow flags +// the Lua driver signs it by. +func stepEMSBattery(sim *Simulator, now time.Time, siteW float64) { + sim.mu.Lock() + defer sim.mu.Unlock() + if !sim.EMSControl || !sim.Running || !sim.DeviceOn || sim.Server == nil || sim.Category != "inverter" { + return + } + b := &sim.ems + b.pending = append(b.pending, emsTarget{at: now.Add(sim.EMSResponse), w: emsTargetW(sim, siteW, b.appliedW)}) + for len(b.pending) > 0 && !b.pending[0].at.After(now) { + b.appliedW = b.pending[0].w + b.pending = b.pending[1:] + } + + setSemanticValue(sim, "battery_power", math.Abs(b.appliedW)) + var flags float64 + switch { + case b.appliedW > 0: + flags = flagCharging + case b.appliedW < 0: + flags = flagDischarging + } + setSemanticValue(sim, "battery_flags", flags) +} diff --git a/cmd/simulator/ems_test.go b/cmd/simulator/ems_test.go new file mode 100644 index 0000000..aa7b932 --- /dev/null +++ b/cmd/simulator/ems_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "math" + "testing" + "time" + + "github.com/srcfl/device-simulator/internal/modbus/devices" +) + +// sungrowInSite is a running Sungrow hybrid in a site with houseW per +// phase, its battery at soc percent with a 5 % floor. +func sungrowInSite(t *testing.T, houseW, soc float64, ems bool) (*Simulator, *Site, *Simulator) { + t.Helper() + inv, err := createSimulator("inverter") + if err != nil { + t.Fatal(err) + } + rs := devices.GetRegisterSet("sungrow") + inv.RegisterSet = rs + inv.Server.InitDefaultsFromSet(rs) + inv.Running = true + inv.EMSControl = ems + inv.EMSResponse = 0 + setSemanticValue(inv, "battery_soc", soc) + setSemanticValue(inv, "battery_min_soc", 5) + site, meter := testSite(t, houseW, inv) + return inv, site, meter +} + +func writeEMS(sim *Simulator, mode, cmd, watts float64) { + setSemanticValue(sim, "grid_mode", mode) + setSemanticValue(sim, "battery_cmd", cmd) + setSemanticValue(sim, "battery_setpoint", watts) +} + +// tick runs the site meter n times and returns its last net power. +func tick(site *Site, meter *Simulator, n int) float64 { + for i := 0; i < n; i++ { + calculateAndUpdateMeter(site, 0) + } + return getSemanticValue(meter, "meter_power") +} + +func TestEMSForcedModeDrivesTheBattery(t *testing.T) { + inv, site, meter := sungrowInSite(t, 1000, 50, true) + + writeEMS(inv, 2, emsCmdCharge, 3000) + if got := tick(site, meter, 2); math.Abs(got-6000) > 1 { + t.Fatalf("charging 3 kW: meter %.0f W, want house 3000 + 3000", got) + } + if got := getBatterySignedPower(inv); got != 3000 { + t.Fatalf("battery %.0f W, want +3000", got) + } + + writeEMS(inv, 2, emsCmdDischarge, 2000) + if got := tick(site, meter, 2); math.Abs(got-1000) > 1 { + t.Fatalf("discharging 2 kW: meter %.0f W, want 3000 - 2000", got) + } + if got := getSemanticValue(inv, "battery_flags"); got != flagDischarging { + t.Fatalf("power-flow flags %v, want discharging (4): the driver signs by them", got) + } + + writeEMS(inv, 2, 204, 2000) + if got := tick(site, meter, 2); math.Abs(got-3000) > 1 { + t.Fatalf("stop: meter %.0f W, want the house alone", got) + } + + writeEMS(inv, 2, emsCmdCharge, 9000) + if got := (tick(site, meter, 2) - 3000); math.Abs(got-5000) > 1 { + t.Fatalf("9 kW asked: battery %.0f W, want the 5 kW limit (33046)", got) + } +} + +// Self-consumption (any mode but forced) cancels the meter, as a real SH +// does without an EMS. +func TestEMSSelfConsumptionCancelsTheMeter(t *testing.T) { + inv, site, meter := sungrowInSite(t, 1000, 50, true) + writeEMS(inv, 0, 204, 0) + if got := tick(site, meter, 3); math.Abs(got) > 1 { + t.Fatalf("meter %.0f W, want 0 with the battery covering the house", got) + } + if got := getBatterySignedPower(inv); math.Abs(got+3000) > 1 { + t.Fatalf("battery %.0f W, want -3000", got) + } +} + +func TestEMSKeepsTheSoCLimits(t *testing.T) { + inv, site, meter := sungrowInSite(t, 1000, 5, true) + writeEMS(inv, 2, emsCmdDischarge, 3000) + if got := tick(site, meter, 2); math.Abs(got-3000) > 1 { + t.Fatalf("at the 5 %% floor: meter %.0f W, want no discharge", got) + } + setSemanticValue(inv, "battery_soc", 100) + writeEMS(inv, 2, emsCmdCharge, 3000) + if got := tick(site, meter, 2); math.Abs(got-3000) > 1 { + t.Fatalf("full: meter %.0f W, want no charge", got) + } +} + +// The battery answers after its response time, not on the write. +func TestEMSResponseTime(t *testing.T) { + inv, site, meter := sungrowInSite(t, 1000, 50, true) + inv.EMSResponse = 300 * time.Millisecond + writeEMS(inv, 2, emsCmdCharge, 3000) + if got := tick(site, meter, 2); math.Abs(got-3000) > 1 { + t.Fatalf("right after the write: meter %.0f W, want the house alone", got) + } + time.Sleep(350 * time.Millisecond) + if got := tick(site, meter, 2); math.Abs(got-6000) > 1 { + t.Fatalf("after 350 ms: meter %.0f W, want house + 3000", got) + } +} + +// Without ems_control the registers stay as written: the dashboard sets the +// battery itself. +func TestEMSWritesIgnoredWithoutEMSControl(t *testing.T) { + inv, site, meter := sungrowInSite(t, 1000, 50, false) + writeEMS(inv, 2, emsCmdCharge, 3000) + if got := tick(site, meter, 2); math.Abs(got-3000) > 1 { + t.Fatalf("meter %.0f W, want the house alone", got) + } +} diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 5af432f..7a773b6 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -62,6 +62,9 @@ type Simulator struct { Automation *AutomationState `json:"-"` // Legacy - will be removed once migration complete internalSOC float64 // High-precision SOC accumulator (avoids register truncation) socInitialized bool // Whether internalSOC has been initialized from register + EMSControl bool `json:"ems_control"` // Battery follows the EMS registers (see ems.go) + EMSResponse time.Duration `json:"-"` // How long the battery takes to follow them + ems emsBattery mu sync.RWMutex } @@ -502,6 +505,7 @@ func createSimulator(simType string) (*Simulator, error) { SiteID: 0, // Orphan until assigned to a site DeviceOn: true, // Devices are "on" by default when added to site BatteryCapacityKWh: defaultBatteryCapacityKWh, + EMSResponse: defaultEMSResponse, ProfilesManager: pm, Automation: &AutomationState{ TimeMultiplier: 1.0, @@ -708,6 +712,8 @@ func restoreSimulator(saved state.SimulatorState) (*Simulator, error) { SiteID: saved.SiteID, DeviceOn: saved.DeviceOn, BatteryCapacityKWh: saved.BatteryCapacityKWh, + EMSControl: saved.EMSControl, + EMSResponse: defaultEMSResponse, ProfilesManager: pm, Automation: &AutomationState{ Enabled: saved.Automation.Enabled, @@ -718,6 +724,9 @@ func restoreSimulator(saved state.SimulatorState) (*Simulator, error) { if sim.BatteryCapacityKWh <= 0 { sim.BatteryCapacityKWh = defaultBatteryCapacityKWh } + if saved.EMSResponseMs != nil && *saved.EMSResponseMs >= 0 { + sim.EMSResponse = time.Duration(*saved.EMSResponseMs) * time.Millisecond + } if sim.Automation.TimeMultiplier == 0 { sim.Automation.TimeMultiplier = 1.0 } @@ -1680,6 +1689,8 @@ func handleSimulatorStatus(w http.ResponseWriter, r *http.Request, sim *Simulato case "modbus": status["slave_id"] = sim.SlaveID status["battery_capacity_kwh"] = sim.BatteryCapacityKWh + status["ems_control"] = sim.EMSControl + status["ems_response_ms"] = sim.EMSResponse.Milliseconds() status["pv_power"] = getSemanticValue(sim, "pv_power") status["soc"] = getSemanticValue(sim, "battery_soc") status["load"] = getSemanticValue(sim, "battery_power") @@ -1710,6 +1721,10 @@ func handleSimulatorConfig(w http.ResponseWriter, r *http.Request, sim *Simulato Serial *string `json:"serial"` OCPPURL *string `json:"ocpp_url"` BatteryCapacityKWh *float64 `json:"battery_capacity_kwh"` + // EMSControl: a Modbus inverter's battery follows its EMS + // registers as a real Sungrow SH does, EMSResponseMs later. + EMSControl *bool `json:"ems_control"` + EMSResponseMs *int `json:"ems_response_ms"` // ProfileDelayS: an OCPP charger accepts a charging profile at // once and applies it this many seconds later (a cloud charger). ProfileDelayS *float64 `json:"profile_delay_s"` @@ -1755,6 +1770,14 @@ func handleSimulatorConfig(w http.ResponseWriter, r *http.Request, sim *Simulato if config.BatteryCapacityKWh != nil && *config.BatteryCapacityKWh > 0 { sim.BatteryCapacityKWh = *config.BatteryCapacityKWh } + if config.EMSResponseMs != nil && *config.EMSResponseMs >= 0 { + sim.EMSResponse = time.Duration(*config.EMSResponseMs) * time.Millisecond + } + if config.EMSControl != nil && sim.Protocol == "modbus" && *config.EMSControl != sim.EMSControl { + sim.EMSControl = *config.EMSControl + // Start from what the battery is doing now. + sim.ems = emsBattery{appliedW: getBatterySignedPower(sim)} + } if config.ProfileDelayS != nil && sim.OCPPServer != nil { sim.OCPPServer.SetProfileDelay(time.Duration(*config.ProfileDelayS * float64(time.Second))) } @@ -3751,6 +3774,11 @@ func buildStateSnapshot() *state.AppState { sState.OCPPURL = sim.OCPPServer.GetOCPPURL() sState.ProfileDelayS = sim.OCPPServer.ProfileDelay().Seconds() } + if sim.Protocol == "modbus" { + sState.EMSControl = sim.EMSControl + ms := int(sim.EMSResponse.Milliseconds()) + sState.EMSResponseMs = &ms + } sim.mu.RUnlock() appState.Simulators = append(appState.Simulators, sState) @@ -3959,6 +3987,12 @@ func updateBatteryFlags(sim *Simulator) { if sim == nil || sim.Server == nil { return } + sim.mu.RLock() + emsControl := sim.EMSControl + sim.mu.RUnlock() + if emsControl { + return // the EMS model sets the flags (ems.go) + } power := getSemanticValue(sim, "battery_power") if power == 0 { setSemanticValue(sim, "battery_flags", 0) @@ -4619,6 +4653,21 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { } } + // Batteries under EMS control answer what the EMS wrote, and in + // self-consumption what the meter just measured. + now := time.Now() + if meterCategory == "inverter" { + stepEMSBattery(meterSim, now, totalPower) + } + for _, simID := range simIDs { + if simID == meterID { + continue + } + if sim := getSimulator(simID); sim != nil { + stepEMSBattery(sim, now, totalPower) + } + } + // Sync site load to inverter load_power registers so the inverter dashboard // reflects the site-defined load (the site is the source of truth for load). siteLoad := sitePhaseLoads.Total() diff --git a/cmd/simulator/static/openapi.json b/cmd/simulator/static/openapi.json index dec0416..e6d027c 100644 --- a/cmd/simulator/static/openapi.json +++ b/cmd/simulator/static/openapi.json @@ -53,7 +53,15 @@ "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } ], - "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } } }, + "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { + "slave_id": { "type": "integer", "description": "Modbus slave ID" }, + "serial": { "type": "string" }, + "ocpp_url": { "type": "string", "description": "OCPP charger: Central System URL to dial. user:password in it is sent as Basic auth." }, + "battery_capacity_kwh": { "type": "number" }, + "profile_delay_s": { "type": "number", "description": "OCPP charger: accept a charging profile at once and apply it this many seconds later, like a cloud charger" }, + "ems_control": { "type": "boolean", "description": "Modbus inverter: the battery follows the EMS registers (13049-13051 on Sungrow) like a real hybrid; self-consumption outside forced mode" }, + "ems_response_ms": { "type": "integer", "description": "Modbus inverter: milliseconds the battery takes to follow the EMS registers (default 2000)" } + } } } } }, "responses": { "200": { "description": "Updated" } } } }, diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 4608307..a7d018e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -403,12 +403,14 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) { "ocpp_url": schemaString("OCPP URL"), "battery_capacity_kwh": schemaNumber("Battery capacity in kWh"), "profile_delay_s": schemaNumber("OCPP charger: seconds between accepting a charging profile and applying it"), + "ems_control": schemaBoolean("Modbus inverter: the battery follows the EMS registers (13049-13051 on Sungrow) like a real hybrid"), + "ems_response_ms": schemaInteger("Modbus inverter: milliseconds the battery takes to follow the EMS registers (default 2000)"), }, []string{"id"}), }, Method: http.MethodPost, PathTemplate: "/api/simulators/{id}/config", PathParams: []string{"id"}, - BodyParams: []string{"slave_id", "serial", "ocpp_url", "battery_capacity_kwh", "profile_delay_s"}, + BodyParams: []string{"slave_id", "serial", "ocpp_url", "battery_capacity_kwh", "profile_delay_s", "ems_control", "ems_response_ms"}, }, { Tool: Tool{ diff --git a/internal/state/state.go b/internal/state/state.go index 542cad1..bedb4c1 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -30,6 +30,8 @@ type SimulatorState struct { Profile string `json:"profile"` OCPPURL string `json:"ocpp_url"` ProfileDelayS float64 `json:"profile_delay_s,omitempty"` + EMSControl bool `json:"ems_control,omitempty"` + EMSResponseMs *int `json:"ems_response_ms,omitempty"` Automation AutomationState `json:"automation"` } From 2c910b333c583c1bd29ef163ee99c0c223aac375 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 08:49:33 +0200 Subject: [PATCH 7/9] feat(profiles): each model names its driver and carries its timing A profile now says which srcfl/device-drivers driver it emulates (driver_id, the file stem: sungrow, sdm630, pixii, ocpp...) and carries the model's timing in a model block, so field evidence for a driver can later set it. command_response_s replaces the two settings this branch added (ems_response_ms, profile_delay_s): it is how long the device takes to act on an accepted command, the battery after an EMS write or the charger after a charging profile. sungrow-hybrid says 2 s; applying a profile sets it, and POST /api/simulators/{id}/config overrides it. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 2 +- cmd/simulator/ems.go | 19 +++++--- cmd/simulator/main.go | 56 ++++++++++++----------- cmd/simulator/model_params_test.go | 60 +++++++++++++++++++++++++ cmd/simulator/site_phases_test.go | 30 ++++++++----- cmd/simulator/static/openapi.json | 3 +- internal/mcp/server.go | 5 +-- internal/profiles/loader.go | 71 ++++++++++++++++++++++++------ internal/state/state.go | 3 +- profiles/sungrow-hybrid.json | 4 ++ 11 files changed, 190 insertions(+), 65 deletions(-) create mode 100644 cmd/simulator/model_params_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1fe73c7..6031bdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ GET /api/simulators # List all simulators POST /api/simulators # Create simulator {type: "inverter"|"energy_meter"|"v2x_charger"|"ocpp_charger"} DELETE /api/simulators/{id} # Delete simulator GET /api/simulators/{id} # Get simulator details -POST /api/simulators/{id}/config # Serial, slave ID, OCPP URL, ems_control, profile_delay_s +POST /api/simulators/{id}/config # Serial, slave ID, OCPP URL, ems_control, command_response_s GET /api/simulators/{id}/registers # Get all registers POST /api/simulators/{id}/registers/{addr} # Write register value GET /api/simulators/{id}/logs # Get protocol operation logs diff --git a/README.md b/README.md index 7a4810d..4327881 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ All simulators are accessible through a unified API: ``` GET /api/simulators # List all simulators GET /api/simulators/{id} # Get simulator details -POST /api/simulators/{id}/config # Serial, slave ID, OCPP URL, ems_control, profile_delay_s +POST /api/simulators/{id}/config # Serial, slave ID, OCPP URL, ems_control, command_response_s GET /api/simulators/{id}/registers # Get all registers POST /api/simulators/{id}/registers/{addr} # Write register value GET /api/simulators/{id}/logs # Get Modbus operation logs diff --git a/cmd/simulator/ems.go b/cmd/simulator/ems.go index 522a07a..8b772ca 100644 --- a/cmd/simulator/ems.go +++ b/cmd/simulator/ems.go @@ -3,6 +3,8 @@ package main import ( "math" "time" + + "github.com/srcfl/device-simulator/internal/profiles" ) // EMS control for a Sungrow hybrid's battery. @@ -12,13 +14,13 @@ import ( // 13051 the forced power in watts. In any mode other than forced it runs // self-consumption, steering the battery against its own meter. Without // ems_control the simulator stores those writes and leaves the battery -// where the dashboard or a register write put it. +// where the dashboard or a register write put it. How long the battery takes +// to follow is the model's command_response_s (2 s for sungrow-hybrid). const ( - emsModeForced = 2 - emsCmdCharge = 170 - emsCmdDischarge = 187 - defaultEMSResponse = 2 * time.Second + emsModeForced = 2 + emsCmdCharge = 170 + emsCmdDischarge = 187 flagCharging = 0x0002 flagDischarging = 0x0004 @@ -92,3 +94,10 @@ func stepEMSBattery(sim *Simulator, now time.Time, siteW float64) { } setSemanticValue(sim, "battery_flags", flags) } + +func seconds(s float64) time.Duration { return time.Duration(s * float64(time.Second)) } + +// commandResponse is the model's command_response_s. +func commandResponse(p *profiles.Profile) time.Duration { + return seconds(p.Params().CommandResponseS) +} diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 7a773b6..77043c0 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -63,7 +63,7 @@ type Simulator struct { internalSOC float64 // High-precision SOC accumulator (avoids register truncation) socInitialized bool // Whether internalSOC has been initialized from register EMSControl bool `json:"ems_control"` // Battery follows the EMS registers (see ems.go) - EMSResponse time.Duration `json:"-"` // How long the battery takes to follow them + EMSResponse time.Duration `json:"-"` // How long it takes to act on a command (command_response_s) ems emsBattery mu sync.RWMutex } @@ -505,7 +505,6 @@ func createSimulator(simType string) (*Simulator, error) { SiteID: 0, // Orphan until assigned to a site DeviceOn: true, // Devices are "on" by default when added to site BatteryCapacityKWh: defaultBatteryCapacityKWh, - EMSResponse: defaultEMSResponse, ProfilesManager: pm, Automation: &AutomationState{ TimeMultiplier: 1.0, @@ -547,6 +546,7 @@ func createSimulator(simType string) (*Simulator, error) { // Apply profile defaults (overwrites RegisterSet defaults with profile-specific values) if profile := pm.GetActive(); profile != nil { + sim.EMSResponse = commandResponse(profile) for addr, value := range profile.Defaults { // Look up in the device's RegisterSet first, then fall back to global var def *modbus.RegisterDef @@ -586,6 +586,7 @@ func createSimulator(simType string) (*Simulator, error) { numConnectors = profile.OCPPConfig.NumConnectors maxPowerKW = profile.OCPPConfig.MaxPowerKW profileConfig = profile.OCPPConfig.Configuration + sim.EMSResponse = commandResponse(profile) } } @@ -593,6 +594,7 @@ func createSimulator(simType string) (*Simulator, error) { if profileConfig != nil { ocppServer.ApplyConfiguration(profileConfig) } + ocppServer.SetProfileDelay(sim.EMSResponse) sim.Serial = ocppServer.SerialNumber() sim.OCPPServer = ocppServer sim.Device = ocppServer @@ -713,7 +715,7 @@ func restoreSimulator(saved state.SimulatorState) (*Simulator, error) { DeviceOn: saved.DeviceOn, BatteryCapacityKWh: saved.BatteryCapacityKWh, EMSControl: saved.EMSControl, - EMSResponse: defaultEMSResponse, + EMSResponse: commandResponse(pm.GetActive()), ProfilesManager: pm, Automation: &AutomationState{ Enabled: saved.Automation.Enabled, @@ -724,8 +726,8 @@ func restoreSimulator(saved state.SimulatorState) (*Simulator, error) { if sim.BatteryCapacityKWh <= 0 { sim.BatteryCapacityKWh = defaultBatteryCapacityKWh } - if saved.EMSResponseMs != nil && *saved.EMSResponseMs >= 0 { - sim.EMSResponse = time.Duration(*saved.EMSResponseMs) * time.Millisecond + if saved.CommandResponseS != nil && *saved.CommandResponseS >= 0 { + sim.EMSResponse = seconds(*saved.CommandResponseS) } if sim.Automation.TimeMultiplier == 0 { sim.Automation.TimeMultiplier = 1.0 @@ -830,7 +832,7 @@ func restoreSimulator(saved state.SimulatorState) (*Simulator, error) { if saved.OCPPURL != "" { ocppServer.SetOCPPURL(saved.OCPPURL) } - ocppServer.SetProfileDelay(time.Duration(saved.ProfileDelayS * float64(time.Second))) + ocppServer.SetProfileDelay(sim.EMSResponse) sim.OCPPServer = ocppServer sim.Device = ocppServer if sim.Automation.Scenario == "" { @@ -1690,7 +1692,7 @@ func handleSimulatorStatus(w http.ResponseWriter, r *http.Request, sim *Simulato status["slave_id"] = sim.SlaveID status["battery_capacity_kwh"] = sim.BatteryCapacityKWh status["ems_control"] = sim.EMSControl - status["ems_response_ms"] = sim.EMSResponse.Milliseconds() + status["command_response_s"] = sim.EMSResponse.Seconds() status["pv_power"] = getSemanticValue(sim, "pv_power") status["soc"] = getSemanticValue(sim, "battery_soc") status["load"] = getSemanticValue(sim, "battery_power") @@ -1702,7 +1704,7 @@ func handleSimulatorStatus(w http.ResponseWriter, r *http.Request, sim *Simulato state := sim.OCPPServer.GetState() status["state"] = state status["ocpp_url"] = sim.OCPPServer.GetOCPPURL() - status["profile_delay_s"] = sim.OCPPServer.ProfileDelay().Seconds() + status["command_response_s"] = sim.OCPPServer.ProfileDelay().Seconds() } sim.mu.RUnlock() @@ -1722,12 +1724,12 @@ func handleSimulatorConfig(w http.ResponseWriter, r *http.Request, sim *Simulato OCPPURL *string `json:"ocpp_url"` BatteryCapacityKWh *float64 `json:"battery_capacity_kwh"` // EMSControl: a Modbus inverter's battery follows its EMS - // registers as a real Sungrow SH does, EMSResponseMs later. - EMSControl *bool `json:"ems_control"` - EMSResponseMs *int `json:"ems_response_ms"` - // ProfileDelayS: an OCPP charger accepts a charging profile at - // once and applies it this many seconds later (a cloud charger). - ProfileDelayS *float64 `json:"profile_delay_s"` + // registers as a real Sungrow SH does. + EMSControl *bool `json:"ems_control"` + // CommandResponseS overrides the model's command_response_s: the + // battery follows an EMS write, or an OCPP charger applies an + // accepted charging profile, this many seconds later. + CommandResponseS *float64 `json:"command_response_s"` } if err := json.NewDecoder(r.Body).Decode(&config); err != nil { @@ -1770,17 +1772,17 @@ func handleSimulatorConfig(w http.ResponseWriter, r *http.Request, sim *Simulato if config.BatteryCapacityKWh != nil && *config.BatteryCapacityKWh > 0 { sim.BatteryCapacityKWh = *config.BatteryCapacityKWh } - if config.EMSResponseMs != nil && *config.EMSResponseMs >= 0 { - sim.EMSResponse = time.Duration(*config.EMSResponseMs) * time.Millisecond + if config.CommandResponseS != nil && *config.CommandResponseS >= 0 { + sim.EMSResponse = seconds(*config.CommandResponseS) + if sim.OCPPServer != nil { + sim.OCPPServer.SetProfileDelay(sim.EMSResponse) + } } if config.EMSControl != nil && sim.Protocol == "modbus" && *config.EMSControl != sim.EMSControl { sim.EMSControl = *config.EMSControl // Start from what the battery is doing now. sim.ems = emsBattery{appliedW: getBatterySignedPower(sim)} } - if config.ProfileDelayS != nil && sim.OCPPServer != nil { - sim.OCPPServer.SetProfileDelay(time.Duration(*config.ProfileDelayS * float64(time.Second))) - } sim.mu.Unlock() markStateDirty() @@ -1977,10 +1979,17 @@ func handleProfile(w http.ResponseWriter, r *http.Request, sim *Simulator, profi } } } + sim.mu.Lock() + sim.EMSResponse = commandResponse(profile) + sim.mu.Unlock() case "ocpp": if profile.OCPPConfig != nil && sim.OCPPServer != nil { sim.OCPPServer.SetVendorModel(profile.OCPPConfig.Vendor, profile.OCPPConfig.Model) sim.OCPPServer.ApplyConfiguration(profile.OCPPConfig.Configuration) + sim.mu.Lock() + sim.EMSResponse = commandResponse(profile) + sim.mu.Unlock() + sim.OCPPServer.SetProfileDelay(commandResponse(profile)) log.Printf("[%s] Applied OCPP profile: %s (%s)", sim.Serial, profile.OCPPConfig.Model, profile.OCPPConfig.Vendor) } } @@ -3772,13 +3781,10 @@ func buildStateSnapshot() *state.AppState { } if sim.OCPPServer != nil { sState.OCPPURL = sim.OCPPServer.GetOCPPURL() - sState.ProfileDelayS = sim.OCPPServer.ProfileDelay().Seconds() - } - if sim.Protocol == "modbus" { - sState.EMSControl = sim.EMSControl - ms := int(sim.EMSResponse.Milliseconds()) - sState.EMSResponseMs = &ms } + sState.EMSControl = sim.EMSControl + cr := sim.EMSResponse.Seconds() + sState.CommandResponseS = &cr sim.mu.RUnlock() appState.Simulators = append(appState.Simulators, sState) diff --git a/cmd/simulator/model_params_test.go b/cmd/simulator/model_params_test.go new file mode 100644 index 0000000..a83ef95 --- /dev/null +++ b/cmd/simulator/model_params_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/srcfl/device-simulator/internal/profiles" +) + +// Each model names the device-drivers driver it emulates, so field evidence +// for that driver can reach the model later. +func TestProfilesNameTheirDriver(t *testing.T) { + pm := profiles.NewManager(t.TempDir()) + pm.InitEmbedded() + generic := map[string]bool{"residential-5kw": true, "commercial-50kw": true, "default": true} + for _, info := range pm.List() { + if info.DriverID == "" && !generic[info.Name] { + t.Errorf("profile %s names no driver", info.Name) + } + } + if got := pm.Get("sungrow-hybrid").DriverID; got != "sungrow" { + t.Errorf("sungrow-hybrid emulates %q, want sungrow", got) + } +} + +// The model's command_response_s reaches the device when its profile is +// applied, and a simulator's config can override it. +func TestCommandResponseComesFromTheModel(t *testing.T) { + sim, err := createSimulator("inverter") + if err != nil { + t.Fatal(err) + } + defer removeTestSimulators(sim.ID) + rec := httptest.NewRecorder() + handleProfile(rec, httptest.NewRequest("POST", "/", nil), sim, "sungrow-hybrid/apply") + if rec.Code != 200 { + t.Fatalf("apply: %d %s", rec.Code, rec.Body) + } + if sim.EMSResponse != 2*time.Second { + t.Fatalf("command response %v after applying sungrow-hybrid, want 2s", sim.EMSResponse) + } + + rec = httptest.NewRecorder() + handleSimulatorConfig(rec, httptest.NewRequest("POST", "/", jsonBody(`{"command_response_s": 0.5}`)), sim) + if sim.EMSResponse != 500*time.Millisecond { + t.Fatalf("command response %v after config 0.5 s", sim.EMSResponse) + } + + cp, err := createSimulator("ocpp_charger") + if err != nil { + t.Fatal(err) + } + defer removeTestSimulators(cp.ID) + rec = httptest.NewRecorder() + handleSimulatorConfig(rec, httptest.NewRequest("POST", "/", jsonBody(`{"command_response_s": 60}`)), cp) + if got := cp.OCPPServer.ProfileDelay(); got != time.Minute { + t.Fatalf("charger profile delay %v, want 1m", got) + } +} diff --git a/cmd/simulator/site_phases_test.go b/cmd/simulator/site_phases_test.go index cfa59df..6df12c5 100644 --- a/cmd/simulator/site_phases_test.go +++ b/cmd/simulator/site_phases_test.go @@ -2,6 +2,7 @@ package main import ( "math" + "strings" "testing" "time" @@ -21,18 +22,7 @@ func testSite(t *testing.T, houseW float64, members ...*Simulator) (*Site, *Simu for _, m := range members { ids = append(ids, m.ID) } - t.Cleanup(func() { - // Not deleteSimulator: it expects the servers main() starts. - simulatorsMu.Lock() - defer simulatorsMu.Unlock() - kept := simulators[:0] - for _, sim := range simulators { - if sim != meter && !containsInt(ids, sim.ID) { - kept = append(kept, sim) - } - } - simulators = kept - }) + t.Cleanup(func() { removeTestSimulators(append(ids, meter.ID)...) }) site := &Site{ ID: -1, MeterID: meter.ID, @@ -96,6 +86,22 @@ func TestSiteMeterPutsOnePhaseChargerOnL1(t *testing.T) { } } +// removeTestSimulators drops simulators a test created. Not +// deleteSimulator: it expects the servers main() starts. +func removeTestSimulators(ids ...int) { + simulatorsMu.Lock() + defer simulatorsMu.Unlock() + kept := simulators[:0] + for _, sim := range simulators { + if !containsInt(ids, sim.ID) { + kept = append(kept, sim) + } + } + simulators = kept +} + +func jsonBody(s string) *strings.Reader { return strings.NewReader(s) } + func containsInt(xs []int, x int) bool { for _, v := range xs { if v == x { diff --git a/cmd/simulator/static/openapi.json b/cmd/simulator/static/openapi.json index e6d027c..c6eb456 100644 --- a/cmd/simulator/static/openapi.json +++ b/cmd/simulator/static/openapi.json @@ -58,9 +58,8 @@ "serial": { "type": "string" }, "ocpp_url": { "type": "string", "description": "OCPP charger: Central System URL to dial. user:password in it is sent as Basic auth." }, "battery_capacity_kwh": { "type": "number" }, - "profile_delay_s": { "type": "number", "description": "OCPP charger: accept a charging profile at once and apply it this many seconds later, like a cloud charger" }, "ems_control": { "type": "boolean", "description": "Modbus inverter: the battery follows the EMS registers (13049-13051 on Sungrow) like a real hybrid; self-consumption outside forced mode" }, - "ems_response_ms": { "type": "integer", "description": "Modbus inverter: milliseconds the battery takes to follow the EMS registers (default 2000)" } + "command_response_s": { "type": "number", "description": "Seconds the device takes to act on an accepted command: the battery after an EMS write, an OCPP charger after a charging profile (accepted at once, like a cloud charger). Overrides the model's command_response_s." } } } } } }, "responses": { "200": { "description": "Updated" } } } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index a7d018e..ae6e9cc 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -402,15 +402,14 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) { "serial": schemaString("Simulator serial"), "ocpp_url": schemaString("OCPP URL"), "battery_capacity_kwh": schemaNumber("Battery capacity in kWh"), - "profile_delay_s": schemaNumber("OCPP charger: seconds between accepting a charging profile and applying it"), "ems_control": schemaBoolean("Modbus inverter: the battery follows the EMS registers (13049-13051 on Sungrow) like a real hybrid"), - "ems_response_ms": schemaInteger("Modbus inverter: milliseconds the battery takes to follow the EMS registers (default 2000)"), + "command_response_s": schemaNumber("Seconds the device takes to act on an accepted command (battery after an EMS write, OCPP charger after a profile); overrides the model's value"), }, []string{"id"}), }, Method: http.MethodPost, PathTemplate: "/api/simulators/{id}/config", PathParams: []string{"id"}, - BodyParams: []string{"slave_id", "serial", "ocpp_url", "battery_capacity_kwh", "profile_delay_s", "ems_control", "ems_response_ms"}, + BodyParams: []string{"slave_id", "serial", "ocpp_url", "battery_capacity_kwh", "ems_control", "command_response_s"}, }, { Tool: Tool{ diff --git a/internal/profiles/loader.go b/internal/profiles/loader.go index bee1ebb..d1a7ea7 100644 --- a/internal/profiles/loader.go +++ b/internal/profiles/loader.go @@ -39,17 +39,34 @@ type OCPPConfig struct { DefaultStatus string `json:"default_status,omitempty"` } +// ModelParams are a device model's timing and quirks. They live with the +// model, which names the device-drivers driver it emulates, so they can +// later come from that driver's field evidence. One simulator may override +// them through its config. +type ModelParams struct { + // CommandResponseS is how long the device takes to act on an accepted + // command: a hybrid's battery after an EMS write, an OCPP charger's + // output after a charging profile. + CommandResponseS float64 `json:"command_response_s,omitempty"` +} + // Profile defines a device simulation profile type Profile struct { Name string `json:"name"` Description string `json:"description"` Protocol string `json:"protocol"` // modbus, mqtt, ocpp DeviceType string `json:"device_type"` - Categories []string `json:"categories,omitempty"` // pv, battery, meter, grid, control, ev, v2x, charger - EVCCRef string `json:"evcc_ref,omitempty"` // Link to EVCC template + Categories []string `json:"categories,omitempty"` // pv, battery, meter, grid, control, ev, v2x, charger + EVCCRef string `json:"evcc_ref,omitempty"` // Link to EVCC template ZapVerified bool `json:"zap_verified,omitempty"` // True if register map is synced with zap-x firmware RegisterSetName string `json:"register_set_name,omitempty"` // Maps to devices.GetRegisterSet() for native register addresses + // DriverID is the srcfl/device-drivers driver this model emulates, by + // its file name without .lua (sungrow, sdm630), or ocpp for FTW's OCPP + // Central System. Empty for generic models. + DriverID string `json:"driver_id,omitempty"` + Model *ModelParams `json:"model,omitempty"` + // Modbus-specific Defaults map[uint16]float64 `json:"defaults,omitempty"` @@ -117,6 +134,14 @@ func (m *Manager) loadFile(path string) (*Profile, error) { return &profile, nil } +// Params returns the profile's model parameters, zero when it has none. +func (p *Profile) Params() ModelParams { + if p == nil || p.Model == nil { + return ModelParams{} + } + return *p.Model +} + // Get returns a profile by name func (m *Manager) Get(name string) *Profile { m.mu.RLock() @@ -137,6 +162,7 @@ func (m *Manager) List() []ProfileInfo { Protocol: p.Protocol, DeviceType: p.DeviceType, ZapVerified: p.ZapVerified, + DriverID: p.DriverID, }) } @@ -203,6 +229,7 @@ func (m *Manager) ListByProtocolAndCategory(protocol string, category string) [] Protocol: p.Protocol, DeviceType: p.DeviceType, ZapVerified: p.ZapVerified, + DriverID: p.DriverID, }) } @@ -215,6 +242,7 @@ func (m *Manager) ListByProtocolAndCategory(protocol string, category string) [] // ProfileInfo is a summary of a profile type ProfileInfo struct { + DriverID string `json:"driver_id,omitempty"` Name string `json:"name"` Description string `json:"description"` Protocol string `json:"protocol"` @@ -275,6 +303,8 @@ func (m *Manager) GetActiveName() string { var EmbeddedProfiles = map[string]*Profile{ "sungrow-hybrid": { Name: "sungrow-hybrid", + DriverID: "sungrow", + Model: &ModelParams{CommandResponseS: 2}, // the battery follows an EMS write about 2 s later Description: "Sungrow SH Series Hybrid Inverter", Protocol: "modbus", DeviceType: "sungrow", @@ -323,6 +353,7 @@ var EmbeddedProfiles = map[string]*Profile{ }, "solis-hybrid": { Name: "solis-hybrid", + DriverID: "solis", Description: "Solis Hybrid Inverter", Protocol: "modbus", DeviceType: "solis", @@ -345,6 +376,7 @@ var EmbeddedProfiles = map[string]*Profile{ }, "solaredge": { Name: "solaredge", + DriverID: "solaredge", Description: "SolarEdge Inverter with StorEdge", Protocol: "modbus", DeviceType: "solaredge", @@ -362,6 +394,7 @@ var EmbeddedProfiles = map[string]*Profile{ }, "sma-tripower": { Name: "sma-tripower", + DriverID: "sma", Description: "SMA Sunny Tripower Inverter", Protocol: "modbus", DeviceType: "sma", @@ -370,15 +403,16 @@ var EmbeddedProfiles = map[string]*Profile{ ZapVerified: true, RegisterSetName: "sma", Defaults: map[uint16]float64{ - 30775: 0, // PV Power - 30845: 70, // Battery SoC - 30847: 0, // Battery Power - 30865: 0, // Meter Power + 30775: 0, // PV Power + 30845: 70, // Battery SoC + 30847: 0, // Battery Power + 30865: 0, // Meter Power 30803: 50, // Grid Frequency (50.00 Hz, scale 0.01) }, }, "huawei-sun2000": { Name: "huawei-sun2000", + DriverID: "huawei", Description: "Huawei SUN2000 Hybrid Inverter", Protocol: "modbus", DeviceType: "huawei", @@ -387,8 +421,8 @@ var EmbeddedProfiles = map[string]*Profile{ ZapVerified: true, RegisterSetName: "huawei", Defaults: map[uint16]float64{ - 32064: 0, // PV Power - 37001: 0, // Battery Power + 32064: 0, // PV Power + 37001: 0, // Battery Power 37004: 65, // Battery SoC (65.0%, scale 0.1) 37113: 0, // Meter Power 32089: 700, // Load Power @@ -397,6 +431,7 @@ var EmbeddedProfiles = map[string]*Profile{ }, "fronius-gen24": { Name: "fronius-gen24", + DriverID: "fronius", Description: "Fronius GEN24 Plus Hybrid", Protocol: "modbus", DeviceType: "fronius", @@ -414,6 +449,7 @@ var EmbeddedProfiles = map[string]*Profile{ }, "deye-hybrid": { Name: "deye-hybrid", + DriverID: "deye", Description: "Deye Hybrid Inverter", Protocol: "modbus", DeviceType: "deye", @@ -421,16 +457,17 @@ var EmbeddedProfiles = map[string]*Profile{ ZapVerified: true, RegisterSetName: "deye", Defaults: map[uint16]float64{ - 672: 0, // PV Power - 590: 0, // Battery Power - 588: 80, // Battery SoC - 619: 0, // Meter Power - 653: 450, // Load Power - 79: 50, // Grid Frequency (50.00 Hz, scale 0.01) + 672: 0, // PV Power + 590: 0, // Battery Power + 588: 80, // Battery SoC + 619: 0, // Meter Power + 653: 450, // Load Power + 79: 50, // Grid Frequency (50.00 Hz, scale 0.01) }, }, "pixii-powershaper": { Name: "pixii-powershaper", + DriverID: "pixii", Description: "Pixii PowerShaper 2 (Battery Only)", Protocol: "modbus", DeviceType: "pixii", @@ -446,6 +483,7 @@ var EmbeddedProfiles = map[string]*Profile{ }, "ferroamp-energyhub": { Name: "ferroamp-energyhub", + DriverID: "ferroamp_modbus", Description: "FerroAmp EnergyHub", Protocol: "modbus", DeviceType: "ferroamp", @@ -459,6 +497,7 @@ var EmbeddedProfiles = map[string]*Profile{ }, "sdm630": { Name: "sdm630", + DriverID: "sdm630", Description: "Eastron SDM630 Energy Meter (Meter Only)", Protocol: "modbus", DeviceType: "sdm630", @@ -495,6 +534,7 @@ var EmbeddedProfiles = map[string]*Profile{ }, "fronius-smart-meter": { Name: "fronius-smart-meter", + DriverID: "fronius_smart_meter", Description: "Fronius Smart Meter TS 65A-3 (SunSpec)", Protocol: "modbus", DeviceType: "fronius", @@ -596,6 +636,7 @@ var EmbeddedProfiles = map[string]*Profile{ // V2X Charger profiles (MQTT) "ambibox": { Name: "ambibox", + DriverID: "ambibox_v2x", Description: "Ambibox V2X Bidirectional Charger", Protocol: "mqtt", DeviceType: "ambibox", @@ -636,6 +677,7 @@ var EmbeddedProfiles = map[string]*Profile{ // OCPP Charger profiles "ocpp-charger": { Name: "ocpp-charger", + DriverID: "ocpp", Description: "Generic OCPP 1.6 EV Charger", Protocol: "ocpp", DeviceType: "ocpp-charger", @@ -658,6 +700,7 @@ var EmbeddedProfiles = map[string]*Profile{ // Charge Amps DAWN "chargeamps-dawn": { Name: "chargeamps-dawn", + DriverID: "ocpp", Description: "Charge Amps DAWN (7.7kW, 3-phase)", Protocol: "ocpp", DeviceType: "chargeamps", diff --git a/internal/state/state.go b/internal/state/state.go index bedb4c1..3ba40f0 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -29,9 +29,8 @@ type SimulatorState struct { BatteryCapacityKWh float64 `json:"battery_capacity_kwh"` Profile string `json:"profile"` OCPPURL string `json:"ocpp_url"` - ProfileDelayS float64 `json:"profile_delay_s,omitempty"` EMSControl bool `json:"ems_control,omitempty"` - EMSResponseMs *int `json:"ems_response_ms,omitempty"` + CommandResponseS *float64 `json:"command_response_s,omitempty"` Automation AutomationState `json:"automation"` } diff --git a/profiles/sungrow-hybrid.json b/profiles/sungrow-hybrid.json index b58149a..407b344 100644 --- a/profiles/sungrow-hybrid.json +++ b/profiles/sungrow-hybrid.json @@ -5,6 +5,10 @@ "device_type": "sungrow", "zap_verified": true, "register_set_name": "sungrow", + "driver_id": "sungrow", + "model": { + "command_response_s": 2 + }, "defaults": { "5016": 0, "13021": 0, From 6e5b87e0959b820c2ffe47d8d3d84393c021d97c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 10:53:15 +0200 Subject: [PATCH 8/9] fix: zero battery limits, phases in composite schedules, stop ids after a queue Codex's review of #8: - A Sungrow limit register holding 0 now allows no power that way; before, 0 turned the clamp off. - GetCompositeSchedule reports the phases the charger draws on: 16 A on one phase, not 5.3 A on three. A change of phases starts a new period. - A session that starts and ends offline is stopped under the transaction id the Central System gives its queued start. Co-Authored-By: Claude Opus 5.5 --- cmd/simulator/ems.go | 10 +++--- cmd/simulator/ems_test.go | 15 +++++++++ internal/ocpp/client_test.go | 60 ++++++++++++++++++++++++++++++++++ internal/ocpp/ocpptest/csms.go | 33 +++++++++++++++---- internal/ocpp/server.go | 58 ++++++++++++++++++++++---------- 5 files changed, 148 insertions(+), 28 deletions(-) diff --git a/cmd/simulator/ems.go b/cmd/simulator/ems.go index 8b772ca..3515dcc 100644 --- a/cmd/simulator/ems.go +++ b/cmd/simulator/ems.go @@ -52,11 +52,13 @@ func emsTargetW(sim *Simulator, siteW, appliedW float64) float64 { } else { w = appliedW - siteW } - if limit := getSemanticValue(sim, "max_charge_power") * 1000; limit > 0 && w > limit { - w = limit + // A model without the limit registers has no limit. One that has them + // enforces each, 0 included, as an SH does. + if getSemanticDef(sim, "max_charge_power") != nil { + w = math.Min(w, getSemanticValue(sim, "max_charge_power")*1000) } - if limit := getSemanticValue(sim, "max_discharge_power") * 1000; limit > 0 && w < -limit { - w = -limit + if getSemanticDef(sim, "max_discharge_power") != nil { + w = math.Max(w, -getSemanticValue(sim, "max_discharge_power")*1000) } soc := getSemanticValue(sim, "battery_soc") if maxSoC := getSemanticValue(sim, "battery_max_soc"); maxSoC > 0 && soc >= maxSoC && w > 0 { diff --git a/cmd/simulator/ems_test.go b/cmd/simulator/ems_test.go index aa7b932..4ac5634 100644 --- a/cmd/simulator/ems_test.go +++ b/cmd/simulator/ems_test.go @@ -72,6 +72,21 @@ func TestEMSForcedModeDrivesTheBattery(t *testing.T) { } } +// A limit register holding 0 allows no power that way. +func TestEMSKeepsZeroPowerLimits(t *testing.T) { + inv, site, meter := sungrowInSite(t, 1000, 50, true) + setSemanticValue(inv, "max_charge_power", 0) + writeEMS(inv, 2, emsCmdCharge, 3000) + if got := tick(site, meter, 2) - 3000; math.Abs(got) > 1 { + t.Fatalf("charge limit 0: battery %.0f W, want 0", got) + } + setSemanticValue(inv, "max_discharge_power", 0) + writeEMS(inv, 0, 204, 0) + if got := tick(site, meter, 3); math.Abs(got-3000) > 1 { + t.Fatalf("discharge limit 0: meter %.0f W, want the house alone", got) + } +} + // Self-consumption (any mode but forced) cancels the meter, as a real SH // does without an EMS. func TestEMSSelfConsumptionCancelsTheMeter(t *testing.T) { diff --git a/internal/ocpp/client_test.go b/internal/ocpp/client_test.go index 8e16978..7684934 100644 --- a/internal/ocpp/client_test.go +++ b/internal/ocpp/client_test.go @@ -1,6 +1,7 @@ package ocpp import ( + "encoding/json" "math" "testing" "time" @@ -99,6 +100,47 @@ func TestStartTransactionWaitsForTheConnection(t *testing.T) { } } +// A session that starts and ends offline is stopped under the transaction +// id the Central System gives its start, not the charger's local one. +func TestQueuedStopNamesTheAssignedTransaction(t *testing.T) { + csms := ocpptest.New(t) + csms.SetClosed(true) + s := newTestServer() + s.reconnectDelay = 50 * time.Millisecond + s.SetOCPPURL(csms.URL("")) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Stop() }) + + if err := s.SetValue("evConnected", true); err != nil { + t.Fatal(err) + } + waitFor(t, "local transaction", 5*time.Second, func() bool { + s.txQueueMu.Lock() + defer s.txQueueMu.Unlock() + return len(s.txQueue) == 1 + }) + if err := s.SetValue("evConnected", false); err != nil { + t.Fatal(err) + } + waitFor(t, "queued stop", 5*time.Second, func() bool { + s.txQueueMu.Lock() + defer s.txQueueMu.Unlock() + return len(s.txQueue) == 2 + }) + + csms.SetClosed(false) + waitFor(t, "StopTransaction", 5*time.Second, func() bool { return csms.Sent(ActionStopTransaction) == 1 }) + var stop StopTransactionRequest + if err := json.Unmarshal(csms.Payloads(ActionStopTransaction)[0], &stop); err != nil { + t.Fatal(err) + } + if stop.TransactionId != 42 { + t.Fatalf("StopTransaction names transaction %d, want 42 from the start's answer", stop.TransactionId) + } +} + func effectiveW(s *Server) float64 { s.mu.RLock() defer s.mu.RUnlock() @@ -201,6 +243,24 @@ func TestOnePhaseProfileDrawsOnL1(t *testing.T) { } } +// The composite schedule reports what the charger draws: 16 A on one phase, +// not a third of it on three. +func TestCompositeScheduleKeepsThePhases(t *testing.T) { + s := charging(t, 22.08, true, txProfileA(16, 1)) + r := s.handleGetCompositeSchedule(GetCompositeScheduleRequest{ConnectorId: 1, Duration: 60, ChargingRateUnit: "A"}) + if r.Status != "Accepted" { + t.Fatalf("status %s", r.Status) + } + p := r.ChargingSchedule.ChargingSchedulePeriod + if len(p) != 1 || math.Abs(p[0].Limit-16) > 0.5 || p[0].NumberPhases == nil || *p[0].NumberPhases != 1 { + t.Fatalf("periods %+v, want one of 16 A on 1 phase", p) + } + r = s.handleGetCompositeSchedule(GetCompositeScheduleRequest{ConnectorId: 1, Duration: 60, ChargingRateUnit: "W"}) + if p := r.ChargingSchedule.ChargingSchedulePeriod; len(p) != 1 || math.Abs(p[0].Limit-3680) > 60 || *p[0].NumberPhases != 1 { + t.Fatalf("periods %+v, want one of 3680 W on 1 phase", p) + } +} + // On one phase a charger gives at most its rated current per phase. func TestOnePhaseDrawIsCappedAtRatedCurrent(t *testing.T) { s := charging(t, 11.04, true, txProfileA(24, 1)) // a 16 A charger diff --git a/internal/ocpp/ocpptest/csms.go b/internal/ocpp/ocpptest/csms.go index f396bbd..7e465e9 100644 --- a/internal/ocpp/ocpptest/csms.go +++ b/internal/ocpp/ocpptest/csms.go @@ -22,13 +22,14 @@ type CSMS struct { t testing.TB srv *httptest.Server - mu sync.Mutex - closed bool - paths []string - auths []string - actions []string - conn *websocket.Conn - seq int + mu sync.Mutex + closed bool + paths []string + auths []string + actions []string + payloads []json.RawMessage + conn *websocket.Conn + seq int } // New starts a Central System that stops with the test. @@ -77,8 +78,13 @@ func New(t testing.TB) *CSMS { result = map[string]interface{}{"transactionId": 42, "idTagInfo": map[string]string{"status": "Accepted"}} } out, _ := json.Marshal([]interface{}{3, id, result}) + var payload json.RawMessage + if len(frame) > 3 { + payload = frame[3] + } f.mu.Lock() f.actions = append(f.actions, action) + f.payloads = append(f.payloads, payload) c.WriteMessage(websocket.TextMessage, out) f.mu.Unlock() } @@ -131,6 +137,19 @@ func (f *CSMS) Sent(action string) int { return n } +// Payloads lists the payloads of one action's CALLs, in order. +func (f *CSMS) Payloads(action string) []json.RawMessage { + f.mu.Lock() + defer f.mu.Unlock() + var out []json.RawMessage + for i, a := range f.actions { + if a == action { + out = append(out, f.payloads[i]) + } + } + return out +} + // Actions lists the charge point's CALLs in the order they arrived. func (f *CSMS) Actions() []string { f.mu.Lock() diff --git a/internal/ocpp/server.go b/internal/ocpp/server.go index 5eb28d9..64099cc 100644 --- a/internal/ocpp/server.go +++ b/internal/ocpp/server.go @@ -108,6 +108,10 @@ type Server struct { // charge point keeps them and delivers them once it is back online. txQueueMu sync.Mutex txQueue []queuedTx + // txAssigned maps a local transaction id to the Central System's, for a + // session that ended before its StartTransaction was answered: its + // queued StopTransaction still names the local id. Guarded by mu. + txAssigned map[int]int // profileDelay models a cloud charger: it accepts a charging profile at // once and applies it this much later. Zero applies it at once. @@ -158,6 +162,7 @@ func NewServer(id int, port int, vendor, model string, numConnectors int, maxPow maxPowerKW: maxPowerKW, connectors: connectors, transactions: make(map[int]*Transaction), + txAssigned: make(map[int]int), chargingProfiles: make(map[string]*storedProfile), configuration: map[string]string{ "HeartbeatInterval": "60", @@ -1172,30 +1177,31 @@ func (s *Server) handleGetCompositeSchedule(req GetCompositeScheduleRequest) *Ge } var periods []ChargingSchedulePeriod - lastLimit := -1.0 + lastLimit, lastPhases := -1.0, 0 for offset := 0; offset <= req.Duration; offset += step { t := now.Add(time.Duration(offset) * time.Second) - powerW := s.computeEffectivePowerLocked(connectorId, t) + powerW, phases := s.computeEffectiveLocked(connectorId, t) var limit float64 if unit == "A" { avgV := s.averagePhaseVoltageLocked() if avgV > 0 { - limit = math.Round(powerW/(3.0*avgV)*10) / 10 // round to 0.1A + limit = math.Round(powerW/(float64(phases)*avgV)*10) / 10 // round to 0.1A } } else { limit = math.Round(powerW) } - if limit != lastLimit { - phases := 3 + // A change of phases is a new period even at the same limit. + if limit != lastLimit || phases != lastPhases { + n := phases periods = append(periods, ChargingSchedulePeriod{ StartPeriod: int64(offset), Limit: limit, - NumberPhases: &phases, + NumberPhases: &n, }) - lastLimit = limit + lastLimit, lastPhases = limit, phases } } @@ -1652,6 +1658,15 @@ func (s *Server) sendStartTransaction(connectorId int, idTag string, meterStart MeterStart: meterStart, Timestamp: FormatTimestamp(time.Now()), } + localID := 0 + s.mu.RLock() + for id, tx := range s.transactions { + if tx.ConnectorID == connectorId && tx.IdTag == idTag { + localID = id + break + } + } + s.mu.RUnlock() s.sendTxRequest(ActionStartTransaction, req, func(resp json.RawMessage) { var txResp StartTransactionResponse @@ -1660,16 +1675,15 @@ func (s *Server) sendStartTransaction(connectorId int, idTag string, meterStart } log.Printf("[%s] Transaction started, server ID: %d", s.serialNumber, txResp.TransactionId) - // Update our local transaction with the server's transaction ID + // Rename the local transaction to the server's ID, or, if it has + // already ended, remember the ID for its queued StopTransaction. s.mu.Lock() - for localId, tx := range s.transactions { - if tx.ConnectorID == connectorId && tx.IdTag == idTag { - // Remove old entry and add with server's ID - delete(s.transactions, localId) - tx.ID = txResp.TransactionId - s.transactions[txResp.TransactionId] = tx - break - } + if tx := s.transactions[localID]; tx != nil { + delete(s.transactions, localID) + tx.ID = txResp.TransactionId + s.transactions[txResp.TransactionId] = tx + } else if localID != 0 { + s.txAssigned[localID] = txResp.TransactionId } s.mu.Unlock() }) @@ -1715,7 +1729,17 @@ func (s *Server) flushTxQueue() { s.txQueue = nil s.txQueueMu.Unlock() - for i, m := range queue { + for i := range queue { + m := &queue[i] + if stop, ok := m.payload.(StopTransactionRequest); ok { + s.mu.Lock() + if id, ok := s.txAssigned[stop.TransactionId]; ok { + delete(s.txAssigned, stop.TransactionId) + stop.TransactionId = id + m.payload = stop + } + s.mu.Unlock() + } resp, err := s.sendRequest(m.action, m.payload) if errors.Is(err, errNotConnected) { // Offline again: keep the rest, in front of anything newer. From 1d7be38ef267d6eef566c2fa9a453927dc148dbd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 23 Oct 2026 08:03:29 +0200 Subject: [PATCH 9/9] minor: headless runs from a house spec, with a timeline and a record (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ocpp): one heartbeat and meter loop per connection, DropConnection Each connection to a Central System started another heartbeat, MeterValues and simulation loop, and nothing ended them but Stop. After one reconnect a charger sent every heartbeat and MeterValues twice and integrated its energy twice a second. Heartbeats and MeterValues now end with their connection; the simulation runs once for the charger's life, connected or not, since a car charges through a network outage. Two hooks for scripted runs: DropConnection(d) closes the connection as a lost network does and keeps the charger away for d, with the session going on; SetFrameLog reports every OCPP-J frame the charger reads or writes. Co-Authored-By: Claude Opus 5.5 * feat(ocpp): the car's own limits (SetCar) A car takes at most its own current per phase and charges on its own phases: a one-phase car under a three-phase order draws on L1 only, and a car that takes 10 A draws 10 A of a 16 A offer. The charger's offer (effectivePowerW) is unchanged; its draw follows the car. Co-Authored-By: Claude Opus 5.5 * feat(run): headless runs from a house spec, with a timeline and a record simulator run -spec house.json -port-base N -out DIR builds one house: devices by the device-drivers driver they emulate (a hybrid whose battery follows its EMS writes, OCPP chargers with their car), the site meter behind a fuse per phase, and the house load. It serves them on ports from the base, applies a timeline of events (loads, pv, soc, plug, car, offline, online, reconnect, mark, stop), and takes the same events over POST /api/run/event while it runs. It records a sample a second of what flows through each fuse, the fuse's damage and each device's state to plant.jsonl, every OCPP frame per charger, ports.json and result.json. Self-update endpoints are off in a run. The site loop read Running without the simulator's lock while a stop wrote it; it now takes the lock. Co-Authored-By: Claude Opus 5.5 * fix(run): an outage asked for during a retry keeps its length; "meter" is reserved Codex's review of #10: - A reconnect event while the charger was already waiting to retry only moved a deadline nobody read; the retry dialled after the usual delay. Each dial now waits out DropConnection's deadline first. - A device called "meter" lost its ports.json entry to the site meter. The spec now refuses that id. Co-Authored-By: Claude Opus 5.5 * test(run): draw port bases below the ephemeral range freeBase took a base from the kernel's next ephemeral port and skipped those over 65000. macOS hands ephemeral ports out in turn, so once its cursor sat near the top every try was skipped and every run test failed with "no free port range". Co-Authored-By: Claude Opus 5.5 * feat(run): the fuse cabinet's air and a warm start With the thermal fuse on main (#9), a run's spec can set the cabinet's air (site.fuse_ambient_c, default 25 °C) and start the fuses warm (site.fuse_preheat_a, a current per phase they have carried long enough to settle), as a house is in the evening. A fuse size the profile does not model is refused before the run touches the simulator's settings. The site loop now reads its tick before it starts: a run sets the tick before it builds its site, and the next run could set it again while the last loop was still reading it (the race detector caught it once runs followed each other within milliseconds). Co-Authored-By: Claude Opus 5.5 * minor: faults and seeded random timelines for headless runs (#11) * feat(run): faults and seeded random timelines Faults for an EMS under test, as events in a headless run and over /api/run/event: a Modbus device that keeps its connections and answers nothing (silence), a site meter whose readings stand still (meter_late), one reading whose total disagrees with its phases (meter_glitch), phase currents that carry a sign (signed_currents), and a charger's own cap per phase (load_balancer). New model parameters, overridable per device: startup_delay_s (the car draws that long after an offer rises from zero) and load_balancer_a for an OCPP charger, signed_phase_currents for a meter. A spec's random block draws a timeline from a seed within the house's bounds (house load per phase under the fuse less 2 A), picks the open timing of each device, and writes timeline.json, a spec that replays it. Co-Authored-By: Claude Opus 5.5 * fix(run): each session waits out the startup delay; faults keep the latest; a late meter keeps its energy Codex's review of #11: - offerSince now resets when a connector leaves its session, so every session waits out startup_delay_s, not only the first. - An earlier silence's or outage's timer no longer ends a later one: each device counts its faults and a timer acts only for the latest. - Energy that flows during meter_late shows on the counters when the readings move again; before, it was lost. Co-Authored-By: Claude Opus 5.5 * feat(ocpp): a charger wired on one phase wired_phases: 1 in a run spec (SetWiredPhases) wires a charger on L1. It gives an order's current there whatever numberPhases says, and max_kw is its rating as wired, so a 16 A order on a 3.68 kW charger draws 16 A on L1. Before, the order became 16 A x 3 x 230 W, was capped to 3.68 kW and spread over three phases, and a one-phase car took 5.3 A of it. FTW's site test needs it for a charger wired on one phase, under Charge now and planned charging. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CLAUDE.md | 9 + README.md | 6 + cmd/simulator/main.go | 177 ++++-- cmd/simulator/run.go | 899 ++++++++++++++++++++++++++++++ cmd/simulator/run_random.go | 197 +++++++ cmd/simulator/run_test.go | 385 +++++++++++++ cmd/simulator/static/openapi.json | 32 ++ docs/HEADLESS_RUN.md | 120 ++++ internal/mcp/server.go | 41 ++ internal/modbus/server.go | 19 +- internal/modbus/server_test.go | 38 ++ internal/ocpp/client_test.go | 195 +++++++ internal/ocpp/ocpptest/csms.go | 22 +- internal/ocpp/server.go | 228 +++++++- internal/profiles/loader.go | 23 + 15 files changed, 2312 insertions(+), 79 deletions(-) create mode 100644 cmd/simulator/run.go create mode 100644 cmd/simulator/run_random.go create mode 100644 cmd/simulator/run_test.go create mode 100644 docs/HEADLESS_RUN.md diff --git a/CLAUDE.md b/CLAUDE.md index 6031bdc..92b3b64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,15 @@ make build-linux - MQTT: 1883+ - OCPP: 9000+ +### Headless Run + +```bash +go run ./cmd/simulator run -spec house.json -port-base 18700 -out run/ +``` + +One house from a spec, a timeline of events, a record a second +(`cmd/simulator/run.go`, [docs/HEADLESS_RUN.md](docs/HEADLESS_RUN.md)). + ### Run Tests ```bash diff --git a/README.md b/README.md index 4327881..9c23309 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,12 @@ POST /api/simulators/{id}/automation/config # Configure time multiplier GET /health # Health check (always 42... I mean OK) ``` +## Headless runs + +`simulator run -spec house.json -port-base 18700 -out run/` builds one house +from a JSON spec, runs a timeline of events and records it once a second, for +an EMS's automated tests. See [docs/HEADLESS_RUN.md](docs/HEADLESS_RUN.md). + ## Configuration | Environment Variable | Default | Description | diff --git a/cmd/simulator/main.go b/cmd/simulator/main.go index 77043c0..c14cbfb 100644 --- a/cmd/simulator/main.go +++ b/cmd/simulator/main.go @@ -149,6 +149,13 @@ type Site struct { FuseOverload FuseOverload `json:"-"` FuseHeat [3]fuse.Phase `json:"-"` // each phase's thermal state (L1, L2, L3) FuseAmbientC *float64 `json:"-"` // the fuse cabinet's air; nil is 25 °C + lastPhaseW PhasePowers // what flowed through each phase's fuse on the last tick + + // What the site meter reports, beside the truth (headless runs). + meterFrozenUntil time.Time // a late meter: its registers stand still until then + meterHeldKWh [2]float64 // import, export that flowed while they stood still + meterGlitch bool // the next reading's total disagrees with its phases + signedCurrents bool // phase currents carry the sign of their power BoundMeter bool `json:"bound_meter"` // True if meter was auto-created with site P1Buffer *P1IntervalBuffer // P1 port interval buffer @@ -189,6 +196,11 @@ var ( ) func main() { + // `simulator run -spec house.json` runs one house headless (run.go). + if len(os.Args) > 1 && os.Args[1] == "run" { + os.Exit(runHeadless(os.Args[2:])) + } + // Base port configuration modbusBasePort := flag.Int("modbus-base", 5000, "Base port for Modbus servers") mqttBasePort := flag.Int("mqtt-base", 1883, "Base port for MQTT brokers") @@ -1953,46 +1965,7 @@ func handleProfile(w http.ResponseWriter, r *http.Request, sim *Simulator, profi return } - // Apply profile based on protocol - switch sim.Protocol { - case "modbus": - // Update RegisterSet if the profile specifies one - if profile.RegisterSetName != "" { - if rs := devices.GetRegisterSet(profile.RegisterSetName); rs != nil { - sim.RegisterSet = rs - sim.Server.RegisterSet = rs - sim.Server.InitDefaultsFromSet(rs) - } - } - // Apply profile-specific defaults - for addr, value := range profile.Defaults { - var def *modbus.RegisterDef - if sim.RegisterSet != nil { - def = sim.RegisterSet.Lookup[addr] - } - if def == nil { - def = modbus.RegisterLookup[addr] - } - if def != nil { - if err := sim.Server.WriteRegisterValue(def, value); err != nil { - log.Printf("[%s] Failed to write register %d: %v", sim.Serial, addr, err) - } - } - } - sim.mu.Lock() - sim.EMSResponse = commandResponse(profile) - sim.mu.Unlock() - case "ocpp": - if profile.OCPPConfig != nil && sim.OCPPServer != nil { - sim.OCPPServer.SetVendorModel(profile.OCPPConfig.Vendor, profile.OCPPConfig.Model) - sim.OCPPServer.ApplyConfiguration(profile.OCPPConfig.Configuration) - sim.mu.Lock() - sim.EMSResponse = commandResponse(profile) - sim.mu.Unlock() - sim.OCPPServer.SetProfileDelay(commandResponse(profile)) - log.Printf("[%s] Applied OCPP profile: %s (%s)", sim.Serial, profile.OCPPConfig.Model, profile.OCPPConfig.Vendor) - } - } + applyProfile(sim, profile) sim.ProfilesManager.SetActive(name) @@ -2010,6 +1983,50 @@ func handleProfile(w http.ResponseWriter, r *http.Request, sim *Simulator, profi json.NewEncoder(w).Encode(profile) } +// applyProfile puts a profile's register set, defaults, OCPP configuration +// and model parameters on a simulator. +func applyProfile(sim *Simulator, profile *profiles.Profile) { + switch sim.Protocol { + case "modbus": + // Update RegisterSet if the profile specifies one + if profile.RegisterSetName != "" { + if rs := devices.GetRegisterSet(profile.RegisterSetName); rs != nil { + sim.RegisterSet = rs + sim.Server.RegisterSet = rs + sim.Server.InitDefaultsFromSet(rs) + } + } + // Apply profile-specific defaults + for addr, value := range profile.Defaults { + var def *modbus.RegisterDef + if sim.RegisterSet != nil { + def = sim.RegisterSet.Lookup[addr] + } + if def == nil { + def = modbus.RegisterLookup[addr] + } + if def != nil { + if err := sim.Server.WriteRegisterValue(def, value); err != nil { + log.Printf("[%s] Failed to write register %d: %v", sim.Serial, addr, err) + } + } + } + sim.mu.Lock() + sim.EMSResponse = commandResponse(profile) + sim.mu.Unlock() + case "ocpp": + if profile.OCPPConfig != nil && sim.OCPPServer != nil { + sim.OCPPServer.SetVendorModel(profile.OCPPConfig.Vendor, profile.OCPPConfig.Model) + sim.OCPPServer.ApplyConfiguration(profile.OCPPConfig.Configuration) + sim.mu.Lock() + sim.EMSResponse = commandResponse(profile) + sim.mu.Unlock() + sim.OCPPServer.SetProfileDelay(commandResponse(profile)) + log.Printf("[%s] Applied OCPP profile: %s (%s)", sim.Serial, profile.OCPPConfig.Model, profile.OCPPConfig.Vendor) + } + } +} + func handleAutomation(w http.ResponseWriter, r *http.Request, sim *Simulator) { sim.Automation.mu.RLock() data := map[string]interface{}{ @@ -4117,6 +4134,14 @@ func getPowerContribution(sim *Simulator) float64 { return 0 } +// isRunning reads Running under the simulator's lock; a stop from the API +// or a headless run writes it while the site loop ticks. +func isRunning(sim *Simulator) bool { + sim.mu.RLock() + defer sim.mu.RUnlock() + return sim.Running +} + // getPhaseContribution returns a charger's draw per phase. A car on a // 1-phase profile loads L1 only, which phase factors cannot express. func getPhaseContribution(sim *Simulator) ([3]float64, bool) { @@ -4147,8 +4172,12 @@ func startSiteAggregation(site *Site) { log.Printf("[Site %d] Aggregation started", site.ID) + // Read here, not in the loop: a headless run sets the tick before it + // builds its site, and the next run may set it again while this loop + // winds down. + interval := getSiteTickInterval() go func() { - ticker := time.NewTicker(getSiteTickInterval()) + ticker := time.NewTicker(interval) defer ticker.Stop() lastTick := time.Now() @@ -4613,6 +4642,9 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { // Calculate total power from phase powers totalPower := phasePowers.L1 + phasePowers.L2 + phasePowers.L3 + site.mu.Lock() + site.lastPhaseW = phasePowers + site.mu.Unlock() // Update BaseLoadW for backward compatibility (used for display) // This is the sum of SitePhaseLoads @@ -4731,29 +4763,54 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { } } + // A late meter keeps its last reading; a glitch shows once. + site.mu.Lock() + frozen := time.Now().Before(site.meterFrozenUntil) + q := meterQuirks{signedCurrents: site.signedCurrents, glitch: site.meterGlitch && !frozen} + if q.glitch { + site.meterGlitch = false + } + // The energy goes on while the readings stand still, and shows when + // they move again. + var held [2]float64 + if frozen { + if totalPower > 0 { + site.meterHeldKWh[0] += energyDeltaKWh + } else if totalPower < 0 { + site.meterHeldKWh[1] += energyDeltaKWh + } + } else { + held, site.meterHeldKWh = site.meterHeldKWh, [2]float64{} + } + site.mu.Unlock() + // Handle P1 interval mode - if p1Buffer == nil || p1Buffer.Mode == P1ModeDirect { + if frozen { + // The meter stands still; the physics above went on. + } else if p1Buffer == nil || p1Buffer.Mode == P1ModeDirect { // Direct mode: write immediately to all meters for _, mID := range meterIDs { mSim := getSimulator(mID) - if mSim != nil && mSim.Protocol == "modbus" && mSim.Server != nil && mSim.Running { - writeMeterRegisters(mSim, totalPower, phasePowers, fuseBlown) + if mSim != nil && mSim.Protocol == "modbus" && mSim.Server != nil && isRunning(mSim) { + writeMeterRegisters(mSim, totalPower, phasePowers, fuseBlown, q) + accumulateMeterEnergy(mSim, held[0], held[1]) updateMeterEnergy(mSim, totalPower, energyDeltaKWh, energyDeltaWh) } } // Also write to primary meter if not in meterIDs (backward compat) if len(meterIDs) == 0 { - writeMeterRegisters(meterSim, totalPower, phasePowers, fuseBlown) + writeMeterRegisters(meterSim, totalPower, phasePowers, fuseBlown, q) + accumulateMeterEnergy(meterSim, held[0], held[1]) updateMeterEnergy(meterSim, totalPower, energyDeltaKWh, energyDeltaWh) } // Propagate meter values to inverter sims that have built-in meter registers for _, simID := range simIDs { sim := getSimulator(simID) - if sim == nil || sim.Protocol != "modbus" || sim.Server == nil || !sim.Running { + if sim == nil || sim.Protocol != "modbus" || sim.Server == nil || !isRunning(sim) { continue } if sim.Category == "inverter" && sim.RegisterSet != nil && sim.RegisterSet.Semantic["meter_power"] != nil { - writeMeterRegisters(sim, totalPower, phasePowers, fuseBlown) + writeMeterRegisters(sim, totalPower, phasePowers, fuseBlown, q) } } } else { @@ -4770,6 +4827,8 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { } else if totalPower < 0 { p1Buffer.ExportEnergyDelta += energyDeltaKWh } + p1Buffer.ImportEnergyDelta += held[0] + p1Buffer.ExportEnergyDelta += held[1] // Check if interval has elapsed intervalDuration := getP1IntervalDuration(p1Buffer.Mode) @@ -4788,7 +4847,7 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { // Write instant power values to all meters for _, mID := range meterIDs { mSim := getSimulator(mID) - if mSim == nil || mSim.Protocol != "modbus" || mSim.Server == nil || !mSim.Running { + if mSim == nil || mSim.Protocol != "modbus" || mSim.Server == nil || !isRunning(mSim) { continue } writeMeterRegisters(mSim, instantPower, instantPhasePowers, fuseBlown) @@ -4802,7 +4861,7 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { // Propagate meter values to inverter sims that have built-in meter registers for _, simID := range simIDs { sim := getSimulator(simID) - if sim == nil || sim.Protocol != "modbus" || sim.Server == nil || !sim.Running { + if sim == nil || sim.Protocol != "modbus" || sim.Server == nil || !isRunning(sim) { continue } if sim.Category == "inverter" && sim.RegisterSet != nil && sim.RegisterSet.Semantic["meter_power"] != nil { @@ -4818,7 +4877,17 @@ func calculateAndUpdateMeter(site *Site, simDelta time.Duration) { // writeMeterRegisters writes power values to meter registers. // Writes to semantic names on the meter's RegisterSet, and also to global-lookup // addresses for dual-format support (SDM630 + Sungrow on same meter). -func writeMeterRegisters(meterSim *Simulator, totalPower float64, phasePowers PhasePowers, fuseBlown PhaseFuses) { +// meterQuirks are what a meter reports beside the truth. +type meterQuirks struct { + signedCurrents bool // currents carry the sign of their phase's power + glitch bool // the total reads 0 W while the phases are right +} + +func writeMeterRegisters(meterSim *Simulator, totalPower float64, phasePowers PhasePowers, fuseBlown PhaseFuses, quirks ...meterQuirks) { + var q meterQuirks + if len(quirks) > 0 { + q = quirks[0] + } if fuseBlown.L1 { phasePowers.L1 = 0 } @@ -4829,6 +4898,9 @@ func writeMeterRegisters(meterSim *Simulator, totalPower float64, phasePowers Ph phasePowers.L3 = 0 } totalPower = phasePowers.L1 + phasePowers.L2 + phasePowers.L3 + if q.glitch { + totalPower = 0 + } // Write total power via semantic name (hits the correct address for any device) setSemanticValue(meterSim, "meter_power", totalPower) @@ -4842,6 +4914,11 @@ func writeMeterRegisters(meterSim *Simulator, totalPower float64, phasePowers Ph l1Current := math.Abs(phasePowers.L1) / 230.0 l2Current := math.Abs(phasePowers.L2) / 230.0 l3Current := math.Abs(phasePowers.L3) / 230.0 + if q.signedCurrents { + l1Current = math.Copysign(l1Current, phasePowers.L1) + l2Current = math.Copysign(l2Current, phasePowers.L2) + l3Current = math.Copysign(l3Current, phasePowers.L3) + } setSemanticValue(meterSim, "meter_l1_current", l1Current) setSemanticValue(meterSim, "meter_l2_current", l2Current) diff --git a/cmd/simulator/run.go b/cmd/simulator/run.go new file mode 100644 index 0000000..da5e51c --- /dev/null +++ b/cmd/simulator/run.go @@ -0,0 +1,899 @@ +package main + +// A headless run: one house built from a JSON spec, served on ports from a +// base, driven by a timeline of events and recorded once a second into an +// output directory. An EMS under test talks to the house over Modbus and +// OCPP; its test harness reads the record and can post events while the +// house runs. +// +// simulator run -spec house.json -port-base 18700 -out run/ +// +// Ports: base+0 HTTP API, from base+10 Modbus, from base+30 OCPP, from +// base+50 MQTT. Two runs on one machine need bases 100 apart. +// +// The output directory gets ports.json (where each device listens, written +// once the house is up), plant.jsonl (a sample a second plus every event and +// mark), ocpp-.jsonl (every OCPP frame each charger reads or writes) +// and result.json (written at the end). + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "log" + "math" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" + "time" + + simmdns "github.com/srcfl/device-simulator/internal/mdns" + "github.com/srcfl/device-simulator/internal/ocpp" + "github.com/srcfl/device-simulator/internal/ports" + "github.com/srcfl/device-simulator/internal/settings" +) + +// runSpec is the house of a headless run. +type runSpec struct { + Name string `json:"name"` + DurationS float64 `json:"duration_s"` // 0 runs until stopped + Site runSiteSpec `json:"site"` + Devices []runDeviceSpec `json:"devices"` + Timeline []runEvent `json:"timeline"` + // Random adds a timeline drawn from a seed (run_random.go). The run + // writes the result to timeline.json, a spec that replays it. + Random *runRandom `json:"random,omitempty"` +} + +type runSiteSpec struct { + FuseA int `json:"fuse_a"` + FuseProfile string `json:"fuse_profile,omitempty"` // default iec_gg + HouseW [3]float64 `json:"house_w"` // per phase + // FuseAmbientC is the cabinet's air around the fuses; omitted is 25 °C. + FuseAmbientC *float64 `json:"fuse_ambient_c,omitempty"` + // FusePreheatA is a current per phase the fuses have carried long + // enough to settle, so the run starts warm, as a house does in the + // evening; omitted starts cold. + FusePreheatA *[3]float64 `json:"fuse_preheat_a,omitempty"` + // SignedPhaseCurrents overrides the SDM630 model's: the meter's phase + // currents carry the sign of their power. + SignedPhaseCurrents *bool `json:"signed_phase_currents,omitempty"` +} + +// runDeviceSpec is one device, by the device-drivers driver it emulates. +// The meter (an SDM630) comes with the site. +type runDeviceSpec struct { + ID string `json:"id"` // the run's name for it, used by events + Driver string `json:"driver"` // sungrow, pixii... or ocpp for a charger + + // Overrides of the model's parameters. + CommandResponseS *float64 `json:"command_response_s,omitempty"` + StartupDelayS *float64 `json:"startup_delay_s,omitempty"` + LoadBalancerA *float64 `json:"load_balancer_a,omitempty"` + + // A hybrid inverter. + EMSControl *bool `json:"ems_control,omitempty"` // default on + BatteryKWh float64 `json:"battery_kwh,omitempty"` + SoCPct *float64 `json:"soc_pct,omitempty"` + MinSoCPct *float64 `json:"min_soc_pct,omitempty"` + MaxSoCPct *float64 `json:"max_soc_pct,omitempty"` + PVW float64 `json:"pv_w,omitempty"` + + // An OCPP 1.6 charger in client mode, and the car on it. + OCPPURL string `json:"ocpp_url,omitempty"` // user:pass@ is sent as Basic auth + MaxKW float64 `json:"max_kw,omitempty"` // as wired; default 11.04 on three phases + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + PhaseSwitch bool `json:"phase_switch,omitempty"` + WiredPhases int `json:"wired_phases,omitempty"` // 1: on L1 only, and max_kw is its rating there + OCPPConfig map[string]string `json:"ocpp_config,omitempty"` // configuration keys over the defaults + CarMaxA float64 `json:"car_max_a,omitempty"` + CarPhases int `json:"car_phases,omitempty"` + Plugged bool `json:"plugged,omitempty"` // a car plugged in at the start +} + +// runEvent changes the house: at at_s seconds into the run on the timeline, +// or at once when posted to /api/run/event. +// +// mark label a named moment in the record +// loads w or a (per phase) house load +// pv pv_w, device PV power of an inverter (default the first) +// soc pct, device battery state of charge +// plug on, device car plugged in or out +// car max_a, phases, device what the car takes +// offline device, for_s the device stops answering and drops out +// of the site; back after for_s if given +// online device back again +// reconnect device, for_s a charger loses its connection for for_s; +// the car keeps charging +// silence device, for_s a Modbus device keeps its connections but +// answers nothing for for_s (0: until online) +// meter_late for_s the site meter's readings stand still +// meter_glitch the next reading's total reads 0 W while +// its phases are right +// signed_currents on the meter's phase currents carry a sign +// load_balancer device, max_a the charger's own cap per phase; 0 none +// stop end the run +type runEvent struct { + AtS float64 `json:"at_s,omitempty"` + Do string `json:"do"` + Device string `json:"device,omitempty"` + Label string `json:"label,omitempty"` + W *[3]float64 `json:"w,omitempty"` + A *[3]float64 `json:"a,omitempty"` + PVW *float64 `json:"pv_w,omitempty"` + Pct *float64 `json:"pct,omitempty"` + On *bool `json:"on,omitempty"` + MaxA *float64 `json:"max_a,omitempty"` + Phases *int `json:"phases,omitempty"` + ForS float64 `json:"for_s,omitempty"` +} + +type headlessRun struct { + spec runSpec + out string + base int + start time.Time + + site *Site + meter *Simulator + devs map[string]*Simulator + ids []string // spec order + + recMu sync.Mutex + rec *os.File + frameMu sync.Mutex + frames map[string]*os.File + events int + samples int + + stop chan struct{} + stopOnce sync.Once + + // faultGen counts the outages and silences applied to each device, so + // the timer of an earlier one cannot end a later one. + faultMu sync.Mutex + faultGen map[string]int +} + +// fault starts an outage or silence of a device and returns a check that +// holds while it is still the latest one. +func (r *headlessRun) fault(id string) func() bool { + r.faultMu.Lock() + defer r.faultMu.Unlock() + if r.faultGen == nil { + r.faultGen = map[string]int{} + } + r.faultGen[id]++ + gen := r.faultGen[id] + return func() bool { + r.faultMu.Lock() + defer r.faultMu.Unlock() + return r.faultGen[id] == gen + } +} + +// runHeadless is `simulator run`. It returns the process exit code. +func runHeadless(args []string) int { + fs := flag.NewFlagSet("run", flag.ContinueOnError) + specPath := fs.String("spec", "", "house spec (JSON)") + base := fs.Int("port-base", 18700, "first of 100 ports on 127.0.0.1") + out := fs.String("out", "run", "output directory") + if err := fs.Parse(args); err != nil { + return 2 + } + b, err := os.ReadFile(*specPath) + if err != nil { + log.Printf("run: %v", err) + return 2 + } + var spec runSpec + if err := json.Unmarshal(b, &spec); err != nil { + log.Printf("run: %s: %v", *specPath, err) + return 2 + } + r, err := startRun(spec, *base, *out) + if err != nil { + log.Printf("run: %v", err) + return 1 + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + select { + case <-sig: + case <-r.stop: + } + r.end() + return 0 +} + +// startRun builds the house and starts serving, recording and the timeline. +func startRun(spec runSpec, base int, out string) (*headlessRun, error) { + // A refused spec changes nothing: checked before the run takes the + // simulator's settings. + if _, err := runFuseProfile(spec.Site); err != nil { + return nil, err + } + if err := os.MkdirAll(out, 0o755); err != nil { + return nil, err + } + if spec.Random != nil { + evs := randomize(&spec) + log.Printf("run: %s", describeRandom(spec.Random, len(evs))) + spec.Timeline = append(spec.Timeline, evs...) + sort.SliceStable(spec.Timeline, func(i, j int) bool { return spec.Timeline[i].AtS < spec.Timeline[j].AtS }) + replay := spec + replay.Random = nil + b, _ := json.MarshalIndent(replay, "", " ") + if err := os.WriteFile(filepath.Join(out, "timeline.json"), b, 0o644); err != nil { + return nil, err + } + } + r := &headlessRun{spec: spec, out: out, base: base, devs: map[string]*Simulator{}, + frames: map[string]*os.File{}, stop: make(chan struct{})} + // A run is self-contained: its own ports and state file, none of the + // user's settings or saved simulators. + os.Setenv("DEVICE_SIMULATOR_STATE_PATH", filepath.Join(out, "state.json")) + runtimeSettings = &settings.AppSettings{ModbusBasePort: base + 10, MQTTBasePort: base + 50, OCPPBasePort: base + 30, SiteTickMs: 500} + portAllocator = ports.NewAllocator(base+10, base+50, base+30) + if mdnsAdvertiser == nil { + mdnsAdvertiser = simmdns.New() + } + f, err := os.Create(filepath.Join(out, "plant.jsonl")) + if err != nil { + return nil, err + } + r.rec = f + r.start = time.Now() + if err := r.build(); err != nil { + r.end() + return nil, err + } + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", base)) + if err != nil { + r.end() + return nil, err + } + go http.Serve(ln, corsMiddleware(r.handler())) + if err := r.writePorts(); err != nil { + r.end() + return nil, err + } + go r.record() + go r.timeline() + if spec.DurationS > 0 { + time.AfterFunc(time.Duration(spec.DurationS*float64(time.Second)), r.finish) + } + log.Printf("run %q on ports %d+, recording to %s", spec.Name, base, out) + return r, nil +} + +// runFuseProfile is the spec's fuse profile, checked with its size. +func runFuseProfile(site runSiteSpec) (string, error) { + profile := site.FuseProfile + if profile == "" { + profile = FuseProfileIecGg + } + if _, ok := fuseProfiles[profile]; !ok { + return "", fmt.Errorf("fuse profile %q", profile) + } + return profile, checkFuse(site.FuseA, profile) +} + +func (r *headlessRun) build() error { + fuseProfile, err := runFuseProfile(r.spec.Site) + if err != nil { + return err + } + var members []int + for _, d := range r.spec.Devices { + if d.ID == "" || r.devs[d.ID] != nil { + return fmt.Errorf("device %q: every device needs its own id", d.ID) + } + if d.ID == "meter" { + return fmt.Errorf(`device "meter": that id is the site meter's`) + } + var sim *Simulator + var err error + if d.Driver == "ocpp" { + sim, err = r.buildCharger(d) + } else { + sim, err = r.buildInverter(d) + } + if err != nil { + return fmt.Errorf("device %s: %w", d.ID, err) + } + r.devs[d.ID] = sim + r.ids = append(r.ids, d.ID) + members = append(members, sim.ID) + } + site, err := createSite(r.spec.Name, 0, members, 0) + if err != nil { + return err + } + r.site = site + r.meter = getSimulator(site.MeterID) + site.mu.Lock() + if r.spec.Site.FuseA > 0 { + site.FuseSize = r.spec.Site.FuseA + } + site.FuseProfile = fuseProfile + site.FuseAmbientC = r.spec.Site.FuseAmbientC + if pre := r.spec.Site.FusePreheatA; pre != nil { + for i, a := range pre { + site.FuseHeat[i].AmbientC = site.FuseAmbientC + if err := fuseProfiles[fuseProfile].Preheat(&site.FuseHeat[i], a, float64(site.FuseSize)); err != nil { + site.mu.Unlock() + return fmt.Errorf("fuse_preheat_a L%d: %w", i+1, err) + } + } + } + site.PhaseFactors = PhaseFactors{L1: 1.0 / 3, L2: 1.0 / 3, L3: 1.0 / 3} + site.LoadScenario = "static" + if r.meter != nil { + site.signedCurrents = r.meter.ProfilesManager.Get("sdm630").Params().SignedPhaseCurrents + } + if v := r.spec.Site.SignedPhaseCurrents; v != nil { + site.signedCurrents = *v + } + site.mu.Unlock() + r.setLoads(r.spec.Site.HouseW) + for _, d := range r.spec.Devices { + if d.Driver == "ocpp" && d.Plugged { + if err := r.devs[d.ID].OCPPServer.SetValue("evConnected", true); err != nil { + return err + } + } + } + return nil +} + +func (r *headlessRun) buildInverter(d runDeviceSpec) (*Simulator, error) { + sim, err := createSimulator("inverter") + if err != nil { + return nil, err + } + profile := sim.ProfilesManager.ForDriver(d.Driver) + if profile == nil || profile.Protocol != "modbus" { + return nil, fmt.Errorf("no Modbus model emulates driver %q", d.Driver) + } + applyProfile(sim, profile) + sim.ProfilesManager.SetActive(profile.Name) + sim.mu.Lock() + sim.EMSControl = d.EMSControl == nil || *d.EMSControl + if d.CommandResponseS != nil { + sim.EMSResponse = seconds(*d.CommandResponseS) + } + if d.BatteryKWh > 0 { + sim.BatteryCapacityKWh = d.BatteryKWh + } + sim.mu.Unlock() + for name, v := range map[string]*float64{"battery_soc": d.SoCPct, "battery_min_soc": d.MinSoCPct, "battery_max_soc": d.MaxSoCPct} { + if v != nil { + setSemanticValue(sim, name, *v) + } + } + setSemanticValue(sim, "pv_power", math.Max(0, d.PVW)) + return sim, startSimulatorServer(sim) +} + +func (r *headlessRun) buildCharger(d runDeviceSpec) (*Simulator, error) { + if d.OCPPURL == "" { + return nil, fmt.Errorf("a charger needs ocpp_url") + } + sim, err := createSimulator("ocpp_charger") + if err != nil { + return nil, err + } + kw := d.MaxKW + if kw <= 0 { + kw = 11.04 + } + vendor, model := d.Vendor, d.Model + if vendor == "" { + vendor = "SimulatorCo" + } + if model == "" { + model = fmt.Sprintf("Sim%.0f", kw) + } + srv := ocpp.NewServer(sim.ID, 0, vendor, model, 1, kw) + cfg := map[string]string{ + "HeartbeatInterval": "60", + "MeterValueSampleInterval": "10", + "NumberOfConnectors": "1", + "ConnectorSwitch3to1PhaseSupported": fmt.Sprint(d.PhaseSwitch), + "ChargingScheduleAllowedChargingRateUnit": "Current,Power", + "SupportedFeatureProfiles": "Core,SmartCharging,RemoteTrigger,LocalAuthListManagement", + } + for k, v := range d.OCPPConfig { + cfg[k] = v + } + srv.ApplyConfiguration(cfg) + srv.SetOCPPURL(d.OCPPURL) + params := sim.ProfilesManager.ForDriver("ocpp").Params() + delay := seconds(params.CommandResponseS) + if d.CommandResponseS != nil { + delay = seconds(*d.CommandResponseS) + } + srv.SetProfileDelay(delay) + startup, lb := params.StartupDelayS, params.LoadBalancerA + if d.StartupDelayS != nil { + startup = *d.StartupDelayS + } + if d.LoadBalancerA != nil { + lb = *d.LoadBalancerA + } + srv.SetStartupDelay(seconds(startup)) + srv.SetLoadBalancer(lb) + srv.SetCar(d.CarMaxA, d.CarPhases) + srv.SetWiredPhases(d.WiredPhases) + frames, err := os.Create(filepath.Join(r.out, "ocpp-"+d.ID+".jsonl")) + if err != nil { + return nil, err + } + r.frames[d.ID] = frames + srv.SetFrameLog(func(dir string, frame []byte) { r.writeFrame(d.ID, dir, frame) }) + sim.mu.Lock() + sim.OCPPServer, sim.Device, sim.Serial, sim.EMSResponse = srv, srv, srv.SerialNumber(), delay + sim.mu.Unlock() + return sim, startSimulatorServer(sim) +} + +// handler is the simulator's API plus /api/run, without self-update. +func (r *headlessRun) handler() http.Handler { + mux := buildHTTPMux() + mux.HandleFunc("/api/run", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"name": r.spec.Name, "elapsed_s": time.Since(r.start).Seconds(), + "events": r.eventCount(), "samples": r.sampleCount()}) + }) + mux.HandleFunc("/api/run/event", func(w http.ResponseWriter, req *http.Request) { + var ev runEvent + if req.Method != http.MethodPost || json.NewDecoder(req.Body).Decode(&ev) != nil { + http.Error(w, "POST a runEvent", http.StatusBadRequest) + return + } + if err := r.apply(ev); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("/api/run/mark", func(w http.ResponseWriter, req *http.Request) { + var m struct { + Label string `json:"label"` + } + if req.Method != http.MethodPost || json.NewDecoder(req.Body).Decode(&m) != nil || m.Label == "" { + http.Error(w, `POST {"label": ...}`, http.StatusBadRequest) + return + } + r.apply(runEvent{Do: "mark", Label: m.Label}) + w.WriteHeader(http.StatusNoContent) + }) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasPrefix(req.URL.Path, "/api/update") || req.URL.Path == "/api/restart" { + http.Error(w, "not in a headless run", http.StatusForbidden) + return + } + mux.ServeHTTP(w, req) + }) +} + +// timeline applies the spec's events at their times. +func (r *headlessRun) timeline() { + evs := append([]runEvent(nil), r.spec.Timeline...) + sort.SliceStable(evs, func(i, j int) bool { return evs[i].AtS < evs[j].AtS }) + for _, ev := range evs { + select { + case <-r.stop: + return + case <-time.After(time.Until(r.start.Add(time.Duration(ev.AtS * float64(time.Second))))): + } + if err := r.apply(ev); err != nil { + log.Printf("run: event at %.1f s: %v", ev.AtS, err) + } + } +} + +func (r *headlessRun) device(id string, wantCharger bool) (*Simulator, error) { + sim := r.devs[id] + if sim == nil { + return nil, fmt.Errorf("no device %q", id) + } + if wantCharger && sim.OCPPServer == nil { + return nil, fmt.Errorf("%s is not a charger", id) + } + return sim, nil +} + +func (r *headlessRun) firstInverter() string { + for _, id := range r.ids { + if r.devs[id].Protocol == "modbus" { + return id + } + } + return "" +} + +// apply changes the house now and records the event. +func (r *headlessRun) apply(ev runEvent) error { + var err error + switch ev.Do { + case "mark": + if ev.Label == "" { + err = fmt.Errorf("mark needs a label") + } + case "loads": + switch { + case ev.W != nil: + r.setLoads(*ev.W) + case ev.A != nil: + a := *ev.A + r.setLoads([3]float64{a[0] * 230, a[1] * 230, a[2] * 230}) + default: + err = fmt.Errorf("loads needs w or a") + } + case "pv", "soc": + id := ev.Device + if id == "" { + id = r.firstInverter() + } + var sim *Simulator + if sim, err = r.device(id, false); err != nil { + break + } + switch { + case ev.Do == "pv" && ev.PVW != nil: + setSemanticValue(sim, "pv_power", math.Max(0, *ev.PVW)) + case ev.Do == "soc" && ev.Pct != nil: + setSemanticValue(sim, "battery_soc", *ev.Pct) + sim.mu.Lock() + sim.socInitialized = false + sim.mu.Unlock() + default: + err = fmt.Errorf("%s needs a value", ev.Do) + } + case "plug": + var sim *Simulator + if sim, err = r.device(ev.Device, true); err == nil { + err = sim.OCPPServer.SetValue("evConnected", ev.On == nil || *ev.On) + } + case "car": + var sim *Simulator + if sim, err = r.device(ev.Device, true); err == nil { + maxA, phases := 0.0, 0 + if ev.MaxA != nil { + maxA = *ev.MaxA + } + if ev.Phases != nil { + phases = *ev.Phases + } + sim.OCPPServer.SetCar(maxA, phases) + } + case "offline": + var sim *Simulator + if sim, err = r.device(ev.Device, false); err == nil { + stopSimulatorServer(sim) + latest := r.fault(ev.Device) + if ev.ForS > 0 { + id := ev.Device + time.AfterFunc(time.Duration(ev.ForS*float64(time.Second)), func() { + if latest() { + r.apply(runEvent{Do: "online", Device: id}) + } + }) + } + } + case "online": + var sim *Simulator + if sim, err = r.device(ev.Device, false); err == nil { + r.fault(ev.Device) + if sim.Server != nil { + sim.Server.SetSilent(false) + } + err = startSimulatorServer(sim) + } + case "reconnect": + var sim *Simulator + if sim, err = r.device(ev.Device, true); err == nil { + sim.OCPPServer.DropConnection(time.Duration(ev.ForS * float64(time.Second))) + } + case "silence": + var sim *Simulator + if sim, err = r.device(ev.Device, false); err == nil && sim.Server == nil { + err = fmt.Errorf("%s is not a Modbus device", ev.Device) + } + if err == nil { + sim.Server.SetSilent(true) + latest := r.fault(ev.Device) + if ev.ForS > 0 { + time.AfterFunc(time.Duration(ev.ForS*float64(time.Second)), func() { + if latest() { + sim.Server.SetSilent(false) + } + }) + } + } + case "meter_late": + r.site.mu.Lock() + r.site.meterFrozenUntil = time.Now().Add(time.Duration(ev.ForS * float64(time.Second))) + r.site.mu.Unlock() + case "meter_glitch": + r.site.mu.Lock() + r.site.meterGlitch = true + r.site.mu.Unlock() + case "signed_currents": + r.site.mu.Lock() + r.site.signedCurrents = ev.On == nil || *ev.On + r.site.mu.Unlock() + case "load_balancer": + var sim *Simulator + if sim, err = r.device(ev.Device, true); err == nil { + a := 0.0 + if ev.MaxA != nil { + a = *ev.MaxA + } + sim.OCPPServer.SetLoadBalancer(a) + } + case "stop": + defer r.finish() + default: + err = fmt.Errorf("unknown event %q", ev.Do) + } + if err != nil { + return err + } + ev.AtS = round3(time.Since(r.start).Seconds()) + r.recMu.Lock() + r.events++ + r.recMu.Unlock() + line := map[string]any{"t": unixSeconds(time.Now()), "elapsed_s": round3(ev.AtS)} + if ev.Do == "mark" { + line["mark"] = ev.Label + } else { + line["event"] = ev + } + r.writeLine(line) + return nil +} + +func (r *headlessRun) setLoads(w [3]float64) { + r.site.mu.Lock() + defer r.site.mu.Unlock() + r.site.SitePhaseLoads = PhaseLoads{L1: w[0], L2: w[1], L3: w[2]} + r.site.BaseLoadW = w[0] + w[1] + w[2] +} + +// plantSample is one second of the house. Power is + into the site. +type plantSample struct { + T float64 `json:"t"` + Elapsed float64 `json:"elapsed_s"` + PhaseW [3]float64 `json:"phase_w"` // through each phase's fuse + PhaseA [3]float64 `json:"phase_a"` // the same, signed amperes + MeterW float64 `json:"meter_w"` // what the site meter reads + Fuse fuseSample `json:"fuse"` + Devices map[string]any `json:"devices"` +} + +type fuseSample struct { + RatingA int `json:"rating_a"` + Profile string `json:"profile"` + Damage [3]float64 `json:"damage"` + Blown [3]bool `json:"blown"` +} + +func (r *headlessRun) record() { + tick := time.NewTicker(time.Second) + defer tick.Stop() + for { + select { + case <-r.stop: + return + case now := <-tick.C: + r.writeLine(r.sample(now)) + r.recMu.Lock() + r.samples++ + r.recMu.Unlock() + } + } +} + +func (r *headlessRun) sample(now time.Time) plantSample { + r.site.mu.RLock() + pw := r.site.lastPhaseW + fs := fuseSample{RatingA: r.site.FuseSize, Profile: r.site.FuseProfile, + Damage: [3]float64{r.site.FuseOverload.L1.Damage, r.site.FuseOverload.L2.Damage, r.site.FuseOverload.L3.Damage}, + Blown: [3]bool{r.site.FuseBlown.L1, r.site.FuseBlown.L2, r.site.FuseBlown.L3}} + r.site.mu.RUnlock() + s := plantSample{T: unixSeconds(now), Elapsed: round3(now.Sub(r.start).Seconds()), + PhaseW: [3]float64{pw.L1, pw.L2, pw.L3}, Fuse: fs, Devices: map[string]any{}} + for k, w := range s.PhaseW { + s.PhaseA[k] = round3(w / 230) + } + if r.meter != nil { + s.MeterW = getSemanticValue(r.meter, "meter_power") + } + for _, id := range r.ids { + sim := r.devs[id] + sim.mu.RLock() + running := sim.Running + sim.mu.RUnlock() + if sim.OCPPServer != nil { + st := sim.OCPPServer.GetState() + pp := sim.OCPPServer.PhasePowers() + status, phases := "", 0 + if cs, ok := st["connectors"].([]map[string]interface{}); ok { + for _, c := range cs { + if c["id"] == 1 { + status, _ = c["status"].(string) + phases, _ = c["phases"].(int) + } + } + } + s.Devices[id] = map[string]any{"running": running, "connected": st["connected"], "ev_connected": st["evConnected"], + "status": status, "w": st["totalPower"], "phase_w": pp, "phases": phases, "offer_w": st["effectivePowerW"], + "transaction": st["hasActiveTransaction"]} + continue + } + s.Devices[id] = map[string]any{"running": running, "pv_w": getSemanticValue(sim, "pv_power"), + "battery_w": getBatterySignedPower(sim), "soc_pct": getSemanticValue(sim, "battery_soc"), + "ems_mode": getSemanticValue(sim, "grid_mode"), "ems_cmd": getSemanticValue(sim, "battery_cmd"), + "ems_setpoint_w": getSemanticValue(sim, "battery_setpoint")} + } + return s +} + +func (r *headlessRun) writeLine(v any) { + b, err := marshalLine(v) + if err != nil { + log.Printf("run: record: %v", err) + return + } + r.recMu.Lock() + defer r.recMu.Unlock() + if r.rec != nil { + r.rec.Write(b) + } +} + +// marshalLine is one JSON line, with "->" and "<" left as they are. +func marshalLine(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + err := enc.Encode(v) + return buf.Bytes(), err +} + +func (r *headlessRun) writeFrame(id, dir string, frame []byte) { + rec := map[string]any{"t": unixSeconds(time.Now()), "dir": dir, "frame": json.RawMessage(frame)} + if !json.Valid(frame) { + rec["frame"] = string(frame) + } + b, _ := marshalLine(rec) + r.frameMu.Lock() + defer r.frameMu.Unlock() + if f := r.frames[id]; f != nil { + f.Write(b) + } +} + +// runPorts is ports.json: where the EMS finds each device. +type runPorts struct { + HTTP int `json:"http"` + Devices map[string]runDevicePorts `json:"devices"` +} + +type runDevicePorts struct { + Driver string `json:"driver"` + Modbus int `json:"modbus_port,omitempty"` + UnitID uint8 `json:"unit_id,omitempty"` + Serial string `json:"serial,omitempty"` // an OCPP charger's identity +} + +func (r *headlessRun) writePorts() error { + p := runPorts{HTTP: r.base, Devices: map[string]runDevicePorts{}} + add := func(id, driver string, sim *Simulator) { + sim.mu.RLock() + defer sim.mu.RUnlock() + dp := runDevicePorts{Driver: driver} + if sim.Protocol == "modbus" { + dp.Modbus, dp.UnitID = sim.Port, sim.SlaveID + } else { + dp.Serial = sim.Serial + } + p.Devices[id] = dp + } + for i, id := range r.ids { + add(id, r.spec.Devices[i].Driver, r.devs[id]) + } + if r.meter != nil { + add("meter", "sdm630", r.meter) + } + b, _ := json.MarshalIndent(p, "", " ") + return os.WriteFile(filepath.Join(r.out, "ports.json"), b, 0o644) +} + +func (r *headlessRun) eventCount() int { r.recMu.Lock(); defer r.recMu.Unlock(); return r.events } +func (r *headlessRun) sampleCount() int { r.recMu.Lock(); defer r.recMu.Unlock(); return r.samples } + +// finish ends the run; runHeadless then calls end. +func (r *headlessRun) finish() { r.stopOnce.Do(func() { close(r.stop) }) } + +// end stops the house, writes result.json and closes the record. +func (r *headlessRun) end() { + r.finish() + res := map[string]any{"name": r.spec.Name, "duration_s": round3(time.Since(r.start).Seconds()), + "samples": r.sampleCount(), "events": r.eventCount()} + if r.site != nil { + r.site.mu.RLock() + res["fuse"] = map[string]any{"rating_a": r.site.FuseSize, "profile": r.site.FuseProfile, + "blown": [3]bool{r.site.FuseBlown.L1, r.site.FuseBlown.L2, r.site.FuseBlown.L3}} + r.site.mu.RUnlock() + stopSiteSimulation(r.site) + stopSiteAggregation(r.site) + } + for _, id := range r.ids { + stopSimulatorServer(r.devs[id]) + } + if r.meter != nil { + stopSimulatorServer(r.meter) + } + b, _ := json.MarshalIndent(res, "", " ") + os.WriteFile(filepath.Join(r.out, "result.json"), b, 0o644) + r.recMu.Lock() + if r.rec != nil { + r.rec.Close() + r.rec = nil + } + r.recMu.Unlock() + r.frameMu.Lock() + for id, f := range r.frames { + f.Close() + delete(r.frames, id) + } + r.frameMu.Unlock() + // Leave the process's simulator lists as they were before the run. + var ids []int + for _, sim := range r.devs { + ids = append(ids, sim.ID) + } + if r.meter != nil { + ids = append(ids, r.meter.ID) + } + simulatorsMu.Lock() + kept := simulators[:0] + for _, sim := range simulators { + drop := false + for _, id := range ids { + drop = drop || sim.ID == id + } + if !drop { + kept = append(kept, sim) + } + } + simulators = kept + simulatorsMu.Unlock() + if r.site != nil { + sitesMu.Lock() + keptSites := sites[:0] + for _, s := range sites { + if s != r.site { + keptSites = append(keptSites, s) + } + } + sites = keptSites + sitesMu.Unlock() + } +} + +func unixSeconds(t time.Time) float64 { return float64(t.UnixMicro()) / 1e6 } + +func round3(x float64) float64 { return math.Round(x*1000) / 1000 } diff --git a/cmd/simulator/run_random.go b/cmd/simulator/run_random.go new file mode 100644 index 0000000..612428c --- /dev/null +++ b/cmd/simulator/run_random.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "math" + "math/rand" + "sort" +) + +// runRandom draws a timeline for a headless run from a seed, within the +// house's physical bounds: house load per phase stays under the fuse less +// 2 A, so the house alone never overloads it and anything over the limit +// is the EMS's doing or a step it must answer. The same seed and spec give +// the same timeline; timeline.json in the output holds it for replay. +type runRandom struct { + Seed int64 `json:"seed"` + DurationS float64 `json:"duration_s"` // events fall in [30 s, duration_s - 30 s] + GapS float64 `json:"gap_s,omitempty"` // mean time between events; default 40 + Kinds []string `json:"kinds,omitempty"` // which kinds may happen; default all + MaxPVW float64 `json:"max_pv_w,omitempty"` // PV ceiling; default 8 000 W + MinSoCPct float64 `json:"min_soc_pct,omitempty"` // the battery's floor; default 5 +} + +// randomKinds are the events a random timeline draws, with their weights. +var randomKinds = []struct { + name string + weight int +}{ + {"loads_step", 6}, {"loads_drift", 3}, {"loads_spike", 4}, {"pv_cloud", 3}, {"soc_floor", 1}, + {"car_limit", 2}, {"silence", 1}, {"offline", 1}, {"reconnect", 2}, {"meter_late", 2}, + {"meter_glitch", 1}, {"signed_currents", 1}, {"load_balancer", 1}, +} + +// randomize fills the unset timing and quirks of the spec's devices from the +// seed and returns the events to add to its timeline. +func randomize(spec *runSpec) []runEvent { + rr := spec.Random + rng := rand.New(rand.NewSource(rr.Seed)) + gap := rr.GapS + if gap <= 0 { + gap = 40 + } + maxPV := rr.MaxPVW + if maxPV <= 0 { + maxPV = 8000 + } + minSoC := rr.MinSoCPct + if minSoC <= 0 { + minSoC = 5 + } + fuse := float64(spec.Site.FuseA) + if fuse <= 0 { + fuse = 16 + } + maxA := fuse - 2 + allowed := map[string]bool{} + for _, k := range rr.Kinds { + allowed[k] = true + } + var kinds []string + total := 0 + for _, k := range randomKinds { + if len(allowed) == 0 || allowed[k.name] { + for i := 0; i < k.weight; i++ { + kinds = append(kinds, k.name) + } + total += k.weight + } + } + + // The devices' timing: whatever the spec leaves open. + var chargers, inverters []string + for i := range spec.Devices { + d := &spec.Devices[i] + if d.Driver == "ocpp" { + chargers = append(chargers, d.ID) + if d.CommandResponseS == nil { + d.CommandResponseS = ptr([]float64{0, 5, 40, 60}[rng.Intn(4)]) + } + if d.StartupDelayS == nil { + d.StartupDelayS = ptr([]float64{0, 0, 5, 30}[rng.Intn(4)]) + } + if d.CarPhases == 0 && rng.Intn(5) == 0 { + d.CarPhases = 1 + } + } else { + inverters = append(inverters, d.ID) + } + } + if spec.Site.SignedPhaseCurrents == nil { + spec.Site.SignedPhaseCurrents = ptr(rng.Intn(2) == 0) + } + if len(kinds) == 0 { + return nil + } + + // House load per phase, in amperes, as the timeline moves it. + var level [3]float64 + for k, w := range spec.Site.HouseW { + level[k] = math.Min(maxA, math.Max(0.3, w/230)) + } + pick := func(xs []string) string { + if len(xs) == 0 { + return "" + } + return xs[rng.Intn(len(xs))] + } + between := func(lo, hi float64) float64 { return math.Round((lo+rng.Float64()*(hi-lo))*10) / 10 } + // later is when a follow-up lands: never after the run. + later := func(at float64) float64 { return math.Min(at, rr.DurationS) } + loads := func(at float64) runEvent { + a := level + return runEvent{AtS: at, Do: "loads", A: &a} + } + + var evs []runEvent + end := rr.DurationS - 30 + for t := 30 + rng.ExpFloat64()*gap; t < end; t += math.Min(3*gap, math.Max(5, rng.ExpFloat64()*gap)) { + t = math.Round(t*10) / 10 + switch kinds[rng.Intn(total)] { + case "loads_step": + if rng.Intn(2) == 0 { + level[rng.Intn(3)] = between(0.3, maxA) + } else { + for k := range level { + level[k] = between(0.3, maxA) + } + } + evs = append(evs, loads(t)) + case "loads_drift": + for i := 0; i < 3+rng.Intn(4); i++ { + for k := range level { + level[k] = math.Min(maxA, math.Max(0.3, level[k]+between(-1.5, 1.5))) + } + evs = append(evs, loads(later(t+float64(10*i)))) + } + case "loads_spike": + k := rng.Intn(3) + if room := maxA - level[k]; room > 1 { + before := level[k] + level[k] += between(1, room) + evs = append(evs, loads(t)) + level[k] = before + evs = append(evs, loads(later(t+between(5, 30)))) + } + case "pv_cloud": + if inv := pick(inverters); inv != "" { + sun := between(0.4, 1) * maxPV + evs = append(evs, runEvent{AtS: t, Do: "pv", Device: inv, PVW: ptr(math.Round(sun * between(0.1, 0.6)))}, + runEvent{AtS: later(t + between(10, 90)), Do: "pv", Device: inv, PVW: ptr(math.Round(sun))}) + } + case "soc_floor": + if inv := pick(inverters); inv != "" { + evs = append(evs, runEvent{AtS: t, Do: "soc", Device: inv, Pct: ptr(minSoC + between(0.5, 3))}) + } + case "car_limit": + if cp := pick(chargers); cp != "" { + a := 0.0 + if rng.Intn(3) > 0 { + a = between(6, 16) + } + evs = append(evs, runEvent{AtS: t, Do: "car", Device: cp, MaxA: ptr(a)}) + } + case "silence": + if inv := pick(inverters); inv != "" { + evs = append(evs, runEvent{AtS: t, Do: "silence", Device: inv, ForS: between(5, 60)}) + } + case "offline": + if inv := pick(inverters); inv != "" { + evs = append(evs, runEvent{AtS: t, Do: "offline", Device: inv, ForS: between(20, 90)}) + } + case "reconnect": + if cp := pick(chargers); cp != "" { + evs = append(evs, runEvent{AtS: t, Do: "reconnect", Device: cp, ForS: between(5, 30)}) + } + case "meter_late": + evs = append(evs, runEvent{AtS: t, Do: "meter_late", ForS: between(5, 40)}) + case "meter_glitch": + evs = append(evs, runEvent{AtS: t, Do: "meter_glitch"}) + case "signed_currents": + evs = append(evs, runEvent{AtS: t, Do: "signed_currents", On: ptr(rng.Intn(2) == 0)}) + case "load_balancer": + if cp := pick(chargers); cp != "" { + evs = append(evs, runEvent{AtS: t, Do: "load_balancer", Device: cp, MaxA: ptr(between(6, 16))}, + runEvent{AtS: later(t + between(30, 180)), Do: "load_balancer", Device: cp, MaxA: ptr(0.0)}) + } + } + } + sort.SliceStable(evs, func(i, j int) bool { return evs[i].AtS < evs[j].AtS }) + return evs +} + +func describeRandom(rr *runRandom, n int) string { + return fmt.Sprintf("random seed %d: %d events over %.0f s", rr.Seed, n, rr.DurationS) +} + +func ptr[T any](v T) *T { return &v } diff --git a/cmd/simulator/run_test.go b/cmd/simulator/run_test.go new file mode 100644 index 0000000..dd33cde --- /dev/null +++ b/cmd/simulator/run_test.go @@ -0,0 +1,385 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "math/rand" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/srcfl/device-simulator/internal/ocpp/ocpptest" +) + +// freeBase finds a base whose next 60 ports are free on 127.0.0.1. It draws +// bases below the ephemeral range, which the kernel hands out in turn and so +// may have used up near its top. +func freeBase(t *testing.T) int { + t.Helper() + for try := 0; try < 100; try++ { + base := 20000 + rand.Intn(300)*100 + ok := true + for p := base; p < base+60 && ok; p++ { + l, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(p))) + if err != nil { + ok = false + continue + } + l.Close() + } + if ok { + return base + } + } + t.Fatal("no free port range") + return 0 +} + +func itoa(n int) string { return strconv.Itoa(n) } + +type planted struct { + samples []plantSample + marks map[string]float64 + events []runEvent +} + +func readPlant(t *testing.T, out string) planted { + t.Helper() + f, err := os.Open(filepath.Join(out, "plant.jsonl")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + p := planted{marks: map[string]float64{}} + sc := bufio.NewScanner(f) + for sc.Scan() { + var line struct { + plantSample + Mark string `json:"mark"` + Event *runEvent `json:"event"` + } + if err := json.Unmarshal(sc.Bytes(), &line); err != nil { + t.Fatalf("plant.jsonl: %v: %s", err, sc.Bytes()) + } + switch { + case line.Mark != "": + p.marks[line.Mark] = line.Elapsed + case line.Event != nil: + p.events = append(p.events, *line.Event) + default: + p.samples = append(p.samples, line.plantSample) + } + } + return p +} + +func runFor(t *testing.T, spec runSpec) (*headlessRun, string) { + t.Helper() + out := t.TempDir() + r, err := startRun(spec, freeBase(t), out) + if err != nil { + t.Fatal(err) + } + t.Cleanup(r.end) + return r, out +} + +func waitStop(t *testing.T, r *headlessRun, within time.Duration) { + t.Helper() + select { + case <-r.stop: + r.end() + case <-time.After(within): + t.Fatal("the run did not end") + } +} + +// fuse_preheat_a starts the fuses warm and fuse_ambient_c sets the +// cabinet; a size the fuse profile does not model is refused. One run at a +// time: runs share the simulator's settings. +func TestRunFuseStartsWarm(t *testing.T) { + t.Run("warm", func(t *testing.T) { + r, _ := runFor(t, runSpec{Name: "warm", Site: runSiteSpec{FuseA: 25, HouseW: [3]float64{230, 230, 230}, + FuseAmbientC: ptr(30.0), FusePreheatA: &[3]float64{25, 12.5, 0}}}) + r.site.mu.RLock() + heat, ambient := r.site.FuseHeat, r.site.FuseAmbientC + r.site.mu.RUnlock() + if heat[0].Damage < 0.4 || heat[1].Damage >= heat[0].Damage || heat[2].Damage != 0 || ambient == nil || *ambient != 30 { + t.Fatalf("preheated at 25/12.5/0 A in 30 °C: damage %.2f/%.2f/%.2f, ambient %v", heat[0].Damage, heat[1].Damage, heat[2].Damage, ambient) + } + }) + t.Run("cold", func(t *testing.T) { + r, _ := runFor(t, runSpec{Name: "cold", Site: runSiteSpec{FuseA: 25}}) + r.site.mu.RLock() + defer r.site.mu.RUnlock() + if d := r.site.FuseHeat[0].Damage; d != 0 { + t.Errorf("a cold start has damage %.2f", d) + } + }) + t.Run("10 A gG", func(t *testing.T) { + if r, err := startRun(runSpec{Name: "ten", Site: runSiteSpec{FuseA: 10}}, freeBase(t), t.TempDir()); err == nil { + r.end() + t.Error("a 10 A gG fuse was accepted") + } + }) +} + +// wired_phases reaches the charger. +func TestRunWiresAOnePhaseCharger(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25}, + Devices: []runDeviceSpec{{ID: "cp", Driver: "ocpp", OCPPURL: "ws://127.0.0.1:9/", MaxKW: 3.68, WiredPhases: 1}}} + r, _ := runFor(t, spec) + srv := r.devs["cp"].OCPPServer + if srv == nil || !srv.WiredOnOnePhase() { + t.Fatal("the charger is not wired on one phase") + } +} + +// "meter" names the site meter in ports.json, so no device may take it. +func TestRunRefusesTheMeterID(t *testing.T) { + spec := runSpec{Name: "meter-id", Site: runSiteSpec{FuseA: 25}, + Devices: []runDeviceSpec{{ID: "meter", Driver: "sungrow", BatteryKWh: 10}}} + if r, err := startRun(spec, freeBase(t), t.TempDir()); err == nil { + r.end() + t.Fatal("a device called meter was accepted") + } +} + +// A run builds its house from the spec, applies its timeline, records a +// sample a second and says where each device listens. +func TestRunRecordsTheHouse(t *testing.T) { + spec := runSpec{Name: "t", DurationS: 4, + Site: runSiteSpec{FuseA: 25, HouseW: [3]float64{690, 690, 690}}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", PVW: 1500, + SoCPct: ptr(50.0), MinSoCPct: ptr(5.0), EMSControl: ptr(false)}}, + Timeline: []runEvent{{AtS: 1.5, Do: "loads", A: &[3]float64{10, 3, 3}}, {AtS: 2, Do: "mark", Label: "after"}}} + r, out := runFor(t, spec) + waitStop(t, r, 10*time.Second) + + var ports runPorts + b, err := os.ReadFile(filepath.Join(out, "ports.json")) + if err != nil || json.Unmarshal(b, &ports) != nil { + t.Fatalf("ports.json: %v %s", err, b) + } + if ports.Devices["inv"].Modbus == 0 || ports.Devices["meter"].Modbus == 0 || ports.HTTP == 0 { + t.Fatalf("ports %+v", ports) + } + p := readPlant(t, out) + if len(p.samples) < 3 || len(p.events) != 1 || p.marks["after"] < 1.9 { + t.Fatalf("%d samples, events %v, marks %v", len(p.samples), p.events, p.marks) + } + first, last := p.samples[0], p.samples[len(p.samples)-1] + // House 690 W per phase less 1 500 W of PV spread evenly. + if first.PhaseW[0] < 180 || first.PhaseW[0] > 200 { + t.Errorf("before the step: %v W", first.PhaseW) + } + if last.PhaseW[0]-last.PhaseW[1] < 1600 || last.PhaseA[0] < 7 || last.Fuse.RatingA != 25 || last.Fuse.Profile != "iec_gg" { + t.Errorf("after 10/3/3 A: %+v", last) + } + if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil { + t.Error(err) + } + if n := len(simulators); n != 0 { + t.Errorf("%d simulators left after the run", n) + } +} + +// The harness can change the house while it runs, and stop it. +func TestRunTakesEventsOverHTTP(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 16, HouseW: [3]float64{230, 230, 230}}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", EMSControl: ptr(false)}}} + r, out := runFor(t, spec) + post := func(path, body string) { + t.Helper() + resp, err := http.Post("http://127.0.0.1:"+itoa(r.base)+path, "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("%s: %s", path, resp.Status) + } + } + time.Sleep(1200 * time.Millisecond) + post("/api/run/mark", `{"label": "step"}`) + post("/api/run/event", `{"do": "loads", "w": [2300, 230, 230]}`) + time.Sleep(1500 * time.Millisecond) + post("/api/run/event", `{"do": "stop"}`) + waitStop(t, r, 5*time.Second) + p := readPlant(t, out) + if _, ok := p.marks["step"]; !ok || len(p.events) != 2 { + t.Fatalf("marks %v, events %v", p.marks, p.events) + } + if last := p.samples[len(p.samples)-1]; last.PhaseW[0] < 2200 { + t.Fatalf("after the posted step: %v W", last.PhaseW) + } + resp, err := http.Get("http://127.0.0.1:" + itoa(r.base) + "/api/update/check") + if err == nil { + resp.Body.Close() + } +} + +// A charger dials the Central System in its spec, logs its frames, and a +// reconnect event drops the connection without ending the session. +func TestRunChargerReconnects(t *testing.T) { + csms := ocpptest.New(t) + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25}, + Devices: []runDeviceSpec{{ID: "garage", Driver: "ocpp", OCPPURL: csms.URL("ftw:pw"), Plugged: true, CarPhases: 1}}} + r, out := runFor(t, spec) + ocpptest.WaitFor(t, "StartTransaction", 10*time.Second, func() bool { return csms.Sent("StartTransaction") == 1 }) + ocpptest.WaitFor(t, "charging", 5*time.Second, func() bool { return r.devs["garage"].OCPPServer.PhasePowers()[0] > 3000 }) + if p := r.devs["garage"].OCPPServer.PhasePowers(); p[1] != 0 { + t.Fatalf("a one-phase car: %v", p) + } + if err := r.apply(runEvent{Do: "reconnect", Device: "garage", ForS: 0.2}); err != nil { + t.Fatal(err) + } + ocpptest.WaitFor(t, "a second connection", 15*time.Second, func() bool { return csms.Connections() == 2 }) + if p := r.devs["garage"].OCPPServer.PhasePowers(); p[0] < 3000 { + t.Fatalf("after the reconnect the car draws %v", p) + } + r.end() + b, err := os.ReadFile(filepath.Join(out, "ocpp-garage.jsonl")) + if err != nil || !bytes.Contains(b, []byte(`"cp->cs"`)) || !bytes.Contains(b, []byte("BootNotification")) { + t.Fatalf("frame log: %v %.300s", err, b) + } +} + +func randomSpec(seed int64) runSpec { + return runSpec{Name: "r", Site: runSiteSpec{FuseA: 20, HouseW: [3]float64{690, 690, 690}}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", PVW: 3000}, + {ID: "garage", Driver: "ocpp", OCPPURL: "ws://127.0.0.1:1/"}}, + Random: &runRandom{Seed: seed, DurationS: 1800}} +} + +// A seed gives the same timeline every time, within the house's bounds. +func TestRandomTimelinesAreSeededAndBounded(t *testing.T) { + a, b, c := randomSpec(7), randomSpec(7), randomSpec(8) + ea, eb, ec := randomize(&a), randomize(&b), randomize(&c) + ja, _ := json.Marshal(ea) + jb, _ := json.Marshal(eb) + jc, _ := json.Marshal(ec) + if !bytes.Equal(ja, jb) || bytes.Equal(ja, jc) { + t.Fatal("seed 7 twice differs, or seed 8 is the same") + } + if *a.Devices[1].CommandResponseS != *b.Devices[1].CommandResponseS || a.Site.SignedPhaseCurrents == nil { + t.Fatal("the seed did not fix the devices' timing") + } + if len(ea) < 20 { + t.Fatalf("%d events in 30 min", len(ea)) + } + kinds := map[string]int{} + for seed := int64(1); seed <= 20; seed++ { + s := randomSpec(seed) + for _, ev := range randomize(&s) { + kinds[ev.Do]++ + if ev.AtS < 30 || ev.AtS > 1800 { + t.Fatalf("seed %d: event at %.0f s outside the run", seed, ev.AtS) + } + if ev.A != nil { + for _, x := range ev.A { + if x < 0.3 || x > 18 { + t.Fatalf("seed %d: house %.1f A on a 20 A fuse", seed, x) + } + } + } + } + } + for _, k := range []string{"loads", "pv", "soc", "car", "silence", "offline", "reconnect", "meter_late", "meter_glitch", "signed_currents", "load_balancer"} { + if kinds[k] == 0 { + t.Errorf("20 seeds never drew %s", k) + } + } + only := randomSpec(1) + only.Random.Kinds = []string{"meter_glitch"} + for _, ev := range randomize(&only) { + if ev.Do != "meter_glitch" { + t.Fatalf("kinds filter let %s through", ev.Do) + } + } +} + +// The meter's faults reach what an EMS reads: a late meter stands still, +// a glitch puts 0 W in the total once, and signed currents carry the sign. +// A later silence or outage is not ended by an earlier one's timer. +func TestOverlappingFaultsKeepTheLatest(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", EMSControl: ptr(false)}}} + r, _ := runFor(t, spec) + inv := r.devs["inv"] + for _, ev := range []runEvent{{Do: "silence", Device: "inv", ForS: 0.3}, {Do: "silence", Device: "inv"}} { + if err := r.apply(ev); err != nil { + t.Fatal(err) + } + } + time.Sleep(600 * time.Millisecond) + if !inv.Server.Silent() { + t.Fatal("an indefinite silence ended at the earlier silence's deadline") + } + for _, ev := range []runEvent{{Do: "online", Device: "inv"}, {Do: "offline", Device: "inv", ForS: 0.3}, {Do: "offline", Device: "inv", ForS: 5}} { + if err := r.apply(ev); err != nil { + t.Fatal(err) + } + } + time.Sleep(600 * time.Millisecond) + if isRunning(inv) { + t.Fatal("a 5 s outage ended at the earlier outage's deadline") + } +} + +// Energy that flows while the meter's readings stand still shows on its +// counters once they move again. +func TestLateMeterKeepsTheEnergy(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25, HouseW: [3]float64{3000, 3000, 3000}}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", EMSControl: ptr(false)}}} + r, _ := runFor(t, spec) + kwh := func() float64 { return getSemanticValue(r.meter, "total_import") } + ocpptest.WaitFor(t, "import on the meter", 5*time.Second, func() bool { return getSemanticValue(r.meter, "meter_power") > 8000 }) + t0, e0 := time.Now(), kwh() + if err := r.apply(runEvent{Do: "meter_late", ForS: 2}); err != nil { + t.Fatal(err) + } + time.Sleep(3 * time.Second) + w := getSemanticValue(r.meter, "meter_power") + got, want := kwh()-e0, w/1000*time.Since(t0).Hours() + if got < 0.8*want || got > 1.2*want { + t.Fatalf("%.5f kWh counted over %v at %.0f W, want about %.5f", got, time.Since(t0).Round(time.Millisecond), w, want) + } +} + +func TestRunMeterFaults(t *testing.T) { + spec := runSpec{Name: "t", Site: runSiteSpec{FuseA: 25, HouseW: [3]float64{690, 690, 690}}, + Devices: []runDeviceSpec{{ID: "inv", Driver: "sungrow", PVW: 5000, EMSControl: ptr(false)}}} + r, _ := runFor(t, spec) + meterW := func() float64 { return getSemanticValue(r.meter, "meter_power") } + l1A := func() float64 { return getSemanticValue(r.meter, "meter_l1_current") } + ocpptest.WaitFor(t, "export on the meter", 5*time.Second, func() bool { return meterW() < -2000 }) + if l1A() < 0 { + t.Fatalf("an SDM630 reports magnitudes, got %v A", l1A()) + } + if err := r.apply(runEvent{Do: "signed_currents", On: ptr(true)}); err != nil { + t.Fatal(err) + } + ocpptest.WaitFor(t, "a signed current", 3*time.Second, func() bool { return l1A() < 0 }) + + if err := r.apply(runEvent{Do: "meter_late", ForS: 2}); err != nil { + t.Fatal(err) + } + r.setLoads([3]float64{3000, 3000, 3000}) + time.Sleep(1200 * time.Millisecond) + if w := meterW(); w > -2000 { + t.Fatalf("a late meter moved to %v W", w) + } + ocpptest.WaitFor(t, "the meter again", 5*time.Second, func() bool { return meterW() > 0 }) + + if err := r.apply(runEvent{Do: "meter_glitch"}); err != nil { + t.Fatal(err) + } + ocpptest.WaitFor(t, "a 0 W total", 3*time.Second, func() bool { return meterW() == 0 && getSemanticValue(r.meter, "meter_l1_power") > 0 }) + ocpptest.WaitFor(t, "the total again", 3*time.Second, func() bool { return meterW() > 0 }) +} diff --git a/cmd/simulator/static/openapi.json b/cmd/simulator/static/openapi.json index c6eb456..070da58 100644 --- a/cmd/simulator/static/openapi.json +++ b/cmd/simulator/static/openapi.json @@ -422,6 +422,38 @@ "responses": { "200": { "description": "Updated" } } } }, + "/api/run": { + "get": { + "summary": "Headless run only (simulator run): name, elapsed time, events and samples so far", + "responses": { "200": { "description": "OK" } } + } + }, + "/api/run/event": { + "post": { + "summary": "Headless run only: change the house now and record the event", + "requestBody": { "content": { "application/json": { "schema": { "type": "object", "required": ["do"], "properties": { + "do": { "type": "string", "enum": ["mark", "loads", "pv", "soc", "plug", "car", "offline", "online", "reconnect", "silence", "meter_late", "meter_glitch", "signed_currents", "load_balancer", "stop"] }, + "device": { "type": "string", "description": "Device id from the run's spec" }, + "label": { "type": "string" }, + "w": { "type": "array", "items": { "type": "number" }, "description": "House load per phase, W" }, + "a": { "type": "array", "items": { "type": "number" }, "description": "House load per phase, A" }, + "pv_w": { "type": "number" }, + "pct": { "type": "number", "description": "Battery state of charge" }, + "on": { "type": "boolean", "description": "Car plugged in" }, + "max_a": { "type": "number", "description": "Most the car draws per phase" }, + "phases": { "type": "integer", "description": "Phases the car charges on" }, + "for_s": { "type": "number", "description": "How long the device stays offline or disconnected" } + } } } } }, + "responses": { "204": { "description": "Applied" }, "400": { "description": "Unknown event or device" } } + } + }, + "/api/run/mark": { + "post": { + "summary": "Headless run only: put a named mark in the record", + "requestBody": { "content": { "application/json": { "schema": { "type": "object", "required": ["label"], "properties": { "label": { "type": "string" } } } } } }, + "responses": { "204": { "description": "Marked" } } + } + }, "/api/load-scenarios": { "get": { "summary": "List load scenarios", diff --git a/docs/HEADLESS_RUN.md b/docs/HEADLESS_RUN.md new file mode 100644 index 0000000..9c2c4e0 --- /dev/null +++ b/docs/HEADLESS_RUN.md @@ -0,0 +1,120 @@ +# Headless runs + +`simulator run` builds one house from a JSON spec, serves it on ports from a +base, applies a timeline of events and records the house once a second. An +EMS under test talks to it over Modbus and OCPP like a real site; its test +harness reads the record and can post events while the house runs. FTW's +site test (`go/test/site` in srcfl/ftw) uses it. + +```bash +simulator run -spec house.json -port-base 18700 -out run/ +``` + +The run ends after `duration_s`, on SIGINT or SIGTERM, or on a `stop` event. + +## Ports + +| Port | What | +|---|---| +| base | HTTP API (the usual one, plus `/api/run`), on 127.0.0.1 | +| base + 10 … | Modbus devices, in spec order, then the site meter | +| base + 30 … | OCPP servers (unused by chargers in client mode) | +| base + 50 … | MQTT | + +Two runs on one machine need bases at least 100 apart. `ports.json` in the +output directory says where each device is. + +## Spec + +```json +{ + "name": "chargenow-25A", + "duration_s": 0, + "site": {"fuse_a": 25, "fuse_profile": "iec_gg", "house_w": [690, 690, 690]}, + "devices": [ + {"id": "inverter", "driver": "sungrow", "battery_kwh": 10, "soc_pct": 50, "min_soc_pct": 5, "pv_w": 0}, + {"id": "garage", "driver": "ocpp", "ocpp_url": "ws://ftw:secret@127.0.0.1:8887/", + "max_kw": 11.04, "phase_switch": false, "command_response_s": 60, + "ocpp_config": {"MeterValueSampleInterval": "10"}, "car_phases": 3, "plugged": false} + ], + "timeline": [{"at_s": 120, "do": "loads", "a": [13, 13, 13]}] +} +``` + +- `driver` is the srcfl/device-drivers driver the device emulates (the file + name without `.lua`), or `ocpp` for an OCPP 1.6 charger. A Modbus device + takes the model (profile) that names that driver; its timing comes from + the model's `model` block and can be overridden per device + (`command_response_s`). +- A hybrid inverter's battery follows its EMS registers (`ems_control`, on by + default in a run). +- The site meter is an SDM630 behind a fuse per phase (`fuse_a`, + `fuse_profile` from package `fuse`: a thermal model, gG for 16-63 A). Its + id is `meter`, which no device may take. `fuse_ambient_c` is the + cabinet's air (default 25 °C); `fuse_preheat_a`, a current per phase the + fuses have carried long enough to settle, starts the run warm instead of + cold. A size the profile does not model is refused. +- A charger dials `ocpp_url`; `user:password@` goes out as Basic auth. + `car_max_a` and `car_phases` are what the car takes. `wired_phases: 1` + wires a charger on L1 only: it gives an order's current there, whatever + `numberPhases` says, up to `max_kw` (its rating as wired). + +## Events + +Each has `do` and, on the timeline, `at_s`. Post one to `/api/run/event` to +apply it now, or `{"label": "..."}` to `/api/run/mark`. + +| `do` | Fields | Effect | +|---|---|---| +| `mark` | `label` | a named moment in the record | +| `loads` | `w` or `a` | house load per phase | +| `pv` | `pv_w`, `device` | PV power of an inverter (default the first) | +| `soc` | `pct`, `device` | battery state of charge | +| `plug` | `on`, `device` | car plugged in or out | +| `car` | `max_a`, `phases`, `device` | what the car takes | +| `offline` | `device`, `for_s` | the device stops answering and drops out of the site; back after `for_s` if given | +| `online` | `device` | back again | +| `reconnect` | `device`, `for_s` | a charger loses its connection for `for_s`; the car keeps charging | +| `silence` | `device`, `for_s` | a Modbus device keeps its connections but answers nothing (timeouts, not refusals); 0 until `online` | +| `meter_late` | `for_s` | the site meter's readings stand still; the house goes on | +| `meter_glitch` | | the next reading's total reads 0 W while its phases are right | +| `signed_currents` | `on` | the meter's phase currents carry the sign of their power (an SDM630 reports magnitudes) | +| `load_balancer` | `device`, `max_a` | the charger's own cap per phase, whatever it is offered; 0 none | +| `stop` | | end the run | + +## Model parameters + +Each model's timing and quirks sit in its profile's `model` block and can +be overridden per device in the spec: + +| Parameter | Device | Meaning | +|---|---|---| +| `command_response_s` | hybrid, OCPP charger | the battery follows an EMS write, or the charger applies an accepted profile, this much later | +| `startup_delay_s` | OCPP charger | the car draws this long after an offer rises from zero; the charger says Charging meanwhile | +| `load_balancer_a` | OCPP charger | the charger's own cap per phase | +| `signed_phase_currents` | meter | currents carry a sign (`site.signed_phase_currents` in a spec) | + +## Random timelines + +```json +"random": {"seed": 42, "duration_s": 1800, "gap_s": 40, "kinds": ["loads_step", "meter_late"]} +``` + +draws events between 30 s and `duration_s` − 30 s, on average `gap_s` +apart: house load steps, drift and one-phase spikes (each phase stays under +the fuse less 2 A), PV cloud edges, the battery near its floor, the car +taking less than offered, device silence and outages, charger reconnects, +a late or glitched meter, signed currents and a load-balancer cap. It also +picks each charger's `command_response_s` (0, 5, 40 or 60 s) and +`startup_delay_s`, a one-phase car now and then, and the meter's sign, +where the spec leaves them open. `kinds` limits the draw. The run writes +`timeline.json`: the spec with the drawn timeline, which replays it. + +## Output + +| File | What | +|---|---| +| `ports.json` | the HTTP port, and each device's Modbus port and unit or OCPP identity | +| `plant.jsonl` | a sample a second: `phase_w` and `phase_a` through each fuse (+ into the site), `meter_w`, `fuse` (`damage`, the element's way from ambient to melting, 0 to 1; `blown`; `rating_a`), and each device's state; plus every event (`event`) and mark (`mark`) as it happened | +| `ocpp-.jsonl` | every OCPP-J frame a charger read (`cs->cp`) or wrote (`cp->cs`), with its time | +| `result.json` | at the end: samples, events, whether a fuse blew | diff --git a/internal/mcp/server.go b/internal/mcp/server.go index ae6e9cc..38341cb 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -895,6 +895,47 @@ func buildToolSpecs() ([]Tool, map[string]ToolSpec) { Method: http.MethodGet, PathTemplate: "/api/load-scenarios", }, + { + Tool: Tool{ + Name: "run.status", + Description: "Headless run only: name, elapsed time, events and samples so far", + InputSchema: schemaObject(nil, nil), + }, + Method: http.MethodGet, + PathTemplate: "/api/run", + }, + { + Tool: Tool{ + Name: "run.event", + Description: "Headless run only: change the house now (do: mark, loads, pv, soc, plug, car, offline, online, reconnect, silence, meter_late, meter_glitch, signed_currents, load_balancer, stop)", + InputSchema: schemaObject(map[string]interface{}{ + "do": schemaString("Event kind"), + "device": schemaString("Device id from the run's spec"), + "label": schemaString("Mark label"), + "w": schemaArray("House load per phase in watts", "number"), + "a": schemaArray("House load per phase in amperes", "number"), + "pv_w": schemaNumber("PV power"), + "pct": schemaNumber("State of charge"), + "on": schemaBoolean("Car plugged in"), + "max_a": schemaNumber("Most the car draws per phase"), + "phases": schemaInteger("Phases the car charges on"), + "for_s": schemaNumber("How long the device stays offline or disconnected"), + }, []string{"do"}), + }, + Method: http.MethodPost, + PathTemplate: "/api/run/event", + BodyParams: []string{"do", "device", "label", "w", "a", "pv_w", "pct", "on", "max_a", "phases", "for_s"}, + }, + { + Tool: Tool{ + Name: "run.mark", + Description: "Headless run only: put a named mark in the record", + InputSchema: schemaObject(map[string]interface{}{"label": schemaString("Mark label")}, []string{"label"}), + }, + Method: http.MethodPost, + PathTemplate: "/api/run/mark", + BodyParams: []string{"label"}, + }, { Tool: Tool{ Name: "system.info", diff --git a/internal/modbus/server.go b/internal/modbus/server.go index 49a46f2..db72f62 100644 --- a/internal/modbus/server.go +++ b/internal/modbus/server.go @@ -47,6 +47,23 @@ type Server struct { clientsMu sync.RWMutex nextClientID int RegisterSet *RegisterSet // Device-specific register definitions (nil = uses global Sungrow defs) + silent bool // read requests but never answer +} + +// SetSilent makes the server keep its connections and read every request +// but answer none, as a device that hangs does: a client sees timeouts, +// not a refused connection. +func (s *Server) SetSilent(on bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.silent = on +} + +// Silent says whether the server is ignoring requests. +func (s *Server) Silent() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.silent } // NewServer creates a new Modbus server with the specified register count @@ -375,7 +392,7 @@ func (s *Server) handleConnection(conn net.Conn) { response = s.makeException(transactionID, unitID, functionCode, 0x01) } - if response != nil { + if response != nil && !s.Silent() { conn.Write(response) } } diff --git a/internal/modbus/server_test.go b/internal/modbus/server_test.go index d5671e1..34281aa 100644 --- a/internal/modbus/server_test.go +++ b/internal/modbus/server_test.go @@ -1,6 +1,7 @@ package modbus import ( + "net" "testing" "time" ) @@ -203,3 +204,40 @@ func TestServerBoundaryConditions(t *testing.T) { t.Errorf("Boundary read: got length %d", len(regs)) } } + +// A silent server keeps the connection and answers nothing until it speaks +// again. +func TestSilentServerAnswersNothing(t *testing.T) { + s := NewServer(65536) + if err := s.Listen("127.0.0.1:0"); err != nil { + t.Fatal(err) + } + go s.Serve() + defer s.Close() + conn, err := net.Dial("tcp", s.listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + ask := func() bool { + req := []byte{0, 1, 0, 0, 0, 6, 1, 0x03, 0x13, 0x89, 0, 1} // read holding 5001 + if _, err := conn.Write(req); err != nil { + t.Fatal(err) + } + conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + buf := make([]byte, 64) + n, err := conn.Read(buf) + return err == nil && n > 0 + } + if !ask() { + t.Fatal("no answer before silence") + } + s.SetSilent(true) + if ask() { + t.Fatal("answered while silent") + } + s.SetSilent(false) + if !ask() { + t.Fatal("no answer after silence") + } +} diff --git a/internal/ocpp/client_test.go b/internal/ocpp/client_test.go index 7684934..f23f6f8 100644 --- a/internal/ocpp/client_test.go +++ b/internal/ocpp/client_test.go @@ -3,6 +3,7 @@ package ocpp import ( "encoding/json" "math" + "sync" "testing" "time" @@ -261,6 +262,35 @@ func TestCompositeScheduleKeepsThePhases(t *testing.T) { } } +// A charger wired on one phase gives an order's current on L1 whatever its +// numberPhases, and no more than its rating. +func TestOnePhaseWiring(t *testing.T) { + wired := func(p ChargingProfile) *Server { + s := NewServer(1, 0, "TestVendor", "TestModel", 1, 3.68) + s.SetWiredPhases(1) + s.transactions[1] = &Transaction{ID: 1, ConnectorID: 1, StartTime: time.Now()} + s.connectors[1].Status = StatusCharging + s.applyChargingProfile(1, &p) + s.GenerateValues(time.Now()) + return s + } + for _, c := range []struct { + name string + p ChargingProfile + want float64 + }{ + {"16 A on three phases", txProfileA(16, 3), 3680}, + {"10 A on one phase", txProfileA(10, 1), 2300}, + {"32 A", txProfileA(32, 3), 3680}, + {"3 kW", txProfileW(1, 3000), 3000}, + } { + s := wired(c.p) + if got := s.PhasePowers(); math.Abs(got[0]-c.want) > 1 || got[1] != 0 || got[2] != 0 { + t.Errorf("%s: phase powers %v, want %.0f W on L1", c.name, got, c.want) + } + } +} + // On one phase a charger gives at most its rated current per phase. func TestOnePhaseDrawIsCappedAtRatedCurrent(t *testing.T) { s := charging(t, 11.04, true, txProfileA(24, 1)) // a 16 A charger @@ -268,3 +298,168 @@ func TestOnePhaseDrawIsCappedAtRatedCurrent(t *testing.T) { t.Fatalf("phase powers %v, want 16 A (3680 W) on L1", got) } } + +// A dropped connection keeps the charger away for the time asked, the car +// keeps charging through it, and after it comes back the charger sends one +// heartbeat per interval, not one per connection it ever had. +// An outage that starts while the charger is already retrying lasts its +// full length. +func TestDropConnectionWhileRetrying(t *testing.T) { + csms := ocpptest.New(t) + csms.SetClosed(true) + s := newTestServer() + s.reconnectDelay = 50 * time.Millisecond + s.SetOCPPURL(csms.URL("")) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Stop() }) + time.Sleep(20 * time.Millisecond) // the first dial fails; a retry waits + before := time.Now() + s.DropConnection(600 * time.Millisecond) + csms.SetClosed(false) + waitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.Sent(ActionBootNotification) == 1 }) + if away := time.Since(before); away < 600*time.Millisecond { + t.Fatalf("back after %v, want at least 600 ms", away) + } +} + +func TestDropConnectionKeepsTheSessionAndOneLoopSet(t *testing.T) { + csms := ocpptest.New(t) + csms.SetHeartbeatInterval(1) + s := newTestServer() + s.reconnectDelay = 50 * time.Millisecond + var mu sync.Mutex + frames := map[string]int{} + s.SetFrameLog(func(dir string, _ []byte) { mu.Lock(); frames[dir]++; mu.Unlock() }) + s.SetOCPPURL(csms.URL("")) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Stop() }) + waitFor(t, "BootNotification", 5*time.Second, func() bool { return csms.Sent(ActionBootNotification) == 1 }) + if err := s.SetValue("evConnected", true); err != nil { + t.Fatal(err) + } + waitFor(t, "StartTransaction", 5*time.Second, func() bool { return csms.Sent(ActionStartTransaction) == 1 }) + + for i := 0; i < 2; i++ { + before := time.Now() + s.DropConnection(300 * time.Millisecond) + waitFor(t, "reconnect", 5*time.Second, func() bool { return csms.Connections() == i+2 }) + if away := time.Since(before); away < 300*time.Millisecond { + t.Fatalf("back after %v, want at least 300 ms", away) + } + } + waitFor(t, "BootNotification after the drops", 5*time.Second, func() bool { return csms.Sent(ActionBootNotification) == 3 }) + + beats := csms.Sent(ActionHeartbeat) + s.mu.RLock() + energy0 := s.connectors[1].EnergyWh + s.mu.RUnlock() + time.Sleep(3500 * time.Millisecond) + if n := csms.Sent(ActionHeartbeat) - beats; n < 2 || n > 4 { + t.Errorf("%d heartbeats in 3.5 s at a 1 s interval after two drops, want 3", n) + } + s.mu.RLock() + gained, txs, power := s.connectors[1].EnergyWh-energy0, len(s.transactions), s.connectors[1].CurrentPower + s.mu.RUnlock() + if txs != 1 || power <= 0 { + t.Fatalf("after the drops: %d transactions, %v W", txs, power) + } + // About 3.5 s at the charger's power, not a multiple of it. + if want := power * 3.5 / 3600; gained > 1.5*want { + t.Errorf("energy grew %.2f Wh in 3.5 s at %.0f W, want about %.2f Wh", gained, power, want) + } + mu.Lock() + defer mu.Unlock() + if frames["cs->cp"] == 0 || frames["cp->cs"] == 0 { + t.Errorf("frame log %v, want both directions", frames) + } +} + +// The car decides too: a one-phase car under a three-phase order draws on +// L1 only, and a car that takes 10 A takes 10 A of a 16 A offer. +func TestCarTakesWhatItCan(t *testing.T) { + // Amps per phase at the 227-230 V the simulator varies over. + amps := func(w float64) float64 { return w / 229 } + near := func(w, a float64) bool { return math.Abs(amps(w)-a) < 0.2 } + s := charging(t, 22.08, false, txProfileA(16, 3)) + s.SetCar(0, 1) + s.GenerateValues(time.Now()) + if got := s.PhasePowers(); !near(got[0], 16) || got[1] != 0 || got[2] != 0 { + t.Fatalf("one-phase car: %v, want 16 A on L1 only", got) + } + s.SetCar(10, 0) + s.GenerateValues(time.Now()) + if got := s.PhasePowers(); !near(got[0], 10) || !near(got[2], 10) { + t.Fatalf("10 A car: %v, want 10 A on each phase", got) + } + s.SetCar(0, 0) + s.GenerateValues(time.Now()) + if got := s.PhasePowers(); !near(got[1], 16) { + t.Fatalf("car without limits: %v, want the 16 A offer", got) + } +} + +// The charger's own load balancer caps the draw per phase below the offer. +func TestLoadBalancerCapsTheDraw(t *testing.T) { + s := charging(t, 22.08, false, txProfileA(16, 3)) + s.SetLoadBalancer(10) + s.GenerateValues(time.Now()) + if got := s.PhasePowers(); got[0]/229 > 10.2 || got[0]/229 < 9.8 { + t.Fatalf("load balancer at 10 A: %v", got) + } + s.mu.RLock() + offer := s.computeEffectivePowerLocked(1, time.Now()) + s.mu.RUnlock() + if offer < 10900 { + t.Fatalf("the offer itself is %v W, want the profile's 16 A", offer) + } +} + +// After an offer rises from zero the car waits the start delay, while the +// charger says Charging. +// Every session waits out the delay, not only the first. +func TestStartupDelayEachSession(t *testing.T) { + s := charging(t, 22.08, false, txProfileA(16, 3)) + s.SetStartupDelay(time.Minute) + s.connectors[1].offerSince = time.Time{} + t0 := time.Now() + s.GenerateValues(t0) + s.GenerateValues(t0.Add(61 * time.Second)) + if got := s.connectors[1].CurrentPower; got < 11000 { + t.Fatalf("first session after the delay: %v W", got) + } + delete(s.transactions, 1) + s.GenerateValues(t0.Add(62 * time.Second)) + s.transactions[2] = &Transaction{ID: 2, ConnectorID: 1, StartTime: t0.Add(63 * time.Second)} + s.connectors[1].Status = StatusCharging + s.GenerateValues(t0.Add(63 * time.Second)) + if got := s.connectors[1].CurrentPower; got != 0 { + t.Fatalf("second session at once: %v W, want 0 until the delay passes", got) + } + s.GenerateValues(t0.Add(124 * time.Second)) + if got := s.connectors[1].CurrentPower; got < 11000 { + t.Fatalf("second session after the delay: %v W", got) + } +} + +func TestStartupDelay(t *testing.T) { + s := charging(t, 22.08, false, txProfileA(16, 3)) + s.SetStartupDelay(time.Minute) + s.connectors[1].offerSince = time.Time{} + t0 := time.Now() + s.GenerateValues(t0) + if got := s.connectors[1].CurrentPower; got != 0 || s.connectors[1].Status != StatusCharging { + t.Fatalf("at the offer: %v W, %s", got, s.connectors[1].Status) + } + s.GenerateValues(t0.Add(30 * time.Second)) + if got := s.connectors[1].CurrentPower; got != 0 { + t.Fatalf("after 30 s: %v W", got) + } + s.GenerateValues(t0.Add(61 * time.Second)) + if got := s.connectors[1].CurrentPower; got < 11000 { + t.Fatalf("after the delay: %v W", got) + } +} diff --git a/internal/ocpp/ocpptest/csms.go b/internal/ocpp/ocpptest/csms.go index 7e465e9..da80237 100644 --- a/internal/ocpp/ocpptest/csms.go +++ b/internal/ocpp/ocpptest/csms.go @@ -23,6 +23,7 @@ type CSMS struct { srv *httptest.Server mu sync.Mutex + interval int // heartbeat interval in the BootNotification answer closed bool paths []string auths []string @@ -34,7 +35,7 @@ type CSMS struct { // New starts a Central System that stops with the test. func New(t testing.TB) *CSMS { - f := &CSMS{t: t} + f := &CSMS{t: t, interval: 60} up := websocket.Upgrader{Subprotocols: []string{"ocpp1.6"}} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() @@ -71,9 +72,12 @@ func New(t testing.TB) *CSMS { } json.Unmarshal(frame[2], &action) var result interface{} = map[string]interface{}{} + f.mu.Lock() + interval := f.interval + f.mu.Unlock() switch action { case "BootNotification": - result = map[string]interface{}{"status": "Accepted", "currentTime": time.Now().UTC().Format(time.RFC3339), "interval": 60} + result = map[string]interface{}{"status": "Accepted", "currentTime": time.Now().UTC().Format(time.RFC3339), "interval": interval} case "StartTransaction": result = map[string]interface{}{"transactionId": 42, "idTagInfo": map[string]string{"status": "Accepted"}} } @@ -103,6 +107,13 @@ func (f *CSMS) URL(userinfo string) string { return u + "/ocpp" } +// SetHeartbeatInterval sets the interval the BootNotification answer gives. +func (f *CSMS) SetHeartbeatInterval(s int) { + f.mu.Lock() + defer f.mu.Unlock() + f.interval = s +} + // SetClosed makes the Central System refuse connections, as one that is down. func (f *CSMS) SetClosed(closed bool) { f.mu.Lock() @@ -157,6 +168,13 @@ func (f *CSMS) Actions() []string { return append([]string(nil), f.actions...) } +// Connections counts the charge point's connections so far. +func (f *CSMS) Connections() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.paths) +} + // Paths lists the request path of each connection. func (f *CSMS) Paths() []string { f.mu.Lock() diff --git a/internal/ocpp/server.go b/internal/ocpp/server.go index 64099cc..eae1c38 100644 --- a/internal/ocpp/server.go +++ b/internal/ocpp/server.go @@ -67,6 +67,8 @@ type Connector struct { CurrentPower float64 `json:"currentPower"` EnergyWh float64 `json:"energyWh"` Phases int `json:"phases"` // 1 or 3: phases the car draws on + + offerSince time.Time // when the offer last rose from zero } // Transaction represents an active charging transaction @@ -124,6 +126,8 @@ type Server struct { ocppURL string // Central System URL to connect to (client mode) clientMode bool // true = connect to URL, false = listen for connections reconnectDelay time.Duration + redialAt time.Time // DropConnection keeps the charger away until then + frameLog func(dir string, frame []byte) // 3-phase metering voltageL1 float64 @@ -135,6 +139,11 @@ type Server struct { phaseW [3]float64 // what the chargers draw on L1, L2, L3 // Simulation control + carMaxA float64 // most the car draws per phase; 0 = whatever is offered + loadBalancerA float64 // the charger's own cap per phase; 0 = none + startupDelay time.Duration // from an offer rising from zero to the car drawing + carPhases int // phases the car charges on; 0 = as many as offered + wiredPhases int // phases the charger is wired on (from L1); 0 = three evConnected bool // Whether an EV is plugged in nextTxId int // Counter for local transaction IDs } @@ -210,6 +219,78 @@ func (s *Server) ProfileDelay() time.Duration { return s.profileDelay } +// SetCar describes the car on the charger: the most it draws per phase and +// on how many phases it charges. A one-phase car draws on L1 whatever the +// charger offers. Zeros mean the car takes what it is offered. +func (s *Server) SetCar(maxA float64, phases int) { + s.mu.Lock() + defer s.mu.Unlock() + s.carMaxA, s.carPhases = maxA, phases +} + +// SetLoadBalancer caps what the charger gives per phase, as a charger with +// its own load balancer (Easee) does whatever the Central System offers. +// Zero turns it off. +func (s *Server) SetLoadBalancer(a float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.loadBalancerA = a +} + +// SetWiredPhases wires the charger on its first n phases from L1; 0 or 3 +// is three. A one-phase charger gives an order's current on L1, whatever +// numberPhases says, and at most its rating there. +func (s *Server) SetWiredPhases(n int) { + s.mu.Lock() + defer s.mu.Unlock() + s.wiredPhases = n +} + +// WiredOnOnePhase says whether SetWiredPhases(1) wired the charger on L1. +func (s *Server) WiredOnOnePhase() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.wiredLocked() == 1 +} + +func (s *Server) wiredLocked() int { + if s.wiredPhases > 0 && s.wiredPhases < 3 { + return s.wiredPhases + } + return 3 +} + +// SetStartupDelay is how long the car takes to draw after an offer rises +// from zero. +func (s *Server) SetStartupDelay(d time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.startupDelay = d +} + +func (s *Server) loadBalancedLocked(w float64, phases int) float64 { + if s.loadBalancerA <= 0 { + return w + } + return math.Min(w, s.loadBalancerA*float64(phases)*s.averagePhaseVoltageLocked()) +} + +// carDrawLocked is what the car takes of an offer of w watts on phases. +func (s *Server) carDrawLocked(w float64, phases int) (float64, int) { + if w <= 0 || (s.carMaxA <= 0 && (s.carPhases <= 0 || s.carPhases >= phases)) { + return w, phases + } + v := s.averagePhaseVoltageLocked() + a := w / (float64(phases) * v) + if s.carMaxA > 0 { + a = math.Min(a, s.carMaxA) + } + if s.carPhases > 0 && s.carPhases < phases { + phases = s.carPhases + } + return a * float64(phases) * v, phases +} + // GetOCPPURL returns the configured Central System URL func (s *Server) GetOCPPURL() string { s.mu.RLock() @@ -252,6 +333,10 @@ func (s *Server) Start() error { // Recreate stop channel (may have been closed by previous Stop) s.stopChan = make(chan struct{}) + // The charger's power follows its profiles whether or not a Central + // System is connected. + go s.simulationLoop(s.stopChan) + if s.clientMode && s.ocppURL != "" { // Client mode: connect to Central System go s.clientLoop() @@ -302,6 +387,17 @@ func (s *Server) clientLoop() { return } + // An outage asked for while a retry was already waiting ends at its + // own deadline, not the retry's. + if wait := s.untilRedial(); wait > 0 { + select { + case <-s.stopChan: + return + case <-time.After(wait): + continue + } + } + // Build the full WebSocket URL with charge point identity // OCPP 1.6 expects: ws://host/path/ChargePointId wsURL, header, err := dialTarget(url, serial) @@ -331,7 +427,7 @@ func (s *Server) clientLoop() { select { case <-s.stopChan: return - case <-time.After(s.reconnectDelay): + case <-time.After(s.redialWait()): continue } } @@ -348,17 +444,14 @@ func (s *Server) clientLoop() { // Send BootNotification go s.sendBootNotification() - // Start heartbeat loop - go s.heartbeatLoop() - - // Start meter values loop - go s.meterValuesLoop() - - // Start simulation loop to update values - go s.simulationLoop() + // Heartbeats and meter values belong to this connection. + done := make(chan struct{}) + go s.heartbeatLoop(done) + go s.meterValuesLoop(done) // Read messages until disconnected s.readLoop(conn) + close(done) s.mu.Lock() s.wsConn = nil @@ -368,7 +461,7 @@ func (s *Server) clientLoop() { select { case <-s.stopChan: return - case <-time.After(s.reconnectDelay): + case <-time.After(s.redialWait()): log.Printf("[%s] Reconnecting to Central System...", serial) } } @@ -398,6 +491,53 @@ func dialTarget(raw, identity string) (string, http.Header, error) { return u.String(), header, nil } +// DropConnection closes the connection to the Central System, as a lost +// network does, and keeps the charger from dialling again for d. A session +// in progress goes on: the car keeps charging. +func (s *Server) DropConnection(d time.Duration) { + s.mu.Lock() + s.redialAt = time.Now().Add(d) + conn := s.wsConn + s.mu.Unlock() + if conn != nil { + conn.Close() + } +} + +// untilRedial is how long DropConnection still keeps the charger away. +func (s *Server) untilRedial() time.Duration { + s.mu.RLock() + defer s.mu.RUnlock() + return time.Until(s.redialAt) +} + +// redialWait is how long the client waits before it dials again. +func (s *Server) redialWait() time.Duration { + s.mu.RLock() + defer s.mu.RUnlock() + if until := time.Until(s.redialAt); until > s.reconnectDelay { + return until + } + return s.reconnectDelay +} + +// SetFrameLog makes the charger report every OCPP-J frame it reads +// ("cs->cp") or writes ("cp->cs"). Nil turns it off. +func (s *Server) SetFrameLog(fn func(dir string, frame []byte)) { + s.mu.Lock() + defer s.mu.Unlock() + s.frameLog = fn +} + +func (s *Server) logFrame(dir string, frame []byte) { + s.mu.RLock() + fn := s.frameLog + s.mu.RUnlock() + if fn != nil { + fn(dir, frame) + } +} + // RedactURL hides the password in a Central System URL for logs. func RedactURL(raw string) string { u, err := url.Parse(raw) @@ -429,7 +569,7 @@ func (s *Server) readLoop(conn *websocket.Conn) { } // meterValuesLoop periodically sends meter values during active transactions -func (s *Server) meterValuesLoop() { +func (s *Server) meterValuesLoop(done <-chan struct{}) { for { intervalSeconds := s.getMeterValueSampleInterval() if intervalSeconds <= 0 { @@ -439,6 +579,8 @@ func (s *Server) meterValuesLoop() { select { case <-s.stopChan: return + case <-done: + return case <-time.After(time.Duration(intervalSeconds) * time.Second): s.sendMeterValuesForActiveTransactions() } @@ -560,14 +702,11 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { // Send BootNotification go s.sendBootNotification() - // Start heartbeat loop - go s.heartbeatLoop() - - // Start meter values loop - go s.meterValuesLoop() - - // Start simulation loop to update values - go s.simulationLoop() + // Heartbeats and meter values belong to this connection. + done := make(chan struct{}) + defer close(done) + go s.heartbeatLoop(done) + go s.meterValuesLoop(done) // Read messages for { @@ -588,6 +727,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleMessage(data []byte) { + s.logFrame("cs->cp", data) messageType, messageId, action, payload, err := ParseOCPPMessage(data) if err != nil { log.Printf("[%s] Failed to parse message: %v", s.serialNumber, err) @@ -1378,6 +1518,8 @@ func (s *Server) evaluateProfile(profile *ChargingProfile, now time.Time, txStar if activePeriod.NumberPhases != nil && *activePeriod.NumberPhases > 0 { phases = *activePeriod.NumberPhases } + // A current flows on the phases the charger has. + phases = min(phases, s.wiredLocked()) // Convert to watts unit := strings.ToUpper(strings.TrimSpace(schedule.ChargingRateUnit)) switch unit { @@ -1491,6 +1633,11 @@ func (s *Server) computeEffectiveLocked(connectorId int, now time.Time) (float64 phases = 1 maxW /= 3 } + // A charger wired on fewer phases draws on those; max_kw is its rating + // as wired. + if w := s.wiredLocked(); w < phases { + phases = w + } // Cap at hardware max effectiveW = math.Min(effectiveW, maxW) @@ -1600,7 +1747,7 @@ func (s *Server) getHeartbeatInterval() int { return 60 } -func (s *Server) heartbeatLoop() { +func (s *Server) heartbeatLoop(done <-chan struct{}) { interval := s.getHeartbeatInterval() ticker := time.NewTicker(time.Duration(interval) * time.Second) defer ticker.Stop() @@ -1609,6 +1756,8 @@ func (s *Server) heartbeatLoop() { select { case <-s.stopChan: return + case <-done: + return case <-ticker.C: s.sendRequest(ActionHeartbeat, HeartbeatRequest{}) // Check if interval changed (e.g. from BootNotification response) @@ -1621,14 +1770,15 @@ func (s *Server) heartbeatLoop() { } } -// simulationLoop updates simulation values every second -func (s *Server) simulationLoop() { +// simulationLoop updates simulation values every second for as long as the +// charger runs, connected or not: a car charges through a network outage. +func (s *Server) simulationLoop(stop <-chan struct{}) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { - case <-s.stopChan: + case <-stop: return case <-ticker.C: s.GenerateValues(time.Now()) @@ -1785,6 +1935,7 @@ func (s *Server) sendRequest(action string, payload interface{}) (json.RawMessag // Serialize WebSocket writes to prevent concurrent write panic s.writeMu.Lock() + s.logFrame("cp->cs", msg) err = conn.WriteMessage(websocket.TextMessage, msg) s.writeMu.Unlock() @@ -1831,6 +1982,7 @@ func (s *Server) sendResponse(messageId string, payload interface{}) { // Serialize WebSocket writes s.writeMu.Lock() + s.logFrame("cp->cs", msg) if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { log.Printf("[%s] Failed to send response: %v", s.serialNumber, err) } @@ -1853,6 +2005,7 @@ func (s *Server) sendError(messageId, errorCode, errorDescription string, errorD // Serialize WebSocket writes s.writeMu.Lock() + s.logFrame("cp->cs", msg) if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { log.Printf("[%s] Failed to send error response: %v", s.serialNumber, err) } @@ -2377,18 +2530,34 @@ func (s *Server) GenerateValues(simTime time.Time) { // Simulate power consumption for active transactions var totalPower float64 var phaseW [3]float64 + offered := map[int]bool{} // connectors in a session that may draw for _, tx := range s.transactions { conn := s.connectors[tx.ConnectorID] if conn.Status == StatusCharging || conn.Status == StatusSuspendedEVSE { - powerW, phases := s.computeEffectiveLocked(tx.ConnectorID, simTime) + offered[tx.ConnectorID] = true + offerW, offerPhases := s.computeEffectiveLocked(tx.ConnectorID, simTime) + offerW = s.loadBalancedLocked(offerW, offerPhases) + powerW, phases := s.carDrawLocked(offerW, offerPhases) conn.Phases = phases + // The car takes a while to start drawing after an offer rises + // from zero; the charger meanwhile reports Charging at 0 W. + if offerW > 0 { + if conn.offerSince.IsZero() { + conn.offerSince = simTime + } + if simTime.Sub(conn.offerSince) < s.startupDelay { + powerW = 0 + } + } else { + conn.offerSince = time.Time{} + } - if powerW <= 0 && conn.Status == StatusCharging { + if offerW <= 0 && conn.Status == StatusCharging { // Power dropped to 0 — transition to SuspendedEVSE conn.Status = StatusSuspendedEVSE conn.CurrentPower = 0 statusChanges = append(statusChanges, statusChange{tx.ConnectorID, StatusSuspendedEVSE}) - } else if powerW > 0 && conn.Status == StatusSuspendedEVSE { + } else if offerW > 0 && conn.Status == StatusSuspendedEVSE { // Power restored — transition back to Charging conn.Status = StatusCharging conn.CurrentPower = powerW @@ -2412,6 +2581,13 @@ func (s *Server) GenerateValues(simTime time.Time) { tx.MeterNow = int(conn.EnergyWh) } } + // A connector out of session waits out the startup delay again on its + // next offer. + for id, conn := range s.connectors { + if !offered[id] && conn != nil { + conn.offerSince = time.Time{} + } + } // Update 3-phase metering from the power on each phase s.phaseW = phaseW diff --git a/internal/profiles/loader.go b/internal/profiles/loader.go index d1a7ea7..22fd728 100644 --- a/internal/profiles/loader.go +++ b/internal/profiles/loader.go @@ -48,6 +48,15 @@ type ModelParams struct { // command: a hybrid's battery after an EMS write, an OCPP charger's // output after a charging profile. CommandResponseS float64 `json:"command_response_s,omitempty"` + // StartupDelayS is how long a car on an OCPP charger takes to draw + // after an offer rises from zero. + StartupDelayS float64 `json:"startup_delay_s,omitempty"` + // LoadBalancerA is an OCPP charger's own cap per phase, whatever the + // Central System offers (Easee's load balancer). Zero is none. + LoadBalancerA float64 `json:"load_balancer_a,omitempty"` + // SignedPhaseCurrents: a meter's phase currents carry the sign of their + // phase's power. An SDM630 reports magnitudes. + SignedPhaseCurrents bool `json:"signed_phase_currents,omitempty"` } // Profile defines a device simulation profile @@ -142,6 +151,20 @@ func (p *Profile) Params() ModelParams { return *p.Model } +// ForDriver returns the first profile, by name, that emulates the +// device-drivers driver id, or nil. +func (m *Manager) ForDriver(id string) *Profile { + m.mu.RLock() + defer m.mu.RUnlock() + var best *Profile + for _, p := range m.profiles { + if p.DriverID == id && (best == nil || p.Name < best.Name) { + best = p + } + } + return best +} + // Get returns a profile by name func (m *Manager) Get(name string) *Profile { m.mu.RLock()