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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ocpp-available-zero-power.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Home use is no longer marked estimated because an OCPP charger has no car. When the charger reports Available, no car is connected and no current flows, so its power counts as a measured 0 W in live Home use and in history. Each heartbeat or other message from the charger keeps that reading current, and it ends as soon as a car arrives. Unavailable and Faulted still leave the charger's power unknown. The Available a charger sends for itself (connector 0) at boot or reconnect no longer reads as an unplugged car. Core now asks OCPP chargers for a heartbeat every 30 s instead of every 60 s, so one late heartbeat no longer marks an idle charger offline: one more small request and reply a minute per charger. A heartbeat_interval_s set in the config is kept.
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ ocpp:
path: /
username: ftw
password: "" # required when enabled; use a long random string
heartbeat_interval_s: 60
heartbeat_interval_s: 30
# Serve wss:// instead of ws://. client_ca_file additionally requires each
# charger to present a certificate signed by that CA whose CN or DNS SAN
# matches the identity in the URL — OCPP 2.0.1 security profile 3.
Expand Down
2 changes: 1 addition & 1 deletion config.local.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ ocpp:
port_v201: 8888
username: ftw
password: sim-ocpp
heartbeat_interval_s: 60
heartbeat_interval_s: 30

state:
# `make dev` starts the Go process from go/, so keep disposable state in
Expand Down
2 changes: 1 addition & 1 deletion docs/ocpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ ocpp:
path: / # default /
username: ftw
password: <a long random string>
heartbeat_interval_s: 60
heartbeat_interval_s: 30
```

**Credentials are mandatory.** FTW refuses to start with `enabled: true` and an
Expand Down
187 changes: 187 additions & 0 deletions go/cmd/ftw/history_source_load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ package main

import (
"context"
"encoding/json"
"math"
"path/filepath"
"testing"
"time"

"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"

"github.com/srcfl/ftw/go/internal/ocpp"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
)
Expand Down Expand Up @@ -120,3 +124,186 @@ func TestHistorySourceQualificationDoesNotInventMissingFlows(t *testing.T) {
}
}
}

// An OCPP charger with no car sends no MeterValues. Its Available status is
// the evidence that nothing flows, so the household stays measured.
func TestHistoryCountsAnEmptyOCPPChargerAsMeasured(t *testing.T) {
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnableHistoryAggregation(); err != nil {
t.Fatal(err)
}
tel := telemetry.NewStore()
h := ocpp.NewHandler(tel, 60)
h.SetApprovedIDs([]string{"charger"})
h.OnConnect("charger")
if _, err := h.OnStatusNotification("charger", &core.StatusNotificationRequest{ConnectorId: 1, Status: core.ChargePointStatusAvailable, ErrorCode: core.NoError}); err != nil {
t.Fatal(err)
}
tel.Update("grid", telemetry.DerMeter, 2000, nil, nil)
tel.RecordDriverSuccess("grid")
if live := tel.LiveMeasurementNow("grid", telemetry.ForecastOptions{}); live.LoadQuality != telemetry.LoadMeasured || live.LoadW != 2000 {
t.Fatalf("live Home use %s (%s), %v W", live.LoadQuality, live.LoadReason, live.LoadW)
}
base := time.Now().Add(-10 * time.Minute).Truncate(5 * time.Minute).UnixMilli()
for i := int64(0); i <= 30; i++ {
p, _ := buildHistoryPoint(tel, tickPersistControl{SiteMeterDriver: "grid"}, base+i*10000, time.Minute)
if p.Sources.Missing != 0 || p.Missing&^state.MissingSoC != 0 {
t.Fatalf("empty charger marked history %b, sources %b", p.Missing, p.Sources.Missing)
}
p.Sources.AtMS = base + i*10000
if err := st.RecordTickWithEnergy(p, nil, nil); err != nil {
t.Fatal(err)
}
}
if err := st.FlushHistory(context.Background()); err != nil {
t.Fatal(err)
}
d, err := st.DailyEnergy(base, base+300000)
if err != nil {
t.Fatal(err)
}
if d.Missing&state.MissingLoad != 0 || math.Abs(d.LoadWh-2000.0/12) > 0.001 {
t.Fatalf("household %v Wh, quality %s", d.LoadWh, d.Missing.Quality())
}
}

// Between heartbeats an Available charger's 0 W is up to a minute old. The
// time-aligned balance calls that time_skew; the chart's quality comes from
// the source rows, so it must not say the measurements do not balance.
func TestHistoryChartKeepsSourceQualityForAnAgeingOCPPZero(t *testing.T) {
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnableHistoryAggregation(); err != nil {
t.Fatal(err)
}
tel := telemetry.NewStore()
tel.Update("grid", telemetry.DerMeter, 2000, nil, nil)
tel.RecordDriverSuccess("grid")
// What the OCPP handler publishes 40 s after the last heartbeat.
at := time.Now().Add(-40 * time.Second).UnixMilli()
data, _ := json.Marshal(map[string]any{"type": "ev", "w": 0.0, "connected": false, "forecast_power": telemetry.ForecastPowerSample{Version: 1, Known: true, MeasuredAtMS: at, ReceivedAtMS: at}})
tel.Update("charger", telemetry.DerEV, 0, nil, data)
tel.RecordDriverSuccess("charger")
base := time.Now().Add(-10 * time.Minute).Truncate(5 * time.Minute).UnixMilli()
for i := int64(0); i <= 30; i++ {
p, _ := buildHistoryPoint(tel, tickPersistControl{SiteMeterDriver: "grid"}, base+i*10000, time.Minute)
if p.Missing&state.BalanceMismatch == 0 || p.Sources.Missing != 0 {
t.Fatalf("want a skewed aligned balance and complete sources, got %b and %b", p.Missing, p.Sources.Missing)
}
p.Sources.AtMS = base + i*10000
if err := st.RecordTickWithEnergy(p, nil, nil); err != nil {
t.Fatal(err)
}
}
if err := st.FlushHistory(context.Background()); err != nil {
t.Fatal(err)
}
points, err := st.LoadHistoryContext(context.Background(), base, base+300000, 0)
if err != nil {
t.Fatal(err)
}
if len(points) == 0 {
t.Fatal("no chart points")
}
for _, p := range points {
if q := p.Missing.Quality(); q == "balance_mismatch" || q == "incomplete" {
t.Fatalf("chart point at %d says %s", p.TsMs, q)
}
}
}

// An idle charger often sends only heartbeats. At Core's default interval,
// with ±1 s jitter, its Available 0 W is at most 31 s old at any 2 s history
// tick: inside the 60 s limit, and its health never lapses. The old 60 s
// default loses ticks to the same jitter.
func TestHistoryKeepsAnIdleOCPPChargerMeasuredBetweenHeartbeats(t *testing.T) {
var c ocpp.Config
c.Defaults()
if lost := idleOCPPStaleTicks(t, time.Duration(c.HeartbeatIntervalS)*time.Second, true); lost != 0 {
t.Fatalf("default heartbeat %d s: %d ticks lost the charger", c.HeartbeatIntervalS, lost)
}
if lost := idleOCPPStaleTicks(t, time.Minute, false); lost == 0 {
t.Fatal("a 60 s heartbeat lost no tick; the test cannot see the edge")
}
}

// idleOCPPStaleTicks plays ten heartbeats at interval ±1 s through the real
// handler and counts history ticks whose source row misses a flow. Between
// beats the charger's reading and health are aged by the time since the beat.
func idleOCPPStaleTicks(t *testing.T, interval time.Duration, record bool) int {
t.Helper()
const watchdog = time.Minute // site default; history uses it as its age limit
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnableHistoryAggregation(); err != nil {
t.Fatal(err)
}
tel := telemetry.NewStore()
h := ocpp.NewHandler(tel, int(interval/time.Second))
h.SetApprovedIDs([]string{"charger"})
h.OnConnect("charger")
if _, err := h.OnStatusNotification("charger", &core.StatusNotificationRequest{ConnectorId: 1, Status: core.ChargePointStatusAvailable, ErrorCode: core.NoError}); err != nil {
t.Fatal(err)
}
age := func(beat time.Time, since time.Duration) {
r := tel.Get("charger", telemetry.DerEV)
var d map[string]any
if err := json.Unmarshal(r.Data, &d); err != nil {
t.Fatal(err)
}
at := beat.Add(-since)
d["forecast_power"] = telemetry.ForecastPowerSample{Version: 1, Known: true, MeasuredAtMS: at.UnixMilli(), ReceivedAtMS: at.UnixMilli()}
r.Data, _ = json.Marshal(d)
r.UpdatedAt = at
tel.DriverHealthMut("charger").LastSuccess = &at
}
base := time.Now().Add(-time.Hour).Truncate(5 * time.Minute).UnixMilli()
tick, lost := base, 0
for beat := 0; beat < 10; beat++ {
gap := interval + []time.Duration{-time.Second, 0, time.Second}[beat%3]
if _, err := h.OnHeartbeat("charger", &core.HeartbeatRequest{}); err != nil {
t.Fatal(err)
}
beatAt := time.Now()
for since := time.Duration(beat%4) * 500 * time.Millisecond; since < gap; since += 2 * time.Second {
age(beatAt, since)
tel.Update("grid", telemetry.DerMeter, 2000, nil, nil)
tel.RecordDriverSuccess("grid")
tel.WatchdogScan(watchdog)
p, _ := buildHistoryPoint(tel, tickPersistControl{SiteMeterDriver: "grid"}, tick, watchdog)
if p.Sources.Missing != 0 {
lost++
}
if record {
p.Sources.AtMS = tick
if err := st.RecordTickWithEnergy(p, nil, nil); err != nil {
t.Fatal(err)
}
}
tick += 2000
}
}
if record {
if err := st.FlushHistory(context.Background()); err != nil {
t.Fatal(err)
}
d, err := st.DailyEnergy(base, tick)
if err != nil {
t.Fatal(err)
}
if d.Missing&(state.MissingLoad|state.MissingEV) != 0 || d.LoadWh <= 0 {
t.Fatalf("history over %d s: %v Wh, quality %s", (tick-base)/1000, d.LoadWh, d.Missing.Quality())
}
}
return lost
}
43 changes: 42 additions & 1 deletion go/internal/config/ocpp_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package config

import "testing"
import (
"os"
"path/filepath"
"strings"
"testing"
)

// The OCPP listener cannot be pinned to one interface — the library builds its
// listen address from the port alone. Basic auth is therefore the only thing
Expand Down Expand Up @@ -142,3 +147,39 @@ func TestConfigValidateCoversOCPP(t *testing.T) {
t.Fatalf("config with a credentialed ocpp section should validate, got %v", err)
}
}

// Only the default heartbeat changed (60 s to 30 s). An unset value must stay
// unset through a save, and a saved 60 must stay 60.
func TestOCPPHeartbeatSurvivesSave(t *testing.T) {
for _, tc := range []struct {
yaml string
want int
}{
{"ocpp:\n enabled: false\n", 0},
{"ocpp:\n enabled: false\n heartbeat_interval_s: 60\n", 60},
} {
dir := t.TempDir()
path := filepath.Join(dir, "c.yaml")
c, err := Parse([]byte(minimalYAML+tc.yaml), dir)
if err != nil {
t.Fatal(err)
}
if err := SaveAtomic(path, c); err != nil {
t.Fatal(err)
}
saved, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if tc.want == 0 && strings.Contains(string(saved), "heartbeat_interval_s") {
t.Fatalf("an unset heartbeat was written:\n%s", saved)
}
c2, err := Load(path)
if err != nil {
t.Fatal(err)
}
if c2.OCPP == nil || c2.OCPP.HeartbeatIntervalS != tc.want {
t.Fatalf("heartbeat after save: got %+v, want %d", c2.OCPP, tc.want)
}
}
}
4 changes: 2 additions & 2 deletions go/internal/ocpp/charging_needs.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ func (h *handlerV201) OnNotifyEVChargingNeeds(id string, req *smartcharging.Noti
"departure", n.DepartureTime)

h.noteChargingNeeds(id, n)
h.telSuccess(id)
h.telHealth(id) // from the car, so no proof of Available
return smartcharging.NewNotifyEVChargingNeedsResponse(smartcharging.EVChargingNeedsStatusAccepted), nil
}

Expand All @@ -207,7 +207,7 @@ func (h *handlerV201) OnNotifyEVChargingNeeds(id string, req *smartcharging.Noti
// acknowledged and dropped — accepting it costs nothing and refusing would
// make the charger retry forever.
func (h *handlerV201) OnNotifyEVChargingSchedule(id string, _ *smartcharging.NotifyEVChargingScheduleRequest) (*smartcharging.NotifyEVChargingScheduleResponse, error) {
h.telSuccess(id)
h.telHealth(id)
return smartcharging.NewNotifyEVChargingScheduleResponse(types201.GenericStatusAccepted), nil
}

Expand Down
5 changes: 4 additions & 1 deletion go/internal/ocpp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,11 @@ func (c *Config) Defaults() {
if c.Path == "" {
c.Path = "/"
}
// Half the 60 s watchdog and history age limit: an idle charger often
// sends nothing else, so one late heartbeat would otherwise mark it
// offline and its Available 0 W stale.
if c.HeartbeatIntervalS == 0 {
c.HeartbeatIntervalS = 60
c.HeartbeatIntervalS = 30
}
}

Expand Down
26 changes: 26 additions & 0 deletions go/internal/ocpp/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ocpp

import (
"testing"

"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"

"github.com/srcfl/ftw/go/internal/telemetry"
)

// Core asks chargers for a heartbeat at half the 60 s watchdog unless the
// owner set one; a saved value wins.
func TestHeartbeatDefaultIsHalfTheWatchdog(t *testing.T) {
for _, tc := range []struct{ set, want int }{{0, 30}, {60, 60}, {120, 120}} {
c := Config{HeartbeatIntervalS: tc.set}
c.Defaults()
if c.HeartbeatIntervalS != tc.want {
t.Fatalf("heartbeat_interval_s %d: got %d, want %d", tc.set, c.HeartbeatIntervalS, tc.want)
}
h := NewHandler(telemetry.NewStore(), c.HeartbeatIntervalS)
conf, err := h.OnBootNotification("charger", &core.BootNotificationRequest{ChargePointVendor: "Rig", ChargePointModel: "One"})
if err != nil || conf.Interval != tc.want {
t.Fatalf("heartbeat_interval_s %d: boot asked for %d s (%v)", tc.set, conf.Interval, err)
}
}
}
Loading
Loading