Skip to content

Commit 3f0d05f

Browse files
authored
Fixes charging (#104)
* feat(charging): overlay Tesla bills, fix energy baseline Two related charging-accuracy fixes: 1. Charge energy delta baselines now use State() strictly before StartedAt instead of at StartedAt, since Fleet Telemetry can emit the session-start batch after energy has already begun accumulating, causing undercounted energy in both the live handler and the completed-session tracker. 2. New Tesla Supercharger invoice overlay: ChargingHandler.Get resolves the vehicle's VIN and matches it against tesla_charging_history via a new FindBestMatch repo method (closest charge_start_datetime within a 2h window). When a match is found, billed energy/cost/currency/rate are added to the response and preferred for display on the frontend KPI tiles, with vehicle-measured energy shown as a secondary subtitle. * Fix FSD trip meter glitch attribution Treat spurious trip-meter resets and implausible counter jumps as discontinuities instead of driven distance. Adds reset/restore cursor logic and tests so include_fields zero snap-backs do not inflate FSD or driving aggregates. * Add Grok dynamics briefing Adds a live Grok powertrain read to Driving Dynamics using motor and chassis signals, with interpretation logic and tests. Also adds shared client-side pagination for FSD insight lists and DataTable-backed FSD tables. * Add telemetry honesty insights Adds honesty-focused UI across the fleet dashboard, status bar, charging, battery, driving, and FSD views. Surfaces Supercharger bill site/fee metadata, preserves signal ingest timestamps for Tesla physics clocks, paginates physics evidence instead of truncating it, and fixes nav highlighting to prefer the most specific active route. * feat(driving): scope dynamics history by trip Add trip selection and date-scoped motor history, preserve active drives, and evenly sample backend history responses across the selected window. * fix(charging): correct history attribution Preserve and backfill charge coordinates without creating geofences from stale GPS, use Tesla billed energy for tariff pricing, and resolve date windows in the vehicle timezone. * Fix sparse FSD drive detail attribution Expand the drive detail FSD insights range to look around 24 hours so sparse counter bookends are included, preventing unknown/blank attribution for valid deltas. Adds backend and frontend regression coverage for the wider range behavior. * Scope FSD insights to drive detail Adds drive_id support for FSD insights with a 7-day bookend lookup and focused attribution, while filtering negligible fidget drives so they do not steal sparse counter deltas. Updates drive detail and list UI to use the drive-scoped hook and omit unknown FSD badges.
1 parent e65dee4 commit 3f0d05f

122 files changed

Lines changed: 5729 additions & 515 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/api/charging/handler.go

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import (
1212
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
1313
"github.com/ev-dev-labs/teslasync/internal/database"
1414
chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging"
15+
tesladb "github.com/ev-dev-labs/teslasync/internal/database/tesla"
16+
vehicledb "github.com/ev-dev-labs/teslasync/internal/database/vehicle"
1517
chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging"
18+
teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla"
19+
vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle"
1620
"github.com/ev-dev-labs/teslasync/internal/signal"
1721
"github.com/rs/zerolog/log"
1822
"go.opentelemetry.io/otel"
@@ -33,6 +37,8 @@ type ChargingHandler struct {
3337
charging chargingByIDFetcher
3438
state signal.StateReader
3539
live signal.LiveStateReader
40+
vehicles vehicleVINReader
41+
teslaBills teslaBillFinder
3642
forwardAuthHeader string
3743
// bulkOverride lets tests substitute the bulk store without standing up a
3844
// real *chargingdb.ChargingRepo. Always nil in production.
@@ -47,6 +53,24 @@ type chargingByIDFetcher interface {
4753
GetByID(ctx context.Context, id int64) (*chargingmodel.ChargingSession, error)
4854
}
4955

56+
// vehicleVINReader is the narrow VIN lookup used to match Supercharger
57+
// invoices onto a measured charging session. *vehicledb.VehicleRepo
58+
// satisfies it; tests leave it nil to skip billed overlay.
59+
type vehicleVINReader interface {
60+
GetByID(ctx context.Context, id int64) (*vehiclemodel.Vehicle, error)
61+
}
62+
63+
// teslaBillFinder locates the Tesla charging-history invoice that overlaps
64+
// a measured session. *tesladb.TeslaChargingHistoryRepo satisfies it.
65+
type teslaBillFinder interface {
66+
FindBestMatch(ctx context.Context, vin string, startedAt time.Time) (*teslamodel.TeslaChargingHistoryEntry, error)
67+
}
68+
69+
var (
70+
_ vehicleVINReader = (*vehicledb.VehicleRepo)(nil)
71+
_ teslaBillFinder = (*tesladb.TeslaChargingHistoryRepo)(nil)
72+
)
73+
5074
func NewChargingHandler(db *database.DB, state signal.StateReader, live signal.LiveStateReader) *ChargingHandler {
5175
repo := chargingdb.NewChargingRepo(db)
5276
return &ChargingHandler{
@@ -55,6 +79,8 @@ func NewChargingHandler(db *database.DB, state signal.StateReader, live signal.L
5579
charging: repo,
5680
state: state,
5781
live: live,
82+
vehicles: vehicledb.NewVehicleRepo(db),
83+
teslaBills: tesladb.NewTeslaChargingHistoryRepo(db),
5884
}
5985
}
6086

@@ -154,14 +180,14 @@ func (h *ChargingHandler) Get(w http.ResponseWriter, r *http.Request) {
154180
}
155181
}
156182

157-
httpx.WriteJSON(w, http.StatusOK, chargingSessionResponse(session, live))
183+
httpx.WriteJSON(w, http.StatusOK, chargingSessionResponse(session, live, h.lookupTeslaBill(ctx, session)))
158184
}
159185

160186
// chargingSessionResponse builds the JSON response map for a charging session,
161187
// including the live indicator. This preserves the original JSON field names
162188
// from the ChargingSession model while adding the extra "live" field.
163-
func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool) map[string]interface{} {
164-
return map[string]interface{}{
189+
func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool, bill *teslamodel.TeslaChargingHistoryEntry) map[string]interface{} {
190+
resp := map[string]interface{}{
165191
"id": s.ID,
166192
"vehicle_id": s.VehicleID,
167193
"started_at": s.StartedAt,
@@ -183,6 +209,61 @@ func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool) map[st
183209
"cable_type": s.CableType,
184210
"live": live,
185211
}
212+
if bill == nil {
213+
return resp
214+
}
215+
if bill.UsageWh != nil {
216+
resp["billed_energy_wh"] = *bill.UsageWh
217+
}
218+
if bill.TotalDue != nil {
219+
resp["billed_cost_decimal"] = *bill.TotalDue
220+
}
221+
if bill.CurrencyCode != nil && *bill.CurrencyCode != "" {
222+
resp["billed_currency"] = *bill.CurrencyCode
223+
}
224+
if bill.RateBase != nil {
225+
resp["billed_rate_per_kwh"] = *bill.RateBase
226+
}
227+
if bill.SiteLocationName != "" {
228+
resp["billed_site"] = bill.SiteLocationName
229+
}
230+
if bill.FeeType != nil && *bill.FeeType != "" {
231+
resp["billed_fee_type"] = *bill.FeeType
232+
}
233+
resp["billed_source"] = "tesla_charging_history"
234+
return resp
235+
}
236+
237+
// chargeEnergyBaselineLookback excludes the session-start telemetry batch from
238+
// the cumulative energy baseline. See telemetry.chargeEnergyBaselineLookback.
239+
const chargeEnergyBaselineLookback = time.Millisecond
240+
241+
func chargeEnergyBaselineTime(start time.Time) time.Time {
242+
if start.IsZero() {
243+
return start
244+
}
245+
return start.Add(-chargeEnergyBaselineLookback)
246+
}
247+
248+
func (h *ChargingHandler) lookupTeslaBill(ctx context.Context, session *chargingmodel.ChargingSession) *teslamodel.TeslaChargingHistoryEntry {
249+
if h == nil || h.teslaBills == nil || h.vehicles == nil || session == nil {
250+
return nil
251+
}
252+
vehicle, err := h.vehicles.GetByID(ctx, session.VehicleID)
253+
if err != nil || vehicle == nil || vehicle.VIN == "" {
254+
if err != nil {
255+
log.Warn().Err(err).Int64("vehicle_id", session.VehicleID).
256+
Msg("charging: VIN lookup failed for Tesla bill overlay")
257+
}
258+
return nil
259+
}
260+
bill, err := h.teslaBills.FindBestMatch(ctx, vehicle.VIN, session.StartedAt)
261+
if err != nil {
262+
log.Warn().Err(err).Int64("session_id", session.ID).
263+
Msg("charging: Tesla bill match failed")
264+
return nil
265+
}
266+
return bill
186267
}
187268

188269
// enrichLiveCharge computes live values for an in-progress charging session
@@ -199,6 +280,17 @@ func (h *ChargingHandler) enrichLiveCharge(ctx context.Context, session *chargin
199280
}
200281
startSnap := stateToSignalMap(startState)
201282

283+
energyStartSnap := startSnap
284+
if energyStartAt := chargeEnergyBaselineTime(session.StartedAt); !energyStartAt.Equal(session.StartedAt) {
285+
energyStartState, energyErr := h.state.State(ctx, session.VehicleID, energyStartAt)
286+
if energyErr != nil {
287+
log.Warn().Err(energyErr).Int64("session_id", session.ID).
288+
Msg("charging: energy baseline snapshot failed; using inclusive start")
289+
} else {
290+
energyStartSnap = stateToSignalMap(energyStartState)
291+
}
292+
}
293+
202294
currentSnap, err := h.currentSignals(ctx, session.VehicleID)
203295
if err != nil {
204296
return fmt.Errorf("current snapshot: %w", err)
@@ -218,7 +310,7 @@ func (h *ChargingHandler) enrichLiveCharge(ctx context.Context, session *chargin
218310
}
219311

220312
for _, field := range []string{"DCChargingEnergyIn", "ACChargingEnergyIn"} {
221-
startEnergy, startOK := signalFloat(startSnap, field)
313+
startEnergy, startOK := signalFloat(energyStartSnap, field)
222314
currentEnergy, currentOK := signalFloat(currentSnap, field)
223315
if startOK && currentOK && currentEnergy > startEnergy {
224316
delta := safeFloat(currentEnergy - startEnergy)

internal/api/charging/handler_test.go

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212

1313
"github.com/ev-dev-labs/teslasync/internal/api/apibulk"
1414
chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging"
15+
teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla"
16+
vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle"
1517
"github.com/ev-dev-labs/teslasync/internal/signal"
1618
"github.com/go-chi/chi/v5"
1719
)
@@ -172,11 +174,12 @@ func TestChargingHandler_Latest_UsesNowSnapshot(t *testing.T) {
172174
if rec.Code != http.StatusOK {
173175
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
174176
}
175-
// enrichLiveCharge issues two State() calls: [0] start snapshot, [1] current.
176-
if len(calls) < 2 {
177-
t.Fatalf("State call count = %d, want at least 2 (start + current)", len(calls))
177+
// enrichLiveCharge issues three State() calls: [0] start snapshot,
178+
// [1] exclusive energy baseline, [2] current (via LiveState fallback).
179+
if len(calls) < 3 {
180+
t.Fatalf("State call count = %d, want at least 3 (start + energy baseline + current)", len(calls))
178181
}
179-
cur := calls[1]
182+
cur := calls[len(calls)-1]
180183
if cur.vehicleID != session.VehicleID {
181184
t.Fatalf("State[1].vehicleID = %d, want %d", cur.vehicleID, session.VehicleID)
182185
}
@@ -191,7 +194,7 @@ func TestChargingHandler_LiveDCSessionUsesCanonicalWhAndW(t *testing.T) {
191194
session := inProgressChargingSession(11, 42, startTs)
192195
fake := &fakeStateReader{
193196
stateFn: func(_ context.Context, _ int64, at time.Time) (signal.State, error) {
194-
if at.Equal(startTs) {
197+
if !at.After(startTs) {
195198
return signal.State{
196199
"DCChargingEnergyIn": 100000.0,
197200
"BatteryLevel": 40.0,
@@ -226,6 +229,120 @@ func TestChargingHandler_LiveDCSessionUsesCanonicalWhAndW(t *testing.T) {
226229
}
227230
}
228231

232+
func TestChargingHandler_LiveEnergyUsesExclusiveStartBaseline(t *testing.T) {
233+
startTs := time.Date(2026, 9, 5, 18, 0, 0, 0, time.UTC)
234+
session := inProgressChargingSession(254, 42, startTs)
235+
fake := &fakeStateReader{
236+
stateFn: func(_ context.Context, _ int64, at time.Time) (signal.State, error) {
237+
if at.Equal(startTs) {
238+
return signal.State{
239+
"DCChargingEnergyIn": 101870.0,
240+
"BatteryLevel": 19.0,
241+
}, nil
242+
}
243+
if at.Before(startTs) {
244+
return signal.State{
245+
"DCChargingEnergyIn": 100000.0,
246+
"BatteryLevel": 19.0,
247+
}, nil
248+
}
249+
return signal.State{
250+
"DCChargingEnergyIn": 142620.0,
251+
"DCChargingPower": 197000.0,
252+
"BatteryLevel": 79.0,
253+
}, nil
254+
},
255+
}
256+
charging := &fakeChargingByIDFetcher{session: session}
257+
h := &ChargingHandler{state: fake, live: newTestLiveStateReader(fake), charging: charging}
258+
259+
rec := httptest.NewRecorder()
260+
h.Get(rec, newChargingRequest(t, "254", ""))
261+
if rec.Code != http.StatusOK {
262+
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
263+
}
264+
265+
var got map[string]interface{}
266+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
267+
t.Fatalf("decode response: %v", err)
268+
}
269+
energy, ok := got["total_energy_added_wh"].(float64)
270+
if !ok || energy != 42620 {
271+
t.Fatalf("total_energy_added_wh = %v, want 42620 (exclusive baseline)", got["total_energy_added_wh"])
272+
}
273+
}
274+
275+
func TestChargingHandler_OverlaysTeslaSuperchargerBill(t *testing.T) {
276+
startTs := time.Date(2026, 9, 5, 18, 0, 0, 0, time.UTC)
277+
endTs := startTs.Add(28 * time.Minute)
278+
energy := 42620.0
279+
cost := 20.88
280+
session := completedChargingSession(254, 42, startTs, endTs)
281+
session.TotalEnergyAddedWh = &energy
282+
session.CostDecimal = &cost
283+
284+
usage := 44490.6
285+
due := 21.80
286+
h := &ChargingHandler{
287+
charging: &fakeChargingByIDFetcher{session: session},
288+
vehicles: fakeVehicleVIN{vin: "5YJ3E1EA7KF000001"},
289+
teslaBills: fakeTeslaBills{entry: &teslamodel.TeslaChargingHistoryEntry{
290+
UsageWh: &usage,
291+
TotalDue: &due,
292+
CurrencyCode: strPtr("USD"),
293+
RateBase: floatPtr(0.49),
294+
SiteLocationName: "Hayward, CA",
295+
FeeType: strPtr("CHARGING"),
296+
}},
297+
}
298+
299+
rec := httptest.NewRecorder()
300+
h.Get(rec, newChargingRequest(t, "254", ""))
301+
if rec.Code != http.StatusOK {
302+
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
303+
}
304+
305+
var got map[string]interface{}
306+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
307+
t.Fatalf("decode response: %v", err)
308+
}
309+
if got["billed_energy_wh"] != 44490.6 {
310+
t.Fatalf("billed_energy_wh = %v, want 44490.6", got["billed_energy_wh"])
311+
}
312+
if got["billed_cost_decimal"] != 21.80 {
313+
t.Fatalf("billed_cost_decimal = %v, want 21.80", got["billed_cost_decimal"])
314+
}
315+
if got["billed_source"] != "tesla_charging_history" {
316+
t.Fatalf("billed_source = %v", got["billed_source"])
317+
}
318+
if got["billed_site"] != "Hayward, CA" {
319+
t.Fatalf("billed_site = %v", got["billed_site"])
320+
}
321+
if got["billed_fee_type"] != "CHARGING" {
322+
t.Fatalf("billed_fee_type = %v", got["billed_fee_type"])
323+
}
324+
if got["total_energy_added_wh"] != 42620.0 {
325+
t.Fatalf("vehicle energy should stay 42620, got %v", got["total_energy_added_wh"])
326+
}
327+
}
328+
329+
type fakeVehicleVIN struct{ vin string }
330+
331+
func (f fakeVehicleVIN) GetByID(_ context.Context, _ int64) (*vehiclemodel.Vehicle, error) {
332+
return &vehiclemodel.Vehicle{VIN: f.vin}, nil
333+
}
334+
335+
type fakeTeslaBills struct {
336+
entry *teslamodel.TeslaChargingHistoryEntry
337+
}
338+
339+
func (f fakeTeslaBills) FindBestMatch(_ context.Context, _ string, _ time.Time) (*teslamodel.TeslaChargingHistoryEntry, error) {
340+
return f.entry, nil
341+
}
342+
343+
func strPtr(v string) *string { return &v }
344+
func floatPtr(v float64) *float64 { return &v }
345+
229346
// TestChargingHandler_Telemetry_ChartMode locks in the chart-mode contract:
230347
// TelemetryReadings MUST call Timeline with an empty CollapseBy slice so
231348
// every change-feed emission becomes one row (forward-folded values appear

0 commit comments

Comments
 (0)