diff --git a/.changeset/ev-recovery-measured-delivery.md b/.changeset/ev-recovery-measured-delivery.md new file mode 100644 index 000000000..db876fdba --- /dev/null +++ b/.changeset/ev-recovery-measured-delivery.md @@ -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. diff --git a/go/cmd/ftw/ev_observation.go b/go/cmd/ftw/ev_observation.go index 30080ca1c..949687145 100644 --- a/go/cmd/ftw/ev_observation.go +++ b/go/cmd/ftw/ev_observation.go @@ -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) diff --git a/go/cmd/ftw/ev_observation_test.go b/go/cmd/ftw/ev_observation_test.go index 383a17000..c80f4528e 100644 --- a/go/cmd/ftw/ev_observation_test.go +++ b/go/cmd/ftw/ev_observation_test.go @@ -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) + } + } +} diff --git a/go/cmd/ftw/ev_ocpp_reconnect_test.go b/go/cmd/ftw/ev_ocpp_reconnect_test.go index b9e6e0c42..07f6a3368 100644 --- a/go/cmd/ftw/ev_ocpp_reconnect_test.go +++ b/go/cmd/ftw/ev_ocpp_reconnect_test.go @@ -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}) @@ -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) @@ -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 { diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 5613b6603..8a9c5d544 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -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) { @@ -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 } diff --git a/go/internal/api/api_clamped_no_draw_test.go b/go/internal/api/api_clamped_no_draw_test.go new file mode 100644 index 000000000..017e0b649 --- /dev/null +++ b/go/internal/api/api_clamped_no_draw_test.go @@ -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) + } + }) + } +} diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 73826f215..f1547bd54 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -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 @@ -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 diff --git a/go/internal/api/api_ev_ramping_no_draw_test.go b/go/internal/api/api_ev_ramping_no_draw_test.go new file mode 100644 index 000000000..76c427a83 --- /dev/null +++ b/go/internal/api/api_ev_ramping_no_draw_test.go @@ -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) + } + }) +} diff --git a/go/internal/api/api_loadpoint_vehicle.go b/go/internal/api/api_loadpoint_vehicle.go index 6659c186c..a2aff8c1c 100644 --- a/go/internal/api/api_loadpoint_vehicle.go +++ b/go/internal/api/api_loadpoint_vehicle.go @@ -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 diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index a1082b6a2..4afafc619 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -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) { diff --git a/go/internal/drivers/registry_control_authority_test.go b/go/internal/drivers/registry_control_authority_test.go new file mode 100644 index 000000000..b812d58b4 --- /dev/null +++ b/go/internal/drivers/registry_control_authority_test.go @@ -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") + } + } +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index f5ac6472f..472cb253a 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -41,7 +41,7 @@ type Controller struct { // registry owns cross-command serialization and lifecycle cancellation. commandTimeout time.Duration wallboxCycleSeq atomic.Uint64 - resumeOffers map[string]resumeOffer // owned by the dispatch tick + resumeOffers map[string]*resumeOffer // owned by the dispatch tick energySamples map[string]*meteredEnergy // fuseEVMax is this tick's EV share of the site fuse. nil means no cap. @@ -120,9 +120,10 @@ type Controller struct { // vehicleStatus is the bound vehicle driver and its charging state. // nil disables auto-wake. - vehicleStatus func(loadpointID string) (driver, chargingState string, ok bool) - vehicleRefreshTarget func(loadpointID string) (string, error) - vehicleChargeState func(loadpointID string) (VehicleChargeState, bool) + vehicleStatus func(loadpointID string) (driver, chargingState string, ok bool) + vehicleRefreshTarget func(loadpointID string) (string, error) + vehicleChargeState func(loadpointID string) (VehicleChargeState, bool) + vehicleControlAllowed func(driver string) bool // peakRemainingSurplusW is the best PV-minus-load surplus left today. // Below the 3Φ minimum, surplus_only locks 1Φ for the day. nil keeps 3Φ. @@ -560,6 +561,18 @@ func (c *Controller) SetVehicleStatus(f func(loadpointID string) (driver, chargi c.vehicleStatus = f } +// SetVehicleControlAllowed checks the loaded driver's write authority. A +// telemetry-only car can inform the plan without receiving wake commands. +func (c *Controller) SetVehicleControlAllowed(f func(string) bool) { + if c != nil { + c.vehicleControlAllowed = f + } +} + +func (c *Controller) canControlVehicle(driver string) bool { + return c.vehicleControlAllowed == nil || c.vehicleControlAllowed(driver) +} + // SetVehicleRefreshTarget selects an authorized telemetry recipient from // configuration. It must not require a fresh SoC or permit charging. func (c *Controller) SetVehicleRefreshTarget(f func(string) (string, error)) { @@ -801,7 +814,7 @@ func (c *Controller) RefreshVehicle(ctx context.Context, lpID string) error { var ( ErrForceStartNotReady = errors.New("loadpoint controller not ready (missing vehicleStatus/send wiring)") ErrForceStartLoadpointGone = errors.New("loadpoint not found") - ErrForceStartNoVehicleBound = errors.New("no vehicle driver bound to loadpoint") + ErrForceStartNoVehicleBound = errors.New("no vehicle driver with charge control bound to loadpoint") ) // ForceStartVehicle sends charge_start now, past the auto-wake backoff. @@ -817,7 +830,7 @@ func (c *Controller) ForceStartVehicle(ctx context.Context, lpID string) (string } } driver, _, ok := c.vehicleStatus(lpID) - if !ok || driver == "" { + if !ok || driver == "" || !c.canControlVehicle(driver) { return "", ErrForceStartNoVehicleBound } now := time.Now() @@ -865,7 +878,7 @@ func (c *Controller) wakeVehicleAuto(ctx context.Context, lpID string, reason st return } driver, _, ok := c.vehicleStatus(lpID) - if !ok || driver == "" { + if !ok || driver == "" || !c.canControlVehicle(driver) { return } now := time.Now() @@ -1288,7 +1301,7 @@ func (c *Controller) standDownBeforeStorage(ctx context.Context, now time.Time, if err := c.sendDispatchWithDeadline(ctx, cfg.DriverName, []byte(`{"action":"ev_set_current","power_w":0}`)); err != nil { slog.Warn("loadpoint safety standdown", "lp", cfg.ID, "driver", cfg.DriverName, "err", err) } else { - c.resumeAfterZeroOffer(ctx, cfg, sample, 0, now) + c.recoverOfferedCharge(ctx, cfg, sample, 0, now) } } @@ -1580,11 +1593,6 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if c.send == nil { return } - // Resume only a zero offer from this transport and plug session. A failed - // optional resume keeps its retry state even after recording positive W. - if haveOffer && offerW > 0 { - c.resumeAfterZeroOffer(ctx, lpCfg, sample, offerW, now) - } // The one command whose outcome decides whether core can actuate this // charger. A charger that answers every poll and refuses this holds // whatever current it last accepted, and the plan goes on counting the @@ -1597,15 +1605,13 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if sendErr != nil { slog.Warn("loadpoint dispatch", "lp", lpCfg.ID, "driver", lpCfg.DriverName, "err", sendErr) - } - if sendErr != nil { // Never start wake work from a command that did not complete. In // particular, a timeout may still be unwinding inside the registry; // its per-driver owner restores default before another command runs. return } if haveOffer && offerW <= 0 { - c.resumeAfterZeroOffer(ctx, lpCfg, sample, 0, now) + c.recoverOfferedCharge(ctx, lpCfg, sample, 0, now) } if !c.driverCanDispatch(lpCfg.DriverName) { // The outcome callback can close Core's health gate synchronously. @@ -1613,19 +1619,41 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s // wake side effects of a dispatch that Core has just rejected. return } + // The current write can block while Stop or new charger data arrives. + // Reuse the same session check for recovery and vehicle wake work. + latest, checkedAt, live := c.currentChargeSample(now, lpCfg, sample) + if !live { + if offerW > 0 { + delete(c.resumeOffers, lpCfg.ID) + } + return + } + sample, now = latest, checkedAt + // Establish the safe current before reopening a paused session. The + // charger may still retain a higher limit from before our standdown. + if haveOffer && offerW > 0 { + c.recoverOfferedCharge(ctx, lpCfg, sample, offerW, now) + } // Wake on Stopped or Disconnected, including a surplus pause at 0 W. // Otherwise the car never draws, so surplus never appears. The // bat-SoC unlock must not poke a sleeping car, so this is the // configured SurplusOnly flag, not the armed tick. - c.maybeWakeVehicle(ctx, now, lpCfg, lpCfg.SurplusOnly, sample.RequestActive, selfWithheld, cmd) + c.maybeWakeVehicle(ctx, now, lpCfg, lpCfg.SurplusOnly, sample, selfWithheld, cmd) } -func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg Config, surplusOn, chargerRequesting, selfPaused bool, cmd map[string]any) { +func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg Config, surplusOn bool, sample EVSample, selfPaused bool, cmd map[string]any) { lpID := lpCfg.ID if c == nil || c.send == nil { return } + var live bool + sample, now, live = c.currentChargeSample(now, lpCfg, sample) + // Vehicle APIs can lag the charger by minutes. Do not interrupt measured + // delivery, or turn missing charger power into evidence of a sleeping car. + if !live || !chargerIdleForWake(sample, now) { + return + } pw, _ := cmd["power_w"].(float64) wantWake := pw > 0 if surplusOn { @@ -1648,13 +1676,16 @@ func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg // pause, cycle the contactor so the vehicle renegotiates — there's // no vehicle driver for charge_start to land on, so the wallbox // cycle is the only lever. Throttled to once per cooldown. - if c.shouldKickWallboxForResume(now, lpID, surplusOn, chargerRequesting, selfPaused, pw) { + if c.shouldKickWallboxForResume(now, lpID, surplusOn, sample.RequestActive, selfPaused, pw) { slog.Info("loadpoint wallbox-cycle: charger not requesting while surplus offered — renegotiating", "lp", lpID, "driver", lpCfg.DriverName) c.cycleWallbox(lpID, lpCfg.DriverName) } return } + if !c.canControlVehicle(driver) { + return + } // "Complete" is intentionally NOT in the wake set: the car says // charging is done because it reached its OWN charge_limit. // Trying to wake it would mean fighting the user's in-app @@ -1715,6 +1746,10 @@ func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg if err := c.sendVehicle(ctx, driver, payload); err != nil { slog.Warn("loadpoint auto-wake failed", "lp", lpID, "vehicle_driver", driver, "err", err) + } else { + // Let an accepted start take effect. Cycling now would immediately + // pause the charger we just asked the car to use. + return } // Wallbox session-cycle: when Tesla is in a "Stopped" state that @@ -1727,6 +1762,10 @@ func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg // driver implementing the standard ev_pause / ev_resume actions // gets this for free. // + latest, checkedAt, live := c.currentChargeSample(now, lpCfg, sample) + if !live || !chargerIdleForWake(latest, checkedAt) { + return + } c.cycleWallbox(lpID, lpCfg.DriverName) } diff --git a/go/internal/loadpoint/controller_positive_recovery_test.go b/go/internal/loadpoint/controller_positive_recovery_test.go new file mode 100644 index 000000000..e8c144920 --- /dev/null +++ b/go/internal/loadpoint/controller_positive_recovery_test.go @@ -0,0 +1,148 @@ +package loadpoint + +import ( + "context" + "encoding/json" + "errors" + "math" + "testing" + "time" +) + +func TestPositiveOfferRecoveryWithoutPriorZero(t *testing.T) { + for _, boundary := range []string{"none", "poll jitter", "charging", "cached zero", "declined", "unknown identity", "unknown power", "safety", "manual stop", "session changed", "current refused", "unplug after retry", "unknown after retry", "declined after retry", "safety after retry"} { + t.Run(boundary, func(t *testing.T) { + now := time.Now() + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 10350} + dir := &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{cfg.ID: 10350}} + sender := &fakeSender{} + sample := EVSample{Connected: true, RequestActive: true, DeviceID: "charger", SessionID: "plug-1", ConnectionGeneration: 1} + samples := map[string]EVSample{cfg.DriverName: sample} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + if boundary == "current refused" { + c.send = func(ctx context.Context, driver string, payload []byte) error { + if string(payload) == `{"action":"ev_resume"}` { + return sender.Send(ctx, driver, payload) + } + return errors.New("current refused") + } + } + for seconds := 0; seconds <= 210; seconds += 5 { + at := now.Add(time.Duration(seconds) * time.Second) + sample.PowerAt = at + switch boundary { + case "poll jitter": + sample.PowerAt = now.Add(time.Duration(math.Floor(float64(seconds)/10.1) * 10.1 * float64(time.Second))) + case "charging": + sample.PowerW = 4712 + case "cached zero": + sample.PowerAt = now + case "declined": + sample.RequestActive = false + case "unknown identity": + sample.SessionID = "" + case "unknown power": + sample.PowerUnavailable = true + case "session changed": + if seconds == 100 { + sample.SessionID = "plug-2" + sample.ConnectionGeneration++ + } + case "manual stop": + if seconds == 100 { + c.SetManualHold(cfg.ID, ManualHold{PowerW: 0, StartedAt: at, Persistent: true}) + } + case "unplug after retry": + if seconds >= 125 { + sample.Connected = false + } + case "unknown after retry": + if seconds >= 125 { + sample.ConnectionUnknown = true + } + case "declined after retry": + if seconds >= 125 { + sample.RequestActive = false + } + } + samples[cfg.DriverName] = sample + c.TickWithDispatch(context.Background(), at, boundary != "safety" && !(boundary == "safety after retry" && seconds >= 125)) + } + want := 0 + if boundary == "none" || boundary == "poll jitter" { + want = 3 + } + if boundary == "unplug after retry" || boundary == "unknown after retry" || boundary == "declined after retry" || boundary == "safety after retry" { + want = 1 + } + if got := countAction(sender.calls, "ev_resume"); got != want { + t.Fatalf("%s: got %d resumes, want %d", boundary, got, want) + } + if countAction(sender.calls, "ev_pause") != 0 { + t.Fatal("recovery must not pause the charger") + } + }) + } +} + +func TestRecoveryRechecksStopAndSessionAfterCurrentWrite(t *testing.T) { + for _, boundary := range []string{"stop", "unplug", "session", "unknown", "started drawing", "stop during resume", "drawing during resume"} { + t.Run(boundary, func(t *testing.T) { + now := time.Now() + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 10350} + dir := &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{cfg.ID: 0}} + sample := EVSample{Connected: true, RequestActive: true, DeviceID: "charger", SessionID: "one", ConnectionGeneration: 1} + samples := map[string]EVSample{cfg.DriverName: sample} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.Tick(context.Background(), now) + c.SetVehicleStatus(func(string) (string, string, bool) { return "tesla", "Stopped", true }) + dir.LoadpointEnergyWh[cfg.ID] = 10350 + c.send = func(ctx context.Context, driver string, payload []byte) error { + var command struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &command); err != nil { + return err + } + if command.Action == "ev_resume" { + switch boundary { + case "stop during resume": + c.SetManualHold(cfg.ID, ManualHold{PowerW: 0, Persistent: true}) + case "drawing during resume": + sample.PowerW = 4712 + } + samples[cfg.DriverName] = sample + } + if command.Action == "ev_set_current" && command.PowerW > 0 { + switch boundary { + case "stop": + c.SetManualHold(cfg.ID, ManualHold{PowerW: 0, Persistent: true}) + case "unplug": + sample.Connected = false + case "session": + sample.SessionID = "two" + case "unknown": + sample.ConnectionUnknown = true + case "started drawing": + sample.PowerW = 4712 + } + samples[cfg.DriverName] = sample + } + return sender.Send(ctx, driver, payload) + } + c.Tick(context.Background(), now.Add(5*time.Second)) + wantResumes := 0 + if boundary == "stop during resume" || boundary == "drawing during resume" { + wantResumes = 1 + } + if countAction(sender.calls, "ev_resume") != wantResumes { + t.Fatalf("resume crossed %s during current write", boundary) + } + if countAction(sender.calls, "charge_start") != 0 || countAction(sender.calls, "ev_pause") != 0 { + t.Fatalf("vehicle wake crossed %s during current write", boundary) + } + }) + } +} diff --git a/go/internal/loadpoint/controller_resume.go b/go/internal/loadpoint/controller_resume.go index 49017b2eb..fe8577737 100644 --- a/go/internal/loadpoint/controller_resume.go +++ b/go/internal/loadpoint/controller_resume.go @@ -3,47 +3,142 @@ package loadpoint import ( "context" "log/slog" + "math" "time" ) type resumeOffer struct { - driver, device, session string - generation uint64 - nextAttempt time.Time - retryDelay time.Duration + driver, device, session string + generation uint64 + nextAttempt time.Time + retryDelay time.Duration + acknowledgedAttempts int + deliverySince, deliveryAt time.Time + zeroOffer bool + noDrawSince, noDrawAt time.Time } -// resumeAfterZeroOffer remembers our standdown independently of commanded W. -// Resume is optional: ev_set_current still runs and alone decides driver health. -// Retries use the next normal tick, the current safety gates and a bounded rate. -func (c *Controller) resumeAfterZeroOffer(ctx context.Context, cfg Config, sample EVSample, offerW float64, now time.Time) { +// Allow a missed five-second tick and ordinary cloud poll jitter. Repeating +// one timestamp still cannot advance either delivery or no-draw proof. +const resumeObservationGap = 15 * time.Second + +// Commands may block while Stop or a new plug session arrives. Use this +// check after either charger dispatch or a vehicle start attempt. +func (c *Controller) currentChargeSample(now time.Time, cfg Config, previous EVSample) (EVSample, time.Time, bool) { + if hold, held := c.GetManualHold(cfg.ID, now); held && hold.PowerW == 0 { + return EVSample{}, now, false + } + if c.tel == nil { + return previous, now, true + } + sample, live := c.tel(cfg.DriverName) + if !live || !sample.Connected || sample.ConnectionUnknown || sample.DeviceID != previous.DeviceID || + sample.SessionID != previous.SessionID || sample.ConnectionGeneration != previous.ConnectionGeneration { + return sample, now, false + } + if checkedAt := time.Now(); sample.PowerAt.After(now) && !sample.PowerAt.After(checkedAt) { + now = checkedAt + } + return sample, now, true +} + +func chargerIdleForWake(sample EVSample, now time.Time) bool { + return !sample.PowerUnavailable && finite(sample.PowerW) && sample.PowerW < DeliveringW && + !sample.PowerAt.After(now) && (sample.PowerAt.IsZero() || now.Sub(sample.PowerAt) <= sample.PowerWindow()) +} + +// recoverOfferedCharge resumes our zero offer, or a known plug session that +// still asks for charge after two minutes of accepted positive offers without +// delivery. The current limit must succeed before this optional resume. Only +// measured delivery ends recovery; an acknowledgement is not a charge. +func (c *Controller) recoverOfferedCharge(ctx context.Context, cfg Config, sample EVSample, offerW float64, now time.Time) { if c.resumeOffers == nil { - c.resumeOffers = make(map[string]resumeOffer) + c.resumeOffers = make(map[string]*resumeOffer) } proof := resumeOffer{driver: cfg.DriverName, device: sample.DeviceID, session: sample.SessionID, generation: sample.ConnectionGeneration} - previous, exists := c.resumeOffers[cfg.ID] - if previous.driver != proof.driver || previous.device != proof.device || previous.session != proof.session || previous.generation != proof.generation { + previous := c.resumeOffers[cfg.ID] + if previous != nil && (previous.driver != proof.driver || previous.device != proof.device || previous.session != proof.session || previous.generation != proof.generation) { delete(c.resumeOffers, cfg.ID) - exists = false + previous = nil } if offerW <= 0 { - c.resumeOffers[cfg.ID] = proof + proof.zeroOffer = true + c.resumeOffers[cfg.ID] = &proof return } - if sample.PowerW >= DeliveringW { + // Ten seconds near the supported minimum prove recovery. A normal + // charge with no recovery in progress needs no retry record at all. + minimum := cfg.MinChargeW + if minimum <= 0 { + minimum = offerW + } + floor := math.Max(DeliveringW, 0.9*math.Min(minimum, offerW)) + if previous == nil { + if proof.device == "" || proof.session == "" || !sample.RequestActive || sample.PowerW >= floor { + return + } + previous = &proof + previous.noDrawSince = now + c.resumeOffers[cfg.ID] = previous + } + if !previous.zeroOffer && !sample.RequestActive { delete(c.resumeOffers, cfg.ID) return } - if !exists || now.Before(previous.nextAttempt) { + observedAt := sample.PowerAt + if observedAt.IsZero() { + observedAt = now // Legacy telemetry uses the fresh-read contract. + } + fresh := !sample.PowerUnavailable && !observedAt.After(now) && now.Sub(observedAt) <= min(sample.PowerWindow(), resumeObservationGap) + if !fresh || !finite(sample.PowerW) { + previous.deliverySince, previous.deliveryAt = time.Time{}, time.Time{} + previous.noDrawSince, previous.noDrawAt = time.Time{}, time.Time{} + return + } + if sample.PowerW >= floor { + if previous.deliverySince.IsZero() || observedAt.Before(previous.deliveryAt) || observedAt.Sub(previous.deliveryAt) > resumeObservationGap { + previous.deliverySince = observedAt + } + previous.deliveryAt = observedAt + if observedAt.Sub(previous.deliverySince) >= 10*time.Second { + delete(c.resumeOffers, cfg.ID) + } + return // Already drawing: observe it rather than sending another resume. + } + previous.deliverySince, previous.deliveryAt = time.Time{}, time.Time{} + if !previous.zeroOffer { + if previous.noDrawSince.IsZero() || observedAt.Before(previous.noDrawAt) || + (!previous.noDrawAt.IsZero() && observedAt.Sub(previous.noDrawAt) > resumeObservationGap) { + previous.noDrawSince = now + } + previous.noDrawAt = observedAt + if previous.noDrawAt.Sub(previous.noDrawSince) < 2*time.Minute { + return + } + } + if now.Before(previous.nextAttempt) { + return + } + if previous.acknowledgedAttempts >= 3 { + if previous.acknowledgedAttempts == 3 { + slog.Warn("loadpoint resume acknowledged but no charge observed", "lp", cfg.ID, "driver", cfg.DriverName) + previous.acknowledgedAttempts++ + } return } if err := c.sendDispatchWithDeadline(ctx, cfg.DriverName, []byte(`{"action":"ev_resume"}`)); err != nil { previous.retryDelay = min(time.Minute, max(5*time.Second, 2*previous.retryDelay)) previous.nextAttempt = now.Add(previous.retryDelay) - c.resumeOffers[cfg.ID] = previous - slog.Warn("loadpoint resume after zero offer failed", "lp", cfg.ID, "driver", cfg.DriverName, + slog.Warn("loadpoint resume failed", "lp", cfg.ID, "driver", cfg.DriverName, "retry_at", previous.nextAttempt, "err", err) return } - delete(c.resumeOffers, cfg.ID) + // An HTTP acknowledgement does not establish that the contactor closed. + // Retry at a bounded rate, under the current dispatch gates, and stop after + // three acknowledged attempts. Only observed delivery clears the proof. + previous.acknowledgedAttempts++ + slog.Info("loadpoint resume acknowledged; waiting for measured charge", "lp", cfg.ID, "driver", cfg.DriverName, + "attempt", previous.acknowledgedAttempts, "offer_w", offerW, "observed_w", sample.PowerW, "power_at", sample.PowerAt) + previous.nextAttempt = now.Add(30 * time.Second) + previous.retryDelay = 0 } diff --git a/go/internal/loadpoint/controller_resume_after_zero_test.go b/go/internal/loadpoint/controller_resume_after_zero_test.go index 782bde8bc..1bfd9644b 100644 --- a/go/internal/loadpoint/controller_resume_after_zero_test.go +++ b/go/internal/loadpoint/controller_resume_after_zero_test.go @@ -9,8 +9,8 @@ import ( ) // A 0 W offer (no plan, surplus pause, safety standdown) maps on Easee to a -// sticky user-pause at 0 A. The next non-zero offer must send ev_resume -// before ev_set_current, otherwise the contactor stays open until unplug. +// sticky user-pause at 0 A. The next non-zero offer must set the safe positive current +// before ev_resume, otherwise the contactor stays open until unplug. func TestTickResumesChargerWhenOfferReturns(t *testing.T) { sender := &fakeSender{} cfgs := []Config{{ @@ -54,13 +54,13 @@ func TestTickResumesChargerWhenOfferReturns(t *testing.T) { } c.Tick(context.Background(), now.Add(5*time.Second)) if len(sender.calls) != 3 { - t.Fatalf("reoffer command count = %d, want 3 (0 W, resume, set); got %+v", len(sender.calls), sender.calls) + t.Fatalf("reoffer command count = %d, want 3 (0 W, set, resume); got %+v", len(sender.calls), sender.calls) } - if sender.calls[1].action != "ev_resume" { - t.Fatalf("expected ev_resume after 0 W standdown, got %+v", sender.calls[1]) + if sender.calls[2].action != "ev_resume" { + t.Fatalf("expected ev_resume after the safe positive current, got %+v", sender.calls[2]) } - if sender.calls[2].action != "ev_set_current" || sender.calls[2].power != 11000 { - t.Fatalf("expected 11000 W ev_set_current after resume, got %+v", sender.calls[2]) + if sender.calls[1].action != "ev_set_current" || sender.calls[1].power != 11000 { + t.Fatalf("expected 11000 W ev_set_current before resume, got %+v", sender.calls[1]) } sender.calls = nil @@ -194,9 +194,16 @@ func TestTickCancelsResumeRetryWhenSessionProofChanges(t *testing.T) { } samples["easee"] = sample c.Tick(context.Background(), now.Add(10*time.Second)) + zeroAt := now.Add(15 * time.Second) + if change == "delivering" { + // Recovery needs sustained delivery, not one startup pulse. + c.Tick(context.Background(), now.Add(15*time.Second)) + c.Tick(context.Background(), now.Add(20*time.Second)) + zeroAt = now.Add(25 * time.Second) + } sample.Connected, sample.ConnectionUnknown, sample.PowerW = true, false, 0 samples["easee"] = sample - c.Tick(context.Background(), now.Add(15*time.Second)) + c.Tick(context.Background(), zeroAt) if attempts != 1 { t.Fatalf("stale retry crossed %s boundary: %d attempts", change, attempts) } diff --git a/go/internal/loadpoint/controller_resume_confirm_test.go b/go/internal/loadpoint/controller_resume_confirm_test.go new file mode 100644 index 000000000..77bde4902 --- /dev/null +++ b/go/internal/loadpoint/controller_resume_confirm_test.go @@ -0,0 +1,73 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestResumeAcknowledgementWaitsForMeasuredCharge(t *testing.T) { + now := time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) + sender := &fakeSender{} + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11000, AllowedStepsW: []float64{0, 4140, 6900, 11000}} + samples := map[string]EVSample{"easee": {Connected: true, RequestActive: true, DeviceID: "box", SessionID: "plug-1", ConnectionGeneration: 1}} + dir := &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{"garage": 0}} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.Tick(context.Background(), now) + dir.LoadpointEnergyWh["garage"] = 11000 + for _, seconds := range []int{5, 10, 35, 65, 95, 125} { + c.Tick(context.Background(), now.Add(time.Duration(seconds)*time.Second)) + } + if n := countAction(sender.calls, "ev_resume"); n != 3 { + t.Fatalf("need bounded retries until delivery, got %d resumes: %+v", n, sender.calls) + } + sample := samples["easee"] + sample.PowerW = 6900 + samples["easee"] = sample + for _, seconds := range []int{130, 135, 140} { + sample.PowerAt = now.Add(time.Duration(seconds) * time.Second) + samples["easee"] = sample + c.Tick(context.Background(), sample.PowerAt) + } + if _, exists := c.resumeOffers[cfg.ID]; exists { + t.Fatal("sustained measured charging did not clear recovery proof") + } + sample.PowerW = 0 + samples["easee"] = sample + c.Tick(context.Background(), now.Add(160*time.Second)) + if n := countAction(sender.calls, "ev_resume"); n != 3 { + t.Fatalf("measured delivery must clear resume proof, got %d", n) + } +} + +func TestAcknowledgedResumeRetryRespectsSessionAndStops(t *testing.T) { + for _, stop := range []string{"session", "safety", "plan_pause"} { + t.Run(stop, func(t *testing.T) { + now := time.Date(2026, 10, 17, 0, 0, 0, 0, time.UTC) + sender := &fakeSender{} + cfg := Config{ID: "garage", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11000} + samples := map[string]EVSample{"easee": {Connected: true, RequestActive: true, DeviceID: "box", SessionID: "one", ConnectionGeneration: 1}} + dir := &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{"garage": 0}} + c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.Tick(context.Background(), now) + dir.LoadpointEnergyWh["garage"] = 11000 + c.Tick(context.Background(), now.Add(5*time.Second)) + if countAction(sender.calls, "ev_resume") != 1 { + t.Fatal(sender.calls) + } + switch stop { + case "session": + sample := samples["easee"] + sample.SessionID = "two" + sample.ConnectionGeneration = 2 + samples["easee"] = sample + case "plan_pause": + dir.LoadpointEnergyWh["garage"] = 0 + } + c.TickWithDispatch(context.Background(), now.Add(35*time.Second), stop != "safety") + if countAction(sender.calls, "ev_resume") != 1 { + t.Fatalf("retry crossed %s: %+v", stop, sender.calls) + } + }) + } +} diff --git a/go/internal/loadpoint/controller_resume_delivery_test.go b/go/internal/loadpoint/controller_resume_delivery_test.go new file mode 100644 index 000000000..966f57075 --- /dev/null +++ b/go/internal/loadpoint/controller_resume_delivery_test.go @@ -0,0 +1,62 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestResumeRequiresSustainedMeaningfulDelivery(t *testing.T) { + for _, tc := range []struct { + name string + power, offer float64 + gap, stale, unavailable bool + clear bool + }{ + {name: "130 W pulse", power: 130, offer: 4830}, + {name: "800 W pulse", power: 800, offer: 4830}, + {name: "full power briefly", power: 4757, offer: 4830, gap: true}, + {name: "cached high sample", power: 4757, offer: 4830, stale: true}, + {name: "unknown high power", power: 4757, offer: 4830, unavailable: true}, + {name: "sustained charging", power: 4757, offer: 4830, clear: true}, + {name: "voltage tolerance at minimum", power: 3800, offer: 4140, clear: true}, + {name: "one phase offer", power: 1350, offer: 1380, clear: true}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Now() + cfg := Config{ID: "car", DriverName: "easee", MinChargeW: 4140} + resumes := 0 + c := &Controller{send: func(context.Context, string, []byte) error { resumes++; return nil }} + sample := EVSample{DeviceID: "box", SessionID: "one", ConnectionGeneration: 1} + c.recoverOfferedCharge(context.Background(), cfg, sample, 0, now) + c.recoverOfferedCharge(context.Background(), cfg, sample, tc.offer, now.Add(time.Second)) + for _, seconds := range []int{5, 10, 15} { + sample.PowerW = tc.power + sample.PowerUnavailable = tc.unavailable + sample.PowerAt = now.Add(time.Duration(seconds) * time.Second) + if tc.stale { + sample.PowerAt = now.Add(5 * time.Second) + } + if tc.gap && seconds == 10 { + sample.PowerW = 0 + } + c.recoverOfferedCharge(context.Background(), cfg, sample, tc.offer, now.Add(time.Duration(seconds)*time.Second)) + } + _, exists := c.resumeOffers[cfg.ID] + if exists == tc.clear { + t.Fatalf("resume proof exists=%v want clear=%v", exists, tc.clear) + } + sample.PowerW = 0 + sample.PowerUnavailable = false + sample.PowerAt = now.Add(35 * time.Second) + c.recoverOfferedCharge(context.Background(), cfg, sample, tc.offer, now.Add(35*time.Second)) + want := 2 + if tc.clear { + want = 1 + } + if resumes != want { + t.Fatalf("resumes=%v want=%v", resumes, want) + } + }) + } +} diff --git a/go/internal/loadpoint/controller_wake_delivery_test.go b/go/internal/loadpoint/controller_wake_delivery_test.go new file mode 100644 index 000000000..7feb67d48 --- /dev/null +++ b/go/internal/loadpoint/controller_wake_delivery_test.go @@ -0,0 +1,135 @@ +package loadpoint + +import ( + "context" + "errors" + "math" + "testing" + "testing/synctest" + "time" +) + +// A vehicle API can still say Stopped while its charger measures a charge. +// A wake must never turn that delayed vehicle report into a charger pause. +func TestAutomaticWakeRequiresFreshZeroChargerPower(t *testing.T) { + for _, tc := range []struct { + name string + power float64 + unavailable bool + age time.Duration + }{ + {"charging", 4712, false, 0}, + {"startup pulse", 299, false, 0}, + {"missing power", 0, true, 0}, + {"old zero", 0, false, 4 * time.Minute}, + {"future zero", 0, false, -time.Minute}, + {"invalid power", math.NaN(), false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + now := time.Now() + sender := &outcomeSender{} + c, _, _ := outcomeFixture(t, now, sender) + c.Tick(context.Background(), now) + c.tel = func(string) (EVSample, bool) { + return EVSample{Connected: true, RequestActive: true, PowerW: tc.power, PowerUnavailable: tc.unavailable, PowerAt: now.Add(time.Second).Add(-tc.age)}, true + } + c.SetVehicleStatus(func(string) (string, string, bool) { return "vehicle", "Stopped", true }) + c.Tick(context.Background(), now.Add(time.Second)) + time.Sleep(wallboxCycleGap) + synctest.Wait() + for _, call := range sender.sent() { + if call.action == "charge_start" || call.action == "ev_pause" || call.action == "ev_resume" { + t.Fatalf("automatic restart from %s: %+v", tc.name, call) + } + } + }) + }) + } +} + +func TestReadOnlyVehicleDoesNotArmWakeOrCycleCharger(t *testing.T) { + now := time.Now() + sender := &outcomeSender{} + c, cfg, _ := outcomeFixture(t, now, sender) + c.SetVehicleStatus(func(string) (string, string, bool) { return "vag_vehicle", "Stopped", true }) + c.SetVehicleControlAllowed(func(string) bool { return false }) + c.Tick(context.Background(), now) + c.wakeVehicleAuto(context.Background(), cfg.ID, "delivering_edge") + if _, err := c.ForceStartVehicle(context.Background(), cfg.ID); err != ErrForceStartNoVehicleBound { + t.Fatalf("read-only force start: %v", err) + } + for _, call := range sender.sent() { + if call.action != "ev_set_current" || call.driver != cfg.DriverName { + t.Fatalf("read-only vehicle caused a wake or contactor cycle: %+v", call) + } + } + if len(c.wakeLast) != 0 || len(c.wakeKickUntil) != 0 || len(c.wakeAttempts) != 0 { + t.Fatal("read-only vehicle armed a wake kick or backoff") + } +} + +func TestAcceptedVehicleStartDoesNotPauseCharger(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + sender := &outcomeSender{} + c, _, _ := outcomeFixture(t, time.Now(), sender) + c.SetVehicleStatus(func(string) (string, string, bool) { return "tesla", "Stopped", true }) + c.Tick(context.Background(), time.Now()) + time.Sleep(wallboxCycleGap) + synctest.Wait() + starts := 0 + for _, call := range sender.sent() { + if call.action == "charge_start" { + starts++ + } + if call.action == "ev_pause" { + t.Fatal("accepted vehicle start was followed by a charger pause") + } + } + if starts != 1 { + t.Fatalf("got %d vehicle starts", starts) + } + }) +} + +func TestWakeRechecksAfterVehicleStartRefusal(t *testing.T) { + for _, boundary := range []string{"stop", "unplug", "session", "unknown", "charging", "missing power"} { + t.Run(boundary, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + now := time.Now() + sender := &outcomeSender{errFor: map[string]error{"tesla": errors.New("vehicle refused start")}} + c, cfg, _ := outcomeFixture(t, now, sender) + sample := EVSample{Connected: true, RequestActive: true, DeviceID: "charger", SessionID: "one", PowerAt: now} + c.tel = func(string) (EVSample, bool) { return sample, true } + c.SetVehicleStatus(func(string) (string, string, bool) { return "tesla", "Stopped", true }) + c.send = func(ctx context.Context, driver string, payload []byte) error { + if driver == "tesla" { + switch boundary { + case "stop": + c.SetManualHold(cfg.ID, ManualHold{PowerW: 0, Persistent: true}) + case "unplug": + sample.Connected = false + case "session": + sample.SessionID = "two" + case "unknown": + sample.ConnectionUnknown = true + case "charging": + sample.PowerW = 4712 + case "missing power": + sample.PowerUnavailable = true + } + } + return sender.Send(ctx, driver, payload) + } + c.Tick(context.Background(), now) + time.Sleep(wallboxCycleGap) + synctest.Wait() + for _, call := range sender.sent() { + if call.action == "ev_pause" || call.action == "ev_resume" { + t.Fatalf("charger cycle crossed %s during vehicle start: %+v", boundary, call) + } + } + }) + }) + } +} diff --git a/go/internal/loadpoint/manual_clamped_no_draw_test.go b/go/internal/loadpoint/manual_clamped_no_draw_test.go new file mode 100644 index 000000000..6fb6af3d2 --- /dev/null +++ b/go/internal/loadpoint/manual_clamped_no_draw_test.go @@ -0,0 +1,22 @@ +package loadpoint + +import ( + "testing" + "time" +) + +func TestPositiveClampDoesNotHideAcceptedZeroDraw(t *testing.T) { + at := time.Date(2026, 10, 17, 0, 12, 0, 0, time.UTC) + h := ManualHold{PowerW: 10350, StartedAt: at} + st := State{Phases: 3, VoltageV: 230, CommandedKnown: true, CommandedW: 6900, CommandedReason: "fuse_limit", CommandedSinceMs: at.UnixMilli()} + ch := ChargerReading{Known: true, UpdatedAt: at.Add(8 * time.Minute), LimitKnown: true, LimitA: 10, Reason: "limited by dynamic charger current"} + m := ManualStatusFrom(h, true, st, ch, at.Add(8*time.Minute)) + if m.State != ManualNotDrawing || m.CommandedA != 10 || m.RequestedA != 15 || m.LimitReason != "fuse_limit" { + t.Fatalf("positive clamp masks zero delivery: %+v", m) + } + st.CommandedW = 0 + ch.LimitA = 0 + if m := ManualStatusFrom(h, true, st, ch, at.Add(8*time.Minute)); m.State != ManualLimited { + t.Fatalf("intentional zero must remain limited: %+v", m) + } +} diff --git a/go/internal/loadpoint/manual_hold_state_test.go b/go/internal/loadpoint/manual_hold_state_test.go index e6de0d332..eadeb728a 100644 --- a/go/internal/loadpoint/manual_hold_state_test.go +++ b/go/internal/loadpoint/manual_hold_state_test.go @@ -18,7 +18,7 @@ func TestLostHardwareProofPausesOldStartButAllowsANewExplicitStart(t *testing.T) t.Helper() c.Tick(context.Background(), now) now = now.Add(time.Second) - if n := len(sender.calls); n == 0 || sender.calls[n-1].power != want { + if last, ok := lastSetCurrent(sender.calls); !ok || last.power != want { t.Fatalf("commands=%+v, want latest power %v", sender.calls, want) } } @@ -362,7 +362,7 @@ func TestManualStartWithoutHardwareCannotFollowAnotherSocket(t *testing.T) { t.Helper() c.Tick(context.Background(), now) now = now.Add(time.Second) - if sender.calls[len(sender.calls)-1].power != want { + if last, ok := lastSetCurrent(sender.calls); !ok || last.power != want { t.Fatalf("commands=%+v, want %v", sender.calls, want) } } diff --git a/go/internal/loadpoint/manual_limits_test.go b/go/internal/loadpoint/manual_limits_test.go index cbe4f1ca6..3d7b5c7f2 100644 --- a/go/internal/loadpoint/manual_limits_test.go +++ b/go/internal/loadpoint/manual_limits_test.go @@ -40,8 +40,8 @@ func TestPauseHoldsThroughCarRefusalAndResumesOnlyOnRequest(t *testing.T) { } c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) c.Tick(context.Background(), now.Add(time.Hour+time.Second)) - if got := sender.calls[len(sender.calls)-1].power; got != 4140 { - t.Fatalf("explicit start failed without planner: %v", got) + if last, ok := lastSetCurrent(sender.calls); !ok || last.power != 4140 { + t.Fatalf("explicit start failed without planner: %+v", sender.calls) } } diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go index 6b47121a2..427160876 100644 --- a/go/internal/loadpoint/manual_status.go +++ b/go/internal/loadpoint/manual_status.go @@ -186,6 +186,9 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now return m } + if clamp { + m.LimitReason = st.CommandedReason + } limitMatches := m.ChargerLimitKnown && m.CommandedA >= 0 && math.Abs(ch.LimitA-m.CommandedA) < 1 switch { case ch.Unavailable || st.PowerUnavailable: @@ -202,12 +205,10 @@ func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now m.State = ManualSent case st.CurrentPowerW >= manualChargingFloorW: m.State = ManualCharging - if clamp { - m.LimitReason = st.CommandedReason - } + case limitMatches && m.CommandedW >= manualChargingFloorW && elapsed >= manualAcceptGrace: + m.State = ManualNotDrawing case clamp: m.State = ManualLimited - m.LimitReason = st.CommandedReason case limitMatches && elapsed >= manualAcceptGrace: m.State = ManualNotDrawing case limitMatches: diff --git a/go/internal/telemetry/command_ev_no_power_test.go b/go/internal/telemetry/command_ev_no_power_test.go new file mode 100644 index 000000000..d0b38309e --- /dev/null +++ b/go/internal/telemetry/command_ev_no_power_test.go @@ -0,0 +1,68 @@ +package telemetry + +import ( + "fmt" + "testing" + "time" +) + +func TestEVNoPowerEvidenceEndsAtControlAndMeasurementBoundaries(t *testing.T) { + for _, boundary := range []string{"pause", "failed", "unknown power", "drawing", "gap", "released"} { + t.Run(boundary, func(t *testing.T) { + s := NewStore() + start := time.Now() + command := func(at time.Time, power float64, result string) { + c := s.BeginCommand("easee", []byte(fmt.Sprintf(`{"action":"ev_set_current","power_w":%g}`, power)), at) + s.CompleteCommand(c, result) + } + observe := func(at time.Time, power float64, data string) { + s.mu.Lock() + defer s.mu.Unlock() + s.observeCommand("easee", DerEV, power, []byte(data), at) + } + for i := 0; i <= 25; i++ { + at := start.Add(time.Duration(i) * 5 * time.Second) + command(at, 4140+690*float64((i/5)%10), "accepted") + observe(at, 0, `{}`) + } + old, _ := s.CommandEvidence("easee", "ev") + if old.NoPowerAt.Sub(old.NoPowerSince) != 125*time.Second { + t.Fatalf("retuning lost the zero-power interval: %+v", old) + } + at := start.Add(130 * time.Second) + switch boundary { + case "pause": + command(at, 0, "accepted") + case "failed": + command(at, 10350, "failed") + case "unknown power": + observe(at, 0, `{"control_power_available":false}`) + case "drawing": + observe(at, 4712, `{}`) + case "gap": + at = at.Add(2 * time.Minute) + case "released": + s.EndCommandControl("easee", false) + } + command(at.Add(time.Second), 10350, "accepted") + observe(at.Add(time.Second), 0, `{}`) + c, _ := s.CommandEvidence("easee", "ev") + if !c.NoPowerSince.Equal(at.Add(time.Second)) { + t.Fatalf("zero proof crossed %s: %+v", boundary, c) + } + }) + } +} + +func TestResumeDoesNotReplaceAcceptedCurrentEvidence(t *testing.T) { + s := NewStore() + now := time.Now() + c := s.BeginCommand("easee", []byte(`{"action":"ev_set_current","power_w":4140}`), now) + s.CompleteCommand(c, "accepted") + resume := s.BeginCommand("easee", []byte(`{"action":"ev_resume"}`), now.Add(time.Second)) + s.CompleteCommand(resume, "accepted") + got, ok := s.CommandEvidence("easee", "ev") + if !ok || got.PowerW == nil || *got.PowerW != 4140 || !got.At.Equal(now) || got.Result != "accepted" { + t.Fatalf("optional resume replaced the current limit: %+v", got) + } +} diff --git a/go/internal/telemetry/command_evidence.go b/go/internal/telemetry/command_evidence.go index 9dc020b48..6523c2bf9 100644 --- a/go/internal/telemetry/command_evidence.go +++ b/go/internal/telemetry/command_evidence.go @@ -30,6 +30,10 @@ type CommandEvidence struct { PowerObservedSince time.Time LastObservation time.Time LastMeasuredAt time.Time + // NoPower is an observed EV stop during uninterrupted positive orders. + // Retuning the current cannot restart this clock; a pause, failed command, + // missing measurement or observed power ends it. + NoPowerSince, NoPowerAt time.Time // LastGapW is the latest fresh reading's distance from the commands in // force during the response delay. Nil until such a reading arrives. LastGapW *float64 @@ -101,6 +105,11 @@ func (s *Store) BeginCommand(driver string, payload []byte, now time.Time) Comma if json.Unmarshal(payload, &request) != nil { return CommandEvidence{} } + // Resume changes permission to draw, not the current limit. Keep the + // accepted limit and its measured response while this optional call runs. + if request.Action == "ev_resume" && request.PowerW == nil { + return CommandEvidence{} + } if request.Action == "ev_pause" { zero := 0.0 request.PowerW = &zero @@ -136,6 +145,9 @@ func (s *Store) BeginCommand(driver string, payload []byte, now time.Time) Comma // step, a failed or unknown call, or a return to device control needs new // proof against the new target. if ok && old.Action == c.Action && (old.Result == "accepted" || old.Result == "pending") { + if kind == "ev" && old.PowerW != nil && c.PowerW != nil && *old.PowerW > 100 && *c.PowerW > 100 { + c.NoPowerSince, c.NoPowerAt = old.NoPowerSince, old.NoPowerAt + } c.Recent, c.MaxAge = append([]CommandPoint(nil), old.Recent...), old.MaxAge if !materialStep(old.PowerW, c.PowerW) { c.Since, c.Baseline, c.StepAfter, c.StepAfterAt = old.Since, old.Baseline, old.StepAfter, old.StepAfterAt @@ -361,6 +373,7 @@ func (s *Store) observeCommand(driver string, kind DerType, power float64, data SetpointW *float64 `json:"setpoint_w"` } if json.Unmarshal(data, &d) != nil { + c.NoPowerSince, c.NoPowerAt = time.Time{}, time.Time{} c.ReadbackMismatchSince, c.PowerMismatchSince, c.PowerMatchSince = time.Time{}, time.Time{}, time.Time{} c.PowerObservedSince, c.LastObservation = time.Time{}, time.Time{} s.commands[k] = c @@ -395,6 +408,17 @@ func (s *Store) observeCommand(driver string, kind DerType, power float64, data // A change-only source that confirms an unchanged sample states the power // at the confirmation time; an unconfirmed repeat adds nothing new. power, observedAt := observation.PowerW, observation.Seen() + if kind == DerEV { + switch { + case !fresh || !finite(power) || *c.PowerW <= 100 || math.Abs(power) >= 100: + c.NoPowerSince, c.NoPowerAt = time.Time{}, time.Time{} + case observedAt.After(c.NoPowerAt) && (!c.NoPowerSince.IsZero() || !observedAt.Before(c.Since)): + if c.NoPowerSince.IsZero() || observedAt.Sub(c.NoPowerAt) > max(time.Minute, c.MaxAge) { + c.NoPowerSince = observedAt + } + c.NoPowerAt = observedAt + } + } // A cached sample from before the run cannot show its response. Repeated // delivery of one source sample also cannot extend a measured time window. if !fresh || !finite(power) || observedAt.Before(c.Since) { diff --git a/web/app.js b/web/app.js index 441279459..0ed9c6673 100644 --- a/web/app.js +++ b/web/app.js @@ -2862,12 +2862,12 @@ case "limited": if (m.limit_reason === "charger_limit") return "The charger limits this request to " + cmdA + " (" + reqA + " requested)."; if (m.limit_reason === "fuse_cooldown") { - return "Paused: main-fuse protection — " + reqA + " requested; charging resumes on its own." + sinceP; + return "Paused by site power limits — " + reqA + " requested; charging resumes on its own." + sinceP; } if (m.limit_reason === "site_meter_stale") { return "Paused for safety: site-meter data is stale — " + reqA + " requested; charging resumes when telemetry recovers."; } - return "Main fuse limits this charge to " + cmdA + " right now (" + reqA + " requested)." + sinceP; + return "Site power limits cap this charge at " + cmdA + " right now (" + reqA + " requested)." + sinceP; } return "FTW received " + reqA + ". Waiting for charger status." + end; } @@ -2915,7 +2915,7 @@ ? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned : "Charging."; if (lp.commanded_reason === "fuse_limit") { - text += " Rate is limited by the main fuse right now."; + text += " Rate is limited by site power limits right now."; } } else if (lp.goal_complete === true) { text = "The car confirmed this charging goal is complete."; @@ -2940,7 +2940,7 @@ // The specific pauses beat the generic branches — this is the // line that stops an operator debugging cable and charger while // the box is protecting the main fuse (#1009). - text = "Paused: main-fuse protection — the house is using the headroom; charging resumes on its own." + kwPlanned; + text = "Paused by site power limits — the house is using the headroom; charging resumes on its own." + kwPlanned; tone = "var(--text)"; } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "site_meter_stale") { text = "Paused for safety: site-meter data is stale — charging resumes when telemetry recovers."; diff --git a/web/control-feedback.js b/web/control-feedback.js index 95fda5faa..18a184e63 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -63,7 +63,7 @@ const REASON_TITLES = { no_plan_budget: 'Waiting for the plan', pv_surplus_pause: 'Waiting for spare solar', wake_kick: 'Starting the charger', vehicle_complete: 'Car is full', vehicle_limit_completion: 'Car reached its limit', vehicle_not_requesting: 'Car not taking power', battery_full: 'Battery full', battery_nearly_full: 'Battery nearly full', battery_nearly_empty: 'Battery nearly empty', - core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a fuse limit', + core_limit: 'Limited by FTW', fuse_limit: 'Limited by site power limits', fuse_cooldown: 'Waiting after a site power limit', charger_limit: 'Limited by FTW’s charger setting', device_limit: 'Charger limit', offered_current_lower: 'Charger offers less current', device_fault: 'Device fault', telemetry_stale: 'No fresh readings', readings_lost: 'Lost control', command_failed: 'Commands failing', command_unconfirmed: 'Command not confirmed', default_failed: 'Cannot hand back control', @@ -92,8 +92,8 @@ function sentence(row) { case 'battery_nearly_full': return `Taking ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery charges slower when it is nearly full.`; case 'battery_nearly_empty': return `Giving ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery limits discharge when it is nearly empty.`; case 'core_limit': return `FTW’s safety limits set the target to ${target(row)}.`; - case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to protect the main fuse. It rises when the house uses less.`; - case 'fuse_cooldown': return 'FTW waits a moment after a fuse limit before it raises the rate.'; + case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to stay within the site’s fuse and import limits.`; + case 'fuse_cooldown': return 'FTW waits a moment after a site power limit before it raises the rate.'; case 'charger_limit': return 'The charger limit in FTW’s settings caps the rate.'; case 'device_limit': return `The charger’s own limit is ${amps(row.device_limit_a)}, below the ${amps(row.requested_a)} FTW asked for.`; case 'offered_current_lower': return `The charger offers ${amps(row.offered_a)}, below the ${amps(row.requested_a)} FTW asked for.`; diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 00654f005..bdef3a5ea 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -140,3 +140,13 @@ test('the summary answers the question for the whole site', () => { assert.equal(controlSummary([row({status: 'not_controlled', reason: 'observe_only'})]).tone, 'neutral'); assert.equal(controlSummary([row()], false).tone, 'stale'); }); + + +test('limited positive EV offer does not explain prolonged zero delivery', () => { + const missing=controlStatus(row({kind:'ev',status:'not_following',reason:'no_power_response',severity:'warning',requested_w:10350,sent_w:6900,actual_w:0,requested_a:15,offered_a:10,device_reason:'limited by dynamic charger current'})); + assert.equal(missing.title,'Not following'); + assert.match(missing.text,/6.9 kW charge.*no power/); + const capped=controlStatus(row({kind:'ev',status:'limited',reason:'fuse_limit',sent_w:6900,actual_w:6800})); + assert.equal(capped.title,'Limited by site power limits'); + assert.match(capped.text,/fuse and import limits/); +}); diff --git a/web/ev-commanded-reason.test.mjs b/web/ev-commanded-reason.test.mjs index 017440d88..745ec3bc5 100644 --- a/web/ev-commanded-reason.test.mjs +++ b/web/ev-commanded-reason.test.mjs @@ -9,7 +9,7 @@ const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); test('every pause reason has its own sentence', () => { assert.match(source, /commanded_reason === "fuse_cooldown" \|\| lp\.commanded_reason === "fuse_limit"/); - assert.match(source, /Paused: main-fuse protection/); + assert.match(source, /Paused by site power limits/); assert.match(source, /commanded_reason === "site_meter_stale"/); assert.match(source, /Paused for safety: site-meter data is stale/); assert.match(source, /commanded_reason === "pv_surplus_pause"/); @@ -20,5 +20,5 @@ test('every pause reason has its own sentence', () => { assert.match(source, /The ready time has passed, so FTW is not charging/); assert.match(source, /Battery level is assumed, not read from the car/); // A fuse-limited ongoing charge says so too. - assert.match(source, /Rate is limited by the main fuse right now/); + assert.match(source, /Rate is limited by site power limits right now/); }); diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs index 4076398db..96aba8e1f 100644 --- a/web/ev-manual-feedback.test.mjs +++ b/web/ev-manual-feedback.test.mjs @@ -25,7 +25,7 @@ test('the status line follows the charger through every state', () => { assert.match(statusText, /Waiting for the car to start drawing/); assert.match(statusText, /but the car is not drawing/); assert.match(statusText, /The charger has not acted on/); - assert.match(statusText, /Main fuse limits this charge/); + assert.match(statusText, /Site power limits cap this charge/); // The charger's own words are part of the sentence. assert.match(statusText, /Charger reports: " \+ m\.charger_reason/); // Elapsed time comes from the box's since_ms, not from click time. @@ -139,3 +139,14 @@ test('pending manual save is distinct from an actual write failure', () => { assert.doesNotMatch(render({ ...paused, manual_save_pending: false }).textContent, /Saving it/); assert.match(render({ ...paused, manual_save_error: true }).textContent, /could not be saved/); }); + + +test('ten amp positive clamp remains distinct from zero delivery with reason 78', () => { + const manual = {active:true,state:'not_drawing',requested_a:15,commanded_a:10,limit_reason:'fuse_limit',charger_reason:'limited by dynamic charger current'}; + const words = describeManual({manual_active:true,current_power_w:0,manual}); + assert.match(words,/Charger offers 10 A but the car is not drawing/); + assert.match(words,/limited by dynamic charger current/); + assert.doesNotMatch(words,/Main fuse|charging resumes on its own/); + const limited=describeManual({manual_active:true,current_power_w:0,manual:{...manual,state:'limited'}}); + assert.match(limited,/Site power limits cap this charge at 10 A/); +});