diff --git a/.changeset/vehicle-stopped-feedback.md b/.changeset/vehicle-stopped-feedback.md new file mode 100644 index 000000000..e03a01f27 --- /dev/null +++ b/.changeset/vehicle-stopped-feedback.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +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/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 bd15a1553..652687309 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:"-"` @@ -74,9 +75,13 @@ 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 { + RequestActive *bool `json:"request_active"` SetpointW *float64 `json:"setpoint_w"` MaxA *float64 `json:"max_a"` PilotA *float64 `json:"pilot_a"` @@ -270,6 +275,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. @@ -310,6 +316,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" @@ -467,6 +474,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, f.LimitA, f.LimitedBy, f.Make = nil, nil, nil, nil, nil, "", "" f.PilotA, f.DynamicLimitA = nil, nil f.PilotObservedAt, f.PilotConfirmedAtMs = "", 0 @@ -493,6 +501,19 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("waiting", "not_connected", "info") return } + // 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_declined", "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..570a164c0 --- /dev/null +++ b/go/internal/api/api_vehicle_stopped_test.go @@ -0,0 +1,158 @@ +package api + +import ( + "fmt" + "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 + 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) + 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), maxA: 12} + for range 30 { + e.tick(8280, "plan") + } + 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, 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" { + t.Fatalf("notification verdict: %+v", states) + } + } + // A new active vehicle request at zero power remains a genuine failed start. + e.stopped = false + 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) + } + }) +} + +// 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 + declined bool + fresh bool + result string + actual float64 + reason string + }{ + {"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, 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) + } + 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..e0037fc20 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 @@ -1748,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) @@ -1860,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 fe8577737..b238f276b 100644 --- a/go/internal/loadpoint/controller_resume.go +++ b/go/internal/loadpoint/controller_resume.go @@ -47,6 +47,20 @@ func chargerIdleForWake(sample EVSample, now time.Time) bool { !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,6 +80,13 @@ func (c *Controller) recoverOfferedCharge(ctx context.Context, cfg Config, sampl c.resumeOffers[cfg.ID] = &proof return } + // 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 + } // 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..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" ) @@ -60,3 +62,151 @@ func TestResumeRequiresSustainedMeaningfulDelivery(t *testing.T) { }) } } + +// 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 got := r.count("ev_resume"); got > attempts+3 { + t.Fatalf("resumed a declining car %d times", got-attempts) + } + // 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") + } + 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 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 9d0d3c0d1..17a90dfd7 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', @@ -85,6 +86,7 @@ function sentence(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_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.'; @@ -136,6 +138,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_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 08df34995..7a617a3bd 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -182,6 +182,23 @@ test('limited positive EV offer does not explain prolonged zero delivery', () => 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_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,/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,''); +}); + test('a confirmed lower pilot is an explained restriction with separate current values', () => { const r = row({kind:'ev',status:'limited',reason:'charger_pilot_limit',severity:'info', requested_a:15,offered_a:15,dynamic_limit_a:15,pilot_a:13,actual_w:8850,sent_w:10350});