diff --git a/.changeset/ev-confirm-starting-level.md b/.changeset/ev-confirm-starting-level.md new file mode 100644 index 00000000..33b3ec2b --- /dev/null +++ b/.changeset/ev-confirm-starting-level.md @@ -0,0 +1,13 @@ +--- +"ftw": patch +--- + +Wait for a confirmed current battery level before scheduling an EV from an assumed plug-in level. Keep older car readings visible with age and offer a one-action confirmation or slider correction. Reject cached automatic dispatch while confirmation is required; explicit manual and PV-only charging remain available. + +Label the household battery projection explicitly so it cannot be mistaken for the EV battery level. + +Show the current car target SoC after the charging timeline’s replanning notice, and update it when the charging goal changes. Keep the target visible even when no charging is planned, including when the current battery level already exceeds the goal. + +Compare the car SoC target with its current level using ≥ or <; mark older readings and FTW estimates, and avoid comparing an unconfirmed assumed starting level. + +Label current car telemetry From Car · Current and a manually confirmed level Confirmed by user. Retain manual provenance within a saved hardware session, and switch to FTW estimate once delivered energy advances the level. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index d440e7f6..44b3274a 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1649,6 +1649,9 @@ func main() { socSource = "vehicle:" + pick.Driver vehicleChargeLimit = pick.ChargeLimit } + if socSource == "inferred" && !loadpoint.SoCConfirmedForPlan(st) { + continue + } // Map target time → slot index using the DP's // actual slot length (hour-of-prices vs. 15-min // quarters vary by market). Both planners clamp a diff --git a/go/internal/api/api_loadpoint_plan.go b/go/internal/api/api_loadpoint_plan.go index 6c8db228..0a379b86 100644 --- a/go/internal/api/api_loadpoint_plan.go +++ b/go/internal/api/api_loadpoint_plan.go @@ -33,6 +33,9 @@ func (s *Server) decorateLoadpointsWithPlan(states []loadpoint.State) { for i := range states { states[i].PlanPending = snapshot.Pending states[i].PlanOutdated = snapshot.Outdated + if !loadpoint.SoCConfirmedForPlan(states[i]) && !states[i].ManualActive && !states[i].SurplusOnly { + continue + } if snapshot.Outdated { continue } diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 517365cc..a36f85d7 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -1559,6 +1559,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s cmdReason = "vehicle_complete" } } + // An old plan must not dispatch from an unconfirmed plug-in guess. + // Explicit manual charging and PV-only charging do not use this estimate. + if st, ok := c.manager.State(lpCfg.ID); ok && sched.HasTarget() && !surplusOn && cmdReason != "pv_surplus" && !SoCConfirmedForPlan(st) { + cmdW, cmdReason = 0, "soc_confirmation_required" + } // Fuse protection: applied LAST (after MPC budget, surplus // clamp, wake-kick) so all upstream sources see their nominal // wantW; only the actual ceiling we send to the wallbox is diff --git a/go/internal/loadpoint/controller_bat_soc_unlock_test.go b/go/internal/loadpoint/controller_bat_soc_unlock_test.go index 6c929098..c74fbcd3 100644 --- a/go/internal/loadpoint/controller_bat_soc_unlock_test.go +++ b/go/internal/loadpoint/controller_bat_soc_unlock_test.go @@ -263,6 +263,8 @@ func scheduledUnlockTick(t *testing.T, cfg Config, budgetWh, batSoC, surplusW fl } samples := map[string]EVSample{cfg.DriverName: {Connected: true, PowerW: 0, RequestActive: true}} c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.manager.Observe(cfg.ID, true, 0, 0, true) + c.manager.SetCurrentSoC(cfg.ID, .5) c.manager.SetSchedule(cfg.ID, Schedule{ SoC: 0.8, TimeOfDayMinUTC: 7 * 60, Recurring: true, SurplusUnlockBatSoC: 0.8, }) diff --git a/go/internal/loadpoint/controller_dispatch_safety_test.go b/go/internal/loadpoint/controller_dispatch_safety_test.go index 30fae77f..db717399 100644 --- a/go/internal/loadpoint/controller_dispatch_safety_test.go +++ b/go/internal/loadpoint/controller_dispatch_safety_test.go @@ -29,6 +29,8 @@ func TestStaleSiteMeterStopsScheduledChargeAndRecovers(t *testing.T) { cfg.DriverName: {Connected: true, RequestActive: true}, } c := newTestController(t, []Config{cfg}, directive, samples, sender) + c.manager.Observe(cfg.ID, true, 0, 0, true) + c.manager.SetCurrentSoC(cfg.ID, .5) if !c.manager.SetSchedule(cfg.ID, Schedule{ SoC: 0.8, TimeOfDayMinUTC: 13 * 60, diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index a19e8a2b..f54604c2 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -162,6 +162,9 @@ type State struct { VehicleStale bool `json:"vehicle_stale,omitempty"` VehicleSoCAgeS int64 `json:"vehicle_soc_age_s,omitempty"` SoCSource string `json:"soc_source,omitempty"` + // True only while the current level still equals a user's confirmation, + // before any additional delivered energy advances the estimate. + SoCConfirmedByUser bool `json:"soc_confirmed_by_user,omitempty"` // VehicleName is the vehicle profile the session identified (via the // charging transaction's idTag/idToken), empty when none matched. VehicleName string `json:"vehicle_name,omitempty"` @@ -428,7 +431,8 @@ type loadpointRuntime struct { // socConfirmed is true only after a level from the user or a matched car. // A configured/default plug-in level remains a planning assumption. - socConfirmed bool + socConfirmed bool + socUserConfirmedAtWh *float64 // surplusWithheld is set by the controller each tick: true when WE // are intentionally withholding power from this loadpoint (a @@ -598,6 +602,7 @@ func (m *Manager) Load(cfgs []Config) { lp.chargingDeclined = existing.chargingDeclined lp.socConfirmed = existing.socConfirmed && existing.DriverName == c.DriverName if existing.DriverName == c.DriverName { + lp.socUserConfirmedAtWh = existing.socUserConfirmedAtWh lp.sessionDeviceID = existing.sessionDeviceID lp.sessionID = existing.sessionID lp.socRetention = existing.socRetention @@ -757,6 +762,7 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 } lp.sessionPluginSoC = anchor lp.socConfirmed = false + lp.socUserConfirmedAtWh = nil lp.completionNotified = false lp.deliveredHistory = nil lp.notRequestingSince = time.Time{} @@ -1080,6 +1086,8 @@ func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { lp.chargingDeclined = false lp.notRequestingSince = time.Time{} reanchorSoCLocked(lp, socPct) + confirmedWh := lp.deliveredWhSession + lp.socUserConfirmedAtWh = &confirmedWh return true } @@ -1213,6 +1221,7 @@ func reanchorSoCLocked(lp *loadpointRuntime, soc float64) { func reanchorSoCAtLocked(lp *loadpointRuntime, soc, deliveredWh float64) { soc = units.ClampFraction(soc) lp.socConfirmed = true + lp.socUserConfirmedAtWh = nil // Re-anchor: new_anchor + delivered/capacity == soc. delivered := 0.0 if lp.VehicleCapacityWh > 0 { @@ -1239,6 +1248,7 @@ func (lp *loadpointRuntime) snapshot() State { DriverName: lp.DriverName, PluggedIn: lp.pluggedIn, CurrentSoC: lp.currentSoC, + SoCConfirmedByUser: lp.pluggedIn && lp.socConfirmed && lp.socUserConfirmedAtWh != nil && lp.deliveredWhSession == *lp.socUserConfirmedAtWh, CurrentPowerW: lp.currentPowerW, DeliveredWhSession: lp.deliveredWhSession, TargetSoC: lp.targetSoC, diff --git a/go/internal/loadpoint/planning_soc.go b/go/internal/loadpoint/planning_soc.go new file mode 100644 index 00000000..adf4ecbc --- /dev/null +++ b/go/internal/loadpoint/planning_soc.go @@ -0,0 +1,11 @@ +package loadpoint + +import "math" + +// SoCConfirmedForPlan excludes default plug-in guesses and completion estimates. +// A user correction or a matched car anchor supplies the starting level. +func SoCConfirmedForPlan(st State) bool { + return !math.IsNaN(st.CurrentSoC) && !math.IsInf(st.CurrentSoC, 0) && + st.CurrentSoC >= 0 && st.CurrentSoC <= 1 && + st.SoCSource != "assumed" && st.SoCSource != "completed" +} diff --git a/go/internal/loadpoint/planning_soc_test.go b/go/internal/loadpoint/planning_soc_test.go new file mode 100644 index 00000000..243fb5b2 --- /dev/null +++ b/go/internal/loadpoint/planning_soc_test.go @@ -0,0 +1,57 @@ +package loadpoint + +import ( + "context" + "math" + "testing" + "time" +) + +func TestPlanningRequiresConfirmedStartingLevel(t *testing.T) { + for _, source := range []string{"assumed", "completed"} { + if SoCConfirmedForPlan(State{CurrentSoC: .2, SoCSource: source}) { + t.Fatal(source) + } + } + for _, soc := range []float64{math.NaN(), math.Inf(1), -.01, 1.01} { + if SoCConfirmedForPlan(State{CurrentSoC: soc}) { + t.Fatal(soc) + } + } + for _, source := range []string{"", "inferred", "vehicle"} { + if !SoCConfirmedForPlan(State{CurrentSoC: .86, SoCSource: source}) { + t.Fatal(source) + } + } +} +func TestUnconfirmedSoCRejectsCachedScheduleUntilCorrection(t *testing.T) { + now := time.Now() + cfg := Config{ID: "easee", DriverName: "easee-cloud", VehicleCapacityWh: 86500, MinChargeW: 4140, MaxChargeW: 11000} + sender := &fakeSender{} + dir := &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{"easee": 11000}} + c := newTestController(t, []Config{cfg}, dir, map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: true}}, sender) + c.manager.SetSchedule(cfg.ID, Schedule{SoC: .9, TimeOfDayMinUTC: 420, Recurring: true}) + c.Tick(context.Background(), now) + last, ok := lastSetCurrent(sender.calls) + if !ok || last.power != 0 { + t.Fatalf("unconfirmed schedule dispatched: %+v", last) + } + st, _ := c.manager.State(cfg.ID) + if st.CommandedReason != "soc_confirmation_required" { + t.Fatal(st.CommandedReason) + } + if !c.manager.SetCurrentSoC(cfg.ID, .86) { + t.Fatal("correction rejected") + } + c.Tick(context.Background(), now.Add(time.Second)) + last, _ = lastSetCurrent(sender.calls) + if last.power <= 0 { + t.Fatalf("confirmed schedule did not resume: %+v", last) + } + c.manager.Observe(cfg.ID, false, 0, 0, true) + c.Tick(context.Background(), now.Add(2*time.Second)) + last, _ = lastSetCurrent(sender.calls) + if last.power != 0 { + t.Fatal("new unconfirmed session reused confirmed plan") + } +} diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go index 3dcd81b1..44714a8a 100644 --- a/go/internal/loadpoint/session_state.go +++ b/go/internal/loadpoint/session_state.go @@ -25,6 +25,7 @@ type savedSession struct { SessionID string `json:"session_id"` AnchorSoC float64 `json:"anchor_soc"` ConfirmedAtWh float64 `json:"confirmed_at_wh"` + UserConfirmedAtWh *float64 `json:"user_confirmed_at_wh,omitempty"` CapacityWh float64 `json:"capacity_wh"` EstimatedWh *float64 `json:"estimated_wh,omitempty"` EstimatedAt time.Time `json:"estimated_at,omitempty"` @@ -132,6 +133,10 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { // a level. Keep that correction while joining the measured time line. baseline := lp.energy.counterWh - lp.energy.integralAt(lp.energy.counterAt) lp.sessionPluginSoC -= baseline * DefaultChargeEfficiency / lp.VehicleCapacityWh + if lp.socUserConfirmedAtWh != nil { + adjusted := *lp.socUserConfirmedAtWh + baseline + lp.socUserConfirmedAtWh = &adjusted + } } if changed { lp.chargingPeriodSince = time.Time{} @@ -194,6 +199,10 @@ func (m *Manager) ObserveSample(id string, sample EVSample) { lp.sessionPluginSoC = restore.AnchorSoC lp.currentSoC = estimateSoC(restore.AnchorSoC, deliveredWh, restore.CapacityWh) lp.socConfirmed = true + lp.socUserConfirmedAtWh = nil + if restore.UserConfirmedAtWh != nil && finite(*restore.UserConfirmedAtWh) && *restore.UserConfirmedAtWh >= 0 && *restore.UserConfirmedAtWh <= deliveredWh { + lp.socUserConfirmedAtWh = restore.UserConfirmedAtWh + } lp.completionNotified = restore.CompletionNotified lp.socRetention = "session" } else if !lp.socConfirmed || deviceID == "" || sessionID == "" || m.sessionStore == nil { @@ -259,7 +268,8 @@ func (m *Manager) persistSession(id string) { } record := savedSession{Version: 2, DeviceID: lp.sessionDeviceID, SessionID: lp.sessionID, AnchorSoC: lp.sessionPluginSoC, ConfirmedAtWh: lp.deliveredWhSession, - CapacityWh: lp.VehicleCapacityWh, CompletionNotified: lp.completionNotified} + UserConfirmedAtWh: lp.socUserConfirmedAtWh, + CapacityWh: lp.VehicleCapacityWh, CompletionNotified: lp.completionNotified} if lp.energy != nil && lp.energy.counterKnown { record.ConfirmedAtWh = lp.energy.counterWh if lp.deliveredWhSession > lp.energy.counterWh { @@ -330,6 +340,7 @@ func (m *Manager) observeConnectionProof(id string, generation uint64, unknown b m.nextSessionGeneration++ lp.sessionGeneration = m.nextSessionGeneration lp.socConfirmed = false + lp.socUserConfirmedAtWh = nil lp.socRetention = "unavailable" if lp.vehicleName != "" || lp.capacityFromCar { lp.VehicleCapacityWh = lp.baseCapacityWh diff --git a/go/internal/loadpoint/session_state_test.go b/go/internal/loadpoint/session_state_test.go index c2e73e44..413514bb 100644 --- a/go/internal/loadpoint/session_state_test.go +++ b/go/internal/loadpoint/session_state_test.go @@ -210,3 +210,33 @@ func TestLegacySessionAnchorMigratesWithoutChangingConfirmedSoC(t *testing.T) { t.Fatalf("post-confirmation loss missing: %v", s.CurrentSoC) } } + +func TestUserSoCProvenanceSurvivesSameSessionAndEndsAfterDelivery(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 9000, true, "easee:ABC", "connection-1") + m.SetCurrentSoC("garage", .86) + if st, _ := m.State("garage"); !st.SoCConfirmedByUser { + t.Fatal("manual level lost its source") + } + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 9000, true, "easee:ABC", "connection-1") + if st, _ := m.State("garage"); !st.SoCConfirmedByUser { + t.Fatal("same-session restore lost user confirmation") + } + m.ObserveSession("garage", true, 0, 9600, true, "easee:ABC", "connection-1") + if st, _ := m.State("garage"); st.SoCConfirmedByUser { + t.Fatal("delivered-energy estimate still claims user confirmation") + } + m.SetCurrentSoC("garage", .87) + m.AnchorVehicleSoC("garage", .88) + if st, _ := m.State("garage"); st.SoCConfirmedByUser { + t.Fatal("vehicle anchor claims user confirmation") + } + m.SetCurrentSoC("garage", .88) + m.ObserveSession("garage", false, 0, 9600, true, "easee:ABC", "connection-1") + m.ObserveSession("garage", true, 0, 0, true, "easee:ABC", "connection-2") + if st, _ := m.State("garage"); st.SoCConfirmedByUser { + t.Fatal("new session reused user confirmation") + } +} diff --git a/go/internal/loadpoint/vehicle_completion_test.go b/go/internal/loadpoint/vehicle_completion_test.go index 096eea03..8b0105fc 100644 --- a/go/internal/loadpoint/vehicle_completion_test.go +++ b/go/internal/loadpoint/vehicle_completion_test.go @@ -32,6 +32,8 @@ func TestVehicleLimitCompletionKeepsChargingAndSafetyWins(t *testing.T) { sender := &fakeSender{} dir := &Directive{SlotStart: now.Add(-time.Second), SlotEnd: now.Add(time.Hour), LoadpointEnergyWh: map[string]float64{cfg.ID: 0}} c := newTestController(t, []Config{cfg}, dir, samples, sender) + c.manager.Observe(cfg.ID, true, 0, 0, true) + c.manager.SetCurrentSoC(cfg.ID, 1) c.manager.SetSchedule(cfg.ID, Schedule{FinishAtVehicleLimit: true, TimeOfDayMinUTC: 5 * 60, Recurring: true}) c.manager.RollSchedules(now) c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) diff --git a/go/internal/mpc/service_requested_replan_test.go b/go/internal/mpc/service_requested_replan_test.go index df8b53fc..f6399342 100644 --- a/go/internal/mpc/service_requested_replan_test.go +++ b/go/internal/mpc/service_requested_replan_test.go @@ -120,6 +120,9 @@ func TestFailedGoalReplanStopsOldEVAllocationButKeepsManualControl(t *testing.T) } return sent[len(sent)-1] } + // Confirm the starting level for this session before scheduling. + ctrl.Tick(context.Background(), now) + mgr.SetCurrentSoC("garage", .5) if got := tickPower(); got <= 0 { t.Fatalf("initial plan did not charge: %v", got) } diff --git a/web/app.js b/web/app.js index 60108111..c0905843 100644 --- a/web/app.js +++ b/web/app.js @@ -3847,6 +3847,19 @@ note.style.marginTop = "0.3rem"; note.style.minHeight = "1em"; socWrap.appendChild(note); + var confirmCar = document.createElement("button"); + confirmCar.type = "button"; + confirmCar.hidden = true; + socWrap.appendChild(confirmCar); + confirmCar.addEventListener("click", function () { + var reported = lastLp.vehicle_soc == null ? 0 : lastLp.vehicle_soc; + if (!lastLp.vehicle_driver || !lastLp.vehicle_stale || !isFinite(reported) || reported < 0 || reported > 1) return; + socRevision++; + socFailed = false; + socPending = Math.round(reported * 100); + note.textContent = "Confirming charge level: " + socPending + " %…"; + sendSoc(); + }); box.appendChild(levels); box.appendChild(levelsNote); @@ -3879,10 +3892,12 @@ : lpNow.soc_retention === "error" ? " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting." : " This level must be entered again after a box restart."; - if (src === "assumed") return "Battery level needs confirmation. The plan currently assumes " + Math.round(lpNow.current_soc * 100) + " %. Drag to match the car." + retention; + if (src === "assumed") return "Battery level needs confirmation. Scheduled charging waits until you confirm the level. Drag to match the car." + retention; if (src === "vehicle") return "The car reports its current level automatically."; if (src === "completed") return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car."; - return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention; + return (lpNow.soc_confirmed_by_user + ? "Confirmed by user. Drag to update the level and the plan follows." + : "Estimated from energy delivered. Drag to the real value and the plan follows.") + retention; } var socPending = null; @@ -3958,7 +3973,11 @@ levels.hidden = !lpNow.plugged_in; levelsNote.hidden = !lpNow.plugged_in; var plugged = !!(lpNow && lpNow.plugged_in); - socWrap.hidden = !plugged || !!(info && info.fromCar); + var oldCar = !!(info && info.fromCar && lpNow.vehicle_stale); + socWrap.hidden = !plugged || !!(info && info.fromCar && !oldCar); + confirmCar.hidden = !oldCar; + confirmCar.disabled = !!lpNow.read_unavailable || socSaving; + confirmCar.textContent = oldCar ? "Confirm " + Math.round((lpNow.vehicle_soc || 0) * 100) + " % matches the car now" : ""; slider.disabled = !!lpNow.read_unavailable; if (!plugged) return; var cur = (lpNow.current_soc != null) ? Math.max(0, Math.min(100, Math.round(lpNow.current_soc * 100))) : null; diff --git a/web/ev-plan.js b/web/ev-plan.js index d5a9fb4a..22ffda3b 100644 --- a/web/ev-plan.js +++ b/web/ev-plan.js @@ -32,7 +32,7 @@ export function chargingLevels(lp = {}) { ? (lp.vehicle_stale ? `From Car · ${Math.max(1, Math.round((lp.vehicle_soc_age_s || 0) / 60))} min old` : 'From Car · Current') - : unconfirmed ? 'Needs confirmation' : 'Estimated by FTW', + : unconfirmed ? 'Needs confirmation' : lp.soc_confirmed_by_user ? 'Confirmed by user' : 'Estimated by FTW', limit: limit == null ? 'Unknown' : percent(limit), limitSource: limit == null ? 'Not reported by car' : 'Reported by car', fromCar, @@ -40,6 +40,7 @@ export function chargingLevels(lp = {}) { : lp.goal_complete === true ? 'The car has confirmed this goal is complete.' : vehicleGoal && limit == null && lp.soc_source === 'vehicle' ? 'FTW does not know the car’s limit. It reserves charging for up to 100%; the car decides when to stop.' + : unconfirmed ? 'Confirm the current battery level to plan charging.' : target != null ? `Planning from ${fraction(planningNow) ? percent(planningNow) : 'an unknown level'} to ${percent(target)}${unconfirmed ? ' · confirm the current level' : ''}.` : 'Set a goal to plan charging.', }; } @@ -50,6 +51,7 @@ export function chargingPlan(lp, start = Date.now(), end = start + 24 * 3600000) else if (lp.charger?.available === false || lp.power_unavailable) message = 'Waiting for current charger data.'; else if (!lp.plugged_in) message = 'Plug in to plan charging.'; else if (lp.manual_active) message = lp.manual_charge_w === 0 ? 'Charging paused by you.' : 'Charge now is active. Scheduled charging resumes when you return to the plan.'; + else if (lp.soc_source === 'assumed' || lp.soc_source === 'completed') message = 'Confirm the current battery level to plan charging.'; else if (lp.goal_complete === true) message = 'Charging goal complete.'; else if (lp.plan_pending) message = 'Updating charging times…'; else if (lp.plan_outdated) message = 'Charging times unavailable. Your goal is saved.'; @@ -99,11 +101,28 @@ export function createChargingTimeline(doc = document) { note.textContent = 'Planned charging · times can change as FTW replans.'; return { el, update(lp, { start = Date.now(), end = start + 24 * 3600000, label = 'Next 24 hours' } = {}) { const data = chargingPlan(lp, start, end); + const vehicleGoal = lp?.schedule?.finish_at_vehicle_limit === true || lp?.finish_at_vehicle_limit === true; + const target = vehicleGoal + ? (lp?.vehicle_driver && !lp.vehicle_stale ? lp.vehicle_charge_limit : null) + : (lp?.schedule?.soc || lp?.target_soc); + const hasCar = !!lp?.plugged_in && !!lp?.vehicle_driver && fraction(lp.vehicle_soc ?? 0); + const confirmed = fraction(lp?.current_soc) && lp.soc_source !== 'assumed' && lp.soc_source !== 'completed'; + const actual = hasCar && !lp.vehicle_stale ? (lp.vehicle_soc ?? 0) + : confirmed ? lp.current_soc : hasCar ? (lp.vehicle_soc ?? 0) : null; + const source = hasCar && !lp.vehicle_stale ? ' (From Car · Current)' + : confirmed ? (lp.soc_confirmed_by_user ? ' (Confirmed by user)' : ' (FTW estimate)') + : hasCar ? ` (From Car · ${Math.max(1, Math.round((lp.vehicle_soc_age_s || 0) / 60))} min old)` : ''; + const comparison = actual == null ? ' · actual SoC unknown' + : ` ${Math.round(target * 100) >= Math.round(actual * 100) ? '≥' : '<'} actual SoC ${percent(actual)}${source}`; + const targetText = fraction(target) && target > 0 ? `Car SoC target ${percent(target)}${comparison}.` + : vehicleGoal ? 'Car target: car’s charge limit.' : ''; + note.textContent = (data.windows.length ? 'Planned charging · times can change as FTW replans.' : '') + + (targetText ? `${data.windows.length ? ' ' : ''}${targetText}` : ''); title.textContent = label; energy.textContent = data.windows.length ? `${data.approximate ? '≈ ' : ''}${(data.wh / 1000).toFixed(1)} kWh` : ''; message.textContent = data.message; message.hidden = !data.message; graphic.hidden = !data.windows.length; list.hidden = !data.windows.length; - note.hidden = !data.windows.length; more.hidden = data.windows.length <= 2; + note.hidden = !data.windows.length && !targetText; more.hidden = data.windows.length <= 2; track.replaceChildren(); ticks.replaceChildren(); list.replaceChildren(); rest.replaceChildren(); for (let i = 0; i <= 4; i++) { const tick = node('span', '', ticks), ts = start + (end - start) * i / 4; diff --git a/web/ev-plan.test.mjs b/web/ev-plan.test.mjs index e143ffe8..3bf2b4dc 100644 --- a/web/ev-plan.test.mjs +++ b/web/ev-plan.test.mjs @@ -144,3 +144,91 @@ test('mounted car view follows source changes and keeps the slider while editing view.slider.emit('pointerup'); view.update({...car,current_soc:.46},{}); assert.equal(view.slider.value,'46'); }); + + +test('unconfirmed plug-in level cannot expose cached charging windows', () => { + const lp = {...known, vehicle_soc: .87, vehicle_stale: true, vehicle_soc_age_s: 11817, + current_soc: .2, soc_source: 'assumed', schedule: {soc: .9}, plan_windows: windows}; + const info = chargingLevels(lp); + assert.equal(info.now, '87%'); + assert.match(info.source, /197 min old/); + assert.match(info.explanation, /Confirm/); + assert.doesNotMatch(info.explanation, /Planning from 20/); + assert.equal(chargingPlan(lp, now).windows.length, 0); + assert.match(chargingPlan(lp, now).message, /Confirm/); + assert.ok(chargingPlan({...lp, soc_source: 'inferred', current_soc: .86}, now).windows.length); +}); + +test('old car level can be confirmed and corrected without hiding the slider', async () => { + const source=readFileSync(new URL('./app.js',import.meta.url),'utf8'); + const functions=source.slice(source.indexOf('function sliderHeader'),source.indexOf('function buildEvCapacityView')); + const writes=[]; + const api=new Function('document','evPlanUI','buildEvCapacityView','renderEvPlanStatus','evWrite','refreshEvModalAfterWrite','setTimeout',functions+';return buildEvPlanView;')( + doc, Promise.resolve({chargingLevels,createChargingTimeline:()=>createChargingTimeline(doc)}), + ()=>({el:new Element('details'),update(){}}),()=>null, + async(url,opts)=>{writes.push(JSON.parse(opts.body));return {ok:true,json:async()=>({ok:true})};},async()=>{},()=>0); + const lp={...known,vehicle_soc:.86,vehicle_stale:true,vehicle_soc_age_s:11817,current_soc:.2,soc_source:'assumed'}; + const view=api(lp,{}); await new Promise(r=>setImmediate(r)); + assert.equal(view.slider.parentNode.hidden,false); + const button=descendants(view.el).find(el=>el.tag==='button' || el.textContent==='Confirm 86 % matches the car now'); + assert.ok(button); assert.equal(button.hidden,false); + button.emit('click'); await new Promise(r=>setImmediate(r)); + assert.equal(writes[0].soc,.86); + view.update({...lp,vehicle_stale:false,soc_source:'vehicle'},{}); + assert.equal(button.hidden,true); +}); + + +test('car timeline names the saved target and follows target changes', () => { + const view=createChargingTimeline(doc); + view.update({...car,schedule:{soc:.9},plan_windows:windows},{start:now}); + assert.match(text(view.el),/FTW replans\. Car SoC target 90%/); + view.update({...car,schedule:{soc:.8},plan_windows:windows},{start:now}); + assert.match(text(view.el),/Car SoC target 80%/); + assert.doesNotMatch(text(view.el),/Car SoC target 90%/); + view.update({...known,vehicle_stale:true,plan_windows:windows},{start:now}); + assert.match(text(view.el),/Car target: car’s charge limit/); + assert.doesNotMatch(text(view.el),/Car SoC target 100%/); +}); + +test('car target stays visible without a charging plan when current SoC exceeds the goal', () => { + const view=createChargingTimeline(doc); + view.update({...car,current_soc:.86,schedule:{soc:.8},plan_windows:[]},{start:now}); + const note=descendants(view.el).find(el=>el.tag==='small' && el.textContent==='Car SoC target 80% < actual SoC 86% (FTW estimate).'); + assert.ok(note); + assert.equal(note.hidden,false); + assert.match(text(view.el),/No charging planned/); + assert.doesNotMatch(text(view.el),/Planned charging · times can change/); + view.update({...known,goal_complete:true,plan_windows:[]},{start:now}); + assert.equal(note.hidden,false); + assert.equal(note.textContent,'Car SoC target 80% ≥ actual SoC 47% (From Car · Current).'); + view.update({...car,schedule:{},target_soc:0,plan_windows:[]},{start:now}); + assert.equal(note.hidden,true); +}); + + +test('car target comparison follows fresh SoC, equality, old reports and unknown levels', () => { + const view=createChargingTimeline(doc); + const update=lp=>{view.update(lp,{start:now});return text(view.el);}; + assert.match(update({...known,vehicle_soc:.86,schedule:{soc:.9}}),/Car SoC target 90% ≥ actual SoC 86%/); + assert.match(update({...known,vehicle_soc:.9,schedule:{soc:.9}}),/90% ≥ actual SoC 90%/); + assert.match(update({...known,vehicle_soc:.91,schedule:{soc:.9}}),/90% < actual SoC 91%/); + assert.match(update({...known,vehicle_soc:0,schedule:{soc:.9}}),/90% ≥ actual SoC 0%/); + assert.match(update({...known,vehicle_stale:true,vehicle_soc_age_s:3600,soc_source:'assumed',schedule:{soc:.9}}),/actual SoC 47% \(From Car · 60 min old\)/); + assert.match(update({...car,soc_source:'assumed',schedule:{soc:.9}}),/Car SoC target 90% · actual SoC unknown/); + assert.match(update({...known,vehicle_stale:true,current_soc:.86,soc_source:'inferred',schedule:{soc:.9}}),/90% ≥ actual SoC 86% \(FTW estimate\)/); +}); + + +test('SoC comparison identifies a user-confirmed level, a fresh car report and later estimates', () => { + const view=createChargingTimeline(doc); + const lp={...car,current_soc:.86,schedule:{soc:.9},soc_confirmed_by_user:true}; + assert.equal(chargingLevels(lp).source,'Confirmed by user'); + view.update(lp,{start:now}); + assert.match(text(view.el),/actual SoC 86% \(Confirmed by user\)/); + view.update({...lp,current_soc:.87,soc_confirmed_by_user:false},{start:now}); + assert.match(text(view.el),/actual SoC 87% \(FTW estimate\)/); + view.update({...lp,vehicle_driver:'audi-vag',vehicle_soc:.86,vehicle_stale:false,soc_source:'vehicle'},{start:now}); + assert.match(text(view.el),/actual SoC 86% \(From Car · Current\)/); + assert.doesNotMatch(text(view.el),/Confirmed by user/); +}); diff --git a/web/ev-plug-in-view.test.mjs b/web/ev-plug-in-view.test.mjs index 26c5797f..466adae7 100644 --- a/web/ev-plug-in-view.test.mjs +++ b/web/ev-plug-in-view.test.mjs @@ -11,11 +11,11 @@ const view = source.slice( // The plug-in moment (#1059): the modal shows what the box will do and // lets the car's charge level be corrected without a button. -test('the charge-level slider writes on release, with no button', () => { +test('the charge-level slider writes on release, without an extra save step', () => { assert.match(view, /slider\.addEventListener\("change"/); assert.match(view, /\/soc"/); assert.doesNotMatch(view, /Set current charge/); - assert.doesNotMatch(view, /createElement\("button"\)/); + assert.match(view, /confirmCar\.addEventListener\("click"/); // The refetch right after the write is what moves the plan on screen. assert.match(view, /Charge level saved:/); assert.match(view, /refreshEvModalAfterWrite\(\)/); diff --git a/web/index.html b/web/index.html index 7c52f39d..7d219ae1 100644 --- a/web/index.html +++ b/web/index.html @@ -518,7 +518,7 @@