Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
11bc199
diagnostics(control): expose EV fuse budget evidence for start skew
segran2 Oct 16, 2026
83a49ee
diagnostics(control): retain blocked budget evidence in normal logs a…
segran2 Oct 16, 2026
e7b210d
fix(ev): confirm resumed delivery and expose stalled limited charging
segran2 Oct 16, 2026
815e305
fix(control): align common EV budget with historical site observations
segran2 Oct 16, 2026
b05957c
test(app): wait for published plan revision after plug-in
segran2 Oct 16, 2026
1fe1195
fix(control): retain qualified EV credit in latest-site fallback
segran2 Oct 17, 2026
8a02c0a
fix(ev): retain confirmed power anchors and require sustained recovery
segran2 Oct 17, 2026
15847d0
chore(ev): keep detailed fuse budget evidence at debug level
segran2 Oct 17, 2026
cde5cdb
fix(ev): record source provenance for rejected charging credit
segran2 Oct 17, 2026
4cae2e3
fix(ev): keep diagnostic telemetry snapshots in telemetry package
segran2 Oct 17, 2026
cc5df41
Merge branch 'master' into diagnose/ev-import-stop-1674
segran2 Oct 17, 2026
6da74c6
fix(ev): recover accepted offers without interrupting measured charging
frahlg Oct 18, 2026
e3ea0c4
test(ev): distinguish current limits from resume during OCPP reconnect
frahlg Oct 18, 2026
9caf7e1
fix(ev): recheck Stop and charger session after setting current
frahlg Oct 18, 2026
2682b04
fix(ev): use observed recovery evidence and recheck Stop after commands
frahlg Oct 18, 2026
a426593
refactor(ev): reuse the checked power sample
frahlg Oct 18, 2026
3b6ff98
refactor(ev): skip recovery state for normal charging
frahlg Oct 18, 2026
e89f27a
fix(ev): describe missing vehicle charge control
frahlg Oct 18, 2026
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/ev-recovery-measured-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Resume a known charging session after sustained non-delivery and keep checking until measured power returns. Set the safe current before resuming. Do not interrupt charging from a delayed vehicle status, cycle a charger after an accepted vehicle start, or wake a read-only vehicle. Show sustained zero draw even while FTW adjusts the current, and record command evidence when charging stops. Keep missing power unknown and recheck Stop and the plug session after slow commands.
4 changes: 3 additions & 1 deletion go/cmd/ftw/ev_observation.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa
sample := loadpoint.EVSample{ConnectionGeneration: d.ConnectionGeneration, PowerW: r.RawW,
PowerAt: r.UpdatedAt, PowerMaxAge: time.Duration(min(max(d.PowerMaxAgeS, 0), 180)) * time.Second, Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID,
SessionWhUnavailable: d.SessionWh == nil}
_, powerKnown := telemetry.ControlPowerObservation(sample.PowerW, r.Data, r.UpdatedAt)
sample.PowerUnavailable = !powerKnown
if d.SessionWh != nil {
sample.SessionWh = *d.SessionWh
}
if d.PowerAt != "" {
at, err := time.Parse(time.RFC3339Nano, d.PowerAt)
sample.PowerAt = at
sample.PowerUnavailable = err != nil || at.After(now.Add(time.Second)) || (now.Sub(at) > sample.PowerWindow() && r.RawW > 0)
sample.PowerUnavailable = sample.PowerUnavailable || err != nil || at.After(now.Add(time.Second)) || (now.Sub(at) > sample.PowerWindow() && r.RawW > 0)
}
if d.EnergyAt != "" {
at, err := time.Parse(time.RFC3339Nano, d.EnergyAt)
Expand Down
17 changes: 17 additions & 0 deletions go/cmd/ftw/ev_observation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,20 @@ func TestEVSourceCadenceDoesNotExtendTransportWatchdog(t *testing.T) {
t.Fatal("source cadence bypassed transport watchdog")
}
}

