Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 96 additions & 4 deletions internal/api/charging/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import (
"github.com/ev-dev-labs/teslasync/internal/api/httpx"
"github.com/ev-dev-labs/teslasync/internal/database"
chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging"
tesladb "github.com/ev-dev-labs/teslasync/internal/database/tesla"
vehicledb "github.com/ev-dev-labs/teslasync/internal/database/vehicle"
chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging"
teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla"
vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle"
"github.com/ev-dev-labs/teslasync/internal/signal"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel"
Expand All @@ -33,6 +37,8 @@ type ChargingHandler struct {
charging chargingByIDFetcher
state signal.StateReader
live signal.LiveStateReader
vehicles vehicleVINReader
teslaBills teslaBillFinder
forwardAuthHeader string
// bulkOverride lets tests substitute the bulk store without standing up a
// real *chargingdb.ChargingRepo. Always nil in production.
Expand All @@ -47,6 +53,24 @@ type chargingByIDFetcher interface {
GetByID(ctx context.Context, id int64) (*chargingmodel.ChargingSession, error)
}

// vehicleVINReader is the narrow VIN lookup used to match Supercharger
// invoices onto a measured charging session. *vehicledb.VehicleRepo
// satisfies it; tests leave it nil to skip billed overlay.
type vehicleVINReader interface {
GetByID(ctx context.Context, id int64) (*vehiclemodel.Vehicle, error)
}

// teslaBillFinder locates the Tesla charging-history invoice that overlaps
// a measured session. *tesladb.TeslaChargingHistoryRepo satisfies it.
type teslaBillFinder interface {
FindBestMatch(ctx context.Context, vin string, startedAt time.Time) (*teslamodel.TeslaChargingHistoryEntry, error)
}

var (
_ vehicleVINReader = (*vehicledb.VehicleRepo)(nil)
_ teslaBillFinder = (*tesladb.TeslaChargingHistoryRepo)(nil)
)

func NewChargingHandler(db *database.DB, state signal.StateReader, live signal.LiveStateReader) *ChargingHandler {
repo := chargingdb.NewChargingRepo(db)
return &ChargingHandler{
Expand All @@ -55,6 +79,8 @@ func NewChargingHandler(db *database.DB, state signal.StateReader, live signal.L
charging: repo,
state: state,
live: live,
vehicles: vehicledb.NewVehicleRepo(db),
teslaBills: tesladb.NewTeslaChargingHistoryRepo(db),
}
}

Expand Down Expand Up @@ -154,14 +180,14 @@ func (h *ChargingHandler) Get(w http.ResponseWriter, r *http.Request) {
}
}

httpx.WriteJSON(w, http.StatusOK, chargingSessionResponse(session, live))
httpx.WriteJSON(w, http.StatusOK, chargingSessionResponse(session, live, h.lookupTeslaBill(ctx, session)))
}

// chargingSessionResponse builds the JSON response map for a charging session,
// including the live indicator. This preserves the original JSON field names
// from the ChargingSession model while adding the extra "live" field.
func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool) map[string]interface{} {
return map[string]interface{}{
func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool, bill *teslamodel.TeslaChargingHistoryEntry) map[string]interface{} {
resp := map[string]interface{}{
"id": s.ID,
"vehicle_id": s.VehicleID,
"started_at": s.StartedAt,
Expand All @@ -183,6 +209,61 @@ func chargingSessionResponse(s *chargingmodel.ChargingSession, live bool) map[st
"cable_type": s.CableType,
"live": live,
}
if bill == nil {
return resp
}
if bill.UsageWh != nil {
resp["billed_energy_wh"] = *bill.UsageWh
}
if bill.TotalDue != nil {
resp["billed_cost_decimal"] = *bill.TotalDue
}
if bill.CurrencyCode != nil && *bill.CurrencyCode != "" {
resp["billed_currency"] = *bill.CurrencyCode
}
if bill.RateBase != nil {
resp["billed_rate_per_kwh"] = *bill.RateBase
}
if bill.SiteLocationName != "" {
resp["billed_site"] = bill.SiteLocationName
}
if bill.FeeType != nil && *bill.FeeType != "" {
resp["billed_fee_type"] = *bill.FeeType
}
resp["billed_source"] = "tesla_charging_history"
return resp
}

// chargeEnergyBaselineLookback excludes the session-start telemetry batch from
// the cumulative energy baseline. See telemetry.chargeEnergyBaselineLookback.
const chargeEnergyBaselineLookback = time.Millisecond

func chargeEnergyBaselineTime(start time.Time) time.Time {
if start.IsZero() {
return start
}
return start.Add(-chargeEnergyBaselineLookback)
}

func (h *ChargingHandler) lookupTeslaBill(ctx context.Context, session *chargingmodel.ChargingSession) *teslamodel.TeslaChargingHistoryEntry {
if h == nil || h.teslaBills == nil || h.vehicles == nil || session == nil {
return nil
}
vehicle, err := h.vehicles.GetByID(ctx, session.VehicleID)
if err != nil || vehicle == nil || vehicle.VIN == "" {
if err != nil {
log.Warn().Err(err).Int64("vehicle_id", session.VehicleID).
Msg("charging: VIN lookup failed for Tesla bill overlay")
}
return nil
}
bill, err := h.teslaBills.FindBestMatch(ctx, vehicle.VIN, session.StartedAt)
if err != nil {
log.Warn().Err(err).Int64("session_id", session.ID).
Msg("charging: Tesla bill match failed")
return nil
}
return bill
}

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

energyStartSnap := startSnap
if energyStartAt := chargeEnergyBaselineTime(session.StartedAt); !energyStartAt.Equal(session.StartedAt) {
energyStartState, energyErr := h.state.State(ctx, session.VehicleID, energyStartAt)
if energyErr != nil {
log.Warn().Err(energyErr).Int64("session_id", session.ID).
Msg("charging: energy baseline snapshot failed; using inclusive start")
} else {
energyStartSnap = stateToSignalMap(energyStartState)
}
}

currentSnap, err := h.currentSignals(ctx, session.VehicleID)
if err != nil {
return fmt.Errorf("current snapshot: %w", err)
Expand All @@ -218,7 +310,7 @@ func (h *ChargingHandler) enrichLiveCharge(ctx context.Context, session *chargin
}

for _, field := range []string{"DCChargingEnergyIn", "ACChargingEnergyIn"} {
startEnergy, startOK := signalFloat(startSnap, field)
startEnergy, startOK := signalFloat(energyStartSnap, field)
currentEnergy, currentOK := signalFloat(currentSnap, field)
if startOK && currentOK && currentEnergy > startEnergy {
delta := safeFloat(currentEnergy - startEnergy)
Expand Down
127 changes: 122 additions & 5 deletions internal/api/charging/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (

"github.com/ev-dev-labs/teslasync/internal/api/apibulk"
chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging"
teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla"
vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle"
"github.com/ev-dev-labs/teslasync/internal/signal"
"github.com/go-chi/chi/v5"
)
Expand Down Expand Up @@ -172,11 +174,12 @@ func TestChargingHandler_Latest_UsesNowSnapshot(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
// enrichLiveCharge issues two State() calls: [0] start snapshot, [1] current.
if len(calls) < 2 {
t.Fatalf("State call count = %d, want at least 2 (start + current)", len(calls))
// enrichLiveCharge issues three State() calls: [0] start snapshot,
// [1] exclusive energy baseline, [2] current (via LiveState fallback).
if len(calls) < 3 {
t.Fatalf("State call count = %d, want at least 3 (start + energy baseline + current)", len(calls))
}
cur := calls[1]
cur := calls[len(calls)-1]
if cur.vehicleID != session.VehicleID {
t.Fatalf("State[1].vehicleID = %d, want %d", cur.vehicleID, session.VehicleID)
}
Expand All @@ -191,7 +194,7 @@ func TestChargingHandler_LiveDCSessionUsesCanonicalWhAndW(t *testing.T) {
session := inProgressChargingSession(11, 42, startTs)
fake := &fakeStateReader{
stateFn: func(_ context.Context, _ int64, at time.Time) (signal.State, error) {
if at.Equal(startTs) {
if !at.After(startTs) {
return signal.State{
"DCChargingEnergyIn": 100000.0,
"BatteryLevel": 40.0,
Expand Down Expand Up @@ -226,6 +229,120 @@ func TestChargingHandler_LiveDCSessionUsesCanonicalWhAndW(t *testing.T) {
}
}

func TestChargingHandler_LiveEnergyUsesExclusiveStartBaseline(t *testing.T) {
startTs := time.Date(2026, 9, 5, 18, 0, 0, 0, time.UTC)
session := inProgressChargingSession(254, 42, startTs)
fake := &fakeStateReader{
stateFn: func(_ context.Context, _ int64, at time.Time) (signal.State, error) {
if at.Equal(startTs) {
return signal.State{
"DCChargingEnergyIn": 101870.0,
"BatteryLevel": 19.0,
}, nil
}
if at.Before(startTs) {
return signal.State{
"DCChargingEnergyIn": 100000.0,
"BatteryLevel": 19.0,
}, nil
}
return signal.State{
"DCChargingEnergyIn": 142620.0,
"DCChargingPower": 197000.0,
"BatteryLevel": 79.0,
}, nil
},
}
charging := &fakeChargingByIDFetcher{session: session}
h := &ChargingHandler{state: fake, live: newTestLiveStateReader(fake), charging: charging}

rec := httptest.NewRecorder()
h.Get(rec, newChargingRequest(t, "254", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}

var got map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
energy, ok := got["total_energy_added_wh"].(float64)
if !ok || energy != 42620 {
t.Fatalf("total_energy_added_wh = %v, want 42620 (exclusive baseline)", got["total_energy_added_wh"])
}
}

func TestChargingHandler_OverlaysTeslaSuperchargerBill(t *testing.T) {
startTs := time.Date(2026, 9, 5, 18, 0, 0, 0, time.UTC)
endTs := startTs.Add(28 * time.Minute)
energy := 42620.0
cost := 20.88
session := completedChargingSession(254, 42, startTs, endTs)
session.TotalEnergyAddedWh = &energy
session.CostDecimal = &cost

usage := 44490.6
due := 21.80
h := &ChargingHandler{
charging: &fakeChargingByIDFetcher{session: session},
vehicles: fakeVehicleVIN{vin: "5YJ3E1EA7KF000001"},
teslaBills: fakeTeslaBills{entry: &teslamodel.TeslaChargingHistoryEntry{
UsageWh: &usage,
TotalDue: &due,
CurrencyCode: strPtr("USD"),
RateBase: floatPtr(0.49),
SiteLocationName: "Hayward, CA",
FeeType: strPtr("CHARGING"),
}},
}

rec := httptest.NewRecorder()
h.Get(rec, newChargingRequest(t, "254", ""))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}

var got map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got["billed_energy_wh"] != 44490.6 {
t.Fatalf("billed_energy_wh = %v, want 44490.6", got["billed_energy_wh"])
}
if got["billed_cost_decimal"] != 21.80 {
t.Fatalf("billed_cost_decimal = %v, want 21.80", got["billed_cost_decimal"])
}
if got["billed_source"] != "tesla_charging_history" {
t.Fatalf("billed_source = %v", got["billed_source"])
}
if got["billed_site"] != "Hayward, CA" {
t.Fatalf("billed_site = %v", got["billed_site"])
}
if got["billed_fee_type"] != "CHARGING" {
t.Fatalf("billed_fee_type = %v", got["billed_fee_type"])
}
if got["total_energy_added_wh"] != 42620.0 {
t.Fatalf("vehicle energy should stay 42620, got %v", got["total_energy_added_wh"])
}
}

type fakeVehicleVIN struct{ vin string }

func (f fakeVehicleVIN) GetByID(_ context.Context, _ int64) (*vehiclemodel.Vehicle, error) {
return &vehiclemodel.Vehicle{VIN: f.vin}, nil
}

type fakeTeslaBills struct {
entry *teslamodel.TeslaChargingHistoryEntry
}

func (f fakeTeslaBills) FindBestMatch(_ context.Context, _ string, _ time.Time) (*teslamodel.TeslaChargingHistoryEntry, error) {
return f.entry, nil
}

func strPtr(v string) *string { return &v }
func floatPtr(v float64) *float64 { return &v }

// TestChargingHandler_Telemetry_ChartMode locks in the chart-mode contract:
// TelemetryReadings MUST call Timeline with an empty CollapseBy slice so
// every change-feed emission becomes one row (forward-folded values appear
Expand Down
Loading
Loading