Skip to content

Commit fdc3f30

Browse files
committed
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.
1 parent e65dee4 commit fdc3f30

12 files changed

Lines changed: 437 additions & 41 deletions

internal/api/charging/handler.go

Lines changed: 90 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,55 @@ 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+
resp["billed_source"] = "tesla_charging_history"
228+
return resp
229+
}
230+
231+
// chargeEnergyBaselineLookback excludes the session-start telemetry batch from
232+
// the cumulative energy baseline. See telemetry.chargeEnergyBaselineLookback.
233+
const chargeEnergyBaselineLookback = time.Millisecond
234+
235+
func chargeEnergyBaselineTime(start time.Time) time.Time {
236+
if start.IsZero() {
237+
return start
238+
}
239+
return start.Add(-chargeEnergyBaselineLookback)
240+
}
241+
242+
func (h *ChargingHandler) lookupTeslaBill(ctx context.Context, session *chargingmodel.ChargingSession) *teslamodel.TeslaChargingHistoryEntry {
243+
if h == nil || h.teslaBills == nil || h.vehicles == nil || session == nil {
244+
return nil
245+
}
246+
vehicle, err := h.vehicles.GetByID(ctx, session.VehicleID)
247+
if err != nil || vehicle == nil || vehicle.VIN == "" {
248+
if err != nil {
249+
log.Warn().Err(err).Int64("vehicle_id", session.VehicleID).
250+
Msg("charging: VIN lookup failed for Tesla bill overlay")
251+
}
252+
return nil
253+
}
254+
bill, err := h.teslaBills.FindBestMatch(ctx, vehicle.VIN, session.StartedAt)
255+
if err != nil {
256+
log.Warn().Err(err).Int64("session_id", session.ID).
257+
Msg("charging: Tesla bill match failed")
258+
return nil
259+
}
260+
return bill
186261
}
187262

188263
// enrichLiveCharge computes live values for an in-progress charging session
@@ -199,6 +274,17 @@ func (h *ChargingHandler) enrichLiveCharge(ctx context.Context, session *chargin
199274
}
200275
startSnap := stateToSignalMap(startState)
201276

277+
energyStartSnap := startSnap
278+
if energyStartAt := chargeEnergyBaselineTime(session.StartedAt); !energyStartAt.Equal(session.StartedAt) {
279+
energyStartState, energyErr := h.state.State(ctx, session.VehicleID, energyStartAt)
280+
if energyErr != nil {
281+
log.Warn().Err(energyErr).Int64("session_id", session.ID).
282+
Msg("charging: energy baseline snapshot failed; using inclusive start")
283+
} else {
284+
energyStartSnap = stateToSignalMap(energyStartState)
285+
}
286+
}
287+
202288
currentSnap, err := h.currentSignals(ctx, session.VehicleID)
203289
if err != nil {
204290
return fmt.Errorf("current snapshot: %w", err)
@@ -218,7 +304,7 @@ func (h *ChargingHandler) enrichLiveCharge(ctx context.Context, session *chargin
218304
}
219305

220306
for _, field := range []string{"DCChargingEnergyIn", "ACChargingEnergyIn"} {
221-
startEnergy, startOK := signalFloat(startSnap, field)
307+
startEnergy, startOK := signalFloat(energyStartSnap, field)
222308
currentEnergy, currentOK := signalFloat(currentSnap, field)
223309
if startOK && currentOK && currentEnergy > startEnergy {
224310
delta := safeFloat(currentEnergy - startEnergy)

internal/api/charging/handler_test.go

Lines changed: 114 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,112 @@ 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+
}},
295+
}
296+
297+
rec := httptest.NewRecorder()
298+
h.Get(rec, newChargingRequest(t, "254", ""))
299+
if rec.Code != http.StatusOK {
300+
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
301+
}
302+
303+
var got map[string]interface{}
304+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
305+
t.Fatalf("decode response: %v", err)
306+
}
307+
if got["billed_energy_wh"] != 44490.6 {
308+
t.Fatalf("billed_energy_wh = %v, want 44490.6", got["billed_energy_wh"])
309+
}
310+
if got["billed_cost_decimal"] != 21.80 {
311+
t.Fatalf("billed_cost_decimal = %v, want 21.80", got["billed_cost_decimal"])
312+
}
313+
if got["billed_source"] != "tesla_charging_history" {
314+
t.Fatalf("billed_source = %v", got["billed_source"])
315+
}
316+
if got["total_energy_added_wh"] != 42620.0 {
317+
t.Fatalf("vehicle energy should stay 42620, got %v", got["total_energy_added_wh"])
318+
}
319+
}
320+
321+
type fakeVehicleVIN struct{ vin string }
322+
323+
func (f fakeVehicleVIN) GetByID(_ context.Context, _ int64) (*vehiclemodel.Vehicle, error) {
324+
return &vehiclemodel.Vehicle{VIN: f.vin}, nil
325+
}
326+
327+
type fakeTeslaBills struct {
328+
entry *teslamodel.TeslaChargingHistoryEntry
329+
}
330+
331+
func (f fakeTeslaBills) FindBestMatch(_ context.Context, _ string, _ time.Time) (*teslamodel.TeslaChargingHistoryEntry, error) {
332+
return f.entry, nil
333+
}
334+
335+
func strPtr(v string) *string { return &v }
336+
func floatPtr(v float64) *float64 { return &v }
337+
229338
// TestChargingHandler_Telemetry_ChartMode locks in the chart-mode contract:
230339
// TelemetryReadings MUST call Timeline with an empty CollapseBy slice so
231340
// every change-feed emission becomes one row (forward-folded values appear