func TestEVObservationKeepsUnknownPowerSeparateFromZero(t *testing.T) {
now := time.Now()
health := &telemetry.DriverHealth{Status: telemetry.StatusOk}
for _, available := range []bool{false, true} {
data, _ := json.Marshal(map[string]any{
"connected": true, "session_id": "same", "control_power_available": available,
"control_power_observed_at": now.Add(-5 * time.Minute).Format(time.RFC3339Nano),
"control_power_confirmed": true,
})
r := &telemetry.DerReading{UpdatedAt: now, Data: data}
sample, ok := currentEVSample(r, health, time.Minute, now, false, "charger")
if !ok || !sample.Connected || sample.PowerUnavailable == available {
t.Fatalf("available=%t: unknown power became a measured zero or an unplug: %+v", available, sample)
}
}
}
13 changes: 9 additions & 4 deletions go/cmd/ftw/ev_ocpp_reconnect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,21 @@ func TestOCPPReconnectDoesNotUnplugOrResumeOldStart(t *testing.T) {
bus.Subscribe(events.KindChargingConnected, func(events.Event) { connectedEvents++ })
bus.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"garage": {Status: telemetry.StatusOk, LastSuccess: &now}}, Now: now})
commands := []float64{}
sendCount := 0
ctrl := loadpoint.NewController(manager, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, func(name string) (loadpoint.EVSample, bool) {
return currentEVSample(tel.Get(name, telemetry.DerEV), tel.DriverHealth(name), time.Minute, time.Now(), srv.Handler().IsOnline(name), currentOCPPDeviceID(srv.Handler(), name))
}, func(_ context.Context, _ string, p []byte) error {
var d struct {
Power float64 `json:"power_w"`
Power float64 `json:"power_w"`
Action string `json:"action"`
}
if e := json.Unmarshal(p, &d); e != nil {
return e
}
commands = append(commands, d.Power)
sendCount++
if d.Action == "ev_set_current" {
commands = append(commands, d.Power)
}
return nil
})
ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3})
Expand All @@ -111,7 +116,7 @@ func TestOCPPReconnectDoesNotUnplugOrResumeOldStart(t *testing.T) {
ctrl.SetManualHold("garage", loadpoint.ManualHold{Persistent: true, PowerW: power})
tick()
before, _ := manager.State("garage")
commandCount := len(commands)
commandCount := sendCount
cp.Stop()
await(func() bool {
r := tel.Get("garage", telemetry.DerEV)
Expand All @@ -128,7 +133,7 @@ func TestOCPPReconnectDoesNotUnplugOrResumeOldStart(t *testing.T) {
if !ok || hold.PowerW != 0 || !st.PluggedIn || st.ManualRestoreUnconfirmed != (power > 0) {
t.Fatalf("unknown socket lost safety: hold=%+v state=%+v", hold, st)
}
if len(commands) != commandCount {
if sendCount != commandCount {
t.Fatal("unknown cable dispatched a command")
}
if st.UpdatedAtMs != before.UpdatedAtMs {
Expand Down
8 changes: 7 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,7 @@ func main() {
// for the sends that are deliberately not.
lpController.SetOutcomeSender(evRouter.SendWithOutcome)
lpController.SetCycleSender(evRouter.SendCycle)
lpController.SetVehicleControlAllowed(reg.CanControl)
lpController.SetDispatchOutcome(actuation.recordCommandOutcome)
lpController.SetDriverOnline(func(name string) bool {
if ocppSrv != nil && ocppSrv.Handler().IsOnline(name) && ocppSrv.Handler().IsApproved(name) {
Expand Down Expand Up @@ -3184,8 +3185,13 @@ func main() {
lastEVStopReplan = time.Now()
fired = true
go mpcSvc.ReplanWithReason(ctx, "loadpoint_ev_stopped")
command, _ := tel.CommandEvidence(lp.DriverName, "ev")
slog.Info("loadpoint EV stopped → MPC replan triggered",
"lp", lp.ID, "prev_w", prev, "curr_w", curr)
"lp", lp.ID, "prev_w", prev, "curr_w", curr,
"commanded_known", lp.CommandedKnown, "commanded_w", lp.CommandedW,
"commanded_reason", lp.CommandedReason, "command_result", command.Result,
"command_at", command.At, "power_at_ms", lp.PowerUpdatedAtMs,
"power_unavailable", lp.PowerUnavailable)
}
evDrawPrev[lp.ID] = curr
}
Expand Down
40 changes: 40 additions & 0 deletions go/internal/api/api_clamped_no_draw_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package api

import (
"github.com/srcfl/ftw/go/internal/telemetry"
"testing"
"time"
)

func TestPositiveEVClampDoesNotHideNoPowerResponse(t *testing.T) {
now := time.Now()
connected := true
for _, tc := range []struct {
name, core string
sent, actual float64
fresh bool
age time.Duration
want string
}{
{"accepted ten amp offer, reason 78", "fuse_limit", 6900, 0, true, 3 * time.Minute, "no_power_response"},
{"intentional zero", "fuse_limit", 0, 0, true, 3 * time.Minute, "fuse_limit"},
{"still in grace", "fuse_limit", 6900, 0, true, time.Minute, "fuse_limit"},
{"charging at limited rate", "fuse_limit", 6900, 6800, true, 3 * time.Minute, "fuse_limit"},
{"stale charger", "fuse_limit", 6900, 0, false, 3 * time.Minute, "telemetry_stale"},
{"stale site", "site_meter_stale", 6900, 0, true, 3 * time.Minute, "site_meter_stale"},
{"cooldown", "fuse_cooldown", 6900, 0, true, 3 * time.Minute, "fuse_cooldown"},
} {
t.Run(tc.name, func(t *testing.T) {
f := ControlFeedback{Kind: "ev", RequestedA: watts(15), OfferedA: watts(10), SentW: watts(tc.sent), ActualW: watts(tc.actual), ObservedAtMs: now.UnixMilli(), DeviceReason: "limited by dynamic charger current"}
cmd := telemetry.CommandEvidence{Result: "accepted", Since: now.Add(-tc.age), NoPowerSince: now.Add(-tc.age), NoPowerAt: now}
classifyControlFeedback(&f, cmd, true, tc.fresh, tc.core, &connected, now)
setControlStatus(&f)
if f.Reason != tc.want {
t.Fatalf("got %+v want %s", f, tc.want)
}
if tc.want == "no_power_response" && f.Status != "not_following" {
t.Fatalf("zero power must be visible: %+v", f)
}
})
}
}
17 changes: 16 additions & 1 deletion go/internal/api/api_control_feedback.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,17 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
set("waiting", "not_connected", "info")
return
}
// A positive clamp explains the lower offer, not prolonged zero delivery.
// Keep known safety pauses and unconfirmed/stale commands authoritative.
evMismatchObserved := !cmd.PowerMismatchSince.IsZero() && cmd.LastObservation.Sub(cmd.PowerMismatchSince) >= 2*time.Minute
noPowerObserved := evMismatchObserved || !cmd.NoPowerSince.IsZero() && cmd.NoPowerAt.Sub(cmd.NoPowerSince) >= 2*time.Minute
if f.Kind == "ev" && commanded && cmd.Result == "accepted" &&
coreReason != "site_meter_stale" && coreReason != "site_phase_currents_stale" && coreReason != "fuse_cooldown" &&
f.SentW != nil && *f.SentW > 100 && f.ActualW != nil && math.Abs(*f.ActualW) < 100 &&
f.OfferedA != nil && *f.OfferedA > 0 && noPowerObserved {
set("warning", "no_power_response", "warning")
return
}
if f.RequestedA != nil && f.DeviceLimitA != nil && *f.RequestedA > *f.DeviceLimitA+0.5 {
set("limited", "device_limit", "warning")
return
Expand Down Expand Up @@ -467,7 +478,11 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence,
if cmd.LastGapW != nil {
gap = *cmd.LastGapW
}
if !cmd.PowerMismatchSince.IsZero() && now.Sub(cmd.PowerMismatchSince) >= grace && gap > telemetry.ControlToleranceW(*f.SentW) {
mismatchSettled := !cmd.PowerMismatchSince.IsZero() && now.Sub(cmd.PowerMismatchSince) >= grace
if f.Kind == "ev" {
mismatchSettled = evMismatchObserved
}
if mismatchSettled && gap > telemetry.ControlToleranceW(*f.SentW) {
if reason := batteryChargeLevelLimit(f); reason != "" {
set("limited", reason, "info")
return
Expand Down
100 changes: 100 additions & 0 deletions go/internal/api/api_ev_ramping_no_draw_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package api

import (
"fmt"
"testing"
"testing/synctest"
"time"

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

// The fuse guard raises the offer by 1 A. Each material step must not buy
// another two minutes of Adjusting when the charger keeps confirming zero.
func TestEVRampingOfferDoesNotHideSustainedZeroDraw(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tel := telemetry.NewStore()
srv := New(&Deps{Tel: tel})
for i := 0; i <= 25; i++ {
now := time.Now()
power := 4140 + 690*((i/5)%10)
cmd := tel.BeginCommand("easee", []byte(fmt.Sprintf(`{"action":"ev_set_current","power_w":%d}`, power)), now)
tel.CompleteCommand(cmd, "accepted")
tel.Update("easee", telemetry.DerEV, 0, nil, []byte(`{"max_a":15}`))
if i == 25 {
f := srv.controlFeedback(time.Now())[0]
if f.Reason != "no_power_response" || f.Status != "not_following" {
t.Fatalf("zero draw hidden by a new positive offer: %+v", f)
}
}
time.Sleep(5 * time.Second)
}
})
}

func TestEVNoPowerWarningRequiresAdvancingObservations(t *testing.T) {
for _, core := range []string{"fuse_limit", "manual_hold"} {
for _, advancing := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/advancing=%t", core, advancing), func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
tel := telemetry.NewStore()
srv := New(&Deps{Tel: tel})
cmd := tel.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":6900}`), start)
tel.CompleteCommand(cmd, "accepted")
for i := 0; i <= 25; i++ {
at := start
if advancing {
at = time.Now()
}
tel.Update("easee", telemetry.DerEV, 0, nil, []byte(fmt.Sprintf(
`{"max_a":10,"power_max_age_s":180,"control_power_observed_at":%q}`, at.Format(time.RFC3339Nano))))
if i < 25 {
time.Sleep(5 * time.Second)
}
}
f := srv.controlFeedback(time.Now())[0]
evidence, _ := tel.CommandEvidence("easee", "ev")
connected := true
classifyControlFeedback(&f, evidence, true, true, core, &connected, time.Now())
want := "no_power_response"
if !advancing {
want = "waiting_response"
if core == "fuse_limit" {
want = core
}
}
if f.Reason != want {
t.Fatalf("got %s, want %s; evidence: %+v", f.Reason, want, evidence)
}
})
})
}
}
}

func TestEVStartupPulseDoesNotHideAnObservedMismatch(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
tel := telemetry.NewStore()
srv := New(&Deps{Tel: tel})
cmd := tel.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":6900}`), time.Now())
tel.CompleteCommand(cmd, "accepted")
for i := 0; i <= 25; i++ {
power := 0.0
if i == 20 {
power = 299
}
tel.Update("easee", telemetry.DerEV, power, nil, []byte(`{"max_a":10}`))
if i < 25 {
time.Sleep(5 * time.Second)
}
}
f := srv.controlFeedback(time.Now())[0]
evidence, _ := tel.CommandEvidence("easee", "ev")
connected := true
classifyControlFeedback(&f, evidence, true, true, "fuse_limit", &connected, time.Now())
if f.Reason != "no_power_response" {
t.Fatalf("startup pulse hid a two-minute measured shortfall: %+v", f)
}
})
}
2 changes: 1 addition & 1 deletion go/internal/api/api_loadpoint_vehicle.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (s *Server) handleLoadpointForceStart(w http.ResponseWriter, r *http.Reques
writeJSON(w, 404, map[string]string{"error": "loadpoint not found", "loadpoint_id": id})
return
case errors.Is(err, loadpoint.ErrForceStartNoVehicleBound):
writeJSON(w, 422, map[string]string{"error": "no vehicle driver bound to loadpoint", "loadpoint_id": id})
writeJSON(w, 422, map[string]string{"error": "no vehicle driver with charge control bound to loadpoint", "loadpoint_id": id})
return
case err != nil:
// Generic 502 — do NOT surface the underlying driver error
Expand Down
9 changes: 9 additions & 0 deletions go/internal/drivers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,15 @@ func (r *Registry) SendDefault(ctx context.Context, name string) error {
}
}

// CanControl checks the loaded driver's write authority without sending a
// command. Registry.Send still checks it again for the selected generation.
func (r *Registry) CanControl(name string) bool {
r.mu.Lock()
defer r.mu.Unlock()
rd, ok := r.rec[name]
return ok && !rd.readOnly && !rd.cfg.ObserveOnly
}

// ControlStatus returns the command safety state for the current driver
// generation without creating or changing any state.
func (r *Registry) ControlStatus(name string) (DriverControlStatus, bool) {
Expand Down
39 changes: 39 additions & 0 deletions go/internal/drivers/registry_control_authority_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package drivers

import (
"context"
"fmt"
"testing"

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

func TestControlAuthorityFollowsLoadedDriver(t *testing.T) {
r := NewRegistry(telemetry.NewStore())
t.Cleanup(r.ShutdownAll)
if r.CanControl("car") {
t.Fatal("missing driver can control")
}
for _, tc := range []struct{ readOnly, observeOnly, want bool }{
{true, false, false}, {false, true, false}, {false, false, true},
} {
path := writeTestDriver(t, fmt.Sprintf(`
DRIVER = { id = "car", capabilities = { "vehicle" }, read_only = %t }
function driver_init(config) end
function driver_poll() return 1000 end
function driver_command(action, w, cmd) return true end
function driver_default_mode() end
`, tc.readOnly))
if err := r.Add(context.Background(), config.Driver{Name: "car", Lua: path, ObserveOnly: tc.observeOnly}); err != nil {
t.Fatal(err)
}
if got := r.CanControl("car"); got != tc.want {
t.Fatalf("read_only=%v observe_only=%v: got %v", tc.readOnly, tc.observeOnly, got)
}
r.Remove("car")
if r.CanControl("car") {
t.Fatal("removed driver can control")
}
}
}
Loading
Loading