From d093afce27ebd279ae26a79e45c0694344b0efa5 Mon Sep 17 00:00:00 2001 From: Segran Date: Fri, 6 Nov 2026 18:37:28 +0100 Subject: [PATCH 1/2] fix(ev): explain a vehicle-side stop without a no-power alarm Signed-off-by: Segran --- .changeset/vehicle-stopped-feedback.md | 5 + go/cmd/ftw/ev_observation.go | 2 +- go/cmd/ftw/ev_observation_test.go | 18 ++++ go/internal/api/api_control_feedback.go | 44 ++++++--- go/internal/api/api_vehicle_stopped_test.go | 93 +++++++++++++++++++ go/internal/loadpoint/controller.go | 1 + go/internal/loadpoint/controller_resume.go | 11 ++- .../controller_resume_delivery_test.go | 27 ++++++ web/control-feedback.js | 3 +- web/control-feedback.test.mjs | 9 ++ 10 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 .changeset/vehicle-stopped-feedback.md create mode 100644 go/internal/api/api_vehicle_stopped_test.go diff --git a/.changeset/vehicle-stopped-feedback.md b/.changeset/vehicle-stopped-feedback.md new file mode 100644 index 000000000..7045569af --- /dev/null +++ b/.changeset/vehicle-stopped-feedback.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Explain a fresh charger-reported vehicle stop without reporting a no-power fault or assuming full SoC. Avoid optional resume and vehicle wake retries while the vehicle explicitly declines power. diff --git a/go/cmd/ftw/ev_observation.go b/go/cmd/ftw/ev_observation.go index 949687145..f88b75c02 100644 --- a/go/cmd/ftw/ev_observation.go +++ b/go/cmd/ftw/ev_observation.go @@ -44,7 +44,7 @@ func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, wa active = *d.RequestActive } 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, + PowerAt: r.UpdatedAt, PowerMaxAge: time.Duration(min(max(d.PowerMaxAgeS, 0), 180)) * time.Second, Connected: *d.Connected, RequestKnown: d.RequestActive != nil, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID, SessionWhUnavailable: d.SessionWh == nil} _, powerKnown := telemetry.ControlPowerObservation(sample.PowerW, r.Data, r.UpdatedAt) sample.PowerUnavailable = !powerKnown diff --git a/go/cmd/ftw/ev_observation_test.go b/go/cmd/ftw/ev_observation_test.go index c80f4528e..e5c79c7c9 100644 --- a/go/cmd/ftw/ev_observation_test.go +++ b/go/cmd/ftw/ev_observation_test.go @@ -85,3 +85,21 @@ func TestEVObservationKeepsUnknownPowerSeparateFromZero(t *testing.T) { } } } + +func TestEVObservationDistinguishesExplicitVehicleStopFromMissingRequest(t *testing.T) { + now := time.Now() + h := &telemetry.DriverHealth{Status: telemetry.StatusOk} + for _, tc := range []struct { + body string + known, active bool + }{ + {`{"connected":true,"request_active":false}`, true, false}, + {`{"connected":true,"request_active":true}`, true, true}, + {`{"connected":true}`, false, true}, + } { + s, ok := currentEVSample(&telemetry.DerReading{UpdatedAt: now, Data: json.RawMessage(tc.body)}, h, time.Minute, now, false, "box") + if !ok || s.RequestKnown != tc.known || s.RequestActive != tc.active { + t.Fatalf("%s: %+v", tc.body, s) + } + } +} diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index f2cc18fdb..5569fb466 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -21,19 +21,20 @@ import ( // are null, never invented zeros. Reasons describe only what Core or the // device reported, not a guessed physical cause. type ControlFeedback struct { - Driver string `json:"driver"` - Kind string `json:"kind"` - Mode string `json:"mode"` - Status string `json:"status"` - Reason string `json:"reason"` - Severity string `json:"severity"` - Evidence string `json:"evidence"` - ReadingsFresh bool `json:"readings_fresh"` - ConfirmedAtMs int64 `json:"confirmed_at_ms,omitempty"` - SiteConfirmation string `json:"site_confirmation"` - SiteMeter string `json:"site_meter,omitempty"` - SiteEvidence *ControlComparison `json:"site_evidence,omitempty"` - ToleranceW *float64 `json:"tolerance_w"` + VehicleRequestActive *bool `json:"vehicle_request_active,omitempty"` + Driver string `json:"driver"` + Kind string `json:"kind"` + Mode string `json:"mode"` + Status string `json:"status"` + Reason string `json:"reason"` + Severity string `json:"severity"` + Evidence string `json:"evidence"` + ReadingsFresh bool `json:"readings_fresh"` + ConfirmedAtMs int64 `json:"confirmed_at_ms,omitempty"` + SiteConfirmation string `json:"site_confirmation"` + SiteMeter string `json:"site_meter,omitempty"` + SiteEvidence *ControlComparison `json:"site_evidence,omitempty"` + ToleranceW *float64 `json:"tolerance_w"` // Internal steps of the verdict; the API exposes their result above. State string `json:"-"` VerificationTier *int `json:"-"` @@ -67,6 +68,7 @@ type ControlFeedback struct { } type feedbackReading struct { + RequestActive *bool `json:"request_active"` SetpointW *float64 `json:"setpoint_w"` MaxA *float64 `json:"max_a"` DeviceLimitA *float64 `json:"device_limit_a"` @@ -270,6 +272,7 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { f.ConfirmedAtMs = comparison.AfterAt.UnixMilli() } } + f.VehicleRequestActive = d.RequestActive classifyControlFeedback(&f, cmd, commanded, fresh, coreReason, d.Connected, now) if h := s.deps.Tel.DriverHealth(rd.Driver); h != nil && h.DeviceFault { f.State, f.Reason, f.Severity = "blocked", "device_fault", "warning" @@ -356,7 +359,7 @@ func setControlStatus(f *ControlFeedback) { status, severity := "waiting", "info" switch f.Reason { case "power_observed", "solar_below_ceiling", "idle", "plan", "manual_hold", "pv_surplus", - "vehicle_complete", "vehicle_limit_completion", "vehicle_not_requesting": + "vehicle_complete", "vehicle_limit_completion": status = "following" case "battery_full", "battery_nearly_full", "battery_nearly_empty", "core_limit", "fuse_limit", "fuse_cooldown", "charger_limit", "one_phase": @@ -427,6 +430,7 @@ const ( func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, commanded, fresh bool, coreReason string, connected *bool, now time.Time) { set := func(state, reason, severity string) { f.State, f.Reason, f.Severity = state, reason, severity } if !fresh { + f.VehicleRequestActive = nil f.ActualW, f.ReadbackW, f.OfferedA, f.DeviceLimitA = nil, nil, nil, nil set("unknown", "telemetry_stale", "warning") return @@ -451,6 +455,18 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("waiting", "not_connected", "info") return } + // A fresh driver-confirmed vehicle stop explains zero draw, not a full + // battery or a completed goal. Failed commands, stale data and safety + // pauses retain their own verdicts. Missing request evidence changes nothing. + if f.Kind == "ev" && commanded && cmd.Result == "accepted" && + connected != nil && *connected && f.VehicleRequestActive != nil && !*f.VehicleRequestActive && + (f.OfferedA == nil || f.RequestedA == nil || *f.OfferedA+0.5 >= *f.RequestedA) && + coreReason != "site_meter_stale" && coreReason != "site_phase_currents_stale" && coreReason != "fuse_cooldown" && + coreReason != "fuse_limit" && coreReason != "import_limit" && + f.SentW != nil && *f.SentW > 100 && f.ActualW != nil && math.Abs(*f.ActualW) < 100 { + set("waiting", "vehicle_not_requesting", "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) >= evMismatchWait diff --git a/go/internal/api/api_vehicle_stopped_test.go b/go/internal/api/api_vehicle_stopped_test.go new file mode 100644 index 000000000..8fd11b26c --- /dev/null +++ b/go/internal/api/api_vehicle_stopped_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "fmt" + "github.com/srcfl/ftw/go/internal/telemetry" + "testing" + "testing/synctest" + "time" +) + +// Keep this fixture independent of the pilot/load-balancer PR's evTrace changes. +type vehicleStopTrace struct { + *evTrace + extra string +} + +func (e *vehicleStopTrace) tick(w float64, reason string) { + c := e.tel.BeginCommand("easee", []byte(fmt.Sprintf(`{"action":"ev_set_current","power_w":%g}`, w)), time.Now()) + e.tel.CompleteCommand(c, "accepted") + e.mgr.SetCommanded("garage", w, reason) + e.tel.Update("easee", telemetry.DerEV, e.drawW, nil, []byte(fmt.Sprintf(`{"connected":true,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`, e.changedAt.Format(time.RFC3339Nano), e.extra))) + e.mgr.Observe("garage", true, e.drawW, 0, true) + time.Sleep(5 * time.Second) +} + +func TestVehicleStopExplainsZeroDrawButNotAFullBattery(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + e := &vehicleStopTrace{evTrace: newEVTrace(8200)} + e.extra = `,"request_active":true,"max_a":12` + for range 30 { + e.tick(8280, "plan") + } + e.drawW = 0 + e.changedAt = time.Now() + e.extra = `,"request_active":false,"op_mode":4,"max_a":15` + for range 12 * 60 / 5 { + e.tick(10350, "plan") + f := e.verdict(t) + if f.Reason != "vehicle_not_requesting" || f.Status == "not_following" || f.Severity != "info" { + t.Fatalf("false fault: %+v", f) + } + states := e.srv.ControlStates(time.Now()) + if len(states) != 1 || states[0].Status == "not_following" { + t.Fatalf("notification verdict: %+v", states) + } + } + // A new active vehicle request at zero power remains a genuine failed start. + e.extra = `,"request_active":true,"max_a":15` + for range 5 * 60 / 5 { + e.tick(10350, "plan") + } + if f := e.verdict(t); f.Status != "not_following" || f.Reason != "no_power_response" { + t.Fatalf("failed start hidden: %+v", f) + } + }) +} +func TestVehicleStopNeverOverridesStaleFailedOrContradictoryEvidence(t *testing.T) { + now := time.Now() + no := false + yes := true + for _, tc := range []struct { + name string + request *bool + fresh bool + result string + actual float64 + reason string + }{ + {"explicit stop", &no, true, "accepted", 0, "vehicle_not_requesting"}, + {"missing request", nil, true, "accepted", 0, "no_power_response"}, + {"active request", &yes, true, "accepted", 0, "no_power_response"}, + {"stale completed", &no, false, "accepted", 0, "telemetry_stale"}, + {"command failed", &no, true, "failed", 0, "command_failed"}, + {"unconfirmed command", &no, true, "unconfirmed", 0, "command_unconfirmed"}, + {"unexpected draw", &no, true, "accepted", 12000, "power_above_target"}, + {"lower held offer", &no, true, "accepted", 0, "offered_current_lower"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := ControlFeedback{Kind: "ev", VehicleRequestActive: tc.request, SentW: watts(10350), RequestedW: watts(10350), RequestedA: watts(15), OfferedA: watts(15), ActualW: watts(tc.actual), ObservedAtMs: now.UnixMilli()} + if tc.name == "lower held offer" { + f.OfferedA = watts(0) + } + cmd := telemetry.CommandEvidence{Result: tc.result, Since: now.Add(-10 * time.Minute), PowerMismatchSince: now.Add(-5 * time.Minute), LastObservation: now} + classifyControlFeedback(&f, cmd, true, tc.fresh, "plan", &yes, now) + if f.Reason != tc.reason { + t.Fatalf("reason %s want %s", f.Reason, tc.reason) + } + if !tc.fresh && f.VehicleRequestActive != nil { + t.Fatal("stale request exposed") + } + }) + } +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 9ca3488c1..34cf20df4 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -336,6 +336,7 @@ type EVSample struct { PowerMaxAge time.Duration EnergyAt time.Time Connected bool + RequestKnown bool RequestActive bool DeviceID string SessionID string diff --git a/go/internal/loadpoint/controller_resume.go b/go/internal/loadpoint/controller_resume.go index fe8577737..26939d771 100644 --- a/go/internal/loadpoint/controller_resume.go +++ b/go/internal/loadpoint/controller_resume.go @@ -43,7 +43,7 @@ func (c *Controller) currentChargeSample(now time.Time, cfg Config, previous EVS } func chargerIdleForWake(sample EVSample, now time.Time) bool { - return !sample.PowerUnavailable && finite(sample.PowerW) && sample.PowerW < DeliveringW && + return !(sample.RequestKnown && !sample.RequestActive) && !sample.PowerUnavailable && finite(sample.PowerW) && sample.PowerW < DeliveringW && !sample.PowerAt.After(now) && (sample.PowerAt.IsZero() || now.Sub(sample.PowerAt) <= sample.PowerWindow()) } @@ -66,6 +66,15 @@ func (c *Controller) recoverOfferedCharge(ctx context.Context, cfg Config, sampl c.resumeOffers[cfg.ID] = &proof return } + // A current, explicit vehicle-side stop is not a failed start. Do not + // infer a full battery or mark its goal complete; a later active request + // naturally re-enables recovery. Unknown/stale evidence cannot suppress it. + if sample.RequestKnown && !sample.RequestActive && sample.Connected && !sample.ConnectionUnknown && + !sample.PowerUnavailable && finite(sample.PowerW) && sample.PowerW >= 0 && sample.PowerW < 100 && + !sample.PowerAt.IsZero() && !sample.PowerAt.After(now) && now.Sub(sample.PowerAt) <= min(sample.PowerWindow(), resumeObservationGap) { + delete(c.resumeOffers, cfg.ID) + return + } // 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 diff --git a/go/internal/loadpoint/controller_resume_delivery_test.go b/go/internal/loadpoint/controller_resume_delivery_test.go index 966f57075..20368ee78 100644 --- a/go/internal/loadpoint/controller_resume_delivery_test.go +++ b/go/internal/loadpoint/controller_resume_delivery_test.go @@ -60,3 +60,30 @@ func TestResumeRequiresSustainedMeaningfulDelivery(t *testing.T) { }) } } + +func TestVehicleDeclinesChargeSuppressesResumeUntilRequestReturns(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{Connected: true, DeviceID: "box", SessionID: "one", RequestKnown: true, RequestActive: false, PowerAt: now} + c.recoverOfferedCharge(context.Background(), cfg, sample, 0, now) + for seconds := 1; seconds <= 300; seconds += 5 { + sample.PowerAt = now.Add(time.Duration(seconds) * time.Second) + c.recoverOfferedCharge(context.Background(), cfg, sample, 10350, sample.PowerAt) + } + if resumes != 0 { + t.Fatalf("resumed stopped vehicle %d times", resumes) + } + if chargerIdleForWake(sample, sample.PowerAt) { + t.Fatal("stopped vehicle eligible for wake") + } + sample.RequestActive = true + for seconds := 305; seconds <= 460; seconds += 5 { + sample.PowerAt = now.Add(time.Duration(seconds) * time.Second) + c.recoverOfferedCharge(context.Background(), cfg, sample, 10350, sample.PowerAt) + } + if resumes == 0 { + t.Fatal("new request did not restore failed-start retries") + } +} diff --git a/web/control-feedback.js b/web/control-feedback.js index e81644039..b64fc8722 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -83,7 +83,7 @@ function sentence(row) { case 'solar_below_ceiling': return `Producing ${controlPower(actual)}, below ${target(row)}.`; case 'vehicle_complete': return 'Not charging: the car reports that it is full.'; case 'vehicle_limit_completion': return 'Not charging: the car reached its own charge limit.'; - case 'vehicle_not_requesting': return 'The car is not asking for power right now.'; + case 'vehicle_not_requesting': return 'The charger reports that the car has finished or paused charging. Its battery level and charge limit are not confirmed by this status.'; case 'waiting_response': return `Moving to ${target(row)}. The ${name} usually responds within ${row.kind === 'ev' ? 'two minutes' : '15 seconds'}.`; case 'no_command': return 'FTW has not sent a command since it started.'; case 'not_connected': return 'Plug in a car to charge.'; @@ -130,6 +130,7 @@ function nextStep(row) { case 'power_below_target': case 'power_above_target': case 'power_wrong_direction': case 'no_power_response': case 'power_while_idle': return `The ${name} has not said why. Check its app or display.`; + case 'vehicle_not_requesting': return 'Check the car’s charge limit and schedule.'; case 'setpoint_changed': return 'Another app or controller may be changing it.'; case 'device_fault': return `Check the ${name}.`; case 'telemetry_stale': case 'readings_lost': case 'command_failed': case 'default_failed': diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 9250313ca..95b09f61a 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -160,3 +160,12 @@ test('limited positive EV offer does not explain prolonged zero delivery', () => assert.match(hour.text,/this hour’s grid import/); assert.doesNotMatch(hour.text,/fuse/); }); + +test('vehicle-side stop is explained without asserting full SoC or a met goal', () => { + const s=controlStatus(row({kind:'ev',status:'waiting',reason:'vehicle_not_requesting',severity:'info',actual_w:0,requested_w:10350})); + assert.match(s.text,/finished or paused/); + assert.match(s.text,/not confirmed/); + assert.match(s.next,/charge limit and schedule/); + assert.equal(s.tone,'neutral'); + assert.doesNotMatch(s.text,/car is full|reached its own charge limit/); +}); From 6957b2cd735470528f19aace861ada23f4b305c2 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 8 Nov 2026 08:17:13 +0100 Subject: [PATCH 2/2] fix(ev): debounce the vehicle-stop verdict and keep Core's own stop following Core's own 0 W stop (vehicle_not_requesting) is following again. The case where the driver says the car stopped while Core still offers power gets its own reason, vehicle_declined, so one reason means one thing. The verdict, resume suppression and wake suppression now need the stop to last SessionCompletionTimeout while Core offers current, using the loadpoint manager's existing timer, plus fresh charger data. A reason-50 blip during the handshake or after Core's own pause no longer hides a failed start or blocks the resume or contactor cycle that pause needs. Co-Authored-By: Claude Opus 5.5 --- .changeset/vehicle-stopped-feedback.md | 2 +- go/internal/api/api_control_feedback.go | 17 +- go/internal/api/api_vehicle_stopped_test.go | 121 ++++++++++--- go/internal/loadpoint/controller.go | 4 +- go/internal/loadpoint/controller_resume.go | 26 ++- .../controller_resume_delivery_test.go | 163 +++++++++++++++--- go/internal/loadpoint/loadpoint.go | 12 ++ web/control-feedback.js | 6 +- web/control-feedback.test.mjs | 12 +- 9 files changed, 295 insertions(+), 68 deletions(-) diff --git a/.changeset/vehicle-stopped-feedback.md b/.changeset/vehicle-stopped-feedback.md index 7045569af..e03a01f27 100644 --- a/.changeset/vehicle-stopped-feedback.md +++ b/.changeset/vehicle-stopped-feedback.md @@ -2,4 +2,4 @@ "ftw": patch --- -Explain a fresh charger-reported vehicle stop without reporting a no-power fault or assuming full SoC. Avoid optional resume and vehicle wake retries while the vehicle explicitly declines power. +Explain a charger-reported vehicle stop that lasts 90 seconds while FTW offers power, without reporting a no-power fault or assuming full SoC. A brief stop during the session handshake no longer hides a failed start or blocks the resume and wake after FTW's own pause. FTW's own 0 W stop for a car that stopped asking counts as following again. diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index 5569fb466..414120536 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -65,6 +65,9 @@ type ControlFeedback struct { SinceMs int64 `json:"since_ms,omitempty"` CommandAtMs int64 `json:"command_at_ms,omitempty"` ObservedAtMs int64 `json:"observed_at_ms,omitempty"` + // VehicleDeclined is Core's debounced record that the car refused its + // offer. The reason carries the result. + VehicleDeclined bool `json:"-"` } type feedbackReading struct { @@ -232,6 +235,7 @@ func (s *Server) controlFeedback(now time.Time) []ControlFeedback { fresh = false f.ActualW = nil } + f.VehicleDeclined = lp.VehicleDeclinesOffer(now) } // Evidence describes measured power, not target fulfilment. A device // delivering 4.4 kW against a 5 kW command can still be site-confirmed. @@ -359,7 +363,7 @@ func setControlStatus(f *ControlFeedback) { status, severity := "waiting", "info" switch f.Reason { case "power_observed", "solar_below_ceiling", "idle", "plan", "manual_hold", "pv_surplus", - "vehicle_complete", "vehicle_limit_completion": + "vehicle_complete", "vehicle_limit_completion", "vehicle_not_requesting": status = "following" case "battery_full", "battery_nearly_full", "battery_nearly_empty", "core_limit", "fuse_limit", "fuse_cooldown", "charger_limit", "one_phase": @@ -455,16 +459,17 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("waiting", "not_connected", "info") return } - // A fresh driver-confirmed vehicle stop explains zero draw, not a full - // battery or a completed goal. Failed commands, stale data and safety - // pauses retain their own verdicts. Missing request evidence changes nothing. - if f.Kind == "ev" && commanded && cmd.Result == "accepted" && + // The car declined Core's offer: the driver says so now, and Core has seen + // it for SessionCompletionTimeout. That explains zero draw, not a full + // battery or a met goal; vehicle_not_requesting is Core's own 0 W stop. + // Failed commands, stale data and safety pauses keep their own verdicts. + if f.Kind == "ev" && commanded && cmd.Result == "accepted" && f.VehicleDeclined && connected != nil && *connected && f.VehicleRequestActive != nil && !*f.VehicleRequestActive && (f.OfferedA == nil || f.RequestedA == nil || *f.OfferedA+0.5 >= *f.RequestedA) && coreReason != "site_meter_stale" && coreReason != "site_phase_currents_stale" && coreReason != "fuse_cooldown" && coreReason != "fuse_limit" && coreReason != "import_limit" && f.SentW != nil && *f.SentW > 100 && f.ActualW != nil && math.Abs(*f.ActualW) < 100 { - set("waiting", "vehicle_not_requesting", "info") + set("waiting", "vehicle_declined", "info") return } // A positive clamp explains the lower offer, not prolonged zero delivery. diff --git a/go/internal/api/api_vehicle_stopped_test.go b/go/internal/api/api_vehicle_stopped_test.go index 8fd11b26c..570a164c0 100644 --- a/go/internal/api/api_vehicle_stopped_test.go +++ b/go/internal/api/api_vehicle_stopped_test.go @@ -2,42 +2,58 @@ package api import ( "fmt" - "github.com/srcfl/ftw/go/internal/telemetry" "testing" "testing/synctest" "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" ) // Keep this fixture independent of the pilot/load-balancer PR's evTrace changes. +// stopped is the driver's request_active=false (Easee reason 50 or op mode 4). type vehicleStopTrace struct { *evTrace - extra string + stopped bool + maxA float64 } func (e *vehicleStopTrace) tick(w float64, reason string) { c := e.tel.BeginCommand("easee", []byte(fmt.Sprintf(`{"action":"ev_set_current","power_w":%g}`, w)), time.Now()) e.tel.CompleteCommand(c, "accepted") e.mgr.SetCommanded("garage", w, reason) - e.tel.Update("easee", telemetry.DerEV, e.drawW, nil, []byte(fmt.Sprintf(`{"connected":true,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`, e.changedAt.Format(time.RFC3339Nano), e.extra))) - e.mgr.Observe("garage", true, e.drawW, 0, true) + extra := `,"request_active":true` + if e.stopped { + extra = `,"request_active":false,"op_mode":4` + } + if e.maxA > 0 { + extra += fmt.Sprintf(`,"max_a":%g`, e.maxA) + } + e.tel.Update("easee", telemetry.DerEV, e.drawW, nil, []byte(fmt.Sprintf(`{"connected":true,"control_power_observed_at":%q,"control_power_confirmed":true,"power_max_age_s":180%s}`, e.changedAt.Format(time.RFC3339Nano), extra))) + e.mgr.Observe("garage", true, e.drawW, 0, !e.stopped) time.Sleep(5 * time.Second) } +// Field report #1976: an Easee charger said Completed at the car's own 80 % +// limit while Core still asked for 10.35 kW. A stop that lasts is explained +// without a fault or a full battery; until it lasts, Core keeps waiting. func TestVehicleStopExplainsZeroDrawButNotAFullBattery(t *testing.T) { synctest.Test(t, func(t *testing.T) { - e := &vehicleStopTrace{evTrace: newEVTrace(8200)} - e.extra = `,"request_active":true,"max_a":12` + e := &vehicleStopTrace{evTrace: newEVTrace(8200), maxA: 12} for range 30 { e.tick(8280, "plan") } - e.drawW = 0 - e.changedAt = time.Now() - e.extra = `,"request_active":false,"op_mode":4,"max_a":15` + e.drawW, e.changedAt, e.stopped, e.maxA = 0, time.Now(), true, 15 + stop := time.Now() for range 12 * 60 / 5 { e.tick(10350, "plan") - f := e.verdict(t) - if f.Reason != "vehicle_not_requesting" || f.Status == "not_following" || f.Severity != "info" { - t.Fatalf("false fault: %+v", f) + f, since := e.verdict(t), time.Since(stop) + want := "vehicle_declined" + if since < loadpoint.SessionCompletionTimeout { + want = "waiting_response" + } + if f.Reason != want || f.Status == "not_following" || f.Severity != "info" { + t.Fatalf("at %v want %s: %+v", since, want, f) } states := e.srv.ControlStates(time.Now()) if len(states) != 1 || states[0].Status == "not_following" { @@ -45,7 +61,7 @@ func TestVehicleStopExplainsZeroDrawButNotAFullBattery(t *testing.T) { } } // A new active vehicle request at zero power remains a genuine failed start. - e.extra = `,"request_active":true,"max_a":15` + e.stopped = false for range 5 * 60 / 5 { e.tick(10350, "plan") } @@ -54,29 +70,78 @@ func TestVehicleStopExplainsZeroDrawButNotAFullBattery(t *testing.T) { } }) } + +// Easee can report reason 50 for a moment during the session handshake. A +// blip during a failed start must not explain the missing power. +func TestVehicleStopBlipKeepsFailedStartVerdict(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + e := &vehicleStopTrace{evTrace: newEVTrace(0), maxA: 15} + start := time.Now() + for range 60 / 5 { + e.tick(10350, "plan") + } + e.stopped = true + for range 3 { + e.tick(10350, "plan") + if f := e.verdict(t); f.Reason != "waiting_response" { + t.Fatalf("handshake blip at %v: %+v", time.Since(start), f) + } + } + e.stopped = false + for range 90 / 5 { + e.tick(10350, "plan") + } + if f := e.verdict(t); f.Reason != "no_power_response" || f.Status != "not_following" { + t.Fatalf("failed start at %v: %+v", time.Since(start), f) + } + e.stopped = true + for range 60 / 5 { + e.tick(10350, "plan") + if f := e.verdict(t); f.Reason != "no_power_response" || f.Status != "not_following" { + t.Fatalf("blip hid a failed start at %v: %+v", time.Since(start), f) + } + } + }) +} + +// Core's own 0 W stop for a car that stopped asking is Core in control. +func TestCoreStopForANotRequestingCarIsFollowing(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + e := &vehicleStopTrace{evTrace: newEVTrace(0), stopped: true} + for range 60 / 5 { + e.tick(0, "vehicle_not_requesting") + } + if f := e.verdict(t); f.Reason != "vehicle_not_requesting" || f.Status != "following" || f.Evidence != "measured" { + t.Fatalf("Core's own stop: %+v", f) + } + }) +} + func TestVehicleStopNeverOverridesStaleFailedOrContradictoryEvidence(t *testing.T) { now := time.Now() no := false yes := true for _, tc := range []struct { - name string - request *bool - fresh bool - result string - actual float64 - reason string + name string + request *bool + declined bool + fresh bool + result string + actual float64 + reason string }{ - {"explicit stop", &no, true, "accepted", 0, "vehicle_not_requesting"}, - {"missing request", nil, true, "accepted", 0, "no_power_response"}, - {"active request", &yes, true, "accepted", 0, "no_power_response"}, - {"stale completed", &no, false, "accepted", 0, "telemetry_stale"}, - {"command failed", &no, true, "failed", 0, "command_failed"}, - {"unconfirmed command", &no, true, "unconfirmed", 0, "command_unconfirmed"}, - {"unexpected draw", &no, true, "accepted", 12000, "power_above_target"}, - {"lower held offer", &no, true, "accepted", 0, "offered_current_lower"}, + {"lasting stop", &no, true, true, "accepted", 0, "vehicle_declined"}, + {"brief stop", &no, false, true, "accepted", 0, "no_power_response"}, + {"missing request", nil, true, true, "accepted", 0, "no_power_response"}, + {"active request", &yes, true, true, "accepted", 0, "no_power_response"}, + {"stale completed", &no, true, false, "accepted", 0, "telemetry_stale"}, + {"command failed", &no, true, true, "failed", 0, "command_failed"}, + {"unconfirmed command", &no, true, true, "unconfirmed", 0, "command_unconfirmed"}, + {"unexpected draw", &no, true, true, "accepted", 12000, "power_above_target"}, + {"lower held offer", &no, true, true, "accepted", 0, "offered_current_lower"}, } { t.Run(tc.name, func(t *testing.T) { - f := ControlFeedback{Kind: "ev", VehicleRequestActive: tc.request, SentW: watts(10350), RequestedW: watts(10350), RequestedA: watts(15), OfferedA: watts(15), ActualW: watts(tc.actual), ObservedAtMs: now.UnixMilli()} + f := ControlFeedback{Kind: "ev", VehicleRequestActive: tc.request, VehicleDeclined: tc.declined, SentW: watts(10350), RequestedW: watts(10350), RequestedA: watts(15), OfferedA: watts(15), ActualW: watts(tc.actual), ObservedAtMs: now.UnixMilli()} if tc.name == "lower held offer" { f.OfferedA = watts(0) } diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 34cf20df4..e0037fc20 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1749,7 +1749,7 @@ func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg 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) { + if !live || !chargerIdleForWake(sample, now) || c.vehicleDeclinesOffer(lpID, sample, now) { return } pw, _ := cmd["power_w"].(float64) @@ -1861,7 +1861,7 @@ func (c *Controller) maybeWakeVehicle(ctx context.Context, now time.Time, lpCfg // gets this for free. // latest, checkedAt, live := c.currentChargeSample(now, lpCfg, sample) - if !live || !chargerIdleForWake(latest, checkedAt) { + if !live || !chargerIdleForWake(latest, checkedAt) || c.vehicleDeclinesOffer(lpID, latest, checkedAt) { return } c.cycleWallbox(lpID, lpCfg.DriverName) diff --git a/go/internal/loadpoint/controller_resume.go b/go/internal/loadpoint/controller_resume.go index 26939d771..b238f276b 100644 --- a/go/internal/loadpoint/controller_resume.go +++ b/go/internal/loadpoint/controller_resume.go @@ -43,10 +43,24 @@ func (c *Controller) currentChargeSample(now time.Time, cfg Config, previous EVS } func chargerIdleForWake(sample EVSample, now time.Time) bool { - return !(sample.RequestKnown && !sample.RequestActive) && !sample.PowerUnavailable && finite(sample.PowerW) && sample.PowerW < DeliveringW && + return !sample.PowerUnavailable && finite(sample.PowerW) && sample.PowerW < DeliveringW && !sample.PowerAt.After(now) && (sample.PowerAt.IsZero() || now.Sub(sample.PowerAt) <= sample.PowerWindow()) } +// vehicleDeclinesOffer needs fresh charger data that says the car stopped +// asking, and a stop that has lasted SessionCompletionTimeout while Core +// offered current. A handshake blip, Core's own pause, or stale or unknown +// evidence is not enough. It says nothing about the battery level. +func (c *Controller) vehicleDeclinesOffer(id string, sample EVSample, now time.Time) bool { + if c == nil || c.manager == nil || !sample.RequestKnown || sample.RequestActive || !sample.Connected || sample.ConnectionUnknown || + sample.PowerUnavailable || !finite(sample.PowerW) || sample.PowerW < 0 || sample.PowerW >= DeliveringW || + sample.PowerAt.IsZero() || sample.PowerAt.After(now) || now.Sub(sample.PowerAt) > min(sample.PowerWindow(), resumeObservationGap) { + return false + } + st, ok := c.manager.State(id) + return ok && st.VehicleDeclinesOffer(now) +} + // 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 @@ -66,12 +80,10 @@ func (c *Controller) recoverOfferedCharge(ctx context.Context, cfg Config, sampl c.resumeOffers[cfg.ID] = &proof return } - // A current, explicit vehicle-side stop is not a failed start. Do not - // infer a full battery or mark its goal complete; a later active request - // naturally re-enables recovery. Unknown/stale evidence cannot suppress it. - if sample.RequestKnown && !sample.RequestActive && sample.Connected && !sample.ConnectionUnknown && - !sample.PowerUnavailable && finite(sample.PowerW) && sample.PowerW >= 0 && sample.PowerW < 100 && - !sample.PowerAt.IsZero() && !sample.PowerAt.After(now) && now.Sub(sample.PowerAt) <= min(sample.PowerWindow(), resumeObservationGap) { + // A lasting vehicle-side stop is not a failed start. Do not infer a full + // battery or mark its goal complete; a later active request re-enables + // recovery. A brief stop still gets the resume after Core's own pause. + if c.vehicleDeclinesOffer(cfg.ID, sample, now) { delete(c.resumeOffers, cfg.ID) return } diff --git a/go/internal/loadpoint/controller_resume_delivery_test.go b/go/internal/loadpoint/controller_resume_delivery_test.go index 20368ee78..30ad66df6 100644 --- a/go/internal/loadpoint/controller_resume_delivery_test.go +++ b/go/internal/loadpoint/controller_resume_delivery_test.go @@ -2,6 +2,8 @@ package loadpoint import ( "context" + "encoding/json" + "sync" "testing" "time" ) @@ -61,29 +63,150 @@ func TestResumeRequiresSustainedMeaningfulDelivery(t *testing.T) { } } -func TestVehicleDeclinesChargeSuppressesResumeUntilRequestReturns(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{Connected: true, DeviceID: "box", SessionID: "one", RequestKnown: true, RequestActive: false, PowerAt: now} - c.recoverOfferedCharge(context.Background(), cfg, sample, 0, now) - for seconds := 1; seconds <= 300; seconds += 5 { - sample.PowerAt = now.Add(time.Duration(seconds) * time.Second) - c.recoverOfferedCharge(context.Background(), cfg, sample, 10350, sample.PowerAt) +// declineRig drives one loadpoint in tick order: observe the charger, record +// Core's order, then run recovery. The car does not ask for current. +type declineRig struct { + m *Manager + c *Controller + cfg Config + sample EVSample + clock time.Time + mu sync.Mutex + actions []string +} + +func newDeclineRig(t *testing.T) *declineRig { + t.Helper() + r := &declineRig{cfg: Config{ID: "car", DriverName: "easee", MinChargeW: 4140, MaxChargeW: 11040}, + clock: time.Date(2026, 11, 6, 5, 43, 0, 0, time.UTC)} + r.m = NewManager() + r.m.Load([]Config{r.cfg}) + r.m.SetNowFn(func() time.Time { return r.clock }) + r.c = NewController(r.m, nil, nil, func(_ context.Context, _ string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + } + _ = json.Unmarshal(payload, &cmd) + r.mu.Lock() + r.actions = append(r.actions, cmd.Action) + r.mu.Unlock() + return nil + }) + r.sample = EVSample{Connected: true, DeviceID: "box", SessionID: "one", RequestKnown: true, PowerAt: r.clock} + r.m.SetCommanded(r.cfg.ID, 0, "pv_surplus_pause") + return r +} + +func (r *declineRig) tick(offerW float64) { + r.sample.PowerAt = r.clock + r.m.ObserveSample(r.cfg.ID, r.sample) + r.m.SetCommanded(r.cfg.ID, offerW, "plan") + r.c.recoverOfferedCharge(context.Background(), r.cfg, r.sample, offerW, r.clock) + r.clock = r.clock.Add(5 * time.Second) +} + +func (r *declineRig) count(action string) int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, a := range r.actions { + if a == action { + n++ + } + } + return n +} + +// Easee reports reason 50 briefly during a session handshake, and after +// Core's own 0 A pause. A brief stop must not cancel the resume that pause +// needs; only a stop that outlasts SessionCompletionTimeout does. +func TestVehicleDeclineSuppressesResumeOnlyAfterItLasts(t *testing.T) { + r := newDeclineRig(t) + r.tick(0) + r.tick(10350) + if got := r.count("ev_resume"); got != 1 { + t.Fatalf("brief stop after Core's pause: %d resumes, want 1", got) + } + start := r.clock + for r.clock.Sub(start) < SessionCompletionTimeout { + if r.c.vehicleDeclinesOffer(r.cfg.ID, r.sample, r.clock) { + t.Fatalf("decline believed after %v", r.clock.Sub(start)) + } + r.tick(10350) + } + for range 60 { + r.tick(10350) + } + if !r.c.vehicleDeclinesOffer(r.cfg.ID, r.sample, r.clock) || r.c.resumeOffers[r.cfg.ID] != nil { + t.Fatalf("lasting stop not believed: %+v", r.c.resumeOffers[r.cfg.ID]) + } + attempts := r.count("ev_resume") + r.tick(0) + r.tick(10350) // Core's own pause again: one resume, as before. + if got := r.count("ev_resume"); got != attempts+1 { + t.Fatalf("resume after Core's pause: %d, want %d", got, attempts+1) + } + for range 60 { + r.tick(10350) } - if resumes != 0 { - t.Fatalf("resumed stopped vehicle %d times", resumes) + if got := r.count("ev_resume"); got > attempts+3 { + t.Fatalf("resumed a declining car %d times", got-attempts) } - if chargerIdleForWake(sample, sample.PowerAt) { - t.Fatal("stopped vehicle eligible for wake") + // Stale evidence cannot keep the stop. + stale := r.sample + stale.PowerAt = r.clock.Add(-time.Minute) + if r.c.vehicleDeclinesOffer(r.cfg.ID, stale, r.clock) { + t.Fatal("stale stop believed") } - sample.RequestActive = true - for seconds := 305; seconds <= 460; seconds += 5 { - sample.PowerAt = now.Add(time.Duration(seconds) * time.Second) - c.recoverOfferedCharge(context.Background(), cfg, sample, 10350, sample.PowerAt) + r.sample.RequestActive = true + r.tick(10350) + if r.c.vehicleDeclinesOffer(r.cfg.ID, r.sample, r.clock) { + t.Fatal("decline kept after the car asked again") + } +} + +// A car woken from Stopped needs charge_start. A handshake stop must not +// block it; a stop that lasts while Core offers current does. +func TestVehicleWakeWaitsForALastingDecline(t *testing.T) { + for _, tc := range []struct { + name string + ticks int + wake bool + }{ + {"brief stop", 3, true}, + {"lasting stop", 30, false}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newDeclineRig(t) + r.c.SetVehicleStatus(func(string) (string, string, bool) { return "car-api", "Stopped", true }) + r.tick(10350) + for range tc.ticks { + r.tick(10350) + } + r.sample.PowerAt = r.clock + r.c.maybeWakeVehicle(context.Background(), r.clock, r.cfg, false, r.sample, false, map[string]any{"power_w": 10350.0}) + if got := r.count("charge_start") == 1; got != tc.wake { + t.Fatalf("charge_start sent=%v want %v", got, tc.wake) + } + }) + } +} + +// A bare charger in "not requesting" after Core's own surplus pause gets a +// contactor cycle when Core offers current again. +func TestSelfPausedNotRequestingChargerStillGetsKick(t *testing.T) { + oldGap := wallboxCycleGap + wallboxCycleGap = 0 + t.Cleanup(func() { wallboxCycleGap = oldGap }) + r := newDeclineRig(t) + r.tick(0) + r.sample.PowerAt = r.clock + r.c.maybeWakeVehicle(context.Background(), r.clock, r.cfg, true, r.sample, true, map[string]any{"power_w": 4140.0}) + deadline := time.Now().Add(2 * time.Second) + for r.count("ev_resume") < 1 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) } - if resumes == 0 { - t.Fatal("new request did not restore failed-start retries") + if r.count("ev_pause") != 1 || r.count("ev_resume") != 1 { + t.Fatalf("no contactor cycle: %d pause, %d resume", r.count("ev_pause"), r.count("ev_resume")) } } diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index ef16f9de2..b6f137d57 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -130,6 +130,9 @@ type State struct { CapacitySource string `json:"capacity_source"` // ChargingDeclined is a sustained vehicle-side refusal, not a battery level. ChargingDeclined bool `json:"charging_declined"` + // NotRequestingSince is when the car stopped asking for the current Core + // offers. Zero while it asks, while Core offers none or power is unknown. + NotRequestingSince time.Time `json:"-"` // SoCRetention reports whether the confirmed estimate can survive restart. EnergySource string `json:"energy_source,omitempty"` EnergyUpdatedAtMs int64 `json:"energy_updated_at_ms,omitempty"` @@ -366,6 +369,14 @@ type Manager struct { // A refusal is not evidence that the battery reached its target. const SessionCompletionTimeout = 90 * time.Second +// VehicleDeclinesOffer reports a car that has refused Core's offered current +// for SessionCompletionTimeout. A brief stop during the session handshake is +// not enough. It says nothing about the battery level or a met goal. +func (st State) VehicleDeclinesOffer(now time.Time) bool { + return st.PluggedIn && !st.NotRequestingSince.IsZero() && !st.NotRequestingSince.After(now) && + now.Sub(st.NotRequestingSince) >= SessionCompletionTimeout +} + // The interruption hysteresis. A charge that had run steadily for at least // InterruptSteadyRun and then stops below steadyChargeFloorW for // interruptConfirm — cable still in, box not the one pausing it, completion @@ -1374,6 +1385,7 @@ func (lp *loadpointRuntime) snapshot() State { SurplusOnly: lp.Config.SurplusOnly, Schedule: lp.schedule, ChargingDeclined: lp.chargingDeclined, + NotRequestingSince: lp.notRequestingSince, SoCRetention: lp.socRetention, CarMaxW: lp.car.MaxW, CarChargeLimit: lp.car.ChargeLimit, diff --git a/web/control-feedback.js b/web/control-feedback.js index b64fc8722..f650ec945 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -62,6 +62,7 @@ const REASON_TITLES = { waiting_response: 'Adjusting', no_command: 'No command yet', not_connected: 'No car connected', 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', + vehicle_declined: 'Car stopped charging', 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 site power limit', import_limit: 'Limited by the hourly import limit', @@ -83,7 +84,8 @@ function sentence(row) { case 'solar_below_ceiling': return `Producing ${controlPower(actual)}, below ${target(row)}.`; case 'vehicle_complete': return 'Not charging: the car reports that it is full.'; case 'vehicle_limit_completion': return 'Not charging: the car reached its own charge limit.'; - case 'vehicle_not_requesting': return 'The charger reports that the car has finished or paused charging. Its battery level and charge limit are not confirmed by this status.'; + case 'vehicle_not_requesting': return 'The car is not asking for power right now.'; + case 'vehicle_declined': return 'The charger reports that the car has finished or paused charging. This does not confirm its battery level or charge limit.'; case 'waiting_response': return `Moving to ${target(row)}. The ${name} usually responds within ${row.kind === 'ev' ? 'two minutes' : '15 seconds'}.`; case 'no_command': return 'FTW has not sent a command since it started.'; case 'not_connected': return 'Plug in a car to charge.'; @@ -130,7 +132,7 @@ function nextStep(row) { case 'power_below_target': case 'power_above_target': case 'power_wrong_direction': case 'no_power_response': case 'power_while_idle': return `The ${name} has not said why. Check its app or display.`; - case 'vehicle_not_requesting': return 'Check the car’s charge limit and schedule.'; + case 'vehicle_declined': return 'Check the car’s charge limit and schedule.'; case 'setpoint_changed': return 'Another app or controller may be changing it.'; case 'device_fault': return `Check the ${name}.`; case 'telemetry_stale': case 'readings_lost': case 'command_failed': case 'default_failed': diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index 95b09f61a..476a608c9 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -162,10 +162,18 @@ test('limited positive EV offer does not explain prolonged zero delivery', () => }); test('vehicle-side stop is explained without asserting full SoC or a met goal', () => { - const s=controlStatus(row({kind:'ev',status:'waiting',reason:'vehicle_not_requesting',severity:'info',actual_w:0,requested_w:10350})); + const s=controlStatus(row({kind:'ev',status:'waiting',reason:'vehicle_declined',severity:'info',actual_w:0,requested_w:10350})); + assert.match(s.title,/Car stopped charging/); assert.match(s.text,/finished or paused/); - assert.match(s.text,/not confirmed/); + assert.match(s.text,/does not confirm/); assert.match(s.next,/charge limit and schedule/); assert.equal(s.tone,'neutral'); assert.doesNotMatch(s.text,/car is full|reached its own charge limit/); }); + +test('Core’s own stop for a car that stopped asking keeps its own words', () => { + const s=controlStatus(row({kind:'ev',status:'following',reason:'vehicle_not_requesting',severity:'info',actual_w:0,sent_w:0})); + assert.equal(s.title,'Car not taking power'); + assert.match(s.text,/not asking for power/); + assert.equal(s.next,''); +});