Skip to content

Commit 55a2804

Browse files
atulmguptaCopilot
andauthored
Muse spark (#105)
* 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. * feat: add charging autopilot, OCPP, TCO ledger, and session insights Wire always-on charge autopilot, shared-charger queue planning, OCPP charge points, Tesla bill variance, session share reports, TCO fixed ledger, and related warranty, silence, and trip copilot surfaces. Register new i18n namespaces (autopilot, chargeQueue, ocpp) so the strict namespace audit and Docker web build pass. Fix TypeScript i18n count types, geofence hook typing, and AutomationFull.conflicts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(charging): add charge autopilot Add per-vehicle autopilot profiles, TOU-based charge previews, health guardrails, and savings tracking. Integrate the configuration and preview panel into Smart Charge with API and UI tests. * feat: expand charging and automation insights Add next-charge recommendations comparing home TOU costs with Supercharger quotes, surfaced on dashboards and vehicle details. Expand automation, routine, and alert template catalogs with category filtering, localization, and tests. * Expand automation and alert templates Adds ecosystem automation presets and routine templates covering more Tesla commands, locations, charging, climate, media, and security scenarios. Updates catalogue tests and alert rule template coverage thresholds for the expanded preset set. * feat: storm guardian severe-weather auto-prep Watches Open-Meteo forecasts at each armed vehicle's home coords and pre-charges before storms: hourly evaluator raises the charge limit on fresh warnings when SOC is below target. Includes status/config/events API, StormGuardPanel on Emergency Resilience, and backend+UI tests. * feat: cabin comfort autopilot with calendar-aware preconditioning Polls a per-vehicle ICS subscription, finds the next offsite event in the lead window, and preconditions once per event (5-minute evaluator). Includes next/config/now/runs API, ComfortPanel on Automations, offline refusal for the actuation endpoint, and backend+UI tests. * feat: warranty claim autopilot drafts service tickets BuildClaimDraft assembles a ready-to-paste ticket from the owner issue, live warranty countdown, matched TSBs, ranked symptoms, and evidence. New claim-draft endpoint, ClaimDraftPanel on Service Intelligence, and backend+UI tests. * feat: ghost-driver alerting for unknown-driver drives Flags recent drives that fit no named profile and sit far from their cluster centroid (median-adaptive score, deterministic pure function). New GET /driver-attribution/ghost-drives endpoint plus a GhostDrivesPanel on the Driver Attribution page with alert banner, score table, and one-click labelling that re-anchors the cluster. * feat: supercharger wait-time oracle from fleet history Predicts queue waits per site and arrival time: hour-of-week demand from tesla_charging_sessions feeds Little's-law load plus an Erlang-C queue model (stalls estimated from peak overlap, documented lower bound). Read-only GET /waitoracle/sites + /forecast; WaitOraclePanel on the fleet sessions page with site/arrival pickers, verdict hero, best-hour suggestion, and the full-day wait curve. * fix(fsd): accept quantized FSD mileage ticks Allow one Tesla 1-mile wire quantum above the physical travel threshold so valid 10-second counter samples are retained, while still rejecting implausible jumps. Add unit and drive analytics regression coverage. * fix: align wait-oracle, ghost, claim, comfort, storm with conventions Route new panels through Icons, SI seconds for wait forecasts, queryPolicy/scopedPath on hooks, ICS host allowlist, catalog i18n keys, and useDataState fatalError surfaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(journey): trip sessions with status machine and versioned plans Slice 1 of Journey Autopilot: persistent journey_sessions (planned/active/paused/completed/aborted, one active per vehicle, race-safe conditional transitions) plus append-only versioned plans for future replan diffs. New /journeys page with session manager: plan form, status filter, lifecycle actions driven by server next_statuses, plan history. Also regenerates routetemplates_gen.go, absorbing 19 previously unregenerated routes so the freshness gate passes. * docs: redesign site and add feature catalogue Refresh the VitePress theme, navigation, landing pages, and onboarding. Add generated screen catalogues, Markdown page tools, and new homepage visuals. * fix(journey): clamp limits and localize status Cap journey list limits at 100 with safe defaults and tests. Localize status labels and inherit theme-aware icon colors. * feat(i18n): add journey status labels Add English translations for planned, active, paused, completed, and aborted journey statuses. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3f0d05f commit 55a2804

323 files changed

Lines changed: 27015 additions & 831 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.

cmd/ocpp-server/main.go

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@
1616
// OCPP_LISTEN_ADDR (default :9090)
1717
// OCPP_HEARTBEAT_INTERVAL (default 300s) — interval returned in BootNotification
1818
// OCPP_READ_DEADLINE (default 900s) — closes the WS if no message within this window
19+
// OCPP_DB_HOST (default "") — empty selects the zero-config
20+
// in-memory session store; set it to persist via
21+
// Postgres (internal/database/ocpp.Store).
22+
// OCPP_DB_PORT (default 5432)
23+
// OCPP_DB_USER (default teslasync)
24+
// OCPP_DB_PASSWORD (default teslasync)
25+
// OCPP_DB_NAME (default teslasync)
26+
// OCPP_DB_SSLMODE (default disable)
1927
//
20-
// Persistence: the foundation PR uses the in-memory session store
21-
// (internal/ocpp.MemorySessionStore). A Postgres-backed store can be
22-
// wired here in a follow-up without touching the protocol layer.
28+
// Persistence: Postgres when OCPP_DB_HOST is set, otherwise the
29+
// in-memory session store. The dispatcher only sees the
30+
// ocpp.SessionStore port, so the protocol layer is untouched either way.
2331
package main
2432

2533
import (
@@ -30,12 +38,16 @@ import (
3038
"net/http"
3139
"os"
3240
"os/signal"
41+
"strconv"
3342
"syscall"
3443
"time"
3544

3645
"github.com/rs/zerolog"
3746
"github.com/rs/zerolog/log"
3847

48+
appconfig "github.com/ev-dev-labs/teslasync/internal/config"
49+
"github.com/ev-dev-labs/teslasync/internal/database"
50+
dbocpp "github.com/ev-dev-labs/teslasync/internal/database/ocpp"
3951
"github.com/ev-dev-labs/teslasync/internal/ocpp"
4052
)
4153

@@ -59,18 +71,53 @@ type config struct {
5971
listenAddr string
6072
heartbeatInterval time.Duration
6173
readDeadline time.Duration
74+
dbHost string
75+
dbPort int
76+
dbUser string
77+
dbPassword string
78+
dbName string
79+
dbSSLMode string
6280
}
6381

6482
// loadConfig resolves the CSMS configuration from the environment,
6583
// falling back to spec-sensible defaults for anything unset or blank.
84+
// An empty OCPP_DB_HOST selects the in-memory session store.
6685
func loadConfig() config {
6786
return config{
6887
listenAddr: envOr("OCPP_LISTEN_ADDR", defaultListenAddr),
6988
heartbeatInterval: envDurationOr("OCPP_HEARTBEAT_INTERVAL", defaultHeartbeatInterval),
7089
readDeadline: envDurationOr("OCPP_READ_DEADLINE", defaultReadDeadline),
90+
dbHost: os.Getenv("OCPP_DB_HOST"),
91+
dbPort: envIntOr("OCPP_DB_PORT", 5432),
92+
dbUser: envOr("OCPP_DB_USER", "teslasync"),
93+
dbPassword: envOr("OCPP_DB_PASSWORD", "teslasync"),
94+
dbName: envOr("OCPP_DB_NAME", "teslasync"),
95+
dbSSLMode: envOr("OCPP_DB_SSLMODE", "disable"),
7196
}
7297
}
7398

99+
// openSessionStore resolves the persistence backend: Postgres when
100+
// OCPP_DB_HOST is set, otherwise the zero-config in-memory store. It
101+
// returns a close func the caller must defer (a no-op for memory).
102+
func openSessionStore(ctx context.Context, cfg config) (ocpp.SessionStore, func(), error) {
103+
if cfg.dbHost == "" {
104+
return ocpp.NewMemorySessionStore(), func() {}, nil
105+
}
106+
db, err := database.New(ctx, appconfig.DatabaseConfig{
107+
Host: cfg.dbHost,
108+
Port: cfg.dbPort,
109+
User: cfg.dbUser,
110+
Password: cfg.dbPassword,
111+
Name: cfg.dbName,
112+
SSLMode: cfg.dbSSLMode,
113+
})
114+
if err != nil {
115+
return nil, nil, fmt.Errorf("connect database: %w", err)
116+
}
117+
log.Info().Str("host", cfg.dbHost).Str("db", cfg.dbName).Msg("OCPP CSMS using Postgres session store")
118+
return dbocpp.NewStore(db), db.Close, nil
119+
}
120+
74121
func main() {
75122
zerolog.TimeFieldFormat = time.RFC3339
76123
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
@@ -93,18 +140,22 @@ func main() {
93140
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
94141
defer cancel()
95142

96-
if err := run(ctx, newServer(cfg), ln, shutdownTimeout); err != nil {
143+
store, closeStore, err := openSessionStore(ctx, cfg)
144+
if err != nil {
145+
log.Fatal().Err(err).Msg("OCPP server failed to open session store")
146+
}
147+
defer closeStore()
148+
149+
if err := run(ctx, newServer(cfg, store), ln, shutdownTimeout); err != nil {
97150
log.Fatal().Err(err).Msg("OCPP server failed")
98151
}
99152
}
100153

101154
// newServer builds the HTTP server that fronts the OCPP CSMS: a
102155
// /healthz liveness probe plus the WebSocket transport mounted at
103-
// /ocpp/. Persistence uses the zero-config in-memory session store;
104-
// a Postgres-backed store can be swapped in without changing this
105-
// wiring or the protocol layer.
106-
func newServer(cfg config) *http.Server {
107-
store := ocpp.NewMemorySessionStore()
156+
// /ocpp/. The session store is injected so main can select the
157+
// Postgres or in-memory backend without touching this wiring.
158+
func newServer(cfg config, store ocpp.SessionStore) *http.Server {
108159
dispatcher := ocpp.NewDispatcher(store, cfg.heartbeatInterval)
109160
ocppServer := ocpp.NewServer(dispatcher, cfg.readDeadline)
110161
return &http.Server{
@@ -171,6 +222,19 @@ func envOr(key, def string) string {
171222
return def
172223
}
173224

225+
func envIntOr(key string, def int) int {
226+
raw := os.Getenv(key)
227+
if raw == "" {
228+
return def
229+
}
230+
n, err := strconv.Atoi(raw)
231+
if err != nil {
232+
log.Warn().Err(err).Str("key", key).Str("raw", raw).Msg("invalid integer, using default")
233+
return def
234+
}
235+
return n
236+
}
237+
174238
func envDurationOr(key string, def time.Duration) time.Duration {
175239
raw := os.Getenv(key)
176240
if raw == "" {

cmd/ocpp-server/main_test.go

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"time"
1313

1414
"github.com/gorilla/websocket"
15+
16+
"github.com/ev-dev-labs/teslasync/internal/ocpp"
1517
)
1618

1719
// ── config helpers ─────────────────────────────────────────────────────────
@@ -196,7 +198,7 @@ func TestNewServer_Shape(t *testing.T) {
196198
listenAddr: "127.0.0.1:0",
197199
heartbeatInterval: 30 * time.Second,
198200
readDeadline: 0,
199-
})
201+
}, ocpp.NewMemorySessionStore())
200202
if srv == nil {
201203
t.Fatal("newServer returned nil")
202204
}
@@ -221,7 +223,7 @@ func TestRun_GracefulShutdown(t *testing.T) {
221223
if err != nil {
222224
t.Fatalf("listen: %v", err)
223225
}
224-
srv := newServer(config{heartbeatInterval: time.Minute})
226+
srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore())
225227

226228
ctx, cancel := context.WithCancel(context.Background())
227229
done := make(chan error, 1)
@@ -269,7 +271,7 @@ func TestRun_ContextAlreadyCancelled(t *testing.T) {
269271
if err != nil {
270272
t.Fatalf("listen: %v", err)
271273
}
272-
srv := newServer(config{heartbeatInterval: time.Minute})
274+
srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore())
273275

274276
ctx, cancel := context.WithCancel(context.Background())
275277
cancel() // already cancelled before run starts
@@ -284,7 +286,7 @@ func TestRun_ServerClosedExternallyReturnsNil(t *testing.T) {
284286
if err != nil {
285287
t.Fatalf("listen: %v", err)
286288
}
287-
srv := newServer(config{heartbeatInterval: time.Minute})
289+
srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore())
288290

289291
// Context never cancels; the serve loop ends only because the server
290292
// is closed out from under it — Serve then reports ErrServerClosed,
@@ -329,7 +331,7 @@ func TestRun_ServeErrorIsWrapped(t *testing.T) {
329331
if err := ln.Close(); err != nil {
330332
t.Fatalf("close listener: %v", err)
331333
}
332-
srv := newServer(config{heartbeatInterval: time.Minute})
334+
srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore())
333335

334336
rerr := run(context.Background(), srv, ln, 2*time.Second)
335337
if rerr == nil {
@@ -400,7 +402,7 @@ func TestRun_ShutdownTimeoutIsWrapped(t *testing.T) {
400402
// ── end-to-end OCPP WebSocket wiring ───────────────────────────────────────
401403

402404
func TestOCPPServer_WebSocketBootNotification(t *testing.T) {
403-
srv := newServer(config{heartbeatInterval: 42 * time.Second, readDeadline: 0})
405+
srv := newServer(config{heartbeatInterval: 42 * time.Second, readDeadline: 0}, ocpp.NewMemorySessionStore())
404406
ts := httptest.NewServer(srv.Handler)
405407
defer ts.Close()
406408

@@ -473,7 +475,7 @@ func TestOCPPServer_WebSocketBootNotification(t *testing.T) {
473475
}
474476

475477
func TestOCPPServer_WebSocketRejectsWrongSubprotocol(t *testing.T) {
476-
srv := newServer(config{heartbeatInterval: time.Minute})
478+
srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore())
477479
ts := httptest.NewServer(srv.Handler)
478480
defer ts.Close()
479481

@@ -500,3 +502,42 @@ func TestOCPPServer_WebSocketRejectsWrongSubprotocol(t *testing.T) {
500502
func dialer(_ *testing.T) *websocket.Dialer {
501503
return &websocket.Dialer{} // no subprotocols
502504
}
505+
506+
// ── session store selection ────────────────────────────────────────────────
507+
508+
func TestOpenSessionStore_MemoryByDefault(t *testing.T) {
509+
store, closeFn, err := openSessionStore(context.Background(), config{})
510+
if err != nil {
511+
t.Fatalf("openSessionStore: %v", err)
512+
}
513+
defer closeFn()
514+
if _, ok := store.(*ocpp.MemorySessionStore); !ok {
515+
t.Fatalf("store = %T, want *ocpp.MemorySessionStore", store)
516+
}
517+
}
518+
519+
func TestEnvIntOr(t *testing.T) {
520+
const key = "OCPP_TEST_ENV_INT_OR"
521+
tests := []struct {
522+
name string
523+
set bool
524+
value string
525+
def int
526+
want int
527+
}{
528+
{"unset returns default", false, "", 5432, 5432},
529+
{"empty value returns default", true, "", 5432, 5432},
530+
{"set value overrides default", true, "5433", 5432, 5433},
531+
{"invalid value returns default", true, "not-a-port", 5432, 5432},
532+
}
533+
for _, tt := range tests {
534+
t.Run(tt.name, func(t *testing.T) {
535+
if tt.set {
536+
t.Setenv(key, tt.value)
537+
}
538+
if got := envIntOr(key, tt.def); got != tt.want {
539+
t.Errorf("envIntOr = %d, want %d", got, tt.want)
540+
}
541+
})
542+
}
543+
}

0 commit comments

Comments
 (0)