internal/api/telemetry/telemetry_sessions_charge_tracking.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,19 @@ func observeChargeEnergyCounter(active *streamingCharge, signals map[string]inte
159159
active.EnergyCounterLastWh = floatPtr(value)
160160
}
161161

162+
// chargeEnergyBaselineLookback excludes the session-start telemetry batch from
163+
// the cumulative energy baseline. Fleet Telemetry often emits DetailedChargeState
164+
// and DCChargingEnergyIn at the same timestamp after charging has already added
165+
// energy; an inclusive State(StartedAt) therefore undercounts the session.
166+
const chargeEnergyBaselineLookback = time.Millisecond
167+
168+
func chargeEnergyBaselineTime(start time.Time) time.Time {
169+
if start.IsZero() {
170+
return start
171+
}
172+
return start.Add(-chargeEnergyBaselineLookback)
173+
}
174+
162175
func snapshotChargeEnergyDelta(
163176
startSnap, endSnap map[string]interface{},
164177
preferredField string,
@@ -615,8 +628,20 @@ func (t *TelemetrySessionTracker) completeChargeLocked(ctx context.Context, vehi
615628
}
616629

617630
// Energy added: difference in one consistent cumulative counter.
631+
// Baseline is strictly before StartedAt so the first in-session
632+
// energy sample is attributed to this session, not subtracted as
633+
// the starting lifetime reading.
634+
energyStartSnap := startSnap
635+
if active.state != nil {
636+
if energyStart, energyStartErr := active.state.State(ctx, vehicleID, chargeEnergyBaselineTime(active.StartTime)); energyStartErr != nil {
637+
log.Warn().Err(energyStartErr).Int64("vehicle_id", vehicleID).
638+
Msg("telemetry: state.State charge energy baseline snapshot failed")
639+
} else {
640+
energyStartSnap = stateToLegacyMap(energyStart)
641+
}
642+
}
618643
if energyDelta, kind, ok := snapshotChargeEnergyDelta(
619-
startSnap,
644+
energyStartSnap,
620645
endSnap,
621646
active.EnergyCounterField,
622647
); ok {

internal/api/telemetry/telemetry_sessions_charge_tracking_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,3 +536,28 @@ func TestTrackCharging_StoppedKeepsSession(t *testing.T) {
536536
t.Fatal("charge session ended on Stopped")
537537
}
538538
}
539+
540+
func TestChargeEnergyBaselineTimeExcludesStartBatch(t *testing.T) {
541+
start := time.Date(2026, 9, 5, 18, 0, 0, 0, time.UTC)
542+
got := chargeEnergyBaselineTime(start)
543+
if !got.Before(start) {
544+
t.Fatalf("baseline %v is not before start %v", got, start)
545+
}
546+
if start.Sub(got) != chargeEnergyBaselineLookback {
547+
t.Fatalf("lookback = %v, want %v", start.Sub(got), chargeEnergyBaselineLookback)
548+
}
549+
startSnap := map[string]interface{}{"DCChargingEnergyIn": 101870.0}
550+
baselineSnap := map[string]interface{}{"DCChargingEnergyIn": 100000.0}
551+
endSnap := map[string]interface{}{"DCChargingEnergyIn": 142620.0}
552+
inclusive, _, _ := snapshotChargeEnergyDelta(startSnap, endSnap, "DCChargingEnergyIn")
553+
exclusive, kind, ok := snapshotChargeEnergyDelta(baselineSnap, endSnap, "DCChargingEnergyIn")
554+
if !ok || kind != signalcounter.ChangeAdvanced {
555+
t.Fatalf("exclusive delta ok=%v kind=%v", ok, kind)
556+
}
557+
if inclusive != 40750 {
558+
t.Fatalf("inclusive delta = %v, want 40750", inclusive)
559+
}
560+
if exclusive != 42620 {
561+
t.Fatalf("exclusive delta = %v, want 42620", exclusive)
562+
}
563+
}

0 commit comments

Comments
 (0)