diff --git a/cmd/ocpp-server/main.go b/cmd/ocpp-server/main.go index c19393519e..529ca1667f 100644 --- a/cmd/ocpp-server/main.go +++ b/cmd/ocpp-server/main.go @@ -16,10 +16,18 @@ // OCPP_LISTEN_ADDR (default :9090) // OCPP_HEARTBEAT_INTERVAL (default 300s) — interval returned in BootNotification // OCPP_READ_DEADLINE (default 900s) — closes the WS if no message within this window +// OCPP_DB_HOST (default "") — empty selects the zero-config +// in-memory session store; set it to persist via +// Postgres (internal/database/ocpp.Store). +// OCPP_DB_PORT (default 5432) +// OCPP_DB_USER (default teslasync) +// OCPP_DB_PASSWORD (default teslasync) +// OCPP_DB_NAME (default teslasync) +// OCPP_DB_SSLMODE (default disable) // -// Persistence: the foundation PR uses the in-memory session store -// (internal/ocpp.MemorySessionStore). A Postgres-backed store can be -// wired here in a follow-up without touching the protocol layer. +// Persistence: Postgres when OCPP_DB_HOST is set, otherwise the +// in-memory session store. The dispatcher only sees the +// ocpp.SessionStore port, so the protocol layer is untouched either way. package main import ( @@ -30,12 +38,16 @@ import ( "net/http" "os" "os/signal" + "strconv" "syscall" "time" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + appconfig "github.com/ev-dev-labs/teslasync/internal/config" + "github.com/ev-dev-labs/teslasync/internal/database" + dbocpp "github.com/ev-dev-labs/teslasync/internal/database/ocpp" "github.com/ev-dev-labs/teslasync/internal/ocpp" ) @@ -59,18 +71,53 @@ type config struct { listenAddr string heartbeatInterval time.Duration readDeadline time.Duration + dbHost string + dbPort int + dbUser string + dbPassword string + dbName string + dbSSLMode string } // loadConfig resolves the CSMS configuration from the environment, // falling back to spec-sensible defaults for anything unset or blank. +// An empty OCPP_DB_HOST selects the in-memory session store. func loadConfig() config { return config{ listenAddr: envOr("OCPP_LISTEN_ADDR", defaultListenAddr), heartbeatInterval: envDurationOr("OCPP_HEARTBEAT_INTERVAL", defaultHeartbeatInterval), readDeadline: envDurationOr("OCPP_READ_DEADLINE", defaultReadDeadline), + dbHost: os.Getenv("OCPP_DB_HOST"), + dbPort: envIntOr("OCPP_DB_PORT", 5432), + dbUser: envOr("OCPP_DB_USER", "teslasync"), + dbPassword: envOr("OCPP_DB_PASSWORD", "teslasync"), + dbName: envOr("OCPP_DB_NAME", "teslasync"), + dbSSLMode: envOr("OCPP_DB_SSLMODE", "disable"), } } +// openSessionStore resolves the persistence backend: Postgres when +// OCPP_DB_HOST is set, otherwise the zero-config in-memory store. It +// returns a close func the caller must defer (a no-op for memory). +func openSessionStore(ctx context.Context, cfg config) (ocpp.SessionStore, func(), error) { + if cfg.dbHost == "" { + return ocpp.NewMemorySessionStore(), func() {}, nil + } + db, err := database.New(ctx, appconfig.DatabaseConfig{ + Host: cfg.dbHost, + Port: cfg.dbPort, + User: cfg.dbUser, + Password: cfg.dbPassword, + Name: cfg.dbName, + SSLMode: cfg.dbSSLMode, + }) + if err != nil { + return nil, nil, fmt.Errorf("connect database: %w", err) + } + log.Info().Str("host", cfg.dbHost).Str("db", cfg.dbName).Msg("OCPP CSMS using Postgres session store") + return dbocpp.NewStore(db), db.Close, nil +} + func main() { zerolog.TimeFieldFormat = time.RFC3339 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) @@ -93,18 +140,22 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - if err := run(ctx, newServer(cfg), ln, shutdownTimeout); err != nil { + store, closeStore, err := openSessionStore(ctx, cfg) + if err != nil { + log.Fatal().Err(err).Msg("OCPP server failed to open session store") + } + defer closeStore() + + if err := run(ctx, newServer(cfg, store), ln, shutdownTimeout); err != nil { log.Fatal().Err(err).Msg("OCPP server failed") } } // newServer builds the HTTP server that fronts the OCPP CSMS: a // /healthz liveness probe plus the WebSocket transport mounted at -// /ocpp/. Persistence uses the zero-config in-memory session store; -// a Postgres-backed store can be swapped in without changing this -// wiring or the protocol layer. -func newServer(cfg config) *http.Server { - store := ocpp.NewMemorySessionStore() +// /ocpp/. The session store is injected so main can select the +// Postgres or in-memory backend without touching this wiring. +func newServer(cfg config, store ocpp.SessionStore) *http.Server { dispatcher := ocpp.NewDispatcher(store, cfg.heartbeatInterval) ocppServer := ocpp.NewServer(dispatcher, cfg.readDeadline) return &http.Server{ @@ -171,6 +222,19 @@ func envOr(key, def string) string { return def } +func envIntOr(key string, def int) int { + raw := os.Getenv(key) + if raw == "" { + return def + } + n, err := strconv.Atoi(raw) + if err != nil { + log.Warn().Err(err).Str("key", key).Str("raw", raw).Msg("invalid integer, using default") + return def + } + return n +} + func envDurationOr(key string, def time.Duration) time.Duration { raw := os.Getenv(key) if raw == "" { diff --git a/cmd/ocpp-server/main_test.go b/cmd/ocpp-server/main_test.go index 158d91cad0..57c46cc08f 100644 --- a/cmd/ocpp-server/main_test.go +++ b/cmd/ocpp-server/main_test.go @@ -12,6 +12,8 @@ import ( "time" "github.com/gorilla/websocket" + + "github.com/ev-dev-labs/teslasync/internal/ocpp" ) // ── config helpers ───────────────────────────────────────────────────────── @@ -196,7 +198,7 @@ func TestNewServer_Shape(t *testing.T) { listenAddr: "127.0.0.1:0", heartbeatInterval: 30 * time.Second, readDeadline: 0, - }) + }, ocpp.NewMemorySessionStore()) if srv == nil { t.Fatal("newServer returned nil") } @@ -221,7 +223,7 @@ func TestRun_GracefulShutdown(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - srv := newServer(config{heartbeatInterval: time.Minute}) + srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore()) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) @@ -269,7 +271,7 @@ func TestRun_ContextAlreadyCancelled(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - srv := newServer(config{heartbeatInterval: time.Minute}) + srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore()) ctx, cancel := context.WithCancel(context.Background()) cancel() // already cancelled before run starts @@ -284,7 +286,7 @@ func TestRun_ServerClosedExternallyReturnsNil(t *testing.T) { if err != nil { t.Fatalf("listen: %v", err) } - srv := newServer(config{heartbeatInterval: time.Minute}) + srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore()) // Context never cancels; the serve loop ends only because the server // is closed out from under it — Serve then reports ErrServerClosed, @@ -329,7 +331,7 @@ func TestRun_ServeErrorIsWrapped(t *testing.T) { if err := ln.Close(); err != nil { t.Fatalf("close listener: %v", err) } - srv := newServer(config{heartbeatInterval: time.Minute}) + srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore()) rerr := run(context.Background(), srv, ln, 2*time.Second) if rerr == nil { @@ -400,7 +402,7 @@ func TestRun_ShutdownTimeoutIsWrapped(t *testing.T) { // ── end-to-end OCPP WebSocket wiring ─────────────────────────────────────── func TestOCPPServer_WebSocketBootNotification(t *testing.T) { - srv := newServer(config{heartbeatInterval: 42 * time.Second, readDeadline: 0}) + srv := newServer(config{heartbeatInterval: 42 * time.Second, readDeadline: 0}, ocpp.NewMemorySessionStore()) ts := httptest.NewServer(srv.Handler) defer ts.Close() @@ -473,7 +475,7 @@ func TestOCPPServer_WebSocketBootNotification(t *testing.T) { } func TestOCPPServer_WebSocketRejectsWrongSubprotocol(t *testing.T) { - srv := newServer(config{heartbeatInterval: time.Minute}) + srv := newServer(config{heartbeatInterval: time.Minute}, ocpp.NewMemorySessionStore()) ts := httptest.NewServer(srv.Handler) defer ts.Close() @@ -500,3 +502,42 @@ func TestOCPPServer_WebSocketRejectsWrongSubprotocol(t *testing.T) { func dialer(_ *testing.T) *websocket.Dialer { return &websocket.Dialer{} // no subprotocols } + +// ── session store selection ──────────────────────────────────────────────── + +func TestOpenSessionStore_MemoryByDefault(t *testing.T) { + store, closeFn, err := openSessionStore(context.Background(), config{}) + if err != nil { + t.Fatalf("openSessionStore: %v", err) + } + defer closeFn() + if _, ok := store.(*ocpp.MemorySessionStore); !ok { + t.Fatalf("store = %T, want *ocpp.MemorySessionStore", store) + } +} + +func TestEnvIntOr(t *testing.T) { + const key = "OCPP_TEST_ENV_INT_OR" + tests := []struct { + name string + set bool + value string + def int + want int + }{ + {"unset returns default", false, "", 5432, 5432}, + {"empty value returns default", true, "", 5432, 5432}, + {"set value overrides default", true, "5433", 5432, 5433}, + {"invalid value returns default", true, "not-a-port", 5432, 5432}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.set { + t.Setenv(key, tt.value) + } + if got := envIntOr(key, tt.def); got != tt.want { + t.Errorf("envIntOr = %d, want %d", got, tt.want) + } + }) + } +} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 744f2af36b..db44a4b9fe 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -27,86 +27,118 @@ export default withMermaid(defineConfig({ head: [ ['link', { rel: 'icon', type: 'image/svg+xml', href: '/teslasync/logo.svg' }], - ['meta', { name: 'theme-color', content: '#00f0ff' }], + ['meta', { name: 'theme-color', content: '#ffffff' }], + ['link', { rel: 'preconnect', href: 'https://fonts.googleapis.com' }], + ['link', { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;650;700&family=IBM+Plex+Mono:wght@400;500&display=swap' }], ['meta', { name: 'og:type', content: 'website' }], ['meta', { name: 'og:title', content: 'TeslaSync Docs' }], - ['meta', { name: 'og:description', content: 'Documentation for TeslaSync - Tesla Fleet Intelligence Platform' }], - ['script', { src: '/teslasync/particles.js', defer: 'true' }], + ['meta', { name: 'og:description', content: 'Install, connect Tesla, operate, and find every in-app screen.' }], ], + lastUpdated: true, + cleanUrls: true, + appearance: false, + themeConfig: { logo: '/logo.svg', siteTitle: 'TeslaSync', nav: [ - { text: 'Guide', link: '/guide/getting-started', activeMatch: '/guide/' }, - { text: 'Deployment', link: '/deployment/docker', activeMatch: '/deployment/' }, - { text: 'Features', link: '/features/dashboard', activeMatch: '/features/' }, - { text: 'Contributing', link: '/CONTRIBUTING', activeMatch: '/(CONTRIBUTING|contributing/)' }, + { text: 'Product', link: '/', activeMatch: '^/$' }, + { text: 'Docs', link: '/get-started', activeMatch: '/(get-started|guide/)' }, + { text: 'Catalogue', link: '/features/catalogue', activeMatch: '/features/' }, + { text: 'Deploy', link: '/deployment/docker', activeMatch: '/deployment/' }, + { text: 'Operate', link: '/operations/release-verification', activeMatch: '/operations/' }, + { text: 'Contribute', link: '/CONTRIBUTING', activeMatch: '/(CONTRIBUTING|contributing/)' }, ], - sidebar: { - '/guide/': [ - { - text: 'Guide', - items: [ - { text: 'Getting Started', link: '/guide/getting-started' }, - { text: 'Tesla Fleet API Setup', link: '/guide/tesla-fleet-api' }, - { text: 'Enable Fleet Telemetry', link: '/guide/fleet-telemetry' }, - { text: 'Configuration', link: '/guide/configuration' }, - { text: 'Local Development', link: '/guide/local-development' }, - { text: 'Architecture', link: '/guide/architecture' }, - { text: 'API Reference', link: '/guide/api-endpoints' }, - { text: 'API Spec (OpenAPI)', link: '/teslasync/openapi.yaml' }, - { text: 'Diagrams', link: '/guide/diagrams' }, - { text: 'Database Schema', link: '/guide/database' }, - { text: 'Technology Stack', link: '/guide/technology' }, - { text: 'Helix AI', link: '/guide/helix-ai' }, - { text: 'Remote Commands', link: '/guide/remote-commands' }, - { text: 'Troubleshooting', link: '/guide/troubleshooting' }, - { text: 'Printing pages', link: '/guide/printing' }, - { text: 'FAQ', link: '/guide/faq' }, - { text: 'Roadmap', link: '/guide/roadmap' }, - ], - }, - ], - '/deployment/': [ - { - text: 'Deployment', - items: [ - { text: 'Docker', link: '/deployment/docker' }, - { text: 'Kubernetes', link: '/deployment/kubernetes' }, - { text: 'GitHub Pages (Docs)', link: '/deployment/github-pages' }, - ], - }, - ], - '/features/': [ - { - text: 'Features', - items: [ - { text: 'Dashboard', link: '/features/dashboard' }, - { text: 'Vehicle Tracking', link: '/features/vehicle-tracking' }, - { text: 'Helix AI', link: '/features/helix-ai' }, - { text: 'Alerts & Notifications', link: '/features/alerts' }, - { text: 'Automations', link: '/features/automations' }, - { text: 'Data Export', link: '/features/data-export' }, - { text: 'Analytics & Charts', link: '/features/analytics' }, - { text: 'Backup & Restore', link: '/features/backup-restore' }, - ], - }, - ], - '/contributing/': [ - { - text: 'Contributing', - items: [ - { text: 'Start Contributing', link: '/CONTRIBUTING' }, - { text: 'Code Structure', link: '/contributing/code-structure' }, - { text: 'Adding Features', link: '/contributing/adding-features' }, - { text: 'API Reference', link: '/contributing/api-reference' }, - ], - }, - ], - }, + sidebar: [ + { + text: 'Get started', + items: [ + { text: 'Overview', link: '/get-started' }, + { text: 'Install', link: '/guide/getting-started' }, + { text: 'Connect Tesla', link: '/guide/tesla-fleet-api' }, + { text: 'Enable streaming', link: '/guide/fleet-telemetry' }, + { text: 'Configuration', link: '/guide/configuration' }, + { text: 'FAQ', link: '/guide/faq' }, + ], + }, + { + text: 'Find a screen', + collapsed: true, + items: [ + { text: 'Catalogue', link: '/features/catalogue' }, + { text: 'Home', link: '/features/catalogue-home' }, + { text: 'Vehicles', link: '/features/catalogue-vehicles' }, + { text: 'Tesla Physics', link: '/features/catalogue-tesla-physics' }, + { text: 'Driving', link: '/features/catalogue-driving' }, + { text: 'Charging', link: '/features/catalogue-charging' }, + { text: 'Battery', link: '/features/catalogue-battery' }, + { text: 'Energy', link: '/features/catalogue-energy' }, + { text: 'Service', link: '/features/catalogue-service' }, + { text: 'Cabin', link: '/features/catalogue-cabin' }, + { text: 'Reports', link: '/features/catalogue-reports' }, + { text: 'Commands', link: '/features/catalogue-commands' }, + { text: 'Automation', link: '/features/catalogue-automation' }, + { text: 'Notifications', link: '/features/catalogue-notifications' }, + { text: 'Advanced Intelligence', link: '/features/catalogue-advanced-intelligence' }, + { text: 'Ownership Intelligence', link: '/features/catalogue-ownership-intelligence' }, + { text: 'Security', link: '/features/catalogue-security' }, + { text: 'Account', link: '/features/catalogue-account' }, + { text: 'Settings', link: '/features/catalogue-settings' }, + { text: 'Integrations', link: '/features/catalogue-integrations' }, + { text: 'Data', link: '/features/catalogue-data' }, + { text: 'Diagnostics', link: '/features/catalogue-diagnostics' }, + ], + }, + { + text: 'Deploy', + collapsed: true, + items: [ + { text: 'Docker', link: '/deployment/docker' }, + { text: 'Kubernetes', link: '/deployment/kubernetes' }, + { text: 'GitHub Pages (Docs)', link: '/deployment/github-pages' }, + ], + }, + { + text: 'Guide', + collapsed: true, + items: [ + { text: 'Local development', link: '/guide/local-development' }, + { text: 'Architecture', link: '/guide/architecture' }, + { text: 'API endpoints', link: '/guide/api-endpoints' }, + { text: 'Database', link: '/guide/database' }, + { text: 'Technology', link: '/guide/technology' }, + { text: 'Helix AI', link: '/guide/helix-ai' }, + { text: 'Remote commands', link: '/guide/remote-commands' }, + { text: 'Troubleshooting', link: '/guide/troubleshooting' }, + { text: 'Printing', link: '/guide/printing' }, + { text: 'Roadmap', link: '/guide/roadmap' }, + ], + }, + { + text: 'Operate', + collapsed: true, + items: [ + { text: 'Release verification', link: '/operations/release-verification' }, + { text: 'Secret management', link: '/operations/secret-management' }, + { text: 'Cost controls', link: '/operations/cost-controls' }, + { text: 'Fleet API budget', link: '/operations/fleet-api-budget' }, + { text: 'Production scorecard', link: '/operations/production-readiness-scorecard' }, + ], + }, + { + text: 'Contribute', + collapsed: true, + items: [ + { text: 'Start contributing', link: '/CONTRIBUTING' }, + { text: 'Code structure', link: '/contributing/code-structure' }, + { text: 'Adding features', link: '/contributing/adding-features' }, + { text: 'API reference', link: '/contributing/api-reference' }, + ], + }, + ], socialLinks: [ { icon: 'github', link: 'https://github.com/ev-dev-labs/teslasync' }, @@ -118,28 +150,40 @@ export default withMermaid(defineConfig({ }, footer: { - message: 'Released under the MIT License.
Visitors', - copyright: `Copyright © ${new Date().getFullYear()} TeslaSync Contributors`, + message: 'MIT License · Self-hosted Tesla intelligence', + copyright: `Copyright © ${new Date().getFullYear()} TeslaSync contributors`, }, + docFooter: { + prev: 'Previous', + next: 'Next', + }, + + returnToTopLabel: 'Back to top', + sidebarMenuLabel: 'Menu', + darkModeSwitchLabel: 'Appearance', + search: { provider: 'local', }, outline: { + label: 'On this page', level: [2, 3], }, + + lastUpdatedText: 'Updated', }, mermaid: { - theme: 'dark', + theme: 'neutral', themeVariables: { - primaryColor: '#00f0ff', - primaryTextColor: '#e4e4ef', - primaryBorderColor: '#00f0ff', - lineColor: '#10b981', - secondaryColor: '#141430', - tertiaryColor: '#0f0f2a', + primaryColor: '#e3e8ee', + primaryTextColor: '#0a2540', + primaryBorderColor: '#0a5cff', + lineColor: '#425466', + secondaryColor: '#f6f9fc', + tertiaryColor: '#ffffff', }, }, })) diff --git a/docs/.vitepress/theme/DocTools.vue b/docs/.vitepress/theme/DocTools.vue new file mode 100644 index 0000000000..b9e3770c2a --- /dev/null +++ b/docs/.vitepress/theme/DocTools.vue @@ -0,0 +1,58 @@ + + + diff --git a/docs/.vitepress/theme/HomeShowcase.vue b/docs/.vitepress/theme/HomeShowcase.vue new file mode 100644 index 0000000000..bc19153555 --- /dev/null +++ b/docs/.vitepress/theme/HomeShowcase.vue @@ -0,0 +1,99 @@ + + + diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css index 4b014a9ca1..dd448cd89f 100644 --- a/docs/.vitepress/theme/custom.css +++ b/docs/.vitepress/theme/custom.css @@ -1,35 +1,36 @@ /* ============================================ - TeslaSync Docs — Tesla-Inspired Dark Theme + TeslaSync Docs — professional dark theme ============================================ */ -/* ── Global Dark Override ── */ :root { - --vp-c-bg: #0a0a1a; - --vp-c-bg-alt: #0f0f2a; - --vp-c-bg-soft: #141430; - --vp-c-bg-elv: #1a1a3e; - --vp-c-text-1: #e4e4ef; - --vp-c-text-2: #a0a0c0; - --vp-c-text-3: #6b6b8d; - --vp-c-brand-1: #00f0ff; - --vp-c-brand-2: #10b981; - --vp-c-brand-3: #00c8d6; - --vp-c-brand-soft: rgba(0, 240, 255, 0.12); - --vp-c-border: rgba(100, 100, 180, 0.15); - --vp-c-divider: rgba(100, 100, 180, 0.1); - --vp-c-gutter: rgba(0, 0, 0, 0.4); - --vp-nav-bg-color: rgba(10, 10, 26, 0.85); - --vp-sidebar-bg-color: #0a0a1a; - --vp-code-bg: rgba(0, 240, 255, 0.06); - --vp-code-color: #00f0ff; - --vp-c-tip-1: #00f0ff; + --vp-c-bg: #0b0d12; + --vp-c-bg-alt: #11141c; + --vp-c-bg-soft: #161b26; + --vp-c-bg-elv: #1b2130; + --vp-c-text-1: #f1f5f9; + --vp-c-text-2: #94a3b8; + --vp-c-text-3: #64748b; + --vp-c-brand-1: #38bdf8; + --vp-c-brand-2: #22c55e; + --vp-c-brand-3: #0ea5e9; + --vp-c-brand-soft: rgba(56, 189, 248, 0.12); + --vp-c-border: rgba(148, 163, 184, 0.14); + --vp-c-divider: rgba(148, 163, 184, 0.1); + --vp-c-gutter: rgba(0, 0, 0, 0.35); + --vp-nav-bg-color: rgba(11, 13, 18, 0.88); + --vp-sidebar-bg-color: #0b0d12; + --vp-code-bg: rgba(15, 23, 42, 0.85); + --vp-code-color: #7dd3fc; + --vp-c-tip-1: #38bdf8; --vp-c-warning-1: #f59e0b; - --vp-c-danger-1: #ef4444; - --vp-button-brand-bg: linear-gradient(135deg, #00f0ff, #10b981); + --vp-c-danger-1: #f43f5e; + --vp-button-brand-bg: #0ea5e9; + --vp-button-brand-hover-bg: #38bdf8; --vp-home-hero-name-color: transparent; - --vp-home-hero-name-background: linear-gradient(135deg, #00f0ff 0%, #10b981 50%, #a855f7 100%); - --vp-home-hero-image-background-image: radial-gradient(circle, rgba(0, 240, 255, 0.15) 0%, transparent 70%); + --vp-home-hero-name-background: linear-gradient(135deg, #38bdf8 0%, #22c55e 100%); + --vp-home-hero-image-background-image: radial-gradient(circle, rgba(56, 189, 248, 0.12) 0%, transparent 70%); --vp-home-hero-image-filter: blur(68px); + --vp-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; } /* ── Light Theme Support ── */ @@ -175,9 +176,10 @@ html:not(.dark) .VPFeature .details { } html:not(.dark) .vp-doc h1 { - background: linear-gradient(135deg, #0077b6, #059669) !important; - -webkit-background-clip: text !important; - -webkit-text-fill-color: transparent !important; + background: none !important; + -webkit-background-clip: unset !important; + -webkit-text-fill-color: unset !important; + color: #0a2540 !important; } html:not(.dark) .vp-doc h2 { @@ -1827,4 +1829,73 @@ body:has(.ts-home)::before { filter: none !important; } .ts-home .ts-hero-cursor { display: none; } +} + +/* Professional doc body: readable tables, quieter headings */ +.vp-doc table { + display: table; + width: 100%; + font-size: 0.9rem; + line-height: 1.5; +} +.vp-doc th { + font-weight: 600; + letter-spacing: 0.01em; +} +.vp-doc td code { + white-space: nowrap; +} +.vp-doc h1 { + letter-spacing: -0.03em; +} +.VPSidebarItem.level-0 .text { + font-weight: 600; +} + +.docs-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + margin: 1.25rem 0 2rem; +} +.docs-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 16px 18px; + border: 1px solid var(--vp-c-border); + border-radius: 8px; + background: var(--vp-c-bg-elv); + text-decoration: none !important; + color: inherit !important; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.docs-card:hover { + border-color: var(--vp-c-brand-1); + box-shadow: 0 4px 16px rgba(10, 37, 64, 0.08); +} +.docs-card strong { + font-size: 0.95rem; + color: var(--vp-c-text-1); +} +.docs-card span { + font-size: 0.85rem; + line-height: 1.45; + color: var(--vp-c-text-2); + font-weight: 400; +} +html:not(.dark) { + --vp-c-bg: #ffffff; + --vp-c-bg-alt: #f6f9fc; + --vp-c-bg-soft: #f6f9fc; + --vp-c-bg-elv: #ffffff; + --vp-c-text-1: #0a2540; + --vp-c-text-2: #425466; + --vp-c-text-3: #6b7c93; + --vp-c-brand-1: #0a5cff; + --vp-c-brand-2: #0a2540; + --vp-c-brand-soft: rgba(10, 92, 255, 0.08); + --vp-c-border: #e3e8ee; + --vp-nav-bg-color: rgba(255, 255, 255, 0.92); + --vp-sidebar-bg-color: #ffffff; } \ No newline at end of file diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 24c2c59eb7..d0d48c0008 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,217 +1,48 @@ import DefaultTheme from 'vitepress/theme' -import './custom.css' - -let homeObserver: MutationObserver | null = null -const wiredCounters = new WeakSet() -const wiredCells = new WeakSet() -let wiredHero = false -let countIO: IntersectionObserver | null = null - -function ensureCountObserver(reduce: boolean): IntersectionObserver { - if (countIO) return countIO - countIO = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return - const el = entry.target as HTMLElement - if (el.dataset.counted === '1') return - el.dataset.counted = '1' - const target = parseInt(el.dataset.count || '0', 10) - const suffix = el.dataset.suffix || '' - if (reduce || !Number.isFinite(target) || target <= 0) { - el.textContent = target + suffix - countIO!.unobserve(el) - return - } - const duration = Math.min(1600, 600 + target * 6) - const start = performance.now() - const step = (now: number) => { - const t = Math.min(1, (now - start) / duration) - const eased = 1 - Math.pow(1 - t, 3) - const value = Math.round(target * eased) - el.textContent = value + suffix - if (t < 1) requestAnimationFrame(step) - else { - el.textContent = target + suffix - countIO!.unobserve(el) - } - } - requestAnimationFrame(step) - }) - }, { threshold: 0.25, rootMargin: '0px 0px -10% 0px' }) - return countIO -} - -function wireHomeNodes() { - if (!document.querySelector('.ts-home')) return false - - document.body.classList.add('ts-home-active') - - const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches - - const hero = document.querySelector('.ts-home .ts-hero') as HTMLElement | null - if (hero && !wiredHero) { - wiredHero = true - const onMove = (e: PointerEvent) => { - const rect = hero.getBoundingClientRect() - const x = ((e.clientX - rect.left) / rect.width) * 100 - const y = ((e.clientY - rect.top) / rect.height) * 100 - hero.style.setProperty('--mx', x + '%') - hero.style.setProperty('--my', y + '%') - } - const onLeave = () => { - hero.style.setProperty('--mx', '50%') - hero.style.setProperty('--my', '40%') - } - hero.addEventListener('pointermove', onMove) - hero.addEventListener('pointerleave', onLeave) - } - - const io = ensureCountObserver(reduce) - const counters = document.querySelectorAll('.ts-home .ts-stat-num[data-count], .ts-home .ts-hero-kpi-num[data-count]') - counters.forEach((c) => { - if (wiredCounters.has(c)) return - wiredCounters.add(c) - io.observe(c) - }) - - const stackCells = document.querySelectorAll('.ts-home .ts-stack-cell') - stackCells.forEach((cell) => { - if (wiredCells.has(cell)) return - wiredCells.add(cell) - cell.addEventListener('pointermove', (e) => { - const rect = cell.getBoundingClientRect() - cell.style.setProperty('--cx', ((e.clientX - rect.left) / rect.width * 100) + '%') - cell.style.setProperty('--cy', ((e.clientY - rect.top) / rect.height * 100) + '%') - }) - }) - - return counters.length > 0 || !!hero -} - -function initHomeAnimations() { - wireHomeNodes() - - if (homeObserver) homeObserver.disconnect() - homeObserver = new MutationObserver(() => { - wireHomeNodes() - }) - homeObserver.observe(document.body, { childList: true, subtree: true }) - - // Belt-and-braces retries in case the markdown hydrates after enhanceApp. - let tries = 0 - const retry = () => { - tries += 1 - wireHomeNodes() - if (tries < 10) setTimeout(retry, 200) - } - setTimeout(retry, 100) -} - -function teardownHomeAnimations() { - document.body.classList.remove('ts-home-active') - if (homeObserver) { - homeObserver.disconnect() - homeObserver = null - } - if (countIO) { - countIO.disconnect() - countIO = null - } - wiredHero = false -} +import { h } from 'vue' +import './stripe.css' +import DocTools from './DocTools.vue' +import HomeShowcase from './HomeShowcase.vue' export default { extends: DefaultTheme, - enhanceApp({ router }: { router: any }) { + Layout() { + return h(DefaultTheme.Layout, null, { + 'doc-before': () => h(DocTools), + 'home-features-after': () => h(HomeShowcase), + }) + }, + enhanceApp() { if (typeof window === 'undefined') return - - document.addEventListener('click', function (e) { + // eslint-disable-next-line no-console + console.log( + '%cTeslaSync%c If you are reading this, clone the repo and grep ProcessAtomics. There is only one ingest.', + 'background:#0a5cff;color:#fff;padding:2px 8px;border-radius:4px;font-weight:700', + 'color:#425466;padding-left:8px', + ) + document.addEventListener('click', (e) => { const target = e.target as HTMLElement - const mermaid = target.closest('.mermaid') as HTMLElement - const img = target.closest('.vp-doc img') as HTMLImageElement - + const mermaid = target.closest('.mermaid') as HTMLElement | null + const img = target.closest('.vp-doc img') as HTMLImageElement | null const el = mermaid || img if (!el) return if (el.closest('.diagram-overlay')) { el.closest('.diagram-overlay')!.remove() return } - const overlay = document.createElement('div') overlay.className = 'diagram-overlay' - overlay.style.cssText = ` - position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999; - background:rgba(0,0,0,0.9);backdrop-filter:blur(12px); - display:flex;align-items:center;justify-content:center; - cursor:zoom-out;padding:20px;flex-direction:column; - ` - - const wrapper = document.createElement('div') - wrapper.style.cssText = ` - background:var(--vp-c-bg, #0a0a1a);border-radius:16px;padding:32px; - box-shadow:0 24px 80px rgba(0,0,0,0.6); - max-width:95vw;max-height:85vh;overflow:auto; - display:flex;align-items:center;justify-content:center; - ` - + overlay.addEventListener('click', () => overlay.remove()) if (mermaid) { const svg = mermaid.querySelector('svg') - if (svg) { - const clone = svg.cloneNode(true) as SVGElement - clone.removeAttribute('width') - clone.removeAttribute('height') - clone.style.cssText = 'width:90vw;height:auto;max-height:80vh;display:block;' - const vb = svg.getAttribute('viewBox') - if (!vb) { - const bb = svg.getBoundingClientRect() - clone.setAttribute('viewBox', `0 0 ${bb.width} ${bb.height}`) - } - wrapper.appendChild(clone) - } else { - const clone = mermaid.cloneNode(true) as HTMLElement - clone.style.cssText = 'transform:scale(2);transform-origin:center;' - wrapper.appendChild(clone) - } + overlay.appendChild((svg ?? mermaid).cloneNode(true)) } else if (img) { const clone = document.createElement('img') clone.src = img.src clone.alt = img.alt || '' - clone.style.cssText = 'max-width:90vw;max-height:80vh;object-fit:contain;display:block;' - wrapper.appendChild(clone) + overlay.appendChild(clone) } - - const hint = document.createElement('div') - hint.textContent = '✕ Click anywhere or press Esc to close' - hint.style.cssText = ` - color:rgba(255,255,255,0.5);font-size:13px; - margin-top:16px;text-align:center; - ` - - overlay.appendChild(wrapper) - overlay.appendChild(hint) - overlay.addEventListener('click', function (ev) { - if (ev.target === overlay || ev.target === hint) overlay.remove() - }) - document.addEventListener('keydown', function handler(ev) { - if (ev.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', handler) } - }) document.body.appendChild(overlay) }) - - const tryInit = () => { - if (document.querySelector('.ts-home')) { - initHomeAnimations() - } else { - teardownHomeAnimations() - } - } - tryInit() - if (router) { - const prev = router.onAfterRouteChanged - router.onAfterRouteChanged = (to: any) => { - try { prev && prev(to) } catch (_) {} - setTimeout(tryInit, 50) - } - } }, } diff --git a/docs/.vitepress/theme/stripe.css b/docs/.vitepress/theme/stripe.css new file mode 100644 index 0000000000..7a56c4f968 --- /dev/null +++ b/docs/.vitepress/theme/stripe.css @@ -0,0 +1,670 @@ +/* TeslaSync docs — Stripe-like light documentation chrome */ + +:root { + --vp-c-bg: #ffffff; + --vp-c-bg-alt: #f6f9fc; + --vp-c-bg-soft: #f6f9fc; + --vp-c-bg-elv: #ffffff; + --vp-c-text-1: #0a2540; + --vp-c-text-2: #425466; + --vp-c-text-3: #6b7c93; + --vp-c-brand-1: #0a5cff; + --vp-c-brand-2: #0a2540; + --vp-c-brand-3: #0847c7; + --vp-c-brand-soft: rgba(10, 92, 255, 0.08); + --vp-c-border: #e3e8ee; + --vp-c-divider: #e3e8ee; + --vp-c-gutter: #f6f9fc; + --vp-nav-bg-color: rgba(255, 255, 255, 0.92); + --vp-sidebar-bg-color: #ffffff; + --vp-code-bg: #f6f9fc; + --vp-code-color: #0a2540; + --vp-c-tip-1: #0a5cff; + --vp-c-warning-1: #c45c00; + --vp-c-danger-1: #cd3d64; + --vp-button-brand-bg: #0a5cff; + --vp-button-brand-hover-bg: #0847c7; + --vp-button-brand-text: #ffffff; + --vp-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --vp-font-family-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --vp-layout-max-width: 1480px; +} + +html.dark { + --vp-c-bg: #0a2540; + --vp-c-bg-alt: #0d2b4a; + --vp-c-bg-soft: #123056; + --vp-c-bg-elv: #123056; + --vp-c-text-1: #f6f9fc; + --vp-c-text-2: #adbdcc; + --vp-c-text-3: #7a8b9a; + --vp-c-brand-1: #80b2ff; + --vp-c-brand-2: #f6f9fc; + --vp-c-border: rgba(173, 189, 204, 0.18); + --vp-nav-bg-color: rgba(10, 37, 64, 0.92); + --vp-sidebar-bg-color: #0a2540; + --vp-code-bg: #123056; + --vp-code-color: #d6e3f0; +} + +body { + background: var(--vp-c-bg); + color: var(--vp-c-text-1); +} + +.VPHero .name { + color: var(--vp-c-brand-1); + letter-spacing: -0.04em; +} +.VPHero .text { + color: var(--vp-c-text-1); + letter-spacing: -0.035em; + max-width: 18ch; +} +.VPHero .tagline { + color: var(--vp-c-text-2); + max-width: 40rem; +} +.VPHome { + padding-bottom: 4rem; +} +.mkt-docs { + max-width: 1100px; + margin: 0 auto; + padding: 0 24px 64px; +} +.mkt-docs h2 { + font-size: 1.5rem; + letter-spacing: -0.03em; + color: var(--vp-c-text-1); + margin-bottom: 0.5rem; +} + +.mkt-show { + max-width: 1100px; + margin: 8px auto 40px; + padding: 0 24px; +} +.mkt-show__grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} +@media (max-width: 900px) { + .mkt-show__grid { grid-template-columns: 1fr; } +} +.mkt-scene { + border: 1px solid var(--vp-c-border); + border-radius: 12px; + background: linear-gradient(180deg, #f6f9fc 0%, #fff 70%); + padding: 16px 16px 14px; + overflow: hidden; + min-height: 196px; +} +.mkt-scene__label { + margin: 0 0 10px; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--vp-c-brand-1); +} +.mkt-scene__cap { + margin: 12px 0 0; + font-size: 13px; + color: var(--vp-c-text-2); + line-height: 1.45; +} +.mkt-road { + position: relative; + height: 88px; + border-radius: 8px; + background: #0a2540; + overflow: hidden; +} +.mkt-lane { + position: absolute; + left: 0; right: 0; top: 50%; + height: 2px; + background: repeating-linear-gradient(90deg, #f6f9fc 0 18px, transparent 18px 36px); + animation: mkt-lane 1.2s linear infinite; +} +.mkt-car { + position: absolute; + bottom: 10px; +} +.mkt-car--a { animation: mkt-drive 7s linear infinite; } +.mkt-car--b { animation: mkt-drive 9s linear infinite reverse; bottom: 28px; opacity: 0.92; } +.mkt-car--c { animation: mkt-drive 11s linear infinite; bottom: 4px; opacity: 0.75; } +.mkt-pkt { + position: absolute; + width: 7px; height: 7px; + border-radius: 50%; + background: #80b2ff; + box-shadow: 0 0 8px #0a5cff; +} +.mkt-pkt--1 { left: 22%; animation: mkt-up 2.2s ease-in infinite; } +.mkt-pkt--2 { left: 48%; animation: mkt-up 2.6s ease-in infinite 0.4s; } +.mkt-pkt--3 { left: 74%; animation: mkt-up 2s ease-in infinite 0.9s; } + +.mkt-pipe, .mkt-flow { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.mkt-pipe li, .mkt-flow li { + font-size: 12px; + font-weight: 600; + color: var(--vp-c-text-1); + background: #fff; + border: 1px solid var(--vp-c-border); + border-radius: 999px; + padding: 6px 10px; +} +.mkt-pipe li { animation: mkt-pulse 3.2s ease-in-out infinite; } +.mkt-pipe li:nth-child(2) { animation-delay: 0.5s; } +.mkt-pipe li:nth-child(3) { animation-delay: 1s; } +.mkt-pipe li:nth-child(4) { animation-delay: 1.5s; } +.mkt-pipe li:nth-child(5) { animation-delay: 2s; } +.mkt-flow li { animation: mkt-pulse 2.8s ease-in-out infinite; } +.mkt-flow li:nth-child(2) { animation-delay: 0.6s; } +.mkt-flow li:nth-child(3) { animation-delay: 1.2s; } +.mkt-flow li:nth-child(4) { animation-delay: 1.8s; } +.mkt-pipe-track { + position: relative; + height: 4px; + margin: 16px 4px 4px; + border-radius: 999px; + background: #e3e8ee; + overflow: hidden; +} +.mkt-pipe-dot { + position: absolute; + top: -3px; + width: 10px; height: 10px; + border-radius: 50%; + background: var(--vp-c-brand-1); + animation: mkt-dot 2.4s linear infinite; +} + +@keyframes mkt-lane { to { transform: translateX(-36px); } } +@keyframes mkt-drive { + 0% { left: -30%; } + 100% { left: 110%; } +} +@keyframes mkt-up { + 0% { bottom: 18px; opacity: 0; } + 20% { opacity: 1; } + 100% { bottom: 90px; opacity: 0; } +} +@keyframes mkt-pulse { + 0%, 70%, 100% { border-color: #e3e8ee; box-shadow: none; } + 35% { border-color: #0a5cff; box-shadow: 0 0 0 3px rgba(10, 92, 255, 0.12); } +} +@keyframes mkt-dot { + 0% { left: -8px; } + 100% { left: 100%; } +} + +@media (prefers-reduced-motion: reduce) { + .mkt-lane, .mkt-car, .mkt-pkt, .mkt-pipe li, .mkt-flow li, .mkt-pipe-dot { + animation: none !important; + } + .mkt-car--a { left: 8%; } + .mkt-car--b { left: 38%; } + .mkt-car--c { left: 64%; } +} + +.hs { + max-width: 1100px; + margin: 48px auto 24px; + padding: 0 24px 32px; +} +.hs-product { + display: grid; + grid-template-columns: 1fr 1.05fr; + gap: 48px; + align-items: center; +} +.hs-kicker { + margin: 0 0 8px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--vp-c-brand-1); +} +.hs-copy h2 { + margin: 0 0 12px; + font-size: clamp(1.75rem, 3vw, 2.35rem); + letter-spacing: -0.035em; + color: var(--vp-c-text-1); + line-height: 1.15; +} +.hs-copy > p { + margin: 0 0 28px; + font-size: 1.05rem; + color: var(--vp-c-text-2); + line-height: 1.55; + max-width: 36rem; +} +.hs-steps { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 18px; +} +.hs-steps li { + display: flex; + gap: 14px; + align-items: flex-start; +} +.hs-steps span { + font-family: var(--vp-font-family-mono); + font-size: 12px; + font-weight: 500; + color: var(--vp-c-brand-1); + padding-top: 3px; +} +.hs-steps strong { + display: block; + font-size: 15px; + color: var(--vp-c-text-1); +} +.hs-steps em { + display: block; + font-style: normal; + font-size: 14px; + color: var(--vp-c-text-2); + line-height: 1.45; + margin-top: 2px; +} +.hs-visual { + position: relative; + margin: -24px -12px -24px 0; + background: none; + box-shadow: none; + border-radius: 0; + overflow: visible; +} +.hs-visual img { + display: block; + width: 100%; + height: 420px; + object-fit: cover; + object-position: center 58%; + filter: saturate(0.78) contrast(0.96) brightness(1.06); + -webkit-mask-image: + linear-gradient(to right, transparent 0%, #000 22%, #000 78%, transparent 100%), + linear-gradient(to bottom, transparent 0%, #000 18%, #000 78%, transparent 100%); + -webkit-mask-composite: destination-in; + mask-image: + linear-gradient(to right, transparent 0%, #000 22%, #000 78%, transparent 100%), + linear-gradient(to bottom, transparent 0%, #000 18%, #000 78%, transparent 100%); + mask-composite: intersect; +} +.hs-visual::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(90deg, #fff 0%, rgba(255,255,255,0.72) 14%, transparent 42%), + linear-gradient(180deg, #fff 0%, transparent 28%, transparent 68%, #fff 100%), + linear-gradient(270deg, #fff 0%, transparent 18%); +} +@media (max-width: 860px) { + .hs-product { grid-template-columns: 1fr; } + .hs-visual { margin: 0; } + .hs-visual img { height: 260px; } +} +.hs-devs { + margin-top: 40px; + padding-top: 28px; + border-top: 1px solid var(--vp-c-border); +} +.hs-devs ul { + margin: 10px 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 8px; +} +.hs-devs li { + font-size: 14.5px; + color: var(--vp-c-text-2); + line-height: 1.5; +} +.hs-devs li::before { + content: "→ "; + color: var(--vp-c-brand-1); + font-weight: 650; +} +.hs-kicker { cursor: default; user-select: none; } +.hs-visual img { cursor: pointer; } +.hs-egg { + position: fixed; + left: 50%; + bottom: 28px; + transform: translateX(-50%); + z-index: 80; + margin: 0; + padding: 12px 18px; + border-radius: 999px; + background: #0a2540; + color: #fff; + font-size: 14px; + box-shadow: 0 12px 32px rgba(10, 37, 64, 0.28); +} +html.hs-ludicrous .VPHero .name { + animation: hs-hue 1.2s linear infinite; +} +@keyframes hs-hue { + to { filter: hue-rotate(360deg); } +} + +.docs-card-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-top: 20px; +} +.docs-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 18px 18px 16px; + border: 1px solid var(--vp-c-border); + border-radius: 12px; + text-decoration: none !important; + background: #fff; + color: inherit; +} +.docs-card:hover { + border-color: #b6c7d6; + box-shadow: 0 8px 24px rgba(10, 37, 64, 0.06); +} +.docs-card strong { + font-size: 15px; + color: var(--vp-c-text-1); +} +.docs-card span { + font-size: 13.5px; + color: var(--vp-c-text-2); + line-height: 1.45; +} +@media (max-width: 800px) { + .docs-card-grid { grid-template-columns: 1fr; } +} + +body::before, +#particles-bg { + display: none !important; +} + +.VPNav { + border-bottom: 1px solid var(--vp-c-divider); + background: var(--vp-nav-bg-color) !important; + backdrop-filter: blur(12px); +} + +.VPNavBarTitle .title { + font-weight: 600; + letter-spacing: -0.02em; +} + +.VPSidebar { + border-right: 1px solid var(--vp-c-divider); + padding-top: calc(var(--vp-nav-height) + 28px) !important; +} +.VPSidebar .nav { + margin-top: 0; +} +.VPSidebarItem.level-0:first-child { + padding-top: 4px; +} + +.VPSidebarItem.level-0 > .item > .text, +.VPSidebarItem.level-0 > .item > p.text { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--vp-c-text-3); +} + +.VPSidebarItem.level-1 > .item .text { + font-size: 14px; + font-weight: 500; + letter-spacing: -0.01em; + text-transform: none; + color: var(--vp-c-text-1); +} + +.VPSidebarItem.level-1.is-active > .item .text { + color: var(--vp-c-brand-1); + font-weight: 600; +} + +.VPDocAside .outline-title { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--vp-c-text-3); +} + +.VPDocAside .outline-link { + font-size: 13.5px; + line-height: 1.4; + color: var(--vp-c-text-2); +} + +.VPDocAside .outline-link.active { + color: var(--vp-c-brand-1); +} + +.VPDoc .content { + min-width: 0; +} +.VPDoc .aside { + flex: 0 0 220px; + width: 220px; + background: var(--vp-c-bg); + padding-left: 20px; + z-index: 2; +} + +.doc-tools { + display: flex; + gap: 8px; + margin: 0 0 1.25rem; + padding-bottom: 12px; + border-bottom: 1px solid var(--vp-c-divider); +} + +.doc-tools__btn { + appearance: none; + border: 1px solid var(--vp-c-border); + background: var(--vp-c-bg); + color: var(--vp-c-text-2); + font: 500 13px/1 var(--vp-font-family-base); + padding: 7px 11px; + border-radius: 8px; + cursor: pointer; +} + +.doc-tools__btn:hover { + color: var(--vp-c-text-1); + border-color: #b6c7d6; +} + +@media (max-width: 1279px) { + .VPDoc .aside { + display: none !important; + } +} + +.vp-doc { + font-size: 16px; + line-height: 1.7; +} + +.vp-doc h1 { + font-size: 2.25rem; + font-weight: 650; + letter-spacing: -0.035em; + line-height: 1.15; + color: var(--vp-c-text-1) !important; + background: none !important; + -webkit-text-fill-color: unset !important; + margin-bottom: 0.5rem; +} + +.vp-doc h2 { + font-size: 1.35rem; + font-weight: 600; + letter-spacing: -0.02em; + border-top: 1px solid var(--vp-c-divider); + margin-top: 2.5rem; + padding-top: 1.5rem; +} + +.vp-doc h2::before { + display: none; +} + +.vp-doc p { + color: var(--vp-c-text-2); +} + +.vp-doc a { + color: var(--vp-c-brand-1); + font-weight: 500; + text-decoration: none; +} + +.vp-doc a:hover { + text-decoration: underline; +} + +.vp-doc table { + display: table; + width: 100%; + max-width: 100%; + table-layout: fixed; + font-size: 14px; + line-height: 1.5; + border-collapse: collapse; +} + +.vp-doc th { + font-weight: 600; + text-align: left; + background: var(--vp-c-bg-alt); + color: var(--vp-c-text-1); +} + +.vp-doc td, +.vp-doc th { + border-color: var(--vp-c-divider); + padding: 10px 12px; + overflow-wrap: anywhere; + word-break: break-word; +} + +.vp-doc td code { + white-space: normal; + font-size: 12.5px; +} + +.vp-doc div[class*="language-"] { + border: 1px solid var(--vp-c-divider); + border-radius: 8px; + background: var(--vp-c-bg-alt) !important; +} + +.VPLocalSearchBox, +.DocSearch-Button { + border: 1px solid var(--vp-c-border) !important; + border-radius: 8px !important; + background: var(--vp-c-bg-alt) !important; +} + +.VPFooter { + border-top: 1px solid var(--vp-c-divider); + background: var(--vp-c-bg) !important; +} + +.docs-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 12px; + margin: 1.5rem 0 2.25rem; +} + +.docs-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 18px 18px 16px; + border: 1px solid var(--vp-c-border); + border-radius: 10px; + background: var(--vp-c-bg); + text-decoration: none !important; + color: inherit !important; + box-shadow: 0 1px 2px rgba(10, 37, 64, 0.04); + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.docs-card:hover { + border-color: #b6c7d6; + box-shadow: 0 8px 24px rgba(10, 37, 64, 0.08); + transform: translateY(-1px); + text-decoration: none !important; +} + +.docs-card strong { + font-size: 0.98rem; + letter-spacing: -0.015em; + color: var(--vp-c-text-1); +} + +.docs-card span { + font-size: 0.875rem; + line-height: 1.5; + color: var(--vp-c-text-2); + font-weight: 400; +} + +.docs-kicker { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--vp-c-brand-1); + margin: 0 0 8px; +} + +.diagram-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: rgba(10, 37, 64, 0.72); + display: flex; + align-items: center; + justify-content: center; + cursor: zoom-out; + padding: 24px; +} + +@media (prefers-reduced-motion: reduce) { + .docs-card { + transition: none; + } +} diff --git a/docs/features/catalogue-about.md b/docs/features/catalogue-about.md new file mode 100644 index 0000000000..4dfe9e2343 --- /dev/null +++ b/docs/features/catalogue-about.md @@ -0,0 +1,9 @@ +# About + +Sidebar group **About**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Roadmap | `/roadmap` | Upcoming features grouped by quarter. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-account.md b/docs/features/catalogue-account.md new file mode 100644 index 0000000000..4c5e316213 --- /dev/null +++ b/docs/features/catalogue-account.md @@ -0,0 +1,17 @@ +# Account + +Sidebar group **Account**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Tesla Account | `/tesla-account` | Linked Tesla account, refresh-token status, and re-auth. | Empty until Tesla Fleet API is connected in Settings → Fleet Setup. | +| Active Orders | `/tesla-orders` | Active orders on your Tesla account. | Renders an empty state when no data is available — the page is not hidden. | +| Fleet API | `/fleet-api` | Fleet API rate-limit usage and registration details. | Empty until Tesla Fleet API is connected in Settings → Fleet Setup. | +| Region & API | `/tesla-region` | Switch Fleet API region (NA, EU, China). | Renders an empty state when no data is available — the page is not hidden. | +| Feature Flags | `/tesla-features` | Tesla feature-flag previews exposed by your firmware version. | Renders an empty state when no data is available — the page is not hidden. | +| Two-Factor Auth | `/account/2fa` | Enroll or disable two-factor authentication on your account. | Renders an empty state when no data is available — the page is not hidden. | +| Active Sessions | `/account/sessions` | Browser and device sessions — revoke any of them. | Renders an empty state when no data is available — the page is not hidden. | +| Privacy | `/account/privacy` | Recently viewed pages, cookies, and analytics consent. | Renders an empty state when no data is available — the page is not hidden. | +| My Activity | `/me/activity` | Your recent page views and actions in this app. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-advanced-intelligence.md b/docs/features/catalogue-advanced-intelligence.md new file mode 100644 index 0000000000..1809a16429 --- /dev/null +++ b/docs/features/catalogue-advanced-intelligence.md @@ -0,0 +1,21 @@ +# Advanced Intelligence + +Sidebar group **Advanced Intelligence**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Vehicle Twin Lab | `/intelligence/twin-lab` | Run calibrated vehicle counterfactuals with explicit uncertainty and sensitivity. | Renders an empty state when no data is available — the page is not hidden. | +| Firmware Canary | `/intelligence/firmware-canary` | Gate firmware rollout using matched pre/post cohorts instead of simple averages. | Renders an empty state when no data is available — the page is not hidden. | +| Component Survival | `/intelligence/component-survival` | Model event-free component horizons, competing risks, and intervention sensitivity. | Renders an empty state when no data is available — the page is not hidden. | +| Road Hazard Mesh | `/intelligence/road-hazards` | Reveal privacy-safe crash and airbag clusters without exposing exact coordinates. | Renders an empty state when no data is available — the page is not hidden. | +| Behavioral Sentinel | `/intelligence/behavioral-sentinel` | Detect command and telemetry behavior shifts without claiming attack attribution. | Renders an empty state when no data is available — the page is not hidden. | +| Charging Forensics | `/intelligence/charging-forensics` | Separate recorded charging facts from unsupported meter and billing assumptions. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Journey Assurance | `/intelligence/journey-assurance` | Stress-test departure readiness against reserve, climate, and uncertainty. | Renders an empty state when no data is available — the page is not hidden. | +| Charging Site Twin | `/intelligence/charging-site-twin` | Simulate charging-site queues, failures, and fallback capacity before deployment. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Federated Learning | `/intelligence/federated-learning` | Train local aggregate models with explicit privacy-budget accounting. | Renders an empty state when no data is available — the page is not hidden. | +| Emergency Resilience | `/intelligence/emergency-resilience` | Build confirmed emergency energy plans from vehicles, home reserve, and loads. | Renders an empty state when no data is available — the page is not hidden. | +| Causal Experiment Lab | `/intelligence/causal-lab` | Run confirmed treatment/control analyses with transparent effect limitations. | Renders an empty state when no data is available — the page is not hidden. | +| TCO Optimizer | `/intelligence/tco-optimizer` | Compare ownership scenarios without inventing prices, tariffs, or depreciation. | Renders an empty state when no data is available — the page is not hidden. | +| Storm Guardian | `/intelligence/emergency-resilience` | Weather-aware energy / charging caution from local storm data. | Needs location history and weather provider configuration. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-automation.md b/docs/features/catalogue-automation.md new file mode 100644 index 0000000000..a4501809ba --- /dev/null +++ b/docs/features/catalogue-automation.md @@ -0,0 +1,12 @@ +# Automation + +Sidebar group **Automation**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Automations | `/automations` | Trigger actions on geofence, time, or vehicle state. | Renders an empty state when no data is available — the page is not hidden. | +| Alert Studio | `/notifications/studio` | Build a custom alert rule with conditions and channels. | Renders an empty state when no data is available — the page is not hidden. | +| Alert Rules | `/notifications/rules` | Manage existing alert rules. | Renders an empty state when no data is available — the page is not hidden. | +| Comfort calendar | `/automations` | ICS-driven climate windows (Comfort panel on Automations). | Needs a reachable https ICS URL; loopback and metadata hosts are blocked. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-battery.md b/docs/features/catalogue-battery.md new file mode 100644 index 0000000000..b6f22d1752 --- /dev/null +++ b/docs/features/catalogue-battery.md @@ -0,0 +1,20 @@ +# Battery + +Sidebar group **Battery**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Battery Health | `/battery` | Pack health: SoH, full-charge capacity, and degradation curve. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Battery Cells | `/battery-cells` | Per-cell voltage and temperature spread. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Battery Degradation | `/battery-degradation` | Capacity loss over time vs fleet average. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Projected Range | `/projected-range` | Range forecast adjusted for weather, terrain, and driving style. | Renders an empty state when no data is available — the page is not hidden. | +| Vampire Drain | `/vampire-drain` | Standby energy loss while parked and asleep. | Renders an empty state when no data is available — the page is not hidden. | +| Sleep Efficiency | `/sleep-efficiency` | How quickly the car drops into low-power sleep when parked. | Renders an empty state when no data is available — the page is not hidden. | +| Battery Passport | `/battery-passport` | Issue a verifiable battery health and provenance certificate. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Pack Capacity | `/pack-capacity` | Estimate usable pack capacity from charging and driving evidence. | Renders an empty state when no data is available — the page is not hidden. | +| Battery Cycle Stress | `/cycle-stress` | Measure depth-of-discharge and cycle stress on the battery. | Renders an empty state when no data is available — the page is not hidden. | +| Range Buffer | `/range-buffer` | Track reserve-range habits and low-state-of-charge exposure. | Renders an empty state when no data is available — the page is not hidden. | +| Battery Care | `/battery-care` | Turn battery behavior into practical longevity recommendations. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Charge Advisor | `/charge-advisor` | Recommend charging limits and timing for battery care. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-cabin.md b/docs/features/catalogue-cabin.md new file mode 100644 index 0000000000..bcad7d51cb --- /dev/null +++ b/docs/features/catalogue-cabin.md @@ -0,0 +1,14 @@ +# Cabin + +Sidebar group **Cabin**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Climate Control | `/climate-control` | Pre-heat, pre-cool, or run Dog Mode remotely. | Renders an empty state when no data is available — the page is not hidden. | +| Cabin Thermal Model | `/cabin-thermal` | Model cabin heating, cooling, and heat-retention behavior. | Renders an empty state when no data is available — the page is not hidden. | +| HVAC Cycling | `/hvac-cycling` | Detect excessive compressor cycling and unstable HVAC operation. | Renders an empty state when no data is available — the page is not hidden. | +| Comfort Consistency | `/comfort-consistency` | Measure how consistently the cabin holds its target temperature. | Renders an empty state when no data is available — the page is not hidden. | +| Preconditioning Effectiveness | `/preconditioning-effectiveness` | Score cabin and battery readiness before departure. | Renders an empty state when no data is available — the page is not hidden. | +| Media Player | `/media-player` | See what is playing and control playback. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-charging.md b/docs/features/catalogue-charging.md new file mode 100644 index 0000000000..48064b1f6e --- /dev/null +++ b/docs/features/catalogue-charging.md @@ -0,0 +1,20 @@ +# Charging + +Sidebar group **Charging**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Charging Overview | `/charging` | All charging sessions — Supercharger, home, third-party. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Charge History | `/tesla-charging-history` | Tesla-provided charging history pulled from your account. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Charging Curve | `/charging-curve` | Power vs SOC curve for any charging session. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Charging Patterns | `/charging-heatmap` | When and where you charge, visualised as a heatmap. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Smart Charging | `/smart-charge` | Schedule charging for off-peak or solar-surplus windows. | Renders an empty state when no data is available — the page is not hidden. | +| Charger Health | `/charger-health` | Track charging-site performance, faults, and declining power. | Renders an empty state when no data is available — the page is not hidden. | +| Charge Interruption | `/charge-interruption` | Explain incomplete sessions and recurring charging interruptions. | Renders an empty state when no data is available — the page is not hidden. | +| Charger Resilience | `/charger-resilience` | Measure dependence on individual sites and charging alternatives. | Renders an empty state when no data is available — the page is not hidden. | +| Charge Alignment | `/charge-departure-alignment` | Check whether charging finishes before predicted departures. | Renders an empty state when no data is available — the page is not hidden. | +| Charging Thermal Tax | `/charging-thermal-tax` | Quantify battery-heating overhead during charging sessions. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Powershare | `/powershare` | Use your vehicle as a backup home battery (V2H). | Renders an empty state when no data is available — the page is not hidden. | +| Wait Oracle | `/tesla-charging-history` | Embedded panel: Supercharger wait forecast (Erlang-C on your site history). Not Tesla live occupancy. | Empty until Supercharger sessions exist for that site name. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-commands.md b/docs/features/catalogue-commands.md new file mode 100644 index 0000000000..b37bfee2d0 --- /dev/null +++ b/docs/features/catalogue-commands.md @@ -0,0 +1,11 @@ +# Commands + +Sidebar group **Commands**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Send Commands | `/commands` | Send a remote command (wake, lock, climate, port, …). | Renders an empty state when no data is available — the page is not hidden. | +| Command History | `/command-history` | Audit log of every command sent and its result. | Renders an empty state when no data is available — the page is not hidden. | +| Command Reliability | `/command-reliability` | Measure command latency, success rate, and recurring failures. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-data.md b/docs/features/catalogue-data.md new file mode 100644 index 0000000000..0cbd892487 --- /dev/null +++ b/docs/features/catalogue-data.md @@ -0,0 +1,11 @@ +# Data + +Sidebar group **Data**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Data Export | `/data-export` | Export drives, charging sessions, and signals to CSV. | Renders an empty state when no data is available — the page is not hidden. | +| Backup & Restore | `/backup` | Take a full backup of the database or restore from one. | Renders an empty state when no data is available — the page is not hidden. | +| Data Repair | `/data-repair` | Re-derive trips, sessions, and analytics from raw signals. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-diagnostics.md b/docs/features/catalogue-diagnostics.md new file mode 100644 index 0000000000..0f9e1ed0bf --- /dev/null +++ b/docs/features/catalogue-diagnostics.md @@ -0,0 +1,40 @@ +# Diagnostics + +Sidebar group **Diagnostics**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| System Status | `/system-status` | Health of every dependent service — MQTT, Redis, DB, Tesla API. | Renders an empty state when no data is available — the page is not hidden. | +| Outage Autobiography | `/outage` | What queued, replayed with original event time, or stayed unknown. | Renders an empty state when no data is available — the page is not hidden. | +| Database Health | `/db-health` | Database size, query latency, and replication lag. | Renders an empty state when no data is available — the page is not hidden. | +| Anomaly Detection | `/anomaly-detection` | Auto-detected outliers in charging, range, and drives. | Renders an empty state when no data is available — the page is not hidden. | +| Remaining Useful Life | `/diagnostics/rul` | Estimate remaining useful life for monitored components. | Renders an empty state when no data is available — the page is not hidden. | +| Root-Cause Intelligence | `/diagnostics/root-cause` | Rank evidence-backed explanations for vehicle anomalies. | Renders an empty state when no data is available — the page is not hidden. | +| Dashcam & Sentry | `/dashcam` | Search, redact, and reconstruct Dashcam and Sentry incidents locally. | Renders an empty state when no data is available — the page is not hidden. | +| Live Signals | `/signals` | Live values for every telemetry signal the car publishes. | Renders an empty state when no data is available — the page is not hidden. | +| Live Signal Inspector | `/admin/live-signals` | Inspect a single signal in real time with history. | Operator surface — needs a healthy API, MQTT, and DB. | +| Ingest X-Ray | `/admin/ingest-xray` | See every payload as it lands from Fleet Telemetry. | Operator surface — needs a healthy API, MQTT, and DB. | +| DLQ Inspector | `/admin/dlq` | Dead-letter queue — messages that failed to ingest. | Operator surface — needs a healthy API, MQTT, and DB. | +| Feature Flags | `/admin/flags` | Runtime feature flags — toggle without redeploy. | Operator surface — needs a healthy API, MQTT, and DB. | +| Schema Drift | `/admin/schema-drift` | Detect divergence between code models and the live DB schema. | Operator surface — needs a healthy API, MQTT, and DB. | +| Slow Queries | `/admin/slow-queries` | Top slow SQL queries with explain plans. | Operator surface — needs a healthy API, MQTT, and DB. | +| Vehicle Cost | `/admin/vehicle-cost` | Per-vehicle infrastructure cost attribution. | Operator surface — needs a healthy API, MQTT, and DB. | +| Data Quality | `/admin/data-quality` | Signal freshness, gaps, duplicates, and normalization provenance. | Operator surface — needs a healthy API, MQTT, and DB. | +| Disk Forecast | `/admin/disk-forecast` | When will the database run out of disk? | Operator surface — needs a healthy API, MQTT, and DB. | +| Secret Rotation | `/admin/secret-rotation` | Track and rotate secrets, tokens, and credentials. | Operator surface — needs a healthy API, MQTT, and DB. | +| Audit Log | `/admin/audit-log` | Every privileged action with actor, target, and timestamp. | Operator surface — needs a healthy API, MQTT, and DB. | +| GDPR Exports | `/admin/gdpr-exports` | Generate and download a complete user-data export. | Operator surface — needs a healthy API, MQTT, and DB. | +| State Debugger | `/state-debugger` | Inspect the per-vehicle finite-state machine in real time. | Operator surface — needs a healthy API, MQTT, and DB. | +| MQTT Inspector | `/mqtt-inspector` | Subscribe to any MQTT topic and watch messages flow. | Operator surface — needs a healthy API, MQTT, and DB. | +| Signal Correlation | `/signal-correlation` | Find signals that move together across a selected time window. | Renders an empty state when no data is available — the page is not hidden. | +| Signal Entropy | `/signal-entropy` | Measure signal variability and information density. | Renders an empty state when no data is available — the page is not hidden. | +| Signal Trend | `/signal-trend` | Detect robust long-term telemetry trends and direction changes. | Renders an empty state when no data is available — the page is not hidden. | +| Signal Change Points | `/signal-change-points` | Locate statistically meaningful shifts in signal behavior. | Renders an empty state when no data is available — the page is not hidden. | +| Signal Deadband Advisor | `/signal-deadband` | Recommend noise thresholds that preserve meaningful telemetry. | Renders an empty state when no data is available — the page is not hidden. | +| Nonlinear Signal Coupling | `/signal-mutual-information` | Discover nonlinear dependencies between telemetry signals. | Renders an empty state when no data is available — the page is not hidden. | +| Redis Signals | `/redis-signals` | Dump the Redis live-signal cache for a vehicle. | Operator surface — needs a healthy API, MQTT, and DB. | +| Telemetry Coverage | `/admin/telemetry/coverage` | Which Fleet Telemetry fields are wired vs missing. | Operator surface — needs a healthy API, MQTT, and DB. | +| API Logs | `/api-logs` | Recent HTTP requests with status, duration, and payload size. | Renders an empty state when no data is available — the page is not hidden. | +| API Playground | `/api-playground` | Try any API endpoint with parameter forms. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-driving.md b/docs/features/catalogue-driving.md new file mode 100644 index 0000000000..da2949fbcd --- /dev/null +++ b/docs/features/catalogue-driving.md @@ -0,0 +1,41 @@ +# Driving + +Sidebar group **Driving**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Drives | `/drives` | Every drive with route, energy used, and efficiency. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Trips | `/trips` | Multi-leg trips grouped into a single journey. | Renders an empty state when no data is available — the page is not hidden. | +| Journeys | `/journeys` | Open Journeys. | Renders an empty state when no data is available — the page is not hidden. | +| Trip Planner | `/trip-planner` | Plan a route with charging stops and ETA before you leave. | Renders an empty state when no data is available — the page is not hidden. | +| Navigation | `/navigation` | Send a destination to the car or save it for later. | Renders an empty state when no data is available — the page is not hidden. | +| Geofences | `/geofences` | Trigger automations when the car enters or leaves a zone. | Renders an empty state when no data is available — the page is not hidden. | +| Mileage Log | `/mileage` | Odometer log with monthly and yearly totals. | Renders an empty state when no data is available — the page is not hidden. | +| Trip Logbook | `/logbook` | Review and annotate a searchable chronological trip log. | Renders an empty state when no data is available — the page is not hidden. | +| Mileage Budget | `/mileage-budget` | Track distance budgets and forecast when thresholds will be reached. | Renders an empty state when no data is available — the page is not hidden. | +| Driving Rhythm | `/driving-rhythm` | See recurring departure, duration, and travel-time patterns. | Renders an empty state when no data is available — the page is not hidden. | +| Speed Sweet Spot | `/speed-sweetspot` | Find the speed band where your vehicle is most efficient. | Renders an empty state when no data is available — the page is not hidden. | +| Efficiency Target | `/efficiency-target` | Set an efficiency goal and measure progress toward it. | Renders an empty state when no data is available — the page is not hidden. | +| Cold Start Cost | `/cold-start` | Quantify the energy and range cost of cold departures. | Renders an empty state when no data is available — the page is not hidden. | +| Drive Compare | `/drive-compare` | Compare two drives across route, speed, energy, and conditions. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Explorer | `/explorer` | Slice and inspect drive history with advanced filters. | Renders an empty state when no data is available — the page is not hidden. | +| Drive Calendar | `/drive-calendar` | Browse driving activity and totals on a calendar. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Milestones | `/milestones` | Celebrate distance, efficiency, and ownership achievements. | Renders an empty state when no data is available — the page is not hidden. | +| Lifetime Stats | `/lifetime-stats` | Every drive ever — distance, energy, and time totals. | Renders an empty state when no data is available — the page is not hidden. | +| Drive Score | `/drive-score` | Smoothness rating per drive (acceleration, braking, cornering). | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| FSD Insights | `/fsd` | Supervised self-driving distance, usage share, and data confidence. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Speed Profile | `/speed-profile` | Speed-vs-time chart for any drive. | Renders an empty state when no data is available — the page is not hidden. | +| Driving Dynamics | `/driving-dynamics` | G-forces, lateral and longitudinal acceleration analysis. | Renders an empty state when no data is available — the page is not hidden. | +| Regen Braking | `/regen-efficiency` | How much energy regenerative braking recaptures. | Renders an empty state when no data is available — the page is not hidden. | +| Route Efficiency | `/route-efficiency` | Compare actual vs predicted Wh/mile for a route. | Renders an empty state when no data is available — the page is not hidden. | +| Drive DNA | `/drive-dna` | Profile the repeatable characteristics of your driving style. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| What-If Simulator | `/what-if` | Simulate how speed, weather, load, and climate change efficiency. | Renders an empty state when no data is available — the page is not hidden. | +| Departure Forecast | `/departure-forecast` | Predict likely departure times from historical routines. | Renders an empty state when no data is available — the page is not hidden. | +| Arrival Reliability | `/arrival-reliability` | Estimate arrival-time reliability and route uncertainty. | Renders an empty state when no data is available — the page is not hidden. | +| Destination Transitions | `/destination-transitions` | Map recurring movement between destinations and likely next stops. | Renders an empty state when no data is available — the page is not hidden. | +| Journey Fragmentation | `/journey-fragmentation` | Measure trip chains, stopovers, and avoidable journey fragments. | Renders an empty state when no data is available — the page is not hidden. | +| Seasonal Efficiency | `/seasonal-efficiency` | Compare efficiency patterns across seasons and weather regimes. | Renders an empty state when no data is available — the page is not hidden. | +| Ghost Racing | `/segments` | Race your historical best on repeated road segments. | Renders an empty state when no data is available — the page is not hidden. | +| Drive detail | `/drives/:id` | Route, energy, FSD share, cost, and session telemetry for one drive. | Open a row from /drives. FSD % needs trip-meter ticks; quantized 1-mile Tesla counters are valid. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-energy.md b/docs/features/catalogue-energy.md new file mode 100644 index 0000000000..b1947b4665 --- /dev/null +++ b/docs/features/catalogue-energy.md @@ -0,0 +1,14 @@ +# Energy + +Sidebar group **Energy**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Energy Usage | `/energy` | Daily kWh in and out of the pack. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Energy Flow | `/energy-flow` | Animated flow diagram showing where the energy is going right now. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Power Flow | `/power-flow` | Live power draw and regen at the wheels. | Renders an empty state when no data is available — the page is not hidden. | +| Solar & Powerwall | `/energy-products` | Solar production and Powerwall stats from your Tesla account. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Energy Ledger | `/energy-ledger` | Reconcile vehicle energy, cost, charging losses, and sources. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Energy Orchestrator | `/energy-orchestrator` | Optimize vehicles, solar, Powerwall, tariffs, and panel capacity. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-home.md b/docs/features/catalogue-home.md new file mode 100644 index 0000000000..a19d920f5f --- /dev/null +++ b/docs/features/catalogue-home.md @@ -0,0 +1,15 @@ +# Home + +Sidebar group **Home**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Dashboard | `/` | Your daily summary — battery, last drive, charging, and alerts at a glance. | Renders an empty state when no data is available — the page is not hidden. | +| Action Center | `/action-center` | Prioritized decisions with evidence, confidence, safe actions, and no fabricated impact. | Renders an empty state when no data is available — the page is not hidden. | +| Explore Features | `/explore` | Browse and search every feature in TeslaSync with a 1-line description for each. | Renders an empty state when no data is available — the page is not hidden. | +| Live Map | `/live` | Real-time map of where your vehicle is right now. | Renders an empty state when no data is available — the page is not hidden. | +| Timeline | `/timeline` | Hour-by-hour history of drives, charges, and events. | Renders an empty state when no data is available — the page is not hidden. | +| Activity Timeline | `/activity` | Unified timeline of drives, charging, alerts, software updates, and annotations. | Renders an empty state when no data is available — the page is not hidden. | +| Weekly Digest | `/weekly-digest` | A printable weekly recap of usage, range, and cost. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-integrations.md b/docs/features/catalogue-integrations.md new file mode 100644 index 0000000000..01f55d64dc --- /dev/null +++ b/docs/features/catalogue-integrations.md @@ -0,0 +1,12 @@ +# Integrations + +Sidebar group **Integrations**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Helix | `/integrations/helix` | Configure the Helix AI assistant — provider, model, API key, and cost cap. | Hidden until Helix is enabled in Settings. | +| API Keys | `/api-keys` | Issue and revoke API keys for external integrations. | Renders an empty state when no data is available — the page is not hidden. | +| Gas Prices | `/gas-price` | Compare your $/mile against gasoline at current prices. | Renders an empty state when no data is available — the page is not hidden. | +| Intelligence Packs | `/intelligence-packs` | Install signed, sandboxed community analytics and automations. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-notifications.md b/docs/features/catalogue-notifications.md new file mode 100644 index 0000000000..6c808268b6 --- /dev/null +++ b/docs/features/catalogue-notifications.md @@ -0,0 +1,17 @@ +# Notifications + +Sidebar group **Notifications**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Notification Inbox | `/notifications/inbox` | Recent alerts and system messages. | Renders an empty state when no data is available — the page is not hidden. | +| Alert Center | `/notifications/alerts` | Active and acknowledged alerts grouped by severity. | Renders an empty state when no data is available — the page is not hidden. | +| Notification Channels | `/notifications/channels` | Where alerts are sent — email, SMS, push, webhook. | Renders an empty state when no data is available — the page is not hidden. | +| Webhooks | `/notifications/webhooks` | POST alerts to your own URL for downstream automation. | Renders an empty state when no data is available — the page is not hidden. | +| Browser Notifications | `/notifications/browser` | Enable browser push notifications for this device. | Renders an empty state when no data is available — the page is not hidden. | +| Quiet Hours | `/notifications/quiet-hours` | Mute non-critical alerts during set times. | Renders an empty state when no data is available — the page is not hidden. | +| Alert Fatigue | `/alert-fatigue` | Identify noisy alert rules and reduce repetitive notifications. | Renders an empty state when no data is available — the page is not hidden. | +| Notification Burn Rate | `/notification-burn-rate` | Track notification reliability against its error budget. | Renders an empty state when no data is available — the page is not hidden. | +| Notification Latency | `/notification-latency` | Measure delivery speed and tail latency by channel. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-ownership-intelligence.md b/docs/features/catalogue-ownership-intelligence.md new file mode 100644 index 0000000000..41429642f1 --- /dev/null +++ b/docs/features/catalogue-ownership-intelligence.md @@ -0,0 +1,18 @@ +# Ownership Intelligence + +Sidebar group **Ownership Intelligence**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Insurance Telematics | `/ownership/insurance-telematics` | Driving-risk scoring and premium evidence for your insurer. | Renders an empty state when no data is available — the page is not hidden. | +| Utility Tariff Lab | `/ownership/tariff-lab` | Compare utility rate plans against your real charging history. | Renders an empty state when no data is available — the page is not hidden. | +| Invoice Reconciliation | `/ownership/charging-reconciliation` | Match charging invoices to sessions and raise disputes. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Driver Attribution | `/ownership/driver-attribution` | Cluster driving fingerprints to attribute trips to drivers. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Warranty Command | `/ownership/warranty-command` | Coverage windows, expiry risk, and claim-readiness evidence. | Renders an empty state when no data is available — the page is not hidden. | +| Data Governance | `/ownership/data-governance` | Plan retention and purge policies before anything is deleted. | Renders an empty state when no data is available — the page is not hidden. | +| Model Trust Lab | `/ownership/model-trust` | Score prediction accuracy, calibration, bias, and drift. | Renders an empty state when no data is available — the page is not hidden. | +| Jurisdiction Compliance | `/ownership/jurisdiction-compliance` | Apportion distance by region for road-usage charges. | Renders an empty state when no data is available — the page is not hidden. | +| Consumables Lifecycle | `/ownership/consumables-lifecycle` | Wear-part life remaining from real usage stress. | Renders an empty state when no data is available — the page is not hidden. | +| Subscription ROI | `/ownership/subscription-roi` | Whether each recurring feature earns back its cost. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-reports.md b/docs/features/catalogue-reports.md new file mode 100644 index 0000000000..89a7104e24 --- /dev/null +++ b/docs/features/catalogue-reports.md @@ -0,0 +1,19 @@ +# Reports + +Sidebar group **Reports**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Statistics | `/statistics` | Bar and pie charts across every metric in the system. | Renders an empty state when no data is available — the page is not hidden. | +| Analytics | `/analytics` | Long-range trends and correlations you can drill into. | Renders an empty state when no data is available — the page is not hidden. | +| Period Comparison | `/period-compare` | Pick two date ranges and see what changed. | Renders an empty state when no data is available — the page is not hidden. | +| Efficiency | `/efficiency` | Wh/mile broken down by speed, climate, and elevation. | Renders an empty state when no data is available — the page is not hidden. | +| Temperature Impact | `/temperature-impact` | How outside temperature affects range and efficiency. | Renders an empty state when no data is available — the page is not hidden. | +| Cost Analysis | `/cost-analysis` | Electricity cost per drive and per mile. | Renders an empty state when no data is available — the page is not hidden. | +| Cost of Ownership | `/tco` | Total cost of ownership — energy, insurance, service, depreciation. | Renders an empty state when no data is available — the page is not hidden. | +| Share Card Studio | `/share-card` | Design privacy-aware visual summaries ready to share. | Renders an empty state when no data is available — the page is not hidden. | +| Carbon Intelligence | `/analytics/carbon` | Track charging emissions and lower-carbon alternatives. | Renders an empty state when no data is available — the page is not hidden. | +| Drive Archetypes | `/drive-archetypes` | Discover recurring drive patterns and representative journeys. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Private Benchmarks | `/benchmarks/privacy` | Compare with similar vehicles without uploading raw trips. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-security.md b/docs/features/catalogue-security.md new file mode 100644 index 0000000000..d193902689 --- /dev/null +++ b/docs/features/catalogue-security.md @@ -0,0 +1,11 @@ +# Security + +Sidebar group **Security**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Security & Access | `/security-access` | Manage who can drive, charge, and unlock your vehicle. | Renders an empty state when no data is available — the page is not hidden. | +| Safety Settings | `/safety-settings` | Speed limit, valet mode, and safety-related preferences. | Renders an empty state when no data is available — the page is not hidden. | +| Guard Mode | `/guard-mode` | Sentry Mode, dashcam, and event-recording settings. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-service.md b/docs/features/catalogue-service.md new file mode 100644 index 0000000000..cb361d406b --- /dev/null +++ b/docs/features/catalogue-service.md @@ -0,0 +1,16 @@ +# Service + +Sidebar group **Service**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Tire Pressure | `/tire-pressure` | Current and historical pressure per tire. | Renders an empty state when no data is available — the page is not hidden. | +| Tire Differential Drift | `/tire-differential-drift` | Detect persistent pressure drift between tires. | Renders an empty state when no data is available — the page is not hidden. | +| Drivetrain Health | `/drivetrain-health` | Motor temperatures, inverter status, and fault codes. | Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions. | +| Software Updates | `/software-updates` | Available firmware updates and changelog. | Renders an empty state when no data is available — the page is not hidden. | +| Firmware Impact | `/firmware-impact` | Compare efficiency and reliability before and after firmware updates. | Renders an empty state when no data is available — the page is not hidden. | +| Maintenance | `/maintenance` | Tire rotations, brake fluid, cabin filter — overdue items first. | Renders an empty state when no data is available — the page is not hidden. | +| Recall & Service Intelligence | `/service-intelligence` | Match recalls and service bulletins to vehicle evidence. | Renders an empty state when no data is available — the page is not hidden. | +| Service Evidence Pack | `/diagnostics/service-evidence` | Export an integrity-checked package of service evidence. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-settings.md b/docs/features/catalogue-settings.md new file mode 100644 index 0000000000..5960f5194d --- /dev/null +++ b/docs/features/catalogue-settings.md @@ -0,0 +1,12 @@ +# Settings + +Sidebar group **Settings**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| General Settings | `/settings` | Units, theme, locale, density, and every app preference. | Renders an empty state when no data is available — the page is not hidden. | +| Fleet Setup | `/settings/fleet-setup` | Connect Tesla, refresh the Fleet token, subscribe telemetry, and confirm streaming. | Empty until Tesla Fleet API is connected in Settings → Fleet Setup. | +| Helix Chat | `/chatbot` | Ask Helix anything about your car or this app. | Hidden until Helix is enabled in Settings. | +| Developer Tools | `/dev-tools` | In-app developer surface — flags, debuggers, and inspectors. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-tesla-physics.md b/docs/features/catalogue-tesla-physics.md new file mode 100644 index 0000000000..4f26a74c4c --- /dev/null +++ b/docs/features/catalogue-tesla-physics.md @@ -0,0 +1,24 @@ +# Tesla Physics + +Sidebar group **Tesla Physics**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| Physics hub | `/tesla-only` | Fifteen Tesla physics views Tesla app and TeslaMate cannot own. | Renders an empty state when no data is available — the page is not hidden. | +| Three Clocks | `/tesla-only/clocks` | Event, ingest, and display time. Ingest stays unknown if not stored. | Renders an empty state when no data is available — the page is not hidden. | +| Life Tape | `/tesla-only/life-tape` | Every second is Park, Neutral, Drive, Charge, or Unknown — not GPS. | Renders an empty state when no data is available — the page is not hidden. | +| Contradiction Court | `/tesla-only/contradictions` | Gear=P with speed is a contradiction. Complete still latched is not. | Renders an empty state when no data is available — the page is not hidden. | +| Trip-Meter Genealogy | `/tesla-only/meters` | Odometer and FSD trip meters. A drop is a reset. Null is not zero. | Renders an empty state when no data is available — the page is not hidden. | +| Unknown OS | `/tesla-only/unknown` | Unknown hours are a budget, never a measured zero of missing physics. | Renders an empty state when no data is available — the page is not hidden. | +| Car Kept Living | `/tesla-only/car-kept-living` | After MQTT or carbon loss: queued, replayed event time, never-received. | Renders an empty state when no data is available — the page is not hidden. | +| Tesla-Language Logbook | `/tesla-only/logbook` | Park, Drive, Neutral, Charging, Complete, Disconnected — Tesla words. | Renders an empty state when no data is available — the page is not hidden. | +| Firmware Epochs | `/tesla-only/firmware-epochs` | Each software version as this VIN physics baseline, not fleet proof. | Renders an empty state when no data is available — the page is not hidden. | +| Charge-Port Court | `/tesla-only/charge-port` | Latch, door, pack current, and ChargeState as one evidence chain. | Renders an empty state when no data is available — the page is not hidden. | +| Black Box 90s | `/tesla-only/black-box` | High-resolution samples in the 90s before Park, unplug, or a gap. | Renders an empty state when no data is available — the page is not hidden. | +| Owner Dictionary | `/tesla-only/dictionary` | This car Complete-to-unplug, Park dwell, and unscheduled Complete. | Renders an empty state when no data is available — the page is not hidden. | +| Physics Vault | `/tesla-only/vault` | Hashed session boundaries, unknown hours, firmware, etiquette dwells. | Renders an empty state when no data is available — the page is not hidden. | +| Mode Laws | `/tesla-only/modes` | Valet, Service, Transport laws. Unknown mode stays unknown. | Renders an empty state when no data is available — the page is not hidden. | +| Nervous System | `/tesla-only/nervous-system` | BMS, Gear, latch, and trip meters: alive, silent, or contradicting. | Renders an empty state when no data is available — the page is not hidden. | +| Range Disagreement | `/tesla-only/range` | Rated, typical, ideal, and energy remaining. Never a true range. | Shows unknown/empty honestly when signals are missing. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue-vehicles.md b/docs/features/catalogue-vehicles.md new file mode 100644 index 0000000000..286f779065 --- /dev/null +++ b/docs/features/catalogue-vehicles.md @@ -0,0 +1,19 @@ +# Vehicles + +Sidebar group **Vehicles**. In the app, expand this section in the left nav (or search `/explore`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +| My Vehicles | `/vehicles` | Manage every Tesla on your account — VIN, options, status. | Renders an empty state when no data is available — the page is not hidden. | +| Vehicle Management | `/vehicle-management` | Fleet API options, specs, warranty, pricing, and payer roles per vehicle. | Renders an empty state when no data is available — the page is not hidden. | +| Vehicle Live View | `/digital-twin` | A live 3D model of your car mirroring doors, lights, and motion. | Renders an empty state when no data is available — the page is not hidden. | +| Compare Vehicles | `/vehicle-comparison` | Side-by-side stats for two or more of your vehicles. | Renders an empty state when no data is available — the page is not hidden. | +| Saved Locations | `/locations` | Frequent destinations — home, work, favorite Superchargers. | Renders an empty state when no data is available — the page is not hidden. | +| Parking Analytics | `/parking` | Parking patterns, dwell time, location mix, and recurring occupancy. | Renders an empty state when no data is available — the page is not hidden. | +| Utilization | `/utilization` | Vehicle availability and productive use across your fleet. | Renders an empty state when no data is available — the page is not hidden. | +| Time Machine | `/time-machine` | Reconstruct vehicle state at any recorded point in time. | Renders an empty state when no data is available — the page is not hidden. | +| Physics Cockpit | `/physics-cockpit` | Live Gear, charge state, port latch, BMS, and trip meters. | Renders an empty state when no data is available — the page is not hidden. | +| Fleet Operations | `/fleet-operations` | Coordinate drivers, bookings, policies, work orders, and utilization. | Renders an empty state when no data is available — the page is not hidden. | +| Warranty & Resale Vault | `/resale-vault` | Create verifiable, selectively disclosed vehicle-history reports. | Renders an empty state when no data is available — the page is not hidden. | + +[← All groups](./catalogue.md) diff --git a/docs/features/catalogue.md b/docs/features/catalogue.md new file mode 100644 index 0000000000..666bff9c9e --- /dev/null +++ b/docs/features/catalogue.md @@ -0,0 +1,44 @@ +# Feature catalogue + +Operator index of TeslaSync screens. Labels and paths come from the live sidebar (`navSections` in `web/src/components/layout/Layout.tsx`). One-line descriptions come from Explore (`web/src/features/explore/featureCatalog.ts`). + +**In the app:** sidebar groups, or **Explore Features** at `/explore`. + +| Sidebar group | Screens | Catalogue page | +| ------------- | ------: | -------------- | +| Home | 7 | [catalogue-home.md](./catalogue-home.md) | +| Vehicles | 11 | [catalogue-vehicles.md](./catalogue-vehicles.md) | +| Tesla Physics | 16 | [catalogue-tesla-physics.md](./catalogue-tesla-physics.md) | +| Driving | 32 | [catalogue-driving.md](./catalogue-driving.md) | +| Charging | 11 | [catalogue-charging.md](./catalogue-charging.md) | +| Battery | 12 | [catalogue-battery.md](./catalogue-battery.md) | +| Energy | 6 | [catalogue-energy.md](./catalogue-energy.md) | +| Service | 8 | [catalogue-service.md](./catalogue-service.md) | +| Cabin | 6 | [catalogue-cabin.md](./catalogue-cabin.md) | +| Reports | 11 | [catalogue-reports.md](./catalogue-reports.md) | +| Commands | 3 | [catalogue-commands.md](./catalogue-commands.md) | +| Automation | 3 | [catalogue-automation.md](./catalogue-automation.md) | +| Notifications | 9 | [catalogue-notifications.md](./catalogue-notifications.md) | +| Advanced Intelligence | 12 | [catalogue-advanced-intelligence.md](./catalogue-advanced-intelligence.md) | +| Ownership Intelligence | 10 | [catalogue-ownership-intelligence.md](./catalogue-ownership-intelligence.md) | +| Security | 3 | [catalogue-security.md](./catalogue-security.md) | +| Account | 9 | [catalogue-account.md](./catalogue-account.md) | +| Settings | 4 | [catalogue-settings.md](./catalogue-settings.md) | +| Integrations | 4 | [catalogue-integrations.md](./catalogue-integrations.md) | +| Data | 3 | [catalogue-data.md](./catalogue-data.md) | +| Diagnostics | 32 | [catalogue-diagnostics.md](./catalogue-diagnostics.md) | +| About | 1 | [catalogue-about.md](./catalogue-about.md) | + +## How to use this + +1. Find the **sidebar group** (same titles as the app). +2. Open the path in your installation (example: `https://your-host/charging`). +3. If a panel is empty, use the **When empty** column — missing telemetry is not a blank product. + +Detail pages such as `/drives/:id` and `/charging/:id` are opened from list rows, not the sidebar. + +Regenerate after nav changes: + +```bash +node docs/scripts/generate-feature-catalogue.mjs +``` diff --git a/docs/get-started.md b/docs/get-started.md new file mode 100644 index 0000000000..f4812bde9b --- /dev/null +++ b/docs/get-started.md @@ -0,0 +1,88 @@ +--- +title: Get started +description: Install TeslaSync, connect Tesla, enable streaming, then find any screen in the app. +--- + +

TeslaSync documentation

+ +# Get started + +Self-hosted Tesla intelligence. Install it, connect a vehicle, then use the catalogue to find every screen. + + + +## Common paths + + + +## Operate and contribute + + + +A local trial is not a public deployment. Add TLS, an authenticating proxy, strong secrets, and backups before you expose the API. + +## See also + +- [Architecture](/guide/architecture) +- [Feature catalogue](/features/catalogue) +- [Remote commands](/guide/remote-commands) +- [Local development](/guide/local-development) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 58c1ad7e7e..befee6e4c6 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -4,6 +4,9 @@ Your first milestone is a connected Tesla account, fresh data from a selected vehicle, and a recorded drive or charging session you can inspect. This guide separates installing TeslaSync from enabling Tesla connectivity. +To find a screen in the app after you are running, use the +[feature catalogue](/features/catalogue) (same groups as the sidebar). + ## Before you begin - Git and Docker with Compose v2. Container installation does not require Go or Node.js. diff --git a/docs/index.md b/docs/index.md index da84e1c71d..f730873832 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,104 +1,69 @@ --- -layout: page -title: TeslaSync — Your Tesla has a story. Own it. -description: Open-source Tesla intelligence on your infrastructure. Start with installation, connect your vehicle, and explore your driving and charging history. +layout: home +title: TeslaSync +description: Self-hosted Tesla intelligence. Own every drive and charge — on your iron. +hero: + name: TeslaSync + text: Stop renting the story of your own car. + tagline: Fleet Telemetry on your MQTT. SI in your Timescale. 213 screens, zero SaaS tax. If you can docker compose, you can ship this tonight. + actions: + - theme: brand + text: Get started + link: /get-started + - theme: alt + text: docker compose up + link: /guide/getting-started + - theme: alt + text: Feature catalogue + link: /features/catalogue +features: + - title: Race your yesterday + details: Ghost Racing, FSD mix, route replay. The car already logged it — we just refuse to throw it away. + link: /features/catalogue-driving + linkText: Driving + - title: Bills that can't gaslight you + details: Session kWh vs Tesla receipts. Wait Oracle from your Supercharger history, not a crowded-lot rumor. + link: /features/catalogue-charging + linkText: Charging + - title: YAML is the product + details: Compose or Helm. One ingest pipeline. Meters, watts, seconds on disk. Display units are a UI problem — as it should be. + link: /deployment/docker + linkText: Deploy + - title: Automate, then sleep + details: 40+ rule templates, geofence routines, Comfort calendars. History before a remote command. Homelab, not a pager. + link: /features/automations + linkText: Automations --- -
-
- - -
-

TeslaSync

-

- Your Tesla - has a story. - Own it. -

-

Open-source Tesla intelligence, on your infrastructure. Turn vehicle data into driving insights, charging history, battery trends, and useful automations.

-

- Get started - Explore the features -

-
- TeslaSync dashboard with vehicle navigation and customizable widgets -
-
-
+
-
-
-

Start here

-

From installation
to your first insight.

-

Install with Docker Compose, connect your Tesla account in Settings → Fleet Setup, then confirm data arrives from your selected vehicle. Streaming needs a separate receiver, public TLS, and Tesla-side setup; starting containers is only the first step.

-

- 1. Install TeslaSync - 2. Connect to Tesla - 3. Enable streaming -

-

A local trial is not a public deployment. Review authentication, TLS, secrets, and backups before exposing your installation.

-

- Deployment checklist - Requirements & FAQ -

-
-
+## Docs that assume you can read a compose file -
-
-

Driving & charging

-

Make sense of
the miles between.

-

Explore recorded drives, replay routes, compare charging sessions, and follow energy use over time. Available detail depends on your vehicle, permissions, configured signals, and the data actually received.

-
-
Recorded drive with route and driving statistics
Understand a drive
-
Charging session with battery and charging statistics
Review a charging session
-
-

- Vehicle history - Analytics & charts - Build your dashboard -

-
-
- -
-
-
-

Alerts & automations

-

Less checking.
More context.

-

Create alerts and automations around the vehicle events that matter to you. Review conditions and actions before enabling them, and inspect execution history. Remote-command availability depends on Tesla permissions, vehicle capability, connectivity, and signing setup.

-

- Build an automation - Configure alerts - Command prerequisites -

-
-
Automation builder with triggers, conditions, and actions
-
-
- -
-
-

Optional Helix AI

-

Ask questions.
Explore your data.

-

Helix adds fleet-aware chat, explanations, summaries, and natural-language drafting. AI features are opt-in; TeslaSync does not require an AI provider. Choose a hosted provider or local Ollama, and review generated suggestions before applying them.

-

Self-hosted does not mean offline. Tesla connectivity uses Tesla services, and configured external providers may receive request context and incur charges. AI output is not a substitute for vehicle diagnostics or professional advice.

-

Providers, privacy & controls

-
-
+ -
-
-

Own the deployment. Join the project.

-

Keep it useful.
Help make it better.

-

Operate your installation with deliberate retention and tested backups. Report problems, improve a guide, or contribute code — start with the contributor walkthrough and choose a focused change.

-

- Configuration - Backups - Troubleshooting - Contribute - Source on GitHub -

-
-
diff --git a/docs/package.json b/docs/package.json index ec265bbaa8..653a6fe956 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,8 @@ "scripts": { "docs:dev": "vitepress dev", "docs:build": "vitepress build", - "docs:preview": "vitepress preview" + "docs:preview": "vitepress preview", + "catalogue": "node scripts/generate-feature-catalogue.mjs" }, "keywords": [], "author": "", diff --git a/docs/public/hero/model3.jpg b/docs/public/hero/model3.jpg new file mode 100644 index 0000000000..e92dbebe54 Binary files /dev/null and b/docs/public/hero/model3.jpg differ diff --git a/docs/public/hero/models.jpg b/docs/public/hero/models.jpg new file mode 100644 index 0000000000..2c7878ec5d Binary files /dev/null and b/docs/public/hero/models.jpg differ diff --git a/docs/public/hero/modely.jpg b/docs/public/hero/modely.jpg new file mode 100644 index 0000000000..b21d59a274 Binary files /dev/null and b/docs/public/hero/modely.jpg differ diff --git a/docs/scripts/generate-feature-catalogue.mjs b/docs/scripts/generate-feature-catalogue.mjs new file mode 100644 index 0000000000..b6b4eb4d27 --- /dev/null +++ b/docs/scripts/generate-feature-catalogue.mjs @@ -0,0 +1,169 @@ +/** + * Generates docs/features/catalogue*.md from the live SPA sidebar + Explore blurbs. + * Run from repo root: node docs/scripts/generate-feature-catalogue.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const layoutPath = path.join(root, 'web/src/components/layout/Layout.tsx'); +const catalogPath = path.join(root, 'web/src/features/explore/featureCatalog.ts'); +const outDir = path.join(root, 'docs/features'); + +const EXTRA_PANELS = { + Charging: [ + { + label: 'Wait Oracle', + to: '/tesla-charging-history', + description: + 'Embedded panel: Supercharger wait forecast (Erlang-C on your site history). Not Tesla live occupancy.', + empty: 'Empty until Supercharger sessions exist for that site name.', + }, + ], + Driving: [ + { + label: 'Drive detail', + to: '/drives/:id', + description: 'Route, energy, FSD share, cost, and session telemetry for one drive.', + empty: 'Open a row from /drives. FSD % needs trip-meter ticks; quantized 1-mile Tesla counters are valid.', + }, + ], + Automation: [ + { + label: 'Comfort calendar', + to: '/automations', + description: 'ICS-driven climate windows (Comfort panel on Automations).', + empty: 'Needs a reachable https ICS URL; loopback and metadata hosts are blocked.', + }, + ], + 'Advanced Intelligence': [ + { + label: 'Storm Guardian', + to: '/intelligence/emergency-resilience', + description: 'Weather-aware energy / charging caution from local storm data.', + empty: 'Needs location history and weather provider configuration.', + }, + ], +}; + +function parseDescriptions(src) { + const map = {}; + const re = /'([^']+)':\s*'((?:\\'|[^'])*)'/g; + const block = src.slice(src.indexOf('const DESCRIPTIONS'), src.indexOf('export function buildFeatureCatalog')); + let m; + while ((m = re.exec(block))) { + map[m[1]] = m[2].replace(/\\'/g, "'"); + } + return map; +} + +function parseNav(src) { + const start = src.indexOf('export const navSections'); + const end = src.indexOf('type NavSection'); + const block = src.slice(start, end); + const sections = []; + const titleRe = /title:\s*'([^']+)'/g; + let tm; + const titles = []; + while ((tm = titleRe.exec(block))) titles.push({ title: tm[1], idx: tm.index }); + for (let i = 0; i < titles.length; i++) { + const chunk = block.slice(titles[i].idx, titles[i + 1]?.idx ?? block.length); + const items = []; + const itemRe = /to:\s*'([^']+)'[\s\S]*?label:\s*'([^']+)'/g; + let im; + while ((im = itemRe.exec(chunk))) items.push({ to: im[1], label: im[2] }); + sections.push({ title: titles[i].title, items }); + } + return sections; +} + +function slug(title) { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +function escapeCell(s) { + return String(s).replace(/\|/g, '\\|').replace(/\n/g, ' '); +} + +function emptyHint(to, description) { + if (/admin|mqtt|redis|dlq|debug/i.test(to)) return 'Operator surface — needs a healthy API, MQTT, and DB.'; + if (/tesla-account|fleet-setup|fleet-api/i.test(to)) return 'Empty until Tesla Fleet API is connected in Settings → Fleet Setup.'; + if (/charging|battery|drive|energy|fsd/i.test(to)) return 'Empty until telemetry (and for billing pages, Tesla charging history) has ingested sessions.'; + if (/helix|chatbot/i.test(to)) return 'Hidden until Helix is enabled in Settings.'; + return description.includes('Never') ? 'Shows unknown/empty honestly when signals are missing.' : 'Renders an empty state when no data is available — the page is not hidden.'; +} + +const layout = fs.readFileSync(layoutPath, 'utf8'); +const catalogSrc = fs.readFileSync(catalogPath, 'utf8'); +const descriptions = parseDescriptions(catalogSrc); +const sections = parseNav(layout); + +const indexRows = sections.map((s) => { + const sl = slug(s.title); + return `| ${s.title} | ${s.items.length} | [catalogue-${sl}.md](./catalogue-${sl}.md) |`; +}); + +const index = `# Feature catalogue + +Operator index of TeslaSync screens. Labels and paths come from the live sidebar (\`navSections\` in \`web/src/components/layout/Layout.tsx\`). One-line descriptions come from Explore (\`web/src/features/explore/featureCatalog.ts\`). + +**In the app:** sidebar groups, or **Explore Features** at \`/explore\`. + +| Sidebar group | Screens | Catalogue page | +| ------------- | ------: | -------------- | +${indexRows.join('\n')} + +## How to use this + +1. Find the **sidebar group** (same titles as the app). +2. Open the path in your installation (example: \`https://your-host/charging\`). +3. If a panel is empty, use the **When empty** column — missing telemetry is not a blank product. + +Detail pages such as \`/drives/:id\` and \`/charging/:id\` are opened from list rows, not the sidebar. + +Regenerate after nav changes: + +\`\`\`bash +node docs/scripts/generate-feature-catalogue.mjs +\`\`\` +`; + +fs.writeFileSync(path.join(outDir, 'catalogue.md'), index); + +for (const section of sections) { + const sl = slug(section.title); + const extras = EXTRA_PANELS[section.title] ?? []; + const rows = [ + ...section.items.map((it) => { + const desc = descriptions[it.to] ?? `Open ${it.label}.`; + return `| ${escapeCell(it.label)} | \`${it.to}\` | ${escapeCell(desc)} | ${escapeCell(emptyHint(it.to, desc))} |`; + }), + ...extras.map( + (p) => + `| ${escapeCell(p.label)} | \`${p.to}\` | ${escapeCell(p.description)} | ${escapeCell(p.empty)} |`, + ), + ]; + const md = `# ${section.title} + +Sidebar group **${section.title}**. In the app, expand this section in the left nav (or search \`/explore\`). + +| Screen | Path | What it does | When empty | +| ------ | ---- | ------------ | ---------- | +${rows.join('\n')} + +[← All groups](./catalogue.md) +`; + fs.writeFileSync(path.join(outDir, `catalogue-${sl}.md`), md); +} + +console.log( + 'Wrote catalogue.md + ' + + sections.length + + ' group pages (' + + sections.reduce((n, s) => n + s.items.length, 0) + + ' screens)', +); diff --git a/internal/api/automation/routines.go b/internal/api/automation/routines.go new file mode 100644 index 0000000000..84ccbec2f5 --- /dev/null +++ b/internal/api/automation/routines.go @@ -0,0 +1,444 @@ +package automation + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/models" +) + +// RoutineAction is one command in a routine template. +type RoutineAction struct { + Command string `json:"command"` + Params map[string]any `json:"params,omitempty"` +} + +// RoutineTemplate is a parameterized geofence routine: an enter/exit trigger +// plus commands, instantiated for a user-chosen place. Unlike static presets +// (which must work without per-user FK references), routines take a place_id +// at install time — the guided-wizard path the presets catalogue defers to. +type RoutineTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Event string `json:"event"` // enter | exit + Actions []RoutineAction `json:"actions"` +} + +// RoutineTemplates is the static catalogue of geofence routines. +func RoutineTemplates() []RoutineTemplate { + return []RoutineTemplate{ + { + ID: "arrive_home", + Name: "Arrive Home", + Description: "When you arrive at home: turn Sentry off and lock the doors.", + Event: "enter", + Actions: []RoutineAction{{Command: "sentry_off"}, {Command: "lock"}}, + }, + { + ID: "leave_home", + Name: "Leave Home", + Description: "When you leave home: turn Sentry on and lock the doors.", + Event: "exit", + Actions: []RoutineAction{{Command: "sentry_on"}, {Command: "lock"}}, + }, + { + ID: "arrive_charger", + Name: "Arrive at Charger", + Description: "When you arrive at a charger: cap the charge limit at 80% for battery health.", + Event: "enter", + Actions: []RoutineAction{{Command: "set_charge_limit", Params: map[string]any{"percent": 80}}}, + }, + { + ID: "leave_work", + Name: "Leave Work", + Description: "When you leave work: start climate so the cabin is comfortable.", + Event: "exit", + Actions: []RoutineAction{{Command: "climate_on"}}, + }, + { + ID: "arrive_work", + Name: "Arrive at Work", + Description: "When you arrive at work: turn Sentry off and climate off.", + Event: "enter", + Actions: []RoutineAction{{Command: "sentry_off"}, {Command: "climate_off"}}, + }, + { + ID: "leave_home_climate", + Name: "Leave Home — Pre-condition", + Description: "When you leave home: start climate and lock the doors.", + Event: "exit", + Actions: []RoutineAction{{Command: "climate_on"}, {Command: "lock"}}, + }, + { + ID: "arrive_home_climate_off", + Name: "Arrive Home — Climate Off", + Description: "When you arrive home: stop HVAC and lock.", + Event: "enter", + Actions: []RoutineAction{{Command: "climate_off"}, {Command: "lock"}}, + }, + { + ID: "arrive_home_windows", + Name: "Arrive Home — Close Windows", + Description: "When you arrive home: close windows and lock.", + Event: "enter", + Actions: []RoutineAction{{Command: "close_windows"}, {Command: "lock"}}, + }, + { + ID: "leave_home_windows", + Name: "Leave Home — Close Windows", + Description: "When you leave home: close windows, arm Sentry, lock.", + Event: "exit", + Actions: []RoutineAction{{Command: "close_windows"}, {Command: "sentry_on"}, {Command: "lock"}}, + }, + { + ID: "arrive_charger_port", + Name: "Arrive at Charger — Open Port", + Description: "When you arrive at a charger: open the charge port and cap at 80%.", + Event: "enter", + Actions: []RoutineAction{{Command: "open_charge_port"}, {Command: "set_charge_limit", Params: map[string]any{"percent": 80}}}, + }, + { + ID: "leave_charger", + Name: "Leave Charger", + Description: "When you leave a charger: stop charging, close the port, lock.", + Event: "exit", + Actions: []RoutineAction{{Command: "charge_stop"}, {Command: "close_charge_port"}, {Command: "lock"}}, + }, + { + ID: "arrive_home_homelink", + Name: "Arrive Home — HomeLink", + Description: "When you arrive home: trigger HomeLink and disarm Sentry.", + Event: "enter", + Actions: []RoutineAction{{Command: "trigger_homelink"}, {Command: "sentry_off"}}, + }, + { + ID: "leave_home_homelink", + Name: "Leave Home — HomeLink", + Description: "When you leave home: trigger HomeLink and arm Sentry.", + Event: "exit", + Actions: []RoutineAction{{Command: "trigger_homelink"}, {Command: "sentry_on"}}, + }, + { + ID: "arrive_work_lock", + Name: "Arrive at Work — Lock", + Description: "When you arrive at work: lock, close windows, arm Sentry.", + Event: "enter", + Actions: []RoutineAction{{Command: "lock"}, {Command: "close_windows"}, {Command: "sentry_on"}}, + }, + { + ID: "leave_work_seats", + Name: "Leave Work — Climate + Seat Heat", + Description: "When you leave work: start climate and heat the driver seat.", + Event: "exit", + Actions: []RoutineAction{{Command: "climate_on"}, {Command: "seat_heater", Params: map[string]any{"seat": 0, "level": 2}}}, + }, + { + ID: "arrive_supercharger", + Name: "Arrive at Supercharger", + Description: "When you arrive at a Supercharger: open the port and set limit 80%.", + Event: "enter", + Actions: []RoutineAction{{Command: "open_charge_port"}, {Command: "set_charge_limit", Params: map[string]any{"percent": 80}}}, + }, + { + ID: "leave_supercharger", + Name: "Leave Supercharger", + Description: "When you leave a Supercharger: close the port and lock.", + Event: "exit", + Actions: []RoutineAction{{Command: "close_charge_port"}, {Command: "lock"}}, + }, + { + ID: "arrive_home_wake", + Name: "Arrive Home — Flash Lights", + Description: "When you arrive home: flash lights so you can find the stall.", + Event: "enter", + Actions: []RoutineAction{{Command: "flash_lights"}}, + }, + { + ID: "leave_home_wake_climate", + Name: "Leave Home — Wake + Climate", + Description: "When you leave home: start climate and unlock.", + Event: "exit", + Actions: []RoutineAction{{Command: "climate_on"}, {Command: "unlock"}}, + }, + { + ID: "arrive_school", + Name: "Arrive at School", + Description: "When you arrive at school: lock and arm Sentry.", + Event: "enter", + Actions: []RoutineAction{{Command: "lock"}, {Command: "sentry_on"}}, + }, + { + ID: "leave_school", + Name: "Leave School", + Description: "When you leave school: start climate for the drive home.", + Event: "exit", + Actions: []RoutineAction{{Command: "climate_on"}}, + }, + { + ID: "arrive_airport", + Name: "Arrive at Airport", + Description: "When you arrive at the airport: lock, close windows, arm Sentry.", + Event: "enter", + Actions: []RoutineAction{{Command: "lock"}, {Command: "close_windows"}, {Command: "sentry_on"}}, + }, + { + ID: "leave_airport", + Name: "Leave Airport", + Description: "When you leave the airport: disarm Sentry and start climate.", + Event: "exit", + Actions: []RoutineAction{{Command: "sentry_off"}, {Command: "climate_on"}}, + }, + { + ID: "arrive_home_frunk", + Name: "Arrive Home — Open Frunk", + Description: "When you arrive home: open the frunk for groceries.", + Event: "enter", + Actions: []RoutineAction{{Command: "frunk_open"}}, + }, + { + ID: "arrive_home_trunk", + Name: "Arrive Home — Open Trunk", + Description: "When you arrive home: open the rear trunk.", + Event: "enter", + Actions: []RoutineAction{{Command: "trunk_open"}}, + }, + { + ID: "arrive_grocery_frunk", + Name: "Arrive at Grocery — Open Frunk", + Description: "When you arrive at a grocery store: open the frunk.", + Event: "enter", + Actions: []RoutineAction{{Command: "frunk_open"}}, + }, + { + ID: "leave_grocery_lock", + Name: "Leave Grocery — Lock", + Description: "When you leave a grocery store: lock and close windows.", + Event: "exit", + Actions: []RoutineAction{{Command: "lock"}, {Command: "close_windows"}}, + }, + { + ID: "leave_home_guest_off", + Name: "Leave Home — Guest Off", + Description: "When you leave home: disable Guest Mode and lock.", + Event: "exit", + Actions: []RoutineAction{{Command: "guest_mode_off"}, {Command: "lock"}}, + }, + { + ID: "arrive_work_guest_off", + Name: "Arrive at Work — Guest Off", + Description: "When you arrive at work: disable Guest Mode and lock.", + Event: "enter", + Actions: []RoutineAction{{Command: "guest_mode_off"}, {Command: "lock"}}, + }, + { + ID: "arrive_home_boombox", + Name: "Arrive Home — Boombox Ping", + Description: "When you arrive home: play a boombox ping so you can find the stall.", + Event: "enter", + Actions: []RoutineAction{{Command: "boombox_ping"}}, + }, + { + ID: "leave_home_flash", + Name: "Leave Home — Flash Lights", + Description: "When you leave home: flash the lights.", + Event: "exit", + Actions: []RoutineAction{{Command: "flash_lights"}}, + }, + { + ID: "arrive_cabin_camp", + Name: "Arrive at Cabin — Camp Mode", + Description: "When you arrive at a cabin: enable Camp Mode.", + Event: "enter", + Actions: []RoutineAction{{Command: "camp_mode"}}, + }, + { + ID: "leave_cabin_keeper_off", + Name: "Leave Cabin — Climate Keeper Off", + Description: "When you leave a cabin: disable Climate Keeper and lock.", + Event: "exit", + Actions: []RoutineAction{{Command: "climate_keeper_off"}, {Command: "lock"}}, + }, + { + ID: "arrive_home_honk", + Name: "Arrive Home — Honk", + Description: "When you arrive home: honk once to confirm arrival.", + Event: "enter", + Actions: []RoutineAction{{Command: "honk_horn"}}, + }, + { + ID: "leave_work_flash", + Name: "Leave Work — Flash Lights", + Description: "When you leave work: flash lights so you can find the car.", + Event: "exit", + Actions: []RoutineAction{{Command: "flash_lights"}}, + }, + { + ID: "arrive_charger_honk", + Name: "Arrive at Charger — Honk", + Description: "When you arrive at a charger: honk and open the charge port.", + Event: "enter", + Actions: []RoutineAction{{Command: "honk_horn"}, {Command: "open_charge_port"}}, + }, + { + ID: "arrive_park_sentry", + Name: "Arrive at Park — Sentry On", + Description: "When you arrive at a park: lock and arm Sentry.", + Event: "enter", + Actions: []RoutineAction{{Command: "lock"}, {Command: "sentry_on"}}, + }, + { + ID: "leave_park_sentry_off", + Name: "Leave Park — Sentry Off", + Description: "When you leave a park: disarm Sentry and start climate.", + Event: "exit", + Actions: []RoutineAction{{Command: "sentry_off"}, {Command: "climate_on"}}, + }, + { + ID: "arrive_home_sunroof_close", + Name: "Arrive Home — Close Sunroof", + Description: "When you arrive home: close the sunroof and lock.", + Event: "enter", + Actions: []RoutineAction{{Command: "sunroof_close"}, {Command: "lock"}}, + }, + { + ID: "leave_home_sunroof_vent", + Name: "Leave Home — Vent Sunroof", + Description: "When you leave home: vent the sunroof.", + Event: "exit", + Actions: []RoutineAction{{Command: "sunroof_vent"}}, + }, + } +} + +// ListRoutineTemplates serves GET /automations/routine-templates. +func (h *AutomationHandler) ListRoutineTemplates(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, RoutineTemplates()) +} + +type installRoutineRequest struct { + PlaceID int64 `json:"place_id"` + VehicleID *int64 `json:"vehicle_id"` + Name string `json:"name"` +} + +// InstallRoutine serves POST /automations/routine-templates/{id}/install. +// It builds the same validated create path as Create (typed steps → +// CreateWithSteps → conflict detection → audit → worker reload). +func (h *AutomationHandler) InstallRoutine(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var tmpl *RoutineTemplate + for _, t := range RoutineTemplates() { + if t.ID == id { + c := t + tmpl = &c + break + } + } + if tmpl == nil { + writeError(w, http.StatusNotFound, "routine template not found") + return + } + var req installRoutineRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.PlaceID <= 0 { + writeError(w, http.StatusBadRequest, "place_id is required") + return + } + + name := req.Name + if name == "" { + name = tmpl.Name + } + steps, err := routineSteps(*tmpl, req.PlaceID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid routine: "+err.Error()) + return + } + creq := &createAutomationRequest{ + Name: name, + Description: tmpl.Description, + VehicleID: req.VehicleID, + Triggers: []automationTypedStep{steps.trigger}, + Actions: steps.actions, + } + writes, err := automationStepWrites(creq) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid routine steps: "+err.Error()) + return + } + a := &models.Automation{ + Name: name, + Description: &tmpl.Description, + VehicleID: req.VehicleID, + Enabled: true, + } + if err := h.repo.CreateWithSteps(r.Context(), a, writes); err != nil { + log.Error().Err(err).Str("routine", tmpl.ID).Msg("failed to install routine") + writeError(w, http.StatusInternalServerError, "failed to install routine") + return + } + + resp := newAutomationResponse(a) + resp.Conflicts = h.detectConflicts(r, a) + if h.auditor != nil { + h.auditor.LogCreated(r.Context(), a.ID, a.Name, firstTriggerKind(creq), a.Enabled, r.RemoteAddr) + } + h.notifyReload(r.Context(), "created", a.ID) + + log.Info().Int64("automation_id", a.ID).Str("routine", tmpl.ID).Msg("routine installed") + writeJSON(w, http.StatusCreated, resp) +} + +type routineStepSet struct { + trigger automationTypedStep + actions []automationTypedStep +} + +// routineSteps builds validated typed steps for a template + place. The +// payload shapes mirror the DTO decoders so automationStepWrites accepts +// them exactly as if they arrived over the wire. +func routineSteps(t RoutineTemplate, placeID int64) (routineStepSet, error) { + if t.Event != "enter" && t.Event != "exit" { + return routineStepSet{}, fmt.Errorf("unknown geofence event %q", t.Event) + } + out := routineStepSet{ + trigger: automationTypedStep{ + Kind: models.AutomationStepKindTriggerGeofence, + Payload: automationTriggerGeofenceDTO{ + Kind: models.AutomationStepKindTriggerGeofence, + PlaceID: placeID, + Event: t.Event, + }, + }, + } + for _, act := range t.Actions { + if act.Command == "" { + return routineStepSet{}, fmt.Errorf("routine action missing command") + } + var raw json.RawMessage + if act.Params != nil { + b, err := json.Marshal(act.Params) + if err != nil { + return routineStepSet{}, err + } + raw = b + } + out.actions = append(out.actions, automationTypedStep{ + Kind: models.AutomationStepKindActionCommand, + Payload: automationActionCommandDTO{ + Kind: models.AutomationStepKindActionCommand, + CommandName: act.Command, + CommandParams: raw, + }, + }) + } + return out, nil +} diff --git a/internal/api/automation/routines_test.go b/internal/api/automation/routines_test.go new file mode 100644 index 0000000000..fa5cfaa543 --- /dev/null +++ b/internal/api/automation/routines_test.go @@ -0,0 +1,100 @@ +package automation + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestRoutineTemplatesCatalogue(t *testing.T) { + all := RoutineTemplates() + if len(all) < 35 { + t.Fatalf("templates = %d, want at least 35", len(all)) + } + seen := map[string]bool{} + for _, tmpl := range all { + if tmpl.ID == "" || tmpl.Name == "" || len(tmpl.Actions) == 0 { + t.Fatalf("incomplete template: %+v", tmpl) + } + if tmpl.Event != "enter" && tmpl.Event != "exit" { + t.Fatalf("bad event %q in %s", tmpl.Event, tmpl.ID) + } + if seen[tmpl.ID] { + t.Fatalf("duplicate template id %s", tmpl.ID) + } + seen[tmpl.ID] = true + if _, err := routineSteps(tmpl, 9); err != nil { + t.Fatalf("routineSteps(%s) error: %v", tmpl.ID, err) + } + } +} + +func TestListRoutineTemplates(t *testing.T) { + h := &AutomationHandler{} + req := httptest.NewRequest(http.MethodGet, "/routine-templates", nil) + rec := httptest.NewRecorder() + h.ListRoutineTemplates(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var out []RoutineTemplate + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if len(out) < 35 { + t.Fatalf("templates = %d, want at least 35", len(out)) + } +} + +func TestInstallRoutineCreatesAutomation(t *testing.T) { + repo := &automationPersistenceFakeRepo{} + h := &AutomationHandler{repo: repo} + r := chi.NewRouter() + r.Post("/routine-templates/{id}/install", h.InstallRoutine) + + body := `{"place_id":7,"name":"Arrive Home"}` + req := httptest.NewRequest(http.MethodPost, "/routine-templates/arrive_home/install", strings.NewReader(body)) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201 (%s)", rec.Code, rec.Body.String()) + } + if len(repo.committedSteps) != 3 { // 1 trigger + 2 actions + t.Fatalf("steps = %d, want 3", len(repo.committedSteps)) + } + if repo.committedParent == nil || repo.committedParent.Name != "Arrive Home" { + t.Fatalf("parent = %+v", repo.committedParent) + } +} + +func TestInstallRoutineRejectsUnknownTemplate(t *testing.T) { + h := &AutomationHandler{repo: &automationPersistenceFakeRepo{}} + r := chi.NewRouter() + r.Post("/routine-templates/{id}/install", h.InstallRoutine) + req := httptest.NewRequest(http.MethodPost, "/routine-templates/nope/install", strings.NewReader(`{"place_id":7}`)) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestInstallRoutineRejectsMissingPlace(t *testing.T) { + repo := &automationPersistenceFakeRepo{} + h := &AutomationHandler{repo: repo} + r := chi.NewRouter() + r.Post("/routine-templates/{id}/install", h.InstallRoutine) + req := httptest.NewRequest(http.MethodPost, "/routine-templates/arrive_home/install", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if repo.committedParent != nil { + t.Fatal("invalid install must not reach the repo") + } +} diff --git a/internal/api/batterydegradation/calculations.go b/internal/api/batterydegradation/calculations.go index 1bdbdbd63c..69413f34db 100644 --- a/internal/api/batterydegradation/calculations.go +++ b/internal/api/batterydegradation/calculations.go @@ -9,6 +9,7 @@ import ( func (h *Handler) predictDegradation(snapshots []batterySnapshotData) regressionResult { res := regressionResult{} pred := &res.Prediction + res.Horizon.Points = []horizonPoint{} if len(snapshots) < 3 { res.Projections = []predictiveProjection{} @@ -81,7 +82,7 @@ func (h *Handler) predictDegradation(snapshots []batterySnapshotData) regression var oldProjections []projPoint var enhancedProjections []predictiveProjection - for i := 0; i <= 36; i++ { + for i := 0; i <= 60; i++ { futureYears := currentYears + float64(i)/12.0 health := intercept + slope*futureYears if health < 0 { @@ -123,9 +124,38 @@ func (h *Handler) predictDegradation(snapshots []batterySnapshotData) regression } res.Projections = enhancedProjections + res.Horizon = horizonOutlook{ + Points: horizonPoints(currentYears, xBar, intercept, slope, se, ssx, n, tValue), + DataMonths: int(math.Round((snapshots[len(snapshots)-1].CreatedAt.Sub(firstTime).Hours() / 24 / 30.44))), + SlopePerYear: math.Round(slope*100) / 100, + HasEnoughData: true, + } return res } +// horizonPoints evaluates the fitted line at the 1/3/5-year horizons with +// the same prediction-interval math as the monthly projections. +func horizonPoints(currentYears, xBar, intercept, slope, se, ssx, n, tValue float64) []horizonPoint { + out := make([]horizonPoint, 0, 3) + for _, years := range []int{1, 3, 5} { + fy := currentYears + float64(years) + health := intercept + slope*fy + health = math.Min(100, math.Max(0, health)) + xDev := fy - xBar + piWidth := 0.0 + if ssx > 1e-10 && n > 2 { + piWidth = tValue * se * math.Sqrt(1+1/n+(xDev*xDev)/ssx) + } + out = append(out, horizonPoint{ + Years: years, + HealthPct: math.Round(health*10) / 10, + ConfidenceLow: math.Round(math.Max(0, health-piWidth)*10) / 10, + ConfidenceHigh: math.Round(math.Min(100, health+piWidth)*10) / 10, + }) + } + return out +} + // computeRiskFactors scores 5 battery risk categories (0-100, higher = more risk). func computeRiskFactors(fastChargePct, highSocPct, avgCellTemp, cyclesPerMonth, deepDischargePct float64) []riskFactor { factors := make([]riskFactor, 0, 5) diff --git a/internal/api/batterydegradation/certificate.go b/internal/api/batterydegradation/certificate.go new file mode 100644 index 0000000000..078e0d11c6 --- /dev/null +++ b/internal/api/batterydegradation/certificate.go @@ -0,0 +1,131 @@ +package batterydegradation + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// Battery certificate: a server-signed, buyer-verifiable attestation of a +// vehicle's battery health for resale. The seller issues it (authenticated), +// shares the JSON + signature with a buyer, and anyone verifies it against +// the public verify endpoint without an account. +// +// The signature is HMAC-SHA256 over the struct's encoding/json bytes, which +// are deterministic (field order follows declaration order), keyed by a +// domain-separated derivation of the auth JWT secret so no new secret needs +// provisioning and the JWT key is never reused across protocols. + +const ( + // batteryCertIssuer identifies the attestation origin. + batteryCertIssuer = "teslasync" + // batteryCertVersion versions the signed payload shape. + batteryCertVersion = 1 + // batteryCertValidity bounds how long an issued certificate verifies. + batteryCertValidity = 30 * 24 * time.Hour + // batteryCertKeyDomain separates the derived HMAC key from the JWT key. + batteryCertKeyDomain = "teslasync-battery-cert-v1\x00" +) + +// BatteryCertificate is the signed payload. Compact by design: only the +// buyer-relevant health snapshot, no history or per-session detail. +type BatteryCertificate struct { + Issuer string `json:"issuer"` + Version int `json:"version"` + VehicleID int64 `json:"vehicle_id"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + CurrentSOH float64 `json:"current_soh"` + EstimatedCapacityKWh float64 `json:"estimated_capacity_kwh"` + OriginalCapacityKWh float64 `json:"original_capacity_kwh"` + DegradationRatePctPerYr float64 `json:"degradation_rate_pct_per_year"` + BatteryAgeMonths int `json:"battery_age_months"` + TotalCycles int `json:"total_cycles"` + ChargeHabitsScore float64 `json:"charge_habits_score"` + StressLevel string `json:"stress_level"` + FastChargePct float64 `json:"fast_charge_pct"` + TempExposureScore *int `json:"temp_exposure_score"` + TempExposureReason *string `json:"temp_exposure_reason"` +} + +// NewBatteryCertificate builds the signed payload from a health response. +// Pure: no I/O, deterministic for a fixed now. +func NewBatteryCertificate(health *batteryHealthResponse, now time.Time) *BatteryCertificate { + now = now.UTC().Truncate(time.Second) + return &BatteryCertificate{ + Issuer: batteryCertIssuer, + Version: batteryCertVersion, + VehicleID: health.VehicleID, + IssuedAt: now, + ExpiresAt: now.Add(batteryCertValidity), + CurrentSOH: health.CurrentSoh, + EstimatedCapacityKWh: health.EstimatedCapacityWh / 1000.0, + OriginalCapacityKWh: health.OriginalCapacityWh / 1000.0, + DegradationRatePctPerYr: health.DegradationRatePctPerYear, + BatteryAgeMonths: health.BatteryAgeMonths, + TotalCycles: health.TotalCycles, + ChargeHabitsScore: health.ChargeHabitsScore, + StressLevel: health.StressLevel, + FastChargePct: health.FastChargePct, + TempExposureScore: health.TempExposureScore, + TempExposureReason: health.TempExposureReason, + } +} + +// DeriveCertKey derives the certificate HMAC key from the auth JWT secret +// with a fixed domain separator. +func DeriveCertKey(jwtSecret string) []byte { + sum := sha256.Sum256([]byte(batteryCertKeyDomain + jwtSecret)) + return sum[:] +} + +// CertSigner signs and verifies battery certificates. The zero value is +// unusable; construct with a derived key. Safe for concurrent use. +type CertSigner struct { + key []byte +} + +// NewCertSigner wires a signer. Panics on an empty key (fail-fast wiring). +func NewCertSigner(key []byte) *CertSigner { + if len(key) == 0 { + panic("batterydegradation: empty certificate key") + } + return &CertSigner{key: key} +} + +// Sign returns the hex HMAC-SHA256 of the certificate's canonical bytes. +func (s *CertSigner) Sign(cert *BatteryCertificate) (string, error) { + raw, err := json.Marshal(cert) + if err != nil { + return "", fmt.Errorf("marshal certificate: %w", err) + } + mac := hmac.New(sha256.New, s.key) + mac.Write(raw) + return hex.EncodeToString(mac.Sum(nil)), nil +} + +// Verify reports whether sig is a valid signature for cert at time now. A +// structurally valid but expired certificate does NOT verify: expiry is +// part of authenticity for a point-in-time health attestation. +func (s *CertSigner) Verify(cert *BatteryCertificate, sig string, now time.Time) bool { + if cert == nil || cert.Issuer != batteryCertIssuer || cert.Version != batteryCertVersion { + return false + } + if !now.Before(cert.ExpiresAt) || now.Before(cert.IssuedAt.Add(-time.Hour)) { + return false + } + want, err := s.Sign(cert) + if err != nil { + return false + } + got, err := hex.DecodeString(sig) + if err != nil { + return false + } + wantRaw, _ := hex.DecodeString(want) + return subtle.ConstantTimeCompare(got, wantRaw) == 1 +} diff --git a/internal/api/batterydegradation/certificate_handler.go b/internal/api/batterydegradation/certificate_handler.go new file mode 100644 index 0000000000..447794d911 --- /dev/null +++ b/internal/api/batterydegradation/certificate_handler.go @@ -0,0 +1,111 @@ +package batterydegradation + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// healthLoaderFunc loads the full battery health response backing a +// certificate. Handler.buildBatteryHealth satisfies it. +type healthLoaderFunc func(ctx context.Context, vehicleID int64) (*batteryHealthResponse, batteryHealthTimings, error) + +// CertificateHandler issues server-signed battery certificates +// (authenticated) and verifies them (public, no auth). It reuses the +// battery-health loader so the attestation always matches what the owner +// sees in the app. +// +// Stateless beyond its constructor inputs; safe for concurrent use. +type CertificateHandler struct { + load healthLoaderFunc + signer *CertSigner + now func() time.Time +} + +// NewCertificateHandler wires the handler. Panics on nil inputs +// (fail-fast wiring contract, matching sibling handlers). +func NewCertificateHandler(load healthLoaderFunc, signer *CertSigner) *CertificateHandler { + if load == nil || signer == nil { + panic("batterydegradation: nil certificate dependency") + } + return &CertificateHandler{load: load, signer: signer, now: time.Now} +} + +// NewCertificateHandlerFromBatteryHandler wires the handler from the +// battery-health Handler so the attestation reuses its loader (and cache +// behavior) without exporting loader internals. +func NewCertificateHandlerFromBatteryHandler(h *Handler, signer *CertSigner) *CertificateHandler { + if h == nil { + panic("batterydegradation: nil battery handler") + } + return NewCertificateHandler(h.healthLoader, signer) +} + +type certificateIssueResponse struct { + Certificate *BatteryCertificate `json:"certificate"` + Signature string `json:"signature"` +} + +// Issue serves GET /analytics/battery-health/certificate?vehicle_id=. +func (h *CertificateHandler) Issue(w http.ResponseWriter, r *http.Request) { + vehicleIDStr := r.URL.Query().Get("vehicle_id") + vehicleID, err := strconv.ParseInt(vehicleIDStr, 10, 64) + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + + health, _, err := h.load(r.Context(), vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("battery certificate: health load failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load battery health") + return + } + + cert := NewBatteryCertificate(health, h.now()) + sig, err := h.signer.Sign(cert) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("battery certificate: sign failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to sign certificate") + return + } + + httpx.WriteJSON(w, http.StatusOK, certificateIssueResponse{Certificate: cert, Signature: sig}) +} + +type certificateVerifyRequest struct { + Certificate *BatteryCertificate `json:"certificate"` + Signature string `json:"signature"` +} + +type certificateVerifyResponse struct { + Valid bool `json:"valid"` + Certificate *BatteryCertificate `json:"certificate,omitempty"` +} + +// Verify serves POST /api/v1/public/battery-certificate/verify. Public: no +// auth, rate-limited at the router. It never reveals why verification +// failed beyond the boolean — the certificate is caller-supplied. +func (h *CertificateHandler) Verify(w http.ResponseWriter, r *http.Request) { + var req certificateVerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Certificate == nil || req.Signature == "" { + httpx.WriteError(w, http.StatusBadRequest, "certificate and signature are required") + return + } + + if !h.signer.Verify(req.Certificate, req.Signature, h.now()) { + httpx.WriteJSON(w, http.StatusOK, certificateVerifyResponse{Valid: false}) + return + } + httpx.WriteJSON(w, http.StatusOK, certificateVerifyResponse{Valid: true, Certificate: req.Certificate}) +} diff --git a/internal/api/batterydegradation/certificate_test.go b/internal/api/batterydegradation/certificate_test.go new file mode 100644 index 0000000000..0584f8922b --- /dev/null +++ b/internal/api/batterydegradation/certificate_test.go @@ -0,0 +1,180 @@ +package batterydegradation + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func testHealth() *batteryHealthResponse { + score := 82 + reason := "garage-kept" + return &batteryHealthResponse{ + VehicleID: 7, + CurrentSoh: 91.5, + EstimatedCapacityWh: 68625, + OriginalCapacityWh: 75000, + DegradationRatePctPerYear: 1.8, + BatteryAgeMonths: 36, + TotalCycles: 412, + ChargeHabitsScore: 88, + StressLevel: "low", + FastChargePct: 12.5, + TempExposureScore: &score, + TempExposureReason: &reason, + } +} + +func testSigner() *CertSigner { + return NewCertSigner(DeriveCertKey("test-jwt-secret")) +} + +func TestCertificateRoundTrip(t *testing.T) { + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + cert := NewBatteryCertificate(testHealth(), now) + + if cert.Issuer != "teslasync" || cert.Version != 1 { + t.Fatalf("unexpected header: %+v", cert) + } + if !cert.ExpiresAt.Equal(now.Add(30 * 24 * time.Hour)) { + t.Fatalf("expires_at = %v, want +30d", cert.ExpiresAt) + } + if cert.EstimatedCapacityKWh != 68.625 { + t.Fatalf("estimated_capacity_kwh = %v, want 68.625", cert.EstimatedCapacityKWh) + } + + signer := testSigner() + sig, err := signer.Sign(cert) + if err != nil { + t.Fatalf("sign: %v", err) + } + if !signer.Verify(cert, sig, now.Add(time.Hour)) { + t.Fatal("valid certificate did not verify") + } +} + +func TestCertificateRejectsTampering(t *testing.T) { + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + signer := testSigner() + cert := NewBatteryCertificate(testHealth(), now) + sig, err := signer.Sign(cert) + if err != nil { + t.Fatalf("sign: %v", err) + } + + tampered := *cert + tampered.CurrentSOH = 99.9 + if signer.Verify(&tampered, sig, now) { + t.Fatal("tampered certificate verified") + } + if signer.Verify(cert, sig+"00", now) { + t.Fatal("corrupted signature verified") + } + if signer.Verify(cert, "not-hex!!", now) { + t.Fatal("non-hex signature verified") + } +} + +func TestCertificateRejectsExpiryAndWrongKey(t *testing.T) { + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + signer := testSigner() + cert := NewBatteryCertificate(testHealth(), now) + sig, err := signer.Sign(cert) + if err != nil { + t.Fatalf("sign: %v", err) + } + + if signer.Verify(cert, sig, now.Add(31*24*time.Hour)) { + t.Fatal("expired certificate verified") + } + other := NewCertSigner(DeriveCertKey("different-secret")) + if other.Verify(cert, sig, now) { + t.Fatal("certificate verified under a different key") + } + // Domain separation: the raw JWT secret is not the HMAC key. + raw := NewCertSigner([]byte("test-jwt-secret")) + if raw.Verify(cert, sig, now) { + t.Fatal("certificate verified under the raw JWT secret") + } +} + +func newCertHandlerForTest() *CertificateHandler { + h := NewCertificateHandler( + func(_ context.Context, _ int64) (*batteryHealthResponse, batteryHealthTimings, error) { + return testHealth(), batteryHealthTimings{}, nil + }, + testSigner(), + ) + h.now = func() time.Time { return time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) } + return h +} + +func TestIssueReturnsSignedCertificate(t *testing.T) { + h := newCertHandlerForTest() + req := httptest.NewRequest(http.MethodGet, "/certificate?vehicle_id=7", nil) + rec := httptest.NewRecorder() + h.Issue(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var res certificateIssueResponse + if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + if res.Certificate == nil || res.Signature == "" { + t.Fatalf("missing certificate or signature: %+v", res) + } + if !testSigner().Verify(res.Certificate, res.Signature, time.Date(2026, 3, 2, 0, 0, 0, 0, time.UTC)) { + t.Fatal("issued certificate does not verify") + } +} + +func TestIssueRejectsBadVehicle(t *testing.T) { + h := newCertHandlerForTest() + req := httptest.NewRequest(http.MethodGet, "/certificate", nil) + rec := httptest.NewRecorder() + h.Issue(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestVerifyEndpointAcceptsAndRejects(t *testing.T) { + h := newCertHandlerForTest() + now := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + cert := NewBatteryCertificate(testHealth(), now) + sig, err := testSigner().Sign(cert) + if err != nil { + t.Fatalf("sign: %v", err) + } + + post := func(body interface{}) certificateVerifyResponse { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/verify", bytes.NewReader(raw)) + rec := httptest.NewRecorder() + h.Verify(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var res certificateVerifyResponse + if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { + t.Fatalf("decode: %v", err) + } + return res + } + + if got := post(certificateVerifyRequest{Certificate: cert, Signature: sig}); !got.Valid { + t.Fatal("valid certificate rejected") + } + tampered := *cert + tampered.TotalCycles = 0 + if got := post(certificateVerifyRequest{Certificate: &tampered, Signature: sig}); got.Valid { + t.Fatal("tampered certificate accepted") + } +} diff --git a/internal/api/batterydegradation/dtos.go b/internal/api/batterydegradation/dtos.go index 7432ae6e1e..07464f2e9c 100644 --- a/internal/api/batterydegradation/dtos.go +++ b/internal/api/batterydegradation/dtos.go @@ -41,9 +41,28 @@ type riskFactor struct { type regressionResult struct { Prediction degradationPrediction Projections []predictiveProjection + Horizon horizonOutlook RatePerMonth float64 } +// horizonPoint is the projected pack health at a fixed year horizon with +// the regression prediction interval. +type horizonPoint struct { + Years int `json:"years"` + HealthPct float64 `json:"health_pct"` + ConfidenceLow float64 `json:"confidence_low"` + ConfidenceHigh float64 `json:"confidence_high"` +} + +// horizonOutlook pins the 1/3/5-year twin readout. DataMonths reports how +// many months of history back the fit so consumers can discount young fits. +type horizonOutlook struct { + Points []horizonPoint `json:"points"` + DataMonths int `json:"data_months"` + SlopePerYear float64 `json:"slope_per_year"` + HasEnoughData bool `json:"has_enough_data"` +} + type chargingHabits struct { FastChargeCount int `json:"fast_charge_count"` SlowChargeCount int `json:"slow_charge_count"` diff --git a/internal/api/batterydegradation/handler.go b/internal/api/batterydegradation/handler.go index e580eb4e40..c095030131 100644 --- a/internal/api/batterydegradation/handler.go +++ b/internal/api/batterydegradation/handler.go @@ -300,6 +300,7 @@ func (h *Handler) Predict(w http.ResponseWriter, r *http.Request) { "degradation_rate_pct_per_month": math.Round(result.RatePerMonth*1000) / 1000, "projected_80pct_date": result.Prediction.PredictedDate, "projections": result.Projections, + "horizon_outlook": result.Horizon, "risk_factors": riskFactors, "recommendations": recommendations, "battery_capacity_wh": capacityWh, diff --git a/internal/api/batterydegradation/horizon_test.go b/internal/api/batterydegradation/horizon_test.go new file mode 100644 index 0000000000..411628830e --- /dev/null +++ b/internal/api/batterydegradation/horizon_test.go @@ -0,0 +1,53 @@ +package batterydegradation + +import ( + "testing" + "time" +) + +func horizonSnapshots() []batterySnapshotData { + base := time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC) + out := make([]batterySnapshotData, 0, 13) + for i := 0; i < 13; i++ { + out = append(out, batterySnapshotData{ + HealthScore: 96 - float64(i)*0.2, // ~-2.4%/yr + CreatedAt: base.AddDate(0, i, 0), + }) + } + return out +} + +func TestPredictDegradationHorizonOutlook(t *testing.T) { + h := &Handler{now: func() time.Time { return time.Date(2025, 2, 1, 12, 0, 0, 0, time.UTC) }} + res := h.predictDegradation(horizonSnapshots()) + if !res.Horizon.HasEnoughData { + t.Fatal("expected HasEnoughData") + } + if len(res.Horizon.Points) != 3 { + t.Fatalf("points = %d, want 3", len(res.Horizon.Points)) + } + if res.Horizon.DataMonths < 11 || res.Horizon.DataMonths > 13 { + t.Fatalf("data months = %d, want ~12", res.Horizon.DataMonths) + } + prev := 101.0 + for _, p := range res.Horizon.Points { + if p.HealthPct >= prev { + t.Fatalf("horizon not declining: %+v", res.Horizon.Points) + } + prev = p.HealthPct + if p.ConfidenceLow > p.HealthPct || p.ConfidenceHigh < p.HealthPct { + t.Fatalf("broken interval: %+v", p) + } + } + if len(res.Projections) != 61 { + t.Fatalf("projections = %d, want 61 (60 months)", len(res.Projections)) + } +} + +func TestPredictDegradationHorizonEmpty(t *testing.T) { + h := &Handler{now: time.Now} + res := h.predictDegradation(nil) + if res.Horizon.HasEnoughData || res.Horizon.Points == nil { + t.Fatalf("empty input must yield empty outlook: %+v", res.Horizon) + } +} diff --git a/internal/api/chargeautopilot/compute.go b/internal/api/chargeautopilot/compute.go new file mode 100644 index 0000000000..d7bac376b9 --- /dev/null +++ b/internal/api/chargeautopilot/compute.go @@ -0,0 +1,306 @@ +package chargeautopilot + +import ( + "fmt" + "math" + "sort" + "time" +) + +// Profile is the wire shape for an Autopilot configuration. +type Profile struct { + VehicleID int64 `json:"vehicle_id"` + Enabled bool `json:"enabled"` + TargetSOC int `json:"target_soc"` + ReadyBy string `json:"ready_by"` // daily "HH:MM" + RatePlan string `json:"rate_plan"` + DailyCapSOC int `json:"daily_cap_soc"` + TripOverride bool `json:"trip_override"` + Precondition bool `json:"precondition"` + MaxAmps int `json:"max_amps"` + BatteryCapacityKWh float64 `json:"battery_capacity_kwh"` +} + +// DefaultProfile returns the out-of-box profile for a vehicle. +func DefaultProfile(vehicleID int64) Profile { + return Profile{ + VehicleID: vehicleID, + Enabled: false, + TargetSOC: 80, + ReadyBy: "07:30", + RatePlan: "pge-ev2a", + DailyCapSOC: 80, + TripOverride: false, + Precondition: true, + MaxAmps: 32, + BatteryCapacityKWh: 75, + } +} + +// ValidateProfile rejects out-of-range configuration before persistence. +func ValidateProfile(p Profile) error { + if p.VehicleID <= 0 { + return fmt.Errorf("vehicle_id is required") + } + if p.TargetSOC < 20 || p.TargetSOC > 100 { + return fmt.Errorf("target_soc must be 20..100") + } + if p.DailyCapSOC < 50 || p.DailyCapSOC > 100 { + return fmt.Errorf("daily_cap_soc must be 50..100") + } + if _, _, err := parseReadyBy(p.ReadyBy); err != nil { + return err + } + if !KnownRatePlan(p.RatePlan) { + return fmt.Errorf("unknown rate plan: %s", p.RatePlan) + } + if p.MaxAmps < 8 || p.MaxAmps > 80 { + return fmt.Errorf("max_amps must be 8..80") + } + if p.BatteryCapacityKWh <= 0 || p.BatteryCapacityKWh > 250 { + return fmt.Errorf("battery_capacity_kwh must be positive") + } + return nil +} + +// EffectiveTarget applies the battery-health guardrail: without a trip +// override the charge target is capped at the daily cap (default 80%). +// Returns the effective target and whether the cap engaged. +func EffectiveTarget(target, dailyCap int, tripOverride bool) (int, bool) { + if !tripOverride && target > dailyCap { + return dailyCap, true + } + return target, false +} + +func parseReadyBy(s string) (hour, min int, err error) { + n, scanErr := fmt.Sscanf(s, "%d:%d", &hour, &min) + if scanErr != nil || n != 2 || hour < 0 || hour > 23 || min < 0 || min > 59 { + return 0, 0, fmt.Errorf("ready_by must be HH:MM (24h)") + } + return hour, min, nil +} + +// NextReadyBy resolves a daily "HH:MM" ready-by time to the next future +// occurrence after now. +func NextReadyBy(readyBy string, now time.Time) (time.Time, error) { + h, m, err := parseReadyBy(readyBy) + if err != nil { + return time.Time{}, err + } + next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location()) + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + return next, nil +} + +// ── Preview engine (pure, no I/O) ──────────────────────────── + +// PreviewInput seeds a next-run preview. +type PreviewInput struct { + Profile Profile + CurrentSOC int + Now time.Time +} + +// PreviewWindow is one priced charge window. +type PreviewWindow struct { + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + RateCentsKWh float64 `json:"rate_cents_kwh"` + EstCost float64 `json:"estimated_cost"` + RateTier string `json:"rate_tier"` +} + +// PreviewResult is the next-run preview: cheapest window, charge-now +// comparison, guardrail outcome, and a human-readable explanation. +type PreviewResult struct { + EffectiveTargetSOC int `json:"effective_target_soc"` + CappedByHealth bool `json:"capped_by_health_guardrail"` + ReadyBy time.Time `json:"ready_by"` + KWhNeeded float64 `json:"kwh_needed"` + EstDurationHours float64 `json:"estimated_duration_hours"` + Window PreviewWindow `json:"window"` + ChargeNowCost float64 `json:"charge_now_cost"` + OptimizedCost float64 `json:"optimized_cost"` + Savings float64 `json:"savings"` + SavingsPct float64 `json:"savings_percent"` + HourlyRates []hourlyRate `json:"hourly_rates"` + Explanation string `json:"explanation"` +} + +type hourlyRate struct { + Hour int `json:"hour"` + RateCents float64 `json:"rate_cents"` + Tier string `json:"tier"` +} + +// Preview computes the cheapest contiguous charge window before the next +// ready-by occurrence. Errors are user-facing feasibility problems +// (already at target, not enough time, unknown rate plan). +func Preview(in PreviewInput) (*PreviewResult, error) { + p := in.Profile + plan, ok := ratePlans[p.RatePlan] + if !ok { + return nil, fmt.Errorf("unknown rate plan: %s", p.RatePlan) + } + target, capped := EffectiveTarget(p.TargetSOC, p.DailyCapSOC, p.TripOverride) + if in.CurrentSOC >= target { + return nil, fmt.Errorf("current SOC (%d%%) already meets target (%d%%)", in.CurrentSOC, target) + } + readyBy, err := NextReadyBy(p.ReadyBy, in.Now) + if err != nil { + return nil, err + } + + kwhNeeded := float64(target-in.CurrentSOC) / 100.0 * p.BatteryCapacityKWh + chargeRateKW := 240.0 * float64(p.MaxAmps) / 1000.0 + kwhWithLoss := kwhNeeded * 1.10 + durationHours := kwhWithLoss / chargeRateKW + durationCeil := int(math.Ceil(durationHours)) + if durationCeil <= 0 { + durationCeil = 1 + } + if float64(durationCeil) > readyBy.Sub(in.Now).Hours() { + return nil, fmt.Errorf( + "not enough time: need %.1f hours but only %.1f hours until ready-by", + durationHours, readyBy.Sub(in.Now).Hours(), + ) + } + + rates := buildHourlyRates(plan.Seasons[seasonForDate(plan, readyBy)]) + + type candidate struct { + startHour int + cost float64 + avgRate float64 + tier string + } + var candidates []candidate + for startH := 0; startH < 24; startH++ { + start := time.Date(readyBy.Year(), readyBy.Month(), readyBy.Day(), startH, 0, 0, 0, readyBy.Location()) + if start.After(readyBy) { + start = start.AddDate(0, 0, -1) + } + end := start.Add(time.Duration(durationCeil) * time.Hour) + if start.Before(in.Now) || end.After(readyBy) { + continue + } + cost, avg := costForWindow(rates, startH, durationCeil, kwhNeeded) + counts := map[string]int{} + for i := 0; i < durationCeil; i++ { + counts[rates[(startH+i)%24].Tier]++ + } + dominant, max := "unknown", 0 + for t, c := range counts { + if c > max { + dominant, max = t, c + } + } + candidates = append(candidates, candidate{startH, cost, avg, dominant}) + } + if len(candidates) == 0 { + return nil, fmt.Errorf("no valid charging window found before ready-by") + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].cost < candidates[j].cost }) + best := candidates[0] + + chargeNowCost, _ := costForWindow(rates, in.Now.Hour(), durationCeil, kwhNeeded) + savings := chargeNowCost - best.cost + savingsPct := 0.0 + if chargeNowCost > 0 { + savingsPct = savings / chargeNowCost * 100.0 + } + + bestStart := time.Date(readyBy.Year(), readyBy.Month(), readyBy.Day(), best.startHour, 0, 0, 0, readyBy.Location()) + if bestStart.After(readyBy) { + bestStart = bestStart.AddDate(0, 0, -1) + } + bestEnd := bestStart.Add(time.Duration(float64(time.Hour) * durationHours)) + + explanation := fmt.Sprintf( + "Charge %d%% → %d%% (%.1f kWh) in the %s window starting %s to be ready by %s, saving %s vs charging now.", + in.CurrentSOC, target, round2(kwhNeeded), best.tier, + bestStart.Format("15:04"), readyBy.Format("15:04"), + fmtMoney(savings), + ) + if capped { + explanation += fmt.Sprintf(" Health guardrail capped the %d%% request to %d%% for daily driving.", p.TargetSOC, target) + } + if p.Precondition { + explanation += " Cabin/battery preconditioning runs before departure." + } + + return &PreviewResult{ + EffectiveTargetSOC: target, + CappedByHealth: capped, + ReadyBy: readyBy, + KWhNeeded: round2(kwhNeeded), + EstDurationHours: round2(durationHours), + Window: PreviewWindow{ + StartTime: bestStart, + EndTime: bestEnd, + RateCentsKWh: round2(best.avgRate * 100), + EstCost: round2(best.cost), + RateTier: best.tier, + }, + ChargeNowCost: round2(chargeNowCost), + OptimizedCost: round2(best.cost), + Savings: round2(savings), + SavingsPct: round2(savingsPct), + HourlyRates: rates, + Explanation: explanation, + }, nil +} + +func seasonForDate(plan touPlan, t time.Time) string { + m := int(t.Month()) + for name, s := range plan.Seasons { + if s.FromMonth <= s.ToMonth { + if m >= s.FromMonth && m <= s.ToMonth { + return name + } + } else if m >= s.FromMonth || m <= s.ToMonth { + return name + } + } + for name := range plan.Seasons { + return name + } + return "" +} + +func buildHourlyRates(season touSeason) []hourlyRate { + rates := make([]hourlyRate, 24) + for i := range rates { + rates[i] = hourlyRate{Hour: i, Tier: "unknown"} + } + for tier, blocks := range season.Tiers { + for _, b := range blocks { + for h := b.Start; h < b.End && h < 24; h++ { + rates[h] = hourlyRate{Hour: h, RateCents: b.Rate * 100, Tier: tier} + } + } + } + return rates +} + +func costForWindow(rates []hourlyRate, startH, hours int, kwh float64) (cost, avgRate float64) { + perHour := kwh / float64(hours) + var sum float64 + for i := 0; i < hours; i++ { + r := rates[(startH+i)%24] + sum += r.RateCents / 100 * perHour + } + return sum, sum / kwh +} + +func round2(f float64) float64 { return math.Round(f*100) / 100 } + +func fmtMoney(f float64) string { + if f < 0 { + return fmt.Sprintf("-$%.2f", -f) + } + return fmt.Sprintf("$%.2f", f) +} diff --git a/internal/api/chargeautopilot/compute_test.go b/internal/api/chargeautopilot/compute_test.go new file mode 100644 index 0000000000..403b2e6491 --- /dev/null +++ b/internal/api/chargeautopilot/compute_test.go @@ -0,0 +1,107 @@ +package chargeautopilot + +import ( + "testing" + "time" +) + +func testProfile() Profile { + return Profile{ + VehicleID: 7, + Enabled: true, + TargetSOC: 90, + ReadyBy: "07:30", + RatePlan: "pge-ev2a", + DailyCapSOC: 80, + TripOverride: false, + Precondition: true, + MaxAmps: 32, + BatteryCapacityKWh: 75, + } +} + +func TestEffectiveTargetCapsWithoutOverride(t *testing.T) { + got, capped := EffectiveTarget(90, 80, false) + if got != 80 || !capped { + t.Fatalf("got (%d, %v), want (80, true)", got, capped) + } +} + +func TestEffectiveTargetPassesThroughWithOverride(t *testing.T) { + got, capped := EffectiveTarget(90, 80, true) + if got != 90 || capped { + t.Fatalf("got (%d, %v), want (90, false)", got, capped) + } +} + +func TestValidateProfileRejectsBadReadyBy(t *testing.T) { + p := testProfile() + p.ReadyBy = "25:99" + if err := ValidateProfile(p); err == nil { + t.Fatal("expected error for bad ready_by") + } +} + +func TestValidateProfileRejectsUnknownPlan(t *testing.T) { + p := testProfile() + p.RatePlan = "nope" + if err := ValidateProfile(p); err == nil { + t.Fatal("expected error for unknown rate plan") + } +} + +func TestNextReadyByRollsToTomorrow(t *testing.T) { + now := time.Date(2026, 3, 10, 8, 0, 0, 0, time.UTC) + next, err := NextReadyBy("07:30", now) + if err != nil { + t.Fatal(err) + } + want := time.Date(2026, 3, 11, 7, 30, 0, 0, time.UTC) + if !next.Equal(want) { + t.Fatalf("got %v, want %v", next, want) + } +} + +func TestPreviewFindsOffPeakWindow(t *testing.T) { + now := time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC) // winter, on-peak evening + res, err := Preview(PreviewInput{Profile: testProfile(), CurrentSOC: 40, Now: now}) + if err != nil { + t.Fatal(err) + } + if res.EffectiveTargetSOC != 80 { + t.Fatalf("effective target = %d, want 80 (health cap)", res.EffectiveTargetSOC) + } + if !res.CappedByHealth { + t.Fatal("expected health guardrail to engage") + } + if res.Window.RateTier == "ON_PEAK" { + t.Fatalf("expected off-peak window, got %+v", res.Window) + } + if res.Savings < 0 { + t.Fatalf("savings should not be negative, got %v", res.Savings) + } + if res.Explanation == "" { + t.Fatal("expected a human-readable explanation") + } +} + +func TestPreviewErrorsWhenAlreadyAtTarget(t *testing.T) { + now := time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC) + _, err := Preview(PreviewInput{Profile: testProfile(), CurrentSOC: 85, Now: now}) + if err == nil { + t.Fatal("expected already-at-target error") + } +} + +func TestPreviewHonorsTripOverride(t *testing.T) { + p := testProfile() + p.TripOverride = true + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + res, err := Preview(PreviewInput{Profile: p, CurrentSOC: 40, Now: now}) + if err != nil { + t.Fatal(err) + } + if res.EffectiveTargetSOC != 90 || res.CappedByHealth { + t.Fatalf("override should keep 90 uncapped, got %+v", res) + } +} diff --git a/internal/api/chargeautopilot/doc.go b/internal/api/chargeautopilot/doc.go new file mode 100644 index 0000000000..04c2000571 --- /dev/null +++ b/internal/api/chargeautopilot/doc.go @@ -0,0 +1,9 @@ +// Package chargeautopilot provides the always-on Smart Charging Autopilot +// layer on top of the one-shot charge planner. +// +// A per-vehicle profile (ready-by time, target SOC, rate plan, battery +// health guardrails) drives a deterministic preview of the next automatic +// charge window plus a savings ledger derived from applied charge plans. +// +// Layer: handler +package chargeautopilot diff --git a/internal/api/chargeautopilot/handler.go b/internal/api/chargeautopilot/handler.go new file mode 100644 index 0000000000..38959724d3 --- /dev/null +++ b/internal/api/chargeautopilot/handler.go @@ -0,0 +1,140 @@ +package chargeautopilot + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Handler serves the Autopilot profile, preview, and savings endpoints. +// +// Stateless beyond its constructor inputs; safe for concurrent use. +type Handler struct { + profiles ProfileStore + savings SavingsReader + now func() time.Time +} + +// NewHandler wires the handler. Panics on nil stores (fail-fast wiring +// contract, matching sibling handlers). +func NewHandler(profiles ProfileStore, savings SavingsReader) *Handler { + if profiles == nil || savings == nil { + panic("chargeautopilot: nil store") + } + return &Handler{profiles: profiles, savings: savings, now: time.Now} +} + +func vehicleIDFromQuery(r *http.Request) (int64, error) { + s := r.URL.Query().Get("vehicle_id") + if s == "" { + return 0, errMissingVehicle + } + id, err := strconv.ParseInt(s, 10, 64) + if err != nil || id <= 0 { + return 0, errMissingVehicle + } + return id, nil +} + +type vehicleErr string + +func (e vehicleErr) Error() string { return string(e) } + +const errMissingVehicle = vehicleErr("vehicle_id must be a positive integer") + +// GetProfile serves GET /charge-autopilot/profile?vehicle_id=. +func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { + vehicleID, err := vehicleIDFromQuery(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + p, err := h.profiles.Get(r.Context(), vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("autopilot: profile read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read autopilot profile") + return + } + httpx.WriteJSON(w, http.StatusOK, p) +} + +// UpsertProfile serves PUT /charge-autopilot/profile. +func (h *Handler) UpsertProfile(w http.ResponseWriter, r *http.Request) { + var p Profile + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if err := ValidateProfile(p); err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if err := h.profiles.Upsert(r.Context(), &p); err != nil { + log.Error().Err(err).Int64("vehicle_id", p.VehicleID).Msg("autopilot: profile write failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save autopilot profile") + return + } + httpx.WriteJSON(w, http.StatusOK, &p) +} + +type previewRequest struct { + VehicleID int64 `json:"vehicle_id"` + CurrentSOC int `json:"current_soc"` +} + +// Preview serves POST /charge-autopilot/preview: the next automatic run +// for the stored profile. Current SOC is caller-supplied so the endpoint +// stays free of signal-store coupling. +func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) { + var req previewRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.VehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + if req.CurrentSOC < 0 || req.CurrentSOC > 100 { + httpx.WriteError(w, http.StatusBadRequest, "current_soc must be 0..100") + return + } + p, err := h.profiles.Get(r.Context(), req.VehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", req.VehicleID).Msg("autopilot: profile read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read autopilot profile") + return + } + res, err := Preview(PreviewInput{Profile: *p, CurrentSOC: req.CurrentSOC, Now: h.now()}) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.WriteJSON(w, http.StatusOK, res) +} + +type savingsResponse struct { + TotalSavings float64 `json:"total_savings"` + Runs int64 `json:"runs"` +} + +// Savings serves GET /charge-autopilot/savings?vehicle_id=. +func (h *Handler) Savings(w http.ResponseWriter, r *http.Request) { + vehicleID, err := vehicleIDFromQuery(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + total, runs, err := h.savings.TotalSavings(r.Context(), vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("autopilot: savings read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read autopilot savings") + return + } + httpx.WriteJSON(w, http.StatusOK, savingsResponse{TotalSavings: total, Runs: runs}) +} diff --git a/internal/api/chargeautopilot/handler_test.go b/internal/api/chargeautopilot/handler_test.go new file mode 100644 index 0000000000..c9f9a96859 --- /dev/null +++ b/internal/api/chargeautopilot/handler_test.go @@ -0,0 +1,161 @@ +package chargeautopilot + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// fakeStores satisfies ProfileStore + SavingsReader without a database. +type fakeStores struct { + profiles map[int64]Profile + upserts int + + savingsTotal float64 + savingsRuns int64 + savingsErr error +} + +func (f *fakeStores) Get(_ context.Context, vehicleID int64) (*Profile, error) { + if p, ok := f.profiles[vehicleID]; ok { + cp := p + return &cp, nil + } + d := DefaultProfile(vehicleID) + return &d, nil +} + +func (f *fakeStores) Upsert(_ context.Context, p *Profile) error { + f.upserts++ + if f.profiles == nil { + f.profiles = map[int64]Profile{} + } + f.profiles[p.VehicleID] = *p + return nil +} + +func (f *fakeStores) TotalSavings(_ context.Context, _ int64) (float64, int64, error) { + return f.savingsTotal, f.savingsRuns, f.savingsErr +} + +var ( + _ ProfileStore = (*fakeStores)(nil) + _ SavingsReader = (*fakeStores)(nil) +) + +func newHandlerForTest(f *fakeStores) *Handler { + h := NewHandler(f, f) + h.now = func() time.Time { return time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC) } + return h +} + +func TestGetProfileRejectsMissingVehicle(t *testing.T) { + h := newHandlerForTest(&fakeStores{}) + req := httptest.NewRequest(http.MethodGet, "/profile", nil) + rec := httptest.NewRecorder() + h.GetProfile(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestGetProfileReturnsDefault(t *testing.T) { + h := newHandlerForTest(&fakeStores{}) + req := httptest.NewRequest(http.MethodGet, "/profile?vehicle_id=9", nil) + rec := httptest.NewRecorder() + h.GetProfile(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var p Profile + if err := json.NewDecoder(rec.Body).Decode(&p); err != nil { + t.Fatal(err) + } + if p.VehicleID != 9 || p.Enabled { + t.Fatalf("unexpected default profile: %+v", p) + } +} + +func TestUpsertProfileRoundTrips(t *testing.T) { + f := &fakeStores{} + h := newHandlerForTest(f) + body := `{"vehicle_id":9,"enabled":true,"target_soc":85,"ready_by":"06:45","rate_plan":"sce-tou-d","daily_cap_soc":80,"trip_override":false,"precondition":true,"max_amps":40,"battery_capacity_kwh":82}` + req := httptest.NewRequest(http.MethodPut, "/profile", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.UpsertProfile(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + if f.upserts != 1 { + t.Fatalf("upserts = %d, want 1", f.upserts) + } +} + +func TestUpsertProfileRejectsBadSOC(t *testing.T) { + f := &fakeStores{} + h := newHandlerForTest(f) + body := `{"vehicle_id":9,"enabled":true,"target_soc":5,"ready_by":"06:45","rate_plan":"sce-tou-d","daily_cap_soc":80,"trip_override":false,"precondition":true,"max_amps":40,"battery_capacity_kwh":82}` + req := httptest.NewRequest(http.MethodPut, "/profile", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.UpsertProfile(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if f.upserts != 0 { + t.Fatal("invalid profile must not reach the store") + } +} + +func TestPreviewUsesStoredProfile(t *testing.T) { + p := DefaultProfile(3) + p.Enabled = true + f := &fakeStores{profiles: map[int64]Profile{3: p}} + h := newHandlerForTest(f) + req := httptest.NewRequest(http.MethodPost, "/preview", strings.NewReader(`{"vehicle_id":3,"current_soc":50}`)) + rec := httptest.NewRecorder() + h.Preview(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var res PreviewResult + if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { + t.Fatal(err) + } + if res.Window.StartTime.IsZero() || res.Explanation == "" { + t.Fatalf("incomplete preview: %+v", res) + } +} + +func TestSavingsSurfacesLedger(t *testing.T) { + f := &fakeStores{savingsTotal: 12.5, savingsRuns: 4} + h := newHandlerForTest(f) + req := httptest.NewRequest(http.MethodGet, "/savings?vehicle_id=3", nil) + rec := httptest.NewRecorder() + h.Savings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var res savingsResponse + if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { + t.Fatal(err) + } + if res.TotalSavings != 12.5 || res.Runs != 4 { + t.Fatalf("unexpected ledger: %+v", res) + } +} + +func TestSavingsPropagatesStoreError(t *testing.T) { + f := &fakeStores{savingsErr: errors.New("db down")} + h := newHandlerForTest(f) + req := httptest.NewRequest(http.MethodGet, "/savings?vehicle_id=3", nil) + rec := httptest.NewRecorder() + h.Savings(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} diff --git a/internal/api/chargeautopilot/rates.go b/internal/api/chargeautopilot/rates.go new file mode 100644 index 0000000000..fb88f90621 --- /dev/null +++ b/internal/api/chargeautopilot/rates.go @@ -0,0 +1,76 @@ +package chargeautopilot + +// ── TOU Rate Presets ───────────────────────────────────────── +// Deliberate mirror of the chargeplanner presets (server-side source of +// truth lives there). Autopilot previews must price the same windows the +// planner would apply, so any rate change there must be ported here. +// Kept local so this package stays dependency-free and unit-testable +// without a database or signal reader. + +type touRateBlock struct { + Rate float64 + Start int + End int +} + +type touSeason struct { + FromMonth int + ToMonth int + Tiers map[string][]touRateBlock +} + +type touPlan struct { + ID string + Name string + Utility string + Seasons map[string]touSeason +} + +var ratePlans = map[string]touPlan{ + "pge-ev2a": { + ID: "pge-ev2a", Name: "PG&E EV2-A", Utility: "Pacific Gas & Electric", + Seasons: map[string]touSeason{ + "Summer": {FromMonth: 6, ToMonth: 9, Tiers: map[string][]touRateBlock{ + "ON_PEAK": {{Rate: 0.49, Start: 16, End: 21}}, + "OFF_PEAK": {{Rate: 0.35, Start: 0, End: 16}, {Rate: 0.35, Start: 21, End: 24}}, + }}, + "Winter": {FromMonth: 10, ToMonth: 5, Tiers: map[string][]touRateBlock{ + "ON_PEAK": {{Rate: 0.42, Start: 16, End: 21}}, + "OFF_PEAK": {{Rate: 0.36, Start: 0, End: 16}, {Rate: 0.36, Start: 21, End: 24}}, + }}, + }, + }, + "sce-tou-d": { + ID: "sce-tou-d", Name: "SCE TOU-D", Utility: "Southern California Edison", + Seasons: map[string]touSeason{ + "Summer": {FromMonth: 6, ToMonth: 9, Tiers: map[string][]touRateBlock{ + "ON_PEAK": {{Rate: 0.54, Start: 16, End: 21}}, + "MID_PEAK": {{Rate: 0.41, Start: 8, End: 16}, {Rate: 0.41, Start: 21, End: 23}}, + "OFF_PEAK": {{Rate: 0.28, Start: 0, End: 8}, {Rate: 0.28, Start: 23, End: 24}}, + }}, + "Winter": {FromMonth: 10, ToMonth: 5, Tiers: map[string][]touRateBlock{ + "MID_PEAK": {{Rate: 0.43, Start: 8, End: 21}}, + "SUPER_OFF_PEAK": {{Rate: 0.28, Start: 0, End: 8}, {Rate: 0.28, Start: 21, End: 24}}, + }}, + }, + }, + "sdge-tou-dr1": { + ID: "sdge-tou-dr1", Name: "SDG&E TOU-DR1", Utility: "San Diego Gas & Electric", + Seasons: map[string]touSeason{ + "Summer": {FromMonth: 6, ToMonth: 9, Tiers: map[string][]touRateBlock{ + "ON_PEAK": {{Rate: 0.71, Start: 16, End: 21}}, + "OFF_PEAK": {{Rate: 0.45, Start: 0, End: 16}, {Rate: 0.45, Start: 21, End: 24}}, + }}, + "Winter": {FromMonth: 10, ToMonth: 5, Tiers: map[string][]touRateBlock{ + "ON_PEAK": {{Rate: 0.57, Start: 16, End: 21}}, + "OFF_PEAK": {{Rate: 0.45, Start: 0, End: 16}, {Rate: 0.45, Start: 21, End: 24}}, + }}, + }, + }, +} + +// KnownRatePlan reports whether id names a supported TOU plan. +func KnownRatePlan(id string) bool { + _, ok := ratePlans[id] + return ok +} diff --git a/internal/api/chargeautopilot/run.go b/internal/api/chargeautopilot/run.go new file mode 100644 index 0000000000..6177a85189 --- /dev/null +++ b/internal/api/chargeautopilot/run.go @@ -0,0 +1,148 @@ +package chargeautopilot + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging" +) + +// PlanCreator persists a draft charge plan built from an autopilot preview. +// *chargingdb.ChargePlanRepo satisfies it. +type PlanCreator interface { + Create(ctx context.Context, p *chargingdb.ChargePlan) error +} + +// PlanRunner applies a draft charge plan to its vehicle via Tesla commands. +// *chargeplanner.Handler satisfies it through ApplyPlanByID. +type PlanRunner interface { + ApplyPlanByID(ctx context.Context, planID int64) (*chargingdb.ChargePlan, string, error) +} + +// RunHandler serves the one-click autopilot run endpoint. It reuses the +// same Preview computation as the preview endpoint, persists the result +// as a draft charge plan (autopilot provenance), then applies it through +// the charge planner's command path — one code path issues Tesla +// commands, never two. +// +// Stateless beyond its constructor inputs; safe for concurrent use. +type RunHandler struct { + profiles ProfileStore + plans PlanCreator + runner PlanRunner + now func() time.Time +} + +// NewRunHandler wires the run handler. Panics on nil inputs (fail-fast +// wiring contract, matching sibling handlers). +func NewRunHandler(profiles ProfileStore, plans PlanCreator, runner PlanRunner) *RunHandler { + if profiles == nil || plans == nil || runner == nil { + panic("chargeautopilot: nil run dependency") + } + return &RunHandler{profiles: profiles, plans: plans, runner: runner, now: time.Now} +} + +type runRequest struct { + VehicleID int64 `json:"vehicle_id"` + CurrentSOC int `json:"current_soc"` +} + +type runResponse struct { + Status string `json:"status"` + PlanID int64 `json:"plan_id"` + StartTime string `json:"start_time"` + TargetSOC int `json:"target_soc"` + Savings float64 `json:"savings"` + Message string `json:"message"` +} + +// Run serves POST /charge-autopilot/run: compute the optimal window from +// the stored profile, persist it as a charge plan, and apply it to the +// vehicle immediately. The profile must be enabled; Preview feasibility +// failures (already at target, not enough time) surface as 409 since the +// request is valid but the run cannot proceed. +func (h *RunHandler) Run(w http.ResponseWriter, r *http.Request) { + var req runRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.VehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + if req.CurrentSOC < 0 || req.CurrentSOC > 100 { + httpx.WriteError(w, http.StatusBadRequest, "current_soc must be 0..100") + return + } + + ctx := r.Context() + p, err := h.profiles.Get(ctx, req.VehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", req.VehicleID).Msg("autopilot: profile read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read autopilot profile") + return + } + if !p.Enabled { + httpx.WriteError(w, http.StatusConflict, "autopilot is not enabled for this vehicle") + return + } + + res, err := Preview(PreviewInput{Profile: *p, CurrentSOC: req.CurrentSOC, Now: h.now()}) + if err != nil { + httpx.WriteError(w, http.StatusConflict, err.Error()) + return + } + + plan := &chargingdb.ChargePlan{ + VehicleID: req.VehicleID, + TargetSOC: res.EffectiveTargetSOC, + DepartBy: &res.ReadyBy, + ScheduledStart: res.Window.StartTime, + ScheduledEnd: res.Window.EndTime, + RatePlan: p.RatePlan, + EstimatedKWh: &res.KWhNeeded, + EstimatedCost: &res.OptimizedCost, + ChargeNowCost: &res.ChargeNowCost, + Savings: &res.Savings, + Status: "draft", + } + if err := h.plans.Create(ctx, plan); err != nil { + log.Error().Err(err).Int64("vehicle_id", req.VehicleID).Msg("autopilot: plan persist failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save charge plan") + return + } + + applied, failedCmd, err := h.runner.ApplyPlanByID(ctx, plan.ID) + if err != nil { + // The plan stays a draft, so the run is retryable from the charge + // planner UI without recomputing. + log.Error().Err(err).Int64("plan_id", plan.ID).Str("command", failedCmd).Msg("autopilot: plan apply failed") + if failure, matched := httpx.ClassifyTeslaBudgetError(err); matched { + httpx.WriteError(w, failure.StatusCode, failure.Message) + return + } + httpx.WriteError(w, http.StatusInternalServerError, "failed to apply charge schedule to vehicle") + return + } + + log.Info(). + Int64("plan_id", applied.ID). + Int64("vehicle_id", req.VehicleID). + Float64("savings", res.Savings). + Msg("autopilot run applied to vehicle") + + httpx.WriteJSON(w, http.StatusOK, runResponse{ + Status: "scheduled", + PlanID: applied.ID, + StartTime: applied.ScheduledStart.Format("15:04"), + TargetSOC: applied.TargetSOC, + Savings: res.Savings, + Message: "Autopilot scheduled charging at " + applied.ScheduledStart.Format("15:04"), + }) +} diff --git a/internal/api/chargeautopilot/run_test.go b/internal/api/chargeautopilot/run_test.go new file mode 100644 index 0000000000..3bf47c99f2 --- /dev/null +++ b/internal/api/chargeautopilot/run_test.go @@ -0,0 +1,175 @@ +package chargeautopilot + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging" +) + +// fakePlanStore satisfies PlanCreator in memory. +type fakePlanStore struct { + created *chargingdb.ChargePlan + err error +} + +func (f *fakePlanStore) Create(_ context.Context, p *chargingdb.ChargePlan) error { + if f.err != nil { + return f.err + } + p.ID = 42 + f.created = p + return nil +} + +// fakeRunner satisfies PlanRunner without touching Tesla. +type fakeRunner struct { + applied *chargingdb.ChargePlan + failedCmd string + err error + appliedIDs []int64 +} + +func (f *fakeRunner) ApplyPlanByID(_ context.Context, planID int64) (*chargingdb.ChargePlan, string, error) { + f.appliedIDs = append(f.appliedIDs, planID) + if f.err != nil { + return nil, f.failedCmd, f.err + } + return f.applied, "", nil +} + +var ( + _ PlanCreator = (*fakePlanStore)(nil) + _ PlanRunner = (*fakeRunner)(nil) +) + +func enabledProfile(vehicleID int64) Profile { + p := DefaultProfile(vehicleID) + p.Enabled = true + p.TargetSOC = 80 + p.ReadyBy = "07:30" + p.RatePlan = "pge-ev2a" + p.DailyCapSOC = 90 + p.MaxAmps = 32 + p.BatteryCapacityKWh = 75 + return p +} + +func newRunHandlerForTest(stores *fakeStores, plans *fakePlanStore, runner *fakeRunner) *RunHandler { + h := NewRunHandler(stores, plans, runner) + h.now = func() time.Time { return time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC) } + return h +} + +func doRun(t *testing.T, h *RunHandler, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/run", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.Run(rec, req) + return rec +} + +func TestRunRejectsDisabledProfile(t *testing.T) { + p := enabledProfile(7) + p.Enabled = false + stores := &fakeStores{profiles: map[int64]Profile{7: p}} + h := newRunHandlerForTest(stores, &fakePlanStore{}, &fakeRunner{}) + + rec := doRun(t, h, `{"vehicle_id":7,"current_soc":40}`) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rec.Code) + } +} + +func TestRunRejectsInfeasiblePreview(t *testing.T) { + // Current SOC already above target: Preview fails, Run must 409 + // without persisting or applying anything. + stores := &fakeStores{profiles: map[int64]Profile{7: enabledProfile(7)}} + plans := &fakePlanStore{} + runner := &fakeRunner{} + h := newRunHandlerForTest(stores, plans, runner) + + rec := doRun(t, h, `{"vehicle_id":7,"current_soc":95}`) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rec.Code) + } + if plans.created != nil { + t.Fatal("no plan should be persisted for an infeasible run") + } + if len(runner.appliedIDs) != 0 { + t.Fatal("no plan should be applied for an infeasible run") + } +} + +func TestRunPersistsAndAppliesPlan(t *testing.T) { + stores := &fakeStores{profiles: map[int64]Profile{7: enabledProfile(7)}} + plans := &fakePlanStore{} + applied := &chargingdb.ChargePlan{ + ID: 42, + VehicleID: 7, + TargetSOC: 80, + ScheduledStart: time.Date(2026, 1, 16, 1, 0, 0, 0, time.UTC), + Status: "scheduled", + } + runner := &fakeRunner{applied: applied} + h := newRunHandlerForTest(stores, plans, runner) + + rec := doRun(t, h, `{"vehicle_id":7,"current_soc":40}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if plans.created == nil { + t.Fatal("expected a persisted draft plan") + } + if plans.created.Status != "draft" { + t.Fatalf("plan status = %q, want draft", plans.created.Status) + } + if plans.created.TargetSOC != 80 { + t.Fatalf("plan target_soc = %d, want 80", plans.created.TargetSOC) + } + if plans.created.RatePlan != "pge-ev2a" { + t.Fatalf("plan rate_plan = %q, want pge-ev2a", plans.created.RatePlan) + } + if len(runner.appliedIDs) != 1 || runner.appliedIDs[0] != 42 { + t.Fatalf("applied IDs = %v, want [42]", runner.appliedIDs) + } + + var res runResponse + if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { + t.Fatalf("decode response: %v", err) + } + if res.PlanID != 42 || res.Status != "scheduled" || res.TargetSOC != 80 { + t.Fatalf("unexpected response: %+v", res) + } +} + +func TestRunSurfacesApplyFailure(t *testing.T) { + stores := &fakeStores{profiles: map[int64]Profile{7: enabledProfile(7)}} + plans := &fakePlanStore{} + runner := &fakeRunner{failedCmd: "set_charge_limit", err: errors.New("tesla unavailable")} + h := newRunHandlerForTest(stores, plans, runner) + + rec := doRun(t, h, `{"vehicle_id":7,"current_soc":40}`) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + // The draft plan survives so the run is retryable from the planner UI. + if plans.created == nil || plans.created.Status != "draft" { + t.Fatal("expected the draft plan to survive an apply failure") + } +} + +func TestNewRunHandlerPanicsOnNil(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic on nil deps") + } + }() + NewRunHandler(nil, &fakePlanStore{}, &fakeRunner{}) +} diff --git a/internal/api/chargeautopilot/store.go b/internal/api/chargeautopilot/store.go new file mode 100644 index 0000000000..6d813d6630 --- /dev/null +++ b/internal/api/chargeautopilot/store.go @@ -0,0 +1,123 @@ +package chargeautopilot + +import ( + "context" + "sync" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/ev-dev-labs/teslasync/internal/database" + chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging" +) + +// ProfileStore persists Autopilot profiles per vehicle. +type ProfileStore interface { + Get(ctx context.Context, vehicleID int64) (*Profile, error) + Upsert(ctx context.Context, p *Profile) error +} + +// SavingsReader totals realized savings from applied charge plans. +type SavingsReader interface { + TotalSavings(ctx context.Context, vehicleID int64) (total float64, runs int64, err error) +} + +// pgProfileStore is the postgres-backed ProfileStore. +type pgProfileStore struct { + repo *chargingdb.AutopilotProfileRepo +} + +// NewPGProfileStore wires the store to a database handle. Panics on nil, +// matching the fail-fast wiring contract of sibling handlers. +func NewPGProfileStore(db *database.DB) ProfileStore { + if db == nil { + panic("chargeautopilot: nil database") + } + return &pgProfileStore{repo: chargingdb.NewAutopilotProfileRepo(db)} +} + +func (s *pgProfileStore) Get(ctx context.Context, vehicleID int64) (*Profile, error) { + p, err := s.repo.GetByVehicle(ctx, vehicleID) + if err != nil { + if err == pgx.ErrNoRows { + d := DefaultProfile(vehicleID) + return &d, nil + } + return nil, err + } + return &Profile{ + VehicleID: p.VehicleID, + Enabled: p.Enabled, + TargetSOC: p.TargetSOC, + ReadyBy: p.ReadyBy, + RatePlan: p.RatePlan, + DailyCapSOC: p.DailyCapSOC, + TripOverride: p.TripOverride, + Precondition: p.Precondition, + MaxAmps: p.MaxAmps, + BatteryCapacityKWh: p.BatteryCapacityKWh, + }, nil +} + +func (s *pgProfileStore) Upsert(ctx context.Context, p *Profile) error { + return s.repo.Upsert(ctx, &chargingdb.AutopilotProfile{ + VehicleID: p.VehicleID, + Enabled: p.Enabled, + TargetSOC: p.TargetSOC, + ReadyBy: p.ReadyBy, + RatePlan: p.RatePlan, + DailyCapSOC: p.DailyCapSOC, + TripOverride: p.TripOverride, + Precondition: p.Precondition, + MaxAmps: p.MaxAmps, + BatteryCapacityKWh: p.BatteryCapacityKWh, + UpdatedAt: time.Now(), + }) +} + +// pgSavingsReader sums realized savings from applied/completed plans. +type pgSavingsReader struct { + repo *chargingdb.AutopilotProfileRepo +} + +// NewPGSavingsReader wires the ledger to a database handle. +func NewPGSavingsReader(db *database.DB) SavingsReader { + if db == nil { + panic("chargeautopilot: nil database") + } + return &pgSavingsReader{repo: chargingdb.NewAutopilotProfileRepo(db)} +} + +func (s *pgSavingsReader) TotalSavings(ctx context.Context, vehicleID int64) (float64, int64, error) { + return s.repo.SumAppliedSavings(ctx, vehicleID) +} + +// MemoryProfileStore is an in-memory ProfileStore for tests and +// environments without a migrated database. +type MemoryProfileStore struct { + mu sync.RWMutex + profiles map[int64]Profile +} + +// NewMemoryProfileStore creates an empty MemoryProfileStore. +func NewMemoryProfileStore() *MemoryProfileStore { + return &MemoryProfileStore{profiles: map[int64]Profile{}} +} + +func (s *MemoryProfileStore) Get(_ context.Context, vehicleID int64) (*Profile, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if p, ok := s.profiles[vehicleID]; ok { + cp := p + return &cp, nil + } + d := DefaultProfile(vehicleID) + return &d, nil +} + +func (s *MemoryProfileStore) Upsert(_ context.Context, p *Profile) error { + s.mu.Lock() + defer s.mu.Unlock() + s.profiles[p.VehicleID] = *p + return nil +} diff --git a/internal/api/chargeplanner/apply.go b/internal/api/chargeplanner/apply.go new file mode 100644 index 0000000000..95bf2b91cd --- /dev/null +++ b/internal/api/chargeplanner/apply.go @@ -0,0 +1,76 @@ +package chargeplanner + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/rs/zerolog/log" + + chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging" + vehicledb "github.com/ev-dev-labs/teslasync/internal/database/vehicle" +) + +// Sentinel errors returned by ApplyPlanByID so HTTP callers (the Apply +// handler and Smart Charging Autopilot's Run endpoint) can map failures +// to the correct status code without re-parsing messages. +var ( + // ErrPlanNotFound indicates the plan ID does not exist. + ErrPlanNotFound = errors.New("charge plan not found") + // ErrPlanNotDraft indicates the plan was already applied or superseded. + ErrPlanNotDraft = errors.New("plan is no longer a draft") + // ErrApplyVehicleNotFound indicates the plan's vehicle does not exist. + ErrApplyVehicleNotFound = errors.New("vehicle not found") +) + +// ApplyPlanByID applies a draft charge plan to its vehicle: it issues the +// two Tesla commands (set_charge_limit, set_scheduled_charging) and marks +// the plan scheduled. It returns the applied plan, plus the canonical +// command name that failed (empty on success or non-command errors) so +// callers can surface per-command failure messages. +func (h *Handler) ApplyPlanByID(ctx context.Context, planID int64) (*chargingdb.ChargePlan, string, error) { + planRepo := chargingdb.NewChargePlanRepo(h.db) + + plan, err := planRepo.GetByID(ctx, planID) + if err != nil { + log.Error().Err(err).Int64("plan_id", planID).Msg("failed to fetch charge plan") + return nil, "", fmt.Errorf("fetch plan: %w", err) + } + if plan == nil { + return nil, "", ErrPlanNotFound + } + if plan.Status != "draft" { + return nil, "", fmt.Errorf("%w: plan already %s", ErrPlanNotDraft, plan.Status) + } + + vehicleRepo := vehicledb.NewVehicleRepo(h.db) + vehicle, err := vehicleRepo.GetByID(ctx, plan.VehicleID) + if err != nil || vehicle == nil { + return nil, "", ErrApplyVehicleNotFound + } + + // Apply the schedule via two Tesla commands, each wrapped in its own + // per-call context.WithTimeout (project rule — Tesla API: 30s). Each + // command runs under a fresh deadline derived from the parent so a + // stuck first call cannot starve the second's budget. + startMinutes := plan.ScheduledStart.Hour()*60 + plan.ScheduledStart.Minute() + if failedCmd, err := h.applyChargeScheduleToVehicle(ctx, vehicle.VIN, plan.TargetSOC, startMinutes); err != nil { + log.Error().Err(err).Str("vin", vehicle.VIN).Str("command", failedCmd).Msg("failed to apply charge schedule") + return nil, failedCmd, err + } + + now := time.Now().UTC() + if err := planRepo.UpdateStatus(ctx, plan.ID, "scheduled", &now, nil); err != nil { + log.Error().Err(err).Int64("plan_id", plan.ID).Msg("failed to update plan status") + } + + log.Info(). + Int64("plan_id", plan.ID). + Str("vin", vehicle.VIN). + Int("start_minutes", startMinutes). + Int("target_soc", plan.TargetSOC). + Msg("charge schedule applied to vehicle") + + return plan, "", nil +} diff --git a/internal/api/chargeplanner/handler.go b/internal/api/chargeplanner/handler.go index 2c56668f67..6b631f42ab 100644 --- a/internal/api/chargeplanner/handler.go +++ b/internal/api/chargeplanner/handler.go @@ -19,7 +19,6 @@ import ( "github.com/ev-dev-labs/teslasync/internal/config" "github.com/ev-dev-labs/teslasync/internal/database" chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging" - vehicledb "github.com/ev-dev-labs/teslasync/internal/database/vehicle" "github.com/ev-dev-labs/teslasync/internal/signal" "github.com/ev-dev-labs/teslasync/internal/tesla" ) @@ -367,71 +366,37 @@ func (h *Handler) Apply(w http.ResponseWriter, r *http.Request) { return } - ctx := r.Context() - planRepo := chargingdb.NewChargePlanRepo(h.db) - - plan, err := planRepo.GetByID(ctx, req.PlanID) + plan, failedCmd, err := h.ApplyPlanByID(r.Context(), req.PlanID) if err != nil { - log.Error().Err(err).Int64("plan_id", req.PlanID).Msg("failed to fetch charge plan") - httpx.WriteError(w, http.StatusInternalServerError, "failed to fetch plan") - return - } - if plan == nil { - httpx.WriteError(w, http.StatusNotFound, "charge plan not found") - return - } - if plan.Status != "draft" { - httpx.WriteError(w, http.StatusBadRequest, fmt.Sprintf("plan already %s", plan.Status)) - return - } - - vehicleRepo := vehicledb.NewVehicleRepo(h.db) - vehicle, err := vehicleRepo.GetByID(ctx, plan.VehicleID) - if err != nil || vehicle == nil { - httpx.WriteError(w, http.StatusNotFound, "vehicle not found") - return - } - - // 1+2. Apply the schedule via two Tesla commands, each wrapped in - // its own per-call context.WithTimeout (project rule: external - // Tesla API calls must wrap with context.WithTimeout — Tesla API: - // 30s). Each command runs under a fresh deadline derived from the - // parent so a stuck first call cannot starve the second's budget. - startMinutes := plan.ScheduledStart.Hour()*60 + plan.ScheduledStart.Minute() - if failedCmd, err := h.applyChargeScheduleToVehicle(ctx, vehicle.VIN, plan.TargetSOC, startMinutes); err != nil { - log.Error().Err(err).Str("vin", vehicle.VIN).Str("command", failedCmd).Msg("failed to apply charge schedule") - // Fleet API daily budget errors are a distinct, structured failure - // mode: ErrBudgetExceeded cannot succeed by retrying until the next - // UTC reset, and ErrBudgetUnavailable means the budget evidence - // store itself could not be read. Surface both as their real HTTP - // status instead of the generic 500 below. - if failure, matched := httpx.ClassifyTeslaBudgetError(err); matched { - httpx.WriteError(w, failure.StatusCode, failure.Message) - return - } - switch failedCmd { - case "set_charge_limit": - httpx.WriteError(w, http.StatusInternalServerError, "failed to set charge limit") - case "set_scheduled_charging": - httpx.WriteError(w, http.StatusInternalServerError, "failed to set scheduled charging") + switch { + case errors.Is(err, ErrPlanNotFound): + httpx.WriteError(w, http.StatusNotFound, "charge plan not found") + case errors.Is(err, ErrPlanNotDraft): + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, ErrApplyVehicleNotFound): + httpx.WriteError(w, http.StatusNotFound, "vehicle not found") default: - httpx.WriteError(w, http.StatusInternalServerError, "failed to apply charge schedule") + // Fleet API daily budget errors are a distinct, structured failure + // mode: ErrBudgetExceeded cannot succeed by retrying until the next + // UTC reset, and ErrBudgetUnavailable means the budget evidence + // store itself could not be read. Surface both as their real HTTP + // status instead of the generic 500 below. + if failure, matched := httpx.ClassifyTeslaBudgetError(err); matched { + httpx.WriteError(w, failure.StatusCode, failure.Message) + return + } + switch failedCmd { + case "set_charge_limit": + httpx.WriteError(w, http.StatusInternalServerError, "failed to set charge limit") + case "set_scheduled_charging": + httpx.WriteError(w, http.StatusInternalServerError, "failed to set scheduled charging") + default: + httpx.WriteError(w, http.StatusInternalServerError, "failed to apply charge schedule") + } } return } - now := time.Now().UTC() - if err := planRepo.UpdateStatus(ctx, plan.ID, "scheduled", &now, nil); err != nil { - log.Error().Err(err).Int64("plan_id", plan.ID).Msg("failed to update plan status") - } - - log.Info(). - Int64("plan_id", plan.ID). - Str("vin", vehicle.VIN). - Int("start_minutes", startMinutes). - Int("target_soc", plan.TargetSOC). - Msg("charge schedule applied to vehicle") - httpx.WriteJSON(w, http.StatusOK, map[string]interface{}{ "status": "scheduled", "plan_id": plan.ID, diff --git a/internal/api/chargeplanner/queue.go b/internal/api/chargeplanner/queue.go new file mode 100644 index 0000000000..d736173faf --- /dev/null +++ b/internal/api/chargeplanner/queue.go @@ -0,0 +1,165 @@ +package chargeplanner + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// QueueVehicle is one car competing for a single shared charger. +type QueueVehicle struct { + VehicleID int64 `json:"vehicle_id"` + CurrentSOC float64 `json:"current_soc"` + TargetSOC float64 `json:"target_soc"` + ReadyBy string `json:"ready_by"` // daily "HH:MM" + BatteryCapacityKWh float64 `json:"battery_capacity_kwh"` +} + +type queueAdviseRequest struct { + Vehicles []QueueVehicle `json:"vehicles"` + ChargerKW float64 `json:"charger_kw"` +} + +// QueueSlot is one ordered charging window. +type QueueSlot struct { + VehicleID int64 `json:"vehicle_id"` + Position int `json:"position"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + KWhNeeded float64 `json:"kwh_needed"` + ReadyBy time.Time `json:"ready_by"` + SlackHours float64 `json:"slack_hours"` + Feasible bool `json:"feasible"` +} + +// QueueAdvice is the POST /charge-planner/queue response. +type QueueAdvice struct { + Slots []QueueSlot `json:"slots"` + AllFeasible bool `json:"all_feasible"` + Explanation string `json:"explanation"` +} + +// ComputeQueue orders vehicles least-slack-first and lays back-to-back +// windows from now. Slack = ready_by − (now + charge_time): the car with +// the least room for delay charges first. now pins the clock for tests. +func ComputeQueue(vehicles []QueueVehicle, chargerKW float64, now time.Time) (QueueAdvice, error) { + if len(vehicles) == 0 || len(vehicles) > 8 { + return QueueAdvice{}, fmt.Errorf("vehicles must list 1..8 entries") + } + if chargerKW < 1 || chargerKW > 22 { + return QueueAdvice{}, fmt.Errorf("charger_kw must be 1..22") + } + type work struct { + v QueueVehicle + kwh float64 + hours float64 + ready time.Time + slack float64 + } + items := make([]work, 0, len(vehicles)) + seen := map[int64]bool{} + for _, v := range vehicles { + if v.VehicleID <= 0 || seen[v.VehicleID] { + return QueueAdvice{}, fmt.Errorf("vehicle ids must be unique and positive") + } + seen[v.VehicleID] = true + if v.CurrentSOC < 0 || v.CurrentSOC > 100 || v.TargetSOC <= 0 || v.TargetSOC > 100 { + return QueueAdvice{}, fmt.Errorf("soc values must be 0..100") + } + if v.TargetSOC <= v.CurrentSOC { + return QueueAdvice{}, fmt.Errorf("vehicle %d: target must exceed current soc", v.VehicleID) + } + capacity := v.BatteryCapacityKWh + if capacity <= 0 { + capacity = 75 + } + h, m, err := parseClock(v.ReadyBy) + if err != nil { + return QueueAdvice{}, fmt.Errorf("vehicle %d: %w", v.VehicleID, err) + } + ready := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location()) + if !ready.After(now) { + ready = ready.Add(24 * time.Hour) + } + kwh := (v.TargetSOC - v.CurrentSOC) / 100 * capacity + hours := kwh * 1.10 / chargerKW // 10% charging loss, same as the planner + items = append(items, work{v: v, kwh: kwh, hours: hours, ready: ready, + slack: ready.Sub(now).Hours() - hours}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].slack != items[j].slack { + return items[i].slack < items[j].slack + } + return items[i].v.VehicleID < items[j].v.VehicleID + }) + + advice := QueueAdvice{Slots: []QueueSlot{}, AllFeasible: true} + cursor := now + for i, it := range items { + end := cursor.Add(time.Duration(it.hours * float64(time.Hour))) + feasible := !end.After(it.ready) + if !feasible { + advice.AllFeasible = false + } + advice.Slots = append(advice.Slots, QueueSlot{ + VehicleID: it.v.VehicleID, + Position: i + 1, + StartTime: cursor, + EndTime: end, + KWhNeeded: math.Round(it.kwh*10) / 10, + ReadyBy: it.ready, + SlackHours: math.Round(it.slack*10) / 10, + Feasible: feasible, + }) + cursor = end + } + if advice.AllFeasible { + advice.Explanation = fmt.Sprintf( + "Charge in order — every car finishes before its ready-by on the shared charger: %s.", + slotList(advice.Slots)) + } else { + advice.Explanation = "The queue overruns at least one ready-by — raise charger power, stagger ready-by times, or top up the tightest car elsewhere first." + } + return advice, nil +} + +func parseClock(s string) (int, int, error) { + var h, m int + n, err := fmt.Sscanf(s, "%d:%d", &h, &m) + if err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(s) != 5 { + return 0, 0, fmt.Errorf("ready_by must be HH:MM (24h)") + } + return h, m, nil +} + +func slotList(slots []QueueSlot) string { + out := "" + for i, s := range slots { + if i > 0 { + out += " → " + } + out += fmt.Sprintf("vehicle %d (%s–%s)", s.VehicleID, + s.StartTime.Format("15:04"), s.EndTime.Format("15:04")) + } + return out +} + +// Queue handles POST /charge-planner/queue. +func (h *Handler) Queue(w http.ResponseWriter, r *http.Request) { + var req queueAdviseRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + advice, err := ComputeQueue(req.Vehicles, req.ChargerKW, time.Now()) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.WriteJSON(w, http.StatusOK, advice) +} diff --git a/internal/api/chargeplanner/queue_test.go b/internal/api/chargeplanner/queue_test.go new file mode 100644 index 0000000000..f849bc27b6 --- /dev/null +++ b/internal/api/chargeplanner/queue_test.go @@ -0,0 +1,78 @@ +package chargeplanner + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestComputeQueueOrdersBySlack(t *testing.T) { + now := time.Date(2026, 3, 10, 18, 0, 0, 0, time.UTC) + got, err := ComputeQueue([]QueueVehicle{ + {VehicleID: 1, CurrentSOC: 50, TargetSOC: 80, ReadyBy: "07:30", BatteryCapacityKWh: 75}, + {VehicleID: 2, CurrentSOC: 20, TargetSOC: 80, ReadyBy: "06:00", BatteryCapacityKWh: 75}, + }, 11, now) + if err != nil { + t.Fatal(err) + } + if len(got.Slots) != 2 || got.Slots[0].VehicleID != 2 { + t.Fatalf("tightest car must charge first: %+v", got.Slots) + } + if !got.Slots[0].StartTime.Equal(now) { + t.Fatalf("first slot must start now: %+v", got.Slots[0]) + } + if !got.Slots[1].StartTime.Equal(got.Slots[0].EndTime) { + t.Fatal("slots must be back-to-back") + } +} + +func TestComputeQueueFlagsInfeasible(t *testing.T) { + now := time.Date(2026, 3, 10, 18, 0, 0, 0, time.UTC) + got, err := ComputeQueue([]QueueVehicle{ + {VehicleID: 1, CurrentSOC: 10, TargetSOC: 100, ReadyBy: "19:00", BatteryCapacityKWh: 75}, + }, 7, now) + if err != nil { + t.Fatal(err) + } + if got.AllFeasible || got.Slots[0].Feasible { + t.Fatalf("expected infeasible: %+v", got) + } +} + +func TestComputeQueueRejects(t *testing.T) { + now := time.Now().UTC() + if _, err := ComputeQueue(nil, 11, now); err == nil { + t.Fatal("expected error for empty queue") + } + if _, err := ComputeQueue([]QueueVehicle{ + {VehicleID: 1, CurrentSOC: 80, TargetSOC: 80, ReadyBy: "07:30"}, + }, 11, now); err == nil { + t.Fatal("expected error when target <= current") + } + if _, err := ComputeQueue([]QueueVehicle{ + {VehicleID: 1, CurrentSOC: 50, TargetSOC: 80, ReadyBy: "25:00"}, + }, 11, now); err == nil { + t.Fatal("expected error for bad ready_by") + } +} + +func TestQueueEndpoint(t *testing.T) { + h := &Handler{} + body := `{"vehicles":[{"vehicle_id":1,"current_soc":50,"target_soc":80,"ready_by":"07:30","battery_capacity_kwh":75}],"charger_kw":11}` + req := httptest.NewRequest(http.MethodPost, "/queue", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.Queue(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var advice QueueAdvice + if err := json.NewDecoder(rec.Body).Decode(&advice); err != nil { + t.Fatal(err) + } + if len(advice.Slots) != 1 || advice.Explanation == "" { + t.Fatalf("incomplete advice: %+v", advice) + } +} diff --git a/internal/api/charging/handler.go b/internal/api/charging/handler.go index 7d221925d5..66a2753eb7 100644 --- a/internal/api/charging/handler.go +++ b/internal/api/charging/handler.go @@ -43,6 +43,16 @@ type ChargingHandler struct { // bulkOverride lets tests substitute the bulk store without standing up a // real *chargingdb.ChargingRepo. Always nil in production. bulkOverride chargingBulkStore + // varianceOverride lets tests substitute the measured-DC aggregate and + // invoice summary. Always nil in production. + varianceOverride *varianceTestSeam +} + +// varianceTestSeam bundles the BillVariance data sources for tests. +type varianceTestSeam struct { + measured measuredDCSummer + invoiced invoicedTotalsReader + vin string } // chargingByIDFetcher is the narrow interface needed by the migrated handlers diff --git a/internal/api/charging/variance.go b/internal/api/charging/variance.go new file mode 100644 index 0000000000..a2a9a5c5d9 --- /dev/null +++ b/internal/api/charging/variance.go @@ -0,0 +1,175 @@ +package charging + +import ( + "context" + "fmt" + "math" + "net/http" + "strconv" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging" + teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" +) + +// measuredDCSummer is the narrow aggregate the variance endpoint needs. +// *chargingdb.ChargingRepo satisfies it; tests inject a fake. +type measuredDCSummer interface { + SumMeasuredDC(ctx context.Context, vehicleID int64) (chargingdb.MeasuredDCTotals, error) +} + +// invoicedTotalsReader pulls the Tesla-side invoice aggregate. +// *tesladb.TeslaChargingHistoryRepo (already held as teslaBillFinder) +// does not expose it, so the handler resolves it via this interface. +type invoicedTotalsReader interface { + GetSummary(ctx context.Context, vin string) (*teslamodel.TeslaChargingHistorySummary, error) +} + +// BillVarianceReport reconciles pack-side measured DC totals against +// Tesla cabinet-side invoices. Positive deltas mean Tesla metered more +// than the pack received (cabinet loss + idle/congestion/tax). +type BillVarianceReport struct { + VehicleID int64 `json:"vehicle_id"` + MeasuredSessions int `json:"measured_sessions"` + MeasuredEnergyWh float64 `json:"measured_energy_wh"` + MeasuredCost float64 `json:"measured_cost"` + InvoicedSessions int `json:"invoiced_sessions"` + InvoicedEnergyWh float64 `json:"invoiced_energy_wh"` + InvoicedCost float64 `json:"invoiced_cost"` + EnergyDeltaWh float64 `json:"energy_delta_wh"` + EnergyDeltaPct float64 `json:"energy_delta_pct"` + CostDelta float64 `json:"cost_delta"` + CostDeltaPct float64 `json:"cost_delta_pct"` + CabinetLossPct float64 `json:"cabinet_loss_pct"` + Verdict string `json:"verdict"` + Explanation string `json:"explanation"` +} + +const ( + billVerdictReconciled = "reconciled" + billVerdictReview = "review" + billVerdictMissing = "missing_data" +) + +// ComputeBillVariance is the pure reconciliation math. invoiced may be nil +// (no invoices on file) — the report then degrades to missing_data instead +// of fabricating a comparison. +func ComputeBillVariance(vehicleID int64, measured chargingdb.MeasuredDCTotals, invoiced *teslamodel.TeslaChargingHistorySummary) BillVarianceReport { + rep := BillVarianceReport{ + VehicleID: vehicleID, + MeasuredSessions: measured.Sessions, + MeasuredEnergyWh: round2(measured.EnergyWh), + MeasuredCost: round2(measured.Cost), + } + if invoiced == nil || invoiced.TotalSessions == 0 { + rep.Verdict = billVerdictMissing + rep.Explanation = "No Tesla invoices on file — sync Tesla charging history to reconcile measured sessions against billed totals." + return rep + } + invWh := deref(invoiced.TotalWh) + invCost := deref(invoiced.TotalSpend) + rep.InvoicedSessions = invoiced.TotalSessions + rep.InvoicedEnergyWh = round2(invWh) + rep.InvoicedCost = round2(invCost) + rep.EnergyDeltaWh = round2(invWh - measured.EnergyWh) + rep.CostDelta = round2(invCost - measured.Cost) + if measured.EnergyWh > 0 { + rep.EnergyDeltaPct = round2((invWh - measured.EnergyWh) / measured.EnergyWh * 100) + } + if measured.Cost > 0 { + rep.CostDeltaPct = round2((invCost - measured.Cost) / measured.Cost * 100) + } + // Cabinet loss = invoiced energy the pack never saw, as a share of the + // invoice. Clamped at zero: a negative value means measurement noise, + // not negative physics. + rep.CabinetLossPct = 0 + if invWh > 0 && invWh > measured.EnergyWh { + rep.CabinetLossPct = round2((invWh - measured.EnergyWh) / invWh * 100) + } + + switch { + case math.Abs(rep.EnergyDeltaPct) <= 8 && math.Abs(rep.CostDeltaPct) <= 10: + rep.Verdict = billVerdictReconciled + rep.Explanation = fmt.Sprintf( + "Measured and billed DC charging agree within %.1f%% energy / %.1f%% cost across %d sessions — cabinet loss of %.1f%% is normal Supercharger overhead.", + math.Abs(rep.EnergyDeltaPct), math.Abs(rep.CostDeltaPct), measured.Sessions, rep.CabinetLossPct, + ) + default: + rep.Verdict = billVerdictReview + rep.Explanation = fmt.Sprintf( + "Billed energy differs from measured by %.1f%% (%s Wh) and cost by %.1f%% (%s). Check idle/congestion fees, missing invoices, or unmatched sessions.", + rep.EnergyDeltaPct, fmtSigned(rep.EnergyDeltaWh), rep.CostDeltaPct, fmtSigned(rep.CostDelta), + ) + } + return rep +} + +// BillVariance serves GET /charging/bill-variance?vehicle_id=.... +func (h *ChargingHandler) BillVariance(w http.ResponseWriter, r *http.Request) { + vidStr := r.URL.Query().Get("vehicle_id") + if vidStr == "" { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id is required") + return + } + vehicleID, err := strconv.ParseInt(vidStr, 10, 64) + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + + ctx := r.Context() + var summer measuredDCSummer + var invoicedReader invoicedTotalsReader + var vin string + if h.varianceOverride != nil { + summer = h.varianceOverride.measured + invoicedReader = h.varianceOverride.invoiced + vin = h.varianceOverride.vin + } else { + summer = h.chargingRepo + if h.vehicles != nil { + if v, verr := h.vehicles.GetByID(ctx, vehicleID); verr == nil && v != nil { + vin = v.VIN + } + } + invoicedReader, _ = h.teslaBills.(invoicedTotalsReader) + } + + measured, err := summer.SumMeasuredDC(ctx, vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("charging.bill-variance: measured totals failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load measured totals") + return + } + + // Invoices are keyed by VIN. A vehicle without a VIN (or with no + // synced history) degrades to missing_data, not an error. + var invoiced *teslamodel.TeslaChargingHistorySummary + if vin != "" && invoicedReader != nil { + if sum, serr := invoicedReader.GetSummary(ctx, vin); serr != nil { + log.Warn().Err(serr).Int64("vehicle_id", vehicleID).Msg("charging.bill-variance: invoice summary failed") + } else { + invoiced = sum + } + } + + httpx.WriteJSON(w, http.StatusOK, ComputeBillVariance(vehicleID, measured, invoiced)) +} + +func deref(f *float64) float64 { + if f == nil { + return 0 + } + return *f +} + +func round2(f float64) float64 { return math.Round(f*100) / 100 } + +func fmtSigned(f float64) string { + if f < 0 { + return fmt.Sprintf("-%.2f", -f) + } + return fmt.Sprintf("+%.2f", f) +} diff --git a/internal/api/charging/variance_test.go b/internal/api/charging/variance_test.go new file mode 100644 index 0000000000..9ae0a788ba --- /dev/null +++ b/internal/api/charging/variance_test.go @@ -0,0 +1,117 @@ +package charging + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + chargingdb "github.com/ev-dev-labs/teslasync/internal/database/charging" + teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" +) + +type fakeMeasuredSummer struct { + totals chargingdb.MeasuredDCTotals + err error +} + +func (f *fakeMeasuredSummer) SumMeasuredDC(_ context.Context, _ int64) (chargingdb.MeasuredDCTotals, error) { + return f.totals, f.err +} + +type fakeInvoicedReader struct { + summary *teslamodel.TeslaChargingHistorySummary + err error +} + +func (f *fakeInvoicedReader) GetSummary(_ context.Context, _ string) (*teslamodel.TeslaChargingHistorySummary, error) { + return f.summary, f.err +} + +var ( + _ measuredDCSummer = (*fakeMeasuredSummer)(nil) + _ invoicedTotalsReader = (*fakeInvoicedReader)(nil) +) + +func f64(v float64) *float64 { return &v } + +func TestComputeBillVarianceReconciled(t *testing.T) { + rep := ComputeBillVariance(9, + chargingdb.MeasuredDCTotals{Sessions: 40, EnergyWh: 100000, Cost: 35}, + &teslamodel.TeslaChargingHistorySummary{TotalSessions: 40, TotalWh: f64(104000), TotalSpend: f64(36.5)}, + ) + if rep.Verdict != billVerdictReconciled { + t.Fatalf("verdict = %s, want reconciled (%+v)", rep.Verdict, rep) + } + if rep.CabinetLossPct <= 0 || rep.CabinetLossPct > 8 { + t.Fatalf("cabinet loss = %v, want (0, 8]", rep.CabinetLossPct) + } +} + +func TestComputeBillVarianceReview(t *testing.T) { + rep := ComputeBillVariance(9, + chargingdb.MeasuredDCTotals{Sessions: 40, EnergyWh: 100000, Cost: 35}, + &teslamodel.TeslaChargingHistorySummary{TotalSessions: 40, TotalWh: f64(130000), TotalSpend: f64(52)}, + ) + if rep.Verdict != billVerdictReview { + t.Fatalf("verdict = %s, want review", rep.Verdict) + } + if rep.Explanation == "" { + t.Fatal("expected an explanation") + } +} + +func TestComputeBillVarianceMissing(t *testing.T) { + rep := ComputeBillVariance(9, chargingdb.MeasuredDCTotals{Sessions: 5, EnergyWh: 12000, Cost: 4}, nil) + if rep.Verdict != billVerdictMissing { + t.Fatalf("verdict = %s, want missing_data", rep.Verdict) + } +} + +func TestBillVarianceServesReport(t *testing.T) { + h := &ChargingHandler{varianceOverride: &varianceTestSeam{ + measured: &fakeMeasuredSummer{totals: chargingdb.MeasuredDCTotals{Sessions: 10, EnergyWh: 50000, Cost: 18}}, + invoiced: &fakeInvoicedReader{summary: &teslamodel.TeslaChargingHistorySummary{ + TotalSessions: 10, TotalWh: f64(52000), TotalSpend: f64(19), + }}, + vin: "VIN1", + }} + req := httptest.NewRequest(http.MethodGet, "/bill-variance?vehicle_id=9", nil) + rec := httptest.NewRecorder() + h.BillVariance(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var rep BillVarianceReport + if err := json.NewDecoder(rec.Body).Decode(&rep); err != nil { + t.Fatal(err) + } + if rep.VehicleID != 9 || rep.Verdict != billVerdictReconciled { + t.Fatalf("unexpected report: %+v", rep) + } +} + +func TestBillVarianceRejectsMissingVehicle(t *testing.T) { + h := &ChargingHandler{varianceOverride: &varianceTestSeam{}} + req := httptest.NewRequest(http.MethodGet, "/bill-variance", nil) + rec := httptest.NewRecorder() + h.BillVariance(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestBillVariancePropagatesMeasuredError(t *testing.T) { + h := &ChargingHandler{varianceOverride: &varianceTestSeam{ + measured: &fakeMeasuredSummer{err: errors.New("db down")}, + vin: "VIN1", + }} + req := httptest.NewRequest(http.MethodGet, "/bill-variance?vehicle_id=9", nil) + rec := httptest.NewRecorder() + h.BillVariance(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } +} diff --git a/internal/api/chatbot/chat.go b/internal/api/chatbot/chat.go index f64d073ef3..c9f5e9118d 100644 --- a/internal/api/chatbot/chat.go +++ b/internal/api/chatbot/chat.go @@ -39,7 +39,10 @@ func (h *ChatbotHandler) Chat(w http.ResponseWriter, r *http.Request) { _ = h.chat.SaveMessage(r.Context(), userMsg) // Generate response by interpreting the query - response := h.processQuery(r.Context(), body.Message) + response, links := h.processQueryWithLinks(r.Context(), body.Message) + if links == nil { + links = []ChatLink{} + } // Save assistant message assistantMsg := &chatbotmodel.ChatMessage{SessionID: body.SessionID, Role: "assistant", Content: response} @@ -48,6 +51,7 @@ func (h *ChatbotHandler) Chat(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, map[string]interface{}{ "response": response, "session_id": body.SessionID, + "links": links, }) } @@ -75,14 +79,14 @@ func (h *ChatbotHandler) processQuery(ctx context.Context, msg string) string { case matchAny(lower, "battery", "charge level", "soc", "state of charge"): return h.queryBatteryStatus(ctx) - case matchAny(lower, "charging", "how many charge", "charge session", "total energy charged", "energy added"): - days := extractDays(lower, 30) - return h.queryChargingSummary(ctx, days) - case matchAny(lower, "charging cost", "total cost", "how much spent", "money spent", "electricity cost"): days := extractDays(lower, 30) return h.queryChargingCost(ctx, days) + case matchAny(lower, "charging", "how many charge", "charge session", "total energy charged", "energy added"): + days := extractDays(lower, 30) + return h.queryChargingSummary(ctx, days) + case matchAny(lower, "longest drive", "farthest drive", "max distance"): return h.queryLongestDrive(ctx) diff --git a/internal/api/chatbot/citations.go b/internal/api/chatbot/citations.go new file mode 100644 index 0000000000..a2ca8076b5 --- /dev/null +++ b/internal/api/chatbot/citations.go @@ -0,0 +1,92 @@ +package chatbot + +import ( + "context" + "strings" +) + +// ChatLink is a deep-link citation attached to an assistant reply: the page +// where the user can see the underlying chart or table. +type ChatLink struct { + Label string `json:"label"` + Path string `json:"path"` +} + +// intentLinks maps each heuristic intent to its evidence pages. Paths must +// match frontend routes in web/src/App.tsx. +func intentLinks(intent string) []ChatLink { + link := func(label, path string) []ChatLink { return []ChatLink{{Label: label, Path: path}} } + switch intent { + case "vehicles": + return link("Vehicles", "/vehicles") + case "drives", "distance": + return link("Drives", "/drives") + case "efficiency": + return []ChatLink{ + {Label: "Temperature impact", Path: "/temperature-impact"}, + {Label: "Drives", Path: "/drives"}, + } + case "battery": + return link("Battery", "/battery") + case "charging": + return link("Charging", "/charging") + case "cost": + return link("Cost analysis", "/cost-analysis") + case "longest", "maxspeed", "lastdrive": + return link("Drives", "/drives") + case "lastcharge": + return link("Charging", "/charging") + case "alerts": + return link("Alerts", "/notifications/alerts") + case "geofences": + return link("Geofences", "/geofences") + case "status": + return link("Vehicles", "/vehicles") + default: + return nil + } +} + +// classifyIntent mirrors the processQuery switch so citations stay aligned +// with the answering branch. It returns "" for help/fallback (no links). +func classifyIntent(lower string) string { + switch { + case matchAny(lower, "how many vehicle", "fleet size", "total vehicle", "how many car"): + return "vehicles" + case matchAny(lower, "how many drive", "total drive", "number of drive", "trips", "total trips"): + return "drives" + case matchAny(lower, "total distance", "how far", "how many km", "how many mile", "distance driven"): + return "distance" + case matchAny(lower, "efficiency", "wh/km", "energy per km", "consumption"): + return "efficiency" + case matchAny(lower, "battery", "charge level", "soc", "state of charge"): + return "battery" + case matchAny(lower, "charging cost", "total cost", "how much spent", "money spent", "electricity cost"): + return "cost" + case matchAny(lower, "charging", "how many charge", "charge session", "total energy charged", "energy added"): + return "charging" + case matchAny(lower, "longest drive", "farthest drive", "max distance"): + return "longest" + case matchAny(lower, "fastest", "top speed", "max speed", "speed record"): + return "maxspeed" + case matchAny(lower, "last drive", "recent drive", "latest drive"): + return "lastdrive" + case matchAny(lower, "last charge", "recent charge", "latest charge"): + return "lastcharge" + case matchAny(lower, "alert", "notification", "warning"): + return "alerts" + case matchAny(lower, "geofence", "zone", "saved location"): + return "geofences" + case matchAny(lower, "online", "awake", "status", "vehicle state"): + return "status" + default: + return "" + } +} + +// processQueryWithLinks answers like processQuery and attaches deep-link +// citations for the answering intent. +func (h *ChatbotHandler) processQueryWithLinks(ctx context.Context, msg string) (string, []ChatLink) { + text := h.processQuery(ctx, msg) + return text, intentLinks(classifyIntent(strings.ToLower(msg))) +} diff --git a/internal/api/chatbot/citations_test.go b/internal/api/chatbot/citations_test.go new file mode 100644 index 0000000000..89f87b3a85 --- /dev/null +++ b/internal/api/chatbot/citations_test.go @@ -0,0 +1,36 @@ +package chatbot + +import ( + "strings" + "testing" +) + +func TestClassifyIntentCostBeatsCharging(t *testing.T) { + if got := classifyIntent(strings.ToLower("what was my charging cost?")); got != "cost" { + t.Fatalf("intent = %q, want cost", got) + } + if got := classifyIntent(strings.ToLower("charging sessions?")); got != "charging" { + t.Fatalf("intent = %q, want charging", got) + } +} + +func TestIntentLinksPointAtRealRoutes(t *testing.T) { + for _, intent := range []string{ + "vehicles", "drives", "distance", "efficiency", "battery", + "charging", "cost", "longest", "maxspeed", "lastdrive", + "lastcharge", "alerts", "geofences", "status", + } { + links := intentLinks(intent) + if len(links) == 0 { + t.Fatalf("intent %s has no links", intent) + } + for _, l := range links { + if l.Label == "" || !strings.HasPrefix(l.Path, "/") { + t.Fatalf("bad link %+v for %s", l, intent) + } + } + } + if links := intentLinks(""); len(links) != 0 { + t.Fatalf("fallback must have no links: %+v", links) + } +} diff --git a/internal/api/comfort/handler.go b/internal/api/comfort/handler.go new file mode 100644 index 0000000000..67636f39f8 --- /dev/null +++ b/internal/api/comfort/handler.go @@ -0,0 +1,311 @@ +package comfort + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + vehicledb "github.com/ev-dev-labs/teslasync/internal/database/vehicle" + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" + "github.com/ev-dev-labs/teslasync/internal/tesla" +) + +// comfortCommandTimeout caps each Tesla climate call (project rule — +// Tesla API: 30s). +const comfortCommandTimeout = 30 * time.Second + +// DefaultEvaluateInterval is the 5-minute event-watch cadence. +const DefaultEvaluateInterval = 5 * time.Minute + +// ConfigStore is the config/run port. *Store satisfies it. +type ConfigStore interface { + GetConfig(ctx context.Context, vehicleID int64) (*Config, error) + UpsertConfig(ctx context.Context, c *Config) error + EnabledConfigs(ctx context.Context) ([]*Config, error) + HasRun(ctx context.Context, vehicleID int64, uid string) (bool, error) + LogRun(ctx context.Context, r *Run) (bool, error) + ListRuns(ctx context.Context, vehicleID int64, limit int) ([]*Run, error) +} + +// FeedFetcher downloads ICS feeds. *Fetcher satisfies it. +type FeedFetcher interface { + Fetch(ctx context.Context, feedURL string) ([]Event, error) +} + +// Commander issues Tesla vehicle commands. *tesla.Client satisfies it. +type Commander interface { + SendCommand(ctx context.Context, vin string, command string, params map[string]interface{}) error +} + +// vehicleByIDFetcher fetches a single vehicle. *vehicledb.VehicleRepo +// satisfies it. +type vehicleByIDFetcher interface { + GetByID(ctx context.Context, id int64) (*vehiclemodel.Vehicle, error) +} + +// Handler serves comfort config/status/runs and runs the event-watch +// evaluator. Stateless beyond constructor inputs; safe for concurrent use. +type Handler struct { + store ConfigStore + feeds FeedFetcher + tesla Commander + vehicles vehicleByIDFetcher + now func() time.Time +} + +// NewHandler wires the handler. Panics on nil inputs (fail-fast wiring +// contract, matching sibling handlers). +func NewHandler(store ConfigStore, feeds FeedFetcher, tesla Commander, vehicles vehicleByIDFetcher) *Handler { + if store == nil || feeds == nil || tesla == nil || vehicles == nil { + panic("comfort: nil dependency") + } + return &Handler{store: store, feeds: feeds, tesla: tesla, vehicles: vehicles, now: time.Now} +} + +type nextResponse struct { + Config *Config `json:"config"` + Event *Event `json:"event,omitempty"` +} + +// Next serves GET /comfort/next?vehicle_id=: the stored config plus the +// next offsite event inside the lead window (null when none). Read-only. +func (h *Handler) Next(w http.ResponseWriter, r *http.Request) { + vehicleID, err := vehicleIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + cfg, err := h.store.GetConfig(ctx, vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("comfort: config read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read comfort config") + return + } + resp := nextResponse{Config: cfg} + if cfg.ICSURL != "" { + events, err := h.feeds.Fetch(ctx, cfg.ICSURL) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("comfort: ICS fetch failed") + httpx.WriteError(w, http.StatusBadGateway, "calendar feed unavailable") + return + } + resp.Event = NextOffsite(events, h.now(), time.Duration(cfg.LeadMinutes)*time.Minute) + } + httpx.WriteJSON(w, http.StatusOK, resp) +} + +type configRequest struct { + VehicleID int64 `json:"vehicle_id"` + Enabled bool `json:"enabled"` + TargetTempC float64 `json:"target_temp_c"` + LeadMinutes int `json:"lead_minutes"` + ICSURL string `json:"ics_url"` +} + +// UpsertConfig serves PUT /comfort/config. +func (h *Handler) UpsertConfig(w http.ResponseWriter, r *http.Request) { + var req configRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.VehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + if req.TargetTempC < 15 || req.TargetTempC > 28 { + httpx.WriteError(w, http.StatusBadRequest, "target_temp_c must be 15..28") + return + } + if req.LeadMinutes < 5 || req.LeadMinutes > 120 { + httpx.WriteError(w, http.StatusBadRequest, "lead_minutes must be 5..120") + return + } + if len(req.ICSURL) > 2000 { + httpx.WriteError(w, http.StatusBadRequest, "ics_url too long") + return + } + if req.ICSURL != "" { + if err := validateICSURL(req.ICSURL); err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + } + cfg := &Config{VehicleID: req.VehicleID, Enabled: req.Enabled, TargetTempC: req.TargetTempC, LeadMinutes: req.LeadMinutes, ICSURL: req.ICSURL} + if err := h.store.UpsertConfig(r.Context(), cfg); err != nil { + log.Error().Err(err).Int64("vehicle_id", req.VehicleID).Msg("comfort: config write failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save comfort config") + return + } + httpx.WriteJSON(w, http.StatusOK, cfg) +} + +type nowRequest struct { + VehicleID int64 `json:"vehicle_id"` +} + +// PreconditionNow serves POST /comfort/now: immediate climate start at +// the configured target. Rate-limited at the router. +func (h *Handler) PreconditionNow(w http.ResponseWriter, r *http.Request) { + var req nowRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.VehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + ctx := r.Context() + cfg, err := h.store.GetConfig(ctx, req.VehicleID) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "failed to read comfort config") + return + } + if err := h.startClimate(ctx, cfg); err != nil { + log.Error().Err(err).Int64("vehicle_id", req.VehicleID).Msg("comfort: precondition failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to start climate") + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]interface{}{"status": "started", "target_temp_c": cfg.TargetTempC}) +} + +// Runs serves GET /comfort/runs?vehicle_id=&limit=. +func (h *Handler) Runs(w http.ResponseWriter, r *http.Request) { + vehicleID, err := vehicleIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + limit := 20 + if s := r.URL.Query().Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + limit = n + } + } + runs, err := h.store.ListRuns(r.Context(), vehicleID, limit) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("comfort: runs read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read comfort runs") + return + } + httpx.WriteJSON(w, http.StatusOK, runs) +} + +func vehicleIDParam(r *http.Request) (int64, error) { + s := r.URL.Query().Get("vehicle_id") + id, err := strconv.ParseInt(s, 10, 64) + if err != nil || id <= 0 { + return 0, errBadVehicleID + } + return id, nil +} + +type vehicleIDError string + +func (e vehicleIDError) Error() string { return string(e) } + +const errBadVehicleID = vehicleIDError("vehicle_id must be a positive integer") + +// Run starts the periodic evaluation loop until ctx ends. Per-pass +// failures are logged inside EvaluateEnabled and never kill the loop. +func (h *Handler) Run(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = DefaultEvaluateInterval + } + h.EvaluateEnabled(ctx) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + h.EvaluateEnabled(ctx) + } + } +} + +// EvaluateEnabled runs one event-watch pass over every enabled vehicle: +// fetch the ICS feed, pick the next offsite event in the lead window, +// skip already-acted UIDs, and precondition. Per-vehicle failures are +// logged and skipped. +func (h *Handler) EvaluateEnabled(ctx context.Context) { + cfgs, err := h.store.EnabledConfigs(ctx) + if err != nil { + log.Error().Err(err).Msg("comfort: enabled list failed") + return + } + for _, cfg := range cfgs { + if err := h.evaluateOne(ctx, cfg); err != nil { + log.Error().Err(err).Int64("vehicle_id", cfg.VehicleID).Msg("comfort: evaluation failed") + } + } +} + +func (h *Handler) evaluateOne(ctx context.Context, cfg *Config) error { + if cfg.ICSURL == "" { + return nil + } + events, err := h.feeds.Fetch(ctx, cfg.ICSURL) + if err != nil { + return err + } + next := NextOffsite(events, h.now(), time.Duration(cfg.LeadMinutes)*time.Minute) + if next == nil { + return nil + } + acted, err := h.store.HasRun(ctx, cfg.VehicleID, next.UID) + if err != nil { + return err + } + if acted { + return nil + } + // Reserve the UID first: concurrent ticks collapse onto the unique + // constraint instead of double-preconditioning. + ran, err := h.store.LogRun(ctx, &Run{ + VehicleID: cfg.VehicleID, EventUID: next.UID, EventTitle: next.Title, StartsAt: next.StartsAt, + }) + if err != nil || !ran { + return err + } + if err := h.startClimate(ctx, cfg); err != nil { + return err + } + log.Info().Int64("vehicle_id", cfg.VehicleID).Str("event", next.Title).Msg("comfort: preconditioned for event") + return nil +} + +// startClimate sets temps then starts climate, each under its own fresh +// deadline so a stuck first call cannot starve the second's budget. +func (h *Handler) startClimate(ctx context.Context, cfg *Config) error { + vehicle, err := h.vehicles.GetByID(ctx, cfg.VehicleID) + if err != nil || vehicle == nil { + return err + } + tempsCtx, cancel := context.WithTimeout(ctx, comfortCommandTimeout) + defer cancel() + if err := h.tesla.SendCommand(tempsCtx, vehicle.VIN, "set_temps", map[string]interface{}{ + "driver_temp": cfg.TargetTempC, "passenger_temp": cfg.TargetTempC, + }); err != nil { + return err + } + onCtx, cancel := context.WithTimeout(ctx, comfortCommandTimeout) + defer cancel() + return h.tesla.SendCommand(onCtx, vehicle.VIN, "climate_on", nil) +} + +// Compile-time port assertions. +var ( + _ ConfigStore = (*Store)(nil) + _ FeedFetcher = (*Fetcher)(nil) + _ Commander = (*tesla.Client)(nil) + _ vehicleByIDFetcher = (*vehicledb.VehicleRepo)(nil) +) diff --git a/internal/api/comfort/handler_test.go b/internal/api/comfort/handler_test.go new file mode 100644 index 0000000000..0488f3aece --- /dev/null +++ b/internal/api/comfort/handler_test.go @@ -0,0 +1,234 @@ +package comfort + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" +) + +type fakeStore struct { + cfg *Config + runs []*Run + ran map[string]bool + upsert *Config + err error +} + +func (f *fakeStore) GetConfig(_ context.Context, vehicleID int64) (*Config, error) { + if f.err != nil { + return nil, f.err + } + if f.cfg != nil { + return f.cfg, nil + } + return DefaultConfig(vehicleID), nil +} + +func (f *fakeStore) UpsertConfig(_ context.Context, c *Config) error { + f.upsert = c + return f.err +} + +func (f *fakeStore) EnabledConfigs(_ context.Context) ([]*Config, error) { + if f.cfg != nil && f.cfg.Enabled { + return []*Config{f.cfg}, f.err + } + return nil, f.err +} + +func (f *fakeStore) HasRun(_ context.Context, _ int64, uid string) (bool, error) { + return f.ran[uid], f.err +} + +func (f *fakeStore) LogRun(_ context.Context, r *Run) (bool, error) { + if f.err != nil { + return false, f.err + } + if f.ran == nil { + f.ran = map[string]bool{} + } + if f.ran[r.EventUID] { + return false, nil + } + f.ran[r.EventUID] = true + f.runs = append(f.runs, r) + return true, nil +} + +func (f *fakeStore) ListRuns(_ context.Context, _ int64, _ int) ([]*Run, error) { + return f.runs, f.err +} + +var _ ConfigStore = (*fakeStore)(nil) + +type fakeFeeds struct { + events []Event + err error +} + +func (f *fakeFeeds) Fetch(_ context.Context, _ string) ([]Event, error) { return f.events, f.err } + +var _ FeedFetcher = (*fakeFeeds)(nil) + +type fakeCommander struct { + calls []string + vin string + err error +} + +func (f *fakeCommander) SendCommand(_ context.Context, vin string, command string, _ map[string]interface{}) error { + f.calls = append(f.calls, command) + f.vin = vin + return f.err +} + +var _ Commander = (*fakeCommander)(nil) + +type fakeVehicles struct { + vin string +} + +func (f *fakeVehicles) GetByID(_ context.Context, id int64) (*vehiclemodel.Vehicle, error) { + return &vehiclemodel.Vehicle{ID: id, VIN: f.vin}, nil +} + +func testHandler(store *fakeStore, feeds *fakeFeeds, cmd *fakeCommander) *Handler { + return &Handler{store: store, feeds: feeds, tesla: cmd, vehicles: &fakeVehicles{vin: "VIN7"}, now: func() time.Time { + return time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + }} +} + +func TestNext(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + store := &fakeStore{cfg: &Config{VehicleID: 7, Enabled: true, LeadMinutes: 20, ICSURL: "http://10.0.0.5/y.ics"}} + feeds := &fakeFeeds{events: []Event{ + {UID: "a", Title: "Dentist", Location: "123 Main", StartsAt: now.Add(15 * time.Minute)}, + }} + h := testHandler(store, feeds, &fakeCommander{}) + + req := httptest.NewRequest(http.MethodGet, "/next?vehicle_id=7", nil) + rec := httptest.NewRecorder() + h.Next(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var resp nextResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Event == nil || resp.Event.UID != "a" { + t.Fatalf("event = %+v, want UID a", resp.Event) + } +} + +func TestNextNoFeed(t *testing.T) { + store := &fakeStore{cfg: &Config{VehicleID: 7, LeadMinutes: 20}} + h := testHandler(store, &fakeFeeds{err: errors.New("must not be called")}, &fakeCommander{}) + req := httptest.NewRequest(http.MethodGet, "/next?vehicle_id=7", nil) + rec := httptest.NewRecorder() + h.Next(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestUpsertConfig(t *testing.T) { + store := &fakeStore{} + h := testHandler(store, &fakeFeeds{}, &fakeCommander{}) + body := `{"vehicle_id":7,"enabled":true,"target_temp_c":22.5,"lead_minutes":30,"ics_url":"http://10.0.0.5/y.ics"}` + req := httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.UpsertConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if store.upsert == nil || store.upsert.TargetTempC != 22.5 || store.upsert.LeadMinutes != 30 { + t.Fatalf("upsert = %+v", store.upsert) + } + for _, bad := range []string{ + `{"vehicle_id":7,"target_temp_c":5,"lead_minutes":20}`, + `{"vehicle_id":7,"target_temp_c":21,"lead_minutes":500}`, + `{"vehicle_id":0,"target_temp_c":21,"lead_minutes":20}`, + } { + req := httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(bad)) + rec := httptest.NewRecorder() + h.UpsertConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("body %q status = %d, want 400", bad, rec.Code) + } + } +} + +func TestPreconditionNow(t *testing.T) { + store := &fakeStore{cfg: &Config{VehicleID: 7, TargetTempC: 22}} + cmd := &fakeCommander{} + h := testHandler(store, &fakeFeeds{}, cmd) + req := httptest.NewRequest(http.MethodPost, "/now", strings.NewReader(`{"vehicle_id":7}`)) + rec := httptest.NewRecorder() + h.PreconditionNow(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if len(cmd.calls) != 2 || cmd.calls[0] != "set_temps" || cmd.calls[1] != "climate_on" { + t.Fatalf("calls = %v, want [set_temps climate_on]", cmd.calls) + } +} + +func TestEvaluateEnabled(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + newCase := func() (*fakeStore, *fakeFeeds, *fakeCommander) { + store := &fakeStore{cfg: &Config{VehicleID: 7, Enabled: true, TargetTempC: 22, LeadMinutes: 20, ICSURL: "http://10.0.0.5/y.ics"}} + feeds := &fakeFeeds{events: []Event{ + {UID: "a", Title: "Dentist", Location: "123 Main", StartsAt: now.Add(15 * time.Minute)}, + }} + return store, feeds, &fakeCommander{} + } + + t.Run("preconditions once per event", func(t *testing.T) { + store, feeds, cmd := newCase() + h := testHandler(store, feeds, cmd) + h.EvaluateEnabled(context.Background()) + h.EvaluateEnabled(context.Background()) + if len(cmd.calls) != 2 { + t.Fatalf("calls = %v, want exactly one set_temps+climate_on pair", cmd.calls) + } + if len(store.runs) != 1 || store.runs[0].EventUID != "a" { + t.Fatalf("runs = %+v", store.runs) + } + }) + + t.Run("no event in window does nothing", func(t *testing.T) { + store, feeds, cmd := newCase() + feeds.events[0].StartsAt = now.Add(2 * time.Hour) + h := testHandler(store, feeds, cmd) + h.EvaluateEnabled(context.Background()) + if len(cmd.calls) != 0 || len(store.runs) != 0 { + t.Fatal("expected silence outside the lead window") + } + }) + + t.Run("feed failure skips vehicle", func(t *testing.T) { + store, _, cmd := newCase() + h := testHandler(store, &fakeFeeds{err: errors.New("down")}, cmd) + h.EvaluateEnabled(context.Background()) + if len(cmd.calls) != 0 { + t.Fatal("expected no commands on feed failure") + } + }) +} + +func TestNewHandlerPanicsOnNil(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + NewHandler(nil, &fakeFeeds{}, &fakeCommander{}, &fakeVehicles{}) +} diff --git a/internal/api/comfort/ics.go b/internal/api/comfort/ics.go new file mode 100644 index 0000000000..68839d72d1 --- /dev/null +++ b/internal/api/comfort/ics.go @@ -0,0 +1,296 @@ +// Package comfort preconditions the cabin ahead of calendar events: each +// armed vehicle polls a user-provided ICS subscription, finds the next +// offsite event inside the lead window, and starts climate + sets temps +// so the car is comfortable at departure. Runs are idempotent per event +// UID; generic cron-based preconditioning stays in the automation engine. +package comfort + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// fetchTimeout bounds the ICS subscription fetch (project rule: external +// HTTP calls wrap with context.WithTimeout). maxICSBytes caps the feed. +const ( + fetchTimeout = 10 * time.Second + maxICSBytes = 1 << 20 +) + +// Event is one parsed VEVENT with the fields comfort needs. +type Event struct { + UID string `json:"uid"` + Title string `json:"title"` + Location string `json:"location"` + StartsAt time.Time `json:"starts_at"` + AllDay bool `json:"all_day"` +} + +// Fetcher downloads ICS feeds. HTTPClient is overridable for tests. +// Safe for concurrent use. +type Fetcher struct { + HTTPClient *http.Client +} + +// lookupICSHost resolves feed hosts. Overridable in tests so validation +// never needs live DNS. +var lookupICSHost = net.LookupIP + +// NewFetcher wires a production fetcher that refuses loopback / link-local +// / metadata redirects (homelab RFC1918 calendars remain allowed). +func NewFetcher() *Fetcher { + return &Fetcher{HTTPClient: &http.Client{ + Timeout: fetchTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return fmt.Errorf("comfort: too many ICS redirects") + } + if req.URL == nil { + return fmt.Errorf("comfort: ICS redirect missing url") + } + return validateICSURL(req.URL.String()) + }, + }} +} + +// validateICSURL rejects non-http(s) schemes, loopback, link-local, and +// cloud-metadata addresses. Empty URLs are handled by the caller. +func validateICSURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return fmt.Errorf("comfort: invalid ICS url") + } + if u.Scheme != "https" && u.Scheme != "http" { + return fmt.Errorf("comfort: ICS url must be http or https") + } + host := strings.ToLower(u.Hostname()) + if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") { + return fmt.Errorf("comfort: ICS url host not allowed") + } + if ip := net.ParseIP(host); ip != nil { + if forbiddenICSIP(ip) { + return fmt.Errorf("comfort: ICS url host not allowed") + } + return nil + } + ips, err := lookupICSHost(host) + if err != nil { + return fmt.Errorf("comfort: ICS url host lookup failed: %w", err) + } + if len(ips) == 0 { + return fmt.Errorf("comfort: ICS url host not allowed") + } + for _, ip := range ips { + if forbiddenICSIP(ip) { + return fmt.Errorf("comfort: ICS url host not allowed") + } + } + return nil +} + +func forbiddenICSIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { + return true + } + return ip.Equal(net.ParseIP("169.254.169.254")) +} + +// Fetch downloads and parses the ICS feed at feedURL. +func (f *Fetcher) Fetch(ctx context.Context, feedURL string) ([]Event, error) { + if feedURL == "" { + return nil, fmt.Errorf("comfort: empty ICS url") + } + if err := validateICSURL(feedURL); err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) + if err != nil { + return nil, fmt.Errorf("comfort: build ICS request: %w", err) + } + req.Header.Set("User-Agent", "TeslaSync/1.0") + client := f.HTTPClient + if client == nil { + client = http.DefaultClient + } + callCtx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + resp, err := client.Do(req.WithContext(callCtx)) + if err != nil { + return nil, fmt.Errorf("comfort: ICS fetch: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("comfort: ICS status %d", resp.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxICSBytes+1)) + if err != nil { + return nil, fmt.Errorf("comfort: ICS read: %w", err) + } + if len(raw) > maxICSBytes { + return nil, fmt.Errorf("comfort: ICS feed exceeds %d bytes", maxICSBytes) + } + return ParseICS(string(raw)) +} + +// ParseICS parses a minimal VEVENT subset (UID/DTSTART/SUMMARY/LOCATION) +// with RFC 5545 line unfolding. Pure: no I/O. Supported DTSTART forms: +// UTC ("...Z"), TZID-parameterized (IANA zone, UTC fallback), floating +// local (interpreted as UTC — feeds that care emit TZID or Z), and +// date-only (all-day, midnight UTC). Malformed events are skipped, never +// fatal: one bad VEVENT must not kill the whole feed. +func ParseICS(raw string) ([]Event, error) { + lines := unfoldLines(raw) + var events []Event + var cur *Event + inEvent := false + for _, ln := range lines { + switch { + case ln == "BEGIN:VEVENT": + inEvent = true + cur = &Event{} + case ln == "END:VEVENT": + if inEvent && cur != nil && cur.UID != "" && !cur.StartsAt.IsZero() { + events = append(events, *cur) + } + inEvent = false + cur = nil + case inEvent && cur != nil: + applyICSLine(cur, ln) + } + } + return events, nil +} + +// unfoldLines joins RFC 5545 folded lines (continuations start with a +// space or tab, which is dropped). +func unfoldLines(raw string) []string { + var out []string + sc := bufio.NewScanner(strings.NewReader(raw)) + sc.Buffer(make([]byte, 64*1024), 64*1024) + for sc.Scan() { + ln := strings.TrimSuffix(sc.Text(), "\r") + if (strings.HasPrefix(ln, " ") || strings.HasPrefix(ln, "\t")) && len(out) > 0 { + out[len(out)-1] += strings.TrimPrefix(strings.TrimPrefix(ln, " "), "\t") + continue + } + out = append(out, ln) + } + return out +} + +func applyICSLine(e *Event, ln string) { + name, value := splitICSProperty(ln) + switch { + case name == "UID": + e.UID = value + case name == "SUMMARY": + e.Title = unescapeICS(value) + case name == "LOCATION": + e.Location = unescapeICS(value) + case name == "DTSTART" || strings.HasPrefix(name, "DTSTART;"): + if ts, allDay, ok := parseICSDate(name, value); ok { + e.StartsAt, e.AllDay = ts, allDay + } + } +} + +// splitICSProperty splits "NAME;PARAM=..:value" into the NAME part (base +// property uppercased, parameters case-preserved — TZIDs are +// case-sensitive) and the value. Returns "","" when malformed. +func splitICSProperty(ln string) (string, string) { + // Feeds in practice never quote parameter values, so the value starts + // after the first colon. + idx := strings.Index(ln, ":") + if idx < 0 { + return "", "" + } + head := ln[:idx] + if i := strings.Index(head, ";"); i >= 0 { + head = strings.ToUpper(head[:i]) + head[i:] + } else { + head = strings.ToUpper(head) + } + return head, ln[idx+1:] +} + +func parseICSDate(name, value string) (time.Time, bool, bool) { + if strings.HasSuffix(strings.ToUpper(name), "VALUE=DATE") || (len(value) == 8 && !strings.Contains(value, "T")) { + ts, err := time.Parse("20060102", value) + if err != nil { + return time.Time{}, false, false + } + return ts.UTC(), true, true + } + if strings.HasSuffix(value, "Z") { + for _, layout := range []string{"20060102T150405Z", "20060102T1504Z"} { + if ts, err := time.Parse(layout, value); err == nil { + return ts.UTC(), false, true + } + } + return time.Time{}, false, false + } + if tz := tzidParam(name); tz != "" { + if loc, err := time.LoadLocation(tz); err == nil { + for _, layout := range []string{"20060102T150405", "20060102T1504"} { + if ts, err := time.ParseInLocation(layout, value, loc); err == nil { + return ts.UTC(), false, true + } + } + } + } + // Floating local: interpret as UTC (documented). + for _, layout := range []string{"20060102T150405", "20060102T1504"} { + if ts, err := time.Parse(layout, value); err == nil { + return ts.UTC(), false, true + } + } + return time.Time{}, false, false +} + +// tzidParam extracts TZID from a "DTSTART;TZID=..." name part, +// matching the parameter name case-insensitively while preserving the +// zone value's case. +func tzidParam(name string) string { + for _, part := range strings.Split(name, ";") { + if len(part) > 5 && strings.EqualFold(part[:5], "TZID=") { + return part[5:] + } + } + return "" +} + +func unescapeICS(s string) string { + r := strings.NewReplacer(`\n`, "\n", `\N`, "\n", `\,`, ",", `\;`, ";", `\\`, `\`) + return r.Replace(s) +} + +// NextOffsite returns the earliest upcoming event with a non-empty +// location starting within (now, now+lead]. All-day events never match +// (no departure time). Pure: no I/O. +func NextOffsite(events []Event, now time.Time, lead time.Duration) *Event { + var best *Event + for i := range events { + e := &events[i] + if e.AllDay || e.Location == "" || e.StartsAt.IsZero() { + continue + } + dt := e.StartsAt.Sub(now) + if dt <= 0 || dt > lead { + continue + } + if best == nil || e.StartsAt.Before(best.StartsAt) { + best = e + } + } + return best +} diff --git a/internal/api/comfort/ics_test.go b/internal/api/comfort/ics_test.go new file mode 100644 index 0000000000..b1aadeb5a9 --- /dev/null +++ b/internal/api/comfort/ics_test.go @@ -0,0 +1,127 @@ +package comfort + +import ( + "net" + "testing" + "time" +) + +func TestValidateICSURL(t *testing.T) { + lookupICSHost = func(host string) ([]net.IP, error) { + return []net.IP{net.ParseIP("203.0.113.10")}, nil + } + t.Cleanup(func() { lookupICSHost = net.LookupIP }) + + if err := validateICSURL("https://calendar.example.com/feed.ics"); err != nil { + t.Fatalf("public https: %v", err) + } + if err := validateICSURL("http://10.0.0.5/calendar.ics"); err != nil { + t.Fatalf("homelab RFC1918: %v", err) + } + if err := validateICSURL("file:///etc/passwd"); err == nil { + t.Fatal("file scheme should be rejected") + } + if err := validateICSURL("http://127.0.0.1/feed.ics"); err == nil { + t.Fatal("loopback should be rejected") + } + if err := validateICSURL("http://169.254.169.254/latest/meta-data"); err == nil { + t.Fatal("link-local metadata should be rejected") + } + lookupICSHost = func(host string) ([]net.IP, error) { + return []net.IP{net.ParseIP("127.0.0.1")}, nil + } + if err := validateICSURL("https://evil.example/feed.ics"); err == nil { + t.Fatal("hostname resolving to loopback should be rejected") + } +} + +const icsFixture = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:one@example.com +DTSTART:20260401T150000Z +SUMMARY:Dentist +LOCATION:123 Main St +END:VEVENT +BEGIN:VEVENT +UID:two@example.com +DTSTART;TZID=America/New_York:20260401T090000 +SUMMARY:Standup\, + continued +LOCATION: +END:VEVENT +BEGIN:VEVENT +UID:allday@example.com +DTSTART;VALUE=DATE:20260402 +SUMMARY:Holiday +LOCATION:Home +END:VEVENT +BEGIN:VEVENT +UID:bad@example.com +DTSTART:not-a-date +SUMMARY:Broken +END:VEVENT +BEGIN:VEVENT +DTSTART:20260401T150000Z +SUMMARY:No UID +END:VEVENT +END:VCALENDAR +` + +func TestParseICS(t *testing.T) { + events, err := ParseICS(icsFixture) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(events) != 3 { + t.Fatalf("events = %d, want 3 (bad + uidless skipped)", len(events)) + } + if events[0].Title != "Dentist" || events[0].Location != "123 Main St" { + t.Fatalf("event0 = %+v", events[0]) + } + want := time.Date(2026, 4, 1, 15, 0, 0, 0, time.UTC) + if !events[0].StartsAt.Equal(want) { + t.Fatalf("event0 start = %v, want %v", events[0].StartsAt, want) + } + // Folded + escaped summary. + if events[1].Title != "Standup, continued" { + t.Fatalf("event1 title = %q", events[1].Title) + } + // 09:00 America/New_York (EDT) = 13:00Z when tzdata is present; + // without a zone database the parser falls back to floating-as-UTC. + wantNY := time.Date(2026, 4, 1, 13, 0, 0, 0, time.UTC) + if _, err := time.LoadLocation("America/New_York"); err != nil { + wantNY = time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC) + } + if !events[1].StartsAt.Equal(wantNY) { + t.Fatalf("event1 start = %v, want %v", events[1].StartsAt, wantNY) + } + if !events[2].AllDay { + t.Fatal("event2 should be all-day") + } +} + +func TestNextOffsite(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + mk := func(uid string, at time.Time, loc string, allDay bool) Event { + return Event{UID: uid, Title: uid, Location: loc, StartsAt: at, AllDay: allDay} + } + events := []Event{ + mk("past", now.Add(-time.Hour), "Office", false), + mk("noloc", now.Add(10*time.Minute), "", false), + mk("allday", now.Add(10*time.Minute), "Office", true), + mk("far", now.Add(2*time.Hour), "Office", false), + mk("later", now.Add(18*time.Minute), "Gym", false), + mk("sooner", now.Add(9*time.Minute), "Office", false), + } + got := NextOffsite(events, now, 20*time.Minute) + if got == nil || got.UID != "sooner" { + t.Fatalf("next = %+v, want sooner", got) + } + if got := NextOffsite(events, now, 5*time.Minute); got != nil { + t.Fatalf("next with 5m lead = %+v, want nil", got) + } + if got := NextOffsite(nil, now, time.Hour); got != nil { + t.Fatalf("next with no events = %+v, want nil", got) + } +} diff --git a/internal/api/comfort/store.go b/internal/api/comfort/store.go new file mode 100644 index 0000000000..f706a36711 --- /dev/null +++ b/internal/api/comfort/store.go @@ -0,0 +1,169 @@ +package comfort + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// Config is the per-vehicle comfort autopilot configuration. +type Config struct { + VehicleID int64 `json:"vehicle_id"` + Enabled bool `json:"enabled"` + TargetTempC float64 `json:"target_temp_c"` + LeadMinutes int `json:"lead_minutes"` + ICSURL string `json:"ics_url"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Run is one preconditioning run (also the idempotency record). +type Run struct { + ID int64 `json:"id"` + VehicleID int64 `json:"vehicle_id"` + EventUID string `json:"event_uid"` + EventTitle string `json:"event_title"` + StartsAt time.Time `json:"starts_at"` + ActedAt time.Time `json:"acted_at"` +} + +// Store persists comfort config + runs. Panics on nil db (fail-fast +// wiring). Safe for concurrent use (pgx pool). +type Store struct { + db *database.DB +} + +// NewStore wires the store. +func NewStore(db *database.DB) *Store { + if db == nil { + panic("comfort: nil db") + } + return &Store{db: db} +} + +// DefaultConfig returns the disabled config for a vehicle. +func DefaultConfig(vehicleID int64) *Config { + return &Config{VehicleID: vehicleID, TargetTempC: 21, LeadMinutes: 20} +} + +// GetConfig returns the stored config, or a disabled default when the +// vehicle was never configured. +func (s *Store) GetConfig(ctx context.Context, vehicleID int64) (*Config, error) { + c := &Config{} + err := s.db.Pool.QueryRow(ctx, + `SELECT vehicle_id, enabled, target_temp_c, lead_minutes, ics_url, updated_at + FROM comfort_config WHERE vehicle_id = $1`, vehicleID, + ).Scan(&c.VehicleID, &c.Enabled, &c.TargetTempC, &c.LeadMinutes, &c.ICSURL, &c.UpdatedAt) + if err == pgx.ErrNoRows { + return DefaultConfig(vehicleID), nil + } + if err != nil { + return nil, fmt.Errorf("comfort: get config: %w", err) + } + return c, nil +} + +// UpsertConfig inserts or replaces the vehicle config. +func (s *Store) UpsertConfig(ctx context.Context, c *Config) error { + _, err := s.db.Pool.Exec(ctx, ` + INSERT INTO comfort_config (vehicle_id, enabled, target_temp_c, lead_minutes, ics_url, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (vehicle_id) DO UPDATE SET + enabled = EXCLUDED.enabled, target_temp_c = EXCLUDED.target_temp_c, + lead_minutes = EXCLUDED.lead_minutes, ics_url = EXCLUDED.ics_url, + updated_at = now()`, + c.VehicleID, c.Enabled, c.TargetTempC, c.LeadMinutes, c.ICSURL, + ) + if err != nil { + return fmt.Errorf("comfort: upsert config: %w", err) + } + return nil +} + +// EnabledConfigs returns every enabled config for the evaluator. +func (s *Store) EnabledConfigs(ctx context.Context) ([]*Config, error) { + rows, err := s.db.Pool.Query(ctx, + `SELECT vehicle_id, enabled, target_temp_c, lead_minutes, ics_url, updated_at + FROM comfort_config WHERE enabled`) + if err != nil { + return nil, fmt.Errorf("comfort: list enabled: %w", err) + } + defer rows.Close() + var out []*Config + for rows.Next() { + c := &Config{} + if err := rows.Scan(&c.VehicleID, &c.Enabled, &c.TargetTempC, &c.LeadMinutes, &c.ICSURL, &c.UpdatedAt); err != nil { + return nil, fmt.Errorf("comfort: scan enabled: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("comfort: list enabled: %w", err) + } + return out, nil +} + +// HasRun reports whether the event UID was already acted on. +func (s *Store) HasRun(ctx context.Context, vehicleID int64, uid string) (bool, error) { + var exists bool + err := s.db.Pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM comfort_runs WHERE vehicle_id = $1 AND event_uid = $2)`, + vehicleID, uid, + ).Scan(&exists) + if err != nil { + return false, fmt.Errorf("comfort: has run: %w", err) + } + return exists, nil +} + +// LogRun records a run. The (vehicle_id, event_uid) unique constraint +// makes double-act a no-op returning ran=false. +func (s *Store) LogRun(ctx context.Context, r *Run) (ran bool, err error) { + err = s.db.Pool.QueryRow(ctx, ` + INSERT INTO comfort_runs (vehicle_id, event_uid, event_title, starts_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (vehicle_id, event_uid) DO NOTHING + RETURNING id, acted_at`, + r.VehicleID, r.EventUID, r.EventTitle, r.StartsAt, + ).Scan(&r.ID, &r.ActedAt) + if err == pgx.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("comfort: log run: %w", err) + } + return true, nil +} + +// ListRuns returns recent runs, newest first. Limit clamped 1..100. +func (s *Store) ListRuns(ctx context.Context, vehicleID int64, limit int) ([]*Run, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + rows, err := s.db.Pool.Query(ctx, ` + SELECT id, vehicle_id, event_uid, event_title, starts_at, acted_at + FROM comfort_runs WHERE vehicle_id = $1 + ORDER BY id DESC LIMIT $2`, vehicleID, limit) + if err != nil { + return nil, fmt.Errorf("comfort: list runs: %w", err) + } + defer rows.Close() + out := []*Run{} + for rows.Next() { + r := &Run{} + if err := rows.Scan(&r.ID, &r.VehicleID, &r.EventUID, &r.EventTitle, &r.StartsAt, &r.ActedAt); err != nil { + return nil, fmt.Errorf("comfort: scan run: %w", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("comfort: list runs: %w", err) + } + return out, nil +} diff --git a/internal/api/fleetops/guardrails.go b/internal/api/fleetops/guardrails.go new file mode 100644 index 0000000000..4eb184e6fd --- /dev/null +++ b/internal/api/fleetops/guardrails.go @@ -0,0 +1,119 @@ +package fleetops + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + models "github.com/ev-dev-labs/teslasync/internal/models/fleetops" +) + +// DriverEvaluation is the GET /fleet-ops/drivers/{id}/evaluate response: +// allow/deny with per-guardrail reasons. +type DriverEvaluation struct { + DriverID int64 `json:"driver_id"` + Allowed bool `json:"allowed"` + Reasons []string `json:"reasons"` + ChargeCap *int16 `json:"charge_cap"` + InCurfew bool `json:"in_curfew"` + Evaluated string `json:"evaluated_at"` +} + +// EvaluateDriverGuardrails is the pure policy check: charge-target cap and +// curfew window (overnight wrap supported). A chargeSOC of 0 skips the cap +// check (the caller isn't proposing a charge target). +func EvaluateDriverGuardrails(d *models.FleetDriver, chargeSOC int, at time.Time) DriverEvaluation { + ev := DriverEvaluation{ + DriverID: d.ID, + Allowed: true, + Reasons: []string{}, + Evaluated: at.UTC().Format(time.RFC3339), + } + if d.Status != "active" { + ev.Allowed = false + ev.Reasons = append(ev.Reasons, "driver is not active") + } + if d.MaxChargeSOC != nil { + ev.ChargeCap = d.MaxChargeSOC + if chargeSOC > 0 && chargeSOC > int(*d.MaxChargeSOC) { + ev.Allowed = false + ev.Reasons = append(ev.Reasons, fmt.Sprintf( + "charge target %d%% exceeds driver cap of %d%%", chargeSOC, *d.MaxChargeSOC)) + } + } + if d.CurfewStart != nil && d.CurfewEnd != nil { + if inCurfew(*d.CurfewStart, *d.CurfewEnd, at) { + ev.Allowed = false + ev.InCurfew = true + ev.Reasons = append(ev.Reasons, fmt.Sprintf( + "inside curfew window %s–%s", *d.CurfewStart, *d.CurfewEnd)) + } + } + if ev.Allowed { + ev.Reasons = append(ev.Reasons, "within driver policy") + } + return ev +} + +// inCurfew reports whether at falls inside [start, end). When end <= start +// the window wraps overnight (e.g. 22:00–06:00). +func inCurfew(start, end string, at time.Time) bool { + var sh, sm, eh, em int + if _, err := fmt.Sscanf(start, "%d:%d", &sh, &sm); err != nil { + return false + } + if _, err := fmt.Sscanf(end, "%d:%d", &eh, &em); err != nil { + return false + } + cur := at.Hour()*60 + at.Minute() + from, to := sh*60+sm, eh*60+em + if to <= from { + return cur >= from || cur < to + } + return cur >= from && cur < to +} + +// EvaluateDriver serves GET /fleet-ops/drivers/{id}/evaluate?charge_soc=&at=. +// at is an optional RFC3339 instant (defaults to now) so fleet managers can +// test a future departure against the curfew. +func (h *Handler) EvaluateDriver(w http.ResponseWriter, r *http.Request) { + ctx, span := startHandlerSpan(r, "drivers.evaluate") + defer span.End() + + id, ok := pathID(w, r) + if !ok { + return + } + q := r.URL.Query() + chargeSOC := 0 + if s := q.Get("charge_soc"); s != "" { + v, err := strconv.Atoi(s) + if err != nil || v < 0 || v > 100 { + httpx.WriteError(w, http.StatusBadRequest, "charge_soc must be 0..100") + return + } + chargeSOC = v + } + at := time.Now().UTC() + if s := q.Get("at"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "at must be RFC3339") + return + } + at = t + } + + d, err := h.service.GetDriver(ctx, id) + if err != nil { + writeHandlerError(ctx, span, w, "drivers.evaluate", err) + return + } + if d == nil { + writeNotFound(w, "driver") + return + } + httpx.WriteJSON(w, http.StatusOK, EvaluateDriverGuardrails(d, chargeSOC, at)) +} diff --git a/internal/api/fleetops/guardrails_test.go b/internal/api/fleetops/guardrails_test.go new file mode 100644 index 0000000000..9bc27e5044 --- /dev/null +++ b/internal/api/fleetops/guardrails_test.go @@ -0,0 +1,126 @@ +package fleetops + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + models "github.com/ev-dev-labs/teslasync/internal/models/fleetops" +) + +func i16(v int16) *int16 { return &v } +func strp(s string) *string { return &s } + +func TestEvaluateDriverGuardrailsAllow(t *testing.T) { + d := &models.FleetDriver{ID: 1, Status: "active", MaxChargeSOC: i16(80)} + at := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC) + ev := EvaluateDriverGuardrails(d, 80, at) + if !ev.Allowed || ev.InCurfew { + t.Fatalf("unexpected evaluation: %+v", ev) + } +} + +func TestEvaluateDriverGuardrailsChargeCap(t *testing.T) { + d := &models.FleetDriver{ID: 1, Status: "active", MaxChargeSOC: i16(80)} + ev := EvaluateDriverGuardrails(d, 95, time.Now().UTC()) + if ev.Allowed { + t.Fatalf("expected deny: %+v", ev) + } + if len(ev.Reasons) != 1 { + t.Fatalf("reasons = %v", ev.Reasons) + } +} + +func TestEvaluateDriverGuardrailsOvernightCurfew(t *testing.T) { + d := &models.FleetDriver{ + ID: 1, Status: "active", + CurfewStart: strp("22:00"), CurfewEnd: strp("06:00"), + } + night := time.Date(2026, 3, 10, 23, 30, 0, 0, time.UTC) + if ev := EvaluateDriverGuardrails(d, 0, night); ev.Allowed || !ev.InCurfew { + t.Fatalf("23:30 must be inside curfew: %+v", ev) + } + early := time.Date(2026, 3, 10, 5, 59, 0, 0, time.UTC) + if ev := EvaluateDriverGuardrails(d, 0, early); ev.Allowed || !ev.InCurfew { + t.Fatalf("05:59 must be inside curfew: %+v", ev) + } + day := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + if ev := EvaluateDriverGuardrails(d, 0, day); !ev.Allowed || ev.InCurfew { + t.Fatalf("noon must be outside curfew: %+v", ev) + } +} + +func TestEvaluateDriverGuardrailsInactive(t *testing.T) { + d := &models.FleetDriver{ID: 1, Status: "inactive"} + if ev := EvaluateDriverGuardrails(d, 0, time.Now().UTC()); ev.Allowed { + t.Fatalf("expected deny: %+v", ev) + } +} + +func TestValidateDriverGuardrails(t *testing.T) { + base := models.FleetDriver{DisplayName: "Teen", ReferenceCode: "T1", Status: "active"} + bad := base + bad.MaxChargeSOC = i16(10) + if err := validateDriver(&bad); err == nil { + t.Fatal("expected error for cap below 20") + } + bad = base + bad.CurfewStart = strp("22:00") + if err := validateDriver(&bad); err == nil { + t.Fatal("expected error for half-set curfew") + } + bad = base + bad.CurfewStart, bad.CurfewEnd = strp("22:00"), strp("25:00") + if err := validateDriver(&bad); err == nil { + t.Fatal("expected error for bad curfew time") + } + ok := base + ok.CurfewStart, ok.CurfewEnd = strp("22:00"), strp("06:00") + ok.MaxChargeSOC = i16(80) + if err := validateDriver(&ok); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +type evaluateServiceFake struct { + fleetOpsService + driver *models.FleetDriver +} + +func (f *evaluateServiceFake) GetDriver(context.Context, int64) (*models.FleetDriver, error) { + return f.driver, nil +} + +func TestEvaluateDriverEndpoint(t *testing.T) { + svc := &evaluateServiceFake{driver: &models.FleetDriver{ + ID: 5, Status: "active", MaxChargeSOC: i16(80), + CurfewStart: strp("22:00"), CurfewEnd: strp("06:00"), + }} + req := httptest.NewRequest(http.MethodGet, + "/fleet-ops/drivers/5/evaluate?charge_soc=90&at=2026-03-10T23:00:00Z", nil) + rec := httptest.NewRecorder() + testRouter(svc).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var ev DriverEvaluation + if err := json.NewDecoder(rec.Body).Decode(&ev); err != nil { + t.Fatal(err) + } + if ev.Allowed || len(ev.Reasons) != 2 { + t.Fatalf("expected cap + curfew deny: %+v", ev) + } +} + +func TestEvaluateDriverEndpointNotFound(t *testing.T) { + svc := &evaluateServiceFake{driver: nil} + req := httptest.NewRequest(http.MethodGet, "/fleet-ops/drivers/9/evaluate", nil) + rec := httptest.NewRecorder() + testRouter(svc).ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} diff --git a/internal/api/fleetops/handler.go b/internal/api/fleetops/handler.go index 5146e731c2..acdfb0f4f2 100644 --- a/internal/api/fleetops/handler.go +++ b/internal/api/fleetops/handler.go @@ -93,6 +93,7 @@ func MountRoutes(r chi.Router, h *Handler) { r.With(writeLimit).Delete(path+"/{id}", remove) } mountCRUD("/drivers", h.ListDrivers, h.CreateDriver, h.GetDriver, h.UpdateDriver, h.DeleteDriver) + r.Get("/drivers/{id}/evaluate", h.EvaluateDriver) mountCRUD("/cost-centers", h.ListCostCenters, h.CreateCostCenter, h.GetCostCenter, h.UpdateCostCenter, h.DeleteCostCenter) mountCRUD("/assignments", h.ListAssignments, h.CreateAssignment, h.GetAssignment, h.UpdateAssignment, h.DeleteAssignment) mountCRUD("/reservations", h.ListReservations, h.CreateReservation, h.GetReservation, h.UpdateReservation, h.DeleteReservation) diff --git a/internal/api/fleetops/service.go b/internal/api/fleetops/service.go index afd9666f8a..3f2bd169b8 100644 --- a/internal/api/fleetops/service.go +++ b/internal/api/fleetops/service.go @@ -80,6 +80,13 @@ func validText(value string, minLen, maxLen int) bool { return n >= minLen && n <= maxLen } +// validClock reports whether s is a 24h HH:MM wall-clock time. +func validClock(s string) bool { + var h, m int + n, err := fmt.Sscanf(s, "%d:%d", &h, &m) + return err == nil && n == 2 && h >= 0 && h <= 23 && m >= 0 && m <= 59 && len(s) == 5 +} + func normalizeOptional(value *string) *string { if value == nil { return nil @@ -129,6 +136,17 @@ func validateDriver(item *models.FleetDriver) error { if item.Status != "active" && item.Status != "inactive" { return validation("status must be active or inactive") } + if item.MaxChargeSOC != nil && (*item.MaxChargeSOC < 20 || *item.MaxChargeSOC > 100) { + return validation("max_charge_soc must be between 20 and 100") + } + if (item.CurfewStart == nil) != (item.CurfewEnd == nil) { + return validation("curfew_start and curfew_end must be set together") + } + for _, c := range []*string{item.CurfewStart, item.CurfewEnd} { + if c != nil && !validClock(*c) { + return validation("curfew times must be HH:MM (24h)") + } + } return nil } diff --git a/internal/api/fsd/counter_advance.go b/internal/api/fsd/counter_advance.go index 1945eb4d08..e7a28caa44 100644 --- a/internal/api/fsd/counter_advance.go +++ b/internal/api/fsd/counter_advance.go @@ -11,6 +11,13 @@ import ( // (include_fields zero, unit mix, trip-meter restore), not distance driven. const maxAttributableSpeedMps = 120.0 +// teslaFSDWireQuantumM is Tesla's minimum_delta for +// SelfDrivingMilesSinceReset (1 international mile). Fleet Telemetry will +// not emit a smaller FSD tick. MilesSinceReset include_fields samples that +// counter every 10s, so a real 1-mile engagement appears as a 1609 m jump +// against a 10-second prior snapshot — ~161 m/s, which is not vehicle speed. +const teslaFSDWireQuantumM = 1609.344 + // minAdvanceInterval floors the speed check so a 0.01 mile tick on a // sub-second change-feed row remains attributable. const minAdvanceInterval = time.Second @@ -97,5 +104,10 @@ func plausibleCounterAdvance(delta float64, dt time.Duration) bool { if dt < minAdvanceInterval { dt = minAdvanceInterval } - return delta <= maxAttributableSpeedMps*dt.Seconds() + // Allow one FSD wire quantum on top of physically possible travel. + // Without this, every 1-mile SelfDrivingMilesSinceReset tick on the + // 10s include_fields cadence is discarded and drives collapse to a + // leftover fraction of a mile (the Aug 31 → Sep 7 regression). + maxDelta := maxAttributableSpeedMps*dt.Seconds() + teslaFSDWireQuantumM + return delta <= maxDelta } diff --git a/internal/api/fsd/counter_advance_test.go b/internal/api/fsd/counter_advance_test.go index 63239508d2..adc387f8ce 100644 --- a/internal/api/fsd/counter_advance_test.go +++ b/internal/api/fsd/counter_advance_test.go @@ -35,6 +35,16 @@ func TestPlausibleCounterAdvance(t *testing.T) { if !plausibleCounterAdvance(16, time.Millisecond) { t.Fatal("a sub-second 0.01 mile tick must still pass the floor") } + mile := teslaFSDWireQuantumM + if !plausibleCounterAdvance(mile, 10*time.Second) { + t.Fatal("Tesla 1-mile FSD tick on 10s include_fields must be attributable") + } + if !plausibleCounterAdvance(mile, time.Second) { + t.Fatal("1-mile quantum against the 1s floor must still pass") + } + if plausibleCounterAdvance(4_913*mile, 10*time.Second) { + t.Fatal("thousands of miles on include_fields cadence must still be rejected") + } } func TestStepTripMeterSpuriousZero(t *testing.T) { diff --git a/internal/api/fsd/drive_aggregate_test.go b/internal/api/fsd/drive_aggregate_test.go index 86f801b226..386aaeb1ee 100644 --- a/internal/api/fsd/drive_aggregate_test.go +++ b/internal/api/fsd/drive_aggregate_test.go @@ -141,6 +141,65 @@ func TestBuildDriveAnalytics_DriveDetailLookaroundIncludesSparseBookend(t *testi } } +func TestBuildDriveAnalytics_OneMileTicksOnIncludeFieldsCadence(t *testing.T) { + // MilesSinceReset include_fields re-emits SelfDrivingMilesSinceReset every + // 10s. Tesla still only *changes* that counter in 1-mile steps, so a real + // FSD commute looks like 1609 m jumps on a 10s snapshot — previously + // discarded as 161 m/s. + start := at(t, "2026-09-11T17:50:00Z") + end := at(t, "2026-09-11T18:30:00Z") + driveStart := at(t, "2026-09-11T17:59:00Z") + driveEndAt := at(t, "2026-09-11T18:25:00Z") + distance := 14.2 * teslaFSDWireQuantumM + const ticks = 12 + samples := make([]Sample, 0, 2*(26*6+4)) + fsdValue := 10_000.0 + drivingValue := 50_000.0 + tick := 0 + for ts := driveStart.Add(-10 * time.Second); !ts.After(driveEndAt); ts = ts.Add(10 * time.Second) { + if !ts.Before(driveStart) && ts.Before(driveEndAt) { + drivingValue += teslaFSDWireQuantumM / 36 + elapsed := ts.Sub(driveStart) + if elapsed >= 50*time.Second && elapsed%(50*time.Second) == 0 && tick < ticks { + fsdValue += teslaFSDWireQuantumM + tick++ + } + } + samples = append( + samples, + trustedSample(SignalFSDDistance, ts, fsdValue), + trustedSample(SignalDrivingDistance, ts, drivingValue), + ) + } + if tick != ticks { + t.Fatalf("emitted %d FSD ticks, want %d", tick, ticks) + } + + current := responseForRange(7, start, end, samples) + previous := responseForRange(7, start.Add(-end.Sub(start)), start, samples) + analytics := BuildDriveAnalytics(current, previous, AnalyticsInput{ + CounterSamples: samples, + Drives: []DriveRecord{{ + ID: 365, + StartedAt: driveStart, + EndedAt: &driveEndAt, + DistanceM: &distance, + }}, + }, time.UTC, true) + + if len(analytics.ContributingDrives) != 1 { + t.Fatalf("drives = %d, want 1", len(analytics.ContributingDrives)) + } + drive := analytics.ContributingDrives[0] + if drive.FSDDistanceM == nil { + t.Fatal("FSD distance unmeasured; 1-mile include_fields ticks were dropped") + } + wantMeasured(t, drive.FSDDistanceM, float64(ticks)*teslaFSDWireQuantumM, "quantized FSD distance") + if drive.FSDSharePct == nil || *drive.FSDSharePct < 80 { + t.Fatalf("share = %v, want >= 80 (was collapsing to ~7%%)", drive.FSDSharePct) + } +} + func TestBuildDriveAnalytics_FidgetDriveDoesNotStealCommuteDelta(t *testing.T) { start := at(t, "2026-09-07T00:00:00Z") end := at(t, "2026-09-08T00:00:00Z") diff --git a/internal/api/journey/handler.go b/internal/api/journey/handler.go new file mode 100644 index 0000000000..c3b781eb78 --- /dev/null +++ b/internal/api/journey/handler.go @@ -0,0 +1,304 @@ +package journey + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// SessionStore is the session/plan port. *Store satisfies it. +type SessionStore interface { + Create(ctx context.Context, in NewSession) (*Session, error) + Get(ctx context.Context, id int64) (*Session, error) + List(ctx context.Context, vehicleID int64, status string, limit int) ([]*Session, error) + ActiveForVehicle(ctx context.Context, vehicleID int64) (*Session, error) + SetStatus(ctx context.Context, id int64, from, to string) (*Session, error) + SavePlan(ctx context.Context, sessionID int64, plan json.RawMessage, note string) (*PlanVersion, error) + ListPlans(ctx context.Context, sessionID int64) ([]*PlanVersion, error) +} + +// Handler serves journey sessions + plan versions. Stateless beyond +// constructor inputs; safe for concurrent use. +type Handler struct { + store SessionStore +} + +// NewHandler wires the handler. Panics on nil input (fail-fast wiring +// contract, matching sibling handlers). +func NewHandler(store SessionStore) *Handler { + if store == nil { + panic("journey: nil dependency") + } + return &Handler{store: store} +} + +type createRequest struct { + VehicleID int64 `json:"vehicle_id"` + Name string `json:"name"` + OriginName string `json:"origin_name"` + OriginLat *float64 `json:"origin_lat"` + OriginLng *float64 `json:"origin_lng"` + DestName string `json:"dest_name"` + DestLat *float64 `json:"dest_lat"` + DestLng *float64 `json:"dest_lng"` +} + +// Create serves POST /journey/sessions: plan a new trip. +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + var req createRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.VehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + if len(req.Name) == 0 || len(req.Name) > 200 { + httpx.WriteError(w, http.StatusBadRequest, "name must be 1..200 characters") + return + } + if len(req.OriginName) > 300 || len(req.DestName) > 300 { + httpx.WriteError(w, http.StatusBadRequest, "origin/dest names must be at most 300 characters") + return + } + if !validCoord(req.OriginLat, req.OriginLng) || !validCoord(req.DestLat, req.DestLng) { + httpx.WriteError(w, http.StatusBadRequest, "lat must be -90..90 and lng -180..180") + return + } + session, err := h.store.Create(r.Context(), NewSession{ + VehicleID: req.VehicleID, Name: req.Name, + OriginName: req.OriginName, OriginLat: req.OriginLat, OriginLng: req.OriginLng, + DestName: req.DestName, DestLat: req.DestLat, DestLng: req.DestLng, + }) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", req.VehicleID).Msg("journey: create failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to create journey") + return + } + httpx.WriteJSON(w, http.StatusCreated, session) +} + +func validCoord(lat, lng *float64) bool { + if lat != nil && (*lat < -90 || *lat > 90) { + return false + } + if lng != nil && (*lng < -180 || *lng > 180) { + return false + } + return true +} + +// List serves GET /journey/sessions?vehicle_id=&status=&limit=. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + vehicleID, err := vehicleIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + status := r.URL.Query().Get("status") + if status != "" && !ValidStatus(status) { + httpx.WriteError(w, http.StatusBadRequest, "unknown status filter") + return + } + limit := 20 + if s := r.URL.Query().Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + limit = clampListLimit(n) + } + } + sessions, err := h.store.List(r.Context(), vehicleID, status, limit) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("journey: list failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to list journeys") + return + } + httpx.WriteJSON(w, http.StatusOK, sessions) +} + +type getResponse struct { + Session *Session `json:"session"` + Plans []*PlanVersion `json:"plans"` + Next []string `json:"next_statuses"` +} + +// Get serves GET /journey/sessions/{id}: the session, its plan history, +// and the currently reachable statuses (drives the UI action set). +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + session, err := h.store.Get(r.Context(), id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + plans, err := h.store.ListPlans(r.Context(), id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: plans read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey plans") + return + } + httpx.WriteJSON(w, http.StatusOK, getResponse{Session: session, Plans: plans, Next: NextStatuses(session.Status)}) +} + +// transition serves POST /journey/sessions/{id}/start|pause|resume| +// complete|abort. Starting is rejected with 409 while another session +// for the vehicle is active. +func (h *Handler) transition(to string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + session, err := h.store.Get(ctx, id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: get failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read journey") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + if err := Transition(session.Status, to); err != nil { + httpx.WriteError(w, http.StatusConflict, err.Error()) + return + } + if to == StatusActive { + if active, err := h.store.ActiveForVehicle(ctx, session.VehicleID); err != nil { + log.Error().Err(err).Int64("id", id).Msg("journey: active lookup failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to start journey") + return + } else if active != nil && active.ID != session.ID { + httpx.WriteError(w, http.StatusConflict, "another journey is already active for this vehicle") + return + } + } + updated, err := h.store.SetStatus(ctx, id, session.Status, to) + if err != nil { + if errors.Is(err, ErrConflict) { + httpx.WriteError(w, http.StatusConflict, "journey moved concurrently; refresh and retry") + return + } + var terr *TransitionError + if errors.As(err, &terr) { + httpx.WriteError(w, http.StatusConflict, terr.Error()) + return + } + log.Error().Err(err).Int64("id", id).Msg("journey: transition failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to update journey") + return + } + httpx.WriteJSON(w, http.StatusOK, updated) + } +} + +// Start serves POST /journey/sessions/{id}/start. +func (h *Handler) Start(w http.ResponseWriter, r *http.Request) { h.transition(StatusActive)(w, r) } + +// Pause serves POST /journey/sessions/{id}/pause. +func (h *Handler) Pause(w http.ResponseWriter, r *http.Request) { h.transition(StatusPaused)(w, r) } + +// Resume serves POST /journey/sessions/{id}/resume. +func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) { h.transition(StatusActive)(w, r) } + +// Complete serves POST /journey/sessions/{id}/complete. +func (h *Handler) Complete(w http.ResponseWriter, r *http.Request) { + h.transition(StatusCompleted)(w, r) +} + +// Abort serves POST /journey/sessions/{id}/abort. +func (h *Handler) Abort(w http.ResponseWriter, r *http.Request) { h.transition(StatusAborted)(w, r) } + +type savePlanRequest struct { + Plan json.RawMessage `json:"plan"` + Note string `json:"note"` +} + +// SavePlan serves POST /journey/sessions/{id}/plans: append a version. +func (h *Handler) SavePlan(w http.ResponseWriter, r *http.Request) { + id, err := sessionIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + var req savePlanRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if len(req.Note) > 500 { + httpx.WriteError(w, http.StatusBadRequest, "note must be at most 500 characters") + return + } + if len(req.Plan) > 0 && !json.Valid(req.Plan) { + httpx.WriteError(w, http.StatusBadRequest, "plan must be valid JSON") + return + } + pv, err := h.store.SavePlan(r.Context(), id, req.Plan, req.Note) + if err != nil { + if errors.Is(err, ErrNoSession) { + httpx.WriteError(w, http.StatusNotFound, "journey not found") + return + } + log.Error().Err(err).Int64("id", id).Msg("journey: save plan failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save plan") + return + } + httpx.WriteJSON(w, http.StatusCreated, pv) +} + +func clampListLimit(n int) int { + if n <= 0 { + return 20 + } + if n > 100 { + return 100 + } + return n +} + +func sessionIDParam(r *http.Request) (int64, error) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil || id <= 0 { + return 0, errBadSessionID + } + return id, nil +} + +func vehicleIDParam(r *http.Request) (int64, error) { + id, err := strconv.ParseInt(r.URL.Query().Get("vehicle_id"), 10, 64) + if err != nil || id <= 0 { + return 0, errBadVehicleID + } + return id, nil +} + +type paramError string + +func (e paramError) Error() string { return string(e) } + +const ( + errBadSessionID = paramError("session id must be a positive integer") + errBadVehicleID = paramError("vehicle_id must be a positive integer") +) + +// Compile-time port assertion. +var _ SessionStore = (*Store)(nil) diff --git a/internal/api/journey/handler_test.go b/internal/api/journey/handler_test.go new file mode 100644 index 0000000000..49e22b97bc --- /dev/null +++ b/internal/api/journey/handler_test.go @@ -0,0 +1,396 @@ +package journey + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" +) + +type fakeStore struct { + sessions map[int64]*Session + plans map[int64][]*PlanVersion + nextID int64 + err error +} + +func newFakeStore() *fakeStore { + return &fakeStore{sessions: map[int64]*Session{}, plans: map[int64][]*PlanVersion{}, nextID: 1} +} + +func (f *fakeStore) Create(_ context.Context, in NewSession) (*Session, error) { + if f.err != nil { + return nil, f.err + } + s := &Session{ + ID: f.nextID, VehicleID: in.VehicleID, Name: in.Name, + OriginName: in.OriginName, OriginLat: in.OriginLat, OriginLng: in.OriginLng, + DestName: in.DestName, DestLat: in.DestLat, DestLng: in.DestLng, + Status: StatusPlanned, CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + f.sessions[s.ID] = s + f.nextID++ + return s, nil +} + +func (f *fakeStore) Get(_ context.Context, id int64) (*Session, error) { + return f.sessions[id], f.err +} + +func (f *fakeStore) List(_ context.Context, vehicleID int64, status string, _ int) ([]*Session, error) { + if f.err != nil { + return nil, f.err + } + out := []*Session{} + for _, s := range f.sessions { + if s.VehicleID != vehicleID { + continue + } + if status != "" && s.Status != status { + continue + } + out = append(out, s) + } + return out, nil +} + +func (f *fakeStore) ActiveForVehicle(_ context.Context, vehicleID int64) (*Session, error) { + if f.err != nil { + return nil, f.err + } + for _, s := range f.sessions { + if s.VehicleID == vehicleID && s.Status == StatusActive { + return s, nil + } + } + return nil, nil +} + +func (f *fakeStore) SetStatus(_ context.Context, id int64, from, to string) (*Session, error) { + if f.err != nil { + return nil, f.err + } + s, ok := f.sessions[id] + if !ok || s.Status != from { + return nil, ErrConflict + } + if err := Transition(from, to); err != nil { + return nil, err + } + s.Status = to + s.UpdatedAt = time.Now() + return s, nil +} + +func (f *fakeStore) SavePlan(_ context.Context, sessionID int64, plan json.RawMessage, note string) (*PlanVersion, error) { + if f.err != nil { + return nil, f.err + } + s, ok := f.sessions[sessionID] + if !ok { + return nil, ErrNoSession + } + s.PlanVersion++ + pv := &PlanVersion{ + ID: int64(len(f.plans[sessionID]) + 1), SessionID: sessionID, + Version: s.PlanVersion, Plan: plan, Note: note, CreatedAt: time.Now(), + } + f.plans[sessionID] = append(f.plans[sessionID], pv) + return pv, nil +} + +func (f *fakeStore) ListPlans(_ context.Context, sessionID int64) ([]*PlanVersion, error) { + return f.plans[sessionID], f.err +} + +var _ SessionStore = (*fakeStore)(nil) + +func withID(t *testing.T, method, target string, id string) *http.Request { + t.Helper() + req := httptest.NewRequest(method, target, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", id) + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) +} + +func TestNewHandlerPanicsOnNil(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + NewHandler(nil) +} + +func TestCreate(t *testing.T) { + h := NewHandler(newFakeStore()) + body := `{"vehicle_id":7,"name":"Tahoe ski trip","origin_name":"Home","dest_name":"Tahoe","dest_lat":39.1,"dest_lng":-120.0}` + req := httptest.NewRequest(http.MethodPost, "/journey/sessions", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.Create(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Session + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Status != StatusPlanned || got.Name != "Tahoe ski trip" || got.DestLat == nil { + t.Fatalf("session = %+v", got) + } +} + +func TestCreateValidation(t *testing.T) { + h := NewHandler(newFakeStore()) + cases := map[string]string{ + "bad json": `{oops`, + "missing vehicle": `{"vehicle_id":0,"name":"x"}`, + "empty name": `{"vehicle_id":1,"name":""}`, + "long name": `{"vehicle_id":1,"name":"` + strings.Repeat("n", 201) + `"}`, + "bad lat": `{"vehicle_id":1,"name":"x","dest_lat":99}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/journey/sessions", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.Create(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } + }) + } +} + +func TestListFiltersByVehicleAndStatus(t *testing.T) { + f := newFakeStore() + h := NewHandler(f) + ctx := context.Background() + if _, err := f.Create(ctx, NewSession{VehicleID: 7, Name: "a"}); err != nil { + t.Fatal(err) + } + b, err := f.Create(ctx, NewSession{VehicleID: 7, Name: "b"}) + if err != nil { + t.Fatal(err) + } + b.Status = StatusActive + if _, err := f.Create(ctx, NewSession{VehicleID: 9, Name: "other"}); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/journey/sessions?vehicle_id=7&status=active", nil) + rec := httptest.NewRecorder() + h.List(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got []*Session + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Name != "b" { + t.Fatalf("list = %+v", got) + } +} + +func TestClampListLimit(t *testing.T) { + t.Parallel() + cases := []struct { + in, want int + }{ + {0, 20}, + {-5, 20}, + {1, 1}, + {20, 20}, + {100, 100}, + {101, 100}, + {10_000, 100}, + } + for _, tc := range cases { + if got := clampListLimit(tc.in); got != tc.want { + t.Fatalf("clampListLimit(%d) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestListValidation(t *testing.T) { + h := NewHandler(newFakeStore()) + for _, url := range []string{ + "/journey/sessions", + "/journey/sessions?vehicle_id=0", + "/journey/sessions?vehicle_id=7&status=bogus", + } { + req := httptest.NewRequest(http.MethodGet, url, nil) + rec := httptest.NewRecorder() + h.List(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s: code = %d, want 400", url, rec.Code) + } + } +} + +func TestGetIncludesPlansAndNext(t *testing.T) { + f := newFakeStore() + h := NewHandler(f) + ctx := context.Background() + s, err := f.Create(ctx, NewSession{VehicleID: 7, Name: "a"}) + if err != nil { + t.Fatal(err) + } + if _, err := f.SavePlan(ctx, s.ID, json.RawMessage(`{"stops":[]}`), "v1"); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.Get(rec, withID(t, http.MethodGet, "/journey/sessions/1", "1")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d", rec.Code) + } + var got getResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Plans) != 1 || got.Plans[0].Version != 1 { + t.Fatalf("plans = %+v", got.Plans) + } + if len(got.Next) != 2 { // active, aborted + t.Fatalf("next = %v", got.Next) + } +} + +func TestGetNotFound(t *testing.T) { + h := NewHandler(newFakeStore()) + rec := httptest.NewRecorder() + h.Get(rec, withID(t, http.MethodGet, "/journey/sessions/9", "9")) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } + rec = httptest.NewRecorder() + h.Get(rec, withID(t, http.MethodGet, "/journey/sessions/x", "x")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec.Code) + } +} + +func TestStartRejectsSecondActive(t *testing.T) { + f := newFakeStore() + h := NewHandler(f) + ctx := context.Background() + a, err := f.Create(ctx, NewSession{VehicleID: 7, Name: "a"}) + if err != nil { + t.Fatal(err) + } + a.Status = StatusActive + b, err := f.Create(ctx, NewSession{VehicleID: 7, Name: "b"}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.Start(rec, withID(t, http.MethodPost, "/journey/sessions/2/start", "2")) + if rec.Code != http.StatusConflict { + t.Fatalf("code = %d, want 409", rec.Code) + } + _ = b +} + +func TestLifecycleTransitions(t *testing.T) { + f := newFakeStore() + h := NewHandler(f) + s, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "a"}) + if err != nil { + t.Fatal(err) + } + steps := []struct { + name string + fn func(http.ResponseWriter, *http.Request) + want string + }{ + {"start", h.Start, StatusActive}, + {"pause", h.Pause, StatusPaused}, + {"resume", h.Resume, StatusActive}, + {"complete", h.Complete, StatusCompleted}, + } + for _, step := range steps { + t.Run(step.name, func(t *testing.T) { + rec := httptest.NewRecorder() + step.fn(rec, withID(t, http.MethodPost, "/journey/sessions/1/x", "1")) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + if s.Status != step.want { + t.Fatalf("status = %q, want %q", s.Status, step.want) + } + }) + } + // Terminal: abort after complete must conflict. + rec := httptest.NewRecorder() + h.Abort(rec, withID(t, http.MethodPost, "/journey/sessions/1/abort", "1")) + if rec.Code != http.StatusConflict { + t.Fatalf("code = %d, want 409", rec.Code) + } +} + +func TestSavePlan(t *testing.T) { + f := newFakeStore() + h := NewHandler(f) + s, err := f.Create(context.Background(), NewSession{VehicleID: 7, Name: "a"}) + if err != nil { + t.Fatal(err) + } + body := `{"plan":{"stops":[{"site":"Kettleman"}]},"note":"initial"}` + req := httptest.NewRequest(http.MethodPost, "/journey/sessions/1/plans", strings.NewReader(body)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "1") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rec := httptest.NewRecorder() + h.SavePlan(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got PlanVersion + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Version != 1 || s.PlanVersion != 1 { + t.Fatalf("version = %d, session pointer = %d", got.Version, s.PlanVersion) + } +} + +func TestSavePlanErrors(t *testing.T) { + h := NewHandler(newFakeStore()) + // Missing session. + body := `{"plan":{},"note":"x"}` + req := httptest.NewRequest(http.MethodPost, "/journey/sessions/9/plans", strings.NewReader(body)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "9") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rec := httptest.NewRecorder() + h.SavePlan(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } + // Invalid JSON plan. + bad := `{"plan":{oops},"note":"x"}` + req2 := httptest.NewRequest(http.MethodPost, "/journey/sessions/1/plans", strings.NewReader(bad)) + rctx2 := chi.NewRouteContext() + rctx2.URLParams.Add("id", "1") + req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rctx2)) + rec2 := httptest.NewRecorder() + h.SavePlan(rec2, req2) + if rec2.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400", rec2.Code) + } +} + +func TestStoreErrorSurfaces500(t *testing.T) { + h := NewHandler(&fakeStore{err: errors.New("db down"), sessions: map[int64]*Session{}, plans: map[int64][]*PlanVersion{}}) + req := httptest.NewRequest(http.MethodGet, "/journey/sessions?vehicle_id=7", nil) + rec := httptest.NewRecorder() + h.List(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("code = %d, want 500", rec.Code) + } +} diff --git a/internal/api/journey/session.go b/internal/api/journey/session.go new file mode 100644 index 0000000000..a42bd38136 --- /dev/null +++ b/internal/api/journey/session.go @@ -0,0 +1,68 @@ +// Package journey owns Journey Autopilot trip sessions: one persistent +// record per planned-or-live trip, a strict status machine, and +// versioned plans so every replan keeps its predecessor for diffing. +package journey + +import "fmt" + +// Statuses of a journey session. +const ( + StatusPlanned = "planned" + StatusActive = "active" + StatusPaused = "paused" + StatusCompleted = "completed" + StatusAborted = "aborted" +) + +// allowedTransitions is the status machine: keys are current statuses, +// values the statuses they may move to. Terminal states have no exits. +var allowedTransitions = map[string][]string{ + StatusPlanned: {StatusActive, StatusAborted}, + StatusActive: {StatusPaused, StatusCompleted, StatusAborted}, + StatusPaused: {StatusActive, StatusCompleted, StatusAborted}, +} + +// TransitionError describes a rejected status move. +type TransitionError struct { + From string + To string +} + +func (e *TransitionError) Error() string { + return fmt.Sprintf("journey: cannot move session from %q to %q", e.From, e.To) +} + +// ValidStatus reports whether s is a known session status. +func ValidStatus(s string) bool { + switch s { + case StatusPlanned, StatusActive, StatusPaused, StatusCompleted, StatusAborted: + return true + default: + return false + } +} + +// Terminal reports whether s ends the session lifecycle. +func Terminal(s string) bool { return s == StatusCompleted || s == StatusAborted } + +// Transition validates a status move. Pure: no I/O, deterministic. +func Transition(from, to string) error { + if from == to { + return nil + } + for _, next := range allowedTransitions[from] { + if next == to { + return nil + } + } + return &TransitionError{From: from, To: to} +} + +// NextStatuses returns the statuses reachable from s (excluding s). +func NextStatuses(s string) []string { + out := append([]string{}, allowedTransitions[s]...) + if out == nil { + return []string{} + } + return out +} diff --git a/internal/api/journey/session_test.go b/internal/api/journey/session_test.go new file mode 100644 index 0000000000..b31a1a0306 --- /dev/null +++ b/internal/api/journey/session_test.go @@ -0,0 +1,74 @@ +package journey + +import ( + "errors" + "testing" +) + +func TestValidStatus(t *testing.T) { + for _, s := range []string{StatusPlanned, StatusActive, StatusPaused, StatusCompleted, StatusAborted} { + if !ValidStatus(s) { + t.Fatalf("ValidStatus(%q) = false", s) + } + } + if ValidStatus("flying") { + t.Fatal("ValidStatus(flying) = true") + } +} + +func TestTerminal(t *testing.T) { + if !Terminal(StatusCompleted) || !Terminal(StatusAborted) { + t.Fatal("completed/aborted must be terminal") + } + for _, s := range []string{StatusPlanned, StatusActive, StatusPaused} { + if Terminal(s) { + t.Fatalf("%q must not be terminal", s) + } + } +} + +func TestTransitionMatrix(t *testing.T) { + all := []string{StatusPlanned, StatusActive, StatusPaused, StatusCompleted, StatusAborted} + allowed := map[string]map[string]bool{ + StatusPlanned: {StatusPlanned: true, StatusActive: true, StatusAborted: true}, + StatusActive: {StatusActive: true, StatusPaused: true, StatusCompleted: true, StatusAborted: true}, + StatusPaused: {StatusPaused: true, StatusActive: true, StatusCompleted: true, StatusAborted: true}, + StatusCompleted: {StatusCompleted: true}, + StatusAborted: {StatusAborted: true}, + } + for _, from := range all { + for _, to := range all { + err := Transition(from, to) + if allowed[from][to] && err != nil { + t.Fatalf("Transition(%q, %q) = %v, want nil", from, to, err) + } + if !allowed[from][to] { + var terr *TransitionError + if !errors.As(err, &terr) { + t.Fatalf("Transition(%q, %q) = %v, want *TransitionError", from, to, err) + } + } + } + } +} + +func TestTransitionUnknown(t *testing.T) { + if err := Transition("bogus", StatusActive); err == nil { + t.Fatal("unknown from-status must fail") + } + if err := Transition(StatusPlanned, "bogus"); err == nil { + t.Fatal("unknown to-status must fail") + } +} + +func TestNextStatuses(t *testing.T) { + if got := NextStatuses(StatusActive); len(got) != 3 { + t.Fatalf("active next = %v, want 3", got) + } + if got := NextStatuses(StatusCompleted); len(got) != 0 { + t.Fatalf("completed next = %v, want empty", got) + } + if got := NextStatuses("bogus"); got == nil || len(got) != 0 { + t.Fatalf("bogus next = %v, want empty non-nil", got) + } +} diff --git a/internal/api/journey/store.go b/internal/api/journey/store.go new file mode 100644 index 0000000000..666e6275af --- /dev/null +++ b/internal/api/journey/store.go @@ -0,0 +1,249 @@ +package journey + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// ErrConflict signals a lost status-transition race: the session moved +// since it was read. +var ErrConflict = errors.New("journey: session moved concurrently") + +// ErrNoSession signals a plan save against a missing session. +var ErrNoSession = errors.New("journey: session not found") + +// Session is one planned-or-live trip. +type Session struct { + ID int64 `json:"id"` + VehicleID int64 `json:"vehicle_id"` + Name string `json:"name"` + OriginName string `json:"origin_name"` + OriginLat *float64 `json:"origin_lat"` + OriginLng *float64 `json:"origin_lng"` + DestName string `json:"dest_name"` + DestLat *float64 `json:"dest_lat"` + DestLng *float64 `json:"dest_lng"` + Status string `json:"status"` + PlanVersion int `json:"plan_version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at"` +} + +// PlanVersion is one versioned plan snapshot for a session. +type PlanVersion struct { + ID int64 `json:"id"` + SessionID int64 `json:"session_id"` + Version int `json:"version"` + Plan json.RawMessage `json:"plan"` + Note string `json:"note"` + CreatedAt time.Time `json:"created_at"` +} + +// NewSession carries the create-session fields. +type NewSession struct { + VehicleID int64 + Name string + OriginName string + OriginLat *float64 + OriginLng *float64 + DestName string + DestLat *float64 + DestLng *float64 +} + +// Store persists journey sessions + plan versions. Panics on nil db +// (fail-fast wiring). Safe for concurrent use (pgx pool). +type Store struct { + db *database.DB +} + +// NewStore wires the store. +func NewStore(db *database.DB) *Store { + if db == nil { + panic("journey: nil db") + } + return &Store{db: db} +} + +const sessionColumns = `id, vehicle_id, name, origin_name, origin_lat, origin_lng, + dest_name, dest_lat, dest_lng, status, plan_version, + created_at, updated_at, started_at, ended_at` + +func scanSession(row pgx.Row) (*Session, error) { + s := &Session{} + if err := row.Scan( + &s.ID, &s.VehicleID, &s.Name, &s.OriginName, &s.OriginLat, &s.OriginLng, + &s.DestName, &s.DestLat, &s.DestLng, &s.Status, &s.PlanVersion, + &s.CreatedAt, &s.UpdatedAt, &s.StartedAt, &s.EndedAt, + ); err != nil { + return nil, err + } + return s, nil +} + +// Create inserts a planned session. +func (s *Store) Create(ctx context.Context, in NewSession) (*Session, error) { + session, err := scanSession(s.db.Pool.QueryRow(ctx, ` + INSERT INTO journey_sessions + (vehicle_id, name, origin_name, origin_lat, origin_lng, dest_name, dest_lat, dest_lng) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING `+sessionColumns, + in.VehicleID, in.Name, in.OriginName, in.OriginLat, in.OriginLng, + in.DestName, in.DestLat, in.DestLng, + )) + if err != nil { + return nil, fmt.Errorf("journey: create session: %w", err) + } + return session, nil +} + +// Get returns one session by id, or nil when missing. +func (s *Store) Get(ctx context.Context, id int64) (*Session, error) { + session, err := scanSession(s.db.Pool.QueryRow(ctx, + `SELECT `+sessionColumns+` FROM journey_sessions WHERE id = $1`, id)) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("journey: get session: %w", err) + } + return session, nil +} + +// List returns sessions for a vehicle, newest first. Empty status lists +// all. Limit clamped 1..100. +func (s *Store) List(ctx context.Context, vehicleID int64, status string, limit int) ([]*Session, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + rows, err := s.db.Pool.Query(ctx, ` + SELECT `+sessionColumns+` FROM journey_sessions + WHERE vehicle_id = $1 AND ($2 = '' OR status = $2) + ORDER BY updated_at DESC LIMIT $3`, vehicleID, status, limit) + if err != nil { + return nil, fmt.Errorf("journey: list sessions: %w", err) + } + defer rows.Close() + out := []*Session{} + for rows.Next() { + session, err := scanSession(rows) + if err != nil { + return nil, fmt.Errorf("journey: scan session: %w", err) + } + out = append(out, session) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("journey: list sessions: %w", err) + } + return out, nil +} + +// ActiveForVehicle returns the vehicle's active session, if any. At most +// one session per vehicle may be active; starting a second is rejected. +func (s *Store) ActiveForVehicle(ctx context.Context, vehicleID int64) (*Session, error) { + session, err := scanSession(s.db.Pool.QueryRow(ctx, ` + SELECT `+sessionColumns+` FROM journey_sessions + WHERE vehicle_id = $1 AND status = 'active' LIMIT 1`, vehicleID)) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("journey: active session: %w", err) + } + return session, nil +} + +// SetStatus moves a session to to, stamping started/ended times. The +// conditional update makes concurrent transitions safe: a lost race +// reports ErrConflict instead of silently overwriting. +func (s *Store) SetStatus(ctx context.Context, id int64, from, to string) (*Session, error) { + if err := Transition(from, to); err != nil { + return nil, err + } + session, err := scanSession(s.db.Pool.QueryRow(ctx, ` + UPDATE journey_sessions SET + status = $2, + updated_at = now(), + started_at = CASE WHEN $2 = 'active' AND started_at IS NULL THEN now() ELSE started_at END, + ended_at = CASE WHEN $2 IN ('completed', 'aborted') THEN now() ELSE NULL END + WHERE id = $1 AND status = $3 + RETURNING `+sessionColumns, id, to, from)) + if err == pgx.ErrNoRows { + return nil, ErrConflict + } + if err != nil { + return nil, fmt.Errorf("journey: set status: %w", err) + } + return session, nil +} + +// SavePlan appends the next plan version and advances the session's +// plan_version pointer atomically. +func (s *Store) SavePlan(ctx context.Context, sessionID int64, plan json.RawMessage, note string) (*PlanVersion, error) { + if len(plan) == 0 { + plan = json.RawMessage(`{}`) + } + tx, err := s.db.Pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("journey: save plan: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck // rollback on success is a no-op + var version int + if err := tx.QueryRow(ctx, ` + UPDATE journey_sessions SET plan_version = plan_version + 1, updated_at = now() + WHERE id = $1 RETURNING plan_version`, sessionID).Scan(&version); err != nil { + if err == pgx.ErrNoRows { + return nil, ErrNoSession + } + return nil, fmt.Errorf("journey: save plan: %w", err) + } + pv := &PlanVersion{} + if err := tx.QueryRow(ctx, ` + INSERT INTO journey_plan_versions (session_id, version, plan, note) + VALUES ($1, $2, $3, $4) + RETURNING id, session_id, version, plan, note, created_at`, + sessionID, version, string(plan), note, + ).Scan(&pv.ID, &pv.SessionID, &pv.Version, &pv.Plan, &pv.Note, &pv.CreatedAt); err != nil { + return nil, fmt.Errorf("journey: save plan: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("journey: save plan: %w", err) + } + return pv, nil +} + +// ListPlans returns a session's plan versions, newest first. +func (s *Store) ListPlans(ctx context.Context, sessionID int64) ([]*PlanVersion, error) { + rows, err := s.db.Pool.Query(ctx, ` + SELECT id, session_id, version, plan, note, created_at + FROM journey_plan_versions WHERE session_id = $1 + ORDER BY version DESC`, sessionID) + if err != nil { + return nil, fmt.Errorf("journey: list plans: %w", err) + } + defer rows.Close() + out := []*PlanVersion{} + for rows.Next() { + pv := &PlanVersion{} + if err := rows.Scan(&pv.ID, &pv.SessionID, &pv.Version, &pv.Plan, &pv.Note, &pv.CreatedAt); err != nil { + return nil, fmt.Errorf("journey: scan plan: %w", err) + } + out = append(out, pv) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("journey: list plans: %w", err) + } + return out, nil +} diff --git a/internal/api/maintenance/forecast.go b/internal/api/maintenance/forecast.go new file mode 100644 index 0000000000..fb8c4cbeb1 --- /dev/null +++ b/internal/api/maintenance/forecast.go @@ -0,0 +1,182 @@ +package maintenance + +import ( + "context" + "math" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Forecast statuses. +const ( + forecastGood = "good" + forecastDueSoon = "due_soon" + forecastOverdue = "overdue" +) + +// maintenanceSpec pins Tesla EV service intervals. IntervalKm == 0 means +// time-only; IntervalMonths == 0 means mileage-only. +type maintenanceSpec struct { + Name string + Category string + Description string + IntervalKm float64 + IntervalMonths int +} + +func maintenanceSpecs() []maintenanceSpec { + return []maintenanceSpec{ + {"Cabin Air Filter", "filters", "Replace cabin air filter (HEPA)", 0, 24}, + {"Tire Rotation", "tires", "Rotate tires for even wear", 10000, 0}, + {"Brake Fluid Check", "brakes", "Test brake fluid for moisture content", 0, 24}, + {"Battery Coolant", "battery", "Check battery coolant level and condition", 0, 48}, + {"Windshield Washer Fluid", "fluids", "Top up windshield washer fluid", 0, 6}, + {"Wiper Blades", "wipers", "Inspect and replace wiper blades if worn", 0, 12}, + {"Wheel Alignment", "alignment", "Check and adjust wheel alignment", 20000, 0}, + {"Brake Caliper Cleaning", "brakes", "Clean and lubricate brake calipers", 20000, 12}, + {"12V Battery Health", "battery", "Load-test the 12V auxiliary battery", 0, 24}, + {"Tire Tread Depth", "tires", "Measure tread; replace below 4/32 in", 40000, 0}, + } +} + +// ForecastItem is one projected maintenance item. +type ForecastItem struct { + Name string `json:"name"` + Category string `json:"category"` + Description string `json:"description"` + DueDate *string `json:"due_date"` + KmRemaining *float64 `json:"km_remaining"` + Status string `json:"status"` + Basis string `json:"basis"` +} + +// MaintenanceForecast is the GET /maintenance/forecast response. +type MaintenanceForecast struct { + VehicleID int64 `json:"vehicle_id"` + OdometerKm float64 `json:"odometer_km"` + KmPerDay float64 `json:"km_per_day"` + Items []ForecastItem `json:"items"` + DueSoonCount int `json:"due_soon_count"` + OverdueCount int `json:"overdue_count"` +} + +// ProjectForecast is the pure wear projection: time-based items count from +// now (no service history is recorded yet), mileage-based items from the +// odometer at the trailing daily rate. A zero rate degrades mileage items +// to date-unknown instead of dividing by zero. +func ProjectForecast(vehicleID int64, odometerKm, kmPerDay float64, now time.Time) MaintenanceForecast { + fc := MaintenanceForecast{ + VehicleID: vehicleID, + OdometerKm: math.Round(odometerKm*10) / 10, + KmPerDay: math.Round(kmPerDay*10) / 10, + Items: []ForecastItem{}, + } + for _, spec := range maintenanceSpecs() { + item := ForecastItem{Name: spec.Name, Category: spec.Category, Description: spec.Description} + switch { + case spec.IntervalKm > 0 && spec.IntervalMonths > 0: + // Whichever comes first. + dateDue := now.AddDate(0, spec.IntervalMonths, 0) + if kmPerDay > 0 { + days := spec.IntervalKm / kmPerDay + if kmDue := now.Add(time.Duration(days*24) * time.Hour); kmDue.Before(dateDue) { + rem := spec.IntervalKm + item.KmRemaining = &rem + item.Basis = "mileage" + setDue(&item, &fc, kmDue, now) + break + } + } + item.Basis = "time" + s := dateDue.Format("2006-01-02") + item.DueDate = &s + setDue(&item, &fc, dateDue, now) + case spec.IntervalKm > 0: + rem := spec.IntervalKm + item.KmRemaining = &rem + item.Basis = "mileage" + if kmPerDay > 0 { + due := now.Add(time.Duration(spec.IntervalKm/kmPerDay*24) * time.Hour) + s := due.Format("2006-01-02") + item.DueDate = &s + setDue(&item, &fc, due, now) + } else { + item.Status = forecastGood + } + default: + due := now.AddDate(0, spec.IntervalMonths, 0) + s := due.Format("2006-01-02") + item.DueDate = &s + item.Basis = "time" + setDue(&item, &fc, due, now) + } + fc.Items = append(fc.Items, item) + } + return fc +} + +func setDue(item *ForecastItem, fc *MaintenanceForecast, due, now time.Time) { + switch { + case !due.After(now): + item.Status = forecastOverdue + fc.OverdueCount++ + case due.Sub(now) <= 30*24*time.Hour: + item.Status = forecastDueSoon + fc.DueSoonCount++ + default: + item.Status = forecastGood + } +} + +// Forecast serves GET /maintenance/forecast?vehicle_id=.... vehicle_id is +// optional (defaults to the first vehicle); the endpoint degrades to an +// empty-items forecast on missing data, matching List. +func (h *Handler) Forecast(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), maintenanceReadTimeout) + defer cancel() + + vehicleID := int64(0) + if s := r.URL.Query().Get("vehicle_id"); s != "" { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil || v <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + vehicleID = v + } else { + var ok bool + if vehicleID, ok = h.firstVehicleID(ctx); !ok { + httpx.WriteJSON(w, http.StatusOK, MaintenanceForecast{Items: []ForecastItem{}}) + return + } + } + + odometer := h.readOdometer(ctx, vehicleID) / 1000.0 + rate := h.dailyRate(ctx, vehicleID) + httpx.WriteJSON(w, http.StatusOK, ProjectForecast(vehicleID, odometer, rate, time.Now())) +} + +// dailyRate returns trailing-90d km/day from the drives table, or 0 when +// unreadable. A single aggregate keeps the forecast to two round-trips. +func (h *Handler) dailyRate(ctx context.Context, vehicleID int64) float64 { + if h.db == nil { + return 0 + } + var rate float64 + err := h.db.QueryRow(ctx, ` + SELECT COALESCE(SUM(distance_m), 0) / 1000.0 / 90.0 + FROM drives + WHERE vehicle_id = $1 + AND started_at >= NOW() - INTERVAL '90 days' + AND distance_m IS NOT NULL AND distance_m > 0`, vehicleID).Scan(&rate) + if err != nil { + log.Warn().Err(err).Int64("vehicle_id", vehicleID).Msg("maintenance: daily rate unreadable — defaulting to 0") + return 0 + } + return rate +} diff --git a/internal/api/maintenance/forecast_test.go b/internal/api/maintenance/forecast_test.go new file mode 100644 index 0000000000..86f445bb18 --- /dev/null +++ b/internal/api/maintenance/forecast_test.go @@ -0,0 +1,92 @@ +package maintenance + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestProjectForecastMileageDriven(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + fc := ProjectForecast(4, 50000, 100, now) // 100 km/day + if len(fc.Items) != 10 { + t.Fatalf("items = %d, want 10", len(fc.Items)) + } + var rotation *ForecastItem + for i := range fc.Items { + if fc.Items[i].Name == "Tire Rotation" { + rotation = &fc.Items[i] + } + } + if rotation == nil || rotation.KmRemaining == nil || *rotation.KmRemaining != 10000 { + t.Fatalf("rotation = %+v", rotation) + } + // 10000 km @ 100/day → ~100 days out → good, dated. + if rotation.Status != forecastGood || rotation.DueDate == nil { + t.Fatalf("rotation = %+v", rotation) + } +} + +func TestProjectForecastZeroRateDegrades(t *testing.T) { + now := time.Now().UTC() + fc := ProjectForecast(4, 50000, 0, now) + for i := range fc.Items { + if fc.Items[i].Basis == "mileage" && fc.Items[i].KmRemaining == nil { + t.Fatalf("mileage item missing remainder: %+v", fc.Items[i]) + } + } +} + +func TestProjectForecastDueSoonBand(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + // Fast driver: 1000 km/day → 10k rotation due in 10 days. + fc := ProjectForecast(4, 50000, 1000, now) + found := false + for _, it := range fc.Items { + if it.Name == "Tire Rotation" && it.Status == forecastDueSoon { + found = true + } + } + if !found { + t.Fatalf("expected due_soon rotation: %+v", fc.Items) + } + if fc.DueSoonCount < 1 { + t.Fatalf("due soon count = %d", fc.DueSoonCount) + } +} + +func TestForecastServesProjection(t *testing.T) { + reader := &fakeRowReader{row: fakeRow{scan: func(dest ...any) error { + *(dest[0].(*float64)) = 55.5 + return nil + }}} + h := &Handler{db: reader, redisCache: &fakeSignalReader{signals: map[string]interface{}{"Odometer": float64(48000000)}}} + req := httptest.NewRequest(http.MethodGet, "/forecast?vehicle_id=4", nil) + rec := httptest.NewRecorder() + h.Forecast(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var fc MaintenanceForecast + if err := json.NewDecoder(rec.Body).Decode(&fc); err != nil { + t.Fatal(err) + } + if fc.VehicleID != 4 || fc.KmPerDay != 55.5 || fc.OdometerKm != 48000 { + t.Fatalf("unexpected forecast: %+v", fc) + } + if len(fc.Items) == 0 { + t.Fatal("expected forecast items") + } +} + +func TestForecastRejectsBadVehicle(t *testing.T) { + h := &Handler{} + req := httptest.NewRequest(http.MethodGet, "/forecast?vehicle_id=x", nil) + rec := httptest.NewRecorder() + h.Forecast(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} diff --git a/internal/api/nextcharge/decide.go b/internal/api/nextcharge/decide.go new file mode 100644 index 0000000000..81ed22288c --- /dev/null +++ b/internal/api/nextcharge/decide.go @@ -0,0 +1,200 @@ +package nextcharge + +import ( + "fmt" + "math" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/chargeautopilot" +) + +// Verdicts returned by Decide. The frontend maps these to i18n copy. +const ( + VerdictEnough = "enough" + VerdictWait = "wait" + VerdictChargeHomeNow = "charge_home_now" + VerdictSupercharger = "supercharger" + VerdictSkipDC = "skip_dc" +) + +// Reason keys for i18n interpolation (nextCharge.reason.*). +const ( + ReasonEnough = "enough" + ReasonWaitOffpeak = "wait_offpeak" + ReasonSuperchargerFaster = "supercharger_faster" + ReasonSuperchargerCheaper = "supercharger_cheaper" + ReasonSkipDC = "skip_dc" + ReasonChargeHomeNow = "charge_home_now" +) + +const ( + defaultHorizon = 12 * time.Hour + minWaitLead = 15 * time.Minute + minWaitSavingsUSD = 0.50 + scCheaperRatio = 0.90 // Supercharger wins if < 90% of the cheaper home option + scPremiumRatio = 1.10 // skip DC if Supercharger is ≥10% more than charge-now +) + +// Quote is the cheapest billed Supercharger/DC site for this VIN. +type Quote struct { + Site string + AvgPerKWh float64 // USD per kWh from Tesla invoices +} + +// Input seeds a 12-hour energy verdict. +type Input struct { + Profile chargeautopilot.Profile + CurrentSOC int + Now time.Time + Horizon time.Duration + Quote *Quote +} + +// Decision is the GET /charge-autopilot/decision wire shape. +type Decision struct { + Verdict string `json:"verdict"` + ReasonKey string `json:"reason_key"` + Reason string `json:"reason"` + CurrentSOC int `json:"current_soc"` + TargetSOC int `json:"target_soc"` + KWhNeeded float64 `json:"kwh_needed"` + HorizonHours float64 `json:"horizon_hours"` + HomeNowCost *float64 `json:"home_now_cost,omitempty"` + HomeWaitCost *float64 `json:"home_wait_cost,omitempty"` + HomeWaitStart *time.Time `json:"home_wait_start,omitempty"` + HomeSavings *float64 `json:"home_savings,omitempty"` + SuperchargerSite *string `json:"supercharger_site,omitempty"` + SuperchargerPerKWh *float64 `json:"supercharger_per_kwh,omitempty"` + SuperchargerCost *float64 `json:"supercharger_cost,omitempty"` + ReadyBy time.Time `json:"ready_by"` + CappedByHealth bool `json:"capped_by_health_guardrail"` +} + +// Decide returns the next-charge verdict. Pure: no I/O. +func Decide(in Input) Decision { + now := in.Now + horizon := in.Horizon + if horizon <= 0 { + horizon = defaultHorizon + } + p := in.Profile + target, capped := chargeautopilot.EffectiveTarget(p.TargetSOC, p.DailyCapSOC, p.TripOverride) + readyBy, err := chargeautopilot.NextReadyBy(p.ReadyBy, now) + if err != nil { + readyBy = now.Add(24 * time.Hour) + } + + d := Decision{ + CurrentSOC: in.CurrentSOC, + TargetSOC: target, + HorizonHours: horizon.Hours(), + ReadyBy: readyBy, + CappedByHealth: capped, + } + attachQuote(&d, in.Quote, 0) + + if in.CurrentSOC >= target { + d.KWhNeeded = 0 + d.Verdict = VerdictEnough + d.ReasonKey = ReasonEnough + d.Reason = fmt.Sprintf("Battery is at %d%%, already at the %d%% target.", in.CurrentSOC, target) + return d + } + + kwhNeeded := round2(float64(target-in.CurrentSOC) / 100.0 * p.BatteryCapacityKWh) + d.KWhNeeded = kwhNeeded + attachQuote(&d, in.Quote, kwhNeeded) + + preview, previewErr := chargeautopilot.Preview(chargeautopilot.PreviewInput{ + Profile: p, + CurrentSOC: in.CurrentSOC, + Now: now, + }) + if previewErr != nil || preview == nil { + if in.Quote != nil && in.Quote.AvgPerKWh > 0 { + d.Verdict = VerdictSupercharger + d.ReasonKey = ReasonSuperchargerFaster + d.Reason = fmt.Sprintf( + "Home charging cannot finish before ready-by; %s is the cheapest billed Supercharger at $%.2f/kWh.", + in.Quote.Site, in.Quote.AvgPerKWh, + ) + return d + } + d.Verdict = VerdictChargeHomeNow + d.ReasonKey = ReasonChargeHomeNow + d.Reason = "Start charging at home now — no cheaper Supercharger quote and no feasible off-peak window." + return d + } + + d.HomeNowCost = ptrf(round2(preview.ChargeNowCost)) + d.HomeWaitCost = ptrf(round2(preview.OptimizedCost)) + d.HomeSavings = ptrf(round2(preview.Savings)) + waitStart := preview.Window.StartTime + d.HomeWaitStart = &waitStart + d.KWhNeeded = round2(preview.KWhNeeded) + attachQuote(&d, in.Quote, preview.KWhNeeded) + + homeFloor := preview.OptimizedCost + if preview.ChargeNowCost < homeFloor { + homeFloor = preview.ChargeNowCost + } + scCost := 0.0 + if in.Quote != nil && in.Quote.AvgPerKWh > 0 { + scCost = round2(in.Quote.AvgPerKWh * preview.KWhNeeded) + } + + if in.Quote != nil && scCost > 0 && homeFloor > 0 && scCost < homeFloor*scCheaperRatio { + d.Verdict = VerdictSupercharger + d.ReasonKey = ReasonSuperchargerCheaper + d.Reason = fmt.Sprintf( + "%s is cheaper ($%.2f vs $%.2f at home) for the %.1f kWh you still need.", + in.Quote.Site, scCost, homeFloor, preview.KWhNeeded, + ) + return d + } + + waitOK := waitStart.After(now.Add(minWaitLead)) && + !waitStart.After(now.Add(horizon)) && + preview.Savings >= minWaitSavingsUSD + if waitOK { + d.Verdict = VerdictWait + d.ReasonKey = ReasonWaitOffpeak + d.Reason = fmt.Sprintf( + "Wait for off-peak at %s — save $%.2f versus charging now.", + waitStart.Format(time.Kitchen), preview.Savings, + ) + return d + } + + if in.Quote != nil && scCost > 0 && preview.ChargeNowCost > 0 && scCost > preview.ChargeNowCost*scPremiumRatio { + d.Verdict = VerdictSkipDC + d.ReasonKey = ReasonSkipDC + d.Reason = fmt.Sprintf( + "Skip %s ($%.2f) — home now is $%.2f for the same energy.", + in.Quote.Site, scCost, preview.ChargeNowCost, + ) + return d + } + + d.Verdict = VerdictChargeHomeNow + d.ReasonKey = ReasonChargeHomeNow + d.Reason = "Charge at home now — the cheapest window is already open (or too far out to wait)." + return d +} + +func attachQuote(d *Decision, q *Quote, kwh float64) { + if q == nil || q.AvgPerKWh <= 0 { + return + } + d.SuperchargerSite = ptrs(q.Site) + d.SuperchargerPerKWh = ptrf(round4(q.AvgPerKWh)) + if kwh > 0 { + d.SuperchargerCost = ptrf(round2(q.AvgPerKWh * kwh)) + } +} + +func ptrf(v float64) *float64 { return &v } +func ptrs(v string) *string { return &v } + +func round2(f float64) float64 { return math.Round(f*100) / 100 } +func round4(f float64) float64 { return math.Round(f*10000) / 10000 } diff --git a/internal/api/nextcharge/decide_test.go b/internal/api/nextcharge/decide_test.go new file mode 100644 index 0000000000..e918c2e623 --- /dev/null +++ b/internal/api/nextcharge/decide_test.go @@ -0,0 +1,97 @@ +package nextcharge + +import ( + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/chargeautopilot" +) + +func testProfile() chargeautopilot.Profile { + p := chargeautopilot.DefaultProfile(1) + p.Enabled = true + return p +} + +func TestDecideEnoughAtTarget(t *testing.T) { + now := time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC) + d := Decide(Input{Profile: testProfile(), CurrentSOC: 85, Now: now}) + if d.Verdict != VerdictEnough { + t.Fatalf("verdict = %s, want %s", d.Verdict, VerdictEnough) + } + if d.KWhNeeded != 0 { + t.Fatalf("kwh_needed = %v, want 0", d.KWhNeeded) + } +} + +func TestDecideWaitOffPeakWhenSavingsClear(t *testing.T) { + // 18:00 winter weekday is on-peak for pge-ev2a; ready-by 07:30 leaves + // overnight off-peak. Savings versus charging now must clear $0.50. + now := time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC) + d := Decide(Input{Profile: testProfile(), CurrentSOC: 50, Now: now}) + if d.Verdict != VerdictWait { + t.Fatalf("verdict = %s reason=%s savings=%v, want wait", d.Verdict, d.Reason, ptrVal(d.HomeSavings)) + } + if d.HomeWaitStart == nil { + t.Fatal("expected home_wait_start") + } + if ptrVal(d.HomeSavings) < minWaitSavingsUSD { + t.Fatalf("savings = %v, want >= %.2f", ptrVal(d.HomeSavings), minWaitSavingsUSD) + } +} + +func TestDecideSuperchargerWhenHomeCannotFinish(t *testing.T) { + now := time.Date(2026, 1, 15, 7, 0, 0, 0, time.UTC) + p := testProfile() + p.ReadyBy = "07:30" + q := &Quote{Site: "Everett, WA", AvgPerKWh: 0.47} + d := Decide(Input{Profile: p, CurrentSOC: 20, Now: now, Quote: q}) + if d.Verdict != VerdictSupercharger { + t.Fatalf("verdict = %s reason=%s, want supercharger", d.Verdict, d.Reason) + } + if d.ReasonKey != ReasonSuperchargerFaster { + t.Fatalf("reason_key = %s, want %s", d.ReasonKey, ReasonSuperchargerFaster) + } + if d.SuperchargerSite == nil || *d.SuperchargerSite != "Everett, WA" { + t.Fatalf("site = %v", d.SuperchargerSite) + } +} + +func TestDecideSkipDCWhenHomeNowCheaper(t *testing.T) { + // Overnight off-peak: best window is now (or soon), Supercharger at + // billed $0.47/kWh is a premium versus home TOU. + now := time.Date(2026, 1, 15, 2, 0, 0, 0, time.UTC) + q := &Quote{Site: "Everett, WA", AvgPerKWh: 0.47} + d := Decide(Input{Profile: testProfile(), CurrentSOC: 50, Now: now, Quote: q}) + if d.Verdict != VerdictSkipDC { + t.Fatalf("verdict = %s reason=%s now=%v sc=%v, want skip_dc", + d.Verdict, d.Reason, ptrVal(d.HomeNowCost), ptrVal(d.SuperchargerCost)) + } +} + +func TestDecideChargeHomeNowWithoutQuote(t *testing.T) { + now := time.Date(2026, 1, 15, 2, 0, 0, 0, time.UTC) + d := Decide(Input{Profile: testProfile(), CurrentSOC: 50, Now: now}) + if d.Verdict != VerdictChargeHomeNow { + t.Fatalf("verdict = %s reason=%s, want charge_home_now", d.Verdict, d.Reason) + } +} + +func TestDecideSuperchargerCheaperThanHome(t *testing.T) { + now := time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC) + q := &Quote{Site: "Promo site", AvgPerKWh: 0.01} + d := Decide(Input{Profile: testProfile(), CurrentSOC: 50, Now: now, Quote: q}) + if d.Verdict != VerdictSupercharger { + t.Fatalf("verdict = %s reason=%s, want supercharger", d.Verdict, d.Reason) + } + if d.ReasonKey != ReasonSuperchargerCheaper { + t.Fatalf("reason_key = %s, want %s", d.ReasonKey, ReasonSuperchargerCheaper) + } +} + +func ptrVal(p *float64) float64 { + if p == nil { + return 0 + } + return *p +} diff --git a/internal/api/nextcharge/handler.go b/internal/api/nextcharge/handler.go new file mode 100644 index 0000000000..4e15da5a46 --- /dev/null +++ b/internal/api/nextcharge/handler.go @@ -0,0 +1,96 @@ +package nextcharge + +import ( + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/chargeautopilot" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// Handler serves GET /charge-autopilot/decision. +type Handler struct { + profiles chargeautopilot.ProfileStore + vins VINFinder + quotes QuoteFinder + now func() time.Time +} + +// NewHandler wires profile + optional VIN/invoice finders. Panics on a nil +// profile store (fail-fast wiring). A nil database degrades Supercharger +// quotes without failing the home TOU verdict. +func NewHandler(profiles chargeautopilot.ProfileStore, db *database.DB) *Handler { + if profiles == nil { + panic("nextcharge: nil profile store") + } + vins, quotes := newFinders(db) + return &Handler{profiles: profiles, vins: vins, quotes: quotes, now: time.Now} +} + +// Get serves GET /charge-autopilot/decision?vehicle_id=¤t_soc=. +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + vehicleID, err := parsePositiveInt(r.URL.Query().Get("vehicle_id")) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + soc, err := parseSOC(r.URL.Query().Get("current_soc")) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "current_soc must be 0..100") + return + } + p, err := h.profiles.Get(r.Context(), vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("nextcharge: profile read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read autopilot profile") + return + } + + var quote *Quote + if h.vins != nil && h.quotes != nil { + vin, vinErr := h.vins.VIN(r.Context(), vehicleID) + if vinErr != nil { + log.Warn().Err(vinErr).Int64("vehicle_id", vehicleID).Msg("nextcharge: vin lookup failed") + } else if vin != "" { + q, qErr := h.quotes.Cheapest(r.Context(), vin) + if qErr != nil { + log.Warn().Err(qErr).Int64("vehicle_id", vehicleID).Msg("nextcharge: supercharger quote failed") + } else { + quote = q + } + } + } + + httpx.WriteJSON(w, http.StatusOK, Decide(Input{ + Profile: *p, + CurrentSOC: soc, + Now: h.now(), + Quote: quote, + })) +} + +func parsePositiveInt(s string) (int64, error) { + id, err := strconv.ParseInt(s, 10, 64) + if err != nil || id <= 0 { + return 0, errMissing + } + return id, nil +} + +func parseSOC(s string) (int, error) { + n, err := strconv.Atoi(s) + if err != nil || n < 0 || n > 100 { + return 0, errMissing + } + return n, nil +} + +type missingErr string + +func (e missingErr) Error() string { return string(e) } + +const errMissing = missingErr("missing") diff --git a/internal/api/nextcharge/handler_test.go b/internal/api/nextcharge/handler_test.go new file mode 100644 index 0000000000..bfa4d37c4a --- /dev/null +++ b/internal/api/nextcharge/handler_test.go @@ -0,0 +1,106 @@ +package nextcharge + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/api/chargeautopilot" +) + +type fakeProfiles struct { + p chargeautopilot.Profile +} + +func (f *fakeProfiles) Get(_ context.Context, vehicleID int64) (*chargeautopilot.Profile, error) { + p := f.p + if p.VehicleID == 0 { + d := chargeautopilot.DefaultProfile(vehicleID) + return &d, nil + } + p.VehicleID = vehicleID + return &p, nil +} + +func (f *fakeProfiles) Upsert(_ context.Context, _ *chargeautopilot.Profile) error { return nil } + +type fakeVIN struct{ vin string } + +func (f fakeVIN) VIN(_ context.Context, _ int64) (string, error) { return f.vin, nil } + +type fakeQuotes struct{ q *Quote } + +func (f fakeQuotes) Cheapest(_ context.Context, _ string) (*Quote, error) { return f.q, nil } + +func TestGetRejectsMissingVehicle(t *testing.T) { + h := NewHandler(&fakeProfiles{}, nil) + req := httptest.NewRequest(http.MethodGet, "/decision?current_soc=50", nil) + rec := httptest.NewRecorder() + h.Get(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestGetRejectsBadSOC(t *testing.T) { + h := NewHandler(&fakeProfiles{}, nil) + req := httptest.NewRequest(http.MethodGet, "/decision?vehicle_id=1¤t_soc=140", nil) + rec := httptest.NewRecorder() + h.Get(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestGetReturnsEnough(t *testing.T) { + h := NewHandler(&fakeProfiles{}, nil) + h.now = func() time.Time { return time.Date(2026, 1, 15, 18, 0, 0, 0, time.UTC) } + req := httptest.NewRequest(http.MethodGet, "/decision?vehicle_id=3¤t_soc=90", nil) + rec := httptest.NewRecorder() + h.Get(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var d Decision + if err := json.NewDecoder(rec.Body).Decode(&d); err != nil { + t.Fatal(err) + } + if d.Verdict != VerdictEnough || d.CurrentSOC != 90 || d.TargetSOC != 80 { + t.Fatalf("unexpected decision: %+v", d) + } +} + +func TestGetAttachesSuperchargerQuote(t *testing.T) { + h := NewHandler(&fakeProfiles{}, nil) + h.now = func() time.Time { return time.Date(2026, 1, 15, 7, 0, 0, 0, time.UTC) } + h.vins = fakeVIN{vin: "5YJTEST"} + h.quotes = fakeQuotes{q: &Quote{Site: "Everett, WA", AvgPerKWh: 0.47}} + req := httptest.NewRequest(http.MethodGet, "/decision?vehicle_id=3¤t_soc=20", nil) + rec := httptest.NewRecorder() + h.Get(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var d Decision + if err := json.NewDecoder(rec.Body).Decode(&d); err != nil { + t.Fatal(err) + } + if d.Verdict != VerdictSupercharger { + t.Fatalf("verdict = %s, want supercharger", d.Verdict) + } + if d.SuperchargerSite == nil || *d.SuperchargerSite != "Everett, WA" { + t.Fatalf("quote not attached: %+v", d) + } +} + +func TestNewHandlerPanicsOnNilProfiles(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + NewHandler(nil, nil) +} diff --git a/internal/api/nextcharge/quotes.go b/internal/api/nextcharge/quotes.go new file mode 100644 index 0000000000..981fd6000b --- /dev/null +++ b/internal/api/nextcharge/quotes.go @@ -0,0 +1,71 @@ +package nextcharge + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/ev-dev-labs/teslasync/internal/api/teslachargehist" + "github.com/ev-dev-labs/teslasync/internal/database" + tesladb "github.com/ev-dev-labs/teslasync/internal/database/tesla" +) + +// VINFinder resolves a vehicle row to its Tesla VIN. +type VINFinder interface { + VIN(ctx context.Context, vehicleID int64) (string, error) +} + +// QuoteFinder returns the cheapest billed Supercharger site for a VIN. +type QuoteFinder interface { + Cheapest(ctx context.Context, vin string) (*Quote, error) +} + +type pgVIN struct { + pool interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + } +} + +func (p pgVIN) VIN(ctx context.Context, vehicleID int64) (string, error) { + if p.pool == nil { + return "", nil + } + var vin string + err := p.pool.QueryRow(ctx, `SELECT vin FROM vehicles WHERE id = $1`, vehicleID).Scan(&vin) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + return "", fmt.Errorf("lookup vehicle vin: %w", err) + } + return vin, nil +} + +type billedQuotes struct { + repo *tesladb.TeslaChargingHistoryRepo +} + +func (b billedQuotes) Cheapest(ctx context.Context, vin string) (*Quote, error) { + if b.repo == nil || vin == "" { + return nil, nil + } + entries, err := b.repo.GetAll(ctx, vin, 2000, 0) + if err != nil { + return nil, fmt.Errorf("list tesla charging history: %w", err) + } + ranking := teslachargehist.RankSites(entries) + if len(ranking.Sites) == 0 { + return nil, nil + } + s := ranking.Sites[0] + return &Quote{Site: s.Site, AvgPerKWh: s.AvgPerKWh}, nil +} + +func newFinders(db *database.DB) (VINFinder, QuoteFinder) { + if db == nil || db.Pool == nil { + return nil, nil + } + return pgVIN{pool: db.Pool}, billedQuotes{repo: tesladb.NewTeslaChargingHistoryRepo(db)} +} diff --git a/internal/api/ocpp/handler.go b/internal/api/ocpp/handler.go new file mode 100644 index 0000000000..24263c0a28 --- /dev/null +++ b/internal/api/ocpp/handler.go @@ -0,0 +1,59 @@ +// Package ocpp exposes the OCPP-J 1.6 charge points and sessions +// recorded by cmd/ocpp-server so mixed-fleet operators see non-Tesla +// charger activity inside the main app. +package ocpp + +import ( + "context" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + dbocpp "github.com/ev-dev-labs/teslasync/internal/database/ocpp" +) + +// Reader is the read port over OCPP persistence. *dbocpp.Store satisfies it. +type Reader interface { + ListChargePoints(ctx context.Context) ([]dbocpp.ChargePoint, error) + ListSessions(ctx context.Context, chargePointID string, limit int) ([]dbocpp.SessionView, error) +} + +// Handler serves the OCPP read endpoints. Stateless beyond its +// constructor input; safe for concurrent use. +type Handler struct { + store Reader +} + +// NewHandler wires the handler. Panics on nil store (fail-fast wiring +// contract, matching sibling handlers). +func NewHandler(store Reader) *Handler { + if store == nil { + panic("api/ocpp: nil store") + } + return &Handler{store: store} +} + +// ListChargePoints serves GET /ocpp/charge-points. +func (h *Handler) ListChargePoints(w http.ResponseWriter, r *http.Request) { + cps, err := h.store.ListChargePoints(r.Context()) + if err != nil { + log.Error().Err(err).Msg("ocpp: list charge points failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to list charge points") + return + } + httpx.WriteJSON(w, http.StatusOK, cps) +} + +// ListSessions serves GET /ocpp/sessions?charge_point_id=&limit=. +func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) { + limit, _ := apiparams.Pagination(r) + sessions, err := h.store.ListSessions(r.Context(), r.URL.Query().Get("charge_point_id"), limit) + if err != nil { + log.Error().Err(err).Msg("ocpp: list sessions failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to list OCPP sessions") + return + } + httpx.WriteJSON(w, http.StatusOK, sessions) +} diff --git a/internal/api/ocpp/handler_test.go b/internal/api/ocpp/handler_test.go new file mode 100644 index 0000000000..e215afa08c --- /dev/null +++ b/internal/api/ocpp/handler_test.go @@ -0,0 +1,107 @@ +package ocpp + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + dbocpp "github.com/ev-dev-labs/teslasync/internal/database/ocpp" +) + +type fakeReader struct { + points []dbocpp.ChargePoint + sessions []dbocpp.SessionView + err error + + gotChargePointID string + gotLimit int +} + +func (f *fakeReader) ListChargePoints(_ context.Context) ([]dbocpp.ChargePoint, error) { + return f.points, f.err +} + +func (f *fakeReader) ListSessions(_ context.Context, chargePointID string, limit int) ([]dbocpp.SessionView, error) { + f.gotChargePointID = chargePointID + f.gotLimit = limit + return f.sessions, f.err +} + +var _ Reader = (*fakeReader)(nil) + +func TestListChargePoints(t *testing.T) { + seen := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + r := &fakeReader{points: []dbocpp.ChargePoint{{ + ID: "wallbox-1", + Vendor: "Wallbox", + Model: "Pulsar Plus", + LastSeenAt: seen, + Connectors: []dbocpp.ConnectorStatus{{ConnectorID: 1, Status: "Charging", ErrorCode: "NoError"}}, + ActiveSessions: 1, + }}} + h := NewHandler(r) + + req := httptest.NewRequest(http.MethodGet, "/ocpp/charge-points", nil) + rec := httptest.NewRecorder() + h.ListChargePoints(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got []dbocpp.ChargePoint + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].ID != "wallbox-1" || got[0].ActiveSessions != 1 { + t.Fatalf("unexpected body: %+v", got) + } +} + +func TestListSessionsPassesFilterAndLimit(t *testing.T) { + r := &fakeReader{sessions: []dbocpp.SessionView{}} + h := NewHandler(r) + + req := httptest.NewRequest(http.MethodGet, "/ocpp/sessions?charge_point_id=wallbox-1&limit=10", nil) + rec := httptest.NewRecorder() + h.ListSessions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if r.gotChargePointID != "wallbox-1" { + t.Fatalf("charge_point_id = %q, want wallbox-1", r.gotChargePointID) + } + if r.gotLimit != 10 { + t.Fatalf("limit = %d, want 10", r.gotLimit) + } +} + +func TestHandlersSurfaceStoreErrors(t *testing.T) { + r := &fakeReader{err: errors.New("db down")} + h := NewHandler(r) + + rec := httptest.NewRecorder() + h.ListChargePoints(rec, httptest.NewRequest(http.MethodGet, "/ocpp/charge-points", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("charge-points status = %d, want 500", rec.Code) + } + + rec = httptest.NewRecorder() + h.ListSessions(rec, httptest.NewRequest(http.MethodGet, "/ocpp/sessions", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("sessions status = %d, want 500", rec.Code) + } +} + +func TestNewHandlerPanicsOnNil(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic on nil store") + } + }() + NewHandler(nil) +} diff --git a/internal/api/router.go b/internal/api/router.go index 40a09666f3..e75d1094a8 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -95,6 +95,7 @@ import ( "github.com/ev-dev-labs/teslasync/internal/api/batterypassport" apibenchmark "github.com/ev-dev-labs/teslasync/internal/api/benchmark" apicarbon "github.com/ev-dev-labs/teslasync/internal/api/carbon" + apichargeautopilot "github.com/ev-dev-labs/teslasync/internal/api/chargeautopilot" apichargeheatmap "github.com/ev-dev-labs/teslasync/internal/api/chargeheatmap" apichargeopt "github.com/ev-dev-labs/teslasync/internal/api/chargeopt" "github.com/ev-dev-labs/teslasync/internal/api/chargeplanner" @@ -103,6 +104,7 @@ import ( apiannot "github.com/ev-dev-labs/teslasync/internal/api/chartannotation" apichatbot "github.com/ev-dev-labs/teslasync/internal/api/chatbot" apiclimate "github.com/ev-dev-labs/teslasync/internal/api/climate" + apicomfort "github.com/ev-dev-labs/teslasync/internal/api/comfort" apicommand "github.com/ev-dev-labs/teslasync/internal/api/command" "github.com/ev-dev-labs/teslasync/internal/api/costforecast" apidash "github.com/ev-dev-labs/teslasync/internal/api/dashboardlayout" @@ -125,13 +127,13 @@ import ( apifleetops "github.com/ev-dev-labs/teslasync/internal/api/fleetops" apifleettelem "github.com/ev-dev-labs/teslasync/internal/api/fleettelemetry" apifsd "github.com/ev-dev-labs/teslasync/internal/api/fsd" - apiphysics "github.com/ev-dev-labs/teslasync/internal/api/teslaphysics" apigas "github.com/ev-dev-labs/teslasync/internal/api/gasprice" apigeocode "github.com/ev-dev-labs/teslasync/internal/api/geocode" apigeo "github.com/ev-dev-labs/teslasync/internal/api/geofence" apiguard "github.com/ev-dev-labs/teslasync/internal/api/guard" apiimpers "github.com/ev-dev-labs/teslasync/internal/api/impersonate" apixray "github.com/ev-dev-labs/teslasync/internal/api/ingestxray" + apijourney "github.com/ev-dev-labs/teslasync/internal/api/journey" apilifetime "github.com/ev-dev-labs/teslasync/internal/api/lifetime" apilocsnap "github.com/ev-dev-labs/teslasync/internal/api/locsnap" "github.com/ev-dev-labs/teslasync/internal/api/maintenance" @@ -139,7 +141,9 @@ import ( apimw "github.com/ev-dev-labs/teslasync/internal/api/middleware" apimileage "github.com/ev-dev-labs/teslasync/internal/api/mileage" apimotor "github.com/ev-dev-labs/teslasync/internal/api/motor" + apinextcharge "github.com/ev-dev-labs/teslasync/internal/api/nextcharge" apinotif "github.com/ev-dev-labs/teslasync/internal/api/notification" + apiocpp "github.com/ev-dev-labs/teslasync/internal/api/ocpp" apionboard "github.com/ev-dev-labs/teslasync/internal/api/onboarding" apiopenapi "github.com/ev-dev-labs/teslasync/internal/api/openapi" apiperiod "github.com/ev-dev-labs/teslasync/internal/api/periodstats" @@ -173,6 +177,7 @@ import ( apispeedprof "github.com/ev-dev-labs/teslasync/internal/api/speedprofile" "github.com/ev-dev-labs/teslasync/internal/api/sse" apistatus "github.com/ev-dev-labs/teslasync/internal/api/status" + apistormguard "github.com/ev-dev-labs/teslasync/internal/api/stormguard" apisynthetic "github.com/ev-dev-labs/teslasync/internal/api/synthetic" apiauthmode "github.com/ev-dev-labs/teslasync/internal/api/sysauthmode" apisystem "github.com/ev-dev-labs/teslasync/internal/api/system" @@ -183,6 +188,7 @@ import ( apiteslachargesess "github.com/ev-dev-labs/teslasync/internal/api/teslachargesess" apiteslaenergyhist "github.com/ev-dev-labs/teslasync/internal/api/teslaenergyhist" apitels "github.com/ev-dev-labs/teslasync/internal/api/teslaenergylivestatus" + apiphysics "github.com/ev-dev-labs/teslasync/internal/api/teslaphysics" apituc "github.com/ev-dev-labs/teslasync/internal/api/teslauserconfig" apituo "github.com/ev-dev-labs/teslasync/internal/api/teslauserorder" apitup "github.com/ev-dev-labs/teslasync/internal/api/teslauserprofile" @@ -202,6 +208,7 @@ import ( apivehsettings "github.com/ev-dev-labs/teslasync/internal/api/vehiclesettings" apivehstates "github.com/ev-dev-labs/teslasync/internal/api/vehiclestates" apivisloc "github.com/ev-dev-labs/teslasync/internal/api/visitedlocation" + apiwaitoracle "github.com/ev-dev-labs/teslasync/internal/api/waitoracle" "github.com/ev-dev-labs/teslasync/internal/api/watch" apiwerr "github.com/ev-dev-labs/teslasync/internal/api/weberrors" apiwhrx "github.com/ev-dev-labs/teslasync/internal/api/webhookreceiver" @@ -224,6 +231,7 @@ import ( geofencedb "github.com/ev-dev-labs/teslasync/internal/database/geofence" dbnotif "github.com/ev-dev-labs/teslasync/internal/database/notification" dbobs "github.com/ev-dev-labs/teslasync/internal/database/observability" + dbocpp "github.com/ev-dev-labs/teslasync/internal/database/ocpp" ownershipinteldb "github.com/ev-dev-labs/teslasync/internal/database/ownershipintel" quiethoursdb "github.com/ev-dev-labs/teslasync/internal/database/quiethours" settingsdb "github.com/ev-dev-labs/teslasync/internal/database/settings" @@ -994,6 +1002,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie softwareUpdateHandler := apisoftupd.NewHandler(db) activityHandler := apiactivity.NewHandler(db) tcoHandler := apitco.NewHandler(db) + tcoLedgerHandler := apitco.NewLedgerHandler(apitco.NewPGLedgerStore(db)) sleepHandler := apisleep.NewSleepHandler(db) //: VampireDrainHandler deleted (vampire_drain_events). visitedLocationHandler := apivisloc.NewHandler(db) @@ -1006,6 +1015,11 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie backupRestoreHandler := apibackup.NewRestoreHandler(db) regenHandler := apiregen.NewRegenHandler(db) batteryDegradationHandler := batterydegradation.NewHandler(db, stateReader) + // Server-signed resale battery certificate, verified publicly without auth. + batteryCertHandler := batterydegradation.NewCertificateHandlerFromBatteryHandler( + batteryDegradationHandler, + batterydegradation.NewCertSigner(batterydegradation.DeriveCertKey(cfg.Auth.JWTSecret)), + ) batteryPassportHandler := batterypassport.NewBatteryPassportHandler(db) carbonHandler := apicarbon.NewCarbonHandler(db) rulHandler := apirul.NewRULHandler(db) @@ -1454,6 +1468,22 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie ) lifetimeHandler := apilifetime.NewHandler(db, eventHub) chargePlannerHandler := chargeplanner.NewHandler(db, teslaClient, cfg, stateReader) + chargeAutopilotHandler := apichargeautopilot.NewHandler( + apichargeautopilot.NewPGProfileStore(db), + apichargeautopilot.NewPGSavingsReader(db), + ) + // One-click autopilot run: persists the preview as a charge plan and + // applies it through the charge planner's command path (single path + // issuing Tesla commands). + chargeAutopilotRunHandler := apichargeautopilot.NewRunHandler( + apichargeautopilot.NewPGProfileStore(db), + chargingdb.NewChargePlanRepo(db), + chargePlannerHandler, + ) + nextChargeHandler := apinextcharge.NewHandler( + apichargeautopilot.NewPGProfileStore(db), + db, + ) yearReviewHandler := yearreview.NewHandler(db) energyFlowHandler := apienergyflow.NewEnergyFlowHandler(db, stateReader, liveStateReader) weeklyDigestHandler := apiweekly.NewHandler(db) @@ -2184,9 +2214,25 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie cfg.Auth.ForwardAuthHeader, ) geocodeHandler := apigeocode.NewHandler(geocoding.NewSearcher("TeslaSync/1.0"), geocoding.NewGeocoder(cfg.GoogleMaps.APIKey, cfg.AzureMaps.APIKey)) - shareHandler := apishare.NewShareHandler(db) + shareHandler := apishare.NewShareHandler(db, stateReader) watchHandler := watch.NewHandler(db, teslaClient) onboardingHandler := apionboard.NewHandler(db, opt.Encryptor) + ocppHandler := apiocpp.NewHandler(dbocpp.NewStore(db)) + stormguardHandler := apistormguard.NewHandler( + apistormguard.NewStore(db), + apistormguard.NewClient(), + teslaClient, + stateReader, + vehicledb.NewVehicleRepo(db), + ) + comfortHandler := apicomfort.NewHandler( + apicomfort.NewStore(db), + apicomfort.NewFetcher(), + teslaClient, + vehicledb.NewVehicleRepo(db), + ) + waitoracleHandler := apiwaitoracle.NewHandler(apiwaitoracle.NewStore(db)) + journeyHandler := apijourney.NewHandler(apijourney.NewStore(db)) searchHandler := apisearch.NewHandler(db) // Wire Redis signal cache to handlers that read live vehicle state. @@ -2320,6 +2366,14 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie httprate.LimitByIP(60, 1*time.Minute), ).Get("/api/v1/share/{token}", shareHandler.GetPublicShare) + // Public: Battery certificate verification (no auth — the HMAC + // signature IS the auth). Lets a buyer verify a seller-issued battery + // health attestation without an account. + // NOTE: If using ForwardAuth (Authentik/Authelia), exempt /api/v1/public/ from auth. + r.With( + httprate.LimitByIP(60, 1*time.Minute), + ).Post("/api/v1/public/battery-certificate/verify", batteryCertHandler.Verify) + // Public: Web Vitals ingest. Anonymous browsers // POST batches of LCP/INP/CLS/FCP/TTFB samples here. Mounted outside // the /api/v1 ForwardAuth subrouter so logged-out clients can still @@ -3181,6 +3235,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Get("/states", fleetStateHandler.List) r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/sync", vehicleHandler.SyncFromTesla) r.Route("/{vehicleID}", func(r chi.Router) { + r.Get("/silence", vehicleHandler.Silence) r.Get("/", vehicleHandler.Get) // destructive: requires sudo. r.With(RequireSudo(sudoStore, sudoCfg)).Delete("/", vehicleHandler.Delete) @@ -3347,14 +3402,19 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Route("/maintenance", func(r chi.Router) { r.Get("/", maintenanceHandler.List) r.Get("/records", maintenanceHandler.Records) + r.Get("/forecast", maintenanceHandler.Forecast) }) r.Route("/charging", func(r chi.Router) { r.Get("/", chargingHandler.ListByVehicle) + r.Get("/bill-variance", chargingHandler.BillVariance) // Bulk delete r.With(httprate.LimitByIP(20, 1*time.Minute)).Delete("/bulk", chargingHandler.BulkDelete) r.Route("/{sessionID}", func(r chi.Router) { r.Get("/", chargingHandler.Get) r.Get("/telemetry", chargingHandler.TelemetryReadings) + // Session share link management (mirrors /drives/{driveID}) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/share", shareHandler.CreateSessionShare) + r.Get("/shares", shareHandler.ListSessionShares) }) }) r.Route("/physics", func(r chi.Router) { @@ -3374,6 +3434,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Route("/tesla/charging", func(r chi.Router) { r.Route("/history", func(r chi.Router) { r.Get("/", teslaChargingHistoryHandler.List) + r.Get("/sites", teslaChargingHistoryHandler.Sites) r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/refresh", teslaChargingHistoryHandler.Refresh) }) r.Get("/invoice/{contentID}", teslaChargingHistoryHandler.Invoice) @@ -3404,6 +3465,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie // Live status (power flow snapshots) r.Get("/live-status", teslaEnergyLiveStatusHandler.LiveStatus) r.Get("/live-status/history", teslaEnergyLiveStatusHandler.LiveStatusHistory) + r.Get("/charge-advice", teslaEnergyLiveStatusHandler.ChargeAdvice) r.With(httprate.LimitByIP(10, 1*time.Minute)).Post("/live-status/refresh", teslaEnergyLiveStatusHandler.RefreshLiveStatus) // Time-of-Use settings (rate plan / tariff) @@ -3651,6 +3713,12 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Get("/{presetId}", automationHandler.GetPreset) }) + // Geofence routine templates (static routes before {id} param) + r.Route("/routine-templates", func(r chi.Router) { + r.Get("/", automationHandler.ListRoutineTemplates) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/{id}/install", automationHandler.InstallRoutine) + }) + r.Route("/{id}", func(r chi.Router) { r.Get("/", automationHandler.Get) r.Get("/export", automationHandler.ExportOne) @@ -3667,6 +3735,9 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie // Analytics r.Get("/analytics/fleet", analyticsHandler.Fleet) r.Get("/analytics/tco", tcoHandler.GetTCO) + r.Get("/analytics/tco/ledger", tcoLedgerHandler.List) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/analytics/tco/ledger", tcoLedgerHandler.Create) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Delete("/analytics/tco/ledger/{id}", tcoLedgerHandler.Delete) // Carbon Intelligence — the vehicle-independent diurnal grid // carbon-intensity model (seeded, admin-editable). Mounted as a @@ -3688,9 +3759,11 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Get("/analytics/regen", regenHandler.Stats) r.Get("/analytics/battery-degradation", batteryDegradationHandler.Predict) r.Get("/analytics/battery-health", batteryDegradationHandler.Health) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Get("/analytics/battery-health/certificate", batteryCertHandler.Issue) r.Get("/analytics/charging-heatmap", chargingHeatmapHandler.Get) r.Get("/analytics/speed-profile", speedProfileHandler.Get) r.Get("/analytics/temperature-impact", tempImpactHandler.Get) + r.Get("/analytics/temperature-impact/shift", tempImpactHandler.Shift) // Supervised self-driving distance analytics. Server-side // aggregation keeps the raw counter change feed off the wire; the // response is canonical SI meters plus explicit data-quality @@ -3752,14 +3825,66 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie // Charge Planner (smart scheduling) r.Route("/charge-planner", func(r chi.Router) { r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/optimize", chargePlannerHandler.Optimize) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/queue", chargePlannerHandler.Queue) r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/apply", chargePlannerHandler.Apply) r.Get("/history", chargePlannerHandler.ListPlans) r.Get("/rate-plans", chargePlannerHandler.ListRatePlans) }) + // Charge Autopilot (always-on profile + next-run preview + savings ledger) + r.Route("/charge-autopilot", func(r chi.Router) { + r.Get("/profile", chargeAutopilotHandler.GetProfile) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Put("/profile", chargeAutopilotHandler.UpsertProfile) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/preview", chargeAutopilotHandler.Preview) + r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/run", chargeAutopilotRunHandler.Run) + r.Get("/savings", chargeAutopilotHandler.Savings) + r.Get("/decision", nextChargeHandler.Get) + }) + + // OCPP (non-Tesla charge points + sessions recorded by cmd/ocpp-server) + r.Route("/ocpp", func(r chi.Router) { + r.Get("/charge-points", ocppHandler.ListChargePoints) + r.Get("/sessions", ocppHandler.ListSessions) + }) + + // Storm Guardian (severe-weather auto-prep; evaluator runs hourly in app) + r.Route("/stormguard", func(r chi.Router) { + r.Get("/status", stormguardHandler.Status) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Put("/config", stormguardHandler.UpsertConfig) + r.Get("/events", stormguardHandler.Events) + }) + + // Cabin Comfort (calendar-aware preconditioning; evaluator runs every 5m in app) + r.Route("/comfort", func(r chi.Router) { + r.Get("/next", comfortHandler.Next) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Put("/config", comfortHandler.UpsertConfig) + r.With(httprate.LimitByIP(5, 1*time.Minute)).Post("/now", comfortHandler.PreconditionNow) + r.Get("/runs", comfortHandler.Runs) + }) + + // Wait Oracle (Supercharger wait forecast from fleet history; read-only) + r.Route("/waitoracle", func(r chi.Router) { + r.Get("/sites", waitoracleHandler.Sites) + r.Get("/forecast", waitoracleHandler.Forecast) + }) + + // Journey Autopilot (trip sessions + versioned plans) + r.Route("/journey", func(r chi.Router) { + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/sessions", journeyHandler.Create) + r.Get("/sessions", journeyHandler.List) + r.Get("/sessions/{id}", journeyHandler.Get) + r.With(httprate.LimitByIP(30, 1*time.Minute)).Post("/sessions/{id}/start", journeyHandler.Start) + r.With(httprate.LimitByIP(30, 1*time.Minute)).Post("/sessions/{id}/pause", journeyHandler.Pause) + r.With(httprate.LimitByIP(30, 1*time.Minute)).Post("/sessions/{id}/resume", journeyHandler.Resume) + r.With(httprate.LimitByIP(30, 1*time.Minute)).Post("/sessions/{id}/complete", journeyHandler.Complete) + r.With(httprate.LimitByIP(30, 1*time.Minute)).Post("/sessions/{id}/abort", journeyHandler.Abort) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/sessions/{id}/plans", journeyHandler.SavePlan) + }) + // Trip Planner (route planning with charging stop estimation) r.Route("/trip-planner", func(r chi.Router) { r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/plan", tripPlannerHandler.Plan) + r.With(httprate.LimitByIP(20, 1*time.Minute)).Post("/confidence", tripPlannerHandler.Confidence) }) // Geocoding (forward address search + reverse coordinate lookup) @@ -3931,6 +4056,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Use(httprate.LimitByIP(60, 1*time.Minute)) r.Get("/", vampireDrainHandler.Events) r.Get("/stats", vampireDrainHandler.Stats) + r.Get("/watch", vampireDrainHandler.Watch) }) // Visited Locations diff --git a/internal/api/serviceintelligence/claim.go b/internal/api/serviceintelligence/claim.go new file mode 100644 index 0000000000..7d05351c14 --- /dev/null +++ b/internal/api/serviceintelligence/claim.go @@ -0,0 +1,240 @@ +package serviceintelligence + +import ( + "fmt" + "math" + "net/http" + "sort" + "strconv" + "strings" + + "go.opentelemetry.io/otel" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Claim-draft limits: the ticket stays scannable for a service advisor. +const ( + maxClaimComms = 3 + maxClaimSymptoms = 5 + maxClaimEvidence = 6 + maxIssueChars = 500 +) + +// ClaimCoverage is one applicable warranty line in the draft. +type ClaimCoverage struct { + Name string `json:"name"` + Status string `json:"status"` + DaysRemaining int `json:"days_remaining"` +} + +// ClaimDraft is a ready-to-paste service ticket assembled from the +// owner's issue description, live warranty countdown, matched +// manufacturer communications, ranked symptoms, and evidence. +type ClaimDraft struct { + Subject string `json:"subject"` + Issue string `json:"issue"` + Vehicle string `json:"vehicle"` + Coverages []ClaimCoverage `json:"coverages"` + Comms []string `json:"communications"` + Symptoms []string `json:"symptoms"` + Evidence []string `json:"evidence"` + Ask string `json:"ask"` + Body string `json:"body"` + Disclaimer string `json:"disclaimer"` +} + +// BuildClaimDraft assembles the draft. Pure: no I/O, deterministic. +// Empty issue yields a template ticket the owner completes by hand. +func BuildClaimDraft(issue string, outlook *WarrantyOutlook, resp *Response) ClaimDraft { + issue = strings.TrimSpace(issue) + if len(issue) > maxIssueChars { + issue = issue[:maxIssueChars] + } + d := ClaimDraft{ + Issue: issue, + Coverages: []ClaimCoverage{}, + Comms: []string{}, + Symptoms: []string{}, + Evidence: []string{}, + Disclaimer: "Auto-drafted by TeslaSync from your vehicle data. Verify coverage " + + "with Tesla before your appointment — terms vary by region and trim.", + } + if outlook != nil { + d.Vehicle = fmt.Sprintf("%s (%d)", outlook.Model, outlook.ModelYear) + for _, c := range outlook.Coverages { + d.Coverages = append(d.Coverages, ClaimCoverage{ + Name: c.Name, Status: c.Status, DaysRemaining: c.DaysRemaining, + }) + } + } + if resp != nil { + d.Comms = topComms(resp.Communications) + d.Symptoms = topSymptoms(resp.RankedSymptoms) + d.Evidence = topEvidence(resp.Evidence.Items) + if d.Vehicle == "" && resp.VehicleContext.Model != "" { + d.Vehicle = fmt.Sprintf("%s %s (%d)", + resp.VehicleContext.Make, resp.VehicleContext.Model, resp.VehicleContext.ModelYear) + } + } + if issue == "" { + d.Subject = "Service request — issue description needed" + } else { + d.Subject = "Service request: " + firstSentence(issue) + } + d.Ask = buildAsk(d.Coverages, len(d.Comms) > 0) + d.Body = renderClaimBody(d) + return d +} + +func topComms(comms []CommunicationFinding) []string { + sorted := append([]CommunicationFinding(nil), comms...) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Confidence > sorted[j].Confidence }) + out := []string{} + for _, c := range sorted { + if len(out) >= maxClaimComms { + break + } + line := fmt.Sprintf("TSB %s (%s): %s", c.CommunicationNumber, c.Component, oneLine(c.Summary)) + if c.SourceDocumentURL != "" { + line += " — " + c.SourceDocumentURL + } + out = append(out, line) + } + return out +} + +func topSymptoms(symptoms []SymptomMatch) []string { + sorted := append([]SymptomMatch(nil), symptoms...) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Score > sorted[j].Score }) + out := []string{} + for _, s := range sorted { + if len(out) >= maxClaimSymptoms { + break + } + ts := s.ObservedAt.Format("2006-01-02") + out = append(out, fmt.Sprintf("%s on %s (%s, observed %s)", s.Signal, s.Component, s.Severity, ts)) + } + return out +} + +func topEvidence(items []EvidenceItem) []string { + out := []string{} + for _, e := range items { + if len(out) >= maxClaimEvidence { + break + } + line := fmt.Sprintf("%s: %s", e.Title, oneLine(e.Summary)) + if e.SourceDocumentURL != nil && *e.SourceDocumentURL != "" { + line += " — " + *e.SourceDocumentURL + } + out = append(out, line) + } + return out +} + +func buildAsk(coverages []ClaimCoverage, hasComms bool) string { + active := []string{} + for _, c := range coverages { + if c.Status != "expired" { + active = append(active, fmt.Sprintf("%s (%d days left)", c.Name, c.DaysRemaining)) + } + } + var b strings.Builder + b.WriteString("Please diagnose the issue above") + if len(active) > 0 { + b.WriteString(" under " + strings.Join(active, " / ")) + } else { + b.WriteString("; all Tesla coverages appear expired, so please quote out-of-warranty repair") + } + if hasComms { + b.WriteString(", checking the listed manufacturer communications for an applicable bulletin fix") + } + b.WriteString(".") + return b.String() +} + +func renderClaimBody(d ClaimDraft) string { + var b strings.Builder + fmt.Fprintf(&b, "Subject: %s\n\n", d.Subject) + if d.Vehicle != "" { + fmt.Fprintf(&b, "Vehicle: %s\n\n", d.Vehicle) + } + if d.Issue != "" { + fmt.Fprintf(&b, "Issue:\n%s\n\n", d.Issue) + } + if len(d.Coverages) > 0 { + b.WriteString("Warranty status:\n") + for _, c := range d.Coverages { + fmt.Fprintf(&b, "- %s: %s (%d days remaining)\n", c.Name, c.Status, c.DaysRemaining) + } + b.WriteString("\n") + } + writeList := func(title string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(&b, "%s:\n", title) + for _, it := range items { + fmt.Fprintf(&b, "- %s\n", it) + } + b.WriteString("\n") + } + writeList("Related manufacturer communications", d.Comms) + writeList("Observed symptoms", d.Symptoms) + writeList("Supporting evidence", d.Evidence) + fmt.Fprintf(&b, "Requested action:\n%s\n\n%s\n", d.Ask, d.Disclaimer) + return b.String() +} + +func firstSentence(s string) string { + for i, r := range s { + if r == '.' || r == '!' || r == '?' || r == '\n' { + return strings.TrimSpace(s[:i]) + } + } + return s +} + +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// ClaimDraftHandler serves GET +// /service-intelligence/vehicles/{vehicleID}/claim-draft?issue=&odometer_km=. +func (h *Handler) ClaimDraftHandler(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer("api").Start(r.Context(), "service_intelligence.claim_draft") + defer span.End() + r = r.WithContext(ctx) + + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + odometerKm := -1.0 + if s := r.URL.Query().Get("odometer_km"); s != "" { + v, err := strconv.ParseFloat(s, 64) + if err != nil || v < 0 || math.IsNaN(v) { + httpx.WriteError(w, http.StatusBadRequest, "odometer_km must be a non-negative number") + return + } + odometerKm = v + } + + resp, err := h.service.Get(ctx, vehicleID, false) + if err != nil { + h.writeServiceError(w, ctx, span, vehicleID, err) + return + } + outlook, err := h.service.Warranty(ctx, vehicleID, odometerKm) + if err != nil { + h.writeServiceError(w, ctx, span, vehicleID, err) + return + } + + draft := BuildClaimDraft(r.URL.Query().Get("issue"), outlook, resp) + w.Header().Set("Cache-Control", endpointCacheControl) + httpx.WriteJSON(w, http.StatusOK, draft) +} diff --git a/internal/api/serviceintelligence/claim_test.go b/internal/api/serviceintelligence/claim_test.go new file mode 100644 index 0000000000..48a368f47c --- /dev/null +++ b/internal/api/serviceintelligence/claim_test.go @@ -0,0 +1,135 @@ +package serviceintelligence + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func claimTestOutlook() *WarrantyOutlook { + return &WarrantyOutlook{ + VehicleID: 42, Model: "Model 3", ModelYear: 2019, + Coverages: []WarrantyCoverage{ + {Name: "Basic Limited", Status: "expired", DaysRemaining: -100}, + {Name: "Battery & Drive Unit", Status: "active", DaysRemaining: 900}, + }, + } +} + +func claimTestResponse() *Response { + resp := handlerResponse() + resp.Communications = []CommunicationFinding{ + {ID: "c1", CommunicationNumber: "SB-21-12-001", Component: "HV Battery", + Summary: "Battery contactor inspection", Confidence: 0.9, + SourceDocumentURL: "https://example.com/tsb1"}, + {ID: "c2", CommunicationNumber: "SB-20-01-003", Component: "Suspension", + Summary: "Control arm torque", Confidence: 0.4}, + } + resp.RankedSymptoms = []SymptomMatch{ + {Signal: "charge_rate_drop", Component: "HV Battery", Severity: "high", + ObservedAt: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC), Score: 0.8}, + } + doc := "https://example.com/ev1" + resp.Evidence.Items = []EvidenceItem{ + {ID: "e1", Title: "Charge curve anomaly", Summary: " taper at 60%", SourceDocumentURL: &doc}, + } + return resp +} + +func TestBuildClaimDraft(t *testing.T) { + d := BuildClaimDraft("Charge rate drops after 60%. Happens daily.", claimTestOutlook(), claimTestResponse()) + + if d.Subject != "Service request: Charge rate drops after 60%" { + t.Fatalf("subject = %q", d.Subject) + } + if d.Vehicle != "Model 3 (2019)" { + t.Fatalf("vehicle = %q", d.Vehicle) + } + if len(d.Coverages) != 2 { + t.Fatalf("coverages = %d, want 2", len(d.Coverages)) + } + if len(d.Comms) != 2 || !strings.Contains(d.Comms[0], "SB-21-12-001") { + t.Fatalf("comms = %v, want confidence-ordered", d.Comms) + } + if len(d.Symptoms) != 1 || !strings.Contains(d.Symptoms[0], "charge_rate_drop") { + t.Fatalf("symptoms = %v", d.Symptoms) + } + if len(d.Evidence) != 1 || !strings.Contains(d.Evidence[0], "taper at 60%") { + t.Fatalf("evidence = %v, want one-lined", d.Evidence) + } + if !strings.Contains(d.Ask, "Battery & Drive Unit") || strings.Contains(d.Ask, "Basic Limited") { + t.Fatalf("ask = %q, want active coverage only", d.Ask) + } + for _, want := range []string{"Subject:", "Vehicle:", "Issue:", "Warranty status:", + "Related manufacturer communications", "Requested action:", "Verify coverage"} { + if !strings.Contains(d.Body, want) { + t.Fatalf("body missing %q:\n%s", want, d.Body) + } + } +} + +func TestBuildClaimDraftEmptyIssue(t *testing.T) { + d := BuildClaimDraft(" ", claimTestOutlook(), claimTestResponse()) + if d.Subject != "Service request — issue description needed" { + t.Fatalf("subject = %q", d.Subject) + } + if strings.Contains(d.Body, "Issue:\n") { + t.Fatal("body should omit the empty issue section") + } +} + +func TestBuildClaimDraftExpired(t *testing.T) { + outlook := &WarrantyOutlook{VehicleID: 42, Model: "Model S", ModelYear: 2015, + Coverages: []WarrantyCoverage{{Name: "Basic Limited", Status: "expired"}}} + d := BuildClaimDraft("rattle", outlook, handlerResponse()) + if !strings.Contains(d.Ask, "out-of-warranty") { + t.Fatalf("ask = %q, want out-of-warranty quote", d.Ask) + } +} + +func TestClaimDraftHandler(t *testing.T) { + svc := &fakeIntelligenceService{response: claimTestResponse(), warranty: claimTestOutlook()} + h := mountedHandler(svc) + + target := "/service-intelligence/vehicles/42/claim-draft?issue=" + url.QueryEscape("Charge rate drops.") + "&odometer_km=80000" + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var draft ClaimDraft + if err := json.Unmarshal(rec.Body.Bytes(), &draft); err != nil { + t.Fatalf("decode: %v", err) + } + if draft.Subject != "Service request: Charge rate drops" { + t.Fatalf("subject = %q", draft.Subject) + } + if svc.warrantyOdo != 80000 { + t.Fatalf("odometer = %v, want 80000", svc.warrantyOdo) + } + if rec.Header().Get("Cache-Control") == "" { + t.Fatal("missing cache-control") + } +} + +func TestClaimDraftHandlerErrors(t *testing.T) { + svc := &fakeIntelligenceService{response: claimTestResponse(), warranty: claimTestOutlook()} + h := mountedHandler(svc) + for _, target := range []string{ + "/service-intelligence/vehicles/nope/claim-draft", + "/service-intelligence/vehicles/42/claim-draft?odometer_km=abc", + "/service-intelligence/vehicles/42/claim-draft?odometer_km=-5", + } { + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s status = %d, want 400", target, rec.Code) + } + } +} diff --git a/internal/api/serviceintelligence/handler.go b/internal/api/serviceintelligence/handler.go index a0dc965aae..50ae141bdb 100644 --- a/internal/api/serviceintelligence/handler.go +++ b/internal/api/serviceintelligence/handler.go @@ -38,6 +38,8 @@ func NewServiceIntelligenceHandler(service IntelligenceService) *Handler { // group. Authentication remains owned by the parent route group. func Mount(r chi.Router, handler *Handler) { r.Get("/service-intelligence/vehicles/{vehicleID}", handler.Get) + r.Get("/service-intelligence/vehicles/{vehicleID}/warranty", handler.WarrantyHandler) + r.Get("/service-intelligence/vehicles/{vehicleID}/claim-draft", handler.ClaimDraftHandler) } // Get serves GET /api/v1/service-intelligence/vehicles/{vehicleID}?refresh=false. diff --git a/internal/api/serviceintelligence/handler_test.go b/internal/api/serviceintelligence/handler_test.go index 1cf95309c1..2e8aa30869 100644 --- a/internal/api/serviceintelligence/handler_test.go +++ b/internal/api/serviceintelligence/handler_test.go @@ -20,6 +20,11 @@ type fakeIntelligenceService struct { calls int vehicleID int64 refresh bool + + warranty *WarrantyOutlook + warrantyErr error + warrantyOdo float64 + warrantySeen bool } func (f *fakeIntelligenceService) Get(_ context.Context, vehicleID int64, refresh bool) (*Response, error) { @@ -29,6 +34,13 @@ func (f *fakeIntelligenceService) Get(_ context.Context, vehicleID int64, refres return f.response, f.err } +func (f *fakeIntelligenceService) Warranty(_ context.Context, vehicleID int64, odometerKm float64) (*WarrantyOutlook, error) { + f.warrantySeen = true + f.vehicleID = vehicleID + f.warrantyOdo = odometerKm + return f.warranty, f.warrantyErr +} + func handlerResponse() *Response { return &Response{ VehicleID: 42, diff --git a/internal/api/serviceintelligence/types.go b/internal/api/serviceintelligence/types.go index 468d368daf..6bb647b2b2 100644 --- a/internal/api/serviceintelligence/types.go +++ b/internal/api/serviceintelligence/types.go @@ -35,6 +35,7 @@ type ObservationReader interface { type IntelligenceService interface { Get(ctx context.Context, vehicleID int64, refresh bool) (*Response, error) + Warranty(ctx context.Context, vehicleID int64, odometerKm float64) (*WarrantyOutlook, error) } type Response struct { diff --git a/internal/api/serviceintelligence/warranty.go b/internal/api/serviceintelligence/warranty.go new file mode 100644 index 0000000000..91d486b64c --- /dev/null +++ b/internal/api/serviceintelligence/warranty.go @@ -0,0 +1,175 @@ +package serviceintelligence + +import ( + "context" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" + + "go.opentelemetry.io/otel" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/integrations/nhtsa" +) + +// Tesla warranty terms (US). Start date is the delivery date, which +// TeslaSync does not know — the outlook conservatively counts from +// January 1 of the model year and says so, so real coverage can only be +// longer than shown. +const ( + basicYears = 4 + basicKm = 80467.0 // 50,000 mi + batteryYears = 8 + batteryKmS3RY = 160934.0 // Model 3 RWD / Model Y RWD: 100,000 mi + batteryKmLR = 192000.0 // Model 3/Y Long Range: 120,000 mi (rounded) + batteryKmSX = 241402.0 // Model S/X: 150,000 mi +) + +// WarrantyCoverage is one countdown: time leg always, mileage leg only when +// the caller supplies an odometer reading. +type WarrantyCoverage struct { + Name string `json:"name"` + ExpiresAt string `json:"expires_at"` + DaysRemaining int `json:"days_remaining"` + KmLimit *float64 `json:"km_limit"` + KmRemaining *float64 `json:"km_remaining"` + Status string `json:"status"` // active | expiring_soon | expired + Basis string `json:"basis"` +} + +// WarrantyOutlook is the GET .../warranty response. +type WarrantyOutlook struct { + VehicleID int64 `json:"vehicle_id"` + Model string `json:"model"` + ModelYear int `json:"model_year"` + Coverages []WarrantyCoverage `json:"coverages"` + Assumption string `json:"assumption"` +} + +// WarrantyOutlookFor is the pure countdown over a model + model year. +// odometerKm < 0 (or NaN) means unknown: mileage legs are omitted rather +// than guessed. +func WarrantyOutlookFor(vehicleID int64, model string, modelYear int, odometerKm float64, now time.Time) WarrantyOutlook { + out := WarrantyOutlook{ + VehicleID: vehicleID, + Model: model, + ModelYear: modelYear, + Coverages: []WarrantyCoverage{}, + Assumption: "Counted from January 1 of the model year (delivery date unknown) — actual coverage runs longer.", + } + if modelYear <= 0 { + return out + } + start := time.Date(modelYear, 1, 1, 0, 0, 0, 0, time.UTC) + out.Coverages = append(out.Coverages, + coverage("Basic Limited", start.AddDate(basicYears, 0, 0), basicKm, odometerKm, now), + coverage("Battery & Drive Unit", start.AddDate(batteryYears, 0, 0), batteryKmFor(model), odometerKm, now), + ) + return out +} + +// batteryKmFor maps the model to its battery/drive-unit mileage cap. +// Unknown trims map to the lowest cap (conservative) and say so. +func batteryKmFor(model string) float64 { + m := strings.ToLower(strings.TrimSpace(model)) + switch { + case strings.Contains(m, "model s"), m == "s", + strings.Contains(m, "model x"), m == "x": + return batteryKmSX + case strings.Contains(m, "model 3"), m == "3": + if strings.Contains(m, "long range") || strings.Contains(m, "performance") { + return batteryKmLR + } + return batteryKmS3RY + case strings.Contains(m, "model y"), m == "y", + strings.Contains(m, "cybertruck"): + return batteryKmLR + default: + return batteryKmS3RY + } +} + +func coverage(name string, expires time.Time, kmLimit, odometerKm float64, now time.Time) WarrantyCoverage { + c := WarrantyCoverage{ + Name: name, + ExpiresAt: expires.Format("2006-01-02"), + Basis: "time", + } + days := int(math.Floor(expires.Sub(now).Hours() / 24)) + c.DaysRemaining = days + if odometerKm >= 0 && !math.IsNaN(odometerKm) { + limit, rem := kmLimit, kmLimit-odometerKm + c.KmLimit, c.KmRemaining = &limit, &rem + if rem < 0 { + c.DaysRemaining = 0 + } + } + switch { + case days < 0 || (c.KmRemaining != nil && *c.KmRemaining < 0): + c.Status = "expired" + if c.KmRemaining != nil && *c.KmRemaining < 0 && days >= 0 { + c.Basis = "mileage" + } + case days <= 180 || (c.KmRemaining != nil && *c.KmRemaining <= 8000): + c.Status = "expiring_soon" + default: + c.Status = "active" + } + return c +} + +// Warranty resolves the vehicle's decoded model/year and returns the +// coverage countdown. odometerKm < 0 means unknown (time-only outlook). +func (s *Service) Warranty(ctx context.Context, vehicleID int64, odometerKm float64) (*WarrantyOutlook, error) { + if vehicleID <= 0 { + return nil, ErrInvalidVehicle + } + if s == nil || s.vehicles == nil || s.nhtsa == nil { + return nil, fmt.Errorf("service intelligence dependencies are not configured") + } + vehicle, err := s.vehicles.GetVehicleMetadata(ctx, vehicleID) + if err != nil { + return nil, fmt.Errorf("load service-intelligence vehicle %d: %w", vehicleID, err) + } + if vehicle == nil { + return nil, ErrVehicleNotFound + } + decoded, err := s.nhtsa.DecodeVIN(ctx, vehicle.VIN, nhtsa.FetchOptions{}) + if err != nil { + return nil, fmt.Errorf("decode service-intelligence vehicle %d: %w", vehicleID, err) + } + out := WarrantyOutlookFor(vehicleID, decoded.Vehicle.Model, decoded.Vehicle.ModelYear, odometerKm, s.now().UTC()) + return &out, nil +} + +// WarrantyHandler serves GET /service-intelligence/vehicles/{vehicleID}/warranty?odometer_km=. +func (h *Handler) WarrantyHandler(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer("api").Start(r.Context(), "service_intelligence.warranty") + defer span.End() + r = r.WithContext(ctx) + + vehicleID, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "invalid vehicle ID") + return + } + odometerKm := -1.0 + if s := r.URL.Query().Get("odometer_km"); s != "" { + v, err := strconv.ParseFloat(s, 64) + if err != nil || v < 0 || math.IsNaN(v) { + httpx.WriteError(w, http.StatusBadRequest, "odometer_km must be a non-negative number") + return + } + odometerKm = v + } + out, err := h.service.Warranty(ctx, vehicleID, odometerKm) + if err != nil { + h.writeServiceError(w, ctx, span, vehicleID, err) + return + } + httpx.WriteJSON(w, http.StatusOK, out) +} diff --git a/internal/api/serviceintelligence/warranty_test.go b/internal/api/serviceintelligence/warranty_test.go new file mode 100644 index 0000000000..ead4c08bee --- /dev/null +++ b/internal/api/serviceintelligence/warranty_test.go @@ -0,0 +1,86 @@ +package serviceintelligence + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestWarrantyOutlookActive(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + out := WarrantyOutlookFor(9, "Model Y", 2024, 30000, now) + if len(out.Coverages) != 2 { + t.Fatalf("coverages = %d, want 2", len(out.Coverages)) + } + if out.Coverages[0].Status != "active" || out.Coverages[1].Status != "active" { + t.Fatalf("unexpected outlook: %+v", out.Coverages) + } + if out.Coverages[1].KmRemaining == nil || *out.Coverages[1].KmRemaining <= 0 { + t.Fatalf("battery mileage leg = %+v", out.Coverages[1]) + } + if out.Assumption == "" { + t.Fatal("expected the delivery-date assumption to be disclosed") + } +} + +func TestWarrantyOutlookExpiredByTime(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + out := WarrantyOutlookFor(9, "Model 3", 2019, 60000, now) + if out.Coverages[0].Status != "expired" { + t.Fatalf("basic = %+v, want expired", out.Coverages[0]) + } + if out.Coverages[1].Status == "expired" { + t.Fatalf("battery = %+v, want still active", out.Coverages[1]) + } +} + +func TestWarrantyOutlookExpiredByMileage(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + out := WarrantyOutlookFor(9, "Model 3", 2024, 100000, now) + if out.Coverages[0].Status != "expired" || out.Coverages[0].Basis != "mileage" { + t.Fatalf("basic = %+v, want mileage-expired", out.Coverages[0]) + } +} + +func TestWarrantyOutlookTimeOnly(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + out := WarrantyOutlookFor(9, "Model S", 2024, -1, now) + if out.Coverages[0].KmLimit != nil || out.Coverages[0].KmRemaining != nil { + t.Fatalf("unknown odometer must omit mileage legs: %+v", out.Coverages[0]) + } + if out.Coverages[0].Status != "active" { + t.Fatalf("basic = %+v, want active", out.Coverages[0]) + } +} + +func TestWarrantyHandlerServesOutlook(t *testing.T) { + svc := &fakeIntelligenceService{warranty: &WarrantyOutlook{VehicleID: 7, Model: "Model Y", ModelYear: 2024}} + req := httptest.NewRequest(http.MethodGet, "/service-intelligence/vehicles/7/warranty?odometer_km=30000", nil) + rec := httptest.NewRecorder() + mountedHandler(svc).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var out WarrantyOutlook + if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out.VehicleID != 7 || !svc.warrantySeen || svc.warrantyOdo != 30000 { + t.Fatalf("unexpected call: %+v (%v)", out, svc.warrantyOdo) + } +} + +func TestWarrantyHandlerRejectsBadOdometer(t *testing.T) { + svc := &fakeIntelligenceService{} + req := httptest.NewRequest(http.MethodGet, "/service-intelligence/vehicles/7/warranty?odometer_km=nope", nil) + rec := httptest.NewRecorder() + mountedHandler(svc).ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if svc.warrantySeen { + t.Fatal("service must not be called for invalid input") + } +} diff --git a/internal/api/share/handler.go b/internal/api/share/handler.go index 66571bb11f..80f6a33e89 100644 --- a/internal/api/share/handler.go +++ b/internal/api/share/handler.go @@ -12,6 +12,7 @@ import ( "github.com/ev-dev-labs/teslasync/internal/api/apiparams" "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" drivedb "github.com/ev-dev-labs/teslasync/internal/database/drive" positiondb "github.com/ev-dev-labs/teslasync/internal/database/position" "github.com/ev-dev-labs/teslasync/internal/database/sharing" @@ -19,11 +20,12 @@ import ( drivemodel "github.com/ev-dev-labs/teslasync/internal/models/drive" telemetrymodel "github.com/ev-dev-labs/teslasync/internal/models/telemetry" vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" + "github.com/ev-dev-labs/teslasync/internal/signal" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" ) -// The handler depends on four narrow persistence ports rather than concrete +// The handler depends on six narrow persistence ports rather than concrete // repos so every path can be exercised end-to-end with in-memory fakes and no // pgx pool. In production each port is satisfied by its repository: // @@ -31,12 +33,15 @@ import ( // driveByIDFetcher <- *drivedb.DriveRepo // positionLister <- *positiondb.PositionRepo // vehicleByIDFetcher <- *vehicledb.VehicleRepo +// sessionByIDFetcher <- *chargingdb.ChargingRepo +// chargeCurveLister <- *SignalCurveLister (signal change feed) // shareTokenStore is the persistence port for share tokens. type shareTokenStore interface { Create(ctx context.Context, st *drivemodel.ShareToken) error GetByToken(ctx context.Context, token string) (*drivemodel.ShareToken, error) ListByDrive(ctx context.Context, driveID int64) ([]*drivemodel.ShareToken, error) + ListByChargingSession(ctx context.Context, sessionID int64) ([]*drivemodel.ShareToken, error) IncrementViews(ctx context.Context, id int64) error Delete(ctx context.Context, token string) error } @@ -62,14 +67,18 @@ type ShareHandler struct { driveRepo driveByIDFetcher posRepo positionLister vehicleRepo vehicleByIDFetcher + sessionRepo sessionByIDFetcher + curveLister chargeCurveLister } -func NewShareHandler(db *database.DB) *ShareHandler { +func NewShareHandler(db *database.DB, state signal.StateReader) *ShareHandler { return &ShareHandler{ shareRepo: sharing.NewTokenRepo(db), driveRepo: drivedb.NewDriveRepo(db), posRepo: positiondb.NewPositionRepo(db), vehicleRepo: vehicledb.NewVehicleRepo(db), + sessionRepo: chargingdb.NewChargingRepo(db), + curveLister: NewSignalCurveLister(state), } } @@ -117,9 +126,11 @@ type publicTelemetryPoint struct { type publicShareResponse struct { PayloadVersion string `json:"payload_version"` + ShareType string `json:"share_type"` Title string `json:"title"` Description string `json:"description"` - Drive publicDriveInfo `json:"drive"` + Drive *publicDriveInfo `json:"drive,omitempty"` + Session *publicSessionInfo `json:"session,omitempty"` Vehicle *publicVehicle `json:"vehicle,omitempty"` MapPoints []publicMapPoint `json:"map_points,omitempty"` ElevationProfile []publicElevationPoint `json:"elevation_profile,omitempty"` @@ -184,17 +195,7 @@ func (h *ShareHandler) Create(w http.ResponseWriter, r *http.Request) { if req.Description != "" { st.Description = &req.Description } - if req.ExpiresInDays > 0 { - // Clamp before the duration multiply: an unbounded day count overflows - // int64 nanoseconds and would wrap to a past instant, silently creating - // an already-expired ("410 Gone") share. - days := req.ExpiresInDays - if days > maxExpiryDays { - days = maxExpiryDays - } - exp := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) - st.ExpiresAt = &exp - } + st.ExpiresAt = expiryFromDays(req.ExpiresInDays) if err := h.shareRepo.Create(ctx, st); err != nil { log.Error().Err(err).Int64("driveID", driveID).Msg("share: failed to create") @@ -214,6 +215,108 @@ func (h *ShareHandler) Create(w http.ResponseWriter, r *http.Request) { }) } +// expiryFromDays converts an optional day count to an absolute expiry, +// clamped to maxExpiryDays. The clamp runs before the duration multiply: +// an unbounded day count overflows int64 nanoseconds and would wrap to a +// past instant, silently creating an already-expired ("410 Gone") share. +func expiryFromDays(days int) *time.Time { + if days <= 0 { + return nil + } + if days > maxExpiryDays { + days = maxExpiryDays + } + exp := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour) + return &exp +} + +// CreateSessionShare handles POST /charging/{sessionID}/share: mint a +// public link for a charging session. include_telemetry opts into the +// charge curve + cost; include_map/include_speed are drive-only and +// stored false for sessions. +func (h *ShareHandler) CreateSessionShare(w http.ResponseWriter, r *http.Request) { + sessionID, err := apiparams.URLParamInt64(r, "sessionID") + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid session ID") + return + } + + ctx := r.Context() + + session, err := h.sessionRepo.GetByID(ctx, sessionID) + if err != nil { + log.Error().Err(err).Int64("sessionID", sessionID).Msg("share: failed to get charging session") + httpx.WriteError(w, http.StatusInternalServerError, "failed to get charging session") + return + } + if session == nil { + httpx.WriteError(w, http.StatusNotFound, "charging session not found") + return + } + + var req createShareRequest + // All fields are optional, so an empty body is valid and yields defaults; + // only a malformed (non-empty, non-JSON) body is a 400. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + + includeTelemetry := false + if req.IncludeTelemetry != nil { + includeTelemetry = *req.IncludeTelemetry + } + + st := &drivemodel.ShareToken{ + ChargingSessionID: sessionID, + IncludeTelemetry: includeTelemetry, + } + if req.Title != "" { + st.Title = &req.Title + } + if req.Description != "" { + st.Description = &req.Description + } + st.ExpiresAt = expiryFromDays(req.ExpiresInDays) + + if err := h.shareRepo.Create(ctx, st); err != nil { + log.Error().Err(err).Int64("sessionID", sessionID).Msg("share: failed to create session share") + httpx.WriteError(w, http.StatusInternalServerError, "failed to create share link") + return + } + + log.Info(). + Str("token", truncateToken(st.Token)). + Int64("charging_session_id", sessionID). + Msg("session share link created") + + httpx.WriteJSON(w, http.StatusCreated, map[string]interface{}{ + "token": st.Token, + "url": "/s/" + st.Token, + "id": st.ID, + }) +} + +// ListSessionShares handles GET /charging/{sessionID}/shares. +func (h *ShareHandler) ListSessionShares(w http.ResponseWriter, r *http.Request) { + sessionID, err := apiparams.URLParamInt64(r, "sessionID") + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid session ID") + return + } + + tokens, err := h.shareRepo.ListByChargingSession(r.Context(), sessionID) + if err != nil { + log.Error().Err(err).Int64("sessionID", sessionID).Msg("share: failed to list session shares") + httpx.WriteError(w, http.StatusInternalServerError, "failed to list shares") + return + } + if tokens == nil { + tokens = make([]*drivemodel.ShareToken, 0) + } + httpx.WriteJSON(w, http.StatusOK, tokens) +} + func (h *ShareHandler) List(w http.ResponseWriter, r *http.Request) { driveID, err := apiparams.URLParamInt64(r, "driveID") if err != nil { @@ -280,6 +383,17 @@ func (h *ShareHandler) GetPublicShare(w http.ResponseWriter, r *http.Request) { log.Warn().Err(err).Int64("shareID", share.ID).Msg("share: failed to increment views") } + if share.ChargingSessionID > 0 { + h.serveSessionShare(w, r, share) + return + } + // Unreachable while the exactly-one-target CHECK holds; a defensive + // 404 rather than a drive lookup with a zero ID. + if share.DriveID <= 0 { + httpx.WriteError(w, http.StatusNotFound, "share target missing") + return + } + drive, err := h.driveRepo.GetByID(ctx, share.DriveID) if err != nil || drive == nil { log.Error().Err(err).Int64("driveID", share.DriveID).Msg("share: drive not found") @@ -320,9 +434,10 @@ func (h *ShareHandler) GetPublicShare(w http.ResponseWriter, r *http.Request) { resp := publicShareResponse{ PayloadVersion: "v2", + ShareType: shareTypeDrive, Title: safeDeref(share.Title, "Shared Drive"), Description: safeDeref(share.Description, ""), - Drive: info, + Drive: &info, } // Vehicle info is limited to model and color: no VIN or IDs. diff --git a/internal/api/share/handler_test.go b/internal/api/share/handler_test.go index a0855b78bf..104a9307bb 100644 --- a/internal/api/share/handler_test.go +++ b/internal/api/share/handler_test.go @@ -36,22 +36,25 @@ func TestMain(m *testing.M) { // --------------------------------------------------------------------------- type fakeShareStore struct { - createFn func(ctx context.Context, st *drivemodel.ShareToken) error - getFn func(ctx context.Context, token string) (*drivemodel.ShareToken, error) - listFn func(ctx context.Context, driveID int64) ([]*drivemodel.ShareToken, error) - incFn func(ctx context.Context, id int64) error - deleteFn func(ctx context.Context, token string) error - - createCalls int - created *drivemodel.ShareToken - getCalls int - getToken string - listCalls int - listDriveID int64 - incCalls int - incID int64 - deleteCalls int - deleteToken string + createFn func(ctx context.Context, st *drivemodel.ShareToken) error + getFn func(ctx context.Context, token string) (*drivemodel.ShareToken, error) + listFn func(ctx context.Context, driveID int64) ([]*drivemodel.ShareToken, error) + listSessionFn func(ctx context.Context, sessionID int64) ([]*drivemodel.ShareToken, error) + incFn func(ctx context.Context, id int64) error + deleteFn func(ctx context.Context, token string) error + + createCalls int + created *drivemodel.ShareToken + getCalls int + getToken string + listCalls int + listDriveID int64 + listSessCalls int + listSessionID int64 + incCalls int + incID int64 + deleteCalls int + deleteToken string } func (f *fakeShareStore) Create(ctx context.Context, st *drivemodel.ShareToken) error { @@ -84,6 +87,15 @@ func (f *fakeShareStore) ListByDrive(ctx context.Context, driveID int64) ([]*dri return f.listFn(ctx, driveID) } +func (f *fakeShareStore) ListByChargingSession(ctx context.Context, sessionID int64) ([]*drivemodel.ShareToken, error) { + f.listSessCalls++ + f.listSessionID = sessionID + if f.listSessionFn == nil { + return nil, nil + } + return f.listSessionFn(ctx, sessionID) +} + func (f *fakeShareStore) IncrementViews(ctx context.Context, id int64) error { f.incCalls++ f.incID = id diff --git a/internal/api/share/session.go b/internal/api/share/session.go new file mode 100644 index 0000000000..4c9bc31e2f --- /dev/null +++ b/internal/api/share/session.go @@ -0,0 +1,242 @@ +package share + +import ( + "context" + "net/http" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging" + drivemodel "github.com/ev-dev-labs/teslasync/internal/models/drive" + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +// Session share links: the same token system as drives, targeting a +// charging session. The public payload is a PII-filtered summary (no +// coordinates, no VIN/IDs) plus an optional downsampled charge curve and +// cost when include_telemetry is set. include_map/include_speed are +// drive-only and ignored for sessions. + +// shareTypeDrive and shareTypeSession discriminate the public payload so +// one /s/:token route serves both link kinds. +const ( + shareTypeDrive = "drive" + shareTypeSession = "charging_session" +) + +// maxCurvePoints caps the public charge curve so a long session cannot +// produce a megabyte-sized share payload. +const maxCurvePoints = 240 + +type publicSessionInfo struct { + Date string `json:"date"` + DurationS int64 `json:"duration_s"` + EnergyAddedWh *float64 `json:"energy_added_wh,omitempty"` + StartSocPct *float64 `json:"start_soc_pct,omitempty"` + EndSocPct *float64 `json:"end_soc_pct,omitempty"` + ChargerType string `json:"charger_type"` + Place string `json:"place"` + PeakPowerW *float64 `json:"peak_power_w,omitempty"` + AvgPowerW *float64 `json:"avg_power_w,omitempty"` + Cost *float64 `json:"cost,omitempty"` + CostCurrency string `json:"cost_currency,omitempty"` + Curve []publicCurvePoint `json:"curve,omitempty"` +} + +type publicCurvePoint struct { + OffsetS int64 `json:"t_s"` + PowerKW *float64 `json:"power_kw,omitempty"` + BatteryPct *float64 `json:"battery_pct,omitempty"` + EnergyKWh *float64 `json:"energy_kwh,omitempty"` +} + +// sessionByIDFetcher fetches a single charging session. +type sessionByIDFetcher interface { + GetByID(ctx context.Context, id int64) (*chargingmodel.ChargingSession, error) +} + +// chargeCurveLister returns downsampled charge-curve points over a +// session window. *SignalCurveLister satisfies it. +type chargeCurveLister interface { + SessionCurve(ctx context.Context, vehicleID int64, from, to time.Time) ([]publicCurvePoint, error) +} + +// shareCurveFieldMappings projects the signal_log change feed into the +// public curve. AC/DC pairs merge downstream (DC wins when positive), +// mirroring the authenticated telemetry endpoint. +var shareCurveFieldMappings = []signal.FieldMapping{ + {Signal: "BatteryLevel", Field: "battery_level"}, + {Signal: "ACChargingPower", Field: "power_kw"}, + {Signal: "DCChargingPower", Field: "dc_power_w"}, + {Signal: "ACChargingEnergyIn", Field: "energy_added"}, + {Signal: "DCChargingEnergyIn", Field: "dc_energy_wh"}, +} + +// SignalCurveLister builds public charge curves from the signal change +// feed. Stateless; safe for concurrent use. +type SignalCurveLister struct { + state signal.StateReader +} + +// NewSignalCurveLister wires the lister. A nil reader is a wiring bug. +func NewSignalCurveLister(state signal.StateReader) *SignalCurveLister { + if state == nil { + panic("share.NewSignalCurveLister: state must not be nil") + } + return &SignalCurveLister{state: state} +} + +var _ chargeCurveLister = (*SignalCurveLister)(nil) + +// SessionCurve returns the downsampled public curve for a session window. +// Canonical feed units are W/Wh; the wire contract is kW/kWh, converted +// strictly at this boundary. +func (l *SignalCurveLister) SessionCurve(ctx context.Context, vehicleID int64, from, to time.Time) ([]publicCurvePoint, error) { + rows, err := l.state.Timeline(ctx, vehicleID, shareCurveFieldMappings, from, to, signal.TimelineOptions{}) + if err != nil { + return nil, err + } + pts := make([]publicCurvePoint, 0, len(rows)) + for _, row := range rows { + pt := publicCurvePoint{OffsetS: int64(row.Timestamp.Sub(from).Seconds())} + if v, ok := mergedPowerKW(row); ok { + v := v + pt.PowerKW = &v + } + if v, ok := signal.Float64(row.Fields["battery_level"]); ok { + v := v + pt.BatteryPct = &v + } + if v, ok := mergedEnergyKWh(row); ok { + v := v + pt.EnergyKWh = &v + } + pts = append(pts, pt) + } + return downsampleCurve(pts, maxCurvePoints), nil +} + +// mergedPowerKW prefers DC power when positive, else AC. Both feed +// values are watts; the result is kilowatts. +func mergedPowerKW(row signal.TimelineRow) (float64, bool) { + if v, ok := signal.Float64(row.Fields["dc_power_w"]); ok && v > 0 { + return v / 1000.0, true + } + if v, ok := signal.Float64(row.Fields["power_kw"]); ok { + return v / 1000.0, true + } + return 0, false +} + +// mergedEnergyKWh prefers DC energy when positive, else AC. Both feed +// values are watt-hours; the result is kilowatt-hours. +func mergedEnergyKWh(row signal.TimelineRow) (float64, bool) { + if v, ok := signal.Float64(row.Fields["dc_energy_wh"]); ok && v > 0 { + return v / 1000.0, true + } + if v, ok := signal.Float64(row.Fields["energy_added"]); ok { + return v / 1000.0, true + } + return 0, false +} + +// downsampleCurve thins pts to at most max points by even stride, always +// keeping the first and last points so the curve endpoints stay exact. +// Pure: no I/O. +func downsampleCurve(pts []publicCurvePoint, max int) []publicCurvePoint { + if max < 2 { + max = 2 + } + if len(pts) <= max { + return pts + } + out := make([]publicCurvePoint, 0, max) + stride := float64(len(pts)-1) / float64(max-1) + for i := 0; i < max; i++ { + out = append(out, pts[int(float64(i)*stride+0.5)]) + } + return out +} + +// sessionDurationS returns the session length in seconds, clamping +// negative clock skew to zero and open sessions to now. +func sessionDurationS(s *chargingmodel.ChargingSession, now time.Time) int64 { + end := now + if s.EndedAt != nil { + end = *s.EndedAt + } + d := int64(end.Sub(s.StartedAt).Seconds()) + if d < 0 { + return 0 + } + return d +} + +func logSessionCurveErr(err error, sessionID int64) { + log.Warn().Err(err).Int64("sessionID", sessionID).Msg("share: session curve unavailable, serving summary") +} + +// serveSessionShare renders the public view of a session-target share. +// The summary always serves; the curve + cost require include_telemetry, +// and a curve failure degrades to the summary rather than failing the +// whole share (telemetry retention may have expired it). +func (h *ShareHandler) serveSessionShare(w http.ResponseWriter, r *http.Request, share *drivemodel.ShareToken) { + ctx := r.Context() + + session, err := h.sessionRepo.GetByID(ctx, share.ChargingSessionID) + if err != nil || session == nil { + log.Error().Err(err).Int64("sessionID", share.ChargingSessionID).Msg("share: session not found") + httpx.WriteError(w, http.StatusNotFound, "shared session no longer exists") + return + } + + now := time.Now().UTC() + info := publicSessionInfo{ + Date: session.StartedAt.Format("2006-01-02"), + DurationS: sessionDurationS(session, now), + EnergyAddedWh: session.TotalEnergyAddedWh, + StartSocPct: session.StartSocPct, + EndSocPct: session.EndSocPct, + ChargerType: safeDeref(session.ChargerType, ""), + Place: safeDeref(session.StartPlace, ""), + PeakPowerW: session.PeakPowerW, + AvgPowerW: session.AvgPowerW, + } + + if share.IncludeTelemetry { + info.Cost = session.CostDecimal + info.CostCurrency = safeDeref(session.CostCurrency, "") + endTs := now + if session.EndedAt != nil { + endTs = *session.EndedAt + } + curve, err := h.curveLister.SessionCurve(ctx, session.VehicleID, session.StartedAt, endTs) + if err != nil { + logSessionCurveErr(err, session.ID) + } else { + info.Curve = curve + } + } + + resp := publicShareResponse{ + PayloadVersion: "v2", + ShareType: shareTypeSession, + Title: safeDeref(share.Title, "Shared Charging Session"), + Description: safeDeref(share.Description, ""), + Session: &info, + } + + // Vehicle info is limited to model and color: no VIN or IDs. + vehicle, err := h.vehicleRepo.GetByID(ctx, session.VehicleID) + if err == nil && vehicle != nil { + resp.Vehicle = &publicVehicle{ + Model: safeDeref(vehicle.Model, ""), + Color: safeDeref(vehicle.Color, ""), + } + } + + w.Header().Set("Cache-Control", "public, max-age=300") + httpx.WriteJSON(w, http.StatusOK, resp) +} diff --git a/internal/api/share/session_test.go b/internal/api/share/session_test.go new file mode 100644 index 0000000000..ddac9686e5 --- /dev/null +++ b/internal/api/share/session_test.go @@ -0,0 +1,418 @@ +package share + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + chargingmodel "github.com/ev-dev-labs/teslasync/internal/models/charging" + drivemodel "github.com/ev-dev-labs/teslasync/internal/models/drive" + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" +) + +// --------------------------------------------------------------------------- +// Fakes for the session ports, mirroring the drive-port fake style in +// handler_test.go. +// --------------------------------------------------------------------------- + +type fakeSessionStore struct { + sessionFn func(ctx context.Context, id int64) (*chargingmodel.ChargingSession, error) + calls int + gotID int64 +} + +func (f *fakeSessionStore) GetByID(ctx context.Context, id int64) (*chargingmodel.ChargingSession, error) { + f.calls++ + f.gotID = id + if f.sessionFn == nil { + return nil, nil + } + return f.sessionFn(ctx, id) +} + +var _ sessionByIDFetcher = (*fakeSessionStore)(nil) + +type fakeCurveLister struct { + curveFn func(ctx context.Context, vehicleID int64, from, to time.Time) ([]publicCurvePoint, error) + calls int + gotFrom time.Time + gotTo time.Time +} + +func (f *fakeCurveLister) SessionCurve(ctx context.Context, vehicleID int64, from, to time.Time) ([]publicCurvePoint, error) { + f.calls++ + f.gotFrom = from + f.gotTo = to + if f.curveFn == nil { + return nil, nil + } + return f.curveFn(ctx, vehicleID, from, to) +} + +var _ chargeCurveLister = (*fakeCurveLister)(nil) + +// completedSession is a fully-populated charging session for share tests. +func completedSession(id, vehicleID int64) *chargingmodel.ChargingSession { + start := time.Date(2026, 3, 15, 8, 0, 0, 0, time.UTC) + end := start.Add(40 * time.Minute) + return &chargingmodel.ChargingSession{ + ID: id, + VehicleID: vehicleID, + StartedAt: start, + EndedAt: &end, + StartSocPct: ptrF64(20), + EndSocPct: ptrF64(80), + StartLat: ptrF64(37.7749), + StartLng: ptrF64(-122.4194), + StartPlace: ptrStr("Home"), + TotalEnergyAddedWh: ptrF64(45000), + PeakPowerW: ptrF64(250000), + AvgPowerW: ptrF64(67500), + CostDecimal: ptrF64(9.99), + CostCurrency: ptrStr("USD"), + ChargerType: ptrStr("supercharger"), + } +} + +// --------------------------------------------------------------------------- +// CreateSessionShare +// --------------------------------------------------------------------------- + +func TestCreateSessionShare(t *testing.T) { + newHandler := func(sess *fakeSessionStore, store *fakeShareStore) *ShareHandler { + return &ShareHandler{shareRepo: store, sessionRepo: sess} + } + + t.Run("invalid session id is a 400", func(t *testing.T) { + h := newHandler(&fakeSessionStore{}, &fakeShareStore{}) + rec := httptest.NewRecorder() + h.CreateSessionShare(rec, newRequest(t, http.MethodPost, "/charging/abc/share", nil, map[string]string{"sessionID": "abc"})) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + }) + + t.Run("missing session is a 404", func(t *testing.T) { + h := newHandler(&fakeSessionStore{}, &fakeShareStore{}) + rec := httptest.NewRecorder() + h.CreateSessionShare(rec, newRequest(t, http.MethodPost, "/charging/9/share", nil, map[string]string{"sessionID": "9"})) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + }) + + t.Run("success mints a session-target token with telemetry off by default", func(t *testing.T) { + sess := &fakeSessionStore{sessionFn: func(_ context.Context, _ int64) (*chargingmodel.ChargingSession, error) { + return completedSession(9, 3), nil + }} + store := &fakeShareStore{} + h := newHandler(sess, store) + + rec := httptest.NewRecorder() + h.CreateSessionShare(rec, newRequest(t, http.MethodPost, "/charging/9/share", nil, map[string]string{"sessionID": "9"})) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body.String()) + } + if store.created == nil { + t.Fatal("expected a created token") + } + if store.created.ChargingSessionID != 9 || store.created.DriveID != 0 { + t.Fatalf("target = (drive %d, session %d), want (0, 9)", + store.created.DriveID, store.created.ChargingSessionID) + } + if store.created.IncludeTelemetry || store.created.IncludeMap || store.created.IncludeSpeed { + t.Fatalf("flags = (map %v, telemetry %v, speed %v), want all false", + store.created.IncludeMap, store.created.IncludeTelemetry, store.created.IncludeSpeed) + } + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["url"] != "/s/"+store.created.Token { + t.Fatalf("url = %v, want /s/", body["url"]) + } + }) + + t.Run("telemetry flag and title flow through", func(t *testing.T) { + sess := &fakeSessionStore{sessionFn: func(_ context.Context, _ int64) (*chargingmodel.ChargingSession, error) { + return completedSession(9, 3), nil + }} + store := &fakeShareStore{} + h := newHandler(sess, store) + + rec := httptest.NewRecorder() + req := newRequest(t, http.MethodPost, "/charging/9/share", + strings.NewReader(`{"title":"Road trip charge","include_telemetry":true,"expires_in_days":7}`), + map[string]string{"sessionID": "9"}) + h.CreateSessionShare(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body.String()) + } + if !store.created.IncludeTelemetry { + t.Error("IncludeTelemetry = false, want true") + } + if store.created.Title == nil || *store.created.Title != "Road trip charge" { + t.Errorf("title = %v, want Road trip charge", store.created.Title) + } + if store.created.ExpiresAt == nil || time.Until(*store.created.ExpiresAt) <= 0 { + t.Errorf("missing or past expiry: %v", store.created.ExpiresAt) + } + }) + + t.Run("malformed body is a 400", func(t *testing.T) { + sess := &fakeSessionStore{sessionFn: func(_ context.Context, _ int64) (*chargingmodel.ChargingSession, error) { + return completedSession(9, 3), nil + }} + h := newHandler(sess, &fakeShareStore{}) + rec := httptest.NewRecorder() + req := newRequest(t, http.MethodPost, "/charging/9/share", + strings.NewReader(`{not json`), map[string]string{"sessionID": "9"}) + h.CreateSessionShare(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + }) +} + +// --------------------------------------------------------------------------- +// ListSessionShares +// --------------------------------------------------------------------------- + +func TestListSessionShares(t *testing.T) { + t.Run("returns empty array when none", func(t *testing.T) { + store := &fakeShareStore{} + h := &ShareHandler{shareRepo: store} + rec := httptest.NewRecorder() + h.ListSessionShares(rec, newRequest(t, http.MethodGet, "/charging/9/shares", nil, map[string]string{"sessionID": "9"})) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("body = %s, want []", rec.Body.String()) + } + if store.listSessionID != 9 { + t.Fatalf("listed session = %d, want 9", store.listSessionID) + } + }) + + t.Run("store error is a 500", func(t *testing.T) { + store := &fakeShareStore{listSessionFn: func(_ context.Context, _ int64) ([]*drivemodel.ShareToken, error) { + return nil, errors.New("db down") + }} + h := &ShareHandler{shareRepo: store} + rec := httptest.NewRecorder() + h.ListSessionShares(rec, newRequest(t, http.MethodGet, "/charging/9/shares", nil, map[string]string{"sessionID": "9"})) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) +} + +// --------------------------------------------------------------------------- +// GetPublicShare — session branch +// --------------------------------------------------------------------------- + +func sessionShareHandler(t *testing.T, share *drivemodel.ShareToken, curve *fakeCurveLister) (*ShareHandler, *fakeShareStore) { + t.Helper() + store := &fakeShareStore{getFn: func(_ context.Context, _ string) (*drivemodel.ShareToken, error) { + return share, nil + }} + sess := &fakeSessionStore{sessionFn: func(_ context.Context, _ int64) (*chargingmodel.ChargingSession, error) { + return completedSession(9, 3), nil + }} + veh := &fakeVehicleStore{vehicleFn: func(_ context.Context, _ int64) (*vehiclemodel.Vehicle, error) { + return &vehiclemodel.Vehicle{Model: ptrStr("Model 3"), Color: ptrStr("White")}, nil + }} + return &ShareHandler{shareRepo: store, sessionRepo: sess, vehicleRepo: veh, curveLister: curve}, store +} + +func getPublic(t *testing.T, h *ShareHandler, token string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.GetPublicShare(rec, newRequest(t, http.MethodGet, "/share/"+token, nil, map[string]string{"token": token})) + return rec +} + +func TestGetPublicShareSession(t *testing.T) { + t.Run("summary serves with share_type and no curve by default", func(t *testing.T) { + h, _ := sessionShareHandler(t, &drivemodel.ShareToken{ID: 1, Token: "tok", ChargingSessionID: 9}, &fakeCurveLister{}) + rec := getPublic(t, h, "tok") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var resp publicShareResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ShareType != shareTypeSession { + t.Fatalf("share_type = %q, want %q", resp.ShareType, shareTypeSession) + } + if resp.Session == nil { + t.Fatal("session payload missing") + } + if resp.Drive != nil { + t.Fatalf("drive payload present for a session share: %+v", resp.Drive) + } + if resp.Session.EnergyAddedWh == nil || *resp.Session.EnergyAddedWh != 45000 { + t.Fatalf("energy_added_wh = %v, want 45000", resp.Session.EnergyAddedWh) + } + if resp.Session.DurationS != 2400 { + t.Fatalf("duration_s = %d, want 2400", resp.Session.DurationS) + } + if resp.Session.Place != "Home" || resp.Session.ChargerType != "supercharger" { + t.Fatalf("place/charger = %q/%q", resp.Session.Place, resp.Session.ChargerType) + } + if len(resp.Session.Curve) != 0 { + t.Fatalf("curve has %d points without telemetry opt-in", len(resp.Session.Curve)) + } + if resp.Session.Cost != nil { + t.Fatalf("cost exposed without telemetry opt-in: %v", resp.Session.Cost) + } + if resp.Vehicle == nil || resp.Vehicle.Model != "Model 3" { + t.Fatalf("vehicle = %+v, want Model 3", resp.Vehicle) + } + // No coordinates anywhere in the public payload. + if strings.Contains(rec.Body.String(), "37.7749") || strings.Contains(rec.Body.String(), "-122.4194") { + t.Fatal("public payload leaks coordinates") + } + }) + + t.Run("telemetry opt-in serves curve and cost", func(t *testing.T) { + curve := &fakeCurveLister{curveFn: func(_ context.Context, _ int64, _, _ time.Time) ([]publicCurvePoint, error) { + pw, soc := 120.5, 42.0 + return []publicCurvePoint{{OffsetS: 0, PowerKW: &pw, BatteryPct: &soc}}, nil + }} + h, _ := sessionShareHandler(t, + &drivemodel.ShareToken{ID: 1, Token: "tok", ChargingSessionID: 9, IncludeTelemetry: true}, curve) + rec := getPublic(t, h, "tok") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var resp publicShareResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Session.Curve) != 1 || resp.Session.Curve[0].OffsetS != 0 { + t.Fatalf("curve = %+v, want 1 point", resp.Session.Curve) + } + if resp.Session.Cost == nil || *resp.Session.Cost != 9.99 { + t.Fatalf("cost = %v, want 9.99", resp.Session.Cost) + } + if resp.Session.CostCurrency != "USD" { + t.Fatalf("cost_currency = %q, want USD", resp.Session.CostCurrency) + } + if curve.calls != 1 { + t.Fatalf("curve calls = %d, want 1", curve.calls) + } + }) + + t.Run("curve failure degrades to summary", func(t *testing.T) { + curve := &fakeCurveLister{curveFn: func(_ context.Context, _ int64, _, _ time.Time) ([]publicCurvePoint, error) { + return nil, errors.New("retention expired") + }} + h, _ := sessionShareHandler(t, + &drivemodel.ShareToken{ID: 1, Token: "tok", ChargingSessionID: 9, IncludeTelemetry: true}, curve) + rec := getPublic(t, h, "tok") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (degraded)", rec.Code) + } + var resp publicShareResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Session == nil || len(resp.Session.Curve) != 0 { + t.Fatal("expected summary without curve on curve failure") + } + }) + + t.Run("missing session is a 404", func(t *testing.T) { + store := &fakeShareStore{getFn: func(_ context.Context, _ string) (*drivemodel.ShareToken, error) { + return &drivemodel.ShareToken{ID: 1, Token: "tok", ChargingSessionID: 9}, nil + }} + h := &ShareHandler{shareRepo: store, sessionRepo: &fakeSessionStore{}, curveLister: &fakeCurveLister{}} + rec := getPublic(t, h, "tok") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + }) + + t.Run("expired session share is gone", func(t *testing.T) { + past := time.Now().UTC().Add(-time.Hour) + store := &fakeShareStore{getFn: func(_ context.Context, _ string) (*drivemodel.ShareToken, error) { + return &drivemodel.ShareToken{ID: 1, Token: "tok", ChargingSessionID: 9, ExpiresAt: &past}, nil + }} + h := &ShareHandler{shareRepo: store} + rec := getPublic(t, h, "tok") + if rec.Code != http.StatusGone { + t.Fatalf("status = %d, want 410", rec.Code) + } + }) +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +func TestDownsampleCurve(t *testing.T) { + mk := func(n int) []publicCurvePoint { + pts := make([]publicCurvePoint, n) + for i := range pts { + pts[i].OffsetS = int64(i * 10) + } + return pts + } + + if got := downsampleCurve(mk(10), 240); len(got) != 10 { + t.Fatalf("short curve len = %d, want 10", len(got)) + } + got := downsampleCurve(mk(1000), 240) + if len(got) != 240 { + t.Fatalf("long curve len = %d, want 240", len(got)) + } + if got[0].OffsetS != 0 || got[len(got)-1].OffsetS != 9990 { + t.Fatalf("endpoints = %d/%d, want 0/9990", got[0].OffsetS, got[len(got)-1].OffsetS) + } + for i := 1; i < len(got); i++ { + if got[i].OffsetS <= got[i-1].OffsetS { + t.Fatalf("not monotonic at %d: %d <= %d", i, got[i].OffsetS, got[i-1].OffsetS) + } + } +} + +func TestSessionDurationS(t *testing.T) { + s := completedSession(1, 1) + if got := sessionDurationS(s, time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)); got != 2400 { + t.Fatalf("completed = %d, want 2400", got) + } + open := completedSession(2, 1) + open.EndedAt = nil + now := open.StartedAt.Add(90 * time.Second) + if got := sessionDurationS(open, now); got != 90 { + t.Fatalf("open = %d, want 90", got) + } + skewed := completedSession(3, 1) + future := skewed.StartedAt.Add(-time.Hour) + skewed.EndedAt = &future + if got := sessionDurationS(skewed, now); got != 0 { + t.Fatalf("skewed = %d, want 0", got) + } +} + +func TestExpiryFromDays(t *testing.T) { + if expiryFromDays(0) != nil || expiryFromDays(-5) != nil { + t.Fatal("non-positive days must yield nil expiry") + } + exp := expiryFromDays(7) + if exp == nil || time.Until(*exp) <= 6*24*time.Hour { + t.Fatalf("7-day expiry = %v", exp) + } + capped := expiryFromDays(maxExpiryDays + 10_000_000) + if capped == nil || time.Until(*capped) > (maxExpiryDays+1)*24*time.Hour { + t.Fatalf("uncapped expiry = %v", capped) + } +} diff --git a/internal/api/stormguard/assess.go b/internal/api/stormguard/assess.go new file mode 100644 index 0000000000..09149f0f66 --- /dev/null +++ b/internal/api/stormguard/assess.go @@ -0,0 +1,107 @@ +package stormguard + +import ( + "fmt" + "time" +) + +// Risk levels, ordered. Stored in stormguard_events.level. +const ( + LevelNone = "none" + LevelWatch = "watch" + LevelWarning = "warning" +) + +// Assessment thresholds. Gust bands follow NWS damage guidance loosely +// (58 mph ≈ 26 m/s destroys; 40 mph ≈ 18 m/s downs branches); WMO codes +// 95/96/99 are thunderstorm, 80-82 violent showers, 71-77 heavy snow. +const ( + warnHorizon = 24 * time.Hour + watchHorizon = 48 * time.Hour + warnGustMS = 25.0 + watchGustMS = 17.0 + maxAssessRows = 72 +) + +// Assessment is the pure verdict over a forecast window. +type Assessment struct { + Level string `json:"level"` + Reason string `json:"reason"` + StartsAt *time.Time `json:"starts_at,omitempty"` + PeakGustMS float64 `json:"peak_gust_ms"` +} + +// Assess grades the forecast from now. Pure: no I/O, deterministic. +// Only the first 72 hourly rows (3 days) are examined; the verdict +// horizons are 24h (warning) and 48h (watch). +func Assess(f *Forecast, now time.Time) Assessment { + a := Assessment{Level: LevelNone, Reason: "no severe weather in the next 48 hours"} + if f == nil { + return a + } + n := len(f.Times) + if len(f.Weather) < n { + n = len(f.Weather) + } + if len(f.WindGustMS) < n { + n = len(f.WindGustMS) + } + if n > maxAssessRows { + n = maxAssessRows + } + for i := 0; i < n; i++ { + dt := f.Times[i].Sub(now) + if dt < 0 || dt > watchHorizon { + continue + } + if gust := f.WindGustMS[i]; gust > a.PeakGustMS { + a.PeakGustMS = gust + } + } + for i := 0; i < n; i++ { + dt := f.Times[i].Sub(now) + if dt < 0 { + continue + } + code := f.Weather[i] + switch { + case dt <= warnHorizon && (isThunder(code) || f.WindGustMS[i] >= warnGustMS): + out := severe(LevelWarning, f, i, code) + out.PeakGustMS = a.PeakGustMS + return out + case dt <= watchHorizon && (isThunder(code) || f.WindGustMS[i] >= watchGustMS || isHeavyPrecip(code)): + if a.Level == LevelNone { + out := severe(LevelWatch, f, i, code) + out.PeakGustMS = a.PeakGustMS + a = out + } + } + } + return a +} + +func severe(level string, f *Forecast, i, code int) Assessment { + start := f.Times[i] + a := Assessment{Level: level, StartsAt: &start, PeakGustMS: f.WindGustMS[i]} + switch { + case isThunder(code): + a.Reason = fmt.Sprintf("thunderstorm (WMO %d) forecast at %s", code, start.Format("Mon 15:04")) + case f.WindGustMS[i] >= warnGustMS: + a.Reason = fmt.Sprintf("damaging gusts %.0f m/s forecast at %s", f.WindGustMS[i], start.Format("Mon 15:04")) + case f.WindGustMS[i] >= watchGustMS: + a.Reason = fmt.Sprintf("strong gusts %.0f m/s forecast at %s", f.WindGustMS[i], start.Format("Mon 15:04")) + default: + a.Reason = fmt.Sprintf("heavy precipitation (WMO %d) forecast at %s", code, start.Format("Mon 15:04")) + } + return a +} + +func isThunder(code int) bool { return code == 95 || code == 96 || code == 99 } + +func isHeavyPrecip(code int) bool { + switch code { + case 80, 81, 82, 71, 73, 75, 77: + return true + } + return false +} diff --git a/internal/api/stormguard/assess_test.go b/internal/api/stormguard/assess_test.go new file mode 100644 index 0000000000..23a0f0d5cf --- /dev/null +++ b/internal/api/stormguard/assess_test.go @@ -0,0 +1,67 @@ +package stormguard + +import ( + "testing" + "time" +) + +func forecastAt(now time.Time, hours []int, codes []int, gusts []float64) *Forecast { + f := &Forecast{} + for i, h := range hours { + f.Times = append(f.Times, now.Add(time.Duration(h)*time.Hour)) + f.Weather = append(f.Weather, codes[i]) + f.WindGustMS = append(f.WindGustMS, gusts[i]) + } + return f +} + +func TestAssessLevels(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + cases := []struct { + name string + hours []int + codes []int + gusts []float64 + want string + }{ + {"calm is none", []int{1, 12, 30}, []int{1, 2, 3}, []float64{5, 6, 8}, LevelNone}, + {"nil forecast is none", nil, nil, nil, LevelNone}, + {"thunderstorm in 6h is warning", []int{6}, []int{95}, []float64{10}, LevelWarning}, + {"severe thunderstorm in 20h is warning", []int{20}, []int{99}, []float64{12}, LevelWarning}, + {"thunderstorm in 30h is watch", []int{30}, []int{96}, []float64{10}, LevelWatch}, + {"damaging gust in 10h is warning", []int{10}, []int{3}, []float64{28}, LevelWarning}, + {"strong gust in 10h is watch", []int{10}, []int{3}, []float64{19}, LevelWatch}, + {"strong gust in 40h is watch", []int{40}, []int{3}, []float64{20}, LevelWatch}, + {"heavy snow in 12h is watch", []int{12}, []int{75}, []float64{8}, LevelWatch}, + {"storm beyond 48h is none", []int{60}, []int{95}, []float64{40}, LevelNone}, + {"past storm is none", []int{-5}, []int{95}, []float64{40}, LevelNone}, + {"warning beats earlier watch", []int{30, 10}, []int{95, 95}, []float64{10, 10}, LevelWarning}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var f *Forecast + if tc.hours != nil { + f = forecastAt(now, tc.hours, tc.codes, tc.gusts) + } + got := Assess(f, now) + if got.Level != tc.want { + t.Fatalf("level = %q, want %q (reason %q)", got.Level, tc.want, got.Reason) + } + if tc.want != LevelNone && got.StartsAt == nil { + t.Fatal("expected StartsAt for elevated level") + } + }) + } +} + +func TestAssessPeakGust(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + f := forecastAt(now, []int{5, 10, 60}, []int{1, 1, 1}, []float64{9, 22, 99}) + got := Assess(f, now) + if got.Level != LevelWatch { + t.Fatalf("level = %q, want watch", got.Level) + } + if got.PeakGustMS != 22 { + t.Fatalf("peak = %v, want 22 (beyond-horizon gust excluded)", got.PeakGustMS) + } +} diff --git a/internal/api/stormguard/handler.go b/internal/api/stormguard/handler.go new file mode 100644 index 0000000000..dee5ac93b2 --- /dev/null +++ b/internal/api/stormguard/handler.go @@ -0,0 +1,289 @@ +package stormguard + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + vehicledb "github.com/ev-dev-labs/teslasync/internal/database/vehicle" + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" + "github.com/ev-dev-labs/teslasync/internal/signal" + "github.com/ev-dev-labs/teslasync/internal/tesla" +) + +// stormCommandTimeout caps the Tesla set_charge_limit call issued when +// the guard acts (project rule — Tesla API: 30s). +const stormCommandTimeout = 30 * time.Second + +// ConfigStore is the config/event port. *Store satisfies it. +type ConfigStore interface { + GetConfig(ctx context.Context, vehicleID int64) (*Config, error) + UpsertConfig(ctx context.Context, c *Config) error + ArmedConfigs(ctx context.Context) ([]*Config, error) + LogEvent(ctx context.Context, e *Event) error + LastEventLevel(ctx context.Context, vehicleID int64) (string, error) + ListEvents(ctx context.Context, vehicleID int64, limit int) ([]*Event, error) +} + +// Forecaster fetches severe-weather forecasts. *Client satisfies it. +type Forecaster interface { + Fetch(ctx context.Context, lat, lng float64) (*Forecast, error) +} + +// Commander issues Tesla vehicle commands. *tesla.Client satisfies it. +type Commander interface { + SendCommand(ctx context.Context, vin string, command string, params map[string]interface{}) error +} + +// vehicleByIDFetcher fetches a single vehicle. *vehicledb.VehicleRepo +// satisfies it. +type vehicleByIDFetcher interface { + GetByID(ctx context.Context, id int64) (*vehiclemodel.Vehicle, error) +} + +// Handler serves storm-guard config/status/events and runs the hourly +// evaluator. Stateless beyond constructor inputs; safe for concurrent use. +type Handler struct { + store ConfigStore + meteo Forecaster + tesla Commander + state signal.StateReader + vehicles vehicleByIDFetcher + now func() time.Time +} + +// NewHandler wires the handler. Panics on nil inputs (fail-fast wiring +// contract, matching sibling handlers). +func NewHandler(store ConfigStore, meteo Forecaster, tesla Commander, state signal.StateReader, vehicles vehicleByIDFetcher) *Handler { + if store == nil || meteo == nil || tesla == nil || state == nil || vehicles == nil { + panic("stormguard: nil dependency") + } + return &Handler{store: store, meteo: meteo, tesla: tesla, state: state, vehicles: vehicles, now: time.Now} +} + +type statusResponse struct { + Config *Config `json:"config"` + Assessment Assessment `json:"assessment"` + CurrentSOC *int `json:"current_soc,omitempty"` +} + +// Status serves GET /stormguard/status?vehicle_id=: live assessment for +// the stored home coordinates plus current battery state. Read-only — it +// never acts; only the evaluator acts. +func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { + vehicleID, err := vehicleIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + cfg, err := h.store.GetConfig(ctx, vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("stormguard: config read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read storm-guard config") + return + } + f, err := h.meteo.Fetch(ctx, cfg.Lat, cfg.Lng) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("stormguard: forecast fetch failed") + httpx.WriteError(w, http.StatusBadGateway, "weather forecast unavailable") + return + } + resp := statusResponse{Config: cfg, Assessment: Assess(f, h.now().UTC())} + if v, err := h.state.SignalAt(ctx, vehicleID, "BatteryLevel", h.now()); err == nil && v != nil { + if f, ok := signal.Float64(v); ok && f > 0 { + soc := int(f) + resp.CurrentSOC = &soc + } + } + httpx.WriteJSON(w, http.StatusOK, resp) +} + +type configRequest struct { + VehicleID int64 `json:"vehicle_id"` + Enabled bool `json:"enabled"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + TargetSOC int `json:"target_soc"` +} + +// UpsertConfig serves PUT /stormguard/config: arm/disarm + home coords + +// pre-storm charge target. +func (h *Handler) UpsertConfig(w http.ResponseWriter, r *http.Request) { + var req configRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.VehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + if req.Lat < -90 || req.Lat > 90 || req.Lng < -180 || req.Lng > 180 { + httpx.WriteError(w, http.StatusBadRequest, "lat/lng out of range") + return + } + if req.TargetSOC < 50 || req.TargetSOC > 100 { + httpx.WriteError(w, http.StatusBadRequest, "target_soc must be 50..100") + return + } + cfg := &Config{VehicleID: req.VehicleID, Enabled: req.Enabled, Lat: req.Lat, Lng: req.Lng, TargetSOC: req.TargetSOC} + if err := h.store.UpsertConfig(r.Context(), cfg); err != nil { + log.Error().Err(err).Int64("vehicle_id", req.VehicleID).Msg("stormguard: config write failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save storm-guard config") + return + } + httpx.WriteJSON(w, http.StatusOK, cfg) +} + +// Events serves GET /stormguard/events?vehicle_id=&limit=. +func (h *Handler) Events(w http.ResponseWriter, r *http.Request) { + vehicleID, err := vehicleIDParam(r) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + limit := 20 + if s := r.URL.Query().Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + limit = n + } + } + events, err := h.store.ListEvents(r.Context(), vehicleID, limit) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("stormguard: events read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read storm-guard events") + return + } + httpx.WriteJSON(w, http.StatusOK, events) +} + +func vehicleIDParam(r *http.Request) (int64, error) { + s := r.URL.Query().Get("vehicle_id") + id, err := strconv.ParseInt(s, 10, 64) + if err != nil || id <= 0 { + return 0, errBadVehicleID + } + return id, nil +} + +type vehicleIDError string + +func (e vehicleIDError) Error() string { return string(e) } + +const errBadVehicleID = vehicleIDError("vehicle_id must be a positive integer") + +// DefaultEvaluateInterval is the hourly guard cadence. +const DefaultEvaluateInterval = time.Hour + +// Run starts the periodic evaluation loop until ctx ends: an immediate +// first pass, then one per interval. Per-pass failures are logged +// inside EvaluateArmed and never kill the loop. +func (h *Handler) Run(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = DefaultEvaluateInterval + } + h.EvaluateArmed(ctx) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + h.EvaluateArmed(ctx) + } + } +} + +// EvaluateArmed runs one guard pass over every armed vehicle: assess, +// log on level transitions, and — on a fresh warning with the battery +// below target — raise the charge limit via the Tesla API. Per-vehicle +// failures are logged and skipped so one bad forecast never blocks the +// fleet. Called hourly from the app ticker. +func (h *Handler) EvaluateArmed(ctx context.Context) { + cfgs, err := h.store.ArmedConfigs(ctx) + if err != nil { + log.Error().Err(err).Msg("stormguard: armed list failed") + return + } + for _, cfg := range cfgs { + if err := h.evaluateOne(ctx, cfg); err != nil { + log.Error().Err(err).Int64("vehicle_id", cfg.VehicleID).Msg("stormguard: evaluation failed") + } + } +} + +func (h *Handler) evaluateOne(ctx context.Context, cfg *Config) error { + f, err := h.meteo.Fetch(ctx, cfg.Lat, cfg.Lng) + if err != nil { + return err + } + a := Assess(f, h.now().UTC()) + + last, err := h.store.LastEventLevel(ctx, cfg.VehicleID) + if err != nil { + return err + } + acted := false + if a.Level == LevelWarning && last != LevelWarning { + acted, err = h.precharge(ctx, cfg) + if err != nil { + return err + } + } + // Log transitions (including recovery to none) and every action, so + // the timeline shows what changed without hourly duplicates. + if a.Level != last || acted { + return h.store.LogEvent(ctx, &Event{ + VehicleID: cfg.VehicleID, Level: a.Level, Reason: a.Reason, Acted: acted, + }) + } + return nil +} + +// precharge raises the charge limit to the storm target when the battery +// sits below it. Returns acted=false when already at/above target or the +// battery state is unreadable (never acts blind). +func (h *Handler) precharge(ctx context.Context, cfg *Config) (bool, error) { + v, err := h.state.SignalAt(ctx, cfg.VehicleID, "BatteryLevel", h.now()) + if err != nil || v == nil { + log.Warn().Err(err).Int64("vehicle_id", cfg.VehicleID).Msg("stormguard: unreadable SOC, not acting") + return false, nil + } + soc, ok := signal.Float64(v) + if !ok || soc <= 0 { + log.Warn().Int64("vehicle_id", cfg.VehicleID).Msg("stormguard: invalid SOC, not acting") + return false, nil + } + if int(soc) >= cfg.TargetSOC { + return false, nil + } + var vehicle *vehiclemodel.Vehicle + vehicle, err = h.vehicles.GetByID(ctx, cfg.VehicleID) + if err != nil || vehicle == nil { + return false, err + } + cmdCtx, cancel := context.WithTimeout(ctx, stormCommandTimeout) + defer cancel() + if err := h.tesla.SendCommand(cmdCtx, vehicle.VIN, "set_charge_limit", map[string]interface{}{ + "percent": cfg.TargetSOC, + }); err != nil { + return false, err + } + log.Info().Int64("vehicle_id", cfg.VehicleID).Int("target_soc", cfg.TargetSOC).Msg("stormguard: pre-charge limit set") + return true, nil +} + +// Compile-time port assertions. +var ( + _ ConfigStore = (*Store)(nil) + _ Forecaster = (*Client)(nil) + _ Commander = (*tesla.Client)(nil) + _ vehicleByIDFetcher = (*vehicledb.VehicleRepo)(nil) +) diff --git a/internal/api/stormguard/handler_test.go b/internal/api/stormguard/handler_test.go new file mode 100644 index 0000000000..052f651977 --- /dev/null +++ b/internal/api/stormguard/handler_test.go @@ -0,0 +1,316 @@ +package stormguard + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + vehiclemodel "github.com/ev-dev-labs/teslasync/internal/models/vehicle" + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +type fakeStore struct { + cfg *Config + armed []*Config + events []*Event + last string + upserts []*Config + err error +} + +func (f *fakeStore) GetConfig(_ context.Context, vehicleID int64) (*Config, error) { + if f.err != nil { + return nil, f.err + } + if f.cfg != nil { + return f.cfg, nil + } + return DefaultConfig(vehicleID), nil +} + +func (f *fakeStore) UpsertConfig(_ context.Context, c *Config) error { + f.upserts = append(f.upserts, c) + return f.err +} + +func (f *fakeStore) ArmedConfigs(_ context.Context) ([]*Config, error) { return f.armed, f.err } + +func (f *fakeStore) LogEvent(_ context.Context, e *Event) error { + f.events = append(f.events, e) + return f.err +} + +func (f *fakeStore) LastEventLevel(_ context.Context, _ int64) (string, error) { return f.last, f.err } + +func (f *fakeStore) ListEvents(_ context.Context, _ int64, _ int) ([]*Event, error) { + return f.events, f.err +} + +var _ ConfigStore = (*fakeStore)(nil) + +type fakeMeteo struct { + forecast *Forecast + err error +} + +func (f *fakeMeteo) Fetch(_ context.Context, _, _ float64) (*Forecast, error) { + return f.forecast, f.err +} + +var _ Forecaster = (*fakeMeteo)(nil) + +type fakeCommander struct { + calls []string + vin string + pct int + err error +} + +func (f *fakeCommander) SendCommand(_ context.Context, vin string, command string, params map[string]interface{}) error { + f.calls = append(f.calls, command) + f.vin = vin + if p, ok := params["percent"].(int); ok { + f.pct = p + } + return f.err +} + +var _ Commander = (*fakeCommander)(nil) + +type fakeState struct { + soc float64 + err error +} + +func (f *fakeState) State(_ context.Context, _ int64, _ time.Time) (signal.State, error) { + return signal.State{}, nil +} + +func (f *fakeState) SignalAt(_ context.Context, _ int64, _ string, _ time.Time) (signal.SignalValue, error) { + if f.err != nil { + return nil, f.err + } + return f.soc, nil +} + +func (f *fakeState) Timeline(_ context.Context, _ int64, _ []signal.FieldMapping, _, _ time.Time, _ signal.TimelineOptions) ([]signal.TimelineRow, error) { + return nil, nil +} + +var _ signal.StateReader = (*fakeState)(nil) + +type fakeVehicles struct { + vin string + err error +} + +func (f *fakeVehicles) GetByID(_ context.Context, id int64) (*vehiclemodel.Vehicle, error) { + if f.err != nil { + return nil, f.err + } + return &vehiclemodel.Vehicle{ID: id, VIN: f.vin}, nil +} + +func testHandler(store *fakeStore, meteo *fakeMeteo, cmd *fakeCommander, state *fakeState, veh *fakeVehicles) *Handler { + if veh == nil { + veh = &fakeVehicles{} + } + return &Handler{store: store, meteo: meteo, tesla: cmd, state: state, vehicles: veh, now: func() time.Time { + return time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + }} +} + +func stormForecast(now time.Time) *Forecast { + return forecastAt(now, []int{6}, []int{95}, []float64{10}) +} + +func TestStatus(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + store := &fakeStore{cfg: &Config{VehicleID: 7, Enabled: true, Lat: 37.7, Lng: -122.4, TargetSOC: 95}} + meteo := &fakeMeteo{forecast: stormForecast(now)} + h := testHandler(store, meteo, &fakeCommander{}, &fakeState{soc: 60}, nil) + + req := httptest.NewRequest(http.MethodGet, "/status?vehicle_id=7", nil) + rec := httptest.NewRecorder() + h.Status(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var resp statusResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Assessment.Level != LevelWarning { + t.Fatalf("level = %q, want warning", resp.Assessment.Level) + } + if resp.CurrentSOC == nil || *resp.CurrentSOC != 60 { + t.Fatalf("soc = %v, want 60", resp.CurrentSOC) + } + + req = httptest.NewRequest(http.MethodGet, "/status", nil) + rec = httptest.NewRecorder() + h.Status(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("missing vehicle status = %d, want 400", rec.Code) + } +} + +func TestStatusMeteoFailure(t *testing.T) { + store := &fakeStore{cfg: &Config{VehicleID: 7}} + h := testHandler(store, &fakeMeteo{err: errors.New("down")}, &fakeCommander{}, &fakeState{}, nil) + req := httptest.NewRequest(http.MethodGet, "/status?vehicle_id=7", nil) + rec := httptest.NewRecorder() + h.Status(rec, req) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", rec.Code) + } +} + +func TestUpsertConfig(t *testing.T) { + store := &fakeStore{} + h := testHandler(store, &fakeMeteo{}, &fakeCommander{}, &fakeState{}, nil) + + body := `{"vehicle_id":7,"enabled":true,"lat":37.7,"lng":-122.4,"target_soc":95}` + req := httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.UpsertConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if len(store.upserts) != 1 || !store.upserts[0].Enabled || store.upserts[0].TargetSOC != 95 { + t.Fatalf("upserts = %+v", store.upserts) + } + + for _, bad := range []string{ + `{"vehicle_id":0,"lat":0,"lng":0,"target_soc":90}`, + `{"vehicle_id":7,"lat":100,"lng":0,"target_soc":90}`, + `{"vehicle_id":7,"lat":0,"lng":0,"target_soc":30}`, + `{not json`, + } { + req := httptest.NewRequest(http.MethodPut, "/config", strings.NewReader(bad)) + rec := httptest.NewRecorder() + h.UpsertConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("body %q status = %d, want 400", bad, rec.Code) + } + } +} + +func TestEvents(t *testing.T) { + store := &fakeStore{events: []*Event{{ID: 1, VehicleID: 7, Level: LevelWarning, Acted: true}}} + h := testHandler(store, &fakeMeteo{}, &fakeCommander{}, &fakeState{}, nil) + req := httptest.NewRequest(http.MethodGet, "/events?vehicle_id=7&limit=5", nil) + rec := httptest.NewRecorder() + h.Events(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var events []*Event + if err := json.Unmarshal(rec.Body.Bytes(), &events); err != nil { + t.Fatalf("decode: %v", err) + } + if len(events) != 1 || !events[0].Acted { + t.Fatalf("events = %+v", events) + } +} + +func TestNewHandlerPanicsOnNil(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + NewHandler(nil, &fakeMeteo{}, &fakeCommander{}, &fakeState{}, nil) +} + +func TestEvaluateArmedActsOnFreshWarning(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + cfg := &Config{VehicleID: 7, Enabled: true, Lat: 37.7, Lng: -122.4, TargetSOC: 95} + store := &fakeStore{armed: []*Config{cfg}} + meteo := &fakeMeteo{forecast: stormForecast(now)} + cmd := &fakeCommander{} + h := testHandler(store, meteo, cmd, &fakeState{soc: 60}, &fakeVehicles{vin: "VIN7"}) + + h.EvaluateArmed(context.Background()) + + if len(cmd.calls) != 1 || cmd.calls[0] != "set_charge_limit" { + t.Fatalf("commands = %v, want [set_charge_limit]", cmd.calls) + } + if cmd.vin != "VIN7" || cmd.pct != 95 { + t.Fatalf("vin/pct = %s/%d, want VIN7/95", cmd.vin, cmd.pct) + } + if len(store.events) != 1 || !store.events[0].Acted || store.events[0].Level != LevelWarning { + t.Fatalf("events = %+v", store.events) + } +} + +func TestEvaluateArmedSkips(t *testing.T) { + now := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + newArmed := func() (*fakeStore, *fakeMeteo, *fakeCommander) { + cfg := &Config{VehicleID: 7, Enabled: true, TargetSOC: 95} + return &fakeStore{armed: []*Config{cfg}}, &fakeMeteo{forecast: stormForecast(now)}, &fakeCommander{} + } + + t.Run("no duplicate action on repeated warning", func(t *testing.T) { + store, meteo, cmd := newArmed() + store.last = LevelWarning + h := testHandler(store, meteo, cmd, &fakeState{soc: 60}, &fakeVehicles{vin: "VIN7"}) + h.EvaluateArmed(context.Background()) + if len(cmd.calls) != 0 { + t.Fatalf("commands = %v, want none", cmd.calls) + } + if len(store.events) != 0 { + t.Fatalf("events = %+v, want none (no transition)", store.events) + } + }) + + t.Run("already above target logs transition without acting", func(t *testing.T) { + store, meteo, cmd := newArmed() + h := testHandler(store, meteo, cmd, &fakeState{soc: 96}, &fakeVehicles{vin: "VIN7"}) + h.EvaluateArmed(context.Background()) + if len(cmd.calls) != 0 { + t.Fatalf("commands = %v, want none", cmd.calls) + } + if len(store.events) != 1 || store.events[0].Acted { + t.Fatalf("events = %+v, want one un-acted transition", store.events) + } + }) + + t.Run("unreadable SOC never acts", func(t *testing.T) { + store, meteo, cmd := newArmed() + h := testHandler(store, meteo, cmd, &fakeState{err: errors.New("no data")}, &fakeVehicles{vin: "VIN7"}) + h.EvaluateArmed(context.Background()) + if len(cmd.calls) != 0 { + t.Fatalf("commands = %v, want none", cmd.calls) + } + }) + + t.Run("calm forecast after warning logs recovery", func(t *testing.T) { + store, meteo, cmd := newArmed() + store.last = LevelWarning + meteo.forecast = forecastAt(now, []int{6, 12}, []int{1, 2}, []float64{5, 6}) + h := testHandler(store, meteo, cmd, &fakeState{soc: 60}, &fakeVehicles{vin: "VIN7"}) + h.EvaluateArmed(context.Background()) + if len(cmd.calls) != 0 { + t.Fatalf("commands = %v, want none", cmd.calls) + } + if len(store.events) != 1 || store.events[0].Level != LevelNone { + t.Fatalf("events = %+v, want recovery to none", store.events) + } + }) + + t.Run("meteo failure skips vehicle", func(t *testing.T) { + store, _, cmd := newArmed() + meteo := &fakeMeteo{err: errors.New("down")} + h := testHandler(store, meteo, cmd, &fakeState{soc: 60}, &fakeVehicles{vin: "VIN7"}) + h.EvaluateArmed(context.Background()) + if len(cmd.calls) != 0 || len(store.events) != 0 { + t.Fatal("expected no commands or events on meteo failure") + } + }) +} diff --git a/internal/api/stormguard/meteo.go b/internal/api/stormguard/meteo.go new file mode 100644 index 0000000000..1a5e63af59 --- /dev/null +++ b/internal/api/stormguard/meteo.go @@ -0,0 +1,116 @@ +// Package stormguard watches severe weather at each armed vehicle's home +// location (Open-Meteo, keyless) and pre-charges the car before the storm +// hits: when a warning-level forecast is in effect and the battery sits +// below the configured target, the hourly evaluator raises the charge +// limit via the Tesla Fleet API so an outage starts with a full pack. +package stormguard + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// meteoTimeout bounds the Open-Meteo forecast call (project rule: +// external HTTP calls wrap with context.WithTimeout). +const meteoTimeout = 10 * time.Second + +// defaultMeteoBase is the keyless Open-Meteo forecast endpoint. +const defaultMeteoBase = "https://api.open-meteo.com/v1/forecast" + +// Forecast is the hourly severe-weather signal subset we assess. +type Forecast struct { + Times []time.Time + Weather []int + WindGustMS []float64 +} + +type meteoHourly struct { + Time []string `json:"time"` + WeatherCode []int `json:"weathercode"` + WindGusts []float64 `json:"windgusts_10m"` +} + +type meteoResponse struct { + Hourly meteoHourly `json:"hourly"` +} + +// Client fetches Open-Meteo forecasts. BaseURL and HTTPClient are +// overridable for tests (httptest). Safe for concurrent use. +type Client struct { + BaseURL string + HTTPClient *http.Client +} + +// NewClient wires a production client. +func NewClient() *Client { + return &Client{BaseURL: defaultMeteoBase, HTTPClient: http.DefaultClient} +} + +// Fetch returns the 48-hour hourly forecast for lat/lng in UTC. +func (c *Client) Fetch(ctx context.Context, lat, lng float64) (*Forecast, error) { + base := c.BaseURL + if base == "" { + base = defaultMeteoBase + } + q := url.Values{ + "latitude": {strconv.FormatFloat(lat, 'f', 5, 64)}, + "longitude": {strconv.FormatFloat(lng, 'f', 5, 64)}, + "hourly": {"weathercode,windgusts_10m"}, + "forecast_days": {"3"}, + "timezone": {"UTC"}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"?"+q.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("stormguard: build meteo request: %w", err) + } + req.Header.Set("User-Agent", "TeslaSync/1.0") + + client := c.HTTPClient + if client == nil { + client = http.DefaultClient + } + callCtx, cancel := context.WithTimeout(ctx, meteoTimeout) + defer cancel() + resp, err := client.Do(req.WithContext(callCtx)) + if err != nil { + return nil, fmt.Errorf("stormguard: meteo fetch: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("stormguard: meteo read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("stormguard: meteo status %d", resp.StatusCode) + } + + var mr meteoResponse + if err := json.Unmarshal(raw, &mr); err != nil { + return nil, fmt.Errorf("stormguard: meteo decode: %w", err) + } + n := len(mr.Hourly.Time) + if len(mr.Hourly.WeatherCode) < n || len(mr.Hourly.WindGusts) < n { + return nil, fmt.Errorf("stormguard: meteo ragged series (n=%d)", n) + } + f := &Forecast{ + Times: make([]time.Time, 0, n), + Weather: make([]int, 0, n), + WindGustMS: make([]float64, 0, n), + } + for i := 0; i < n; i++ { + ts, err := time.Parse("2006-01-02T15:04", mr.Hourly.Time[i]) + if err != nil { + return nil, fmt.Errorf("stormguard: meteo time %q: %w", mr.Hourly.Time[i], err) + } + f.Times = append(f.Times, ts.UTC()) + f.Weather = append(f.Weather, mr.Hourly.WeatherCode[i]) + f.WindGustMS = append(f.WindGustMS, mr.Hourly.WindGusts[i]) + } + return f, nil +} diff --git a/internal/api/stormguard/meteo_test.go b/internal/api/stormguard/meteo_test.go new file mode 100644 index 0000000000..28f06aac1a --- /dev/null +++ b/internal/api/stormguard/meteo_test.go @@ -0,0 +1,75 @@ +package stormguard + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +const meteoFixture = `{"hourly":{ + "time":["2026-04-01T12:00","2026-04-01T13:00"], + "weathercode":[3,95], + "windgusts_10m":[8.5,30.0]}}` + +func TestClientFetch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("hourly") != "weathercode,windgusts_10m" || q.Get("timezone") != "UTC" { + t.Errorf("unexpected query: %s", r.URL.RawQuery) + } + if q.Get("latitude") == "" || q.Get("longitude") == "" { + t.Errorf("missing coords: %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(meteoFixture)) + })) + defer srv.Close() + + c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()} + f, err := c.Fetch(context.Background(), 37.7749, -122.4194) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if len(f.Times) != 2 || f.Weather[1] != 95 || f.WindGustMS[1] != 30.0 { + t.Fatalf("unexpected forecast: %+v", f) + } + want := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + if !f.Times[0].Equal(want) { + t.Fatalf("t0 = %v, want %v", f.Times[0], want) + } +} + +func TestClientFetchErrors(t *testing.T) { + t.Run("non-200", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()} + if _, err := c.Fetch(context.Background(), 0, 0); err == nil { + t.Fatal("expected error for 429") + } + }) + t.Run("ragged series", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"hourly":{"time":["2026-04-01T12:00"],"weathercode":[],"windgusts_10m":[]}}`)) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()} + if _, err := c.Fetch(context.Background(), 0, 0); err == nil { + t.Fatal("expected error for ragged series") + } + }) + t.Run("bad time", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"hourly":{"time":["not-a-time"],"weathercode":[1],"windgusts_10m":[1]}}`)) + })) + defer srv.Close() + c := &Client{BaseURL: srv.URL, HTTPClient: srv.Client()} + if _, err := c.Fetch(context.Background(), 0, 0); err == nil { + t.Fatal("expected error for bad time") + } + }) +} diff --git a/internal/api/stormguard/store.go b/internal/api/stormguard/store.go new file mode 100644 index 0000000000..d0245f0358 --- /dev/null +++ b/internal/api/stormguard/store.go @@ -0,0 +1,166 @@ +package stormguard + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// Config is the per-vehicle storm-guard arming + home coordinates. +type Config struct { + VehicleID int64 `json:"vehicle_id"` + Enabled bool `json:"enabled"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + TargetSOC int `json:"target_soc"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Event is one assessment/action log row. +type Event struct { + ID int64 `json:"id"` + VehicleID int64 `json:"vehicle_id"` + Level string `json:"level"` + Reason string `json:"reason"` + Acted bool `json:"acted"` + CreatedAt time.Time `json:"created_at"` +} + +// Store persists storm-guard config + events. Panics on nil db +// (fail-fast wiring). Safe for concurrent use (pgx pool). +type Store struct { + db *database.DB +} + +// NewStore wires the store. +func NewStore(db *database.DB) *Store { + if db == nil { + panic("stormguard: nil db") + } + return &Store{db: db} +} + +// DefaultConfig returns the disarmed config for a vehicle. +func DefaultConfig(vehicleID int64) *Config { + return &Config{VehicleID: vehicleID, TargetSOC: 90} +} + +// GetConfig returns the stored config, or a disarmed default when the +// vehicle was never configured. +func (s *Store) GetConfig(ctx context.Context, vehicleID int64) (*Config, error) { + c := &Config{} + err := s.db.Pool.QueryRow(ctx, + `SELECT vehicle_id, enabled, lat, lng, target_soc, updated_at + FROM stormguard_config WHERE vehicle_id = $1`, vehicleID, + ).Scan(&c.VehicleID, &c.Enabled, &c.Lat, &c.Lng, &c.TargetSOC, &c.UpdatedAt) + if err == pgx.ErrNoRows { + return DefaultConfig(vehicleID), nil + } + if err != nil { + return nil, fmt.Errorf("stormguard: get config: %w", err) + } + return c, nil +} + +// UpsertConfig inserts or replaces the vehicle config. +func (s *Store) UpsertConfig(ctx context.Context, c *Config) error { + _, err := s.db.Pool.Exec(ctx, ` + INSERT INTO stormguard_config (vehicle_id, enabled, lat, lng, target_soc, updated_at) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (vehicle_id) DO UPDATE SET + enabled = EXCLUDED.enabled, lat = EXCLUDED.lat, lng = EXCLUDED.lng, + target_soc = EXCLUDED.target_soc, updated_at = now()`, + c.VehicleID, c.Enabled, c.Lat, c.Lng, c.TargetSOC, + ) + if err != nil { + return fmt.Errorf("stormguard: upsert config: %w", err) + } + return nil +} + +// ArmedConfigs returns every enabled config for the hourly evaluator. +func (s *Store) ArmedConfigs(ctx context.Context) ([]*Config, error) { + rows, err := s.db.Pool.Query(ctx, + `SELECT vehicle_id, enabled, lat, lng, target_soc, updated_at + FROM stormguard_config WHERE enabled`) + if err != nil { + return nil, fmt.Errorf("stormguard: list armed: %w", err) + } + defer rows.Close() + var out []*Config + for rows.Next() { + c := &Config{} + if err := rows.Scan(&c.VehicleID, &c.Enabled, &c.Lat, &c.Lng, &c.TargetSOC, &c.UpdatedAt); err != nil { + return nil, fmt.Errorf("stormguard: scan armed: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("stormguard: list armed: %w", err) + } + return out, nil +} + +// LogEvent appends an assessment/action row. +func (s *Store) LogEvent(ctx context.Context, e *Event) error { + err := s.db.Pool.QueryRow(ctx, ` + INSERT INTO stormguard_events (vehicle_id, level, reason, acted) + VALUES ($1, $2, $3, $4) RETURNING id, created_at`, + e.VehicleID, e.Level, e.Reason, e.Acted, + ).Scan(&e.ID, &e.CreatedAt) + if err != nil { + return fmt.Errorf("stormguard: log event: %w", err) + } + return nil +} + +// LastEventLevel returns the most recent logged level for dedupe ("" +// / when none). +func (s *Store) LastEventLevel(ctx context.Context, vehicleID int64) (string, error) { + var level string + err := s.db.Pool.QueryRow(ctx, + `SELECT level FROM stormguard_events + WHERE vehicle_id = $1 ORDER BY id DESC LIMIT 1`, vehicleID, + ).Scan(&level) + if err == pgx.ErrNoRows { + return "", nil + } + if err != nil { + return "", fmt.Errorf("stormguard: last level: %w", err) + } + return level, nil +} + +// ListEvents returns recent events, newest first. Limit clamped 1..100. +func (s *Store) ListEvents(ctx context.Context, vehicleID int64, limit int) ([]*Event, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + rows, err := s.db.Pool.Query(ctx, ` + SELECT id, vehicle_id, level, reason, acted, created_at + FROM stormguard_events WHERE vehicle_id = $1 + ORDER BY id DESC LIMIT $2`, vehicleID, limit) + if err != nil { + return nil, fmt.Errorf("stormguard: list events: %w", err) + } + defer rows.Close() + out := []*Event{} + for rows.Next() { + e := &Event{} + if err := rows.Scan(&e.ID, &e.VehicleID, &e.Level, &e.Reason, &e.Acted, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("stormguard: scan event: %w", err) + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("stormguard: list events: %w", err) + } + return out, nil +} diff --git a/internal/api/tco/ledger.go b/internal/api/tco/ledger.go new file mode 100644 index 0000000000..82b4e6fd61 --- /dev/null +++ b/internal/api/tco/ledger.go @@ -0,0 +1,99 @@ +package tco + +import ( + "fmt" + "math" + "regexp" + "time" +) + +// Ledger categories. Stored as free text but validated against this set so +// the UI can render stable labels and the rollup stays meaningful. +const ( + LedgerPayment = "payment" + LedgerInsurance = "insurance" + LedgerMaintenance = "maintenance" + LedgerService = "service" + LedgerTires = "tires" + LedgerAccessories = "accessories" + LedgerDepreciation = "depreciation" + LedgerOther = "other" +) + +// ValidLedgerCategories is the allowlist for entry categories. +func ValidLedgerCategories() []string { + return []string{ + LedgerPayment, LedgerInsurance, LedgerMaintenance, LedgerService, + LedgerTires, LedgerAccessories, LedgerDepreciation, LedgerOther, + } +} + +// LedgerEntry is one fixed-cost row: loan/lease payments, insurance, service, +// tires, or a depreciation estimate the owner records. +type LedgerEntry struct { + ID int64 `json:"id"` + VehicleID int64 `json:"vehicle_id"` + Category string `json:"category"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Incurred string `json:"incurred_on"` + Note string `json:"note"` + CreatedAt string `json:"created_at"` +} + +var currencyRe = regexp.MustCompile(`^[A-Z]{3}$`) + +// ValidateLedgerEntry rejects malformed rows before persistence. now pins +// the future-date guard so tests are deterministic. +func ValidateLedgerEntry(e LedgerEntry, now time.Time) error { + if e.VehicleID <= 0 { + return fmt.Errorf("vehicle_id is required") + } + valid := false + for _, c := range ValidLedgerCategories() { + if e.Category == c { + valid = true + break + } + } + if !valid { + return fmt.Errorf("unknown category: %s", e.Category) + } + if !(e.Amount > 0) || e.Amount >= 10_000_000 { + return fmt.Errorf("amount must be positive and below 10,000,000") + } + if !currencyRe.MatchString(e.Currency) { + return fmt.Errorf("currency must be a 3-letter ISO code") + } + day, err := time.Parse("2006-01-02", e.Incurred) + if err != nil { + return fmt.Errorf("incurred_on must be YYYY-MM-DD") + } + if day.After(now.AddDate(1, 0, 0)) { + return fmt.Errorf("incurred_on is too far in the future") + } + if len(e.Note) > 280 { + return fmt.Errorf("note must be at most 280 characters") + } + return nil +} + +// LedgerTotals is the pure rollup over a vehicle's entries. +type LedgerTotals struct { + ByCategory map[string]float64 `json:"by_category"` + GrandTotal float64 `json:"grand_total"` + Entries int `json:"entries"` +} + +// SummarizeLedger folds entries into per-category totals. Amounts are +// rounded to cents; an empty input yields an empty (non-nil) map. +func SummarizeLedger(entries []LedgerEntry) LedgerTotals { + t := LedgerTotals{ByCategory: map[string]float64{}, Entries: len(entries)} + for _, e := range entries { + t.ByCategory[e.Category] = roundCents(t.ByCategory[e.Category] + e.Amount) + t.GrandTotal = roundCents(t.GrandTotal + e.Amount) + } + return t +} + +func roundCents(f float64) float64 { return math.Round(f*100) / 100 } diff --git a/internal/api/tco/ledger_handler.go b/internal/api/tco/ledger_handler.go new file mode 100644 index 0000000000..f09d2f6153 --- /dev/null +++ b/internal/api/tco/ledger_handler.go @@ -0,0 +1,112 @@ +package tco + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// LedgerHandler serves fixed-cost ledger CRUD. Kept separate from Handler +// so the pinned TCO summary contract is untouched. +type LedgerHandler struct { + store LedgerStore + now func() time.Time +} + +// NewLedgerHandler wires the handler. Panics on nil (fail-fast wiring). +func NewLedgerHandler(store LedgerStore) *LedgerHandler { + if store == nil { + panic("tco: nil ledger store") + } + return &LedgerHandler{store: store, now: time.Now} +} + +// List serves GET /analytics/tco/ledger?vehicle_id=. +func (h *LedgerHandler) List(w http.ResponseWriter, r *http.Request) { + vehicleID, err := strconv.ParseInt(r.URL.Query().Get("vehicle_id"), 10, 64) + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + entries, err := h.store.List(r.Context(), vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("tco.ledger: list failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to list ledger entries") + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{ + "vehicle_id": vehicleID, + "entries": entries, + "totals": SummarizeLedger(entries), + }) +} + +type createLedgerRequest struct { + VehicleID int64 `json:"vehicle_id"` + Category string `json:"category"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Incurred string `json:"incurred_on"` + Note string `json:"note"` +} + +// Create serves POST /analytics/tco/ledger. +func (h *LedgerHandler) Create(w http.ResponseWriter, r *http.Request) { + var req createLedgerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Currency == "" { + req.Currency = "USD" + } + e := LedgerEntry{ + VehicleID: req.VehicleID, + Category: req.Category, + Amount: req.Amount, + Currency: req.Currency, + Incurred: req.Incurred, + Note: req.Note, + } + if err := ValidateLedgerEntry(e, h.now()); err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if err := h.store.Create(r.Context(), &e); err != nil { + log.Error().Err(err).Int64("vehicle_id", e.VehicleID).Msg("tco.ledger: create failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to save ledger entry") + return + } + httpx.WriteJSON(w, http.StatusCreated, &e) +} + +// Delete serves DELETE /analytics/tco/ledger/{id}?vehicle_id=. +func (h *LedgerHandler) Delete(w http.ResponseWriter, r *http.Request) { + vehicleID, err := strconv.ParseInt(r.URL.Query().Get("vehicle_id"), 10, 64) + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil || id <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "id must be a positive integer") + return + } + found, err := h.store.Delete(r.Context(), vehicleID, id) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Int64("id", id).Msg("tco.ledger: delete failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to delete ledger entry") + return + } + if !found { + httpx.WriteError(w, http.StatusNotFound, "ledger entry not found") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/tco/ledger_store.go b/internal/api/tco/ledger_store.go new file mode 100644 index 0000000000..cbfa0802d1 --- /dev/null +++ b/internal/api/tco/ledger_store.go @@ -0,0 +1,115 @@ +package tco + +import ( + "context" + "sync" + + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// LedgerStore persists fixed-cost entries per vehicle. +type LedgerStore interface { + List(ctx context.Context, vehicleID int64) ([]LedgerEntry, error) + Create(ctx context.Context, e *LedgerEntry) error + Delete(ctx context.Context, vehicleID, id int64) (bool, error) +} + +// pgLedgerStore is the postgres-backed LedgerStore. +type pgLedgerStore struct { + db *database.DB +} + +// NewPGLedgerStore wires the store. Panics on nil (fail-fast wiring). +func NewPGLedgerStore(db *database.DB) LedgerStore { + if db == nil { + panic("tco: nil database") + } + return &pgLedgerStore{db: db} +} + +func (s *pgLedgerStore) List(ctx context.Context, vehicleID int64) ([]LedgerEntry, error) { + rows, err := s.db.Pool.Query(ctx, ` + SELECT id, vehicle_id, category, amount, currency, + to_char(incurred_on, 'YYYY-MM-DD'), note, + to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + FROM tco_ledger_entries + WHERE vehicle_id = $1 + ORDER BY incurred_on DESC, id DESC`, vehicleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []LedgerEntry{} + for rows.Next() { + var e LedgerEntry + if err := rows.Scan(&e.ID, &e.VehicleID, &e.Category, &e.Amount, + &e.Currency, &e.Incurred, &e.Note, &e.CreatedAt); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +func (s *pgLedgerStore) Create(ctx context.Context, e *LedgerEntry) error { + return s.db.Pool.QueryRow(ctx, ` + INSERT INTO tco_ledger_entries (vehicle_id, category, amount, currency, incurred_on, note) + VALUES ($1, $2, $3, $4, $5::date, $6) + RETURNING id, to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + e.VehicleID, e.Category, e.Amount, e.Currency, e.Incurred, e.Note, + ).Scan(&e.ID, &e.CreatedAt) +} + +func (s *pgLedgerStore) Delete(ctx context.Context, vehicleID, id int64) (bool, error) { + tag, err := s.db.Pool.Exec(ctx, + `DELETE FROM tco_ledger_entries WHERE vehicle_id = $1 AND id = $2`, + vehicleID, id) + if err != nil { + return false, err + } + return tag.RowsAffected() > 0, nil +} + +// MemoryLedgerStore is an in-memory LedgerStore for tests. +type MemoryLedgerStore struct { + mu sync.Mutex + next int64 + entries map[int64][]LedgerEntry +} + +// NewMemoryLedgerStore creates an empty MemoryLedgerStore. +func NewMemoryLedgerStore() *MemoryLedgerStore { + return &MemoryLedgerStore{entries: map[int64][]LedgerEntry{}} +} + +func (s *MemoryLedgerStore) List(_ context.Context, vehicleID int64) ([]LedgerEntry, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := append([]LedgerEntry{}, s.entries[vehicleID]...) + return out, nil +} + +func (s *MemoryLedgerStore) Create(_ context.Context, e *LedgerEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + s.next++ + e.ID = s.next + s.entries[e.VehicleID] = append(s.entries[e.VehicleID], *e) + return nil +} + +func (s *MemoryLedgerStore) Delete(_ context.Context, vehicleID, id int64) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + kept := s.entries[vehicleID][:0] + found := false + for _, e := range s.entries[vehicleID] { + if e.ID == id { + found = true + continue + } + kept = append(kept, e) + } + s.entries[vehicleID] = kept + return found, nil +} diff --git a/internal/api/tco/ledger_test.go b/internal/api/tco/ledger_test.go new file mode 100644 index 0000000000..a30f8df363 --- /dev/null +++ b/internal/api/tco/ledger_test.go @@ -0,0 +1,132 @@ +package tco + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" +) + +func TestValidateLedgerEntryOK(t *testing.T) { + e := LedgerEntry{VehicleID: 3, Category: "insurance", Amount: 142.5, Currency: "USD", Incurred: "2026-01-15"} + if err := ValidateLedgerEntry(e, time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateLedgerEntryRejects(t *testing.T) { + now := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + base := LedgerEntry{VehicleID: 3, Category: "insurance", Amount: 142.5, Currency: "USD", Incurred: "2026-01-15"} + cases := map[string]func(*LedgerEntry){ + "bad category": func(e *LedgerEntry) { e.Category = "yacht" }, + "zero amount": func(e *LedgerEntry) { e.Amount = 0 }, + "bad currency": func(e *LedgerEntry) { e.Currency = "usd" }, + "bad date": func(e *LedgerEntry) { e.Incurred = "15/01/2026" }, + "future date": func(e *LedgerEntry) { e.Incurred = "2028-01-01" }, + "long note": func(e *LedgerEntry) { e.Note = strings.Repeat("x", 281) }, + "missing veh": func(e *LedgerEntry) { e.VehicleID = 0 }, + } + for name, mutate := range cases { + e := base + mutate(&e) + if err := ValidateLedgerEntry(e, now); err == nil { + t.Fatalf("%s: expected error", name) + } + } +} + +func TestSummarizeLedger(t *testing.T) { + s := SummarizeLedger([]LedgerEntry{ + {Category: "insurance", Amount: 100}, + {Category: "insurance", Amount: 50.5}, + {Category: "tires", Amount: 800}, + }) + if s.GrandTotal != 950.5 || s.Entries != 3 { + t.Fatalf("unexpected totals: %+v", s) + } + if s.ByCategory["insurance"] != 150.5 || s.ByCategory["tires"] != 800 { + t.Fatalf("unexpected categories: %+v", s.ByCategory) + } + if empty := SummarizeLedger(nil); empty.ByCategory == nil || empty.GrandTotal != 0 { + t.Fatalf("empty input must yield empty totals: %+v", empty) + } +} + +func TestLedgerListServesEntriesAndTotals(t *testing.T) { + store := NewMemoryLedgerStore() + _ = store.Create(context.Background(), &LedgerEntry{VehicleID: 3, Category: "payment", Amount: 500, Currency: "USD", Incurred: "2026-01-01"}) + h := NewLedgerHandler(store) + req := httptest.NewRequest(http.MethodGet, "/ledger?vehicle_id=3", nil) + rec := httptest.NewRecorder() + h.List(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var res struct { + Entries []LedgerEntry `json:"entries"` + Totals LedgerTotals `json:"totals"` + } + if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { + t.Fatal(err) + } + if len(res.Entries) != 1 || res.Totals.GrandTotal != 500 { + t.Fatalf("unexpected list: %+v", res) + } +} + +func TestLedgerCreateRoundTrips(t *testing.T) { + h := NewLedgerHandler(NewMemoryLedgerStore()) + body := `{"vehicle_id":3,"category":"tires","amount":800,"currency":"USD","incurred_on":"2026-01-10","note":"winter set"}` + req := httptest.NewRequest(http.MethodPost, "/ledger", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.Create(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201 (%s)", rec.Code, rec.Body.String()) + } + var e LedgerEntry + if err := json.NewDecoder(rec.Body).Decode(&e); err != nil { + t.Fatal(err) + } + if e.ID == 0 || e.Category != "tires" { + t.Fatalf("unexpected entry: %+v", e) + } +} + +func TestLedgerCreateDefaultsCurrency(t *testing.T) { + h := NewLedgerHandler(NewMemoryLedgerStore()) + body := `{"vehicle_id":3,"category":"service","amount":60,"incurred_on":"2026-01-10"}` + req := httptest.NewRequest(http.MethodPost, "/ledger", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.Create(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201 (%s)", rec.Code, rec.Body.String()) + } +} + +func TestLedgerDeleteRemoves(t *testing.T) { + store := NewMemoryLedgerStore() + e := &LedgerEntry{VehicleID: 3, Category: "other", Amount: 10, Currency: "USD", Incurred: "2026-01-01"} + _ = store.Create(context.Background(), e) + h := NewLedgerHandler(store) + + r := chi.NewRouter() + r.Delete("/ledger/{id}", h.Delete) + req := httptest.NewRequest(http.MethodDelete, "/ledger/1?vehicle_id=3", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec.Code) + } + + req2 := httptest.NewRequest(http.MethodDelete, "/ledger/1?vehicle_id=3", nil) + rec2 := httptest.NewRecorder() + r.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec2.Code) + } +} diff --git a/internal/api/tempimpact/shift.go b/internal/api/tempimpact/shift.go new file mode 100644 index 0000000000..ca980d7d8c --- /dev/null +++ b/internal/api/tempimpact/shift.go @@ -0,0 +1,137 @@ +package tempimpact + +import ( + "context" + "fmt" + "math" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Shift verdicts for the efficiency detective. +const ( + shiftStable = "stable" + shiftColderWeather = "colder_weather" + shiftWarmerDriving = "warmer_driving" + shiftDrivingPattern = "driving_pattern" + shiftInsufficient = "insufficient_data" +) + +// EfficiencyShift is the GET /analytics/temperature-impact/shift response: +// latest-month vs prior-month efficiency with temperature attribution. +type EfficiencyShift struct { + LatestMonth string `json:"latest_month"` + PriorMonth string `json:"prior_month"` + LatestEfficiency float64 `json:"latest_efficiency"` + PriorEfficiency float64 `json:"prior_efficiency"` + EfficiencyDelta float64 `json:"efficiency_delta_pct"` + LatestTemp float64 `json:"latest_temp_c"` + PriorTemp float64 `json:"prior_temp_c"` + TempDelta float64 `json:"temp_delta_c"` + TempSensitivity float64 `json:"temp_sensitivity_per_c"` + TempAttributed float64 `json:"temp_attributed_pct"` + ResidualPct float64 `json:"residual_pct"` + Verdict string `json:"verdict"` + Explanation string `json:"explanation"` +} + +// AnalyzeShift compares the two most recent qualifying months (drive_count +// >= 3) and attributes the efficiency move to temperature via the +// least-squares slope of the qualifying series. Efficiency here is +// battery-%/100km (lower is better); deltas are signed accordingly. +func AnalyzeShift(months []monthlyTempTrend) EfficiencyShift { + qualified := months[:0:0] + for _, m := range months { + if m.DriveCount >= 3 { + qualified = append(qualified, m) + } + } + if len(qualified) < 2 { + return EfficiencyShift{Verdict: shiftInsufficient, + Explanation: "Need at least two months with 3+ drives each to diagnose an efficiency shift."} + } + prior, latest := qualified[len(qualified)-2], qualified[len(qualified)-1] + + rep := EfficiencyShift{ + LatestMonth: latest.Month, PriorMonth: prior.Month, + LatestEfficiency: round2(latest.AvgEfficiency), PriorEfficiency: round2(prior.AvgEfficiency), + LatestTemp: round1(latest.AvgTemp), PriorTemp: round1(prior.AvgTemp), + } + rep.TempDelta = round1(latest.AvgTemp - prior.AvgTemp) + if prior.AvgEfficiency != 0 { + // Negative delta = improvement (fewer %/100km). + rep.EfficiencyDelta = round2((latest.AvgEfficiency - prior.AvgEfficiency) / math.Abs(prior.AvgEfficiency) * 100) + } + + slope := tempSlope(qualified) + rep.TempSensitivity = round2(slope) + // slope is %/100km per °C; convert the explained move into percent of + // the prior baseline so it compares directly with EfficiencyDelta. + if prior.AvgEfficiency != 0 { + rep.TempAttributed = round2(slope * (latest.AvgTemp - prior.AvgTemp) / math.Abs(prior.AvgEfficiency) * 100) + } + rep.ResidualPct = round2(rep.EfficiencyDelta - rep.TempAttributed) + + delta, attr := rep.EfficiencyDelta, rep.TempAttributed + switch { + case math.Abs(delta) < 5: + rep.Verdict = shiftStable + rep.Explanation = fmt.Sprintf( + "Efficiency is stable (%+.1f%% month over month) — no diagnosis needed.", delta) + case delta > 0 && attr > 0 && math.Abs(attr) >= math.Abs(delta)*0.6: + rep.Verdict = shiftColderWeather + rep.Explanation = fmt.Sprintf( + "Efficiency worsened %+.1f%% and colder weather explains about %.1f%% of it (%.1f°C drop × %.2f%%/100km per °C). Battery heating and denser air are the likely drivers — not your driving.", + delta, math.Abs(attr), math.Abs(rep.TempDelta), math.Abs(slope)) + case delta < 0 && attr < 0 && math.Abs(attr) >= math.Abs(delta)*0.6: + rep.Verdict = shiftWarmerDriving + rep.Explanation = fmt.Sprintf( + "Efficiency improved %+.1f%%, mostly warmer weather (+%.1f°C). Enjoy it — and bank the number as your fair-weather baseline.", + delta, rep.TempDelta) + default: + rep.Verdict = shiftDrivingPattern + rep.Explanation = fmt.Sprintf( + "Efficiency moved %+.1f%% but temperature explains only %.1f%% of it. Check tire pressure, shorter trips, higher speeds, or roof loads before blaming the weather.", + delta, attr) + } + return rep +} + +// tempSlope fits efficiency (%/100km) on temperature (°C) by least squares. +// A negative slope means warmer months use less battery per 100km. +func tempSlope(months []monthlyTempTrend) float64 { + var sx, sy, sxx, sxy float64 + n := float64(len(months)) + for _, m := range months { + sx += m.AvgTemp + sy += m.AvgEfficiency + sxx += m.AvgTemp * m.AvgTemp + sxy += m.AvgTemp * m.AvgEfficiency + } + denom := n*sxx - sx*sx + if denom == 0 { + return 0 + } + return (n*sxy - sx*sy) / denom +} + +// Shift serves GET /analytics/temperature-impact/shift?vehicle_id=.... +func (h *Handler) Shift(w http.ResponseWriter, r *http.Request) { + vehicleID, err := parseVehicleID(r.URL.Query().Get("vehicle_id")) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + ctx, cancel := context.WithTimeout(r.Context(), queryTimeout) + defer cancel() + trend, err := h.repo.MonthlyTrend(ctx, vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicleID", vehicleID).Msg("temp impact: failed to query monthly trend") + httpx.WriteError(w, http.StatusInternalServerError, "failed to query monthly trend") + return + } + httpx.WriteJSON(w, http.StatusOK, AnalyzeShift(trend)) +} diff --git a/internal/api/tempimpact/shift_test.go b/internal/api/tempimpact/shift_test.go new file mode 100644 index 0000000000..661e91e044 --- /dev/null +++ b/internal/api/tempimpact/shift_test.go @@ -0,0 +1,98 @@ +package tempimpact + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func shiftMonth(month string, temp, eff float64, drives int) monthlyTempTrend { + return monthlyTempTrend{Month: month, AvgTemp: temp, AvgEfficiency: eff, DriveCount: drives} +} + +func TestAnalyzeShiftStable(t *testing.T) { + rep := AnalyzeShift([]monthlyTempTrend{ + shiftMonth("2025-11", 10, 20.0, 20), + shiftMonth("2025-12", 9, 20.5, 22), + }) + if rep.Verdict != shiftStable { + t.Fatalf("verdict = %s, want stable (%+v)", rep.Verdict, rep) + } +} + +func TestAnalyzeShiftColderWeather(t *testing.T) { + rep := AnalyzeShift([]monthlyTempTrend{ + shiftMonth("2025-09", 20, 17.0, 20), + shiftMonth("2025-10", 15, 18.5, 20), + shiftMonth("2025-11", 10, 20.0, 20), + shiftMonth("2025-12", 2, 23.0, 22), + }) + if rep.Verdict != shiftColderWeather { + t.Fatalf("verdict = %s, want colder_weather (%+v)", rep.Verdict, rep) + } + if rep.TempSensitivity >= 0 { + t.Fatalf("sensitivity = %v, want negative (warmer = leaner)", rep.TempSensitivity) + } + if rep.Explanation == "" { + t.Fatal("expected an explanation") + } +} + +func TestAnalyzeShiftDrivingPattern(t *testing.T) { + // Same temperature, efficiency jumps anyway → residual dominates. + rep := AnalyzeShift([]monthlyTempTrend{ + shiftMonth("2025-09", 20, 17.0, 20), + shiftMonth("2025-10", 20, 17.2, 20), + shiftMonth("2025-11", 20, 17.1, 20), + shiftMonth("2025-12", 20, 22.0, 22), + }) + if rep.Verdict != shiftDrivingPattern { + t.Fatalf("verdict = %s, want driving_pattern (%+v)", rep.Verdict, rep) + } +} + +func TestAnalyzeShiftInsufficient(t *testing.T) { + rep := AnalyzeShift([]monthlyTempTrend{shiftMonth("2025-12", 2, 23.0, 22)}) + if rep.Verdict != shiftInsufficient { + t.Fatalf("verdict = %s, want insufficient_data", rep.Verdict) + } + // Thin months don't qualify. + rep = AnalyzeShift([]monthlyTempTrend{ + shiftMonth("2025-11", 10, 20.0, 1), + shiftMonth("2025-12", 2, 23.0, 1), + }) + if rep.Verdict != shiftInsufficient { + t.Fatalf("verdict = %s, want insufficient_data", rep.Verdict) + } +} + +func TestShiftServesReport(t *testing.T) { + h := newHandler(&fakeTempImpactRepo{trend: []monthlyTempTrend{ + shiftMonth("2025-11", 10, 20.0, 20), + shiftMonth("2025-12", 2, 23.0, 22), + }}) + req := httptest.NewRequest(http.MethodGet, "/shift?vehicle_id=4", nil) + rec := httptest.NewRecorder() + h.Shift(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var rep EfficiencyShift + if err := json.NewDecoder(rec.Body).Decode(&rep); err != nil { + t.Fatal(err) + } + if rep.PriorMonth == "" || rep.LatestMonth == "" || rep.Verdict == "" { + t.Fatalf("incomplete report: %+v", rep) + } +} + +func TestShiftRejectsBadVehicle(t *testing.T) { + h := newHandler(&fakeTempImpactRepo{}) + req := httptest.NewRequest(http.MethodGet, "/shift?vehicle_id=x", nil) + rec := httptest.NewRecorder() + h.Shift(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} diff --git a/internal/api/teslachargehist/handler.go b/internal/api/teslachargehist/handler.go index 60b195bc2b..5f4a488dfb 100644 --- a/internal/api/teslachargehist/handler.go +++ b/internal/api/teslachargehist/handler.go @@ -125,26 +125,36 @@ func (h *TeslaChargingHistoryHandler) Refresh(w http.ResponseWriter, r *http.Req return } - var resp teslaChargingHistoryResponse - if err := json.Unmarshal(body, &resp); err != nil { + page, err := decodeTeslaChargingHistoryPage(body) + if err != nil { log.Error().Err(err).Msg("failed to parse tesla charging history response") httpx.WriteError(w, http.StatusInternalServerError, "failed to parse Tesla response") return } - entries := parseTeslaChargingEntries(resp.Response.Data) + entries := parseTeslaChargingEntries(page.Data) allEntries = append(allEntries, entries...) - if !resp.Response.HasMoreData || len(resp.Response.Data) == 0 { + if len(page.Data) == 0 { break } - pageNo++ - - // Safety limit to prevent infinite loops - if pageNo > 100 { - log.Warn().Msg("tesla charging history: hit 100-page safety limit") - break + if page.HasMoreData { + pageNo++ + if pageNo > 100 { + log.Warn().Msg("tesla charging history: hit 100-page safety limit") + break + } + continue } + if page.TotalResults > 0 && len(allEntries) < page.TotalResults && len(page.Data) == pageSize { + pageNo++ + if pageNo > 100 { + log.Warn().Msg("tesla charging history: hit 100-page safety limit") + break + } + continue + } + break } upserted, err := h.repo.UpsertBatch(r.Context(), allEntries) @@ -216,12 +226,30 @@ func (h *TeslaChargingHistoryHandler) Invoice(w http.ResponseWriter, r *http.Req // --- Tesla API response types --- +// teslaChargingHistoryPage is one charging-history page. Tesla's DX endpoint +// has shipped both `{response:{data,totalResults,hasMoreData}}` and a +// top-level `{data,totalResults}` envelope; decodeTeslaChargingHistoryPage +// accepts either so a successful Tesla fetch is not discarded as empty. +type teslaChargingHistoryPage struct { + Data []teslaChargingHistoryItem `json:"data"` + TotalResults int `json:"totalResults"` + HasMoreData bool `json:"hasMoreData"` +} + type teslaChargingHistoryResponse struct { - Response struct { - Data []teslaChargingHistoryItem `json:"data"` - TotalResults int `json:"totalResults"` - HasMoreData bool `json:"hasMoreData"` - } `json:"response"` + Response teslaChargingHistoryPage `json:"response"` + teslaChargingHistoryPage +} + +func decodeTeslaChargingHistoryPage(body []byte) (teslaChargingHistoryPage, error) { + var wrapped teslaChargingHistoryResponse + if err := json.Unmarshal(body, &wrapped); err != nil { + return teslaChargingHistoryPage{}, err + } + if len(wrapped.Response.Data) > 0 || wrapped.Response.HasMoreData || wrapped.Response.TotalResults > 0 { + return wrapped.Response, nil + } + return wrapped.teslaChargingHistoryPage, nil } type teslaChargingHistoryItem struct { @@ -231,6 +259,7 @@ type teslaChargingHistoryItem struct { ChargeStartDateTime string `json:"chargeStartDateTime"` ChargeStopDateTime string `json:"chargeStopDateTime"` Country string `json:"country"` + CountryCode string `json:"countryCode"` State string `json:"state"` County string `json:"county"` PostalCode string `json:"postalCode"` @@ -279,9 +308,13 @@ func parseTeslaChargingEntries(items []teslaChargingHistoryItem) []*teslamodel.T } } - // Location fields - if item.Country != "" { - e.Country = &item.Country + // Location fields. Tesla DX sessions expose ISO country as countryCode. + country := item.Country + if country == "" { + country = item.CountryCode + } + if country != "" { + e.Country = &country } if item.State != "" { e.State = &item.State diff --git a/internal/api/teslachargehist/handler_test.go b/internal/api/teslachargehist/handler_test.go index 77c63a94a7..7f49f35674 100644 --- a/internal/api/teslachargehist/handler_test.go +++ b/internal/api/teslachargehist/handler_test.go @@ -156,6 +156,16 @@ func historyPageBytes(hasMore bool) []byte { return b } +func unwrappedHistoryPage(t *testing.T, items ...teslaChargingHistoryItem) []byte { + t.Helper() + page := teslaChargingHistoryPage{Data: items, TotalResults: len(items)} + b, err := json.Marshal(page) + if err != nil { + t.Fatalf("marshal unwrapped history page: %v", err) + } + return b +} + func validItem(sessionID int64) teslaChargingHistoryItem { return teslaChargingHistoryItem{ SessionID: sessionID, @@ -397,6 +407,17 @@ func TestParseTeslaChargingEntries_Table(t *testing.T) { } }, }, + { + name: "countryCode fills country when country is omitted", + items: []teslaChargingHistoryItem{{ + SessionID: 1, + ChargeStartDateTime: "2026-01-02T15:04:05Z", + CountryCode: "US", + }}, + verify: func(t *testing.T, got []*teslamodel.TeslaChargingHistoryEntry) { + wantStrPtr(t, "Country", got[0].Country, "US") + }, + }, { name: "empty optional location strings stay nil", items: []teslaChargingHistoryItem{{ @@ -630,6 +651,49 @@ func TestRefresh(t *testing.T) { } }) + t.Run("unwrapped Tesla DX envelope still upserts sessions", func(t *testing.T) { + api := &fakeChargeHistoryAPI{ + historyFn: func(_ context.Context, _, _, _ string, _, _ int) ([]byte, int, error) { + item := validItem(758665885) + item.Country = "" + item.CountryCode = "US" + item.SiteLocationName = "Everett, WA" + return unwrappedHistoryPage(t, item), http.StatusOK, nil + }, + } + store := &fakeChargeHistoryStore{ + getAllFn: func(_ context.Context, _ string, _, _ int) ([]*teslamodel.TeslaChargingHistoryEntry, error) { + return []*teslamodel.TeslaChargingHistoryEntry{{SessionID: 758665885, SiteLocationName: "Everett, WA"}}, nil + }, + } + h := newHandler(api, store) + + rec := httptest.NewRecorder() + h.Refresh(rec, httptest.NewRequest(http.MethodGet, "/tesla/charging/history/refresh?vin=5YJ", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if len(store.upsertBatches) != 1 || len(store.upsertBatches[0]) != 1 { + t.Fatalf("upsert batch = %+v, want one session from unwrapped envelope", store.upsertBatches) + } + got := store.upsertBatches[0][0] + if got.SessionID != 758665885 { + t.Fatalf("SessionID = %d, want 758665885", got.SessionID) + } + if got.SiteLocationName != "Everett, WA" { + t.Fatalf("SiteLocationName = %q", got.SiteLocationName) + } + wantStrPtr(t, "Country", got.Country, "US") + var resp listResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Upserted == nil || *resp.Upserted != 1 { + t.Fatalf("upserted = %v, want 1", resp.Upserted) + } + }) + t.Run("omitted dates default to a ~3 month window", func(t *testing.T) { api := &fakeChargeHistoryAPI{} store := &fakeChargeHistoryStore{} diff --git a/internal/api/teslachargehist/sites.go b/internal/api/teslachargehist/sites.go new file mode 100644 index 0000000000..a20ef731e7 --- /dev/null +++ b/internal/api/teslachargehist/sites.go @@ -0,0 +1,96 @@ +package teslachargehist + +import ( + "math" + "net/http" + "sort" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" +) + +// siteRankLimit bounds the invoice scan for the site ranking. History rows +// are small and the fold is O(n); 2000 covers years of Supercharging. +const siteRankLimit = 2000 + +// SiteRank is one visited Supercharger/DC site with its realized $/kWh. +type SiteRank struct { + Site string `json:"site"` + Visits int `json:"visits"` + TotalWh float64 `json:"total_wh"` + TotalSpend float64 `json:"total_spend"` + AvgPerKWh float64 `json:"avg_per_kwh"` + LastVisit string `json:"last_visit"` +} + +// SiteRanking is the GET /tesla/charging/history/sites response. +type SiteRanking struct { + Sites []SiteRank `json:"sites"` + UnpricedCount int `json:"unpriced_count"` +} + +// RankSites folds invoice entries into per-site realized pricing, cheapest +// first. Entries without metered usage + spend are counted as unpriced +// instead of polluting the ranking with zeros. +func RankSites(entries []*teslamodel.TeslaChargingHistoryEntry) SiteRanking { + ranking := SiteRanking{Sites: []SiteRank{}} + bySite := map[string]*SiteRank{} + for _, e := range entries { + if e == nil { + continue + } + wh, spend := deref(e.UsageWh), deref(e.TotalDue) + if wh <= 0 || spend <= 0 { + ranking.UnpricedCount++ + continue + } + name := e.SiteLocationName + if name == "" { + name = "Unknown site" + } + s, ok := bySite[name] + if !ok { + s = &SiteRank{Site: name} + bySite[name] = s + } + s.Visits++ + s.TotalWh += wh + s.TotalSpend += spend + if last := e.ChargeStartDatetime.Format("2006-01-02"); last > s.LastVisit { + s.LastVisit = last + } + } + for _, s := range bySite { + s.TotalWh = round2(s.TotalWh) + s.TotalSpend = round2(s.TotalSpend) + s.AvgPerKWh = round4(s.TotalSpend / (s.TotalWh / 1000)) + ranking.Sites = append(ranking.Sites, *s) + } + sort.Slice(ranking.Sites, func(i, j int) bool { return ranking.Sites[i].AvgPerKWh < ranking.Sites[j].AvgPerKWh }) + return ranking +} + +// Sites serves GET /tesla/charging/history/sites?vin=.... +func (h *TeslaChargingHistoryHandler) Sites(w http.ResponseWriter, r *http.Request) { + vin := r.URL.Query().Get("vin") + entries, err := h.repo.GetAll(r.Context(), vin, siteRankLimit, 0) + if err != nil { + log.Error().Err(err).Msg("failed to list tesla charging history for site ranking") + httpx.WriteError(w, http.StatusInternalServerError, "failed to rank charging sites") + return + } + httpx.WriteJSON(w, http.StatusOK, RankSites(entries)) +} + +func deref(f *float64) float64 { + if f == nil { + return 0 + } + return *f +} + +func round2(f float64) float64 { return math.Round(f*100) / 100 } + +func round4(f float64) float64 { return math.Round(f*10000) / 10000 } diff --git a/internal/api/teslachargehist/sites_test.go b/internal/api/teslachargehist/sites_test.go new file mode 100644 index 0000000000..b1f260d26d --- /dev/null +++ b/internal/api/teslachargehist/sites_test.go @@ -0,0 +1,70 @@ +package teslachargehist + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" +) + +func siteEntry(site string, wh, due float64, day string) *teslamodel.TeslaChargingHistoryEntry { + t, _ := time.Parse("2006-01-02", day) + return &teslamodel.TeslaChargingHistoryEntry{ + SiteLocationName: site, UsageWh: &wh, TotalDue: &due, ChargeStartDatetime: t, + } +} + +func TestRankSitesCheapestFirst(t *testing.T) { + got := RankSites([]*teslamodel.TeslaChargingHistoryEntry{ + siteEntry("Pricey SC", 50000, 25, "2026-01-02"), // $0.50/kWh + siteEntry("Cheap SC", 50000, 15, "2026-01-03"), // $0.30/kWh + siteEntry("Cheap SC", 25000, 7.5, "2026-01-10"), // $0.30/kWh again + {SiteLocationName: "No invoice"}, // unpriced + }) + if len(got.Sites) != 2 { + t.Fatalf("sites = %d, want 2", len(got.Sites)) + } + if got.Sites[0].Site != "Cheap SC" || got.Sites[0].AvgPerKWh != 0.3 { + t.Fatalf("first = %+v, want Cheap SC @ 0.30", got.Sites[0]) + } + if got.Sites[0].Visits != 2 || got.Sites[0].LastVisit != "2026-01-10" { + t.Fatalf("cheap site = %+v", got.Sites[0]) + } + if got.UnpricedCount != 1 { + t.Fatalf("unpriced = %d, want 1", got.UnpricedCount) + } +} + +func TestRankSitesEmpty(t *testing.T) { + got := RankSites(nil) + if got.Sites == nil || len(got.Sites) != 0 { + t.Fatalf("expected empty non-nil sites: %+v", got) + } +} + +func TestSitesServesRanking(t *testing.T) { + h := newHandler(&fakeChargeHistoryAPI{}, &fakeChargeHistoryStore{ + getAllFn: func(_ context.Context, _ string, _ int, _ int) ([]*teslamodel.TeslaChargingHistoryEntry, error) { + return []*teslamodel.TeslaChargingHistoryEntry{ + siteEntry("A", 40000, 12, "2026-01-01"), + }, nil + }, + }) + req := httptest.NewRequest(http.MethodGet, "/sites?vin=V1", nil) + rec := httptest.NewRecorder() + h.Sites(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var res SiteRanking + if err := json.NewDecoder(rec.Body).Decode(&res); err != nil { + t.Fatal(err) + } + if len(res.Sites) != 1 || res.Sites[0].AvgPerKWh != 0.3 { + t.Fatalf("unexpected ranking: %+v", res) + } +} diff --git a/internal/api/teslaenergylivestatus/advice.go b/internal/api/teslaenergylivestatus/advice.go new file mode 100644 index 0000000000..01f37210ff --- /dev/null +++ b/internal/api/teslaenergylivestatus/advice.go @@ -0,0 +1,124 @@ +package teslaenergylivestatus + +import ( + "fmt" + "math" + "net/http" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" +) + +// Charge-advice verdicts. +const ( + adviceChargeNow = "charge_now" + adviceChargeSoon = "charge_soon" + adviceWait = "wait" + adviceNoData = "no_data" +) + +const ( + // minChargeW is the practical floor for useful car charging (~6A @ 240V). + minChargeW = 1400.0 + // soonChargeW is the surplus band worth waiting/watching (~2A+ @ 240V). + soonChargeW = 500.0 + // chargerVoltageV converts surplus watts into a solar-matched amp target. + chargerVoltageV = 240.0 + // maxAdviceAmps caps the recommendation at a common home-charging ceiling. + maxAdviceAmps = 48 +) + +// ChargeAdvice is the GET .../charge-advice response: whether surplus solar +// is available for car charging and the solar-matched amp target. +// +// Sign conventions (canonical, shared with the power-flow UI): +// battery_power < 0 means the Powerwall is charging (a load); +// grid_power < 0 means exporting to the grid. +type ChargeAdvice struct { + Verdict string `json:"verdict"` + SurplusW float64 `json:"surplus_w"` + SolarW float64 `json:"solar_w"` + HomeW float64 `json:"home_w"` + BatteryChargeW float64 `json:"battery_charge_w"` + RecommendedAmps int `json:"recommended_amps"` + SnapshotAgeS int64 `json:"snapshot_age_s"` + Explanation string `json:"explanation"` +} + +// AdviseCharge is the pure surplus computation over a live-status snapshot. +// A nil snapshot degrades to no_data. nowSecs pins snapshot age for tests. +func AdviseCharge(snap *teslamodel.TeslaEnergyLiveStatus, nowSecs int64) ChargeAdvice { + if snap == nil { + return ChargeAdvice{Verdict: adviceNoData, + Explanation: "No energy snapshot yet — refresh live status to get solar charging advice."} + } + solar := deref(snap.SolarPower) + home := deref(snap.LoadPower) + // Only charging Powerwall flow counts as committed load; a discharging + // pack is stored energy, conservatively excluded from "free" surplus. + battCharge := math.Max(-deref(snap.BatteryPower), 0) + surplus := math.Max(solar-home-battCharge, 0) + + age := nowSecs - snap.Timestamp.Unix() + if age < 0 { + age = 0 + } + rep := ChargeAdvice{ + SurplusW: round0(surplus), + SolarW: round0(solar), + HomeW: round0(home), + BatteryChargeW: round0(battCharge), + SnapshotAgeS: age, + } + rep.RecommendedAmps = int(math.Min(surplus/chargerVoltageV, maxAdviceAmps)) + + switch { + case surplus >= minChargeW: + rep.Verdict = adviceChargeNow + rep.Explanation = fmt.Sprintf( + "%.1f kW of surplus solar is available — charge the car at ~%dA to soak it up instead of exporting it.", + surplus/1000, rep.RecommendedAmps) + case surplus >= soonChargeW: + rep.Verdict = adviceChargeSoon + rep.Explanation = fmt.Sprintf( + "Only %.1f kW surplus right now — worth a low-amp top-up, or wait for midday sun.", surplus/1000) + default: + rep.Verdict = adviceWait + if solar <= 0 { + rep.Explanation = "No solar production right now — overnight charging should follow the cheap-rate window, not the sun." + } else { + rep.Explanation = fmt.Sprintf( + "Home load (%.1f kW) is eating the %.1f kW of solar — no free surplus for the car yet.", home/1000, solar/1000) + } + } + return rep +} + +// ChargeAdvice serves GET /tesla/energy-sites/{siteID}/charge-advice. +func (h *Handler) ChargeAdvice(w http.ResponseWriter, r *http.Request) { + siteID, err := apiparams.URLParamInt64(r, "siteID") + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid site_id") + return + } + status, err := h.repo.GetLatest(r.Context(), siteID) + if err != nil { + log.Error().Err(err).Int64("site_id", siteID).Msg("failed to get latest energy live status") + httpx.WriteError(w, http.StatusInternalServerError, "failed to query live status") + return + } + httpx.WriteJSON(w, http.StatusOK, AdviseCharge(status, time.Now().Unix())) +} + +func deref(f *float64) float64 { + if f == nil { + return 0 + } + return *f +} + +func round0(f float64) float64 { return math.Round(f) } diff --git a/internal/api/teslaenergylivestatus/advice_test.go b/internal/api/teslaenergylivestatus/advice_test.go new file mode 100644 index 0000000000..bb3f433fa8 --- /dev/null +++ b/internal/api/teslaenergylivestatus/advice_test.go @@ -0,0 +1,88 @@ +package teslaenergylivestatus + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + teslamodel "github.com/ev-dev-labs/teslasync/internal/models/tesla" +) + +func adviceSnap(solar, battery, load float64) *teslamodel.TeslaEnergyLiveStatus { + return &teslamodel.TeslaEnergyLiveStatus{ + SolarPower: &solar, + BatteryPower: &battery, + LoadPower: &load, + Timestamp: time.Unix(1_700_000_000, 0).UTC(), + } +} + +func TestAdviseChargeNow(t *testing.T) { + // 6kW solar, 1kW home, 2kW into Powerwall → 3kW free. + rep := AdviseCharge(adviceSnap(6000, -2000, 1000), 1_700_000_060) + if rep.Verdict != adviceChargeNow { + t.Fatalf("verdict = %s, want charge_now (%+v)", rep.Verdict, rep) + } + if rep.SurplusW != 3000 { + t.Fatalf("surplus = %v, want 3000", rep.SurplusW) + } + if rep.RecommendedAmps != 12 { // 3000/240 + t.Fatalf("amps = %d, want 12", rep.RecommendedAmps) + } + if rep.SnapshotAgeS != 60 { + t.Fatalf("age = %d, want 60", rep.SnapshotAgeS) + } +} + +func TestAdviseChargeWaitAtNight(t *testing.T) { + rep := AdviseCharge(adviceSnap(0, 500, 800), 1_700_000_060) + if rep.Verdict != adviceWait { + t.Fatalf("verdict = %s, want wait", rep.Verdict) + } + if rep.RecommendedAmps != 0 { + t.Fatalf("amps = %d, want 0", rep.RecommendedAmps) + } +} + +func TestAdviseChargeExcludesDischargingPack(t *testing.T) { + // 1kW solar, 0.4kW home, pack discharging 2kW → free surplus is only + // 0.6kW (stored energy is conservatively excluded). + rep := AdviseCharge(adviceSnap(1000, 2000, 400), 1_700_000_060) + if rep.Verdict != adviceChargeSoon { + t.Fatalf("verdict = %s, want charge_soon (%+v)", rep.Verdict, rep) + } +} + +func TestAdviseChargeNoData(t *testing.T) { + if rep := AdviseCharge(nil, 0); rep.Verdict != adviceNoData { + t.Fatalf("verdict = %s, want no_data", rep.Verdict) + } +} + +func TestChargeAdviceServesReport(t *testing.T) { + h := &Handler{repo: &fakeLiveStatusRepo{ + getLatestFn: func(_ context.Context, _ int64) (*teslamodel.TeslaEnergyLiveStatus, error) { + return adviceSnap(6000, -2000, 1000), nil + }, + }} + r := chi.NewRouter() + r.Get("/tesla/energy-sites/{siteID}/charge-advice", h.ChargeAdvice) + req := httptest.NewRequest(http.MethodGet, "/tesla/energy-sites/11/charge-advice", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var rep ChargeAdvice + if err := json.NewDecoder(rec.Body).Decode(&rep); err != nil { + t.Fatal(err) + } + if rep.Verdict != adviceChargeNow || rep.RecommendedAmps != 12 { + t.Fatalf("unexpected advice: %+v", rep) + } +} diff --git a/internal/api/tripplanner/compute.go b/internal/api/tripplanner/compute.go index f5628a0ab2..11f36a7002 100644 --- a/internal/api/tripplanner/compute.go +++ b/internal/api/tripplanner/compute.go @@ -72,6 +72,7 @@ func (h *TripPlannerHandler) computePlan(ctx context.Context, req *tripPlanReque socCurve := h.buildSOCCurve(legs, chargeStops, routeDistanceM) totalDurationS := drivingDurationS + chargingDurationS + evCost := math.Round(chargingCost*100) / 100 return &tripPlanResponse{ Route: tripPlanRoute{ @@ -80,18 +81,54 @@ func (h *TripPlannerHandler) computePlan(ctx context.Context, req *tripPlanReque DrivingDurationS: math.Round(drivingDurationS*10) / 10, ChargingDurationS: math.Round(chargingDurationS*10) / 10, TotalEnergyWh: math.Round(totalEnergyWh*10) / 10, - EstimatedCost: math.Round(chargingCost*100) / 100, + EstimatedCost: evCost, ArrivalSOC: math.Round(arrivalSOC*10) / 10, Feasible: feasible, IsEstimate: true, }, - Legs: legs, - ChargeStops: chargeStops, - WeatherImpact: weatherImpact, - SOCCurve: socCurve, + Legs: legs, + ChargeStops: chargeStops, + WeatherImpact: weatherImpact, + SOCCurve: socCurve, + CostComparison: CompareTripCost(routeDistanceM, evCost, req.Preferences.GasPricePerGallon, req.Preferences.GasMPG), }, nil } +// Default gasoline assumptions for the cost comparison. +const ( + defaultGasPricePerGallon = 3.50 + defaultGasMPG = 30.0 + kmPerMile = 1.60934 +) + +// CompareTripCost contrasts EV charging cost with the gasoline equivalent +// for the same distance. Non-positive gas inputs fall back to defaults so +// older clients (which omit the fields) still get a comparison. +func CompareTripCost(distanceKm, evCost, gasPrice, mpg float64) tripCostComparison { + if gasPrice <= 0 { + gasPrice = defaultGasPricePerGallon + } + if mpg <= 0 { + mpg = defaultGasMPG + } + gallons := distanceKm / kmPerMile / mpg + gasCost := gallons * gasPrice + savings := gasCost - evCost + pct := 0.0 + if gasCost > 0 { + pct = savings / gasCost * 100 + } + return tripCostComparison{ + EVCost: math.Round(evCost*100) / 100, + GasCost: math.Round(gasCost*100) / 100, + GasGallons: math.Round(gallons*100) / 100, + Savings: math.Round(savings*100) / 100, + SavingsPct: math.Round(pct*10) / 10, + GasPrice: gasPrice, + GasMPG: mpg, + } +} + // buildStopsAlongRoute simulates driving the route and inserts charging stops // when SOC drops below the threshold. func (h *TripPlannerHandler) buildStopsAlongRoute( diff --git a/internal/api/tripplanner/confidence.go b/internal/api/tripplanner/confidence.go new file mode 100644 index 0000000000..ab5986b998 --- /dev/null +++ b/internal/api/tripplanner/confidence.go @@ -0,0 +1,115 @@ +package tripplanner + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// Arrival-confidence verdicts. +const ( + confidenceComfortable = "comfortable" + confidenceTight = "tight" + confidenceChargeNow = "charge_now" +) + +type confidenceRequest struct { + CurrentSOC float64 `json:"current_soc"` + BatteryCapacityKWh float64 `json:"battery_capacity_kwh"` + RemainingKm float64 `json:"remaining_km"` + EfficiencyWhKm float64 `json:"efficiency_wh_km"` + EfficiencyFactor float64 `json:"efficiency_factor"` + MinArrivalSOC float64 `json:"min_arrival_soc"` +} + +type confidenceResponse struct { + ArrivalSOC float64 `json:"arrival_soc"` + UsableKWh float64 `json:"usable_kwh"` + NeededKWh float64 `json:"needed_kwh"` + MarginKWh float64 `json:"margin_kwh"` + ChargeNeededKWh float64 `json:"charge_needed_kwh"` + Verdict string `json:"verdict"` + Explanation string `json:"explanation"` +} + +// ComputeConfidence is the pure en-route arrival math: given the current +// SOC and remaining distance, will the car make it above the arrival floor? +// EfficiencyFactor scales consumption (>1 in cold/headwind); defaults apply +// when the caller omits capacity, efficiency, or the arrival floor. +func ComputeConfidence(req confidenceRequest) (confidenceResponse, error) { + if req.CurrentSOC <= 0 || req.CurrentSOC > 100 { + return confidenceResponse{}, fmt.Errorf("current_soc must be 0..100") + } + if req.RemainingKm <= 0 { + return confidenceResponse{}, fmt.Errorf("remaining_km must be positive") + } + capacity := req.BatteryCapacityKWh + if capacity <= 0 { + capacity = defaultBatteryCapacityKWh + } + eff := req.EfficiencyWhKm + if eff <= 0 { + eff = defaultEfficiencyWhKm + } + factor := req.EfficiencyFactor + if factor <= 0 { + factor = 1.0 + } + minArrival := req.MinArrivalSOC + if minArrival < 0 { + minArrival = 10 + } + + usable := req.CurrentSOC / 100 * capacity + needed := req.RemainingKm * eff * factor / 1000 + arrivalKWh := usable - needed + arrivalSOC := arrivalKWh / capacity * 100 + margin := arrivalKWh - minArrival/100*capacity + + rep := confidenceResponse{ + ArrivalSOC: round1(arrivalSOC), + UsableKWh: round1(usable), + NeededKWh: round1(needed), + MarginKWh: round1(margin), + } + switch { + case arrivalSOC >= minArrival+10: + rep.Verdict = confidenceComfortable + rep.Explanation = fmt.Sprintf( + "You'll arrive with ~%.0f%% — %.1f kWh above your %.0f%% floor. Drive normally.", + math.Max(arrivalSOC, 0), math.Max(margin, 0), minArrival) + case arrivalSOC >= minArrival: + rep.Verdict = confidenceTight + rep.Explanation = fmt.Sprintf( + "Tight: ~%.0f%% at arrival with only %.1f kWh of margin. Ease off above 110 km/h and skip the detour.", + math.Max(arrivalSOC, 0), math.Max(margin, 0)) + default: + rep.Verdict = confidenceChargeNow + short := minArrival/100*capacity - arrivalKWh + rep.ChargeNeededKWh = round1(math.Max(short, 0)) + rep.Explanation = fmt.Sprintf( + "You won't make it — projected arrival is ~%.0f%%. Add at least %.1f kWh (about %d Supercharger minutes) before continuing.", + arrivalSOC, rep.ChargeNeededKWh, int(math.Ceil(rep.ChargeNeededKWh/chargerPowerKW*60))) + } + return rep, nil +} + +// Confidence handles POST /trip-planner/confidence. +func (h *TripPlannerHandler) Confidence(w http.ResponseWriter, r *http.Request) { + var req confidenceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid request body") + return + } + rep, err := ComputeConfidence(req) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.WriteJSON(w, http.StatusOK, rep) +} + +func round1(f float64) float64 { return math.Round(f*10) / 10 } diff --git a/internal/api/tripplanner/confidence_test.go b/internal/api/tripplanner/confidence_test.go new file mode 100644 index 0000000000..2d757bec35 --- /dev/null +++ b/internal/api/tripplanner/confidence_test.go @@ -0,0 +1,97 @@ +package tripplanner + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestComputeConfidenceComfortable(t *testing.T) { + rep, err := ComputeConfidence(confidenceRequest{ + CurrentSOC: 80, BatteryCapacityKWh: 75, RemainingKm: 150, + EfficiencyWhKm: 160, EfficiencyFactor: 1, MinArrivalSOC: 10, + }) + if err != nil { + t.Fatal(err) + } + if rep.Verdict != confidenceComfortable { + t.Fatalf("verdict = %s, want comfortable (%+v)", rep.Verdict, rep) + } + // usable 60kWh, needed 24kWh → arrival 48%. + if rep.ArrivalSOC != 48 { + t.Fatalf("arrival = %v, want 48", rep.ArrivalSOC) + } +} + +func TestComputeConfidenceTight(t *testing.T) { + rep, err := ComputeConfidence(confidenceRequest{ + CurrentSOC: 50, BatteryCapacityKWh: 75, RemainingKm: 150, + EfficiencyWhKm: 160, MinArrivalSOC: 10, + }) + if err != nil { + t.Fatal(err) + } + if rep.Verdict != confidenceTight { + t.Fatalf("verdict = %s, want tight (%+v)", rep.Verdict, rep) + } +} + +func TestComputeConfidenceChargeNow(t *testing.T) { + rep, err := ComputeConfidence(confidenceRequest{ + CurrentSOC: 20, BatteryCapacityKWh: 75, RemainingKm: 300, + EfficiencyWhKm: 160, MinArrivalSOC: 10, + }) + if err != nil { + t.Fatal(err) + } + if rep.Verdict != confidenceChargeNow { + t.Fatalf("verdict = %s, want charge_now (%+v)", rep.Verdict, rep) + } + if rep.ChargeNeededKWh <= 0 { + t.Fatalf("charge needed = %v, want positive", rep.ChargeNeededKWh) + } +} + +func TestComputeConfidenceRejects(t *testing.T) { + if _, err := ComputeConfidence(confidenceRequest{CurrentSOC: 0, RemainingKm: 10}); err == nil { + t.Fatal("expected error for zero SOC") + } + if _, err := ComputeConfidence(confidenceRequest{CurrentSOC: 50, RemainingKm: 0}); err == nil { + t.Fatal("expected error for zero distance") + } +} + +func TestCompareTripCost(t *testing.T) { + // 500 km ≈ 310.7 mi → 10.36 gal @30mpg → $36.25 @ $3.50. + got := CompareTripCost(500, 12, 0, 0) + if got.GasCost != 36.25 && (got.GasCost < 36.2 || got.GasCost > 36.3) { + t.Fatalf("gas cost = %v, want ~36.25", got.GasCost) + } + if got.Savings <= 0 || got.GasPrice != 3.5 || got.GasMPG != 30 { + t.Fatalf("unexpected comparison: %+v", got) + } + custom := CompareTripCost(500, 12, 5, 25) + if custom.GasPrice != 5 || custom.GasMPG != 25 { + t.Fatalf("custom inputs not honored: %+v", custom) + } +} + +func TestConfidenceEndpoint(t *testing.T) { + h := &TripPlannerHandler{} + body := `{"current_soc":80,"battery_capacity_kwh":75,"remaining_km":150,"efficiency_wh_km":160,"min_arrival_soc":10}` + req := httptest.NewRequest(http.MethodPost, "/confidence", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.Confidence(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var rep confidenceResponse + if err := json.NewDecoder(rec.Body).Decode(&rep); err != nil { + t.Fatal(err) + } + if rep.Verdict == "" || rep.Explanation == "" { + t.Fatalf("incomplete report: %+v", rep) + } +} diff --git a/internal/api/tripplanner/dtos.go b/internal/api/tripplanner/dtos.go index 4dbb07ecef..62e4df2134 100644 --- a/internal/api/tripplanner/dtos.go +++ b/internal/api/tripplanner/dtos.go @@ -11,6 +11,8 @@ type tripPlanPreferences struct { SpeedFactor float64 `json:"speed_factor"` // 1.0 = normal, >1 faster, <1 slower IncludeWeather bool `json:"include_weather"` PreferSupercharger bool `json:"prefer_superchargers"` + GasPricePerGallon float64 `json:"gas_price_per_gallon"` // optional, default 3.50 + GasMPG float64 `json:"gas_mpg"` // optional, default 30 } type tripPlanRequest struct { @@ -70,11 +72,24 @@ type tripSOCPoint struct { } type tripPlanResponse struct { - Route tripPlanRoute `json:"route"` - Legs []tripPlanLeg `json:"legs"` - ChargeStops []tripChargeStop `json:"charge_stops"` - WeatherImpact tripWeatherImpact `json:"weather_impact"` - SOCCurve []tripSOCPoint `json:"soc_curve"` + Route tripPlanRoute `json:"route"` + Legs []tripPlanLeg `json:"legs"` + ChargeStops []tripChargeStop `json:"charge_stops"` + WeatherImpact tripWeatherImpact `json:"weather_impact"` + SOCCurve []tripSOCPoint `json:"soc_curve"` + CostComparison tripCostComparison `json:"cost_comparison"` +} + +// tripCostComparison is the door-to-door $ readout: EV charging cost vs the +// gasoline equivalent for the same distance. +type tripCostComparison struct { + EVCost float64 `json:"ev_cost"` + GasCost float64 `json:"gas_cost"` + GasGallons float64 `json:"gas_gallons"` + Savings float64 `json:"savings"` + SavingsPct float64 `json:"savings_pct"` + GasPrice float64 `json:"gas_price_per_gallon"` + GasMPG float64 `json:"gas_mpg"` } // Exported aliases keep the deterministic planner's typed compute surface diff --git a/internal/api/vampiredrain/handler.go b/internal/api/vampiredrain/handler.go index e83c26dbfc..af1037dcd5 100644 --- a/internal/api/vampiredrain/handler.go +++ b/internal/api/vampiredrain/handler.go @@ -234,6 +234,63 @@ func (h *VampireDrainHandler) Stats(w http.ResponseWriter, r *http.Request) { }) } +// Watch serves GET /vampire-drain/watch?vehicle_id=...&threshold_pct_per_day=.... +// +// Reuses the Events + Stats repo surface (no new SQL): the watchdog is a +// threshold evaluation over the same derived parked windows. Threshold +// defaults to 3%/day and must stay within 0.5..10. +func (h *VampireDrainHandler) Watch(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + vidStr := q.Get("vehicle_id") + if vidStr == "" { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id is required") + return + } + vehicleID, err := strconv.ParseInt(vidStr, 10, 64) + if err != nil || vehicleID <= 0 { + httpx.WriteError(w, http.StatusBadRequest, "vehicle_id must be a positive integer") + return + } + threshold := DefaultWatchThresholdPctPerDay + if t := q.Get("threshold_pct_per_day"); t != "" { + v, err := strconv.ParseFloat(t, 64) + if err != nil || v < 0.5 || v > 10 { + httpx.WriteError(w, http.StatusBadRequest, "threshold_pct_per_day must be 0.5..10") + return + } + threshold = v + } + + ctx := r.Context() + exists, err := h.repo.VehicleExists(ctx, vehicleID) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("vampire_drain.watch: existence probe failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to verify vehicle") + return + } + if !exists { + httpx.WriteError(w, http.StatusNotFound, "vehicle not found") + return + } + + now := h.now() + windowStart := now.Add(-time.Duration(vampireDrainStatsWindowDays) * 24 * time.Hour) + events, err := h.repo.Events(ctx, vehicleID, windowStart, vampireDrainStatsLimit) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("vampire_drain.watch: events query failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load vampire drain events") + return + } + stats, err := h.repo.Stats(ctx, vehicleID, windowStart, vampireDrainStatsWindowDays, vampireDrainStatsLimit) + if err != nil { + log.Error().Err(err).Int64("vehicle_id", vehicleID).Msg("vampire_drain.watch: stats query failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to load vampire drain stats") + return + } + + httpx.WriteJSON(w, http.StatusOK, EvaluateWatch(events, stats.AvgDrainPctPerDay, threshold, now)) +} + // now returns the injected clock or wall time. func (h *VampireDrainHandler) now() time.Time { if h.clock != nil { diff --git a/internal/api/vampiredrain/watch.go b/internal/api/vampiredrain/watch.go new file mode 100644 index 0000000000..3ac25b136d --- /dev/null +++ b/internal/api/vampiredrain/watch.go @@ -0,0 +1,127 @@ +package vampiredrain + +import ( + "fmt" + "math" + "time" + + drivedb "github.com/ev-dev-labs/teslasync/internal/database/drive" +) + +// Watchdog status levels. Thresholds mirror the frontend severity ramp +// (VampireDrainWidget drainColor): <1%/day green, 1–3 amber, >=3 red. +const ( + WatchStatusOK = "ok" + WatchStatusWatch = "watch" + WatchStatusAlert = "alert" +) + +// DefaultWatchThresholdPctPerDay is the breach line when the caller omits +// threshold_pct_per_day: 3%/day, the "red" boundary owners complain about. +const DefaultWatchThresholdPctPerDay = 3.0 + +// WatchReport is the GET /vampire-drain/watch response: threshold +// evaluation over recent parked windows plus a human-readable diagnosis. +type WatchReport struct { + Status string `json:"status"` + ThresholdPctPerDay float64 `json:"threshold_pct_per_day"` + AvgDrainPctPerDay *float64 `json:"avg_drain_pct_per_day"` + EventsEvaluated int `json:"events_evaluated"` + BreachStreak int `json:"breach_streak"` + BreachesLast7Days int `json:"breaches_last_7_days"` + Worst *drivedb.VampireDrainEvent `json:"worst_event"` + ColdNote string `json:"cold_note,omitempty"` + Recommendation string `json:"recommendation"` +} + +// EvaluateWatch is the pure watchdog computation over most-recent-first +// events. now pins "last 7 days" so tests are deterministic. +func EvaluateWatch(events []drivedb.VampireDrainEvent, avg *float64, threshold float64, now time.Time) WatchReport { + rep := WatchReport{ + Status: WatchStatusOK, + ThresholdPctPerDay: threshold, + AvgDrainPctPerDay: avg, + EventsEvaluated: len(events), + } + if len(events) == 0 { + rep.Recommendation = "No parked windows observed yet — park unplugged for a few hours to seed the watchdog." + return rep + } + + weekAgo := now.Add(-7 * 24 * time.Hour) + var worst *drivedb.VampireDrainEvent + for i := range events { + ev := &events[i] + if worst == nil || ev.DrainPctPerDay > worst.DrainPctPerDay { + worst = ev + } + if ev.DrainPctPerDay >= threshold && !ev.StartedAt.Before(weekAgo) { + rep.BreachesLast7Days++ + } + } + rep.Worst = worst + + // Breach streak: consecutive most-recent events over the line. + for i := range events { + if events[i].DrainPctPerDay < threshold { + break + } + rep.BreachStreak++ + } + + avgVal := 0.0 + if avg != nil && !math.IsNaN(*avg) { + avgVal = *avg + } + switch { + case avgVal >= threshold || rep.BreachStreak >= 3: + rep.Status = WatchStatusAlert + case avgVal >= threshold*2/3 || rep.BreachesLast7Days > 0: + rep.Status = WatchStatusWatch + } + + // Cold correlation: compare sub-5°C windows against milder ones. + var coldSum, mildSum float64 + var coldN, mildN int + for i := range events { + t := events[i].AmbientTempCAvg + if t == nil { + continue + } + if *t < 5 { + coldSum += events[i].DrainPctPerDay + coldN++ + } else { + mildSum += events[i].DrainPctPerDay + mildN++ + } + } + if coldN > 0 && mildN > 0 && coldSum/float64(coldN) > 1.5*mildSum/float64(mildN) { + rep.ColdNote = fmt.Sprintf( + "Cold-parked windows average %.1f%%/day vs %.1f%%/day in mild weather — battery heating is a likely driver.", + round1(coldSum/float64(coldN)), round1(mildSum/float64(mildN)), + ) + } + + switch rep.Status { + case WatchStatusAlert: + rep.Recommendation = fmt.Sprintf( + "Drain is breaching %.1f%%/day (%d in a row). Check Sentry Mode, Cabin Overheat Protection, and third-party polling apps keeping the car awake.", + threshold, max1(rep.BreachStreak), + ) + case WatchStatusWatch: + rep.Recommendation = "Drain is elevated but not critical. Watch the next few parked nights; disable Sentry at home first if the streak grows." + default: + rep.Recommendation = "Parked drain looks healthy. No action needed." + } + return rep +} + +func round1(f float64) float64 { return math.Round(f*10) / 10 } + +func max1(n int) int { + if n < 1 { + return 1 + } + return n +} diff --git a/internal/api/vampiredrain/watch_test.go b/internal/api/vampiredrain/watch_test.go new file mode 100644 index 0000000000..724ba993fc --- /dev/null +++ b/internal/api/vampiredrain/watch_test.go @@ -0,0 +1,114 @@ +package vampiredrain + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + drivedb "github.com/ev-dev-labs/teslasync/internal/database/drive" +) + +func fptr(f float64) *float64 { return &f } + +func watchEvent(start time.Time, perDay float64, temp *float64) drivedb.VampireDrainEvent { + return drivedb.VampireDrainEvent{ + StartedAt: start, + EndedAt: start.Add(10 * time.Hour), + DurationHours: 10, + DrainPctPerDay: perDay, + AmbientTempCAvg: temp, + } +} + +func TestEvaluateWatchOK(t *testing.T) { + now := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC) + events := []drivedb.VampireDrainEvent{ + watchEvent(now.Add(-24*time.Hour), 0.8, fptr(12)), + watchEvent(now.Add(-48*time.Hour), 0.6, fptr(14)), + } + rep := EvaluateWatch(events, fptr(0.7), 3.0, now) + if rep.Status != WatchStatusOK { + t.Fatalf("status = %s, want ok", rep.Status) + } + if rep.BreachStreak != 0 || rep.BreachesLast7Days != 0 { + t.Fatalf("unexpected breaches: %+v", rep) + } +} + +func TestEvaluateWatchAlertOnStreak(t *testing.T) { + now := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC) + events := []drivedb.VampireDrainEvent{ + watchEvent(now.Add(-24*time.Hour), 3.4, fptr(10)), + watchEvent(now.Add(-48*time.Hour), 3.1, fptr(11)), + watchEvent(now.Add(-72*time.Hour), 3.8, fptr(9)), + watchEvent(now.Add(-96*time.Hour), 0.5, fptr(12)), + } + rep := EvaluateWatch(events, fptr(2.7), 3.0, now) + if rep.Status != WatchStatusAlert { + t.Fatalf("status = %s, want alert", rep.Status) + } + if rep.BreachStreak != 3 { + t.Fatalf("streak = %d, want 3", rep.BreachStreak) + } + if rep.Worst == nil || rep.Worst.DrainPctPerDay != 3.8 { + t.Fatalf("worst = %+v, want 3.8/day", rep.Worst) + } + if rep.Recommendation == "" { + t.Fatal("expected a recommendation") + } +} + +func TestEvaluateWatchColdNote(t *testing.T) { + now := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC) + events := []drivedb.VampireDrainEvent{ + watchEvent(now.Add(-24*time.Hour), 4.0, fptr(-2)), + watchEvent(now.Add(-48*time.Hour), 3.6, fptr(0)), + watchEvent(now.Add(-72*time.Hour), 1.0, fptr(15)), + watchEvent(now.Add(-96*time.Hour), 0.8, fptr(16)), + } + rep := EvaluateWatch(events, fptr(2.3), 3.0, now) + if rep.ColdNote == "" { + t.Fatal("expected a cold-weather note") + } +} + +func TestEvaluateWatchEmpty(t *testing.T) { + rep := EvaluateWatch(nil, nil, 3.0, time.Now().UTC()) + if rep.Status != WatchStatusOK || rep.Recommendation == "" { + t.Fatalf("unexpected empty report: %+v", rep) + } +} + +func TestWatchRejectsBadThreshold(t *testing.T) { + h := newVampireDrainHandlerForTest(&fakeVampireDrainRepo{}, time.Now().UTC()) + req := httptest.NewRequest(http.MethodGet, "/watch?vehicle_id=1&threshold_pct_per_day=99", nil) + rec := httptest.NewRecorder() + h.Watch(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestWatchServesReport(t *testing.T) { + now := time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC) + h := newVampireDrainHandlerForTest(&fakeVampireDrainRepo{ + exists: map[int64]bool{5: true}, + events: []drivedb.VampireDrainEvent{watchEvent(now.Add(-24*time.Hour), 2.5, nil)}, + stats: drivedb.VampireDrainStats{AvgDrainPctPerDay: fptr(2.5)}, + }, now) + req := httptest.NewRequest(http.MethodGet, "/watch?vehicle_id=5", nil) + rec := httptest.NewRecorder() + h.Watch(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var rep WatchReport + if err := json.NewDecoder(rec.Body).Decode(&rep); err != nil { + t.Fatal(err) + } + if rep.Status != WatchStatusWatch || rep.EventsEvaluated != 1 { + t.Fatalf("unexpected report: %+v", rep) + } +} diff --git a/internal/api/vehicle/silence.go b/internal/api/vehicle/silence.go new file mode 100644 index 0000000000..254007636b --- /dev/null +++ b/internal/api/vehicle/silence.go @@ -0,0 +1,124 @@ +package vehicle + +import ( + "fmt" + "net/http" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/apiparams" + "github.com/ev-dev-labs/teslasync/internal/api/apperror" + "github.com/ev-dev-labs/teslasync/internal/api/httpx" + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +// Silence statuses. +const ( + silenceOK = "ok" + silenceQuiet = "quiet" + silenceSilent = "silent" + silenceNever = "never" +) + +const ( + // quietAfter: no telemetry for 6h is noteworthy but often just sleep. + quietAfter = 6 * time.Hour + // silentAfter: 24h without telemetry deserves an explicit check. + silentAfter = 24 * time.Hour + // silenceLookback bounds the recency scan; a car quieter than this + // reports last_seen_at: null either way. + silenceLookback = 72 * time.Hour + // silenceMaxRows caps the scan — only the latest timestamp is read. + silenceMaxRows = 100 +) + +// VehicleSilence is the GET /vehicles/{vehicleID}/silence response. +type VehicleSilence struct { + VehicleID int64 `json:"vehicle_id"` + Status string `json:"status"` + LastSeenAt *time.Time `json:"last_seen_at"` + SilentForS *int64 `json:"silent_for_s"` + CheckedAt time.Time `json:"checked_at"` + Explanation string `json:"explanation"` +} + +// ClassifySilence is the pure last-seen evaluation. now pins the clock. +func ClassifySilence(vehicleID int64, last *time.Time, now time.Time) VehicleSilence { + s := VehicleSilence{VehicleID: vehicleID, CheckedAt: now.UTC()} + if last == nil { + s.Status = silenceNever + s.Explanation = "No telemetry in the last 72 hours — the car may be asleep, out of coverage, or unpaired. Try a wake; if it stays dark, check the Tesla app." + return s + } + ago := now.Sub(*last) + secs := int64(ago.Seconds()) + if secs < 0 { + secs = 0 + } + s.LastSeenAt = last + s.SilentForS = &secs + switch { + case ago < quietAfter: + s.Status = silenceOK + s.Explanation = fmt.Sprintf("Telemetry is fresh — last seen %s ago.", humanAgo(ago)) + case ago < silentAfter: + s.Status = silenceQuiet + s.Explanation = fmt.Sprintf("Quiet for %s — usually just deep sleep. Wake the car if you expected recent activity.", humanAgo(ago)) + default: + s.Status = silenceSilent + s.Explanation = fmt.Sprintf("Silent for %s. If the car should be reachable, check Tesla connectivity, then wake it; a 12V failure also presents as prolonged silence.", humanAgo(ago)) + } + return s +} + +func humanAgo(d time.Duration) string { + if d < time.Hour { + m := int(d.Minutes()) + if m < 1 { + return "under a minute" + } + return fmt.Sprintf("%dm", m) + } + h := int(d.Hours()) + if h < 48 { + return fmt.Sprintf("%dh", h) + } + return fmt.Sprintf("%dd", h/24) +} + +// Silence serves GET /vehicles/{vehicleID}/silence. It scans the most +// recent telemetry across heartbeat signals and classifies recency. +func (h *Handler) Silence(w http.ResponseWriter, r *http.Request) { + id, err := apiparams.URLParamInt64(r, "vehicleID") + if err != nil { + apperror.Write(w, r, apperror.ErrInvalidID.WithMessage("invalid vehicle ID")) + return + } + now := time.Now().UTC() + rows, err := h.state.Timeline(r.Context(), id, silenceFields(), + now.Add(-silenceLookback), now.Add(time.Nanosecond), + signal.TimelineOptions{MaxRows: silenceMaxRows}) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("failed to load telemetry recency") + apperror.Write(w, r, apperror.ErrDBQuery.WithMessage("failed to load telemetry recency")) + return + } + var last *time.Time + for i := range rows { + t := rows[i].Timestamp.UTC() + if last == nil || t.After(*last) { + last = &t + } + } + httpx.WriteJSON(w, http.StatusOK, ClassifySilence(id, last, now)) +} + +func silenceFields() []signal.FieldMapping { + return []signal.FieldMapping{ + {Signal: "BatteryLevel", Field: "battery_level"}, + {Signal: "Location", Field: "location"}, + {Signal: "Odometer", Field: "odometer"}, + {Signal: "Gear", Field: "gear"}, + } +} diff --git a/internal/api/vehicle/silence_test.go b/internal/api/vehicle/silence_test.go new file mode 100644 index 0000000000..10de5c9c90 --- /dev/null +++ b/internal/api/vehicle/silence_test.go @@ -0,0 +1,77 @@ +package vehicle + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/ev-dev-labs/teslasync/internal/signal" +) + +func TestClassifySilenceOK(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + last := now.Add(-30 * time.Minute) + s := ClassifySilence(3, &last, now) + if s.Status != silenceOK || s.SilentForS == nil { + t.Fatalf("unexpected: %+v", s) + } +} + +func TestClassifySilenceQuiet(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + last := now.Add(-8 * time.Hour) + if s := ClassifySilence(3, &last, now); s.Status != silenceQuiet { + t.Fatalf("status = %s, want quiet", s.Status) + } +} + +func TestClassifySilenceSilent(t *testing.T) { + now := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + last := now.Add(-30 * time.Hour) + s := ClassifySilence(3, &last, now) + if s.Status != silenceSilent { + t.Fatalf("status = %s, want silent", s.Status) + } + if s.Explanation == "" { + t.Fatal("expected guidance") + } +} + +func TestClassifySilenceNever(t *testing.T) { + s := ClassifySilence(3, nil, time.Now().UTC()) + if s.Status != silenceNever || s.LastSeenAt != nil { + t.Fatalf("unexpected: %+v", s) + } +} + +func TestSilenceEndpointReadsLatestRow(t *testing.T) { + base := time.Now().UTC() + h := &Handler{state: &fakeStateReader{ + timelineFn: func(_ context.Context, _ int64, _ []signal.FieldMapping, _, _ time.Time, _ signal.TimelineOptions) ([]signal.TimelineRow, error) { + return []signal.TimelineRow{ + {Timestamp: base.Add(-2 * time.Hour)}, + {Timestamp: base.Add(-10 * time.Minute)}, + }, nil + }, + }} + r := chi.NewRouter() + r.Get("/vehicles/{vehicleID}/silence", h.Silence) + req := httptest.NewRequest(http.MethodGet, "/vehicles/3/silence", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + var s VehicleSilence + if err := json.NewDecoder(rec.Body).Decode(&s); err != nil { + t.Fatal(err) + } + if s.Status != silenceOK || s.VehicleID != 3 { + t.Fatalf("unexpected: %+v", s) + } +} diff --git a/internal/api/waitoracle/forecast.go b/internal/api/waitoracle/forecast.go new file mode 100644 index 0000000000..f9a53cccaf --- /dev/null +++ b/internal/api/waitoracle/forecast.go @@ -0,0 +1,317 @@ +// Package waitoracle predicts Supercharger wait times from the fleet's +// own charging history (tesla_charging_sessions). +// +// Model: arrivals per hour-of-week bucket give the arrival rate λ; +// the site median session duration gives the service time; Little's law +// (L = λW) yields offered load in Erlangs; Erlang-C for a c-stall site +// yields the wait probability and expected queue delay. Stall count is +// unknown from session data, so it is estimated as the peak concurrency +// ever observed at the site — a documented lower bound. +// +// All buckets are UTC: historical starts are UTC and the arrival instant +// is converted to UTC before bucketing, so the curve is self-consistent +// (DST smears bucket edges by up to an hour twice a year). +package waitoracle + +import ( + "math" + "sort" + "strconv" + "time" +) + +// Verdicts for the arrival bucket. +const ( + VerdictQuiet = "quiet" + VerdictSteady = "steady" + VerdictBusy = "busy" + VerdictPacked = "packed" +) + +// Confidence levels, driven by sample depth. +const ( + ConfidenceHigh = "high" + ConfidenceMedium = "medium" + ConfidenceLow = "low" +) + +const ( + hoursPerWeek = 24 * 7 + // minBucketStarts gates medium confidence: fewer starts in the + // arrival bucket and the estimate leans on the site average. + minBucketStarts = 8 + // minSiteSessions gates the forecast at all: below this the site + // has no usable history. + minSiteSessions = 10 + // maxHistoryRows bounds the raw session pull for median/concurrency. + maxHistoryRows = 10000 + // historyWeeks bounds how far back demand is measured, so closed + // or rebuilt sites age out of the curve. + historyWeeks = 26 +) + +// Bucket is one hour-of-week demand cell in UTC. +type Bucket struct { + Weekday int // 0=Sunday, matching time.Weekday + Hour int // 0..23 UTC + Starts int // session starts observed in this cell + // Congested counts starts that paid a congestion fee — Tesla's own + // "this site was full" signal. + Congested int +} + +// Session is the minimal (start, stop) pair for median + concurrency. +type Session struct { + Start time.Time + Stop time.Time +} + +// SiteHistory is everything Forecast needs: pre-aggregated demand plus +// raw sessions for duration/concurrency. +type SiteHistory struct { + Site string + Sessions int // total sessions in window (uncapped count) + Weeks float64 + Buckets []Bucket // sparse; missing cells are zero + Spans []Session +} + +// HourPoint is one hour of the arrival day for the chart. +type HourPoint struct { + Hour int `json:"hour"` + ExpectedS float64 `json:"expected_wait_s"` + Busyness float64 `json:"busyness"` +} + +// Forecast is the wait prediction for one arrival instant. +// Duration fields are SI seconds (Phase-48); the SPA converts at render. +type Forecast struct { + Site string `json:"site"` + ArriveAt time.Time `json:"arrive_at"` + ExpectedS float64 `json:"expected_wait_s"` + WaitProbPct float64 `json:"wait_probability_pct"` + Busyness float64 `json:"busyness"` + Verdict string `json:"verdict"` + Confidence string `json:"confidence"` + StallsEstimate int `json:"stalls_estimated"` + BestHour int `json:"best_hour_utc"` + BestWaitS float64 `json:"best_wait_s"` + SaveS float64 `json:"save_s"` + Hours []HourPoint `json:"hours"` + Evidence []string `json:"evidence"` +} + +// ErrNoHistory is returned when the site has too little data. +type noHistoryError string + +func (e noHistoryError) Error() string { return string(e) } + +// ErrNoHistory signals insufficient site history. +const ErrNoHistory = noHistoryError("site has insufficient charging history") + +// Predict computes the wait forecast for arriving at arrival (any location; it +// is converted to UTC). Pure: no I/O, deterministic. +func Predict(h SiteHistory, arrival time.Time) (*Forecast, error) { + if h.Sessions < minSiteSessions || len(h.Spans) == 0 { + return nil, ErrNoHistory + } + weeks := h.Weeks + if weeks < 1 { + weeks = 1 + } + medianMin := medianDurationMin(h.Spans) + if medianMin <= 0 { + return nil, ErrNoHistory + } + stalls := peakConcurrency(h.Spans) + if stalls < 1 { + stalls = 1 + } + + starts := make([]float64, hoursPerWeek) + for _, b := range h.Buckets { + if b.Weekday < 0 || b.Weekday > 6 || b.Hour < 0 || b.Hour > 23 { + continue + } + starts[b.Weekday*24+b.Hour] += float64(b.Starts) + } + var peak float64 + for _, s := range starts { + peak = math.Max(peak, s) + } + rate := func(cell int) float64 { // arrivals/hour in this cell + if weeks <= 0 { + return 0 + } + return starts[cell] / weeks + } + + arrUTC := arrival.UTC() + arrCell := int(arrUTC.Weekday())*24 + arrUTC.Hour() + load := rate(arrCell) * medianMin / 60 // Erlangs (Little's law) + waitMin, waitProb := erlangCWait(load, float64(medianMin), stalls) + + busyness := 0.0 + if peak > 0 { + busyness = starts[arrCell] / peak * 100 + } + verdict := verdictFor(busyness, load >= float64(stalls)) + confidence := ConfidenceHigh + if starts[arrCell] < minBucketStarts { + confidence = ConfidenceMedium + } + if h.Sessions < 30 || weeks < 4 { + confidence = ConfidenceLow + } + + // Best arrival within ±3h of the arrival hour, same weekday. + bestHour, bestWait := arrUTC.Hour(), waitMin + day := int(arrUTC.Weekday()) * 24 + for d := -3; d <= 3; d++ { + hr := arrUTC.Hour() + d + if hr < 0 || hr > 23 { + continue + } + w, _ := erlangCWait(rate(day+hr)*medianMin/60, float64(medianMin), stalls) + if w < bestWait-0.5 { // half-minute hysteresis: no churn + bestHour, bestWait = hr, w + } + } + + hours := make([]HourPoint, 0, 24) + for hr := 0; hr < 24; hr++ { + w, _ := erlangCWait(rate(day+hr)*medianMin/60, float64(medianMin), stalls) + b := 0.0 + if peak > 0 { + b = starts[day+hr] / peak * 100 + } + hours = append(hours, HourPoint{Hour: hr, ExpectedS: minutesToSeconds(w), Busyness: round1(b)}) + } + + return &Forecast{ + Site: h.Site, + ArriveAt: arrUTC, + ExpectedS: minutesToSeconds(waitMin), + WaitProbPct: round1(waitProb * 100), + Busyness: round1(busyness), + Verdict: verdict, + Confidence: confidence, + StallsEstimate: stalls, + BestHour: bestHour, + BestWaitS: minutesToSeconds(bestWait), + SaveS: minutesToSeconds(math.Max(0, waitMin-bestWait)), + Hours: hours, + Evidence: evidence(h, medianMin, stalls), + }, nil +} + +func minutesToSeconds(min float64) float64 { + return round1(min * 60) +} + +// erlangCWait returns (expected queue wait minutes, P(wait > 0)) for +// offered load a Erlangs, mean service time svcMin, c servers. When the +// site is saturated (a >= c) the queue is unbounded: it reports one +// full service time as the expected wait with P=1 — a deliberate, +// documented floor, not a prediction of the unbounded tail. +func erlangCWait(a, svcMin float64, c int) (waitMin, waitProb float64) { + if a <= 0 || c <= 0 { + return 0, 0 + } + if a >= float64(c) { + return svcMin, 1 + } + rho := a / float64(c) + // Erlang-C: p = [a^c/(c!(1-ρ))] / [Σ₀ᶜ⁻¹ aᵏ/k! + a^c/(c!(1-ρ))] + sum := 0.0 + term := 1.0 // a^k/k! + for k := 0; k < c; k++ { + if k > 0 { + term *= a / float64(k) + } + sum += term + } + term *= a / float64(c) // a^c/c! + last := term / (1 - rho) + p := last / (sum + last) + return p * svcMin / (float64(c) - a), p +} + +func verdictFor(busyness float64, saturated bool) string { + switch { + case saturated || busyness >= 75: + return VerdictPacked + case busyness >= 50: + return VerdictBusy + case busyness >= 25: + return VerdictSteady + default: + return VerdictQuiet + } +} + +func medianDurationMin(spans []Session) float64 { + ds := make([]float64, 0, len(spans)) + for _, s := range spans { + if s.Stop.After(s.Start) { + ds = append(ds, s.Stop.Sub(s.Start).Minutes()) + } + } + if len(ds) == 0 { + return 0 + } + sort.Float64s(ds) + mid := len(ds) / 2 + if len(ds)%2 == 1 { + return ds[mid] + } + return (ds[mid-1] + ds[mid]) / 2 +} + +// peakConcurrency sweeps start/stop events; the max overlap is the +// stall-count lower bound. +func peakConcurrency(spans []Session) int { + type event struct { + t time.Time + delta int + } + evs := make([]event, 0, 2*len(spans)) + for _, s := range spans { + if !s.Stop.After(s.Start) { + continue + } + evs = append(evs, event{s.Start, 1}, event{s.Stop, -1}) + } + sort.Slice(evs, func(i, j int) bool { + if evs[i].t.Equal(evs[j].t) { + return evs[i].delta < evs[j].delta // ends before starts + } + return evs[i].t.Before(evs[j].t) + }) + peak, cur := 0, 0 + for _, e := range evs { + cur += e.delta + peak = max(peak, cur) + } + return peak +} + +func evidence(h SiteHistory, medianMin float64, stalls int) []string { + congested := 0 + starts := 0 + for _, b := range h.Buckets { + congested += b.Congested + starts += b.Starts + } + out := []string{ + strconv.Itoa(starts) + " sessions over " + strconv.FormatFloat(h.Weeks, 'f', 1, 64) + " weeks", + "median session " + strconv.FormatFloat(medianMin, 'f', 0, 64) + " min", + strconv.Itoa(stalls) + " stalls observed at peak overlap", + } + if starts > 0 && congested > 0 { + out = append(out, "congestion fees on "+strconv.Itoa(congested*100/starts)+"% of sessions") + } + return out +} + +func round1(v float64) float64 { return math.Round(v*10) / 10 } diff --git a/internal/api/waitoracle/forecast_test.go b/internal/api/waitoracle/forecast_test.go new file mode 100644 index 0000000000..e7a646a995 --- /dev/null +++ b/internal/api/waitoracle/forecast_test.go @@ -0,0 +1,199 @@ +package waitoracle + +import ( + "errors" + "testing" + "time" +) + +func TestErlangCWaitKnownValues(t *testing.T) { + // M/M/1 with ρ=0.5: P(wait) = ρ, Wq = ρ·S/(1−ρ) = 30. + w, p := erlangCWait(0.5, 30, 1) + if !close(w, 30) || !close(p, 0.5) { + t.Fatalf("M/M/1: got wait=%.4f p=%.4f, want 30 / 0.5", w, p) + } + // M/M/2 with a=1: C(2,1) = 1/3, Wq = 60/3 = 20. + w, p = erlangCWait(1.0, 60, 2) + if !close(w, 20) || !close(p, 1.0/3.0) { + t.Fatalf("M/M/2: got wait=%.4f p=%.4f, want 20 / 0.333", w, p) + } +} + +func TestErlangCWaitEdges(t *testing.T) { + if w, p := erlangCWait(0, 30, 4); w != 0 || p != 0 { + t.Fatalf("zero load: got %v %v, want 0 0", w, p) + } + // Saturated: documented floor of one service time, P=1. + if w, p := erlangCWait(4.0, 30, 4); w != 30 || p != 1 { + t.Fatalf("saturated: got %v %v, want 30 1", w, p) + } + if w, p := erlangCWait(9.9, 30, 4); w != 30 || p != 1 { + t.Fatalf("overloaded: got %v %v, want 30 1", w, p) + } +} + +// fridayPeakHistory builds 10 weeks of history with a Friday 18:00 UTC +// crush (60 starts), light background elsewhere, 30-min median +// sessions and 4-stall peak overlap. +func fridayPeakHistory() SiteHistory { + h := SiteHistory{Site: "Kettleman City", Sessions: 2000, Weeks: 10} + for d := 0; d < 7; d++ { + for hr := 0; hr < 24; hr++ { + h.Buckets = append(h.Buckets, Bucket{Weekday: d, Hour: hr, Starts: 10}) + } + } + h.Buckets = append(h.Buckets, Bucket{Weekday: 5, Hour: 18, Starts: 60, Congested: 12}) + base := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) // a Friday + for i := 0; i < 4; i++ { // the 4-stall overlap + h.Spans = append(h.Spans, Session{Start: base, Stop: base.Add(30 * time.Minute)}) + } + for i := 0; i < 196; i++ { + s := base.AddDate(0, 0, 1).Add(time.Duration(i) * time.Hour) + h.Spans = append(h.Spans, Session{Start: s, Stop: s.Add(30 * time.Minute)}) + } + return h +} + +func friday18UTC() time.Time { + arr := time.Date(2026, 9, 11, 18, 0, 0, 0, time.UTC) + if arr.Weekday() != time.Friday { + panic("test date is not a Friday") + } + return arr +} + +func TestForecastPeakVerdict(t *testing.T) { + f, err := Predict(fridayPeakHistory(), friday18UTC()) + if err != nil { + t.Fatal(err) + } + // Load = 7/hr × 0.5h = 3.5 Erlangs on 4 stalls → packed. + if f.Verdict != VerdictPacked { + t.Fatalf("verdict = %q, want packed", f.Verdict) + } + if f.ExpectedS <= 0 { + t.Fatalf("expected wait = %v, want positive", f.ExpectedS) + } + if f.WaitProbPct <= 0 || f.WaitProbPct > 100 { + t.Fatalf("wait prob = %v, want (0, 100]", f.WaitProbPct) + } + if f.Busyness != 100 { + t.Fatalf("busyness = %v, want 100 at the peak cell", f.Busyness) + } + if f.StallsEstimate != 4 { + t.Fatalf("stalls = %d, want 4", f.StallsEstimate) + } + if f.Confidence != ConfidenceHigh { + t.Fatalf("confidence = %q, want high", f.Confidence) + } + if len(f.Hours) != 24 { + t.Fatalf("hours = %d, want 24", len(f.Hours)) + } + if len(f.Evidence) == 0 { + t.Fatal("evidence is empty") + } +} + +func TestForecastQuietBucket(t *testing.T) { + arr := time.Date(2026, 9, 8, 3, 0, 0, 0, time.UTC) // Tuesday 03:00 + if arr.Weekday() != time.Tuesday { + t.Fatal("test date is not a Tuesday") + } + f, err := Predict(fridayPeakHistory(), arr) + if err != nil { + t.Fatal(err) + } + if f.Verdict != VerdictQuiet { + t.Fatalf("verdict = %q, want quiet", f.Verdict) + } + if f.ExpectedS >= 60 { + t.Fatalf("expected wait = %v s, want under 1 min in a quiet bucket", f.ExpectedS) + } +} + +func TestForecastArrivalTimezone(t *testing.T) { + // Friday 20:00 +02:00 is Friday 18:00 UTC — the peak cell. + arr := time.Date(2026, 9, 11, 20, 0, 0, 0, time.FixedZone("CEST", 2*3600)) + f, err := Predict(fridayPeakHistory(), arr) + if err != nil { + t.Fatal(err) + } + if f.Verdict != VerdictPacked || f.Busyness != 100 { + t.Fatalf("tz arrival missed the peak cell: %+v", f) + } +} + +func TestForecastBestHour(t *testing.T) { + f, err := Predict(fridayPeakHistory(), friday18UTC()) + if err != nil { + t.Fatal(err) + } + // ±3h of 18:00, background hours are all empty of the crush; the + // first strictly better hour (15:00) wins. + if f.BestHour != 15 { + t.Fatalf("best hour = %d, want 15", f.BestHour) + } + if f.SaveS <= 0 { + t.Fatalf("save = %v, want positive", f.SaveS) + } + if !close(f.SaveS, f.ExpectedS-f.BestWaitS) { + t.Fatalf("save %v != expected-best %v", f.SaveS, f.ExpectedS-f.BestWaitS) + } +} + +func TestForecastNoHistory(t *testing.T) { + h := fridayPeakHistory() + h.Sessions = 9 + if _, err := Predict(h, friday18UTC()); !errors.Is(err, ErrNoHistory) { + t.Fatalf("few sessions: err = %v, want ErrNoHistory", err) + } + h = fridayPeakHistory() + h.Spans = nil + if _, err := Predict(h, friday18UTC()); !errors.Is(err, ErrNoHistory) { + t.Fatalf("no spans: err = %v, want ErrNoHistory", err) + } + h = fridayPeakHistory() + stamp := time.Now() + h.Spans = []Session{{Start: stamp, Stop: stamp}} // zero duration + if _, err := Predict(h, friday18UTC()); !errors.Is(err, ErrNoHistory) { + t.Fatalf("zero durations: err = %v, want ErrNoHistory", err) + } +} + +func TestForecastIgnoresBadCells(t *testing.T) { + h := fridayPeakHistory() + h.Buckets = append(h.Buckets, + Bucket{Weekday: 9, Hour: 3, Starts: 100000}, + Bucket{Weekday: 2, Hour: 99, Starts: 100000}, + ) + f, err := Predict(h, friday18UTC()) + if err != nil { + t.Fatal(err) + } + if f.Busyness != 100 { + t.Fatalf("bad cells leaked into the peak: busyness=%v", f.Busyness) + } +} + +func TestForecastDeterministic(t *testing.T) { + h := fridayPeakHistory() + a, err := Predict(h, friday18UTC()) + if err != nil { + t.Fatal(err) + } + b, err := Predict(h, friday18UTC()) + if err != nil { + t.Fatal(err) + } + if a.ExpectedS != b.ExpectedS || a.BestHour != b.BestHour || a.Verdict != b.Verdict { + t.Fatalf("nondeterministic:\n%+v\n%+v", a, b) + } +} + +func close(a, b float64) bool { + d := a - b + if d < 0 { + d = -d + } + return d < 1e-9 +} diff --git a/internal/api/waitoracle/handler.go b/internal/api/waitoracle/handler.go new file mode 100644 index 0000000000..ff843bc319 --- /dev/null +++ b/internal/api/waitoracle/handler.go @@ -0,0 +1,95 @@ +package waitoracle + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/api/httpx" +) + +// HistoryStore is the demand-history port. *Store satisfies it. +type HistoryStore interface { + ListSites(ctx context.Context, q string, limit int) ([]*Site, error) + History(ctx context.Context, site string) (SiteHistory, error) +} + +// Handler serves the wait oracle. Stateless beyond constructor inputs; +// safe for concurrent use. +type Handler struct { + store HistoryStore + now func() time.Time +} + +// NewHandler wires the handler. Panics on nil inputs (fail-fast wiring +// contract, matching sibling handlers). +func NewHandler(store HistoryStore) *Handler { + if store == nil { + panic("waitoracle: nil dependency") + } + return &Handler{store: store, now: time.Now} +} + +// Sites serves GET /waitoracle/sites?q=&limit=: the site directory. +func (h *Handler) Sites(w http.ResponseWriter, r *http.Request) { + limit := 50 + if s := r.URL.Query().Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil { + limit = n + } + } + sites, err := h.store.ListSites(r.Context(), r.URL.Query().Get("q"), limit) + if err != nil { + log.Error().Err(err).Msg("waitoracle: sites read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read site directory") + return + } + httpx.WriteJSON(w, http.StatusOK, sites) +} + +// Forecast serves GET /waitoracle/forecast?site=&arrive_at=: the wait +// prediction. arrive_at is RFC3339 (any zone); empty means now. +func (h *Handler) Forecast(w http.ResponseWriter, r *http.Request) { + site := r.URL.Query().Get("site") + if site == "" { + httpx.WriteError(w, http.StatusBadRequest, "site must be a non-empty site name") + return + } + arrival := h.now().UTC() + if s := r.URL.Query().Get("arrive_at"); s != "" { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "arrive_at must be RFC3339") + return + } + arrival = t + } + history, err := h.store.History(r.Context(), site) + if err != nil { + if errors.Is(err, ErrNoHistory) { + httpx.WriteError(w, http.StatusNotFound, "site has insufficient charging history for a forecast") + return + } + log.Error().Err(err).Str("site", site).Msg("waitoracle: history read failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to read site history") + return + } + f, err := Predict(history, arrival) + if err != nil { + if errors.Is(err, ErrNoHistory) { + httpx.WriteError(w, http.StatusNotFound, "site has insufficient charging history for a forecast") + return + } + log.Error().Err(err).Str("site", site).Msg("waitoracle: forecast failed") + httpx.WriteError(w, http.StatusInternalServerError, "failed to compute forecast") + return + } + httpx.WriteJSON(w, http.StatusOK, f) +} + +// Compile-time port assertion. +var _ HistoryStore = (*Store)(nil) diff --git a/internal/api/waitoracle/handler_test.go b/internal/api/waitoracle/handler_test.go new file mode 100644 index 0000000000..056d601fdf --- /dev/null +++ b/internal/api/waitoracle/handler_test.go @@ -0,0 +1,145 @@ +package waitoracle + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +type fakeStore struct { + sites []*Site + history SiteHistory + err error +} + +func (f *fakeStore) ListSites(_ context.Context, _ string, _ int) ([]*Site, error) { + return f.sites, f.err +} + +func (f *fakeStore) History(_ context.Context, _ string) (SiteHistory, error) { + return f.history, f.err +} + +var _ HistoryStore = (*fakeStore)(nil) + +func TestNewHandlerPanicsOnNil(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + NewHandler(nil) +} + +func TestSites(t *testing.T) { + h := NewHandler(&fakeStore{sites: []*Site{ + {Name: "Kettleman City", Sessions: 200}, + {Name: "Barstow", Sessions: 40}, + }}) + req := httptest.NewRequest(http.MethodGet, "/waitoracle/sites?q=kettle&limit=10", nil) + rec := httptest.NewRecorder() + h.Sites(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", rec.Code) + } + var got []*Site + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Name != "Kettleman City" { + t.Fatalf("sites = %+v", got) + } +} + +func TestSitesStoreError(t *testing.T) { + h := NewHandler(&fakeStore{err: errors.New("db down")}) + req := httptest.NewRequest(http.MethodGet, "/waitoracle/sites", nil) + rec := httptest.NewRecorder() + h.Sites(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("code = %d, want 500", rec.Code) + } +} + +func TestForecastHandler(t *testing.T) { + h := NewHandler(&fakeStore{history: fridayPeakHistory()}) + req := httptest.NewRequest(http.MethodGet, + "/waitoracle/forecast?site=Kettleman+City&arrive_at=2026-09-11T18:00:00Z", nil) + rec := httptest.NewRecorder() + h.Forecast(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String()) + } + var got Forecast + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Verdict != VerdictPacked || got.Site != "Kettleman City" { + t.Fatalf("forecast = %+v", got) + } +} + +func TestForecastHandlerDefaultsToNow(t *testing.T) { + now := time.Date(2026, 9, 11, 18, 0, 0, 0, time.UTC) + h := NewHandler(&fakeStore{history: fridayPeakHistory()}) + h.now = func() time.Time { return now } + req := httptest.NewRequest(http.MethodGet, "/waitoracle/forecast?site=Kettleman+City", nil) + rec := httptest.NewRecorder() + h.Forecast(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", rec.Code) + } + var got Forecast + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !got.ArriveAt.Equal(now) { + t.Fatalf("arrive_at = %v, want %v", got.ArriveAt, now) + } +} + +func TestForecastHandlerErrors(t *testing.T) { + h := NewHandler(&fakeStore{history: fridayPeakHistory()}) + cases := []struct { + name string + url string + code int + }{ + {"missing site", "/waitoracle/forecast", http.StatusBadRequest}, + {"bad arrive_at", "/waitoracle/forecast?site=x&arrive_at=tomorrow", http.StatusBadRequest}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, c.url, nil) + rec := httptest.NewRecorder() + h.Forecast(rec, req) + if rec.Code != c.code { + t.Fatalf("code = %d, want %d", rec.Code, c.code) + } + }) + } +} + +func TestForecastHandlerNoHistory(t *testing.T) { + h := NewHandler(&fakeStore{err: ErrNoHistory}) + req := httptest.NewRequest(http.MethodGet, "/waitoracle/forecast?site=Nowhere", nil) + rec := httptest.NewRecorder() + h.Forecast(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("code = %d, want 404", rec.Code) + } +} + +func TestForecastHandlerStoreError(t *testing.T) { + h := NewHandler(&fakeStore{err: errors.New("db down")}) + req := httptest.NewRequest(http.MethodGet, "/waitoracle/forecast?site=x", nil) + rec := httptest.NewRecorder() + h.Forecast(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("code = %d, want 500", rec.Code) + } +} diff --git a/internal/api/waitoracle/store.go b/internal/api/waitoracle/store.go new file mode 100644 index 0000000000..39c68dac2a --- /dev/null +++ b/internal/api/waitoracle/store.go @@ -0,0 +1,144 @@ +package waitoracle + +import ( + "context" + "fmt" + "time" + + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// Site is one named charging location from fleet history. +type Site struct { + Name string `json:"name"` + Sessions int `json:"sessions"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + LastSession time.Time `json:"last_session"` +} + +// Store reads fleet charging history for the oracle. Read-only: no +// migration, no writes. Panics on nil db (fail-fast wiring). Safe for +// concurrent use (pgx pool). +type Store struct { + db *database.DB + now func() time.Time +} + +// NewStore wires the store. +func NewStore(db *database.DB) *Store { + if db == nil { + panic("waitoracle: nil db") + } + return &Store{db: db, now: time.Now} +} + +// ListSites returns named sites ordered by session count. Query filters +// by case-insensitive substring. Limit clamped 1..200. +func (s *Store) ListSites(ctx context.Context, q string, limit int) ([]*Site, error) { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + rows, err := s.db.Pool.Query(ctx, ` + SELECT site_location_name, COUNT(*), + COALESCE(AVG(latitude), 0), COALESCE(AVG(longitude), 0), + MAX(charge_start_datetime) + FROM tesla_charging_sessions + WHERE site_location_name <> '' AND ($1 = '' OR site_location_name ILIKE '%' || $1 || '%') + GROUP BY site_location_name + ORDER BY COUNT(*) DESC + LIMIT $2`, q, limit) + if err != nil { + return nil, fmt.Errorf("waitoracle: list sites: %w", err) + } + defer rows.Close() + out := []*Site{} + for rows.Next() { + site := &Site{} + if err := rows.Scan(&site.Name, &site.Sessions, &site.Lat, &site.Lng, &site.LastSession); err != nil { + return nil, fmt.Errorf("waitoracle: scan site: %w", err) + } + out = append(out, site) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("waitoracle: list sites: %w", err) + } + return out, nil +} + +// History loads the demand aggregates + session spans for one site over +// the trailing history window. +func (s *Store) History(ctx context.Context, site string) (SiteHistory, error) { + h := SiteHistory{Site: site} + if site == "" { + return h, ErrNoHistory + } + since := s.now().UTC().AddDate(0, 0, -historyWeeks*7) + + var count int + var minStart, maxStart time.Time + err := s.db.Pool.QueryRow(ctx, ` + SELECT COUNT(*), COALESCE(MIN(charge_start_datetime), now()), COALESCE(MAX(charge_start_datetime), now()) + FROM tesla_charging_sessions + WHERE site_location_name = $1 AND charge_start_datetime >= $2`, site, since, + ).Scan(&count, &minStart, &maxStart) + if err != nil { + return h, fmt.Errorf("waitoracle: site stats: %w", err) + } + if count < minSiteSessions { + return h, ErrNoHistory + } + h.Sessions = count + h.Weeks = max(maxStart.Sub(minStart).Hours()/24/7, 1) + + rows, err := s.db.Pool.Query(ctx, ` + SELECT EXTRACT(DOW FROM charge_start_datetime)::int, + EXTRACT(HOUR FROM charge_start_datetime)::int, + COUNT(*), + COUNT(*) FILTER (WHERE COALESCE(congestion_fee, 0) > 0) + FROM tesla_charging_sessions + WHERE site_location_name = $1 AND charge_start_datetime >= $2 + GROUP BY 1, 2`, site, since) + if err != nil { + return h, fmt.Errorf("waitoracle: demand: %w", err) + } + defer rows.Close() + for rows.Next() { + var b Bucket + if err := rows.Scan(&b.Weekday, &b.Hour, &b.Starts, &b.Congested); err != nil { + return h, fmt.Errorf("waitoracle: scan demand: %w", err) + } + h.Buckets = append(h.Buckets, b) + } + if err := rows.Err(); err != nil { + return h, fmt.Errorf("waitoracle: demand: %w", err) + } + + spanRows, err := s.db.Pool.Query(ctx, ` + SELECT charge_start_datetime, COALESCE(charge_stop_datetime, charge_start_datetime) + FROM tesla_charging_sessions + WHERE site_location_name = $1 AND charge_start_datetime >= $2 + ORDER BY charge_start_datetime DESC + LIMIT $3`, site, since, maxHistoryRows) + if err != nil { + return h, fmt.Errorf("waitoracle: spans: %w", err) + } + defer spanRows.Close() + for spanRows.Next() { + var sp Session + if err := spanRows.Scan(&sp.Start, &sp.Stop); err != nil { + return h, fmt.Errorf("waitoracle: scan span: %w", err) + } + h.Spans = append(h.Spans, sp) + } + if err := spanRows.Err(); err != nil { + return h, fmt.Errorf("waitoracle: spans: %w", err) + } + if len(h.Spans) == 0 { + return h, ErrNoHistory + } + return h, nil +} diff --git a/internal/api/webvitals/routetemplates_gen.go b/internal/api/webvitals/routetemplates_gen.go index b9e291cb16..041fe1674e 100644 --- a/internal/api/webvitals/routetemplates_gen.go +++ b/internal/api/webvitals/routetemplates_gen.go @@ -11,7 +11,7 @@ package webvitals // generatedRoutePaths is the canonical SPA route table. Segments beginning // with ':' are client-controlled parameter positions and are templated to // `:id` before a route can become a Prometheus label. -var generatedRoutePaths = [241]string{ +var generatedRoutePaths = [261]string{ "/", "/account/2fa", "/account/privacy", @@ -141,6 +141,7 @@ var generatedRoutePaths = [241]string{ "/intelligence/tco-optimizer", "/intelligence/twin-lab", "/journey-fragmentation", + "/journeys", "/lifetime-stats", "/live", "/live-monitor", @@ -168,6 +169,7 @@ var generatedRoutePaths = [241]string{ "/notifications/studio", "/notifications/webhooks", "/onboarding", + "/outage", "/ownership/charging-reconciliation", "/ownership/consumables-lifecycle", "/ownership/data-governance", @@ -181,6 +183,7 @@ var generatedRoutePaths = [241]string{ "/pack-capacity", "/parking", "/period-compare", + "/physics-cockpit", "/power-flow", "/power/dashboards", "/power/grafana", @@ -203,6 +206,7 @@ var generatedRoutePaths = [241]string{ "/segments", "/service-intelligence", "/settings", + "/settings/fleet-setup", "/settings/safety", "/share-card", "/sharing/trips", @@ -232,6 +236,22 @@ var generatedRoutePaths = [241]string{ "/tesla-charging-history", "/tesla-charging-sessions", "/tesla-features", + "/tesla-only", + "/tesla-only/black-box", + "/tesla-only/car-kept-living", + "/tesla-only/charge-port", + "/tesla-only/clocks", + "/tesla-only/contradictions", + "/tesla-only/dictionary", + "/tesla-only/firmware-epochs", + "/tesla-only/life-tape", + "/tesla-only/logbook", + "/tesla-only/meters", + "/tesla-only/modes", + "/tesla-only/nervous-system", + "/tesla-only/range", + "/tesla-only/unknown", + "/tesla-only/vault", "/tesla-orders", "/tesla-region", "/time-machine", diff --git a/internal/app/new.go b/internal/app/new.go index 3b8f79c7da..954db35d24 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -17,8 +17,10 @@ import ( "github.com/rs/zerolog/log" "github.com/ev-dev-labs/teslasync/internal/api" + apicomfort "github.com/ev-dev-labs/teslasync/internal/api/comfort" apidatarepair "github.com/ev-dev-labs/teslasync/internal/api/datarepair" apiopenapi "github.com/ev-dev-labs/teslasync/internal/api/openapi" + apistormguard "github.com/ev-dev-labs/teslasync/internal/api/stormguard" apisystem "github.com/ev-dev-labs/teslasync/internal/api/system" apitelem "github.com/ev-dev-labs/teslasync/internal/api/telemetry" "github.com/ev-dev-labs/teslasync/internal/apilog" @@ -151,6 +153,8 @@ func New(ctx context.Context, cfg *config.Config, build BuildInfo) (*App, error) a.initAIBackgroundJobs(ctx) a.initDataRepairScanner(ctx) a.initHealthWatchdog(ctx) + a.initStormguard(ctx) + a.initComfort(ctx) a.loadOpenAPISpec() return a, nil @@ -1469,6 +1473,50 @@ func (a *App) initHealthWatchdog(ctx context.Context) { }) } +// initStormguard starts the hourly severe-weather guard pass: armed +// vehicles get their home forecast assessed and, on a fresh warning with +// the battery below target, a pre-charge limit bump. Skipped when core +// dependencies are missing (dev without Tesla credentials); the +// read-only status endpoint still serves. +func (a *App) initStormguard(ctx context.Context) { + if a.DB == nil || a.TeslaClient == nil || a.StateReader == nil { + log.Warn().Msg("stormguard: missing DB/Tesla/state dependency — evaluator disabled") + return + } + h := apistormguard.NewHandler( + apistormguard.NewStore(a.DB), + apistormguard.NewClient(), + a.TeslaClient, + a.StateReader, + vehicledb.NewVehicleRepo(a.DB), + ) + resilience.SafeGoLoop(ctx, "stormguard", func(loopCtx context.Context) { + h.Run(loopCtx, apistormguard.DefaultEvaluateInterval) + }) + log.Info().Msg("stormguard evaluator started") +} + +// initComfort starts the 5-minute calendar event watch: enabled vehicles +// get their ICS feed polled and, when an offsite event falls inside the +// lead window, a one-shot precondition. Skipped when core dependencies +// are missing; the read-only next-event endpoint still serves. +func (a *App) initComfort(ctx context.Context) { + if a.DB == nil || a.TeslaClient == nil { + log.Warn().Msg("comfort: missing DB/Tesla dependency — evaluator disabled") + return + } + h := apicomfort.NewHandler( + apicomfort.NewStore(a.DB), + apicomfort.NewFetcher(), + a.TeslaClient, + vehicledb.NewVehicleRepo(a.DB), + ) + resilience.SafeGoLoop(ctx, "comfort", func(loopCtx context.Context) { + h.Run(loopCtx, apicomfort.DefaultEvaluateInterval) + }) + log.Info().Msg("comfort evaluator started") +} + // workerDegradedThreshold mirrors resilience.HealthMonitor's own // consecutive-failure bar for StatusDegraded so the "worker" component // probe (Worker.HealthSnapshot) and the generic HealthMonitor agree on diff --git a/internal/app/ownershipintelsvc/ghost.go b/internal/app/ownershipintelsvc/ghost.go new file mode 100644 index 0000000000..58ce31a29a --- /dev/null +++ b/internal/app/ownershipintelsvc/ghost.go @@ -0,0 +1,118 @@ +package ownershipintelsvc + +import ( + "context" + "fmt" + "sort" + + "github.com/ev-dev-labs/teslasync/internal/domain/ownershipintel" +) + +// Ghost scoring: unattributed drives far from their cluster centroid are +// the ghost signature (valet, teen, thief — or just a hire car weekend). +// Score 0..100; ghostScoreThreshold flags. Median-based ratio keeps the +// bar adaptive per vehicle instead of a magic absolute distance. +const ( + ghostScoreThreshold = 60.0 + ghostConfidenceBar = 70.0 + ghostScanLimit = 100 +) + +// GhostDrives scores recent drives for unknown-driver activity, reusing +// the full attribution pipeline (fingerprints + clusters + profiles). +func (s *Service) GhostDrives(ctx context.Context, subject string, vehicleID int64, windowDays int) (*ownershipintel.GhostReport, error) { + if vehicleID <= 0 { + return nil, fmt.Errorf("%w: vehicle_id must be positive", ErrInvalidInput) + } + report, err := s.DriverAttribution(ctx, subject, vehicleID, windowDays, ghostScanLimit, 0) + if err != nil { + return nil, err + } + return &ownershipintel.GhostReport{ + VehicleID: vehicleID, + Scanned: len(report.Fingerprints), + Ghosts: DetectGhosts(report.Fingerprints), + }, nil +} + +// DetectGhosts flags unattributed drives whose behaviour deviates from +// the norm. Pure: no I/O, deterministic. Score composition: +// +// - 40 points for being unattributed to any named profile; +// - up to 40 for distance-to-own-centroid above the fleet median +// (ratio excess × 20, capped); +// - up to 20 for attribution confidence below 70. +// +// Attributed drives can never score above 60 without both strong +// distance AND weak confidence, so a named driver's odd trip only flags +// when it genuinely looks like someone else. +func DetectGhosts(fps []ownershipintel.DriveFingerprint) []ownershipintel.GhostDrive { + median := medianDistance(fps) + out := []ownershipintel.GhostDrive{} + for _, fp := range fps { + score := 0.0 + if fp.DriverProfileID == nil { + score += 40 + } + ratio := 1.0 + if median > 0 { + ratio = fp.DistanceToOwn / median + } + if excess := ratio - 1; excess > 0 { + score += minFloat(excess*20, 40) + } + if short := ghostConfidenceBar - fp.ConfidencePct; short > 0 { + score += minFloat(short*0.5, 20) + } + if score < ghostScoreThreshold { + continue + } + out = append(out, ownershipintel.GhostDrive{ + DriveID: fp.DriveID, StartedAt: fp.StartedAt, + DistanceM: fp.DistanceM, DurationS: fp.DurationS, + ClusterID: fp.ClusterID, Score: round1(score), + ConfidencePct: fp.ConfidencePct, DistanceRatio: round2(ratio), + Reason: ghostReason(fp, ratio), + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) + return out +} + +func ghostReason(fp ownershipintel.DriveFingerprint, ratio float64) string { + switch { + case fp.DriverProfileID == nil && ratio >= 2: + return fmt.Sprintf("unattributed drive, %.0f%% confidence, %.1f× typical distance", fp.ConfidencePct, ratio) + case fp.DriverProfileID == nil: + return fmt.Sprintf("unattributed drive, %.0f%% confidence", fp.ConfidencePct) + default: + return fmt.Sprintf("drives unlike its profile (%.1f× typical distance)", ratio) + } +} + +func medianDistance(fps []ownershipintel.DriveFingerprint) float64 { + if len(fps) == 0 { + return 0 + } + ds := make([]float64, 0, len(fps)) + for _, fp := range fps { + ds = append(ds, fp.DistanceToOwn) + } + sort.Float64s(ds) + mid := len(ds) / 2 + if len(ds)%2 == 1 { + return ds[mid] + } + return (ds[mid-1] + ds[mid]) / 2 +} + +func minFloat(a, b float64) float64 { + if a < b { + return a + } + return b +} + +func round1(v float64) float64 { return float64(int(v*10+0.5)) / 10 } + +func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } diff --git a/internal/app/ownershipintelsvc/ghost_test.go b/internal/app/ownershipintelsvc/ghost_test.go new file mode 100644 index 0000000000..31eea014f3 --- /dev/null +++ b/internal/app/ownershipintelsvc/ghost_test.go @@ -0,0 +1,108 @@ +package ownershipintelsvc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/ev-dev-labs/teslasync/internal/domain/ownershipintel" +) + +func ghostFP(driveID int64, profileID *int64, confidence, distOwn float64) ownershipintel.DriveFingerprint { + return ownershipintel.DriveFingerprint{ + DriveID: driveID, + StartedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC), + DistanceM: 12000, + DurationS: 1200, + ClusterID: 0, + DriverProfileID: profileID, + ConfidencePct: confidence, + DistanceToOwn: distOwn, + } +} + +func TestDetectGhosts(t *testing.T) { + owner := int64(1) + normal := []ownershipintel.DriveFingerprint{ + ghostFP(1, &owner, 92, 0.5), + ghostFP(2, &owner, 88, 0.6), + ghostFP(3, &owner, 90, 0.55), + ghostFP(4, &owner, 85, 0.7), + } + + t.Run("unattributed far drive flags", func(t *testing.T) { + fps := append(append([]ownershipintel.DriveFingerprint{}, normal...), + ghostFP(9, nil, 55, 2.5)) + got := DetectGhosts(fps) + if len(got) != 1 || got[0].DriveID != 9 { + t.Fatalf("ghosts = %+v, want drive 9", got) + } + if got[0].Score < 60 { + t.Fatalf("score = %v, want >= 60", got[0].Score) + } + }) + + t.Run("attributed normal drives stay quiet", func(t *testing.T) { + if got := DetectGhosts(normal); len(got) != 0 { + t.Fatalf("ghosts = %+v, want none", got) + } + }) + + t.Run("attributed odd trip flags only when extreme", func(t *testing.T) { + fps := append(append([]ownershipintel.DriveFingerprint{}, normal...), + ghostFP(9, &owner, 30, 3.0)) + got := DetectGhosts(fps) + if len(got) != 1 { + t.Fatalf("ghosts = %+v, want the extreme trip", got) + } + }) + + t.Run("attributed mild outlier stays quiet", func(t *testing.T) { + fps := append(append([]ownershipintel.DriveFingerprint{}, normal...), + ghostFP(9, &owner, 75, 0.9)) + if got := DetectGhosts(fps); len(got) != 0 { + t.Fatalf("ghosts = %+v, want none", got) + } + }) + + t.Run("empty input yields empty output", func(t *testing.T) { + if got := DetectGhosts(nil); len(got) != 0 { + t.Fatalf("ghosts = %+v, want none", got) + } + }) + + t.Run("results sort by score descending", func(t *testing.T) { + fps := append(append([]ownershipintel.DriveFingerprint{}, normal...), + ghostFP(9, nil, 60, 1.8), + ghostFP(10, nil, 40, 3.0), + ) + got := DetectGhosts(fps) + if len(got) != 2 || got[0].DriveID != 10 { + t.Fatalf("ghosts = %+v, want 10 first", got) + } + }) +} + +func TestGhostDrivesRejectsBadVehicle(t *testing.T) { + s := &Service{} + _, err := s.GhostDrives(context.Background(), "tester", 0, 30) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("err = %v, want ErrInvalidInput", err) + } +} + +func TestMedianDistance(t *testing.T) { + if got := medianDistance(nil); got != 0 { + t.Fatalf("empty median = %v, want 0", got) + } + owner := int64(1) + fps := []ownershipintel.DriveFingerprint{ + ghostFP(1, &owner, 90, 3), + ghostFP(2, &owner, 90, 1), + ghostFP(3, &owner, 90, 2), + } + if got := medianDistance(fps); got != 2 { + t.Fatalf("median = %v, want 2", got) + } +} diff --git a/internal/automation/presets/builtins.go b/internal/automation/presets/builtins.go index 5628e6d6a8..bd08dea8b1 100644 --- a/internal/automation/presets/builtins.go +++ b/internal/automation/presets/builtins.go @@ -248,6 +248,9 @@ func (r *Registry) registerBuiltins() { }, Tags: []string{"energy", "amperage"}, }) + + r.registerExtended() + r.registerEcosystem() } // --- builders ------------------------------------------------------------- @@ -294,6 +297,36 @@ func actionCommand(name string, params map[string]any) json.RawMessage { return mustMarshal(step) } +func conditionTimeWindow(start, end, tz string) json.RawMessage { + if tz == "" { + tz = "UTC" + } + return mustMarshal(map[string]any{ + "kind": "condition_time_window", + "start_time": start, + "end_time": end, + "timezone": tz, + }) +} + +func conditionSignalNum(signal, op string, value float64) json.RawMessage { + return mustMarshal(map[string]any{ + "kind": "condition_signal", + "signal": signal, + "op": op, + "value_num": value, + }) +} + +func conditionSignalBool(signal, op string, value bool) json.RawMessage { + return mustMarshal(map[string]any{ + "kind": "condition_signal", + "signal": signal, + "op": op, + "value_bool": value, + }) +} + func mustMarshal(v any) json.RawMessage { b, err := json.Marshal(v) if err != nil { diff --git a/internal/automation/presets/ecosystem.go b/internal/automation/presets/ecosystem.go new file mode 100644 index 0000000000..11891161cd --- /dev/null +++ b/internal/automation/presets/ecosystem.go @@ -0,0 +1,394 @@ +package presets + +import "encoding/json" + +// registerEcosystem adds one-click presets for Tesla commands that the +// starter + extended catalogues did not cover. Same constraints: no +// geofence/notify/FK steps, no PIN/erase/remote-start, no navigation +// without a destination. +func (r *Registry) registerEcosystem() { + // ---- Locate / alerts --------------------------------------------- + r.register(Preset{ + ID: "locate_honk_weekday_morning", Name: "Honk Weekdays at 7 AM", + Description: "Honk the horn weekday mornings so you can find the car in a crowded lot.", + Category: "security", Icon: "volume", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("honk_horn", nil)}, + Tags: []string{"honk", "locate", "weekday"}, + }) + r.register(Preset{ + ID: "locate_honk_charge_end", Name: "Honk When Charging Ends", + Description: "Honk once a charge session finishes so you can find the stall.", + Category: "charging", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("charge_end")}, + Actions: []json.RawMessage{actionCommand("honk_horn", nil)}, + Tags: []string{"honk", "charge"}, + }) + r.register(Preset{ + ID: "locate_flash_drive_end", Name: "Flash Lights After Drive", + Description: "Flash the lights when a drive ends to mark the parked car.", + Category: "driving", Icon: "lightbulb", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("flash_lights", nil)}, + Tags: []string{"flash", "drive"}, + }) + r.register(Preset{ + ID: "locate_flash_sleep_end", Name: "Flash Lights When Vehicle Wakes", + Description: "Flash lights when the vehicle leaves sleep.", + Category: "security", Icon: "lightbulb", + Triggers: []json.RawMessage{triggerEvent("sleep_end")}, + Actions: []json.RawMessage{actionCommand("flash_lights", nil)}, + Tags: []string{"flash", "wake"}, + }) + r.register(Preset{ + ID: "locate_honk_online", Name: "Honk When Vehicle Comes Online", + Description: "Honk once the vehicle is reachable after being offline.", + Category: "security", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("online")}, + Actions: []json.RawMessage{actionCommand("honk", nil)}, + Tags: []string{"honk", "online"}, + }) + + // ---- Boombox / media extras -------------------------------------- + r.register(Preset{ + ID: "media_boombox_ping_online", Name: "Boombox Ping on Wake", + Description: "Play the boombox ping when the vehicle comes online.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("online")}, + Actions: []json.RawMessage{actionCommand("boombox_ping", nil)}, + Tags: []string{"boombox", "wake"}, + }) + r.register(Preset{ + ID: "media_boombox_ping_drive_end", Name: "Boombox Ping After Drive", + Description: "Ping the pedestrian speaker when a drive ends.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("boombox_ping", nil)}, + Tags: []string{"boombox", "drive"}, + }) + r.register(Preset{ + ID: "media_prev_track_drive_start", Name: "Previous Track on Drive Start", + Description: "Jump back one track as you start driving.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("media_prev_track", nil)}, + Tags: []string{"media", "drive"}, + }) + r.register(Preset{ + ID: "media_next_fav_drive_start", Name: "Next Favorite on Drive Start", + Description: "Switch to the next favorite station when a drive starts.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("media_next_fav", nil)}, + Tags: []string{"media", "favorite"}, + }) + r.register(Preset{ + ID: "media_prev_fav_drive_start", Name: "Previous Favorite on Drive Start", + Description: "Switch to the previous favorite station when a drive starts.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("media_prev_fav", nil)}, + Tags: []string{"media", "favorite"}, + }) + r.register(Preset{ + ID: "media_next_fav_online", Name: "Next Favorite on Wake", + Description: "Advance favorites when the vehicle comes online.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("online")}, + Actions: []json.RawMessage{actionCommand("media_next_fav", nil)}, + Tags: []string{"media", "wake"}, + }) + r.register(Preset{ + ID: "media_volume_down_drive_end", Name: "Lower Volume After Drive", + Description: "Turn the cabin volume down when a drive ends.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("media_volume_down", nil)}, + Tags: []string{"media", "drive"}, + }) + r.register(Preset{ + ID: "media_toggle_charge_start", Name: "Toggle Playback When Charging Starts", + Description: "Start or pause media as charging begins.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("media_toggle_playback", nil)}, + Tags: []string{"media", "charge"}, + }) + + // ---- Guest / safety extras --------------------------------------- + r.register(Preset{ + ID: "safety_guest_on_friday", Name: "Enable Guest Mode Friday Evening", + Description: "Turn Guest Mode on every Friday at 6 PM for weekend sharing.", + Category: "safety", Icon: "shield-check", + Triggers: []json.RawMessage{triggerSchedule("0 18 * * 5", "UTC")}, + Actions: []json.RawMessage{actionCommand("guest_mode_on", nil)}, + Tags: []string{"guest", "weekend"}, + }) + r.register(Preset{ + ID: "safety_guest_on_saturday", Name: "Enable Guest Mode Saturday Morning", + Description: "Turn Guest Mode on Saturday at 8 AM.", + Category: "safety", Icon: "shield-check", + Triggers: []json.RawMessage{triggerSchedule("0 8 * * 6", "UTC")}, + Actions: []json.RawMessage{actionCommand("guest_mode_on", nil)}, + Tags: []string{"guest", "weekend"}, + }) + r.register(Preset{ + ID: "safety_guest_off_charge_end", Name: "Disable Guest Mode After Charge", + Description: "Turn Guest Mode off when charging ends.", + Category: "safety", Icon: "shield-check", + Triggers: []json.RawMessage{triggerEvent("charge_end")}, + Actions: []json.RawMessage{actionCommand("guest_mode_off", nil)}, + Tags: []string{"guest", "charge"}, + }) + r.register(Preset{ + ID: "safety_cop_temp_high_noon", Name: "Set Overheat Protection High at Noon", + Description: "Raise cabin overheat protection to High every day at noon.", + Category: "safety", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSchedule("0 12 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_cop_temp", map[string]any{"cop_temp": 2})}, + Tags: []string{"overheat", "schedule"}, + }) + r.register(Preset{ + ID: "safety_cop_temp_low_morning", Name: "Set Overheat Protection Low at 8 AM", + Description: "Drop cabin overheat protection to Low each morning.", + Category: "safety", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSchedule("0 8 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_cop_temp", map[string]any{"cop_temp": 0})}, + Tags: []string{"overheat", "schedule"}, + }) + r.register(Preset{ + ID: "safety_cop_fan_hot_cabin", Name: "COP Fan-Only If Cabin > 32°C", + Description: "Enable fan-only overheat protection when the cabin is warm.", + Category: "safety", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSignalNum("inside_temp", ">", 32)}, + Actions: []json.RawMessage{actionCommand("cop_fan_only", nil)}, + Tags: []string{"overheat", "fan"}, + }) + r.register(Preset{ + ID: "safety_dog_mode_hot_cabin", Name: "Dog Mode If Cabin > 28°C", + Description: "Enable Dog Mode when cabin temperature climbs.", + Category: "safety", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSignalNum("inside_temp", ">", 28)}, + Actions: []json.RawMessage{actionCommand("dog_mode", nil)}, + Tags: []string{"dog", "cabin"}, + }) + + // ---- Climate / comfort extras ------------------------------------ + r.register(Preset{ + ID: "comfort_steering_level_morning", Name: "Steering Heat Level 3 Weekdays at 7 AM", + Description: "Set steering-wheel heat to level 3 on weekday mornings.", + Category: "comfort", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("steering_wheel_level", map[string]any{"level": 3})}, + Tags: []string{"steering", "weekday"}, + }) + r.register(Preset{ + ID: "comfort_steering_level_off_night", Name: "Steering Heat Level 0 at 10 PM", + Description: "Turn steering-wheel heat off every night.", + Category: "comfort", Icon: "moon", + Triggers: []json.RawMessage{triggerSchedule("0 22 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("steering_wheel_level", map[string]any{"level": 0})}, + Tags: []string{"steering", "night"}, + }) + r.register(Preset{ + ID: "comfort_camp_mode_night", Name: "Camp Mode at 9 PM", + Description: "Enable Camp Mode every evening.", + Category: "comfort", Icon: "moon", + Triggers: []json.RawMessage{triggerSchedule("0 21 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("camp_mode", nil)}, + Tags: []string{"camp", "night"}, + }) + r.register(Preset{ + ID: "comfort_keeper_off_morning", Name: "Climate Keeper Off at 7 AM", + Description: "Disable Climate Keeper each morning.", + Category: "climate", Icon: "thermometer-snowflake", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("climate_keeper_off", nil)}, + Tags: []string{"keeper", "morning"}, + }) + r.register(Preset{ + ID: "comfort_precondition_reset_drive_end", Name: "Reset Preconditioning After Drive", + Description: "Clear max preconditioning when a drive ends.", + Category: "climate", Icon: "thermometer", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("preconditioning_reset", nil)}, + Tags: []string{"precondition", "drive"}, + }) + r.register(Preset{ + ID: "comfort_seat_cooler_drive_hot", Name: "Cool Driver Seat If Cabin > 30°C on Drive", + Description: "Start driver-seat cooling when a drive begins in a hot cabin.", + Category: "comfort", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Conditions: []json.RawMessage{conditionSignalNum("inside_temp", ">", 30)}, + Actions: []json.RawMessage{actionCommand("seat_cooler", map[string]any{"seat_position": 0, "seat_cooler_level": 2})}, + Tags: []string{"seat", "cooling"}, + }) + r.register(Preset{ + ID: "comfort_auto_steering_drive", Name: "Auto Steering Heat on Drive Start", + Description: "Enable automatic steering-wheel heat when a drive starts.", + Category: "comfort", Icon: "thermometer", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("auto_steering_heat", nil)}, + Tags: []string{"steering", "drive"}, + }) + + // ---- Windows / sunroof extras ------------------------------------ + r.register(Preset{ + ID: "windows_sunroof_stop_drive", Name: "Stop Sunroof on Drive Start", + Description: "Halt sunroof motion when you start driving.", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("sunroof_stop", nil)}, + Tags: []string{"sunroof", "drive"}, + }) + r.register(Preset{ + ID: "windows_sunroof_close_drive", Name: "Close Sunroof on Drive Start", + Description: "Close the sunroof as soon as a drive starts.", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("sunroof_close", nil)}, + Tags: []string{"sunroof", "drive"}, + }) + r.register(Preset{ + ID: "windows_sunroof_vent_hot", Name: "Vent Sunroof If Cabin > 32°C", + Description: "Crack the sunroof when the cabin is hot.", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerSignalNum("inside_temp", ">", 32)}, + Actions: []json.RawMessage{actionCommand("sunroof_vent", nil)}, + Tags: []string{"sunroof", "heat"}, + }) + r.register(Preset{ + ID: "windows_sunroof_close_sleep", Name: "Close Sunroof When Vehicle Sleeps", + Description: "Close the sunroof whenever the vehicle goes to sleep.", + Category: "windows", Icon: "moon", + Triggers: []json.RawMessage{triggerEvent("sleep_start")}, + Actions: []json.RawMessage{actionCommand("sunroof_close", nil)}, + Tags: []string{"sunroof", "sleep"}, + }) + r.register(Preset{ + ID: "windows_close_offline", Name: "Close Windows When Vehicle Goes Offline", + Description: "Close windows if the vehicle drops offline.", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerEvent("offline")}, + Actions: []json.RawMessage{actionCommand("close_windows", nil)}, + Tags: []string{"windows", "offline"}, + }) + + // ---- Charging extras --------------------------------------------- + r.register(Preset{ + ID: "charge_port_close_sleep", Name: "Close Charge Port on Sleep", + Description: "Close the charge port whenever the vehicle sleeps.", + Category: "charging", Icon: "battery-charging", + Triggers: []json.RawMessage{triggerEvent("sleep_start")}, + Actions: []json.RawMessage{actionCommand("close_charge_port", nil)}, + Tags: []string{"port", "sleep"}, + }) + r.register(Preset{ + ID: "charge_port_close_charge_end", Name: "Close Charge Port When Charging Ends", + Description: "Close the charge port after a session.", + Category: "charging", Icon: "battery-charging", + Triggers: []json.RawMessage{triggerEvent("charge_end")}, + Actions: []json.RawMessage{actionCommand("close_charge_port", nil)}, + Tags: []string{"port", "charge"}, + }) + r.register(Preset{ + ID: "charge_port_open_weekday_morning", Name: "Open Charge Port Weekdays at 7 AM", + Description: "Open the charge port weekday mornings before you leave.", + Category: "charging", Icon: "battery-charging", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("open_charge_port", nil)}, + Tags: []string{"port", "weekday"}, + }) + r.register(Preset{ + ID: "charge_standard_weekday", Name: "Charge Standard on Weekday Mornings", + Description: "Switch to standard charge limit weekday mornings.", + Category: "charging", Icon: "battery", + Triggers: []json.RawMessage{triggerSchedule("0 6 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("charge_standard", nil)}, + Tags: []string{"limit", "weekday"}, + }) + r.register(Preset{ + ID: "charge_max_range_friday_evening", Name: "Max Range Charge Friday Evening", + Description: "Switch to max-range charging every Friday at 6 PM for weekend trips.", + Category: "charging", Icon: "battery-charging", + Triggers: []json.RawMessage{triggerSchedule("0 18 * * 5", "UTC")}, + Actions: []json.RawMessage{actionCommand("charge_max_range", nil)}, + Tags: []string{"range", "weekend"}, + }) + r.register(Preset{ + ID: "charge_amps_32_start", Name: "Set 32A When Charging Starts", + Description: "Raise charging amps to 32A at the start of every session.", + Category: "energy", Icon: "gauge", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("set_charging_amps", map[string]any{"charging_amps": 32})}, + Tags: []string{"amps", "charge"}, + }) + r.register(Preset{ + ID: "charge_limit_100_friday", Name: "Charge Limit 100% Friday Evening", + Description: "Set the charge limit to 100% every Friday at 6 PM.", + Category: "energy", Icon: "battery", + Triggers: []json.RawMessage{triggerSchedule("0 18 * * 5", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_charge_limit", map[string]any{"percent": 100})}, + Tags: []string{"limit", "weekend"}, + }) + + // ---- Wake / maintenance extras ----------------------------------- + r.register(Preset{ + ID: "maint_wake_5am", Name: "Wake Vehicle at 5 AM", + Description: "Wake the vehicle every morning at 5 AM before preconditioning.", + Category: "maintenance", Icon: "clock", + Triggers: []json.RawMessage{triggerSchedule("0 5 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("wake_up", nil)}, + Tags: []string{"wake", "schedule"}, + }) + r.register(Preset{ + ID: "maint_wake_weekday_6", Name: "Wake Vehicle Weekdays at 6 AM", + Description: "Wake the vehicle weekday mornings.", + Category: "maintenance", Icon: "clock", + Triggers: []json.RawMessage{triggerSchedule("0 6 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("wake", nil)}, + Tags: []string{"wake", "weekday"}, + }) + r.register(Preset{ + ID: "maint_flash_charge_end", Name: "Flash Lights When Charging Ends", + Description: "Flash lights so you can spot a finished Supercharger stall.", + Category: "maintenance", Icon: "lightbulb", + Triggers: []json.RawMessage{triggerEvent("charge_end")}, + Actions: []json.RawMessage{actionCommand("flash", nil)}, + Tags: []string{"flash", "charge"}, + }) + + // ---- Security extras --------------------------------------------- + r.register(Preset{ + ID: "sec_unlock_weekday_7am", Name: "Unlock Weekdays at 7 AM", + Description: "Unlock the doors weekday mornings as you walk out.", + Category: "security", Icon: "unlock", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("unlock", nil)}, + Tags: []string{"unlock", "weekday"}, + }) + r.register(Preset{ + ID: "sec_lock_drive_start", Name: "Lock Doors on Drive Start", + Description: "Lock as soon as a drive begins.", + Category: "security", Icon: "lock", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("lock", nil)}, + Tags: []string{"lock", "drive"}, + }) + r.register(Preset{ + ID: "sec_sentry_on_sentry_alert", Name: "Re-arm Sentry After Sentry Alert", + Description: "Turn Sentry back on after a Sentry alert event.", + Category: "security", Icon: "shield", + Triggers: []json.RawMessage{triggerEvent("sentry_alert")}, + Actions: []json.RawMessage{actionCommand("sentry_on", nil)}, + Tags: []string{"sentry", "alert"}, + }) + r.register(Preset{ + ID: "home_homelink_sleep_end", Name: "HomeLink When Vehicle Wakes", + Description: "Trigger HomeLink when the vehicle leaves sleep.", + Category: "home", Icon: "home", + Triggers: []json.RawMessage{triggerEvent("sleep_end")}, + Actions: []json.RawMessage{actionCommand("trigger_homelink", nil)}, + Tags: []string{"homelink", "wake"}, + }) +} diff --git a/internal/automation/presets/extended.go b/internal/automation/presets/extended.go new file mode 100644 index 0000000000..52ee6fe22e --- /dev/null +++ b/internal/automation/presets/extended.go @@ -0,0 +1,773 @@ +package presets + +import "encoding/json" + +// registerExtended adds the large one-click catalogue. Starter presets in +// builtins.go stay unchanged; this set only uses schedule/event/signal +// triggers, optional time-window or signal conditions, and Tesla commands +// that do not need per-user FKs or PIN parameters. +func (r *Registry) registerExtended() { + // ---- Security ----------------------------------------------------- + r.register(Preset{ + ID: "sec_sentry_on_sleep", Name: "Sentry On When Vehicle Sleeps", + Description: "Enable Sentry Mode whenever the vehicle goes to sleep.", + Category: "security", Icon: "shield", + Triggers: []json.RawMessage{triggerEvent("sleep_start")}, + Actions: []json.RawMessage{actionCommand("sentry_on", nil)}, + Tags: []string{"sentry", "sleep"}, + }) + r.register(Preset{ + ID: "sec_sentry_on_drive_end", Name: "Sentry On After Drive", + Description: "Arm Sentry Mode as soon as a drive ends.", + Category: "security", Icon: "shield", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("sentry_on", nil)}, + Tags: []string{"sentry", "drive"}, + }) + r.register(Preset{ + ID: "sec_sentry_on_offline", Name: "Sentry On When Vehicle Goes Offline", + Description: "Arm Sentry if the vehicle drops offline unexpectedly.", + Category: "security", Icon: "shield", + Triggers: []json.RawMessage{triggerEvent("offline")}, + Actions: []json.RawMessage{actionCommand("sentry_on", nil)}, + Tags: []string{"sentry", "offline"}, + }) + r.register(Preset{ + ID: "sec_lock_on_offline", Name: "Lock Doors When Vehicle Goes Offline", + Description: "Lock the doors if the vehicle goes offline.", + Category: "security", Icon: "lock", + Triggers: []json.RawMessage{triggerEvent("offline")}, + Actions: []json.RawMessage{actionCommand("lock", nil)}, + Tags: []string{"lock", "offline"}, + }) + r.register(Preset{ + ID: "sec_lock_on_charge_start", Name: "Lock Doors When Charging Starts", + Description: "Lock while plugged in at a public charger.", + Category: "security", Icon: "lock", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("lock", nil)}, + Tags: []string{"lock", "charge"}, + }) + r.register(Preset{ + ID: "sec_lock_nightly", Name: "Lock Doors Every Night at 10 PM", + Description: "Nightly door lock in case someone left the car unlocked.", + Category: "security", Icon: "lock", + Triggers: []json.RawMessage{triggerSchedule("0 22 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("lock", nil)}, + Tags: []string{"lock", "night", "schedule"}, + }) + r.register(Preset{ + ID: "sec_lock_and_close_on_sleep", Name: "Lock and Close Windows on Sleep", + Description: "Lock doors and close windows whenever the vehicle sleeps.", + Category: "security", Icon: "lock", + Triggers: []json.RawMessage{triggerEvent("sleep_start")}, + Actions: []json.RawMessage{ + actionCommand("lock", nil), + actionCommand("close_windows", nil), + }, + Tags: []string{"lock", "windows", "sleep"}, + }) + r.register(Preset{ + ID: "sec_sentry_if_battery_ok", Name: "Sentry On After Drive If Battery ≥ 20%", + Description: "Arm Sentry after a drive only when the pack has enough energy.", + Category: "security", Icon: "shield", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Conditions: []json.RawMessage{conditionSignalNum("battery_level", ">=", 20)}, + Actions: []json.RawMessage{actionCommand("sentry_on", nil)}, + Tags: []string{"sentry", "battery"}, + }) + r.register(Preset{ + ID: "sec_lock_after_charge_night", Name: "Lock After Charge at Night", + Description: "When charging ends between 10 PM and 6 AM, lock the doors.", + Category: "security", Icon: "lock", + Triggers: []json.RawMessage{triggerEvent("charge_end")}, + Conditions: []json.RawMessage{conditionTimeWindow("22:00", "06:00", "UTC")}, + Actions: []json.RawMessage{actionCommand("lock", nil)}, + Tags: []string{"lock", "charge", "night"}, + }) + r.register(Preset{ + ID: "sec_unlock_weekday_morning", Name: "Unlock Weekday Mornings at 7 AM", + Description: "Unlock for a commute grab-and-go. Disable if you park on the street.", + Category: "security", Icon: "unlock", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("unlock", nil)}, + Tags: []string{"unlock", "weekday"}, + }) + r.register(Preset{ + ID: "sec_sentry_weekend_night", Name: "Sentry On Weekend Nights", + Description: "Arm Sentry at 9 PM on Friday and Saturday.", + Category: "security", Icon: "shield", + Triggers: []json.RawMessage{triggerSchedule("0 21 * * 5,6", "UTC")}, + Actions: []json.RawMessage{actionCommand("sentry_on", nil)}, + Tags: []string{"sentry", "weekend"}, + }) + r.register(Preset{ + ID: "sec_sentry_off_weekday_morning", Name: "Sentry Off Weekdays at 7 AM", + Description: "Disarm Sentry before the weekday commute.", + Category: "security", Icon: "shield-off", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("sentry_off", nil)}, + Tags: []string{"sentry", "weekday"}, + }) + + // ---- Climate ------------------------------------------------------ + r.register(Preset{ + ID: "climate_weekend_precondition", Name: "Weekend Pre-condition at 8 AM", + Description: "Warm or cool the cabin at 8 AM on Saturday and Sunday.", + Category: "climate", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSchedule("0 8 * * 6,0", "UTC")}, + Actions: []json.RawMessage{actionCommand("climate_on", nil)}, + Tags: []string{"climate", "weekend"}, + }) + r.register(Preset{ + ID: "climate_off_nightly", Name: "Climate Off Every Night at 10 PM", + Description: "Make sure HVAC is not left running overnight.", + Category: "climate", Icon: "thermometer-snowflake", + Triggers: []json.RawMessage{triggerSchedule("0 22 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("climate_off", nil)}, + Tags: []string{"climate", "night"}, + }) + r.register(Preset{ + ID: "climate_off_on_sleep", Name: "Climate Off When Vehicle Sleeps", + Description: "Stop HVAC as the vehicle enters sleep.", + Category: "climate", Icon: "thermometer-snowflake", + Triggers: []json.RawMessage{triggerEvent("sleep_start")}, + Actions: []json.RawMessage{actionCommand("climate_off", nil)}, + Tags: []string{"climate", "sleep"}, + }) + r.register(Preset{ + ID: "climate_on_drive_start", Name: "Climate On at Drive Start", + Description: "Start climate automatically when a drive begins.", + Category: "climate", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("climate_on", nil)}, + Tags: []string{"climate", "drive"}, + }) + r.register(Preset{ + ID: "climate_on_charge_start", Name: "Climate On When Charging Starts", + Description: "Pre-condition while plugged in so it does not use pack energy on the road.", + Category: "climate", Icon: "thermometer", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("climate_on", nil)}, + Tags: []string{"climate", "charge"}, + }) + r.register(Preset{ + ID: "climate_set_21c_weekday", Name: "Set Cabin to 21°C Weekdays at 7 AM", + Description: "Weekday commute temperature target.", + Category: "climate", Icon: "thermometer", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_temps", map[string]any{ + "driver_temp": 21, "passenger_temp": 21, + })}, + Tags: []string{"climate", "temperature", "weekday"}, + }) + r.register(Preset{ + ID: "climate_set_20c_evening", Name: "Set Cabin to 20°C at 6 PM", + Description: "Evening cabin target for the drive home.", + Category: "climate", Icon: "thermometer", + Triggers: []json.RawMessage{triggerSchedule("0 18 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_temps", map[string]any{ + "driver_temp": 20, "passenger_temp": 20, + })}, + Tags: []string{"climate", "temperature"}, + }) + r.register(Preset{ + ID: "climate_precondition_max_commute", Name: "Max Pre-condition Weekdays at 6:30 AM", + Description: "Aggressive cabin heat/cool before the commute.", + Category: "climate", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSchedule("30 6 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("preconditioning_max", nil)}, + Tags: []string{"climate", "precondition"}, + }) + r.register(Preset{ + ID: "climate_reset_precondition_night", Name: "Reset Max Pre-condition at 9 PM", + Description: "Turn off max preconditioning in the evening.", + Category: "climate", Icon: "thermometer", + Triggers: []json.RawMessage{triggerSchedule("0 21 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("preconditioning_reset", nil)}, + Tags: []string{"climate", "precondition"}, + }) + r.register(Preset{ + ID: "climate_cop_on_midday", Name: "Cabin Overheat Protection at Noon", + Description: "Enable cabin overheat protection every day at noon.", + Category: "climate", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSchedule("0 12 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("cop_on", nil)}, + Tags: []string{"climate", "overheat"}, + }) + r.register(Preset{ + ID: "climate_cop_fan_afternoon", Name: "Cabin Fan-Only Protection at 2 PM", + Description: "Fan-only overheat protection for mild afternoons.", + Category: "climate", Icon: "thermometer", + Triggers: []json.RawMessage{triggerSchedule("0 14 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("cop_fan_only", nil)}, + Tags: []string{"climate", "overheat"}, + }) + r.register(Preset{ + ID: "climate_cop_off_evening", Name: "Disable Overheat Protection at 7 PM", + Description: "Turn cabin overheat protection off in the evening.", + Category: "climate", Icon: "thermometer-snowflake", + Triggers: []json.RawMessage{triggerSchedule("0 19 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("cop_off", nil)}, + Tags: []string{"climate", "overheat"}, + }) + r.register(Preset{ + ID: "climate_keeper_on_charge", Name: "Climate Keeper On When Charging", + Description: "Keep the cabin conditioned while plugged in.", + Category: "climate", Icon: "thermometer", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("climate_keeper_on", nil)}, + Tags: []string{"climate", "keeper"}, + }) + r.register(Preset{ + ID: "climate_keeper_off_drive_end", Name: "Climate Keeper Off After Drive", + Description: "Disable Climate Keeper when you finish driving.", + Category: "climate", Icon: "thermometer-snowflake", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("climate_keeper_off", nil)}, + Tags: []string{"climate", "keeper"}, + }) + r.register(Preset{ + ID: "climate_on_if_battery_ok", Name: "Climate On Drive Start If Battery ≥ 30%", + Description: "Start HVAC at drive start only when the pack is not critically low.", + Category: "climate", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Conditions: []json.RawMessage{conditionSignalNum("battery_level", ">=", 30)}, + Actions: []json.RawMessage{actionCommand("climate_on", nil)}, + Tags: []string{"climate", "battery"}, + }) + + // ---- Charging ----------------------------------------------------- + r.register(Preset{ + ID: "charge_stop_at_70", Name: "Stop Charging at 70%", + Description: "Daily-driver limit for long calendar life.", + Category: "charging", Icon: "battery", + Triggers: []json.RawMessage{triggerSignalNum("battery_level", ">=", 70)}, + Actions: []json.RawMessage{actionCommand("charge_stop", nil)}, + Tags: []string{"charging", "battery-health"}, + }) + r.register(Preset{ + ID: "charge_stop_at_50", Name: "Stop Charging at 50% (Storage)", + Description: "Storage SoC target when the car will sit unused.", + Category: "charging", Icon: "battery", + Triggers: []json.RawMessage{triggerSignalNum("battery_level", ">=", 50)}, + Actions: []json.RawMessage{actionCommand("charge_stop", nil)}, + Tags: []string{"charging", "storage"}, + }) + r.register(Preset{ + ID: "charge_limit_90_friday", Name: "Charge Limit 90% Friday Evening", + Description: "Raise the limit before a weekend trip.", + Category: "charging", Icon: "battery-charging", + Triggers: []json.RawMessage{triggerSchedule("0 18 * * 5", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_charge_limit", map[string]any{"percent": 90})}, + Tags: []string{"charging", "weekend"}, + }) + r.register(Preset{ + ID: "charge_limit_80_sunday", Name: "Charge Limit 80% Sunday Night", + Description: "Return to the weekday health limit after the weekend.", + Category: "charging", Icon: "battery", + Triggers: []json.RawMessage{triggerSchedule("0 21 * * 0", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_charge_limit", map[string]any{"percent": 80})}, + Tags: []string{"charging", "battery-health"}, + }) + r.register(Preset{ + ID: "charge_max_range_friday", Name: "Max Range Charge Friday 8 PM", + Description: "Switch to max-range charging before a long weekend drive.", + Category: "charging", Icon: "battery-charging", + Triggers: []json.RawMessage{triggerSchedule("0 20 * * 5", "UTC")}, + Actions: []json.RawMessage{actionCommand("charge_max_range", nil)}, + Tags: []string{"charging", "trip"}, + }) + r.register(Preset{ + ID: "charge_standard_monday", Name: "Standard Charge Monday 8 PM", + Description: "Return to standard charging after a trip weekend.", + Category: "charging", Icon: "battery", + Triggers: []json.RawMessage{triggerSchedule("0 20 * * 1", "UTC")}, + Actions: []json.RawMessage{actionCommand("charge_standard", nil)}, + Tags: []string{"charging", "battery-health"}, + }) + r.register(Preset{ + ID: "charge_open_port_evening", Name: "Open Charge Port at 10 PM", + Description: "Pop the charge port so you can plug in after parking.", + Category: "charging", Icon: "battery-charging", + Triggers: []json.RawMessage{triggerSchedule("0 22 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("open_charge_port", nil)}, + Tags: []string{"charging", "port"}, + }) + r.register(Preset{ + ID: "charge_close_port_on_end", Name: "Close Charge Port When Charging Ends", + Description: "Close the port door after a session completes.", + Category: "charging", Icon: "battery", + Triggers: []json.RawMessage{triggerEvent("charge_end")}, + Actions: []json.RawMessage{actionCommand("close_charge_port", nil)}, + Tags: []string{"charging", "port"}, + }) + r.register(Preset{ + ID: "charge_amps_32_on_start", Name: "Set Charging to 32A on Session Start", + Description: "Cap home charging at 32 amps when a session begins.", + Category: "charging", Icon: "gauge", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("set_charging_amps", map[string]any{"charging_amps": 32})}, + Tags: []string{"charging", "amperage"}, + }) + r.register(Preset{ + ID: "charge_amps_48_weekend", Name: "Set Charging to 48A Saturday Morning", + Description: "Faster weekend top-up when household load is lower.", + Category: "charging", Icon: "gauge", + Triggers: []json.RawMessage{triggerSchedule("0 8 * * 6", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_charging_amps", map[string]any{"charging_amps": 48})}, + Tags: []string{"charging", "weekend"}, + }) + r.register(Preset{ + ID: "charge_stop_offpeak_end", Name: "Stop Charging at 7 AM", + Description: "End charging when the off-peak window closes.", + Category: "charging", Icon: "clock", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("charge_stop", nil)}, + Tags: []string{"charging", "off-peak"}, + }) + r.register(Preset{ + ID: "charge_start_1am", Name: "Start Charging at 1 AM", + Description: "Begin charging in the deepest off-peak hour.", + Category: "charging", Icon: "clock", + Triggers: []json.RawMessage{triggerSchedule("0 1 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("charge_start", nil)}, + Tags: []string{"charging", "off-peak"}, + }) + r.register(Preset{ + ID: "charge_limit_100_trip", Name: "Charge Limit 100% Thursday 8 PM", + Description: "Full pack the night before a long Friday drive.", + Category: "charging", Icon: "battery-charging", + Triggers: []json.RawMessage{triggerSchedule("0 20 * * 4", "UTC")}, + Actions: []json.RawMessage{actionCommand("set_charge_limit", map[string]any{"percent": 100})}, + Tags: []string{"charging", "trip"}, + }) + + // ---- Home --------------------------------------------------------- + r.register(Preset{ + ID: "home_flash_on_drive_end", Name: "Flash Lights When Drive Ends", + Description: "A visual “arrived” cue in a dark driveway.", + Category: "home", Icon: "lightbulb", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("flash_lights", nil)}, + Tags: []string{"locate", "drive"}, + }) + r.register(Preset{ + ID: "home_homelink_drive_end", Name: "HomeLink When Drive Ends", + Description: "Trigger HomeLink (garage) as soon as a drive ends.", + Category: "home", Icon: "home", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("trigger_homelink", nil)}, + Tags: []string{"homelink", "garage"}, + }) + r.register(Preset{ + ID: "home_homelink_weekday_morning", Name: "HomeLink Weekdays at 7 AM", + Description: "Open the garage for the weekday commute.", + Category: "home", Icon: "home", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("trigger_homelink", nil)}, + Tags: []string{"homelink", "weekday"}, + }) + r.register(Preset{ + ID: "home_wake_commute", Name: "Wake Vehicle Weekdays at 6:45 AM", + Description: "Wake before the commute so commands and climate are ready.", + Category: "home", Icon: "alarm-clock", + Triggers: []json.RawMessage{triggerSchedule("45 6 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("wake_up", nil)}, + Tags: []string{"wake", "weekday"}, + }) + r.register(Preset{ + ID: "home_lock_drive_end_night", Name: "Lock After Drive at Night", + Description: "Lock when a drive ends between 9 PM and 6 AM.", + Category: "home", Icon: "lock", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Conditions: []json.RawMessage{conditionTimeWindow("21:00", "06:00", "UTC")}, + Actions: []json.RawMessage{actionCommand("lock", nil)}, + Tags: []string{"lock", "night"}, + }) + r.register(Preset{ + ID: "home_flash_on_charge_end", Name: "Flash Lights When Charging Completes", + Description: "See from the house when the session is done.", + Category: "home", Icon: "lightbulb", + Triggers: []json.RawMessage{triggerEvent("charge_end")}, + Actions: []json.RawMessage{actionCommand("flash_lights", nil)}, + Tags: []string{"charging", "locate"}, + }) + + // ---- Driving ------------------------------------------------------ + r.register(Preset{ + ID: "drive_close_windows_start", Name: "Close Windows on Drive Start", + Description: "Close windows automatically when you begin driving.", + Category: "driving", Icon: "car", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("close_windows", nil)}, + Tags: []string{"windows", "drive"}, + }) + r.register(Preset{ + ID: "drive_climate_and_seats", Name: "Climate + Driver Heat on Drive Start", + Description: "Start HVAC and driver seat heat together.", + Category: "driving", Icon: "car", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{ + actionCommand("climate_on", nil), + actionCommand("seat_heater", map[string]any{"seat": 0, "level": 2}), + }, + Tags: []string{"climate", "comfort", "drive"}, + }) + r.register(Preset{ + ID: "drive_passenger_heat", Name: "Passenger Seat Heat on Drive Start", + Description: "Heat the front passenger seat when a drive begins.", + Category: "driving", Icon: "user", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("seat_heater", map[string]any{"seat": 1, "level": 2})}, + Tags: []string{"comfort", "drive"}, + }) + r.register(Preset{ + ID: "drive_sunroof_close_start", Name: "Close Sunroof on Drive Start", + Description: "Close the sunroof when you start driving.", + Category: "driving", Icon: "car", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("sunroof_close", nil)}, + Tags: []string{"sunroof", "drive"}, + }) + r.register(Preset{ + ID: "drive_lock_start", Name: "Lock Doors on Drive Start", + Description: "Auto-lock as soon as you begin a drive.", + Category: "driving", Icon: "lock", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("lock", nil)}, + Tags: []string{"lock", "drive"}, + }) + r.register(Preset{ + ID: "drive_climate_off_end_night", Name: "Climate Off After Night Drives", + Description: "Turn HVAC off when a drive ends after 9 PM.", + Category: "driving", Icon: "thermometer-snowflake", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Conditions: []json.RawMessage{conditionTimeWindow("21:00", "06:00", "UTC")}, + Actions: []json.RawMessage{actionCommand("climate_off", nil)}, + Tags: []string{"climate", "night"}, + }) + + // ---- Comfort ------------------------------------------------------ + r.register(Preset{ + ID: "comfort_seat_cooler_drive", Name: "Cool Driver Seat on Drive Start", + Description: "Ventilated seat on when a drive begins.", + Category: "comfort", Icon: "user", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("seat_cooler", map[string]any{"seat": 0, "level": 2})}, + Tags: []string{"comfort", "summer"}, + }) + r.register(Preset{ + ID: "comfort_auto_seat_climate", Name: "Auto Seat Climate Weekdays at 7 AM", + Description: "Enable automatic seat climate before the commute.", + Category: "comfort", Icon: "sparkles", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("auto_seat_climate", map[string]any{"auto_seat_climate": true})}, + Tags: []string{"comfort", "weekday"}, + }) + r.register(Preset{ + ID: "comfort_auto_steering_heat", Name: "Auto Steering Heat Weekdays at 7 AM", + Description: "Automatic steering-wheel heat for cold commutes.", + Category: "comfort", Icon: "wheel", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{actionCommand("auto_steering_heat", map[string]any{"on": true})}, + Tags: []string{"comfort", "weekday"}, + }) + r.register(Preset{ + ID: "comfort_rear_seat_heat", Name: "Heat Rear Seats on Drive Start", + Description: "Warm both rear seats when a drive begins.", + Category: "comfort", Icon: "user", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{ + actionCommand("seat_heater", map[string]any{"seat": 2, "level": 2}), + actionCommand("seat_heater", map[string]any{"seat": 4, "level": 2}), + }, + Tags: []string{"comfort", "drive"}, + }) + r.register(Preset{ + ID: "comfort_steering_and_climate", Name: "Steering Heat + Climate Weekdays 7 AM", + Description: "Wheel heat and HVAC together for winter mornings.", + Category: "comfort", Icon: "wheel", + Triggers: []json.RawMessage{triggerSchedule("0 7 * * 1-5", "UTC")}, + Actions: []json.RawMessage{ + actionCommand("climate_on", nil), + actionCommand("steering_wheel_heat", map[string]any{"level": 3}), + }, + Tags: []string{"comfort", "winter"}, + }) + r.register(Preset{ + ID: "comfort_dog_mode_weekend", Name: "Dog Mode Saturdays at 9 AM", + Description: "Enable Dog Mode for weekend errands with a pet.", + Category: "comfort", Icon: "sparkles", + Triggers: []json.RawMessage{triggerSchedule("0 9 * * 6", "UTC")}, + Actions: []json.RawMessage{actionCommand("dog_mode", nil)}, + Tags: []string{"dog", "weekend"}, + }) + r.register(Preset{ + ID: "comfort_camp_mode_friday", Name: "Camp Mode Friday 8 PM", + Description: "Enable Camp Mode at the start of a weekend trip.", + Category: "comfort", Icon: "sparkles", + Triggers: []json.RawMessage{triggerSchedule("0 20 * * 5", "UTC")}, + Actions: []json.RawMessage{actionCommand("camp_mode", nil)}, + Tags: []string{"camp", "weekend"}, + }) + r.register(Preset{ + ID: "comfort_bioweapon_on_drive", Name: "Bioweapon Defense on Drive Start", + Description: "Maximum filtration when a drive begins.", + Category: "comfort", Icon: "sparkles", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("bioweapon_on", nil)}, + Tags: []string{"filtration", "drive"}, + }) + r.register(Preset{ + ID: "comfort_bioweapon_off_end", Name: "Bioweapon Defense Off After Drive", + Description: "Turn filtration off when the drive ends.", + Category: "comfort", Icon: "sparkles", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("bioweapon_off", nil)}, + Tags: []string{"filtration", "drive"}, + }) + + // ---- Maintenance -------------------------------------------------- + r.register(Preset{ + ID: "maint_wake_noon", Name: "Wake Vehicle Daily at Noon", + Description: "Midday wake so telemetry does not go stale.", + Category: "maintenance", Icon: "alarm-clock", + Triggers: []json.RawMessage{triggerSchedule("0 12 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("wake_up", nil)}, + Tags: []string{"telemetry", "schedule"}, + }) + r.register(Preset{ + ID: "maint_wake_evening", Name: "Wake Vehicle Daily at 6 PM", + Description: "Evening wake before the drive home.", + Category: "maintenance", Icon: "alarm-clock", + Triggers: []json.RawMessage{triggerSchedule("0 18 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("wake_up", nil)}, + Tags: []string{"telemetry", "schedule"}, + }) + r.register(Preset{ + ID: "maint_flash_charge_start", Name: "Flash Lights When Charging Starts", + Description: "Confirm from a distance that the session began.", + Category: "maintenance", Icon: "lightbulb", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("flash_lights", nil)}, + Tags: []string{"charging", "locate"}, + }) + r.register(Preset{ + ID: "maint_wake_on_offline", Name: "Wake When Vehicle Goes Offline", + Description: "Try to bring the car back online if it drops unexpectedly.", + Category: "maintenance", Icon: "alarm-clock", + Triggers: []json.RawMessage{triggerEvent("offline")}, + Actions: []json.RawMessage{actionCommand("wake_up", nil)}, + Tags: []string{"wake", "offline"}, + }) + r.register(Preset{ + ID: "maint_flash_online", Name: "Flash Lights When Coming Online", + Description: "Visual confirmation the vehicle woke successfully.", + Category: "maintenance", Icon: "lightbulb", + Triggers: []json.RawMessage{triggerEvent("online")}, + Actions: []json.RawMessage{actionCommand("flash_lights", nil)}, + Tags: []string{"locate", "wake"}, + }) + + // ---- Energy ------------------------------------------------------- + r.register(Preset{ + ID: "energy_amps_12_start", Name: "Cap Charging Amps to 12A", + Description: "Very conservative house-circuit limit on session start.", + Category: "energy", Icon: "gauge", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("set_charging_amps", map[string]any{"charging_amps": 12})}, + Tags: []string{"energy", "amperage"}, + }) + r.register(Preset{ + ID: "energy_amps_24_start", Name: "Cap Charging Amps to 24A", + Description: "Moderate home charging rate when a session starts.", + Category: "energy", Icon: "gauge", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("set_charging_amps", map[string]any{"charging_amps": 24})}, + Tags: []string{"energy", "amperage"}, + }) + r.register(Preset{ + ID: "energy_charge_start_22", Name: "Start Charging at 10 PM", + Description: "Begin charging at the start of many off-peak tariffs.", + Category: "energy", Icon: "zap", + Triggers: []json.RawMessage{triggerSchedule("0 22 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("charge_start", nil)}, + Tags: []string{"energy", "off-peak"}, + }) + r.register(Preset{ + ID: "energy_limit_85", Name: "Default Charge Limit to 85%", + Description: "Set 85% whenever charging starts.", + Category: "energy", Icon: "battery", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("set_charge_limit", map[string]any{"percent": 85})}, + Tags: []string{"energy", "battery-health"}, + }) + r.register(Preset{ + ID: "energy_stop_at_60", Name: "Stop Charging at 60%", + Description: "Lower daily target for cars that sit most of the week.", + Category: "energy", Icon: "battery", + Triggers: []json.RawMessage{triggerSignalNum("battery_level", ">=", 60)}, + Actions: []json.RawMessage{actionCommand("charge_stop", nil)}, + Tags: []string{"energy", "storage"}, + }) + r.register(Preset{ + ID: "energy_climate_off_low_battery", Name: "Climate Off When Battery < 15%", + Description: "Shed HVAC load if the pack is critically low.", + Category: "energy", Icon: "zap", + Triggers: []json.RawMessage{triggerSignalNum("battery_level", "<", 15)}, + Actions: []json.RawMessage{actionCommand("climate_off", nil)}, + Tags: []string{"energy", "climate"}, + }) + + // ---- Windows ------------------------------------------------------ + r.register(Preset{ + ID: "win_vent_early_morning", Name: "Vent Windows at 5 AM", + Description: "Dump overnight cabin heat before you leave.", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerSchedule("0 5 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("vent_windows", nil)}, + Tags: []string{"windows", "summer"}, + }) + r.register(Preset{ + ID: "win_close_evening", Name: "Close Windows at 9 PM", + Description: "Close windows every evening.", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerSchedule("0 21 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("close_windows", nil)}, + Tags: []string{"windows", "night"}, + }) + r.register(Preset{ + ID: "win_vent_after_drive", Name: "Vent Windows After Drive", + Description: "Crack the windows when a drive ends to cool the cabin.", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Actions: []json.RawMessage{actionCommand("vent_windows", nil)}, + Tags: []string{"windows", "drive"}, + }) + r.register(Preset{ + ID: "win_close_on_charge", Name: "Close Windows When Charging Starts", + Description: "Close windows as you plug in (rain / public lots).", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerEvent("charge_start")}, + Actions: []json.RawMessage{actionCommand("close_windows", nil)}, + Tags: []string{"windows", "charge"}, + }) + r.register(Preset{ + ID: "win_sunroof_vent_noon", Name: "Vent Sunroof at Noon", + Description: "Crack the sunroof at midday.", + Category: "windows", Icon: "sun", + Triggers: []json.RawMessage{triggerSchedule("0 12 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("sunroof_vent", nil)}, + Tags: []string{"sunroof"}, + }) + r.register(Preset{ + ID: "win_sunroof_close_evening", Name: "Close Sunroof at 6 PM", + Description: "Close the sunroof every evening.", + Category: "windows", Icon: "moon", + Triggers: []json.RawMessage{triggerSchedule("0 18 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("sunroof_close", nil)}, + Tags: []string{"sunroof", "night"}, + }) + r.register(Preset{ + ID: "win_close_on_offline", Name: "Close Windows When Vehicle Goes Offline", + Description: "Close windows if the car drops offline.", + Category: "windows", Icon: "x-square", + Triggers: []json.RawMessage{triggerEvent("offline")}, + Actions: []json.RawMessage{actionCommand("close_windows", nil)}, + Tags: []string{"windows", "offline"}, + }) + r.register(Preset{ + ID: "win_close_night_drive_end", Name: "Close Windows After Night Drives", + Description: "Close windows when a drive ends between 9 PM and 6 AM.", + Category: "windows", Icon: "moon", + Triggers: []json.RawMessage{triggerEvent("drive_end")}, + Conditions: []json.RawMessage{conditionTimeWindow("21:00", "06:00", "UTC")}, + Actions: []json.RawMessage{actionCommand("close_windows", nil)}, + Tags: []string{"windows", "night"}, + }) + + // ---- Media -------------------------------------------------------- + r.register(Preset{ + ID: "media_volume_down_drive", Name: "Lower Volume on Drive Start", + Description: "Drop media volume when a drive begins.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("media_volume_down", nil)}, + Tags: []string{"media", "drive"}, + }) + r.register(Preset{ + ID: "media_volume_down_night", Name: "Lower Volume Every Night at 10 PM", + Description: "Quiet the cabin if media was left loud.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerSchedule("0 22 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("media_volume_down", nil)}, + Tags: []string{"media", "night"}, + }) + r.register(Preset{ + ID: "media_next_track_online", Name: "Skip Track When Vehicle Wakes", + Description: "Advance to the next track on wake — useful after a parked playlist.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("online")}, + Actions: []json.RawMessage{actionCommand("media_next_track", nil)}, + Tags: []string{"media", "wake"}, + }) + r.register(Preset{ + ID: "media_toggle_drive_start", Name: "Toggle Playback on Drive Start", + Description: "Start or pause media as you begin driving.", + Category: "media", Icon: "volume", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("media_toggle_playback", nil)}, + Tags: []string{"media", "drive"}, + }) + + // ---- Safety ------------------------------------------------------- + r.register(Preset{ + ID: "safety_guest_off_drive", Name: "Disable Guest Mode on Drive Start", + Description: "Ensure Guest Mode is off when you start driving.", + Category: "safety", Icon: "shield-check", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("guest_mode_off", nil)}, + Tags: []string{"guest", "drive"}, + }) + r.register(Preset{ + ID: "safety_guest_off_night", Name: "Disable Guest Mode at 10 PM", + Description: "Turn Guest Mode off every night.", + Category: "safety", Icon: "shield-check", + Triggers: []json.RawMessage{triggerSchedule("0 22 * * *", "UTC")}, + Actions: []json.RawMessage{actionCommand("guest_mode_off", nil)}, + Tags: []string{"guest", "night"}, + }) + r.register(Preset{ + ID: "safety_speed_limit_off_drive", Name: "Speed Limit Mode Off on Drive Start", + Description: "Deactivate Speed Limit Mode when you begin a drive (PIN already stored).", + Category: "safety", Icon: "gauge", + Triggers: []json.RawMessage{triggerEvent("drive_start")}, + Actions: []json.RawMessage{actionCommand("speed_limit_off", nil)}, + Tags: []string{"speed-limit", "drive"}, + }) + r.register(Preset{ + ID: "safety_cop_on_hot_cabin", Name: "Overheat Protection If Cabin > 35°C", + Description: "Enable cabin overheat protection when inside temp is high.", + Category: "safety", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSignalNum("inside_temp", ">", 35)}, + Actions: []json.RawMessage{actionCommand("cop_on", nil)}, + Tags: []string{"overheat", "cabin"}, + }) + r.register(Preset{ + ID: "safety_climate_on_hot_cabin", Name: "Climate On If Cabin > 40°C", + Description: "Start HVAC if the cabin is dangerously hot.", + Category: "safety", Icon: "thermometer-sun", + Triggers: []json.RawMessage{triggerSignalNum("inside_temp", ">", 40)}, + Actions: []json.RawMessage{actionCommand("climate_on", nil)}, + Tags: []string{"overheat", "climate"}, + }) + r.register(Preset{ + ID: "safety_climate_on_freezing", Name: "Climate On If Cabin < 0°C", + Description: "Start HVAC if the cabin is below freezing.", + Category: "safety", Icon: "thermometer-snowflake", + Triggers: []json.RawMessage{triggerSignalNum("inside_temp", "<", 0)}, + Actions: []json.RawMessage{actionCommand("climate_on", nil)}, + Tags: []string{"cold", "climate"}, + }) +} diff --git a/internal/automation/presets/preset.go b/internal/automation/presets/preset.go index edd0300a36..72f6d339db 100644 --- a/internal/automation/presets/preset.go +++ b/internal/automation/presets/preset.go @@ -49,6 +49,9 @@ func NewRegistry() *Registry { {ID: "comfort", Name: "Comfort", Description: "Cabin comfort automation templates", Icon: "sparkles"}, {ID: "maintenance", Name: "Maintenance", Description: "Maintenance reminder automation templates", Icon: "wrench"}, {ID: "energy", Name: "Energy", Description: "Energy monitoring automation templates", Icon: "zap"}, + {ID: "windows", Name: "Windows", Description: "Window and sunroof automation templates", Icon: "x-square"}, + {ID: "media", Name: "Media", Description: "Cabin media automation templates", Icon: "volume"}, + {ID: "safety", Name: "Safety", Description: "Guest mode and cabin-protection templates", Icon: "shield-check"}, } { r.registerCategory(category) } diff --git a/internal/automation/presets/preset_test.go b/internal/automation/presets/preset_test.go index 6d8b60002d..7d9393fff9 100644 --- a/internal/automation/presets/preset_test.go +++ b/internal/automation/presets/preset_test.go @@ -6,6 +6,44 @@ import ( "testing" ) +func TestRegistry_StarterPresetsPreserved(t *testing.T) { + r := NewRegistry() + starters := []string{ + "sec_sentry_at_night", + "sec_sentry_off_morning", + "sec_lock_after_charge", + "climate_morning_precondition", + "climate_off_after_drive", + "climate_set_default_temp", + "charge_stop_at_80", + "charge_set_limit_80", + "charge_overnight_start", + "home_lock_on_sleep", + "home_close_windows_on_sleep", + "drive_sentry_off_on_start", + "drive_lock_after_drive", + "comfort_steering_heat_morning", + "comfort_seat_heat_on_drive", + "maint_daily_wake", + "maint_flash_on_online", + "energy_charge_at_off_peak", + "energy_stop_at_90", + "energy_low_battery_alert_action", + } + for _, id := range starters { + if r.Get(id) == nil { + t.Errorf("missing starter preset %q", id) + } + } +} + +func TestRegistry_ExtensiveCatalogue(t *testing.T) { + got := len(NewRegistry().Presets("")) + if got < 140 { + t.Fatalf("presets = %d, want at least 140", got) + } +} + // TestRegistry_AllCategoriesPopulated ensures every advertised category has at // least one preset so the gallery never renders an empty section. func TestRegistry_AllCategoriesPopulated(t *testing.T) { @@ -200,9 +238,25 @@ func knownTeslaCommand(name string) bool { "set_temps", "charge_start", "charge_stop", "set_charge_limit", "set_charging_amps", - "lock", "close_windows", - "steering_wheel_heat", "seat_heater", - "wake_up", "flash_lights": + "charge_max_range", "charge_standard", + "open_charge_port", "close_charge_port", + "lock", "unlock", "close_windows", "vent_windows", + "sunroof_close", "sunroof_vent", + "steering_wheel_heat", "seat_heater", "seat_cooler", + "auto_seat_climate", "auto_steering_heat", + "wake_up", "flash_lights", + "trigger_homelink", + "preconditioning_max", "preconditioning_reset", + "cop_on", "cop_off", "cop_fan_only", + "climate_keeper_on", "climate_keeper_off", + "dog_mode", "camp_mode", + "bioweapon_on", "bioweapon_off", + "media_volume_down", "media_next_track", "media_toggle_playback", + "media_prev_track", "media_next_fav", "media_prev_fav", + "guest_mode_off", "guest_mode_on", "speed_limit_off", + "honk_horn", "honk", "boombox_ping", + "steering_wheel_level", "set_cop_temp", "sunroof_stop", + "wake", "flash": return true } return false diff --git a/internal/database/charging/autopilot_repo.go b/internal/database/charging/autopilot_repo.go new file mode 100644 index 0000000000..a9520251a9 --- /dev/null +++ b/internal/database/charging/autopilot_repo.go @@ -0,0 +1,92 @@ +package charging + +import ( + "context" + "time" + + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// AutopilotProfile is the persisted per-vehicle Smart Charging Autopilot +// configuration. ReadyBy is a daily wall-clock "HH:MM" time; the preview +// engine resolves it to the next future occurrence. +type AutopilotProfile struct { + VehicleID int64 `json:"vehicle_id" db:"vehicle_id"` + Enabled bool `json:"enabled" db:"enabled"` + TargetSOC int `json:"target_soc" db:"target_soc"` + ReadyBy string `json:"ready_by" db:"ready_by"` + RatePlan string `json:"rate_plan" db:"rate_plan"` + DailyCapSOC int `json:"daily_cap_soc" db:"daily_cap_soc"` + TripOverride bool `json:"trip_override" db:"trip_override"` + Precondition bool `json:"precondition" db:"precondition"` + MaxAmps int `json:"max_amps" db:"max_amps"` + BatteryCapacityKWh float64 `json:"battery_capacity_kwh" db:"battery_capacity_kwh"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// AutopilotProfileRepo provides data access for charge_autopilot_profiles. +type AutopilotProfileRepo struct { + db *database.DB +} + +// NewAutopilotProfileRepo creates a new AutopilotProfileRepo. +func NewAutopilotProfileRepo(db *database.DB) *AutopilotProfileRepo { + return &AutopilotProfileRepo{db: db} +} + +// GetByVehicle returns the profile for a vehicle, or nil when none exists. +func (r *AutopilotProfileRepo) GetByVehicle(ctx context.Context, vehicleID int64) (*AutopilotProfile, error) { + p := &AutopilotProfile{} + query := ` + SELECT vehicle_id, enabled, target_soc, ready_by, rate_plan, + daily_cap_soc, trip_override, precondition, max_amps, + battery_capacity_kwh, updated_at + FROM charge_autopilot_profiles WHERE vehicle_id = $1` + err := r.db.Pool.QueryRow(ctx, query, vehicleID).Scan( + &p.VehicleID, &p.Enabled, &p.TargetSOC, &p.ReadyBy, &p.RatePlan, + &p.DailyCapSOC, &p.TripOverride, &p.Precondition, &p.MaxAmps, + &p.BatteryCapacityKWh, &p.UpdatedAt, + ) + if err != nil { + return nil, err + } + return p, nil +} + +// Upsert creates or replaces the profile for a vehicle. +func (r *AutopilotProfileRepo) Upsert(ctx context.Context, p *AutopilotProfile) error { + query := ` + INSERT INTO charge_autopilot_profiles ( + vehicle_id, enabled, target_soc, ready_by, rate_plan, + daily_cap_soc, trip_override, precondition, max_amps, + battery_capacity_kwh, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) + ON CONFLICT (vehicle_id) DO UPDATE SET + enabled = EXCLUDED.enabled, + target_soc = EXCLUDED.target_soc, + ready_by = EXCLUDED.ready_by, + rate_plan = EXCLUDED.rate_plan, + daily_cap_soc = EXCLUDED.daily_cap_soc, + trip_override = EXCLUDED.trip_override, + precondition = EXCLUDED.precondition, + max_amps = EXCLUDED.max_amps, + battery_capacity_kwh = EXCLUDED.battery_capacity_kwh, + updated_at = NOW() + RETURNING updated_at` + return r.db.Pool.QueryRow(ctx, query, + p.VehicleID, p.Enabled, p.TargetSOC, p.ReadyBy, p.RatePlan, + p.DailyCapSOC, p.TripOverride, p.Precondition, p.MaxAmps, + p.BatteryCapacityKWh, + ).Scan(&p.UpdatedAt) +} + +// SumAppliedSavings totals the savings recorded on applied/completed charge +// plans for a vehicle — the Autopilot savings ledger. +func (r *AutopilotProfileRepo) SumAppliedSavings(ctx context.Context, vehicleID int64) (total float64, runs int64, err error) { + query := ` + SELECT COALESCE(SUM(savings), 0), COUNT(*) + FROM charge_plans + WHERE vehicle_id = $1 AND status IN ('applied', 'completed')` + err = r.db.Pool.QueryRow(ctx, query, vehicleID).Scan(&total, &runs) + return total, runs, err +} diff --git a/internal/database/charging/repo.go b/internal/database/charging/repo.go index dde0597853..09f201e65c 100644 --- a/internal/database/charging/repo.go +++ b/internal/database/charging/repo.go @@ -114,6 +114,35 @@ func (r *ChargingRepo) GetByVehicle(ctx context.Context, vehicleID int64, limit, return sessions, nil } +// MeasuredDCTotals is the lifetime measured (pack-side) aggregate over +// completed DC sessions for one vehicle. +type MeasuredDCTotals struct { + Sessions int + EnergyWh float64 + Cost float64 +} + +// SumMeasuredDC totals measured energy and cost over completed DC +// (DC/Supercharger) sessions. Scoped to DC so the result reconciles +// against Tesla cabinet-side invoices, which only exist for DC charging. +func (r *ChargingRepo) SumMeasuredDC(ctx context.Context, vehicleID int64) (MeasuredDCTotals, error) { + var t MeasuredDCTotals + err := r.db.Pool.QueryRow(ctx, ` + SELECT COUNT(*), + COALESCE(SUM(total_energy_added_wh), 0), + COALESCE(SUM(cost_decimal), 0) + FROM charging_sessions + WHERE vehicle_id = $1 + AND ended_at IS NOT NULL + AND charger_type IN ('DC', 'Supercharger') + AND total_energy_added_wh > 0`, vehicleID, + ).Scan(&t.Sessions, &t.EnergyWh, &t.Cost) + if err != nil { + return MeasuredDCTotals{}, err + } + return t, nil +} + func (r *ChargingRepo) GetByID(ctx context.Context, id int64) (*chargingmodel.ChargingSession, error) { query := `SELECT ` + chargingColumns + ` FROM charging_sessions WHERE id=$1` c, err := scanChargingSession(r.db.Pool.QueryRow(ctx, query, id)) diff --git a/internal/database/fleetops/repository.go b/internal/database/fleetops/repository.go index 10fb464519..e1b8415b6c 100644 --- a/internal/database/fleetops/repository.go +++ b/internal/database/fleetops/repository.go @@ -150,12 +150,13 @@ func advisoryLocks(ctx context.Context, tx pgx.Tx, keys ...string) error { func vehicleLockKey(id int64) string { return fmt.Sprintf("fleetops:vehicle:%d", id) } func driverLockKey(id int64) string { return fmt.Sprintf("fleetops:driver:%d", id) } -const driverColumns = `id, display_name, reference_code, status, version, created_at, updated_at` +const driverColumns = `id, display_name, reference_code, status, max_charge_soc, curfew_start, curfew_end, version, created_at, updated_at` func scanDriver(row pgx.Row) (*models.FleetDriver, error) { item := &models.FleetDriver{} err := row.Scan( &item.ID, &item.DisplayName, &item.ReferenceCode, &item.Status, + &item.MaxChargeSOC, &item.CurfewStart, &item.CurfewEnd, &item.Version, &item.CreatedAt, &item.UpdatedAt, ) return item, err @@ -214,10 +215,11 @@ func (r *Repository) GetDriver(ctx context.Context, id int64) (*models.FleetDriv func (r *Repository) CreateDriver(ctx context.Context, item *models.FleetDriver) error { got, err := scanDriver(r.db.Pool.QueryRow(ctx, ` - INSERT INTO fleet_drivers (display_name, reference_code, status) - VALUES ($1, $2, $3) + INSERT INTO fleet_drivers (display_name, reference_code, status, max_charge_soc, curfew_start, curfew_end) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING `+driverColumns, item.DisplayName, item.ReferenceCode, item.Status, + item.MaxChargeSOC, item.CurfewStart, item.CurfewEnd, )) if err != nil { return fmt.Errorf("create fleet driver: %w", classifyPGError(err)) @@ -229,10 +231,13 @@ func (r *Repository) CreateDriver(ctx context.Context, item *models.FleetDriver) func (r *Repository) UpdateDriver(ctx context.Context, item *models.FleetDriver) error { got, err := scanDriver(r.db.Pool.QueryRow(ctx, ` UPDATE fleet_drivers - SET display_name = $2, reference_code = $3, status = $4, version = version + 1 - WHERE id = $1 AND version = $5 + SET display_name = $2, reference_code = $3, status = $4, + max_charge_soc = $5, curfew_start = $6, curfew_end = $7, + version = version + 1 + WHERE id = $1 AND version = $8 RETURNING `+driverColumns, - item.ID, item.DisplayName, item.ReferenceCode, item.Status, item.Version, + item.ID, item.DisplayName, item.ReferenceCode, item.Status, + item.MaxChargeSOC, item.CurfewStart, item.CurfewEnd, item.Version, )) if errors.Is(err, pgx.ErrNoRows) { return classifyMutationMiss(ctx, r.db.Pool, diff --git a/internal/database/ocpp/queries.go b/internal/database/ocpp/queries.go new file mode 100644 index 0000000000..e33253d453 --- /dev/null +++ b/internal/database/ocpp/queries.go @@ -0,0 +1,145 @@ +package ocpp + +import ( + "context" + "fmt" + "time" +) + +// ChargePoint is one known charger with its latest connector statuses. +type ChargePoint struct { + ID string `json:"id"` + Vendor string `json:"vendor"` + Model string `json:"model"` + SerialNumber string `json:"serial_number"` + FirmwareVersion string `json:"firmware_version"` + LastBootAt *time.Time `json:"last_boot_at"` + LastSeenAt time.Time `json:"last_seen_at"` + Connectors []ConnectorStatus `json:"connectors"` + ActiveSessions int `json:"active_sessions"` +} + +// ConnectorStatus is the latest status of one connector. +type ConnectorStatus struct { + ConnectorID int `json:"connector_id"` + Status string `json:"status"` + ErrorCode string `json:"error_code"` + Info string `json:"info"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SessionView is one charging transaction for operator views. +type SessionView struct { + TransactionID int `json:"transaction_id"` + ChargePointID string `json:"charge_point_id"` + ConnectorID int `json:"connector_id"` + StartedAt time.Time `json:"started_at"` + StartMeterWh int `json:"start_meter_wh"` + EndedAt *time.Time `json:"ended_at"` + EndMeterWh *int `json:"end_meter_wh"` + StopReason string `json:"stop_reason"` + EnergyDeliveredWh *int `json:"energy_delivered_wh"` +} + +// ListChargePoints returns every known charger, most recently seen +// first, each with its connector statuses and open-session count. +func (s *Store) ListChargePoints(ctx context.Context) ([]ChargePoint, error) { + const query = ` + SELECT charge_point_id, vendor, model, serial_number, firmware_version, + last_boot_at, last_seen_at, + (SELECT count(*) FROM ocpp_sessions os + WHERE os.charge_point_id = ocp.charge_point_id AND os.ended_at IS NULL) + FROM ocpp_charge_points ocp + ORDER BY last_seen_at DESC` + rows, err := s.db.Pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("ocpp: list charge points: %w", err) + } + defer rows.Close() + + var out []ChargePoint + for rows.Next() { + var cp ChargePoint + if err := rows.Scan(&cp.ID, &cp.Vendor, &cp.Model, &cp.SerialNumber, + &cp.FirmwareVersion, &cp.LastBootAt, &cp.LastSeenAt, &cp.ActiveSessions); err != nil { + return nil, fmt.Errorf("ocpp: scan charge point: %w", err) + } + out = append(out, cp) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("ocpp: list charge points: %w", err) + } + if len(out) == 0 { + return []ChargePoint{}, nil + } + + const statusQuery = ` + SELECT charge_point_id, connector_id, status, error_code, info, updated_at + FROM ocpp_connector_status + ORDER BY charge_point_id, connector_id` + statusRows, err := s.db.Pool.Query(ctx, statusQuery) + if err != nil { + return nil, fmt.Errorf("ocpp: list connector status: %w", err) + } + defer statusRows.Close() + + byCP := make(map[string][]ConnectorStatus, len(out)) + for statusRows.Next() { + var cpID string + var cs ConnectorStatus + if err := statusRows.Scan(&cpID, &cs.ConnectorID, &cs.Status, &cs.ErrorCode, &cs.Info, &cs.UpdatedAt); err != nil { + return nil, fmt.Errorf("ocpp: scan connector status: %w", err) + } + byCP[cpID] = append(byCP[cpID], cs) + } + if err := statusRows.Err(); err != nil { + return nil, fmt.Errorf("ocpp: list connector status: %w", err) + } + for i := range out { + out[i].Connectors = byCP[out[i].ID] + if out[i].Connectors == nil { + out[i].Connectors = []ConnectorStatus{} + } + } + return out, nil +} + +// ListSessions returns recent transactions, newest first. An empty +// chargePointID lists across all chargers. Limit is clamped to 1..200. +func (s *Store) ListSessions(ctx context.Context, chargePointID string, limit int) ([]SessionView, error) { + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + const query = ` + SELECT transaction_id, charge_point_id, connector_id, started_at, + start_meter_wh, ended_at, end_meter_wh, stop_reason, + CASE WHEN end_meter_wh IS NOT NULL AND end_meter_wh >= start_meter_wh + THEN end_meter_wh - start_meter_wh END + FROM ocpp_sessions + WHERE ($1 = '' OR charge_point_id = $1) + ORDER BY started_at DESC + LIMIT $2` + rows, err := s.db.Pool.Query(ctx, query, chargePointID, limit) + if err != nil { + return nil, fmt.Errorf("ocpp: list sessions: %w", err) + } + defer rows.Close() + + out := []SessionView{} + for rows.Next() { + var v SessionView + if err := rows.Scan(&v.TransactionID, &v.ChargePointID, &v.ConnectorID, + &v.StartedAt, &v.StartMeterWh, &v.EndedAt, &v.EndMeterWh, + &v.StopReason, &v.EnergyDeliveredWh); err != nil { + return nil, fmt.Errorf("ocpp: scan session: %w", err) + } + out = append(out, v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("ocpp: list sessions: %w", err) + } + return out, nil +} diff --git a/internal/database/ocpp/store.go b/internal/database/ocpp/store.go new file mode 100644 index 0000000000..f11d87ac96 --- /dev/null +++ b/internal/database/ocpp/store.go @@ -0,0 +1,217 @@ +// Package ocpp persists OCPP-J 1.6 CSMS state recorded by cmd/ocpp-server +// and reads it back for the main API. Store implements the +// internal/ocpp.SessionStore port so the dispatcher needs no changes; +// the List methods serve the operator-facing charge-point views. +package ocpp + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/jackc/pgx/v5" + "github.com/rs/zerolog/log" + + "github.com/ev-dev-labs/teslasync/internal/database" + proto "github.com/ev-dev-labs/teslasync/internal/ocpp" +) + +// Store is the Postgres-backed ocpp.SessionStore. All methods are safe +// for concurrent use (pgx pool); callers must still treat transaction +// IDs as dispatcher-global. +type Store struct { + db *database.DB +} + +// NewStore wires the store. Panics on nil db (fail-fast wiring). +func NewStore(db *database.DB) *Store { + if db == nil { + panic("database/ocpp: nil db") + } + return &Store{db: db} +} + +var _ proto.SessionStore = (*Store)(nil) + +// StartSession records a new charging transaction, upserting the charge +// point row first so the FK always resolves (a charger may transact +// before its BootNotification is processed). +func (s *Store) StartSession(ctx context.Context, sess proto.Session) error { + if err := s.upsertChargePoint(ctx, sess.ChargePointID, "", "", "", ""); err != nil { + return err + } + const query = ` + INSERT INTO ocpp_sessions ( + transaction_id, charge_point_id, connector_id, id_tag, + started_at, start_meter_wh + ) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (transaction_id) DO NOTHING` + _, err := s.db.Pool.Exec(ctx, query, + sess.TransactionID, clamp(sess.ChargePointID, 128), sess.ConnectorID, clamp(sess.IDTag, 64), + sess.StartedAt, sess.StartMeterWh, + ) + if err != nil { + return fmt.Errorf("ocpp: start session: %w", err) + } + return nil +} + +// StopSession closes a transaction. An unknown transaction mirrors the +// memory store: an error naming the ID, so the dispatcher logs it. +func (s *Store) StopSession(ctx context.Context, transactionID int, endedAt time.Time, endMeterWh int, reason string) error { + const query = ` + UPDATE ocpp_sessions + SET ended_at = $2, end_meter_wh = $3, stop_reason = $4 + WHERE transaction_id = $1 AND ended_at IS NULL` + tag, err := s.db.Pool.Exec(ctx, query, transactionID, endedAt, endMeterWh, clamp(reason, 64)) + if err != nil { + return fmt.Errorf("ocpp: stop session: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("unknown transaction %d", transactionID) + } + return nil +} + +// RecordMeterValues appends numeric samples to an open transaction. Like +// the memory store, samples for an unknown transaction are logged and +// dropped (a charger bug per the OCPP spec) rather than failing the +// response; non-numeric sample values are skipped the same way. +func (s *Store) RecordMeterValues(ctx context.Context, transactionID int, mv proto.MeterValuesReq) error { + var sessionID int64 + err := s.db.Pool.QueryRow(ctx, + `SELECT id FROM ocpp_sessions WHERE transaction_id = $1`, transactionID, + ).Scan(&sessionID) + if err != nil { + if err == pgx.ErrNoRows { + log.Warn().Int("transaction_id", transactionID).Msg("MeterValues for unknown transaction") + return nil + } + return fmt.Errorf("ocpp: resolve session: %w", err) + } + + const query = ` + INSERT INTO ocpp_meter_values ( + session_id, connector_id, sampled_at, measurand, value, unit + ) VALUES ($1, $2, $3, $4, $5, $6)` + batch := &pgx.Batch{} + count := 0 + for _, m := range mv.MeterValue { + sampledAt := parseOCPPTime(m.Timestamp) + for _, sv := range m.SampledValue { + v, err := strconv.ParseFloat(sv.Value, 64) + if err != nil { + log.Warn(). + Int("transaction_id", transactionID). + Str("value", sv.Value). + Msg("dropping non-numeric meter sample") + continue + } + measurand := sv.Measurand + if measurand == "" { + measurand = "Energy.Active.Import.Register" + } + batch.Queue(query, sessionID, mv.ConnectorID, sampledAt, clamp(measurand, 64), v, clamp(sv.Unit, 16)) + count++ + } + } + if count == 0 { + return nil + } + if err := s.db.Pool.SendBatch(ctx, batch).Close(); err != nil { + return fmt.Errorf("ocpp: insert meter values: %w", err) + } + return nil +} + +// RecordStatus upserts the latest connector status for a charge point. +func (s *Store) RecordStatus(ctx context.Context, chargePointID string, st proto.StatusNotificationReq) error { + if err := s.upsertChargePoint(ctx, chargePointID, "", "", "", ""); err != nil { + return err + } + const query = ` + INSERT INTO ocpp_connector_status ( + charge_point_id, connector_id, status, error_code, info, updated_at + ) VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (charge_point_id, connector_id) DO UPDATE SET + status = EXCLUDED.status, + error_code = EXCLUDED.error_code, + info = EXCLUDED.info, + updated_at = now()` + _, err := s.db.Pool.Exec(ctx, query, + clamp(chargePointID, 128), st.ConnectorID, clamp(st.Status, 32), clamp(st.ErrorCode, 64), clamp(st.Info, 500), + ) + if err != nil { + return fmt.Errorf("ocpp: record status: %w", err) + } + return s.touchSeen(ctx, chargePointID) +} + +// RecordBoot upserts the charge point identity from a BootNotification. +func (s *Store) RecordBoot(ctx context.Context, chargePointID string, b proto.BootNotificationReq) error { + const query = ` + INSERT INTO ocpp_charge_points ( + charge_point_id, vendor, model, serial_number, firmware_version, + last_boot_at, last_seen_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, now(), now(), now()) + ON CONFLICT (charge_point_id) DO UPDATE SET + vendor = EXCLUDED.vendor, + model = EXCLUDED.model, + serial_number = EXCLUDED.serial_number, + firmware_version = EXCLUDED.firmware_version, + last_boot_at = now(), + last_seen_at = now(), + updated_at = now()` + _, err := s.db.Pool.Exec(ctx, query, + clamp(chargePointID, 128), clamp(b.ChargePointVendor, 128), clamp(b.ChargePointModel, 128), + clamp(b.ChargePointSerialNumber, 128), clamp(b.FirmwareVersion, 128), + ) + if err != nil { + return fmt.Errorf("ocpp: record boot: %w", err) + } + return nil +} + +func (s *Store) upsertChargePoint(ctx context.Context, id, vendor, model, serial, firmware string) error { + const query = ` + INSERT INTO ocpp_charge_points ( + charge_point_id, vendor, model, serial_number, firmware_version, + last_seen_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, now(), now()) + ON CONFLICT (charge_point_id) DO UPDATE SET + last_seen_at = now(), updated_at = now()` + _, err := s.db.Pool.Exec(ctx, query, + clamp(id, 128), clamp(vendor, 128), clamp(model, 128), clamp(serial, 128), clamp(firmware, 128)) + if err != nil { + return fmt.Errorf("ocpp: upsert charge point: %w", err) + } + return nil +} + +func (s *Store) touchSeen(ctx context.Context, id string) error { + _, err := s.db.Pool.Exec(ctx, + `UPDATE ocpp_charge_points SET last_seen_at = now(), updated_at = now() WHERE charge_point_id = $1`, clamp(id, 128)) + return err +} + +// clamp truncates free-text charger input to the column bound so one +// oversized string fails slow truncation instead of a CHECK violation. +func clamp(s string, n int) string { + if len(s) > n { + return s[:n] + } + return s +} + +// parseOCPPTime parses an OCPP 1.6 timestamp (RFC 3339). Unparseable or +// empty values fall back to now so one bad sample never fails a batch. +func parseOCPPTime(v string) time.Time { + if v == "" { + return time.Now().UTC() + } + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t.UTC() + } + return time.Now().UTC() +} diff --git a/internal/database/ocpp/store_test.go b/internal/database/ocpp/store_test.go new file mode 100644 index 0000000000..fd181d73cb --- /dev/null +++ b/internal/database/ocpp/store_test.go @@ -0,0 +1,45 @@ +package ocpp + +import ( + "strings" + "testing" + "time" +) + +func TestClamp(t *testing.T) { + if got := clamp("abc", 8); got != "abc" { + t.Fatalf("clamp short = %q, want abc", got) + } + if got := clamp("abcdef", 6); got != "abcdef" { + t.Fatalf("clamp exact = %q, want abcdef", got) + } + if got := clamp("abcdefg", 6); got != "abcdef" { + t.Fatalf("clamp long = %q, want abcdef", got) + } + if got := clamp(strings.Repeat("x", 200), 128); len(got) != 128 { + t.Fatalf("clamp len = %d, want 128", len(got)) + } +} + +func TestParseOCPPTime(t *testing.T) { + want := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + if got := parseOCPPTime("2026-03-01T12:00:00Z"); !got.Equal(want) { + t.Fatalf("parse valid = %v, want %v", got, want) + } + before := time.Now().UTC() + for _, raw := range []string{"", "not-a-time", "2026-13-99T99:99:99Z"} { + got := parseOCPPTime(raw) + if got.Before(before) || time.Since(got) > time.Minute { + t.Fatalf("parse %q = %v, want ~now", raw, got) + } + } +} + +func TestNewStorePanicsOnNil(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic on nil db") + } + }() + NewStore(nil) +} diff --git a/internal/database/sharing/token_repo.go b/internal/database/sharing/token_repo.go index 7207eecb60..972914b7ac 100644 --- a/internal/database/sharing/token_repo.go +++ b/internal/database/sharing/token_repo.go @@ -3,6 +3,7 @@ package sharing import ( "context" "crypto/rand" + "database/sql" "encoding/hex" "errors" "fmt" @@ -51,12 +52,12 @@ func NewTokenRepo(db *database.DB) *TokenRepo { // tests can assert column names, filters, and RETURNING clauses without a live // database — a mistyped column would otherwise only surface at runtime. const insertTokenSQL = ` - INSERT INTO share_tokens (token, drive_id, created_by, title, description, + INSERT INTO share_tokens (token, drive_id, charging_session_id, created_by, title, description, include_map, include_telemetry, include_speed, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at` -const selectTokenColumns = `id, token, drive_id, created_by, title, description, +const selectTokenColumns = `id, token, drive_id, charging_session_id, created_by, title, description, include_map, include_telemetry, include_speed, views, expires_at, created_at` const getByTokenSQL = ` @@ -68,6 +69,11 @@ const listByDriveSQL = ` FROM share_tokens WHERE drive_id = $1 ORDER BY created_at DESC` +const listByChargingSessionSQL = ` + SELECT ` + selectTokenColumns + ` + FROM share_tokens WHERE charging_session_id = $1 + ORDER BY created_at DESC` + const incrementViewsSQL = `UPDATE share_tokens SET views = views + 1 WHERE id = $1` const deleteTokenSQL = `DELETE FROM share_tokens WHERE token = $1` @@ -83,14 +89,49 @@ func generateToken() (string, error) { return hex.EncodeToString(b), nil } -// Create inserts a new share token for a drive, generating a unique token and -// populating st.Token/st.ID/st.CreatedAt in place. +// scanShareToken scans one share_tokens row into st. The target IDs are +// nullable in the schema (exactly one is set per the CHECK); NULL scans +// to 0, matching the model's "0 = none" convention. +func scanShareToken(scan func(dest ...any) error, st *drivemodel.ShareToken) error { + var driveID, sessionID sql.NullInt64 + err := scan( + &st.ID, &st.Token, &driveID, &sessionID, &st.CreatedBy, &st.Title, &st.Description, + &st.IncludeMap, &st.IncludeTelemetry, &st.IncludeSpeed, &st.Views, + &st.ExpiresAt, &st.CreatedAt, + ) + if err != nil { + return err + } + st.DriveID = driveID.Int64 + st.ChargingSessionID = sessionID.Int64 + return nil +} + +// nullTargetID converts a model target ID to its bind value: NULL when +// unset so the exactly-one-target CHECK sees the real shape. +func nullTargetID(id int64) any { + if id <= 0 { + return nil + } + return id +} + +// Create inserts a new share token for a drive or a charging session, +// generating a unique token and populating st.Token/st.ID/st.CreatedAt +// in place. Exactly one target must be set. func (r *TokenRepo) Create(ctx context.Context, st *drivemodel.ShareToken) error { if st == nil { return fmt.Errorf("create share token: nil token") } - if st.DriveID <= 0 { - return fmt.Errorf("create share token: invalid drive id %d", st.DriveID) + targets := 0 + if st.DriveID > 0 { + targets++ + } + if st.ChargingSessionID > 0 { + targets++ + } + if targets != 1 { + return fmt.Errorf("create share token: exactly one of drive_id, charging_session_id must be set") } token, err := generateToken() @@ -100,7 +141,8 @@ func (r *TokenRepo) Create(ctx context.Context, st *drivemodel.ShareToken) error st.Token = token if err := r.pool.QueryRow(ctx, insertTokenSQL, - st.Token, st.DriveID, st.CreatedBy, st.Title, st.Description, + st.Token, nullTargetID(st.DriveID), nullTargetID(st.ChargingSessionID), + st.CreatedBy, st.Title, st.Description, st.IncludeMap, st.IncludeTelemetry, st.IncludeSpeed, st.ExpiresAt, ).Scan(&st.ID, &st.CreatedAt); err != nil { return fmt.Errorf("create share token: %w", err) @@ -117,11 +159,7 @@ func (r *TokenRepo) GetByToken(ctx context.Context, token string) (*drivemodel.S } st := &drivemodel.ShareToken{} - err := r.pool.QueryRow(ctx, getByTokenSQL, token).Scan( - &st.ID, &st.Token, &st.DriveID, &st.CreatedBy, &st.Title, &st.Description, - &st.IncludeMap, &st.IncludeTelemetry, &st.IncludeSpeed, &st.Views, - &st.ExpiresAt, &st.CreatedAt, - ) + err := scanShareToken(r.pool.QueryRow(ctx, getByTokenSQL, token).Scan, st) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } @@ -142,11 +180,30 @@ func (r *TokenRepo) ListByDrive(ctx context.Context, driveID int64) ([]*drivemod var tokens []*drivemodel.ShareToken for rows.Next() { st := &drivemodel.ShareToken{} - if err := rows.Scan( - &st.ID, &st.Token, &st.DriveID, &st.CreatedBy, &st.Title, &st.Description, - &st.IncludeMap, &st.IncludeTelemetry, &st.IncludeSpeed, &st.Views, - &st.ExpiresAt, &st.CreatedAt, - ); err != nil { + if err := scanShareToken(rows.Scan, st); err != nil { + return nil, fmt.Errorf("scan share token: %w", err) + } + tokens = append(tokens, st) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list share tokens: rows iteration: %w", err) + } + return tokens, nil +} + +// ListByChargingSession returns all share tokens for a charging session, +// newest first. +func (r *TokenRepo) ListByChargingSession(ctx context.Context, sessionID int64) ([]*drivemodel.ShareToken, error) { + rows, err := r.pool.Query(ctx, listByChargingSessionSQL, sessionID) + if err != nil { + return nil, fmt.Errorf("list share tokens: %w", err) + } + defer rows.Close() + + var tokens []*drivemodel.ShareToken + for rows.Next() { + st := &drivemodel.ShareToken{} + if err := scanShareToken(rows.Scan, st); err != nil { return nil, fmt.Errorf("scan share token: %w", err) } tokens = append(tokens, st) diff --git a/internal/database/sharing/token_repo_test.go b/internal/database/sharing/token_repo_test.go index c408f82920..fe05e654b2 100644 --- a/internal/database/sharing/token_repo_test.go +++ b/internal/database/sharing/token_repo_test.go @@ -2,6 +2,7 @@ package sharing import ( "context" + "database/sql" "encoding/hex" "errors" "fmt" @@ -120,25 +121,33 @@ func setDest[T any](dest any, v T) error { return nil } -// fillShareToken populates the 12 scan destinations produced by getByTokenSQL / -// listByDriveSQL from src, in the exact column order the repo scans. +// fillShareToken populates the 13 scan destinations produced by getByTokenSQL / +// listByDriveSQL / listByChargingSessionSQL from src, in the exact column +// order the repo scans. The target IDs are nullable in the schema, so the +// fake produces sql.NullInt64 (invalid when the model holds 0) exactly as +// pgx would for a NULL column. func fillShareToken(dest []any, src drivemodel.ShareToken) error { - if len(dest) != 12 { - return fmt.Errorf("share token scan: got %d dest, want 12", len(dest)) + if len(dest) != 13 { + return fmt.Errorf("share token scan: got %d dest, want 13", len(dest)) } steps := []func() error{ func() error { return setDest(dest[0], src.ID) }, func() error { return setDest(dest[1], src.Token) }, - func() error { return setDest(dest[2], src.DriveID) }, - func() error { return setDest(dest[3], src.CreatedBy) }, - func() error { return setDest(dest[4], src.Title) }, - func() error { return setDest(dest[5], src.Description) }, - func() error { return setDest(dest[6], src.IncludeMap) }, - func() error { return setDest(dest[7], src.IncludeTelemetry) }, - func() error { return setDest(dest[8], src.IncludeSpeed) }, - func() error { return setDest(dest[9], src.Views) }, - func() error { return setDest(dest[10], src.ExpiresAt) }, - func() error { return setDest(dest[11], src.CreatedAt) }, + func() error { + return setDest(dest[2], sql.NullInt64{Int64: src.DriveID, Valid: src.DriveID != 0}) + }, + func() error { + return setDest(dest[3], sql.NullInt64{Int64: src.ChargingSessionID, Valid: src.ChargingSessionID != 0}) + }, + func() error { return setDest(dest[4], src.CreatedBy) }, + func() error { return setDest(dest[5], src.Title) }, + func() error { return setDest(dest[6], src.Description) }, + func() error { return setDest(dest[7], src.IncludeMap) }, + func() error { return setDest(dest[8], src.IncludeTelemetry) }, + func() error { return setDest(dest[9], src.IncludeSpeed) }, + func() error { return setDest(dest[10], src.Views) }, + func() error { return setDest(dest[11], src.ExpiresAt) }, + func() error { return setDest(dest[12], src.CreatedAt) }, } for i, step := range steps { if err := step(); err != nil { @@ -185,7 +194,7 @@ func TestGenerateToken(t *testing.T) { // ── SQL-shape pinning ──────────────────────────────────────────────────────── var shareTokenColumns = []string{ - "id", "token", "drive_id", "created_by", "title", "description", + "id", "token", "drive_id", "charging_session_id", "created_by", "title", "description", "include_map", "include_telemetry", "include_speed", "views", "expires_at", "created_at", } @@ -194,9 +203,9 @@ func TestInsertTokenSQL_Shape(t *testing.T) { t.Parallel() mustContain := []string{ "INSERT INTO share_tokens", - "token, drive_id, created_by, title, description", + "token, drive_id, charging_session_id, created_by, title, description", "include_map, include_telemetry, include_speed, expires_at", - "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", "RETURNING id, created_at", } for _, frag := range mustContain { @@ -208,14 +217,15 @@ func TestInsertTokenSQL_Shape(t *testing.T) { func TestSelectSQL_ProjectAllColumns(t *testing.T) { t.Parallel() - // getByTokenSQL and listByDriveSQL must share the same projection so - // scanShareToken (12 dests) stays valid for both paths. + // All three SELECTs must share the same projection so scanShareToken + // (13 dests) stays valid for every path. for _, sql := range []struct { name string body string }{ {"getByTokenSQL", getByTokenSQL}, {"listByDriveSQL", listByDriveSQL}, + {"listByChargingSessionSQL", listByChargingSessionSQL}, } { if !strings.Contains(sql.body, selectTokenColumns) { t.Errorf("%s does not embed selectTokenColumns\nfull SQL:\n%s", sql.name, sql.body) @@ -251,6 +261,19 @@ func TestListByDriveSQL_Shape(t *testing.T) { } } +func TestListByChargingSessionSQL_Shape(t *testing.T) { + t.Parallel() + mustContain := []string{ + "WHERE charging_session_id = $1", + "ORDER BY created_at DESC", + } + for _, frag := range mustContain { + if !strings.Contains(listByChargingSessionSQL, frag) { + t.Errorf("listByChargingSessionSQL missing %q\nfull SQL:\n%s", frag, listByChargingSessionSQL) + } + } +} + func TestMutationSQL_Shape(t *testing.T) { t.Parallel() cases := []struct { @@ -279,12 +302,13 @@ func TestMutationSQL_Shape(t *testing.T) { func TestSQL_ParameterisedOnly(t *testing.T) { t.Parallel() all := map[string]string{ - "insertTokenSQL": insertTokenSQL, - "getByTokenSQL": getByTokenSQL, - "listByDriveSQL": listByDriveSQL, - "incrementViewsSQL": incrementViewsSQL, - "deleteTokenSQL": deleteTokenSQL, - "deleteExpiredSQL": deleteExpiredSQL, + "insertTokenSQL": insertTokenSQL, + "getByTokenSQL": getByTokenSQL, + "listByDriveSQL": listByDriveSQL, + "listByChargingSessionSQL": listByChargingSessionSQL, + "incrementViewsSQL": incrementViewsSQL, + "deleteTokenSQL": deleteTokenSQL, + "deleteExpiredSQL": deleteExpiredSQL, } for name, sql := range all { if !strings.Contains(sql, "$1") { @@ -373,7 +397,7 @@ func TestTokenRepo_Create(t *testing.T) { } }) - t.Run("invalid drive id returns error without querying", func(t *testing.T) { + t.Run("missing target returns error without querying", func(t *testing.T) { t.Parallel() for _, driveID := range []int64{0, -1} { fp := &fakePool{} @@ -388,6 +412,19 @@ func TestTokenRepo_Create(t *testing.T) { } }) + t.Run("both targets set returns error without querying", func(t *testing.T) { + t.Parallel() + fp := &fakePool{} + repo := &TokenRepo{pool: fp} + err := repo.Create(context.Background(), &drivemodel.ShareToken{DriveID: 1, ChargingSessionID: 2}) + if err == nil { + t.Fatal("expected error for dual targets") + } + if fp.rowCalls != 0 { + t.Errorf("QueryRow called %d times, want 0 for dual targets", fp.rowCalls) + } + }) + t.Run("success populates id, created_at, token and passes args", func(t *testing.T) { t.Parallel() fp := &fakePool{ @@ -427,8 +464,8 @@ func TestTokenRepo_Create(t *testing.T) { if fp.lastSQL != insertTokenSQL { t.Errorf("Create used unexpected SQL:\n%s", fp.lastSQL) } - if len(fp.lastArgs) != 9 { - t.Fatalf("Create passed %d args, want 9", len(fp.lastArgs)) + if len(fp.lastArgs) != 10 { + t.Fatalf("Create passed %d args, want 10", len(fp.lastArgs)) } if fp.lastArgs[0] != st.Token { t.Errorf("arg[0] = %v, want token %q", fp.lastArgs[0], st.Token) @@ -436,6 +473,37 @@ func TestTokenRepo_Create(t *testing.T) { if fp.lastArgs[1] != int64(42) { t.Errorf("arg[1] = %v, want drive_id 42", fp.lastArgs[1]) } + if fp.lastArgs[2] != nil { + t.Errorf("arg[2] = %v, want NULL charging_session_id", fp.lastArgs[2]) + } + }) + + t.Run("session target binds NULL drive_id", func(t *testing.T) { + t.Parallel() + fp := &fakePool{ + rowFn: func(_ string, _ []any) pgx.Row { + return fakeRow{scan: func(dest ...any) error { + if err := setDest(dest[0], int64(78)); err != nil { + return err + } + return setDest(dest[1], scanTime) + }} + }, + } + repo := &TokenRepo{pool: fp} + st := &drivemodel.ShareToken{ChargingSessionID: 7} + if err := repo.Create(context.Background(), st); err != nil { + t.Fatalf("Create() error = %v", err) + } + if len(fp.lastArgs) != 10 { + t.Fatalf("Create passed %d args, want 10", len(fp.lastArgs)) + } + if fp.lastArgs[1] != nil { + t.Errorf("arg[1] = %v, want NULL drive_id", fp.lastArgs[1]) + } + if fp.lastArgs[2] != int64(7) { + t.Errorf("arg[2] = %v, want charging_session_id 7", fp.lastArgs[2]) + } }) t.Run("scan error is wrapped", func(t *testing.T) { @@ -667,6 +735,55 @@ func TestTokenRepo_ListByDrive(t *testing.T) { }) } +// ── ListByChargingSession ────────────────────────────────────────────────────── + +// TestTokenRepo_ListByChargingSession covers the session-target list path. +// The error matrix (query/scan/rows.Err) is identical to ListByDrive by +// construction, so only the success path plus SQL/args pinning is repeated. +func TestTokenRepo_ListByChargingSession(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) + rows := []drivemodel.ShareToken{ + {ID: 3, Token: "s1", ChargingSessionID: 11, IncludeTelemetry: true, Views: 5, CreatedAt: base}, + } + + t.Run("rows scanned with session target", func(t *testing.T) { + t.Parallel() + fr := &fakeRows{data: rows} + fp := &fakePool{rows: fr} + repo := &TokenRepo{pool: fp} + got, err := repo.ListByChargingSession(context.Background(), 11) + if err != nil { + t.Fatalf("ListByChargingSession() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("ListByChargingSession() len = %d, want 1", len(got)) + } + assertShareTokenEqual(t, *got[0], rows[0]) + if fp.lastSQL != listByChargingSessionSQL { + t.Errorf("ListByChargingSession used unexpected SQL:\n%s", fp.lastSQL) + } + if len(fp.lastArgs) != 1 || fp.lastArgs[0] != int64(11) { + t.Errorf("ListByChargingSession args = %v, want [11]", fp.lastArgs) + } + }) + + t.Run("query error is wrapped", func(t *testing.T) { + t.Parallel() + sentinel := errors.New("query boom") + fp := &fakePool{queryErr: sentinel} + repo := &TokenRepo{pool: fp} + got, err := repo.ListByChargingSession(context.Background(), 11) + if got != nil { + t.Errorf("ListByChargingSession() = %v, want nil on error", got) + } + if !errors.Is(err, sentinel) { + t.Fatalf("ListByChargingSession() error = %v, want wrapped %v", err, sentinel) + } + }) +} + // ── IncrementViews ─────────────────────────────────────────────────────────── func TestTokenRepo_IncrementViews(t *testing.T) { @@ -844,9 +961,11 @@ func TestTokenRepo_DeleteExpired(t *testing.T) { func assertShareTokenEqual(t *testing.T, got, want drivemodel.ShareToken) { t.Helper() - if got.ID != want.ID || got.Token != want.Token || got.DriveID != want.DriveID { - t.Errorf("scalar mismatch: got {ID:%d Token:%q DriveID:%d}, want {ID:%d Token:%q DriveID:%d}", - got.ID, got.Token, got.DriveID, want.ID, want.Token, want.DriveID) + if got.ID != want.ID || got.Token != want.Token || got.DriveID != want.DriveID || + got.ChargingSessionID != want.ChargingSessionID { + t.Errorf("scalar mismatch: got {ID:%d Token:%q DriveID:%d SessionID:%d}, want {ID:%d Token:%q DriveID:%d SessionID:%d}", + got.ID, got.Token, got.DriveID, got.ChargingSessionID, + want.ID, want.Token, want.DriveID, want.ChargingSessionID) } if !strPtrEqual(got.CreatedBy, want.CreatedBy) { t.Errorf("CreatedBy = %v, want %v", derefStr(got.CreatedBy), derefStr(want.CreatedBy)) diff --git a/internal/domain/ownershipintel/ghost.go b/internal/domain/ownershipintel/ghost.go new file mode 100644 index 0000000000..9643a7018e --- /dev/null +++ b/internal/domain/ownershipintel/ghost.go @@ -0,0 +1,25 @@ +package ownershipintel + +import "time" + +// GhostDrive is one drive flagged as plausibly driven by someone other +// than a known driver: unattributed to any named profile and behaviourally +// far from its cluster. +type GhostDrive struct { + DriveID int64 `json:"drive_id"` + StartedAt time.Time `json:"started_at"` + DistanceM float64 `json:"distance_m"` + DurationS int64 `json:"duration_s"` + ClusterID int `json:"cluster_id"` + Score float64 `json:"score"` + ConfidencePct float64 `json:"confidence_pct"` + DistanceRatio float64 `json:"distance_ratio"` + Reason string `json:"reason"` +} + +// GhostReport is the ghost-driver scan over recent drives. +type GhostReport struct { + VehicleID int64 `json:"vehicle_id"` + Scanned int `json:"scanned"` + Ghosts []GhostDrive `json:"ghosts"` +} diff --git a/internal/handler/v1/ownershipintel/handler.go b/internal/handler/v1/ownershipintel/handler.go index cd8b3519d9..7a2d169a95 100644 --- a/internal/handler/v1/ownershipintel/handler.go +++ b/internal/handler/v1/ownershipintel/handler.go @@ -51,6 +51,7 @@ type service interface { CreateDispute(context.Context, string, int64, domain.CreateDisputeRequest) (*domain.InvoiceDispute, error) DriverAttribution(context.Context, string, int64, int, int, int) (*domain.DriverAttributionReport, error) + GhostDrives(context.Context, string, int64, int) (*domain.GhostReport, error) ListDriverProfiles(context.Context, string, int64) ([]domain.DriverProfile, error) CreateDriverProfile(context.Context, string, domain.CreateDriverProfileRequest) (*domain.DriverProfile, error) DeleteDriverProfile(context.Context, string, int64) error @@ -133,6 +134,7 @@ func (h *Handler) MountRoutes(r chi.Router) { r.Route("/driver-attribution", func(r chi.Router) { r.Get("/", h.DriverAttribution) + r.Get("/ghost-drives", h.GhostDrives) r.Get("/profiles", h.ListDriverProfiles) r.With(writeLimit).Post("/profiles", h.CreateDriverProfile) r.With(writeLimit).Delete("/profiles/{id}", h.DeleteDriverProfile) @@ -438,6 +440,32 @@ func (h *Handler) DriverAttribution(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, response) } +func (h *Handler) GhostDrives(w http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(r.Context(), "ownershipintel.GhostDrives") + defer span.End() + subject, ok := h.subject(w, r, span) + if !ok { + return + } + vehicleID, _, _, err := parseListRequest(r) + if err != nil { + validationError(w, span, err) + return + } + windowDays, err := parseWindowDays(r) + if err != nil { + validationError(w, span, err) + return + } + span.SetAttributes(attribute.Int64("vehicle_id", vehicleID), attribute.Int("window_days", windowDays)) + response, err := h.service.GhostDrives(ctx, subject, vehicleID, windowDays) + if err != nil { + h.handleError(w, span, "detect ghost drives", err) + return + } + writeJSON(w, http.StatusOK, response) +} + func (h *Handler) ListDriverProfiles(w http.ResponseWriter, r *http.Request) { ctx, span := tracer.Start(r.Context(), "ownershipintel.ListDriverProfiles") defer span.End() diff --git a/internal/models/drive/drive.go b/internal/models/drive/drive.go index 8938b2b12b..a73b6f1072 100644 --- a/internal/models/drive/drive.go +++ b/internal/models/drive/drive.go @@ -106,18 +106,21 @@ type DriveTelemetryReading struct { CreatedAt time.Time `json:"created_at" db:"created_at"` } -// ShareToken represents a public share link for a drive. +// ShareToken represents a public share link for a drive or a charging +// session — exactly one of DriveID / ChargingSessionID is set (0 = none), +// enforced by the share_tokens_exactly_one_target CHECK. type ShareToken struct { - ID int64 `json:"id" db:"id"` - Token string `json:"token" db:"token"` - DriveID int64 `json:"drive_id" db:"drive_id"` - CreatedBy *string `json:"created_by,omitempty" db:"created_by"` - Title *string `json:"title,omitempty" db:"title"` - Description *string `json:"description,omitempty" db:"description"` - IncludeMap bool `json:"include_map" db:"include_map"` - IncludeTelemetry bool `json:"include_telemetry" db:"include_telemetry"` - IncludeSpeed bool `json:"include_speed" db:"include_speed"` - Views int `json:"views" db:"views"` - ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` + ID int64 `json:"id" db:"id"` + Token string `json:"token" db:"token"` + DriveID int64 `json:"drive_id,omitempty" db:"drive_id"` + ChargingSessionID int64 `json:"charging_session_id,omitempty" db:"charging_session_id"` + CreatedBy *string `json:"created_by,omitempty" db:"created_by"` + Title *string `json:"title,omitempty" db:"title"` + Description *string `json:"description,omitempty" db:"description"` + IncludeMap bool `json:"include_map" db:"include_map"` + IncludeTelemetry bool `json:"include_telemetry" db:"include_telemetry"` + IncludeSpeed bool `json:"include_speed" db:"include_speed"` + Views int `json:"views" db:"views"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` } diff --git a/internal/models/fleetops/models.go b/internal/models/fleetops/models.go index 6efefbf069..03e4a6d8a3 100644 --- a/internal/models/fleetops/models.go +++ b/internal/models/fleetops/models.go @@ -3,13 +3,18 @@ package fleetops import "time" type FleetDriver struct { - ID int64 `db:"id" json:"id"` - DisplayName string `db:"display_name" json:"display_name"` - ReferenceCode string `db:"reference_code" json:"reference_code"` - Status string `db:"status" json:"status"` - Version int `db:"version" json:"version"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID int64 `db:"id" json:"id"` + DisplayName string `db:"display_name" json:"display_name"` + ReferenceCode string `db:"reference_code" json:"reference_code"` + Status string `db:"status" json:"status"` + // Guardrails (all optional): per-driver charge-target cap and a daily + // curfew window (HH:MM, overnight wrap allowed) evaluated by /evaluate. + MaxChargeSOC *int16 `db:"max_charge_soc" json:"max_charge_soc"` + CurfewStart *string `db:"curfew_start" json:"curfew_start"` + CurfewEnd *string `db:"curfew_end" json:"curfew_end"` + Version int `db:"version" json:"version"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type FleetCostCenter struct { diff --git a/migrations/000235_charge_autopilot_profiles.down.sql b/migrations/000235_charge_autopilot_profiles.down.sql new file mode 100644 index 0000000000..77417faa93 --- /dev/null +++ b/migrations/000235_charge_autopilot_profiles.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS charge_autopilot_profiles; diff --git a/migrations/000235_charge_autopilot_profiles.up.sql b/migrations/000235_charge_autopilot_profiles.up.sql new file mode 100644 index 0000000000..07615b4b05 --- /dev/null +++ b/migrations/000235_charge_autopilot_profiles.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE charge_autopilot_profiles ( + vehicle_id BIGINT PRIMARY KEY REFERENCES vehicles(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + target_soc INT NOT NULL DEFAULT 80, + ready_by TEXT NOT NULL DEFAULT '07:30', + rate_plan TEXT NOT NULL DEFAULT 'pge-ev2a', + daily_cap_soc INT NOT NULL DEFAULT 80, + trip_override BOOLEAN NOT NULL DEFAULT FALSE, + precondition BOOLEAN NOT NULL DEFAULT TRUE, + max_amps INT NOT NULL DEFAULT 32, + battery_capacity_kwh NUMERIC(6,2) NOT NULL DEFAULT 75, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/migrations/000236_tco_ledger_entries.down.sql b/migrations/000236_tco_ledger_entries.down.sql new file mode 100644 index 0000000000..6204cca596 --- /dev/null +++ b/migrations/000236_tco_ledger_entries.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tco_ledger_entries; diff --git a/migrations/000236_tco_ledger_entries.up.sql b/migrations/000236_tco_ledger_entries.up.sql new file mode 100644 index 0000000000..15ba0704db --- /dev/null +++ b/migrations/000236_tco_ledger_entries.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE tco_ledger_entries ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + category TEXT NOT NULL, + amount NUMERIC(12,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'USD', + incurred_on DATE NOT NULL, + note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_tco_ledger_vehicle ON tco_ledger_entries(vehicle_id); +CREATE INDEX idx_tco_ledger_incurred ON tco_ledger_entries(vehicle_id, incurred_on DESC); diff --git a/migrations/000237_fleet_driver_guardrails.down.sql b/migrations/000237_fleet_driver_guardrails.down.sql new file mode 100644 index 0000000000..b0261f60fe --- /dev/null +++ b/migrations/000237_fleet_driver_guardrails.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE fleet_drivers DROP CONSTRAINT IF EXISTS fleet_drivers_max_charge_soc_range; +ALTER TABLE fleet_drivers + DROP COLUMN IF EXISTS max_charge_soc, + DROP COLUMN IF EXISTS curfew_start, + DROP COLUMN IF EXISTS curfew_end; diff --git a/migrations/000237_fleet_driver_guardrails.up.sql b/migrations/000237_fleet_driver_guardrails.up.sql new file mode 100644 index 0000000000..055f4393a2 --- /dev/null +++ b/migrations/000237_fleet_driver_guardrails.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE fleet_drivers + ADD COLUMN IF NOT EXISTS max_charge_soc SMALLINT NULL, + ADD COLUMN IF NOT EXISTS curfew_start TEXT NULL, + ADD COLUMN IF NOT EXISTS curfew_end TEXT NULL; + +ALTER TABLE fleet_drivers + ADD CONSTRAINT fleet_drivers_max_charge_soc_range + CHECK (max_charge_soc IS NULL OR (max_charge_soc >= 20 AND max_charge_soc <= 100)); diff --git a/migrations/000238_ocpp_integration.down.sql b/migrations/000238_ocpp_integration.down.sql new file mode 100644 index 0000000000..051e3b89d5 --- /dev/null +++ b/migrations/000238_ocpp_integration.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS ocpp_meter_values; +DROP TABLE IF EXISTS ocpp_sessions; +DROP TABLE IF EXISTS ocpp_connector_status; +DROP TABLE IF EXISTS ocpp_charge_points; diff --git a/migrations/000238_ocpp_integration.up.sql b/migrations/000238_ocpp_integration.up.sql new file mode 100644 index 0000000000..48a4fc96d3 --- /dev/null +++ b/migrations/000238_ocpp_integration.up.sql @@ -0,0 +1,74 @@ +-- OCPP-J 1.6 CSMS persistence: charge points, connector status, +-- charging sessions, and meter samples recorded by cmd/ocpp-server. +-- Read back by the main API so mixed-fleet operators see non-Tesla +-- charger activity next to Tesla charging sessions. + +CREATE TABLE IF NOT EXISTS ocpp_charge_points ( + charge_point_id text PRIMARY KEY + CHECK (char_length(charge_point_id) BETWEEN 1 AND 128), + vendor text NOT NULL DEFAULT '' + CHECK (char_length(vendor) <= 128), + model text NOT NULL DEFAULT '' + CHECK (char_length(model) <= 128), + serial_number text NOT NULL DEFAULT '' + CHECK (char_length(serial_number) <= 128), + firmware_version text NOT NULL DEFAULT '' + CHECK (char_length(firmware_version) <= 128), + last_boot_at timestamptz, + last_seen_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS ocpp_connector_status ( + charge_point_id text NOT NULL REFERENCES ocpp_charge_points (charge_point_id) ON DELETE CASCADE, + connector_id integer NOT NULL CHECK (connector_id >= 0), + status text NOT NULL DEFAULT '' + CHECK (char_length(status) <= 32), + error_code text NOT NULL DEFAULT '' + CHECK (char_length(error_code) <= 64), + info text NOT NULL DEFAULT '' + CHECK (char_length(info) <= 500), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (charge_point_id, connector_id) +); + +-- Transaction IDs are allocated by the CSMS dispatcher from one +-- process-global atomic counter, so they are globally unique and a +-- plain UNIQUE holds (StopSession/MeterValues address them bare). +CREATE TABLE IF NOT EXISTS ocpp_sessions ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + transaction_id integer NOT NULL UNIQUE CHECK (transaction_id > 0), + charge_point_id text NOT NULL REFERENCES ocpp_charge_points (charge_point_id) ON DELETE CASCADE, + connector_id integer NOT NULL CHECK (connector_id >= 0), + id_tag text NOT NULL DEFAULT '' + CHECK (char_length(id_tag) <= 64), + started_at timestamptz NOT NULL DEFAULT now(), + start_meter_wh integer NOT NULL DEFAULT 0 CHECK (start_meter_wh >= 0), + ended_at timestamptz, + end_meter_wh integer CHECK (end_meter_wh IS NULL OR end_meter_wh >= 0), + stop_reason text NOT NULL DEFAULT '' + CHECK (char_length(stop_reason) <= 64), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ocpp_sessions_end_consistency CHECK ( + (ended_at IS NULL AND end_meter_wh IS NULL) OR + (ended_at IS NOT NULL AND end_meter_wh IS NOT NULL) + ) +); +CREATE INDEX IF NOT EXISTS idx_ocpp_sessions_charge_point + ON ocpp_sessions (charge_point_id, started_at DESC); + +CREATE TABLE IF NOT EXISTS ocpp_meter_values ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id bigint NOT NULL REFERENCES ocpp_sessions (id) ON DELETE CASCADE, + connector_id integer NOT NULL CHECK (connector_id >= 0), + sampled_at timestamptz NOT NULL DEFAULT now(), + measurand text NOT NULL DEFAULT '' + CHECK (char_length(measurand) <= 64), + value double precision NOT NULL, + unit text NOT NULL DEFAULT '' + CHECK (char_length(unit) <= 16), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_ocpp_meter_values_session + ON ocpp_meter_values (session_id, sampled_at); diff --git a/migrations/000239_session_share_tokens.down.sql b/migrations/000239_session_share_tokens.down.sql new file mode 100644 index 0000000000..a2e974aa0c --- /dev/null +++ b/migrations/000239_session_share_tokens.down.sql @@ -0,0 +1,15 @@ +-- Roll back session share links. Session-bound rows cannot survive the +-- drive_id NOT NULL restore, so they are removed first (share links are +-- disposable by design; revoke semantics). +DELETE FROM share_tokens WHERE charging_session_id IS NOT NULL; + +ALTER TABLE share_tokens + DROP CONSTRAINT IF EXISTS share_tokens_exactly_one_target; + +ALTER TABLE share_tokens + ALTER COLUMN drive_id SET NOT NULL; + +DROP INDEX IF EXISTS idx_share_tokens_charging_session; + +ALTER TABLE share_tokens + DROP COLUMN IF EXISTS charging_session_id; diff --git a/migrations/000239_session_share_tokens.up.sql b/migrations/000239_session_share_tokens.up.sql new file mode 100644 index 0000000000..088d609a37 --- /dev/null +++ b/migrations/000239_session_share_tokens.up.sql @@ -0,0 +1,18 @@ +-- Session share links: share_tokens can now target either a drive or a +-- charging session (exactly one). Existing rows are drive-bound, so the +-- CHECK holds for all of them at ADD time. + +ALTER TABLE share_tokens + ADD COLUMN charging_session_id BIGINT NULL + REFERENCES charging_sessions (id) ON DELETE CASCADE; + +ALTER TABLE share_tokens + ALTER COLUMN drive_id DROP NOT NULL; + +ALTER TABLE share_tokens + ADD CONSTRAINT share_tokens_exactly_one_target CHECK ( + (drive_id IS NULL) != (charging_session_id IS NULL) + ); + +CREATE INDEX IF NOT EXISTS idx_share_tokens_charging_session + ON share_tokens (charging_session_id); diff --git a/migrations/000240_stormguard.down.sql b/migrations/000240_stormguard.down.sql new file mode 100644 index 0000000000..f86281b96c --- /dev/null +++ b/migrations/000240_stormguard.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS stormguard_events; +DROP TABLE IF EXISTS stormguard_config; diff --git a/migrations/000240_stormguard.up.sql b/migrations/000240_stormguard.up.sql new file mode 100644 index 0000000000..15fae0323b --- /dev/null +++ b/migrations/000240_stormguard.up.sql @@ -0,0 +1,22 @@ +-- Storm Guardian: per-vehicle severe-weather auto-prep config plus an +-- append-only assessment/action log. + +CREATE TABLE IF NOT EXISTS stormguard_config ( + vehicle_id bigint PRIMARY KEY REFERENCES vehicles (id) ON DELETE CASCADE, + enabled boolean NOT NULL DEFAULT false, + lat double precision NOT NULL CHECK (lat BETWEEN -90 AND 90), + lng double precision NOT NULL CHECK (lng BETWEEN -180 AND 180), + target_soc integer NOT NULL DEFAULT 90 CHECK (target_soc BETWEEN 50 AND 100), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS stormguard_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + vehicle_id bigint NOT NULL REFERENCES vehicles (id) ON DELETE CASCADE, + level text NOT NULL CHECK (level IN ('none', 'watch', 'warning')), + reason text NOT NULL DEFAULT '' CHECK (char_length(reason) <= 500), + acted boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_stormguard_events_vehicle + ON stormguard_events (vehicle_id, created_at DESC); diff --git a/migrations/000241_comfort.down.sql b/migrations/000241_comfort.down.sql new file mode 100644 index 0000000000..3d33f49c0e --- /dev/null +++ b/migrations/000241_comfort.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS comfort_runs; +DROP TABLE IF EXISTS comfort_config; diff --git a/migrations/000241_comfort.up.sql b/migrations/000241_comfort.up.sql new file mode 100644 index 0000000000..4ee53e2285 --- /dev/null +++ b/migrations/000241_comfort.up.sql @@ -0,0 +1,23 @@ +-- Cabin Comfort Autopilot: calendar-aware preconditioning config plus +-- an append-only run log (also the idempotency record per event UID). + +CREATE TABLE IF NOT EXISTS comfort_config ( + vehicle_id bigint PRIMARY KEY REFERENCES vehicles (id) ON DELETE CASCADE, + enabled boolean NOT NULL DEFAULT false, + target_temp_c double precision NOT NULL DEFAULT 21 CHECK (target_temp_c BETWEEN 15 AND 28), + lead_minutes integer NOT NULL DEFAULT 20 CHECK (lead_minutes BETWEEN 5 AND 120), + ics_url text NOT NULL DEFAULT '' CHECK (char_length(ics_url) <= 2000), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS comfort_runs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + vehicle_id bigint NOT NULL REFERENCES vehicles (id) ON DELETE CASCADE, + event_uid text NOT NULL CHECK (char_length(event_uid) <= 500), + event_title text NOT NULL DEFAULT '' CHECK (char_length(event_title) <= 300), + starts_at timestamptz NOT NULL, + acted_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (vehicle_id, event_uid) +); +CREATE INDEX IF NOT EXISTS idx_comfort_runs_vehicle + ON comfort_runs (vehicle_id, acted_at DESC); diff --git a/migrations/000242_journey.down.sql b/migrations/000242_journey.down.sql new file mode 100644 index 0000000000..a558fb0eee --- /dev/null +++ b/migrations/000242_journey.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS journey_plan_versions; +DROP TABLE IF EXISTS journey_sessions; diff --git a/migrations/000242_journey.up.sql b/migrations/000242_journey.up.sql new file mode 100644 index 0000000000..f72baa3699 --- /dev/null +++ b/migrations/000242_journey.up.sql @@ -0,0 +1,43 @@ +-- Journey Autopilot slice 1: trip sessions + versioned plans. +-- +-- A journey_session is one planned-or-live trip. Status machine +-- (planned -> active -> paused -> completed/aborted) is enforced in the +-- API; the CHECK below only bounds the value domain. Plans are +-- versioned rows so every replan keeps its predecessor for diffing. + +CREATE TABLE IF NOT EXISTS journey_sessions ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + vehicle_id bigint NOT NULL REFERENCES vehicles (id) ON DELETE CASCADE, + name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200), + origin_name text NOT NULL DEFAULT '' CHECK (char_length(origin_name) <= 300), + origin_lat double precision, + origin_lng double precision, + dest_name text NOT NULL DEFAULT '' CHECK (char_length(dest_name) <= 300), + dest_lat double precision, + dest_lng double precision, + status text NOT NULL DEFAULT 'planned' + CHECK (status IN ('planned', 'active', 'paused', 'completed', 'aborted')), + plan_version integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + ended_at timestamptz, + CHECK (origin_lat IS NULL OR (origin_lat BETWEEN -90 AND 90)), + CHECK (origin_lng IS NULL OR (origin_lng BETWEEN -180 AND 180)), + CHECK (dest_lat IS NULL OR (dest_lat BETWEEN -90 AND 90)), + CHECK (dest_lng IS NULL OR (dest_lng BETWEEN -180 AND 180)) +); +CREATE INDEX IF NOT EXISTS idx_journey_sessions_vehicle + ON journey_sessions (vehicle_id, status, updated_at DESC); + +CREATE TABLE IF NOT EXISTS journey_plan_versions ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id bigint NOT NULL REFERENCES journey_sessions (id) ON DELETE CASCADE, + version integer NOT NULL CHECK (version > 0), + plan jsonb NOT NULL DEFAULT '{}', + note text NOT NULL DEFAULT '' CHECK (char_length(note) <= 500), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (session_id, version) +); +CREATE INDEX IF NOT EXISTS idx_journey_plan_versions_session + ON journey_plan_versions (session_id, version DESC); diff --git a/web/src/App.tsx b/web/src/App.tsx index 3b9942e9c7..859ee3504e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -50,6 +50,7 @@ const Powershare = lazy(() => import('./features/charging/pages/PowersharePage') // Trips const Trips = lazy(() => import('./features/trips/pages/TripListPage')) const TripDetail = lazy(() => import('./features/trips/pages/TripDetailPage')) +const Journeys = lazy(() => import('./features/trips/pages/JourneysPage')) // Battery & Energy const Energy = lazy(() => import('./features/battery/pages/EnergyPage')) @@ -635,6 +636,7 @@ export default function App() { } /> } /> } /> + } /> {/* Phase-50 / 0060 — GEN1 trip-postcard-share-card-image-generation registers frontend route `/sharing/trips`. The page renders the deterministic recent-trips list + static-share-card hints diff --git a/web/src/__tests__/lazyRoutes.list.ts b/web/src/__tests__/lazyRoutes.list.ts index c50cc1d594..ccaa35ebc8 100644 --- a/web/src/__tests__/lazyRoutes.list.ts +++ b/web/src/__tests__/lazyRoutes.list.ts @@ -47,6 +47,7 @@ export const LAZY_ROUTE_IMPORTS: Array<{ // Trips { name: 'Trips', load: () => import('../features/trips/pages/TripListPage') }, { name: 'TripDetail', load: () => import('../features/trips/pages/TripDetailPage') }, + { name: 'Journeys', load: () => import('../features/trips/pages/JourneysPage') }, // Battery & Energy { name: 'Energy', load: () => import('../features/battery/pages/EnergyPage') }, diff --git a/web/src/api/hooks/useAnalytics.ts b/web/src/api/hooks/useAnalytics.ts index 4c7dc7cc7c..40b0e07991 100644 --- a/web/src/api/hooks/useAnalytics.ts +++ b/web/src/api/hooks/useAnalytics.ts @@ -1,11 +1,13 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { request } from '../client'; import { queryPolicy } from '../queryPolicy'; import { scopeKey, scopedPath, type QueryScope } from '../scope'; import { safeArray } from '@/lib/safeArray'; import { STALE_TIMES } from '@/lib/constants'; import { browserTimezone } from '@/lib/timezone'; -import type { AnalyticsSummary, MileageStats, CostBreakdown, TimelineEvent, StateSummary, WeeklyDigestData, MonthlyMileageBucket, MonthlyMileageResponse, DailyMileageBucket, DailyMileageResponse } from '@/types/analytics'; +import { useMutationToast } from './_toastHelpers'; +import { invalidateAndBroadcast } from '@/lib/queryBroadcast'; +import type { AnalyticsSummary, MileageStats, CostBreakdown, TimelineEvent, StateSummary, WeeklyDigestData, MonthlyMileageBucket, MonthlyMileageResponse, DailyMileageBucket, DailyMileageResponse, TcoLedgerResponse, TcoLedgerCreate, TcoLedgerEntry } from '@/types/analytics'; import { FSD_DEFAULT_PERIOD_DAYS, type FsdInsights } from '@/types/fsd'; import type { FleetAnalytics } from '@/api/types'; @@ -110,6 +112,54 @@ export function useCostBreakdown(vehicleId: string) { }); } +export const tcoLedgerKeys = { + all: ['tco-ledger'] as const, + byVehicle: (vehicleId: number) => ['tco-ledger', vehicleId] as const, +}; + +/** Fetches fixed-cost ledger entries + totals for a vehicle. */ +export function useTcoLedger(vehicleId?: number) { + return useQuery({ + queryKey: tcoLedgerKeys.byVehicle(vehicleId!), + queryFn: ({ signal }) => + request(`/analytics/tco/ledger?vehicle_id=${vehicleId}`, { signal }), + enabled: !!vehicleId, + }); +} + +/** Mutation to record a fixed-cost ledger entry. */ +export function useAddTcoLedgerEntry() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (params: TcoLedgerCreate) => + request('/analytics/tco/ledger', { + method: 'POST', + body: JSON.stringify(params), + }), + onSuccess: (entry) => { + invalidateAndBroadcast(qc, { queryKey: tcoLedgerKeys.byVehicle(entry.vehicle_id) }); + success('toast.tco.ledger.add.success', 'Cost recorded'); + }, + onError: (err) => error(err, 'toast.tco.ledger.add.error', 'Failed to record cost'), + }); +} + +/** Mutation to delete a fixed-cost ledger entry. */ +export function useDeleteTcoLedgerEntry() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ vehicleId, id }: { vehicleId: number; id: number }) => + request(`/analytics/tco/ledger/${id}?vehicle_id=${vehicleId}`, { method: 'DELETE' }), + onSuccess: (_, { vehicleId }) => { + invalidateAndBroadcast(qc, { queryKey: tcoLedgerKeys.byVehicle(vehicleId) }); + success('toast.tco.ledger.delete.success', 'Entry deleted'); + }, + onError: (err) => error(err, 'toast.tco.ledger.delete.error', 'Failed to delete entry'), + }); +} + /** * @deprecated Phase-42 / Prompt 0077 removed `/vehicle-states/timeline` * along with the `vehicle_states` snapshot table. State transitions are @@ -446,6 +496,39 @@ export function useTemperatureImpact(vehicleId: string) { }); } +/** GET /analytics/temperature-impact/shift — month-over-month diagnosis. */ +export interface EfficiencyShift { + latest_month: string; + prior_month: string; + latest_efficiency: number; + prior_efficiency: number; + efficiency_delta_pct: number; + latest_temp_c: number; + prior_temp_c: number; + temp_delta_c: number; + temp_sensitivity_per_c: number; + temp_attributed_pct: number; + residual_pct: number; + verdict: 'stable' | 'colder_weather' | 'warmer_driving' | 'driving_pattern' | 'insufficient_data'; + explanation: string; +} + +/** + * GET /analytics/temperature-impact/shift?vehicle_id=X — the efficiency + * detective: latest vs prior month with temperature attribution. + */ +export function useEfficiencyShift(vehicleId: string) { + return useQuery({ + queryKey: [...analyticsKeys.temperatureImpact(vehicleId), 'shift'] as const, + queryFn: ({ signal }) => + request( + `/analytics/temperature-impact/shift?vehicle_id=${encodeURIComponent(vehicleId)}`, + { signal }, + ), + enabled: !!vehicleId, + }); +} + /* ── FSD Insights ───────────────────────────────────────────────── */ /** diff --git a/web/src/api/hooks/useAutomations.ts b/web/src/api/hooks/useAutomations.ts index 67d883b977..89064b2f3d 100644 --- a/web/src/api/hooks/useAutomations.ts +++ b/web/src/api/hooks/useAutomations.ts @@ -14,6 +14,8 @@ import type { AutomationPresetsResponse, AutomationPreset, AutomationTriggerInput, + RoutineTemplate, + InstallRoutineRequest, } from '@/api/types'; export type AutomationStepInput = @@ -314,3 +316,37 @@ export function useAutomationPreset(id: string | undefined) { staleTime: STALE_TIMES.STATIC, }); } + +// ── Geofence routine templates ───────────────────────────────────────── + +export const routineKeys = { + all: ['automation-routines'] as const, +}; + +/** Fetches the parameterized geofence routine catalogue. */ +export function useRoutineTemplates() { + return useQuery({ + queryKey: routineKeys.all, + queryFn: ({ signal }) => request('/automations/routine-templates', { signal }), + staleTime: STALE_TIMES.STATIC, + select: safeArray, + }); +} + +/** Mutation to install a routine for a chosen place. */ +export function useInstallRoutine() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ id, ...params }: InstallRoutineRequest & { id: string }) => + request(`/automations/routine-templates/${encodeURIComponent(id)}/install`, { + method: 'POST', + body: JSON.stringify(params), + }), + onSuccess: () => { + invalidateAndBroadcast(qc, { queryKey: automationKeys.all }); + success('toast.automations.routine.success', 'Routine installed'); + }, + onError: (err) => error(err, 'toast.automations.routine.error', 'Failed to install routine'), + }); +} diff --git a/web/src/api/hooks/useBatteryCertificate.ts b/web/src/api/hooks/useBatteryCertificate.ts new file mode 100644 index 0000000000..229e2fa509 --- /dev/null +++ b/web/src/api/hooks/useBatteryCertificate.ts @@ -0,0 +1,75 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import { request } from '../client'; +import { STALE_TIMES } from '@/lib/constants'; + +/** + * Server-signed battery certificate — a buyer-verifiable resale attestation. + * These hooks read the two backend routes registered in + * internal/api/router.go: + * + * GET /analytics/battery-health/certificate?vehicle_id= (authenticated) + * POST /public/battery-certificate/verify (public — signature IS the auth) + * + * `request()` prepends the version prefix automatically, so the paths below + * must NOT include it. All field names are snake_case to mirror the Go JSON + * tags. + */ + +/** The signed certificate payload (compact buyer-facing health snapshot). */ +export interface BatteryCertificate { + issuer: string; + version: number; + vehicle_id: number; + /** RFC 3339 issue instant. */ + issued_at: string; + /** RFC 3339 expiry instant (30 days after issue). */ + expires_at: string; + current_soh: number; + estimated_capacity_kwh: number; + original_capacity_kwh: number; + degradation_rate_pct_per_year: number; + battery_age_months: number; + total_cycles: number; + charge_habits_score: number; + stress_level: string; + fast_charge_pct: number; + temp_exposure_score: number | null; + temp_exposure_reason: string | null; +} + +/** Issue result: the certificate plus its lowercase hex HMAC signature. */ +export interface BatteryCertificateIssueResponse { + certificate: BatteryCertificate; + signature: string; +} + +/** Verify result: echoes the certificate only when the signature is valid. */ +export interface BatteryCertificateVerifyResponse { + valid: boolean; + certificate?: BatteryCertificate; +} + +/** Issue (fetch) the current server-signed battery certificate. */ +export function useBatteryCertificate(vehicleId: string | null) { + return useQuery({ + queryKey: ['battery-certificate', vehicleId], + queryFn: ({ signal }) => + request( + `/analytics/battery-health/certificate?vehicle_id=${encodeURIComponent(vehicleId ?? '')}`, + { signal }, + ), + enabled: vehicleId !== null, + staleTime: STALE_TIMES.ANALYTICS, + }); +} + +/** Verify a seller-supplied certificate + signature (public endpoint). */ +export function useVerifyBatteryCertificate() { + return useMutation({ + mutationFn: (params: { certificate: BatteryCertificate; signature: string }) => + request('/public/battery-certificate/verify', { + method: 'POST', + body: JSON.stringify(params), + }), + }); +} diff --git a/web/src/api/hooks/useCharging.ts b/web/src/api/hooks/useCharging.ts index b43b6c907f..a6da1bd25c 100644 --- a/web/src/api/hooks/useCharging.ts +++ b/web/src/api/hooks/useCharging.ts @@ -1,5 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { request } from '../client'; +import { queryPolicy } from '../queryPolicy'; +import { scopedPath } from '../scope'; import { safeArray } from '@/lib/safeArray'; import { STALE_TIMES, INTERVALS } from '@/lib/constants'; import { useMutationToast } from './_toastHelpers'; @@ -14,6 +16,15 @@ import type { ApplyScheduleResponse, ChargePlan, RatePlanInfo, + AutopilotProfile, + AutopilotPreviewRequest, + AutopilotPreview, + AutopilotRunResponse, + AutopilotSavings, + NextChargeDecision, + BillVarianceReport, + QueueAdviseRequest, + QueueAdvice, } from '@/types/charging'; import type { ChargingSession as ApiChargingSession, ChargeTelemetryReading } from '../types'; @@ -179,11 +190,30 @@ export interface TeslaChargingHistoryResponse { upserted?: number; } +export interface ChargingSiteRank { + site: string; + visits: number; + total_wh: number; + total_spend: number; + avg_per_kwh: number; + last_visit: string; +} + +export interface ChargingSiteRanking { + sites: ChargingSiteRank[]; + unpriced_count: number; +} + export const teslaChargingHistoryKeys = { all: ['tesla-charging-history'] as const, byVin: (vin: string) => ['tesla-charging-history', vin] as const, }; +export const teslaChargingSiteKeys = { + all: ['tesla-charging-site-ranking'] as const, + byVin: (vin?: string) => ['tesla-charging-site-ranking', vin] as const, +}; + /** Fetches Tesla Supercharger/DC charging history from the local DB. */ export function useTeslaChargingHistory(vin?: string, options?: { enabled?: boolean }) { return useQuery({ @@ -214,12 +244,25 @@ export function useRefreshTeslaChargingHistory() { }, onSuccess: () => { qc.invalidateQueries({ queryKey: teslaChargingHistoryKeys.all }); + qc.invalidateQueries({ queryKey: teslaChargingSiteKeys.all }); success('toast.charging.history.success', 'Charging history refreshed'); }, onError: (err) => error(err, 'toast.charging.history.error', 'Failed to refresh charging history'), }); } +/** Fetches visited Supercharger sites ranked by realized $/kWh, cheapest first. */ +export function useChargingSiteRanking(vin?: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: teslaChargingSiteKeys.byVin(vin), + queryFn: ({ signal }) => request( + `/tesla/charging/history/sites${vin ? `?vin=${vin}` : ''}`, { signal } + ), + staleTime: STALE_TIMES.SLOW, + enabled: options?.enabled ?? true, + }); +} + /** Returns the direct URL for downloading a Tesla charging invoice PDF. */ export function getTeslaChargingInvoiceURL(contentId: string): string { // Direct download URL — not a request() call, so the full API @@ -288,6 +331,79 @@ export function useTeslaChargingSessions(vin?: string, options?: { enabled?: boo }); } +// --- Supercharger Wait Oracle --- + +export interface WaitOracleSite { + name: string; + sessions: number; + lat: number; + lng: number; + last_session: string; +} + +export interface WaitOracleHour { + hour: number; + expected_wait_s: number; + busyness: number; +} + +export interface WaitOracleForecast { + site: string; + arrive_at: string; + expected_wait_s: number; + wait_probability_pct: number; + busyness: number; + verdict: 'quiet' | 'steady' | 'busy' | 'packed'; + confidence: 'high' | 'medium' | 'low'; + stalls_estimated: number; + best_hour_utc: number; + best_wait_s: number; + save_s: number; + hours: WaitOracleHour[]; + evidence: string[]; +} + +export const waitOracleKeys = { + all: ['wait-oracle'] as const, + sites: (q: string) => ['wait-oracle', 'sites', q] as const, + forecast: (site: string, arriveAt: string | null) => + ['wait-oracle', 'forecast', site, arriveAt] as const, +}; + +/** Lists named charging sites from fleet history, most-visited first. */ +export function useWaitOracleSites(q = '', options?: { enabled?: boolean }) { + return useQuery({ + queryKey: waitOracleKeys.sites(q), + queryFn: ({ signal }) => + request( + scopedPath('/waitoracle/sites', { filters: { q: q || null } }), + { signal }, + ), + enabled: options?.enabled ?? true, + ...queryPolicy('historical'), + }); +} + +/** Forecasts the queue wait for arriving at a site at an instant (RFC3339; null = now). */ +export function useWaitOracleForecast( + site: string | null, + arriveAt: string | null, + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: waitOracleKeys.forecast(site ?? '', arriveAt), + queryFn: ({ signal }) => + request( + scopedPath('/waitoracle/forecast', { + filters: { site: site ?? '', arrive_at: arriveAt }, + }), + { signal }, + ), + enabled: (options?.enabled ?? true) && site != null && site !== '', + ...queryPolicy('historical'), + }); +} + /** Mutation to refresh Tesla fleet charging sessionsfrom the Tesla API. */ export function useRefreshTeslaChargingSessions() { const qc = useQueryClient(); @@ -375,6 +491,129 @@ export function useRatePlans() { }); } +// --- Charge Autopilot --- + +export const autopilotKeys = { + all: ['charge-autopilot'] as const, + profile: (vehicleId: number) => ['charge-autopilot', 'profile', vehicleId] as const, + savings: (vehicleId: number) => ['charge-autopilot', 'savings', vehicleId] as const, + decision: (vehicleId: number, soc: number) => + ['charge-autopilot', 'decision', vehicleId, soc] as const, +}; + +/** Fetches the Autopilot profile for a vehicle (defaults when never saved). */ +export function useAutopilotProfile(vehicleId?: number) { + return useQuery({ + queryKey: autopilotKeys.profile(vehicleId!), + queryFn: ({ signal }) => + request(`/charge-autopilot/profile?vehicle_id=${vehicleId}`, { signal }), + enabled: !!vehicleId, + }); +} + +/** Mutation to save the Autopilot profile for a vehicle. */ +export function useSaveAutopilotProfile() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (params: AutopilotProfile) => + request('/charge-autopilot/profile', { + method: 'PUT', + body: JSON.stringify(params), + }), + onSuccess: () => { + invalidateAndBroadcast(qc, { queryKey: autopilotKeys.all }); + success('toast.autopilot.save.success', 'Autopilot settings saved'); + }, + onError: (err) => error(err, 'toast.autopilot.save.error', 'Failed to save autopilot settings'), + }); +} + +/** Mutation to preview the next automatic Autopilot run. */ +export function useAutopilotPreview() { + const { error } = useMutationToast(); + return useMutation({ + mutationFn: (params: AutopilotPreviewRequest) => + request('/charge-autopilot/preview', { + method: 'POST', + body: JSON.stringify(params), + }), + onError: (err) => error(err, 'toast.autopilot.preview.error', 'Failed to preview autopilot run'), + }); +} + +/** + * One-click Autopilot run: computes the optimal window from the stored + * profile, persists it as a charge plan, and applies it to the vehicle. + * Issues real Tesla commands, so it requires live mode like /apply. + */ +export function useAutopilotRun() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (params: AutopilotPreviewRequest) => + request('/charge-autopilot/run', { + method: 'POST', + requiresLiveMode: true, + body: JSON.stringify(params), + }), + onSuccess: (res) => { + invalidateAndBroadcast(qc, { queryKey: chargePlannerKeys.all }); + invalidateAndBroadcast(qc, { queryKey: autopilotKeys.all }); + success('toast.autopilot.run.success', res.message || 'Autopilot run scheduled'); + }, + onError: (err) => error(err, 'toast.autopilot.run.error', 'Failed to run autopilot'), + }); +} + +/** 12-hour next-charge verdict (home TOU vs billed Supercharger). */ +export function useNextChargeDecision(vehicleId?: number, currentSoc?: number) { + const socReady = currentSoc != null && Number.isFinite(currentSoc); + return useQuery({ + queryKey: autopilotKeys.decision(vehicleId ?? 0, currentSoc ?? -1), + queryFn: ({ signal }) => + request( + `/charge-autopilot/decision?vehicle_id=${vehicleId}¤t_soc=${currentSoc}`, + { signal }, + ), + enabled: !!vehicleId && socReady, + staleTime: STALE_TIMES.FAST, + }); +} + +/** Fetches realized Autopilot savings from applied charge plans. */ +export function useAutopilotSavings(vehicleId?: number) { + return useQuery({ + queryKey: autopilotKeys.savings(vehicleId!), + queryFn: ({ signal }) => + request(`/charge-autopilot/savings?vehicle_id=${vehicleId}`, { signal }), + enabled: !!vehicleId, + }); +} + +/** Mutation to order a shared-charger queue across vehicles. */ +export function useAdviseChargeQueue() { + const { error } = useMutationToast(); + return useMutation({ + mutationFn: (params: QueueAdviseRequest) => + request('/charge-planner/queue', { + method: 'POST', + body: JSON.stringify(params), + }), + onError: (err) => error(err, 'toast.charge.queue.error', 'Failed to plan charger queue'), + }); +} + +/** Fetches the measured-vs-invoiced DC reconciliation for a vehicle. */ +export function useBillVariance(vehicleId?: number) { + return useQuery({ + queryKey: ['bill-variance', vehicleId], + queryFn: ({ signal }) => + request(`/charging/bill-variance?vehicle_id=${vehicleId}`, { signal }), + enabled: !!vehicleId, + }); +} + /** * Bulk delete charging sessions. Returns the standardized * BulkOperationResult envelope. diff --git a/web/src/api/hooks/useComfort.ts b/web/src/api/hooks/useComfort.ts new file mode 100644 index 0000000000..a7c11209b1 --- /dev/null +++ b/web/src/api/hooks/useComfort.ts @@ -0,0 +1,135 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { request } from '../client'; +import { queryPolicy } from '../queryPolicy'; +import { scopedPath } from '../scope'; +import { safeArray } from '@/lib/safeArray'; +import { useMutationToast } from './_toastHelpers'; +import { invalidateAndBroadcast } from '@/lib/queryBroadcast'; +import { useUnits } from '@/hooks/useUnits'; + +/** + * Cabin Comfort: calendar-aware preconditioning per vehicle. Reads the + * backend routes registered in internal/api/router.go: + * + * GET /comfort/next?vehicle_id= + * PUT /comfort/config + * POST /comfort/now + * GET /comfort/runs?vehicle_id=&limit= + * + * `request()` prepends the version prefix automatically, so the paths below + * must NOT include it. All field names are snake_case to mirror the Go JSON + * tags. + */ + +export interface ComfortConfig { + vehicle_id: number; + enabled: boolean; + target_temp_c: number; + lead_minutes: number; + ics_url: string; + updated_at: string; +} + +export interface ComfortEvent { + uid: string; + title: string; + location: string; + starts_at: string; + all_day: boolean; +} + +export interface ComfortNext { + config: ComfortConfig; + event?: ComfortEvent; +} + +export interface ComfortRun { + id: number; + vehicle_id: number; + event_uid: string; + event_title: string; + starts_at: string; + acted_at: string; +} + +export interface ComfortConfigRequest { + vehicle_id: number; + enabled: boolean; + target_temp_c: number; + lead_minutes: number; + ics_url: string; +} + +export const comfortKeys = { + all: ['comfort'] as const, + next: (vehicleId: number) => ['comfort', 'next', vehicleId] as const, + runs: (vehicleId: number) => ['comfort', 'runs', vehicleId] as const, +}; + +/** Stored config plus the next offsite event inside the lead window. */ +export function useComfortNext(vehicleId?: number | null) { + return useQuery({ + queryKey: comfortKeys.next(vehicleId!), + queryFn: ({ signal }) => + request(scopedPath('/comfort/next', { vehicleId }), { signal }), + enabled: vehicleId != null, + ...queryPolicy('operational'), + }); +} + +/** Recent preconditioning runs, newest first. */ +export function useComfortRuns(vehicleId?: number | null) { + return useQuery({ + queryKey: comfortKeys.runs(vehicleId!), + queryFn: ({ signal }) => + request( + scopedPath('/comfort/runs', { vehicleId, filters: { limit: 10 } }), + { signal }, + ), + enabled: vehicleId != null, + ...queryPolicy('operational'), + select: safeArray, + }); +} + +/** Saves the comfort config (arm + target temp + lead + ICS url). */ +export function useSaveComfortConfig() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (params: ComfortConfigRequest) => + request('/comfort/config', { + method: 'PUT', + body: JSON.stringify(params), + }), + onSuccess: (cfg) => { + invalidateAndBroadcast(qc, { queryKey: comfortKeys.next(cfg.vehicle_id) }); + success('toast.comfort.save.success', 'Comfort autopilot saved'); + }, + onError: (err) => error(err, 'toast.comfort.save.error', 'Failed to save comfort autopilot'), + }); +} + +/** One-tap precondition now at the configured target. Issues a live Tesla + * command, so it requires live mode like other actuation mutations. */ +export function usePreconditionNow() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + const { formatTemperature } = useUnits(); + return useMutation({ + mutationFn: (vehicleId: number) => + request<{ status: string; target_temp_c: number }>('/comfort/now', { + method: 'POST', + requiresLiveMode: true, + body: JSON.stringify({ vehicle_id: vehicleId }), + }), + onSuccess: (res, vehicleId) => { + invalidateAndBroadcast(qc, { queryKey: comfortKeys.runs(vehicleId) }); + success( + 'toast.comfort.now.success', + `Preconditioning to ${formatTemperature(res.target_temp_c)}`, + ); + }, + onError: (err) => error(err, 'toast.comfort.now.error', 'Failed to start preconditioning'), + }); +} diff --git a/web/src/api/hooks/useDriving.ts b/web/src/api/hooks/useDriving.ts index c8ac84cc55..8b4a55f725 100644 --- a/web/src/api/hooks/useDriving.ts +++ b/web/src/api/hooks/useDriving.ts @@ -21,6 +21,8 @@ import type { DrivingCoachData, TripPlan, TripPlanRequest, + TripConfidence, + TripConfidenceRequest, GeocodeResult, } from '@/types/driving'; import type { @@ -296,6 +298,19 @@ export function usePlanTrip() { }); } +/** Mutation to check en-route arrival confidence for remaining distance. */ +export function useTripConfidence() { + const { error } = useMutationToast(); + return useMutation({ + mutationFn: (params: TripConfidenceRequest) => + request('/trip-planner/confidence', { + method: 'POST', + body: JSON.stringify(params), + }), + onError: (err) => error(err, 'toast.trip.confidence.error', 'Failed to check arrival confidence'), + }); +} + export function useGeocodeSearch(query: string, enabled = true) { return useQuery({ queryKey: ['geocode-search', query], diff --git a/web/src/api/hooks/useEnergy.ts b/web/src/api/hooks/useEnergy.ts index bc4577a4fe..80bdfcb4b0 100644 --- a/web/src/api/hooks/useEnergy.ts +++ b/web/src/api/hooks/useEnergy.ts @@ -15,12 +15,14 @@ import type { VampireDrainStats, VampireDrainEvent, VampireDrainEventsResponse, + VampireDrainWatch, ProjectedRangeData, SleepEfficiencyData, TeslaEnergyHistoryEntry, TeslaBackupEvent, TeslaWCChargingEntry, TeslaEnergyLiveStatus, + SolarChargeAdvice, TeslaEnergySite, TeslaEnergySiteInfoResponse, TOUSettingsPayload, @@ -121,6 +123,16 @@ export function useVampireDrainEvents(vehicleId: string | null, limit = 50) { }); } +/** Fetches the watchdog evaluation: status, breach streak, and diagnosis. */ +export function useVampireDrainWatch(vehicleId: string | null, threshold = 3) { + return useQuery({ + queryKey: ['vampire-drain-watch', vehicleId, threshold], + queryFn: ({ signal }) => request(`/vampire-drain/watch?vehicle_id=${vehicleId}&threshold_pct_per_day=${threshold}`, { signal }), + enabled: vehicleId !== null, + staleTime: STALE_TIMES.STANDARD, + }); +} + export function useProjectedRange(vehicleId: string | null) { return useQuery({ queryKey: ['projected-range', vehicleId], @@ -391,6 +403,17 @@ export function useTeslaEnergyLiveStatus(siteId?: number) { }); } +/** Fetches the solar-surplus car-charging advice for an energy site. */ +export function useSolarChargeAdvice(siteId?: number) { + return useQuery({ + queryKey: ['tesla-charge-advice', siteId], + queryFn: ({ signal }) => + request(`/tesla/energy-sites/${siteId}/charge-advice`, { signal }), + enabled: !!siteId, + refetchInterval: INTERVALS.STANDARD, + }); +} + export function useTeslaEnergyLiveStatusHistory( siteId?: number, since?: string, diff --git a/web/src/api/hooks/useFleetOps.ts b/web/src/api/hooks/useFleetOps.ts index 283b45f02c..6dedfeadc6 100644 --- a/web/src/api/hooks/useFleetOps.ts +++ b/web/src/api/hooks/useFleetOps.ts @@ -19,11 +19,23 @@ export interface FleetDriver { display_name: string; reference_code: string; status: DriverStatus; + max_charge_soc?: number | null; + curfew_start?: string | null; + curfew_end?: string | null; version: number; created_at: string; updated_at: string; } +export interface DriverEvaluation { + driver_id: number; + allowed: boolean; + reasons: string[]; + charge_cap: number | null; + in_curfew: boolean; + evaluated_at: string; +} + export interface FleetCostCenter { id: number; code: string; @@ -181,7 +193,7 @@ export interface WorkOrderFilter extends ListFilter { severity?: WorkOrderSeverity; } -export type FleetDriverInput = Pick; +export type FleetDriverInput = Pick; export type FleetCostCenterInput = Pick; export type FleetAssignmentInput = Pick< FleetAssignment, @@ -319,6 +331,21 @@ export function useFleetDrivers(filter: DriverFilter = {}) { return useListQuery('drivers', fleetOpsKeys.drivers(filter), filter); } export function useFleetDriver(id?: number) { return useDetailQuery('drivers', id); } + +/** Evaluates a driver's guardrails (charge cap + curfew) at an instant. */ +export function useEvaluateFleetDriver(id?: number, chargeSoc?: number, at?: string) { + return useQuery({ + queryKey: ['fleet-ops', 'drivers', id, 'evaluate', chargeSoc, at], + queryFn: ({ signal }) => { + const params = new URLSearchParams(); + if (chargeSoc != null) params.set('charge_soc', String(chargeSoc)); + if (at) params.set('at', at); + const qs = params.toString(); + return request(`/fleet-ops/drivers/${id}/evaluate${qs ? `?${qs}` : ''}`, { signal }); + }, + enabled: !!id, + }); +} export function useCreateFleetDriver() { return useCreateMutation('drivers'); } export function useUpdateFleetDriver() { return useUpdateMutation('drivers'); } export function useDeleteFleetDriver() { return useDeleteMutation('drivers'); } diff --git a/web/src/api/hooks/useJourney.ts b/web/src/api/hooks/useJourney.ts new file mode 100644 index 0000000000..44db1204bc --- /dev/null +++ b/web/src/api/hooks/useJourney.ts @@ -0,0 +1,155 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { request } from '../client'; +import { queryPolicy } from '../queryPolicy'; +import { scopedPath } from '../scope'; +import { safeArray } from '@/lib/safeArray'; +import { useMutationToast } from './_toastHelpers'; +import { invalidateAndBroadcast } from '@/lib/queryBroadcast'; + +/** + * Journey Autopilot: trip sessions + versioned plans. Reads the backend + * routes registered in internal/api/router.go: + * + * POST /journey/sessions + * GET /journey/sessions?vehicle_id=&status=&limit= + * GET /journey/sessions/{id} + * POST /journey/sessions/{id}/start|pause|resume|complete|abort + * POST /journey/sessions/{id}/plans + * + * `request()` prepends the version prefix automatically, so the paths below + * must NOT include it. All field names are snake_case to mirror the Go JSON + * tags. Durations are SI seconds; the UI converts at render. + */ + +export type JourneyStatus = 'planned' | 'active' | 'paused' | 'completed' | 'aborted'; + +export interface JourneySession { + id: number; + vehicle_id: number; + name: string; + origin_name: string; + origin_lat: number | null; + origin_lng: number | null; + dest_name: string; + dest_lat: number | null; + dest_lng: number | null; + status: JourneyStatus; + plan_version: number; + created_at: string; + updated_at: string; + started_at: string | null; + ended_at: string | null; +} + +export interface JourneyPlanVersion { + id: number; + session_id: number; + version: number; + plan: unknown; + note: string; + created_at: string; +} + +export interface JourneyDetail { + session: JourneySession; + plans: JourneyPlanVersion[]; + next_statuses: JourneyStatus[]; +} + +export interface CreateJourneyRequest { + vehicle_id: number; + name: string; + origin_name?: string; + origin_lat?: number | null; + origin_lng?: number | null; + dest_name?: string; + dest_lat?: number | null; + dest_lng?: number | null; +} + +export const journeyKeys = { + all: ['journey'] as const, + list: (vehicleId: number | null, status: string) => + ['journey', 'sessions', vehicleId, status] as const, + detail: (id: number | null) => ['journey', 'session', id] as const, +}; + +function isValidVehicle(vehicleId: number | null | undefined): vehicleId is number { + return vehicleId != null && vehicleId > 0; +} + +/** Lists journey sessions for a vehicle, newest first. */ +export function useJourneys( + vehicleId: number | null | undefined, + status = '', + options?: { enabled?: boolean }, +) { + return useQuery({ + queryKey: journeyKeys.list(vehicleId ?? null, status), + queryFn: ({ signal }) => { + if (!isValidVehicle(vehicleId)) { + throw new Error('vehicle_id must be a positive integer'); + } + return request( + scopedPath('/journey/sessions', { + vehicleId, + filters: { status: status || null }, + }), + { signal }, + ); + }, + enabled: (options?.enabled ?? true) && isValidVehicle(vehicleId), + ...queryPolicy('operational'), + select: safeArray, + }); +} + +/** Reads one session with its plan history and reachable statuses. */ +export function useJourney(id: number | null | undefined, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: journeyKeys.detail(id ?? null), + queryFn: ({ signal }) => + request(`/journey/sessions/${id}`, { signal }), + enabled: (options?.enabled ?? true) && id != null && id > 0, + ...queryPolicy('operational'), + }); +} + +/** Plans a new journey (starts in `planned`). */ +export function useCreateJourney() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (params: CreateJourneyRequest) => + request('/journey/sessions', { + method: 'POST', + body: JSON.stringify(params), + }), + onSuccess: (session) => { + invalidateAndBroadcast(qc, { queryKey: journeyKeys.all }); + success('toast.journey.create.success', 'Journey planned', { + name: session.name, + }); + }, + onError: (err) => error(err, 'toast.journey.create.error', 'Failed to plan journey'), + }); +} + +export type JourneyTransition = 'start' | 'pause' | 'resume' | 'complete' | 'abort'; + +/** Moves a session along its status machine. */ +export function useTransitionJourney() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: ({ id, action }: { id: number; action: JourneyTransition }) => + request(`/journey/sessions/${id}/${action}`, { method: 'POST' }), + onSuccess: (session) => { + invalidateAndBroadcast(qc, { queryKey: journeyKeys.all }); + success('toast.journey.transition.success', 'Journey {{status}}', { + status: session.status, + }); + }, + onError: (err) => error(err, 'toast.journey.transition.error', 'Failed to update journey'), + }); +} diff --git a/web/src/api/hooks/useOcpp.ts b/web/src/api/hooks/useOcpp.ts new file mode 100644 index 0000000000..fc4717136c --- /dev/null +++ b/web/src/api/hooks/useOcpp.ts @@ -0,0 +1,78 @@ +import { useQuery } from '@tanstack/react-query'; +import { request } from '../client'; +import { safeArray } from '@/lib/safeArray'; +import { STALE_TIMES } from '@/lib/constants'; + +/** + * OCPP charge points + sessions recorded by cmd/ocpp-server. Reads the two + * backend routes registered in internal/api/router.go: + * + * GET /ocpp/charge-points + * GET /ocpp/sessions?charge_point_id=&limit= + * + * `request()` prepends the version prefix automatically, so the paths below + * must NOT include it. All field names are snake_case to mirror the Go JSON + * tags. + */ + +export interface OcppConnectorStatus { + connector_id: number; + status: string; + error_code: string; + info: string; + updated_at: string; +} + +export interface OcppChargePoint { + id: string; + vendor: string; + model: string; + serial_number: string; + firmware_version: string; + last_boot_at: string | null; + last_seen_at: string; + connectors: OcppConnectorStatus[]; + active_sessions: number; +} + +export interface OcppSession { + transaction_id: number; + charge_point_id: string; + connector_id: number; + started_at: string; + start_meter_wh: number; + ended_at: string | null; + end_meter_wh: number | null; + stop_reason: string; + energy_delivered_wh: number | null; +} + +export const ocppKeys = { + all: ['ocpp'] as const, + chargePoints: ['ocpp', 'charge-points'] as const, + sessions: (chargePointId: string, limit: number) => ['ocpp', 'sessions', chargePointId, limit] as const, +}; + +/** Lists every known OCPP charger with live connector statuses. */ +export function useOcppChargePoints() { + return useQuery({ + queryKey: ocppKeys.chargePoints, + queryFn: ({ signal }) => request('/ocpp/charge-points', { signal }), + staleTime: STALE_TIMES.FAST, + select: safeArray, + }); +} + +/** Lists recent OCPP charging transactions, optionally per charger. */ +export function useOcppSessions(chargePointId = '', limit = 20) { + return useQuery({ + queryKey: ocppKeys.sessions(chargePointId, limit), + queryFn: ({ signal }) => + request( + `/ocpp/sessions?charge_point_id=${encodeURIComponent(chargePointId)}&limit=${limit}`, + { signal }, + ), + staleTime: STALE_TIMES.FAST, + select: safeArray, + }); +} diff --git a/web/src/api/hooks/useOwnership.ts b/web/src/api/hooks/useOwnership.ts index 60452d9995..5bf3f73984 100644 --- a/web/src/api/hooks/useOwnership.ts +++ b/web/src/api/hooks/useOwnership.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { request } from '../client'; +import { queryPolicy } from '../queryPolicy'; import { useMutationToast } from './_toastHelpers'; import type { AssignDriveRequest, @@ -22,6 +23,7 @@ import type { CreateWarrantyRequest, DriverAttributionReport, DriverProfile, + GhostReport, GovernanceOverview, GovernanceSimulationRequest, GovernanceSimulationResponse, @@ -83,6 +85,8 @@ export const ownershipKeys = { [...ownershipKeys.all, 'driver', vehicleId, windowDays, limit, offset] as const, driverProfiles: (vehicleId: number | null) => [...ownershipKeys.all, 'driver-profiles', vehicleId] as const, + ghosts: (vehicleId: number | null, windowDays: number) => + [...ownershipKeys.all, 'ghosts', vehicleId, windowDays] as const, warranty: (vehicleId: number | null) => [...ownershipKeys.all, 'warranty', vehicleId] as const, warranties: (vehicleId: number | null) => [...ownershipKeys.all, 'warranties', vehicleId] as const, @@ -344,6 +348,26 @@ export function useDriverProfiles(vehicleId: number | null) { ); } +export function useGhostDrives(vehicleId: number | null, windowDays = 90) { + return useQuery({ + queryKey: ownershipKeys.ghosts(vehicleId, windowDays), + queryFn: ({ signal }) => { + if (!isValidVehicle(vehicleId)) { + throw new Error('vehicle_id must be a positive integer'); + } + return request( + `${DRIVER}/ghost-drives${query({ + vehicle_id: vehicleId, + window_days: windowDays, + })}`, + { signal }, + ); + }, + enabled: isValidVehicle(vehicleId), + ...queryPolicy('operational'), + }); +} + export function useCreateDriverProfile() { const client = useQueryClient(); const toast = useMutationToast(); diff --git a/web/src/api/hooks/useServiceIntelligence.ts b/web/src/api/hooks/useServiceIntelligence.ts index 21be6f3342..ff56dbff6d 100644 --- a/web/src/api/hooks/useServiceIntelligence.ts +++ b/web/src/api/hooks/useServiceIntelligence.ts @@ -1,5 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { request, SudoCanceledError } from '../client'; +import { queryPolicy } from '../queryPolicy'; +import { scopedPath } from '../scope'; import { STALE_TIMES } from '@/lib/constants'; import { useMutationToast } from './_toastHelpers'; @@ -205,6 +207,73 @@ export function useServiceIntelligence(vehicleId: number | null, refresh = false }); } +export interface WarrantyCoverage { + name: string; + expires_at: string; + days_remaining: number; + km_limit: number | null; + km_remaining: number | null; + status: 'active' | 'expiring_soon' | 'expired'; + basis: string; +} + +export interface WarrantyOutlook { + vehicle_id: number; + model: string; + model_year: number; + coverages: WarrantyCoverage[]; + assumption: string; +} + +export interface ClaimCoverage { + name: string; + status: string; + days_remaining: number; +} + +export interface ClaimDraft { + subject: string; + issue: string; + vehicle: string; + coverages: ClaimCoverage[]; + communications: string[]; + symptoms: string[]; + evidence: string[]; + ask: string; + body: string; + disclaimer: string; +} + +/** Fetches an auto-drafted service ticket for an owner-described issue. */ +export function useClaimDraft(vehicleId: number | null, issue: string | null, odometerKm?: number) { + return useQuery({ + queryKey: [...serviceIntelligenceKeys.vehicles, vehicleId, 'claim-draft', issue, odometerKm] as const, + queryFn: ({ signal }) => + request( + scopedPath(`/service-intelligence/vehicles/${vehicleId}/claim-draft`, { + filters: { issue, odometer_km: odometerKm ?? null }, + }), + { signal }, + ), + enabled: !!vehicleId && issue != null, + ...queryPolicy('historical'), + }); +} + +/** Fetches the warranty coverage countdown for a vehicle. */ +export function useWarrantyOutlook(vehicleId: number | null, odometerKm?: number) { + return useQuery({ + queryKey: [...serviceIntelligenceKeys.vehicles, vehicleId, 'warranty', odometerKm] as const, + queryFn: ({ signal }) => + request( + `/service-intelligence/vehicles/${vehicleId}/warranty${odometerKm != null ? `?odometer_km=${odometerKm}` : ''}`, + { signal }, + ), + enabled: !!vehicleId, + staleTime: STALE_TIMES.ANALYTICS, + }); +} + export function useCommunicationsCatalogStatus() { return useQuery({ queryKey: serviceIntelligenceKeys.catalog, diff --git a/web/src/api/hooks/useSharing.ts b/web/src/api/hooks/useSharing.ts index be65dd247e..5a601a08cf 100644 --- a/web/src/api/hooks/useSharing.ts +++ b/web/src/api/hooks/useSharing.ts @@ -7,12 +7,14 @@ import type { ShareToken, SharedDriveData, SharedDriveDataV1, + SharedSessionData, CreateShareRequest, CreateShareResponse, } from '@/types/sharing'; export const sharingKeys = { shares: (driveId: string) => ['shares', driveId] as const, + sessionShares: (sessionId: string) => ['session-shares', sessionId] as const, shared: (token: string) => ['shared-drive', token] as const, }; @@ -46,6 +48,49 @@ export function useShareLinks(driveId: string) { }); } +/** Creates a share link for a charging session (authenticated). */ +export function useCreateSessionShareLink(sessionId: string) { + const queryClient = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (data: CreateShareRequest) => + request(`/charging/${sessionId}/share`, { + method: 'POST', + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: sharingKeys.sessionShares(sessionId) }); + success('share.toast.created', 'Share link created'); + }, + onError: (err) => error(err, 'share.toast.createError', 'Failed to create share link'), + }); +} + +/** Lists all share links for a charging session (authenticated). */ +export function useSessionShareLinks(sessionId: string) { + return useQuery({ + queryKey: sharingKeys.sessionShares(sessionId), + queryFn: ({ signal }) => request(`/charging/${sessionId}/shares`, { signal }), + enabled: !!sessionId, + select: safeArray, + }); +} + +/** Revokes (deletes) a session share link (authenticated). */ +export function useRevokeSessionShareLink(sessionId: string) { + const queryClient = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (token: string) => + request<{ status: string }>(`/shares/${token}`, { method: 'DELETE' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: sharingKeys.sessionShares(sessionId) }); + success('share.toast.revoked', 'Share link revoked'); + }, + onError: (err) => error(err, 'share.toast.revokeError', 'Failed to revoke share link'), + }); +} + /** Revokes (deletes) a share link (authenticated). */ export function useRevokeShareLink(driveId: string) { const queryClient = useQueryClient(); @@ -62,14 +107,16 @@ export function useRevokeShareLink(driveId: string) { } /** - * Fetches shared drive data via the public endpoint. + * Fetches shared drive OR charging-session data via the public endpoint. * The share endpoint is mounted before auth middleware on the backend, - * so no authentication is required. + * so no authentication is required. Branch on `isSharedSession()` to tell + * the payloads apart. */ export function useSharedDrive(token: string) { return useQuery({ queryKey: sharingKeys.shared(token), - queryFn: ({ signal }) => request(`/share/${token}`, { signal }), + queryFn: ({ signal }) => + request(`/share/${token}`, { signal }), enabled: !!token, retry: false, staleTime: STALE_TIMES.SLOW, diff --git a/web/src/api/hooks/useStormguard.ts b/web/src/api/hooks/useStormguard.ts new file mode 100644 index 0000000000..3b7dbf1c04 --- /dev/null +++ b/web/src/api/hooks/useStormguard.ts @@ -0,0 +1,112 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { request } from '../client'; +import { queryPolicy } from '../queryPolicy'; +import { scopedPath } from '../scope'; +import { safeArray } from '@/lib/safeArray'; +import { useMutationToast } from './_toastHelpers'; +import { invalidateAndBroadcast } from '@/lib/queryBroadcast'; + +/** + * Storm Guardian: severe-weather auto-prep per vehicle. Reads the backend + * routes registered in internal/api/router.go: + * + * GET /stormguard/status?vehicle_id= + * PUT /stormguard/config + * GET /stormguard/events?vehicle_id=&limit= + * + * `request()` prepends the version prefix automatically, so the paths below + * must NOT include it. All field names are snake_case to mirror the Go JSON + * tags. + */ + +export type StormLevel = 'none' | 'watch' | 'warning'; + +export interface StormguardConfig { + vehicle_id: number; + enabled: boolean; + lat: number; + lng: number; + target_soc: number; + updated_at: string; +} + +export interface StormAssessment { + level: StormLevel; + reason: string; + starts_at: string | null; + peak_gust_ms: number; +} + +export interface StormguardStatus { + config: StormguardConfig; + assessment: StormAssessment; + current_soc?: number; +} + +export interface StormguardEvent { + id: number; + vehicle_id: number; + level: StormLevel; + reason: string; + acted: boolean; + created_at: string; +} + +export interface StormguardConfigRequest { + vehicle_id: number; + enabled: boolean; + lat: number; + lng: number; + target_soc: number; +} + +export const stormguardKeys = { + all: ['stormguard'] as const, + status: (vehicleId: number) => ['stormguard', 'status', vehicleId] as const, + events: (vehicleId: number) => ['stormguard', 'events', vehicleId] as const, +}; + +/** Live storm assessment for the stored home coordinates. */ +export function useStormguardStatus(vehicleId?: number | null) { + return useQuery({ + queryKey: stormguardKeys.status(vehicleId!), + queryFn: ({ signal }) => + request(scopedPath('/stormguard/status', { vehicleId }), { signal }), + enabled: vehicleId != null, + ...queryPolicy('operational'), + }); +} + +/** Recent assessment/action log, newest first. */ +export function useStormguardEvents(vehicleId?: number | null) { + return useQuery({ + queryKey: stormguardKeys.events(vehicleId!), + queryFn: ({ signal }) => + request( + scopedPath('/stormguard/events', { vehicleId, filters: { limit: 10 } }), + { signal }, + ), + enabled: vehicleId != null, + ...queryPolicy('operational'), + select: safeArray, + }); +} + +/** Arms/disarms the guard and stores home coords + pre-storm target. */ +export function useSaveStormguardConfig() { + const qc = useQueryClient(); + const { success, error } = useMutationToast(); + return useMutation({ + mutationFn: (params: StormguardConfigRequest) => + request('/stormguard/config', { + method: 'PUT', + body: JSON.stringify(params), + }), + onSuccess: (cfg) => { + invalidateAndBroadcast(qc, { queryKey: stormguardKeys.status(cfg.vehicle_id) }); + invalidateAndBroadcast(qc, { queryKey: stormguardKeys.events(cfg.vehicle_id) }); + success('toast.stormguard.save.success', 'Storm guard saved'); + }, + onError: (err) => error(err, 'toast.stormguard.save.error', 'Failed to save storm guard'), + }); +} diff --git a/web/src/api/hooks/useVehicleSystems.ts b/web/src/api/hooks/useVehicleSystems.ts index d4b25ad6c8..002a6e655e 100644 --- a/web/src/api/hooks/useVehicleSystems.ts +++ b/web/src/api/hooks/useVehicleSystems.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { request } from '../client'; import { safeArray } from '@/lib/safeArray'; import { INTERVALS, STALE_TIMES } from '@/lib/constants'; -import type { ClimateState, TirePressureReading, MaintenanceItem, ServiceRecord, SoftwareUpdate, SafetySnapshot } from '@/types/vehicle-systems'; +import type { ClimateState, TirePressureReading, MaintenanceItem, ServiceRecord, SoftwareUpdate, SafetySnapshot, MaintenanceForecast } from '@/types/vehicle-systems'; // MediaSnapshot must be the canonical snake_case shape that matches the Go // media handler JSON tags (now_playing_title, playback_source, audio_volume, // created_at, …). The camelCase MediaSnapshot in @/types/vehicle-systems does @@ -101,6 +101,20 @@ export function useServiceRecords() { }); } +/** Fetches the wear-based maintenance forecast (defaults to first vehicle). */ +export function useMaintenanceForecast(vehicleId?: number) { + return useQuery({ + queryKey: [...vehicleSystemsKeys.maintenance, 'forecast', vehicleId] as const, + queryFn: ({ signal }) => + request( + `/maintenance/forecast${vehicleId ? `?vehicle_id=${vehicleId}` : ''}`, + { signal }, + ), + retry: false, + staleTime: STALE_TIMES.STATIC, + }); +} + export function useSoftwareUpdates(vehicleId: string) { return useQuery({ queryKey: vehicleSystemsKeys.softwareUpdates(vehicleId), diff --git a/web/src/api/hooks/useVehicles.ts b/web/src/api/hooks/useVehicles.ts index 768f5476e8..0edca9ab32 100644 --- a/web/src/api/hooks/useVehicles.ts +++ b/web/src/api/hooks/useVehicles.ts @@ -36,8 +36,28 @@ export const vehicleKeys = { state: (id: number, asOf?: string | null) => asOf ? (['vehicle-state', id, asOf] as const) : (['vehicle-state', id] as const), positions: (id: number) => ['vehicle-positions', id] as const, + silence: (id: number) => ['vehicle-silence', id] as const, }; +export interface VehicleSilence { + vehicle_id: number; + status: 'ok' | 'quiet' | 'silent' | 'never'; + last_seen_at: string | null; + silent_for_s: number | null; + checked_at: string; + explanation: string; +} + +/** Fetches the telemetry silence watchdog status for a vehicle. */ +export function useVehicleSilence(id?: number) { + return useQuery({ + queryKey: vehicleKeys.silence(id!), + queryFn: ({ signal }) => request(`/vehicles/${id}/silence`, { signal }), + enabled: !!id, + staleTime: STALE_TIMES.STANDARD, + }); +} + /** * Append `?as_of=` to a path when the time-machine * URL parameter is set. Returns the path unchanged when the parameter is diff --git a/web/src/api/offlineCache.test.ts b/web/src/api/offlineCache.test.ts index 0c6227b8fb..2addb7beb1 100644 --- a/web/src/api/offlineCache.test.ts +++ b/web/src/api/offlineCache.test.ts @@ -47,6 +47,9 @@ describe('isOfflineUnsafeWrite', () => { ['POST', '/impersonation/start'], ['POST', '/rbac/matrix'], ['DELETE', '/vehicles/12/drivers/3'], + ['POST', '/charge-autopilot/run'], + ['POST', '/charge-planner/apply'], + ['POST', '/comfort/now'], ] it.each(destructive)('classifies %s %s as never-queueable', (method, path) => { diff --git a/web/src/api/offlineCache.ts b/web/src/api/offlineCache.ts index abf8e1b763..bb7335026c 100644 --- a/web/src/api/offlineCache.ts +++ b/web/src/api/offlineCache.ts @@ -53,6 +53,11 @@ export const OFFLINE_UNSAFE_PATTERNS: readonly RegExp[] = [ /^\/commands?(\/|$)/i, /^\/watch\/[^/]+\/command/i, /^\/guard\/(panic|config)/i, + // Smart charging actuation: applies schedules to the vehicle via + // Tesla commands (charge limits, scheduled charging start). + /^\/charge-autopilot\/run(\/|$)/i, + /^\/charge-planner\/apply(\/|$)/i, + /^\/comfort\/now(\/|$)/i, // Operator judgement encoded into the data set. /^\/data-repair(\/|$)/i, /^\/repair-cases?(\/|$)/i, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index c3e4a01a5f..caee8ed736 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1157,9 +1157,15 @@ export interface ChatMessage { created_at: string } +export interface ChatLink { + label: string + path: string +} + export interface ChatResponse { response: string session_id: string + links?: ChatLink[] | null } /** @@ -2486,6 +2492,25 @@ export interface AutomationPresetsResponse { presets: AutomationPreset[] } +export interface RoutineTemplateAction { + command: string + params?: Record | null +} + +export interface RoutineTemplate { + id: string + name: string + description: string + event: 'enter' | 'exit' + actions: RoutineTemplateAction[] +} + +export interface InstallRoutineRequest { + place_id: number + vehicle_id?: number | null + name?: string +} + export type AutomationHistoryStatus = 'running' | 'success' | 'partial' | 'failed' | 'skipped' | 'cancelled' | 'test' | 'undo' export interface AutomationHistory { diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 8b0e205d7e..d9f9d3c5e3 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -140,6 +140,7 @@ export const navSearchKeywords: Record = { '/navigation': ['route', 'directions', 'map', 'nav'], '/drives': ['drive history', 'sessions', 'trips'], '/trips': ['trip history', 'journeys', 'routes'], + '/journeys': ['journey autopilot', 'plan trip', 'live trip', 'replan'], '/trip-planner': ['plan trip', 'route planner', 'range planning'], '/arrival-reliability': ['arrival reliability', 'travel time', 'route uncertainty', 'on time'], '/destination-transitions': ['destination transitions', 'mobility graph', 'next destination'], @@ -415,6 +416,7 @@ export const navSections = [ items: [ { to: '/drives', icon: Icons.drive, label: 'Drives', color: 'text-violet-400' }, { to: '/trips', icon: Icons.trip, label: 'Trips', color: 'text-teal-400' }, + { to: '/journeys', icon: Icons.compass, label: 'Journeys', color: 'text-sky-400' }, { to: '/trip-planner', icon: Icons.mapPinned, label: 'Trip Planner', color: 'text-emerald-400' }, { to: '/navigation', icon: Icons.signpost, label: 'Navigation', color: 'text-teal-400' }, { to: '/geofences', icon: Icons.fence, label: 'Geofences', color: 'text-lime-400' }, diff --git a/web/src/features/advanced-intelligence/components/StormGuardPanel.test.tsx b/web/src/features/advanced-intelligence/components/StormGuardPanel.test.tsx new file mode 100644 index 0000000000..94bdb84bf8 --- /dev/null +++ b/web/src/features/advanced-intelligence/components/StormGuardPanel.test.tsx @@ -0,0 +1,102 @@ +/** + * StormGuardPanel — behaviour coverage. + * + * Data hooks (`useStormguardStatus` / `useStormguardEvents` / + * `useSaveStormguardConfig`) are mocked and driven per test; shared UI + * (GlassPanel, Badge, Toggle, Input, Slider, Button) is REAL so the + * render-boundary wiring is genuinely exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('@/api/hooks/useStormguard', () => ({ + useStormguardStatus: vi.fn(), + useStormguardEvents: vi.fn(), + useSaveStormguardConfig: vi.fn(), +})); + +import { + useStormguardStatus, + useStormguardEvents, + useSaveStormguardConfig, +} from '@/api/hooks/useStormguard'; +import { StormGuardPanel } from './StormGuardPanel'; + +const mockStatus = useStormguardStatus as unknown as ReturnType; +const mockEvents = useStormguardEvents as unknown as ReturnType; +const mockSave = useSaveStormguardConfig as unknown as ReturnType; + +const armedStatus = { + config: { vehicle_id: 7, enabled: true, lat: 37.7, lng: -122.4, target_soc: 95, updated_at: '' }, + assessment: { + level: 'warning', + reason: 'thunderstorm (WMO 95) forecast at Mon 18:00', + starts_at: '2026-04-01T18:00:00Z', + peak_gust_ms: 28, + }, + current_soc: 60, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockStatus.mockReturnValue({ data: armedStatus, isLoading: false, isError: false }); + mockEvents.mockReturnValue({ data: [], isLoading: false, isError: false }); + mockSave.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false, error: null }); +}); + +describe('StormGuardPanel', () => { + it('renders the live assessment with warning badge and battery state', () => { + render(); + expect(screen.getByText('Storm Guardian')).toBeInTheDocument(); + expect(screen.getByText('Storm warning')).toBeInTheDocument(); + expect(screen.getByText(/thunderstorm \(WMO 95\)/)).toBeInTheDocument(); + expect(screen.getByText(/Battery 60%/)).toBeInTheDocument(); + expect(screen.getByText(/Peak gust 28 m\/s/)).toBeInTheDocument(); + }); + + it('hydrates the form from stored config and saves edits', () => { + const mutate = vi.fn(); + mockSave.mockReturnValue({ mutate, isPending: false, isError: false, error: null }); + render(); + + expect(screen.getByLabelText('Home latitude')).toHaveProperty('value', '37.7'); + fireEvent.change(screen.getByLabelText('Home latitude'), { target: { value: '38.1' } }); + fireEvent.click(screen.getByText('Save Guard')); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toMatchObject({ + vehicle_id: 7, + enabled: true, + lat: 38.1, + lng: -122.4, + target_soc: 95, + }); + }); + + it('renders the recent activity timeline with acted markers', () => { + mockEvents.mockReturnValue({ + data: [ + { id: 1, vehicle_id: 7, level: 'warning', reason: 'thunderstorm', acted: true, created_at: '2026-04-01T12:00:00Z' }, + ], + isLoading: false, + isError: false, + }); + render(); + expect(screen.getByText('Recent activity')).toBeInTheDocument(); + expect(screen.getByText(/acted/)).toBeInTheDocument(); + }); + + it('prompts for a vehicle and surfaces save errors', () => { + const { rerender } = render(); + expect(screen.getByText('Select a vehicle to configure storm protection.')).toBeInTheDocument(); + + mockSave.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isError: true, + error: new Error('db down'), + }); + rerender(); + expect(screen.getByText('db down')).toBeInTheDocument(); + }); +}); diff --git a/web/src/features/advanced-intelligence/components/StormGuardPanel.tsx b/web/src/features/advanced-intelligence/components/StormGuardPanel.tsx new file mode 100644 index 0000000000..48e10c4a1c --- /dev/null +++ b/web/src/features/advanced-intelligence/components/StormGuardPanel.tsx @@ -0,0 +1,214 @@ +/** + * Storm Guardian panel — arm/disarm severe-weather auto-prep, set the home + * coordinates + pre-storm charge target, and show the live assessment with + * the recent action log. Mirrors AutopilotPanel structure (status header, + * config form, timeline) so both autopilots read as one product. + */ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; + +import { + GlassPanel, + Button, + Input, + Slider, + Toggle, + Badge, + PanelTitle, + Text, + Caption, + ErrorText, +} from '@/components/ui'; +import { QueryError, Skeleton } from '@/components/feedback'; +import { useDateFormat } from '@/hooks/useDateFormat'; +import { useDataState } from '@/hooks/useDataState'; +import { + useStormguardStatus, + useStormguardEvents, + useSaveStormguardConfig, + type StormLevel, +} from '@/api/hooks/useStormguard'; + +function levelVariant(level: StormLevel): 'success' | 'warning' | 'danger' | 'neutral' { + switch (level) { + case 'warning': + return 'danger'; + case 'watch': + return 'warning'; + default: + return 'success'; + } +} + +function levelLabel(t: (k: string, f: string) => string, level: StormLevel): string { + switch (level) { + case 'warning': + return t('stormguard.warning', 'Storm warning'); + case 'watch': + return t('stormguard.watch', 'Storm watch'); + default: + return t('stormguard.clear', 'Clear'); + } +} + +export function StormGuardPanel({ vehicleId }: { vehicleId?: number | null }) { + const { t } = useTranslation(); + const { formatDateTime } = useDateFormat(); + + const statusQuery = useStormguardStatus(vehicleId); + const statusState = useDataState(statusQuery); + const eventsQuery = useStormguardEvents(vehicleId); + const saveMutation = useSaveStormguardConfig(); + + const stored = statusQuery.data?.config; + const [enabled, setEnabled] = useState(false); + const [lat, setLat] = useState('37.7749'); + const [lng, setLng] = useState('-122.4194'); + const [targetSoc, setTargetSoc] = useState(90); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + if (stored && !hydrated) { + setEnabled(stored.enabled); + setLat(String(stored.lat)); + setLng(String(stored.lng)); + setTargetSoc(stored.target_soc); + setHydrated(true); + } + }, [stored, hydrated]); + + const assessment = statusQuery.data?.assessment; + const currentSoc = statusQuery.data?.current_soc; + + const handleSave = () => { + if (!vehicleId) return; + saveMutation.mutate({ + vehicle_id: vehicleId, + enabled, + lat: Number(lat), + lng: Number(lng), + target_soc: targetSoc, + }); + }; + + const saveError = + saveMutation.isError + ? (saveMutation.error as Error)?.message || t('stormguard.saveError', 'Save failed') + : ''; + + const events = eventsQuery.data ?? []; + + return ( + +
+ + + {assessment && ( + + {assessment.level === 'none' ? ( + + )} +
+ + {!vehicleId ? ( + + {t('stormguard.noVehicle', 'Select a vehicle to configure storm protection.')} + + ) : statusQuery.isLoading ? ( + + ) : statusState.fatalError || !assessment ? ( + statusState.fatalError ? ( + statusState.retry?.()} /> + ) : ( + {t('stormguard.statusError', 'Weather assessment unavailable.')} + ) + ) : ( + <> + {assessment.reason} +
+ + {t('stormguard.peakGust', 'Peak gust {{gust}} m/s', { + gust: assessment.peak_gust_ms.toFixed(0), + })} + + {currentSoc != null && ( + + {t('stormguard.currentSoc', 'Battery {{soc}}%', { soc: currentSoc })} + + )} +
+ +
+ +
+ setLat(e.target.value)} + inputMode="decimal" + /> + setLng(e.target.value)} + inputMode="decimal" + /> +
+ `${v}%`} + /> + + {saveError && {saveError}} +
+ + {events.length > 0 && ( +
+ + {t('stormguard.recent', 'Recent activity')} + +
    + {events.slice(0, 5).map((e) => ( +
  • + + + {levelLabel(t, e.level)} + + {e.reason} + + + {formatDateTime(e.created_at)} + {e.acted && ` · ${t('stormguard.acted', 'acted')}`} + +
  • + ))} +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/features/advanced-intelligence/components/index.ts b/web/src/features/advanced-intelligence/components/index.ts index 42f93287b2..f0e5863ebd 100644 --- a/web/src/features/advanced-intelligence/components/index.ts +++ b/web/src/features/advanced-intelligence/components/index.ts @@ -2,4 +2,5 @@ export { EvidencePanel } from './EvidencePanel'; export { InsightPanel } from './InsightPanel'; export { MutationError } from './MutationError'; export { SiNumberInput } from './SiNumberInput'; +export { StormGuardPanel } from './StormGuardPanel'; export { TwinScenarioForm } from './TwinScenarioForm'; diff --git a/web/src/features/advanced-intelligence/pages/AdvancedIntelligencePages.test.tsx b/web/src/features/advanced-intelligence/pages/AdvancedIntelligencePages.test.tsx index 2f74333ca3..57deca5826 100644 --- a/web/src/features/advanced-intelligence/pages/AdvancedIntelligencePages.test.tsx +++ b/web/src/features/advanced-intelligence/pages/AdvancedIntelligencePages.test.tsx @@ -66,6 +66,14 @@ vi.mock('@/hooks/useSelectedVehicle', () => ({ }), })); +// StormGuardPanel (embedded in EmergencyResiliencePage) stays idle: its own +// contract tests cover behaviour; here it must only not fire live queries. +vi.mock('@/api/hooks/useStormguard', () => ({ + useStormguardStatus: () => ({ data: undefined, isLoading: true, isError: false }), + useStormguardEvents: () => ({ data: [], isLoading: false, isError: false }), + useSaveStormguardConfig: () => ({ mutate: vi.fn(), isPending: false, isError: false, error: null }), +})); + vi.mock('@/hooks/useUnits', () => ({ useUnits: () => ({ unitPrefs: { diff --git a/web/src/features/advanced-intelligence/pages/EmergencyResiliencePage.tsx b/web/src/features/advanced-intelligence/pages/EmergencyResiliencePage.tsx index 543fac1395..591fb2b1bf 100644 --- a/web/src/features/advanced-intelligence/pages/EmergencyResiliencePage.tsx +++ b/web/src/features/advanced-intelligence/pages/EmergencyResiliencePage.tsx @@ -19,7 +19,7 @@ import { convertDurationFromSI, convertEnergyFromSI, SI, } from '@/lib/unitConversion'; import type { ResiliencePlanRequest } from '@/types/advancedIntelligence'; -import { EvidencePanel, InsightPanel, MutationError, SiNumberInput } from '../components'; +import { EvidencePanel, InsightPanel, MutationError, SiNumberInput, StormGuardPanel } from '../components'; type ResilienceForm = Omit; @@ -72,6 +72,10 @@ export default function EmergencyResiliencePage() { )} + + + +
diff --git a/web/src/features/analytics/components/true-cost/TrueCostFixedLedger.test.tsx b/web/src/features/analytics/components/true-cost/TrueCostFixedLedger.test.tsx new file mode 100644 index 0000000000..59df02b903 --- /dev/null +++ b/web/src/features/analytics/components/true-cost/TrueCostFixedLedger.test.tsx @@ -0,0 +1,100 @@ +/** + * TrueCostFixedLedger — totals, add flow, delete flow. + * + * Ledger hooks are mocked and driven per test; GlassPanel/DataTable/ + * ConfirmDialog render for real so the wiring is exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { TcoLedgerResponse } from '@/types/analytics'; + +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +vi.mock('@/hooks/useFormatting', () => ({ + useFormatting: () => ({ + formatCurrency: (amount: number, decimals = 2) => `$${Number(amount ?? 0).toFixed(decimals)}`, + }), +})); + +vi.mock('@/api/hooks/useAnalytics', () => ({ + useTcoLedger: vi.fn(), + useAddTcoLedgerEntry: vi.fn(), + useDeleteTcoLedgerEntry: vi.fn(), +})); + +import { useTcoLedger, useAddTcoLedgerEntry, useDeleteTcoLedgerEntry } from '@/api/hooks/useAnalytics'; +import { TrueCostFixedLedger } from './TrueCostFixedLedger'; + +const mockList = useTcoLedger as unknown as ReturnType; +const mockAdd = useAddTcoLedgerEntry as unknown as ReturnType; +const mockDelete = useDeleteTcoLedgerEntry as unknown as ReturnType; + +const ledger: TcoLedgerResponse = { + vehicle_id: 3, + entries: [ + { id: 1, vehicle_id: 3, category: 'insurance', amount: 500, currency: 'USD', incurred_on: '2026-01-01', note: '', created_at: '' }, + { id: 2, vehicle_id: 3, category: 'tires', amount: 800, currency: 'USD', incurred_on: '2026-01-10', note: 'winter set', created_at: '' }, + ], + totals: { by_category: { insurance: 500, tires: 800 }, grand_total: 1300, entries: 2 }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockList.mockReturnValue({ data: ledger, isLoading: false, isError: false, error: null, refetch: vi.fn() }); + mockAdd.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false, error: null }); + mockDelete.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false, error: null }); +}); + +describe('TrueCostFixedLedger', () => { + it('shows the all-in total combining charging and fixed costs', () => { + render(); + expect(screen.getByText('All-in: $2000.00 ($0.20/km)')).toBeTruthy(); + expect(screen.getByText('winter set')).toBeTruthy(); + }); + + it('blocks submit with an empty amount and submits once filled', () => { + const mutate = vi.fn(); + mockAdd.mockReturnValue({ mutate, isPending: false, isError: false, error: null }); + render(); + expect(screen.getByText('Record cost').closest('button')).toHaveProperty('disabled', true); + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '120' } }); + fireEvent.click(screen.getByText('Record cost')); + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toMatchObject({ vehicle_id: 3, amount: 120 }); + }); + + it('deletes through the confirm dialog', () => { + const mutate = vi.fn(); + mockDelete.mockReturnValue({ mutate, isPending: false, isError: false, error: null }); + render(); + fireEvent.click(screen.getAllByLabelText('Delete entry')[0]); + fireEvent.click(screen.getByText('Delete')); + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toMatchObject({ vehicleId: 3, id: 1 }); + }); +}); diff --git a/web/src/features/analytics/components/true-cost/TrueCostFixedLedger.tsx b/web/src/features/analytics/components/true-cost/TrueCostFixedLedger.tsx new file mode 100644 index 0000000000..9625fda766 --- /dev/null +++ b/web/src/features/analytics/components/true-cost/TrueCostFixedLedger.tsx @@ -0,0 +1,248 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Wallet, Trash2 } from 'lucide-react'; + +import { + GlassPanel, + PanelTitle, + Text, + Button, + Select, + Input, + DataTable, + ConfirmDialog, + ErrorText, + type Column, +} from '@/components/ui'; +import { Skeleton, QueryError } from '@/components/feedback'; +import { useFormatting } from '@/hooks/useFormatting'; +import { fmtNumber } from '@/lib/numberFormat'; +import { + useTcoLedger, + useAddTcoLedgerEntry, + useDeleteTcoLedgerEntry, +} from '@/api/hooks/useAnalytics'; +import type { TcoLedgerCategory, TcoLedgerEntry } from '@/types/analytics'; + +interface TrueCostFixedLedgerProps { + vehicleId?: number; + totalKm: number; + totalChargingCost: number; +} + +const CATEGORIES: TcoLedgerCategory[] = [ + 'payment', + 'insurance', + 'maintenance', + 'service', + 'tires', + 'accessories', + 'depreciation', + 'other', +]; + +/** + * Fixed-cost ledger: owner-recorded payments, insurance, service, tires, + * and depreciation that turn the fuel-only TCO into an all-in $/km. + * Same GlassPanel + DataTable idiom as the Smart Charge plan history. + */ +export function TrueCostFixedLedger({ vehicleId, totalKm, totalChargingCost }: TrueCostFixedLedgerProps) { + const { t } = useTranslation(); + const { formatCurrency } = useFormatting(); + + const ledgerQuery = useTcoLedger(vehicleId); + const { data, isLoading, isError, error, refetch } = ledgerQuery; + const addMutation = useAddTcoLedgerEntry(); + const deleteMutation = useDeleteTcoLedgerEntry(); + + const [category, setCategory] = useState('insurance'); + const [amount, setAmount] = useState(''); + const [incurredOn, setIncurredOn] = useState(() => new Date().toISOString().slice(0, 10)); + const [note, setNote] = useState(''); + const [pendingDelete, setPendingDelete] = useState(null); + + const entries = data?.entries ?? []; + const totals = data?.totals; + const allInCost = totalChargingCost + (totals?.grand_total ?? 0); + const allInPerKm = totalKm > 0 ? allInCost / totalKm : null; + + const columns = useMemo[]>( + () => [ + { + key: 'incurred_on', + header: t('tco.ledger.date', 'Date'), + sortable: true, + render: (e) => {e.incurred_on}, + }, + { + key: 'category', + header: t('tco.ledger.category', 'Category'), + sortable: true, + render: (e) => {e.category}, + }, + { + key: 'amount', + header: t('tco.ledger.amount', 'Amount'), + align: 'right', + sortable: true, + render: (e) => ( + + {formatCurrency(e.amount)} + + ), + }, + { + key: 'note', + header: t('tco.ledger.note', 'Note'), + render: (e) => ( + + {e.note || '—'} + + ), + }, + { + key: 'actions', + header: '', + align: 'right', + render: (e) => ( + +
+ + {addMutation.isError && ( + + {(addMutation.error as Error)?.message || t('tco.ledger.addError', 'Failed to record cost')} + + )} + + {t('tco.ledger.hint', '{{count}} entries · {{total}} fixed · {{km}} km lifetime', { + count: totals?.entries ?? 0, + total: formatCurrency(totals?.grand_total ?? 0), + km: fmtNumber(totalKm, 0), + })} + + + )} + + setPendingDelete(null)} + /> + + ); +} diff --git a/web/src/features/analytics/components/true-cost/index.ts b/web/src/features/analytics/components/true-cost/index.ts index e244e9cb8e..7d9e6e2968 100644 --- a/web/src/features/analytics/components/true-cost/index.ts +++ b/web/src/features/analytics/components/true-cost/index.ts @@ -5,6 +5,7 @@ export { TrueCostBreakEven } from './TrueCostBreakEven'; export { TrueCostCumulativeChart } from './TrueCostCumulativeChart'; export { TrueCostEnergyCostTrend } from './TrueCostEnergyCostTrend'; export { TrueCostEvidenceLedger } from './TrueCostEvidenceLedger'; +export { TrueCostFixedLedger } from './TrueCostFixedLedger'; export { TrueCostMethodology } from './TrueCostMethodology'; export { TrueCostMonthlyCostChart } from './TrueCostMonthlyCostChart'; export { TrueCostMonthlyDeltaChart } from './TrueCostMonthlyDeltaChart'; diff --git a/web/src/features/analytics/pages/TrueCostPage.tsx b/web/src/features/analytics/pages/TrueCostPage.tsx index 6e1a0c42fd..e4870c9e30 100644 --- a/web/src/features/analytics/pages/TrueCostPage.tsx +++ b/web/src/features/analytics/pages/TrueCostPage.tsx @@ -17,6 +17,7 @@ import { TrueCostCumulativeChart, TrueCostEnergyCostTrend, TrueCostEvidenceLedger, + TrueCostFixedLedger, TrueCostMethodology, TrueCostMonthlyCostChart, TrueCostMonthlyDeltaChart, @@ -94,6 +95,13 @@ export default function TrueCostPage() { + + +
diff --git a/web/src/features/automations/components/ComfortPanel.test.tsx b/web/src/features/automations/components/ComfortPanel.test.tsx new file mode 100644 index 0000000000..1a1ed91179 --- /dev/null +++ b/web/src/features/automations/components/ComfortPanel.test.tsx @@ -0,0 +1,118 @@ +/** + * ComfortPanel — behaviour coverage. + * + * Data hooks (`useComfortNext` / `useComfortRuns` / `useSaveComfortConfig` + * / `usePreconditionNow`) plus `useSelectedVehicle` are mocked and driven + * per test; shared UI (GlassPanel, Badge, Toggle, Input, Slider, Button) + * is REAL so the render-boundary wiring is genuinely exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('@/api/hooks/useComfort', () => ({ + useComfortNext: vi.fn(), + useComfortRuns: vi.fn(), + useSaveComfortConfig: vi.fn(), + usePreconditionNow: vi.fn(), +})); +vi.mock('@/hooks/useSelectedVehicle', () => ({ + useSelectedVehicle: vi.fn(), +})); + +import { + useComfortNext, + useComfortRuns, + useSaveComfortConfig, + usePreconditionNow, +} from '@/api/hooks/useComfort'; +import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; +import { ComfortPanel } from './ComfortPanel'; + +const mockNext = useComfortNext as unknown as ReturnType; +const mockRuns = useComfortRuns as unknown as ReturnType; +const mockSave = useSaveComfortConfig as unknown as ReturnType; +const mockNow = usePreconditionNow as unknown as ReturnType; +const mockVehicle = useSelectedVehicle as unknown as ReturnType; + +const armedNext = { + config: { + vehicle_id: 7, enabled: true, target_temp_c: 22, lead_minutes: 30, + ics_url: 'https://x/y.ics', updated_at: '', + }, + event: { + uid: 'a', title: 'Dentist', location: '123 Main', + starts_at: '2026-04-01T15:00:00Z', all_day: false, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockVehicle.mockReturnValue({ vehicleId: 7 }); + mockNext.mockReturnValue({ data: armedNext, isLoading: false, isError: false }); + mockRuns.mockReturnValue({ data: [], isLoading: false, isError: false }); + mockSave.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false, error: null }); + mockNow.mockReturnValue({ mutate: vi.fn(), isPending: false, isError: false, error: null }); +}); + +describe('ComfortPanel', () => { + it('renders the next offsite event with armed badge', () => { + render(); + expect(screen.getByText('Cabin Comfort Autopilot')).toBeInTheDocument(); + expect(screen.getByText('Armed')).toBeInTheDocument(); + expect(screen.getByText(/Dentist/)).toBeInTheDocument(); + expect(screen.getByText(/123 Main/)).toBeInTheDocument(); + }); + + it('hydrates the form and saves edits', () => { + const mutate = vi.fn(); + mockSave.mockReturnValue({ mutate, isPending: false, isError: false, error: null }); + render(); + + expect(screen.getByLabelText('Calendar subscription URL (ICS)')).toHaveProperty( + 'value', 'https://x/y.ics', + ); + fireEvent.change(screen.getByLabelText('Calendar subscription URL (ICS)'), { + target: { value: 'https://x/z.ics' }, + }); + fireEvent.click(screen.getByText('Save Autopilot')); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toMatchObject({ + vehicle_id: 7, + enabled: true, + target_temp_c: 22, + lead_minutes: 30, + ics_url: 'https://x/z.ics', + }); + }); + + it('preconditions on demand', () => { + const mutate = vi.fn(); + mockNow.mockReturnValue({ mutate, isPending: false, isError: false, error: null }); + render(); + fireEvent.click(screen.getByText('Precondition now')); + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toBe(7); + }); + + it('prompts for a feed, a vehicle, and surfaces errors', () => { + mockNext.mockReturnValue({ + data: { config: { ...armedNext.config, ics_url: '' }, event: undefined }, + isLoading: false, + isError: false, + }); + const { rerender } = render(); + expect(screen.getByText('Add a calendar subscription to watch for events.')).toBeInTheDocument(); + + mockVehicle.mockReturnValue({ vehicleId: null }); + rerender(); + expect(screen.getByText('Select a vehicle to configure cabin comfort.')).toBeInTheDocument(); + + mockVehicle.mockReturnValue({ vehicleId: 7 }); + mockSave.mockReturnValue({ + mutate: vi.fn(), isPending: false, isError: true, error: new Error('db down'), + }); + rerender(); + expect(screen.getByText('db down')).toBeInTheDocument(); + }); +}); diff --git a/web/src/features/automations/components/ComfortPanel.tsx b/web/src/features/automations/components/ComfortPanel.tsx new file mode 100644 index 0000000000..a3e53c701c --- /dev/null +++ b/web/src/features/automations/components/ComfortPanel.tsx @@ -0,0 +1,213 @@ +/** + * Cabin Comfort panel — arm calendar-aware preconditioning, set the target + * temperature + lead time + ICS subscription, precondition now on demand, + * and review recent runs. Mirrors StormGuardPanel structure so the two + * autopilots read as one product. + */ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; + +import { + GlassPanel, + Button, + Input, + Slider, + Toggle, + Badge, + PanelTitle, + Text, + Caption, + ErrorText, +} from '@/components/ui'; +import { QueryError, Skeleton } from '@/components/feedback'; +import { useDateFormat } from '@/hooks/useDateFormat'; +import { useDataState } from '@/hooks/useDataState'; +import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; +import { useUnits } from '@/hooks/useUnits'; +import { + useComfortNext, + useComfortRuns, + useSaveComfortConfig, + usePreconditionNow, +} from '@/api/hooks/useComfort'; + +export function ComfortPanel() { + const { t } = useTranslation(); + const { formatDateTime } = useDateFormat(); + const { formatTemperature } = useUnits(); + const { vehicleId } = useSelectedVehicle(); + + const nextQuery = useComfortNext(vehicleId); + const nextState = useDataState(nextQuery); + const runsQuery = useComfortRuns(vehicleId); + const saveMutation = useSaveComfortConfig(); + const nowMutation = usePreconditionNow(); + + const stored = nextQuery.data?.config; + const [enabled, setEnabled] = useState(false); + const [targetTemp, setTargetTemp] = useState(21); + const [leadMinutes, setLeadMinutes] = useState(20); + const [icsUrl, setIcsUrl] = useState(''); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + if (stored && !hydrated) { + setEnabled(stored.enabled); + setTargetTemp(stored.target_temp_c); + setLeadMinutes(stored.lead_minutes); + setIcsUrl(stored.ics_url); + setHydrated(true); + } + }, [stored, hydrated]); + + // When the selected vehicle changes, re-hydrate from its config. + useEffect(() => { + setHydrated(false); + }, [vehicleId]); + + const nextEvent = nextQuery.data?.event; + const runs = runsQuery.data ?? []; + + const handleSave = () => { + if (vehicleId == null) return; + saveMutation.mutate({ + vehicle_id: vehicleId, + enabled, + target_temp_c: targetTemp, + lead_minutes: leadMinutes, + ics_url: icsUrl.trim(), + }); + }; + + const saveError = + saveMutation.isError + ? (saveMutation.error as Error)?.message || t('comfort.saveError', 'Save failed') + : ''; + const nowError = + nowMutation.isError + ? (nowMutation.error as Error)?.message || t('comfort.nowError', 'Precondition failed') + : ''; + + return ( + +
+ + + {stored && ( + + {enabled + ? t('comfort.armed', 'Armed') + : t('comfort.disarmed', 'Off')} + + )} +
+ + {vehicleId == null ? ( + + {t('comfort.noVehicle', 'Select a vehicle to configure cabin comfort.')} + + ) : nextQuery.isLoading ? ( + + ) : nextState.fatalError ? ( + nextState.retry?.()} /> + ) : ( + <> + {nextEvent ? ( +
+
+ ) : ( + + {stored?.ics_url + ? t('comfort.noUpcoming', 'No offsite events inside the lead window.') + : t('comfort.noFeed', 'Add a calendar subscription to watch for events.')} + + )} + +
+ + setIcsUrl(e.target.value)} + placeholder="https://calendar.example.com/feed.ics" + inputMode="url" + /> + formatTemperature(v)} + /> + `${v} min`} + /> +
+ + +
+ {saveError && {saveError}} + {nowError && {nowError}} +
+ + {runs.length > 0 && ( +
+ + {t('comfort.recent', 'Recent runs')} + +
    + {runs.slice(0, 5).map((r) => ( +
  • + + {r.event_title || t('comfort.untitled', 'Untitled event')} + + + {formatDateTime(r.acted_at)} + +
  • + ))} +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/features/automations/components/RoutineWizard.test.tsx b/web/src/features/automations/components/RoutineWizard.test.tsx new file mode 100644 index 0000000000..1ff5a580a1 --- /dev/null +++ b/web/src/features/automations/components/RoutineWizard.test.tsx @@ -0,0 +1,82 @@ +/** + * RoutineWizard — place picker gates install; install posts template + place. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { RoutineTemplate } from '@/api/types'; + +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +vi.mock('@/api/hooks/useAutomations', () => ({ + useRoutineTemplates: vi.fn(), + useInstallRoutine: vi.fn(), +})); +vi.mock('@/api/hooks/useLocations', () => ({ useGeofencesFull: vi.fn() })); + +import { useRoutineTemplates, useInstallRoutine } from '@/api/hooks/useAutomations'; +import { useGeofencesFull } from '@/api/hooks/useLocations'; +import { RoutineWizard } from './RoutineWizard'; + +const mockTemplates = useRoutineTemplates as unknown as ReturnType; +const mockInstall = useInstallRoutine as unknown as ReturnType; +const mockGeofences = useGeofencesFull as unknown as ReturnType; + +const routines: RoutineTemplate[] = [ + { id: 'arrive_home', name: 'Arrive Home', description: 'Sentry off + lock.', event: 'enter', actions: [{ command: 'sentry_off' }, { command: 'lock' }] }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + mockTemplates.mockReturnValue({ data: routines, isLoading: false, isError: false, error: null, refetch: vi.fn() }); + mockGeofences.mockReturnValue({ + data: [{ id: 7, name: 'Home', enabled: true, archived_at: null }], + }); + mockInstall.mockReturnValue({ mutate: vi.fn(), isPending: false }); +}); + +describe('RoutineWizard', () => { + it('disables install until a place is chosen, then posts template + place', () => { + const mutate = vi.fn(); + mockInstall.mockReturnValue({ mutate, isPending: false }); + render(); + expect(screen.getByText('Arrive Home')).toBeTruthy(); + const installBtn = screen.getByRole('button', { name: 'Install Arrive Home' }); + expect(installBtn).toHaveProperty('disabled', true); + + fireEvent.change(screen.getByLabelText('Place'), { target: { value: '7' } }); + fireEvent.click(screen.getByRole('button', { name: 'Install Arrive Home' })); + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toMatchObject({ id: 'arrive_home', place_id: 7 }); + }); + + it('prompts to create a place when none exist', () => { + mockGeofences.mockReturnValue({ data: [] }); + render(); + expect(screen.getByText('Create a geofence place first to install routines.')).toBeTruthy(); + }); +}); diff --git a/web/src/features/automations/components/RoutineWizard.tsx b/web/src/features/automations/components/RoutineWizard.tsx new file mode 100644 index 0000000000..a487766b30 --- /dev/null +++ b/web/src/features/automations/components/RoutineWizard.tsx @@ -0,0 +1,150 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; + +import { GlassPanel, Button as UiButton, Badge, Text, Select, PanelTitle } from '@/components/ui'; +import { EmptyState, Skeleton, QueryError } from '@/components/feedback'; +import { useRoutineTemplates, useInstallRoutine } from '@/api/hooks/useAutomations'; +import { useGeofencesFull } from '@/api/hooks/useLocations'; +import type { RoutineTemplate } from '@/api/types'; + +function RoutineCard({ + routine, + placeId, + placeName, + disabled, +}: { + routine: RoutineTemplate; + placeId: number | null; + placeName: string; + disabled?: boolean; +}) { + const { t } = useTranslation(); + const install = useInstallRoutine(); + + return ( + +
+
+
+
+ + {routine.name} + + + {routine.event === 'enter' + ? t('automations.routines.onEnter', 'On geofence enter') + : t('automations.routines.onExit', 'On geofence exit')} + +
+ + {t('automations.routines.actionCount', '{{count}} actions', { count: routine.actions.length })} + +
+ + + {routine.description} + + + + placeId != null && + install.mutate({ id: routine.id, place_id: placeId, name: `${routine.name} — ${placeName}` }) + } + aria-label={t('automations.routines.installNamed', 'Install {{name}}', { name: routine.name })} + className="mt-1 w-full" + > + +
+ ); +} + +/** + * Geofence routine wizard: pick a place, one-click install arrival/departure + * routines. Rendered above the static preset grid in PresetGallery. + */ +export function RoutineWizard({ actionsDisabled }: { actionsDisabled?: boolean }) { + const { t } = useTranslation(); + const { data: routines, isLoading, isError, error, refetch } = useRoutineTemplates(); + const { data: geofences } = useGeofencesFull(); + const [placeId, setPlaceId] = useState(''); + + const places = useMemo( + () => (geofences ?? []).filter((g) => g.enabled && !g.archived_at), + [geofences], + ); + const selectedPlace = places.find((g) => String(g.id) === placeId) ?? null; + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + ))} +
+ ); + } + + if (isError) { + return ( + refetch()} + resourceName={t('automations.routines.resource', 'Geofence routines')} + /> + ); + } + + if ((routines ?? []).length === 0) return null; + + return ( +
+
+ + +
+ setReadyBy(e.target.value)} + /> + `${n}%`} + min={0} + max={100} + step={1} + value={currentSoc} + onChange={setCurrentSoc} + /> + `${n}%`} + min={20} + max={100} + step={5} + value={targetSoc} + onChange={setTargetSoc} + /> + `${n}%`} + min={50} + max={100} + step={5} + value={dailyCap} + onChange={setDailyCap} + /> + setMaxAmps(Number(e.target.value))} + /> +
+ + +
+
+ + + +
+ {saveError && {saveError}} + {runError && {runError}} + {runResult && ( + + + )} + {!vehicleId && ( + + {t('autopilot.selectVehicle', 'Select a vehicle to configure autopilot.')} + + )} +
+ + {/* Next run + savings */} +
+
+
+ + {previewMutation.isPending ? ( + + ) : previewError ? ( + {previewError} + ) : !preview ? ( + } + message={t( + 'autopilot.runToPreview', + 'Save your settings, then preview the next automatic charge window.', + )} + /> + ) : ( + <> + {preview.capped_by_health_guardrail && ( + + + )} + + {preview.explanation} + + + {t('autopilot.energyNeeded', '{{kwh}} kWh · ~{{hours}}h · {{tier}}', { + kwh: fmtNumber(preview.kwh_needed ?? 0, 1), + hours: fmtNumber(preview.estimated_duration_hours ?? 0, 1), + tier: preview.window.rate_tier, + })} + + + + )} + +
+ + {t('autopilot.realized', 'Realized savings')} + + + {savings + ? t('autopilot.realizedValue', '{{total}} across {{runs}} runs', { + total: formatCurrency(savings.total_savings ?? 0), + runs: savings.runs ?? 0, + }) + : '—'} + +
+
+
+ )} + + ); +} diff --git a/web/src/features/charging/components/ChargePointsPanel.test.tsx b/web/src/features/charging/components/ChargePointsPanel.test.tsx new file mode 100644 index 0000000000..55b01b689a --- /dev/null +++ b/web/src/features/charging/components/ChargePointsPanel.test.tsx @@ -0,0 +1,91 @@ +/** + * ChargePointsPanel — behaviour coverage. + * + * Data hooks (`useOcppChargePoints` / `useOcppSessions`) are mocked and + * driven per test; shared UI (GlassPanel, Badge, QueryError, EmptyState) + * is REAL so the render-boundary wiring is genuinely exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('@/api/hooks/useOcpp', () => ({ + useOcppChargePoints: vi.fn(), + useOcppSessions: vi.fn(), +})); + +import { useOcppChargePoints, useOcppSessions } from '@/api/hooks/useOcpp'; +import type { OcppChargePoint, OcppSession } from '@/api/hooks/useOcpp'; +import { ChargePointsPanel } from './ChargePointsPanel'; + +const mockPoints = useOcppChargePoints as unknown as ReturnType; +const mockSessions = useOcppSessions as unknown as ReturnType; + +const chargePoint: OcppChargePoint = { + id: 'wallbox-1', + vendor: 'Wallbox', + model: 'Pulsar Plus', + serial_number: 'WB123', + firmware_version: '5.1', + last_boot_at: '2026-03-01T10:00:00Z', + last_seen_at: '2026-03-01T12:00:00Z', + connectors: [ + { connector_id: 1, status: 'Charging', error_code: 'NoError', info: '', updated_at: '2026-03-01T12:00:00Z' }, + { connector_id: 2, status: 'Available', error_code: 'NoError', info: '', updated_at: '2026-03-01T12:00:00Z' }, + ], + active_sessions: 1, +}; + +const session: OcppSession = { + transaction_id: 42, + charge_point_id: 'wallbox-1', + connector_id: 1, + started_at: '2026-03-01T11:00:00Z', + start_meter_wh: 1000, + ended_at: '2026-03-01T12:00:00Z', + end_meter_wh: 8500, + stop_reason: 'EVDisconnected', + energy_delivered_wh: 7500, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockPoints.mockReturnValue({ data: [chargePoint], isLoading: false, isError: false, error: null, refetch: vi.fn() }); + mockSessions.mockReturnValue({ data: [session], isLoading: false, isError: false, error: null, refetch: vi.fn() }); +}); + +describe('ChargePointsPanel', () => { + it('renders charger identity with per-connector status badges', () => { + render(); + expect(screen.getByText('OCPP Charge Points')).toBeInTheDocument(); + expect(screen.getByText('Wallbox Pulsar Plus')).toBeInTheDocument(); + expect(screen.getByText('#1 Charging')).toBeInTheDocument(); + expect(screen.getByText('#2 Available')).toBeInTheDocument(); + expect(screen.getByText('1 active')).toBeInTheDocument(); + }); + + it('renders recent sessions with delivered energy', () => { + render(); + expect(screen.getByText('Recent sessions')).toBeInTheDocument(); + expect(screen.getByText(/wallbox-1 · #42/)).toBeInTheDocument(); + }); + + it('renders an empty state when no charger has reported', () => { + mockPoints.mockReturnValue({ data: [], isLoading: false, isError: false, error: null, refetch: vi.fn() }); + mockSessions.mockReturnValue({ data: [], isLoading: false, isError: false, error: null, refetch: vi.fn() }); + render(); + expect(screen.getByText(/No OCPP chargers reporting yet/)).toBeInTheDocument(); + expect(screen.queryByText('Recent sessions')).not.toBeInTheDocument(); + }); + + it('surfaces query errors with retry', () => { + mockPoints.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('db down'), + refetch: vi.fn(), + }); + render(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); +}); diff --git a/web/src/features/charging/components/ChargePointsPanel.tsx b/web/src/features/charging/components/ChargePointsPanel.tsx new file mode 100644 index 0000000000..595e8b6fd5 --- /dev/null +++ b/web/src/features/charging/components/ChargePointsPanel.tsx @@ -0,0 +1,130 @@ +/** + * OCPP charge points — operator view over the non-Tesla chargers reporting + * to cmd/ocpp-server. Lists each charger with live connector statuses plus + * recent charging transactions. Rendered on SmartChargePage so mixed-fleet + * charging lives next to Tesla smart charging, not in a separate silo. + */ +import { useTranslation } from 'react-i18next'; +import { PlugZap } from 'lucide-react'; + +import { GlassPanel, PanelTitle, Text, Badge, Caption } from '@/components/ui'; +import { Skeleton, EmptyState, QueryError } from '@/components/feedback'; +import { useDateFormat } from '@/hooks/useDateFormat'; +import { useUnits } from '@/hooks/useUnits'; +import { useOcppChargePoints, useOcppSessions } from '@/api/hooks/useOcpp'; + +type BadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'neutral'; + +function statusVariant(status: string): BadgeVariant { + switch (status) { + case 'Charging': + return 'success'; + case 'Preparing': + case 'SuspendedEV': + case 'SuspendedEVSE': + return 'info'; + case 'Finishing': + case 'Reserved': + return 'warning'; + case 'Faulted': + case 'Unavailable': + return 'danger'; + default: + return 'neutral'; + } +} + +export function ChargePointsPanel() { + const { t } = useTranslation(); + const { formatDateTime } = useDateFormat(); + const { formatEnergy } = useUnits(); + + const pointsQuery = useOcppChargePoints(); + const sessionsQuery = useOcppSessions('', 10); + + const points = pointsQuery.data ?? []; + const sessions = sessionsQuery.data ?? []; + + return ( + + + + + {pointsQuery.isLoading ? ( + + ) : pointsQuery.isError ? ( + pointsQuery.refetch()} /> + ) : points.length === 0 ? ( + } + message={t( + 'ocpp.noChargePoints', + 'No OCPP chargers reporting yet. Point a charger at the OCPP server to see it here.', + )} + /> + ) : ( +
    + {points.map((cp) => ( +
  • +
    + + {cp.vendor || cp.model ? `${cp.vendor} ${cp.model}`.trim() : cp.id} + + + {t('ocpp.lastSeen', 'last seen {{when}}', { + when: formatDateTime(cp.last_seen_at), + })} + +
    +
    + {(cp.connectors ?? []).map((c) => ( + + {t('ocpp.connector', '#{{id}} {{status}}', { + id: c.connector_id, + status: c.status, + })} + + ))} + {cp.active_sessions > 0 && ( + + {t('ocpp.activeSessions', '{{count}} active', { count: cp.active_sessions })} + + )} +
    +
  • + ))} +
+ )} + + {sessions.length > 0 && ( +
+ + {t('ocpp.recentSessions', 'Recent sessions')} + +
    + {sessions.map((s) => ( +
  • + + {s.charge_point_id} · #{s.transaction_id} + + + {s.energy_delivered_wh != null + ? formatEnergy(s.energy_delivered_wh) + : t('ocpp.inProgress', 'in progress')} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/web/src/features/charging/components/ChargeQueuePlanner.test.tsx b/web/src/features/charging/components/ChargeQueuePlanner.test.tsx new file mode 100644 index 0000000000..03eb0cf171 --- /dev/null +++ b/web/src/features/charging/components/ChargeQueuePlanner.test.tsx @@ -0,0 +1,100 @@ +/** + * ChargeQueuePlanner — posts per-vehicle rows; renders ordered slots. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { QueueAdvice } from '@/types/charging'; + +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatTime: (v: unknown) => (v == null ? '—' : new Date(v as string).toISOString().slice(11, 16)), + }), +})); + +vi.mock('@/api/hooks/useVehicles', () => ({ useVehicles: vi.fn() })); +vi.mock('@/api/hooks/useCharging', () => ({ useAdviseChargeQueue: vi.fn() })); + +import { useVehicles } from '@/api/hooks/useVehicles'; +import { useAdviseChargeQueue } from '@/api/hooks/useCharging'; +import { ChargeQueuePlanner } from './ChargeQueuePlanner'; + +const mockVehicles = useVehicles as unknown as ReturnType; +const mockAdvise = useAdviseChargeQueue as unknown as ReturnType; + +const cars = [ + { id: 1, display_name: 'Alpha' }, + { id: 2, display_name: 'Beta' }, +]; + +const advice: QueueAdvice = { + all_feasible: true, + explanation: 'Charge in order.', + slots: [ + { vehicle_id: 2, position: 1, start_time: '2026-03-10T18:00:00.000Z', end_time: '2026-03-10T21:00:00.000Z', kwh_needed: 33, ready_by: '2026-03-11T06:00:00.000Z', slack_hours: 9, feasible: true }, + { vehicle_id: 1, position: 2, start_time: '2026-03-10T21:00:00.000Z', end_time: '2026-03-11T00:00:00.000Z', kwh_needed: 22.5, ready_by: '2026-03-11T07:30:00.000Z', slack_hours: 7.5, feasible: true }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockVehicles.mockReturnValue({ data: cars }); + mockAdvise.mockReturnValue({ mutate: vi.fn(), data: null, isPending: false, isError: false, error: null }); +}); + +describe('ChargeQueuePlanner', () => { + it('prompts for a second vehicle with only one car', () => { + mockVehicles.mockReturnValue({ data: cars.slice(0, 1) }); + render(); + expect(screen.getByText('Add a second vehicle to plan a shared-charger queue.')).toBeTruthy(); + }); + + it('posts default rows for every car', () => { + const mutate = vi.fn(); + mockAdvise.mockReturnValue({ mutate, data: null, isPending: false, isError: false, error: null }); + render(); + fireEvent.click(screen.getByText('Plan queue')); + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toMatchObject({ + charger_kw: 11, + vehicles: [ + { vehicle_id: 1, current_soc: 40, target_soc: 80, ready_by: '07:30' }, + { vehicle_id: 2, current_soc: 40, target_soc: 80, ready_by: '07:30' }, + ], + }); + }); + + it('renders the ordered queue with per-car windows', () => { + mockAdvise.mockReturnValue({ mutate: vi.fn(), data: advice, isPending: false, isError: false, error: null }); + render(); + expect(screen.getByText('All cars ready on time')).toBeTruthy(); + expect(screen.getByText('Charge in order.')).toBeTruthy(); + expect(screen.getByText('Beta')).toBeTruthy(); + }); +}); diff --git a/web/src/features/charging/components/ChargeQueuePlanner.tsx b/web/src/features/charging/components/ChargeQueuePlanner.tsx new file mode 100644 index 0000000000..59ac49da57 --- /dev/null +++ b/web/src/features/charging/components/ChargeQueuePlanner.tsx @@ -0,0 +1,162 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ListOrdered } from 'lucide-react'; + +import { GlassPanel, PanelTitle, Text, Caption, Button, Input, Badge, ErrorText } from '@/components/ui'; +import { Skeleton, EmptyState } from '@/components/feedback'; +import { useVehicles } from '@/api/hooks/useVehicles'; +import { useAdviseChargeQueue } from '@/api/hooks/useCharging'; +import { useDateFormat } from '@/hooks/useDateFormat'; +import { fmtNumber } from '@/lib/numberFormat'; + +interface QueueRow { + currentSoc: string; + targetSoc: string; + readyBy: string; +} + +const DEFAULT_ROW: QueueRow = { currentSoc: '40', targetSoc: '80', readyBy: '07:30' }; + +/** + * Shared-charger queue planner: one charger, many Teslas. Each vehicle row + * feeds the least-slack-first advisor; the result is an ordered overnight + * queue with feasibility per car. + */ +export function ChargeQueuePlanner() { + const { t } = useTranslation(); + const { formatTime } = useDateFormat(); + const { data: vehicles } = useVehicles(); + const advise = useAdviseChargeQueue(); + + const [rows, setRows] = useState>({}); + const [chargerKw, setChargerKw] = useState('11'); + const result = advise.data ?? null; + + const cars = vehicles ?? []; + const rowFor = (id: number): QueueRow => rows[id] ?? DEFAULT_ROW; + const setRow = (id: number, patch: Partial) => + setRows((prev) => ({ ...prev, [id]: { ...rowFor(id), ...patch } })); + + const handleAdvise = () => { + const kw = Number(chargerKw); + if (!Number.isFinite(kw) || kw < 1 || kw > 22) return; + advise.mutate({ + charger_kw: kw, + vehicles: cars.map((c) => { + const row = rowFor(c.id); + return { + vehicle_id: c.id, + current_soc: Number(row.currentSoc), + target_soc: Number(row.targetSoc), + ready_by: row.readyBy, + }; + }), + }); + }; + + const nameFor = (id: number) => cars.find((c) => c.id === id)?.display_name ?? `#${id}`; + + return ( + +
+ + + {result && ( + + {result.all_feasible + ? t('chargeQueue.feasible', 'All cars ready on time') + : t('chargeQueue.tight', 'Queue overruns a ready-by')} + + )} +
+ + {cars.length < 2 ? ( + } + message={t('chargeQueue.singleCar', 'Add a second vehicle to plan a shared-charger queue.')} + /> + ) : ( +
+ {cars.map((car) => { + const row = rowFor(car.id); + return ( +
+ + {car.display_name} + + setRow(car.id, { currentSoc: e.target.value })} + /> + setRow(car.id, { targetSoc: e.target.value })} + /> + setRow(car.id, { readyBy: e.target.value })} + /> +
+ ); + })} + +
+
+ setChargerKw(e.target.value)} + /> +
+ +
+ + {advise.isPending ? ( + + ) : advise.isError ? ( + {(advise.error as Error)?.message || t('chargeQueue.error', 'Queue planning failed')} + ) : result ? ( +
+ {result.explanation} +
    + {result.slots.map((slot) => ( +
  1. + + {slot.position} + + {nameFor(slot.vehicle_id)} + + {t('chargeQueue.slot', '{{start}}–{{end}} · {{kwh}} kWh · ready {{ready}}', { + start: formatTime(slot.start_time), + end: formatTime(slot.end_time), + kwh: fmtNumber(slot.kwh_needed, 1), + ready: formatTime(slot.ready_by), + })} + +
  2. + ))} +
+
+ ) : null} +
+ )} +
+ ); +} diff --git a/web/src/features/charging/components/ShareSessionDialog.test.tsx b/web/src/features/charging/components/ShareSessionDialog.test.tsx new file mode 100644 index 0000000000..ff0f469f27 --- /dev/null +++ b/web/src/features/charging/components/ShareSessionDialog.test.tsx @@ -0,0 +1,264 @@ +/** + * ShareSessionDialog contract tests. + * + * Mirrors ShareDriveDialog.test.tsx for charging sessions: the dialog POSTs + * /charging/{id}/share (telemetry toggle = charge curve + cost, no + * speed/map toggles), flips to a one-time link-result view, and manages the + * existing-links list from GET /charging/{id}/shares with per-share copy + + * revoke (DELETE /shares/{tok}). + * + * Network is driven entirely through the mocked `@/api/client` `request` + * routed by path + method. react-i18next is stubbed so t(key, fallback, + * vars) resolves to the fallback with {{var}} interpolation. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('@/api/client', async () => { + const actual = await vi.importActual('@/api/client'); + return { ...actual, request: vi.fn() }; +}); + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, fallbackOrOpts?: unknown, opts?: Record) => { + let fallback = key; + let vars: Record | undefined; + if (typeof fallbackOrOpts === 'string') { + fallback = fallbackOrOpts; + vars = opts; + } else if (fallbackOrOpts && typeof fallbackOrOpts === 'object') { + const o = fallbackOrOpts as Record; + if (typeof o.defaultValue === 'string') fallback = o.defaultValue; + vars = o; + } + if (vars) { + return Object.entries(vars).reduce( + (acc, [k, v]) => acc.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)), + fallback, + ); + } + return fallback; + }, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + }; +}); + +import { request } from '@/api/client'; +import { ToastProvider } from '@/components/feedback/Toast'; +import { ShareSessionDialog } from './ShareSessionDialog'; +import type { ShareToken, CreateShareResponse } from '@/types/sharing'; + +const mockedRequest = request as unknown as ReturnType; + +const SESSION_ID = '9'; +const ORIGIN = window.location.origin; +const LIST_PATH = `/charging/${SESSION_ID}/shares`; +const CREATE_PATH = `/charging/${SESSION_ID}/share`; + +function makeCreateResponse(over: Partial = {}): CreateShareResponse { + return { token: 'tok_abc', url: `${ORIGIN}/s/tok_abc`, id: 1, ...over }; +} + +function makeShare(over: Partial = {}): ShareToken { + return { + id: 1, + token: 'tok_1', + charging_session_id: 9, + created_by: 'user@example.com', + title: 'Baker stop', + description: null, + include_map: false, + include_telemetry: false, + include_speed: false, + views: 3, + expires_at: '2999-01-01T00:00:00Z', + created_at: '2020-01-01T00:00:00Z', + ...over, + }; +} + +type Handlers = { + listShares?: () => Promise; + createShare?: (body: Record) => Promise; + revokeShare?: (token: string) => Promise; +}; + +/** Route the single `request` mock by path + method to the two endpoints. */ +function routeRequest(handlers: Handlers = {}) { + mockedRequest.mockImplementation((path: string, opts?: RequestInit) => { + const method = (opts?.method ?? 'GET').toUpperCase(); + if (method === 'GET' && path === LIST_PATH) { + return handlers.listShares ? handlers.listShares() : Promise.resolve([]); + } + if (method === 'POST' && path === CREATE_PATH) { + const body = opts?.body ? (JSON.parse(String(opts.body)) as Record) : {}; + return handlers.createShare ? handlers.createShare(body) : Promise.resolve(makeCreateResponse()); + } + if (method === 'DELETE' && path.startsWith('/shares/')) { + const token = path.replace('/shares/', ''); + return handlers.revokeShare ? handlers.revokeShare(token) : Promise.resolve({ status: 'revoked' }); + } + return Promise.reject(new Error(`unhandled request: ${method} ${path}`)); + }); +} + +function renderDialog(props: { open?: boolean; onClose?: () => void } = {}) { + const onClose = props.onClose ?? vi.fn(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const utils = render( + + + + + , + ); + return { ...utils, onClose, client }; +} + +/** The parsed POST body of the last create call. */ +function lastCreateBody(): Record { + const call = mockedRequest.mock.calls.find( + (c) => c[0] === CREATE_PATH && (c[1] as RequestInit | undefined)?.method === 'POST', + ); + if (!call) throw new Error('create POST was never issued'); + return JSON.parse(String((call[1] as RequestInit).body)); +} + +beforeEach(() => { + mockedRequest.mockReset(); + routeRequest(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('ShareSessionDialog — create mode', () => { + it('renders nothing when closed', async () => { + const { onClose } = renderDialog({ open: false }); + // The hooks still run, so the list GET fires; flush it to stay in act(). + await waitFor(() => expect(mockedRequest).toHaveBeenCalledWith(LIST_PATH, expect.anything())); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByText('Generate Link')).not.toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('renders one curve toggle and no speed toggle', async () => { + renderDialog(); + + const dialog = await screen.findByRole('dialog', { name: 'Share Charging Session' }); + const telemetry = within(dialog).getByRole('switch', { name: 'Include charge curve and cost' }); + expect(telemetry).toHaveAttribute('aria-checked', 'false'); + expect(within(dialog).queryByRole('switch', { name: /speed/i })).not.toBeInTheDocument(); + }); + + it('POSTs the default payload (telemetry off, 30-day expiry, no title)', async () => { + renderDialog(); + await screen.findByText('No active share links yet.'); + + fireEvent.click(screen.getByRole('button', { name: /Generate Link/i })); + + await waitFor(() => expect(mockedRequest).toHaveBeenCalledWith(CREATE_PATH, expect.anything())); + expect(lastCreateBody()).toEqual({ + include_telemetry: false, + expires_in_days: 30, + }); + }); + + it('POSTs the trimmed title + toggled curve + chosen expiry', async () => { + renderDialog(); + await screen.findByText('No active share links yet.'); + + fireEvent.change(screen.getByLabelText('Share title'), { target: { value: ' Baker stop ' } }); + fireEvent.click(screen.getByRole('switch', { name: 'Include charge curve and cost' })); + fireEvent.change(screen.getByLabelText('Link expires after'), { target: { value: '7' } }); + fireEvent.click(screen.getByRole('button', { name: /Generate Link/i })); + + await waitFor(() => expect(mockedRequest).toHaveBeenCalledWith(CREATE_PATH, expect.anything())); + expect(lastCreateBody()).toEqual({ + title: 'Baker stop', + include_telemetry: true, + expires_in_days: 7, + }); + }); + + it('reveals the share URL on success and can return to the form', async () => { + routeRequest({ createShare: () => Promise.resolve(makeCreateResponse({ token: 'tok_new' })) }); + renderDialog(); + await screen.findByText('No active share links yet.'); + + fireEvent.click(screen.getByRole('button', { name: /Generate Link/i })); + + expect(await screen.findByDisplayValue(`${ORIGIN}/s/tok_new`)).toBeInTheDocument(); + expect(screen.getByText('Share link created!')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Create another link' })); + expect(screen.getByRole('button', { name: /Generate Link/i })).toBeInTheDocument(); + expect(screen.queryByText('Share link created!')).not.toBeInTheDocument(); + }); + + it('keeps the form usable when creation fails (no unhandled rejection)', async () => { + routeRequest({ createShare: () => Promise.reject(new Error('network down')) }); + renderDialog(); + await screen.findByText('No active share links yet.'); + + const generate = screen.getByRole('button', { name: /Generate Link/i }); + fireEvent.click(generate); + + await waitFor(() => expect(mockedRequest).toHaveBeenCalledWith(CREATE_PATH, expect.anything())); + await waitFor(() => expect(generate).not.toHaveAttribute('aria-busy', 'true')); + expect(generate).toBeEnabled(); + expect(screen.queryByText('Share link created!')).not.toBeInTheDocument(); + }); +}); + +describe('ShareSessionDialog — existing shares list', () => { + it('renders an active share with views, expiry and revoke controls', async () => { + routeRequest({ listShares: () => Promise.resolve([makeShare({ title: 'Baker stop', views: 3 })]) }); + renderDialog(); + + const title = await screen.findByText('Baker stop'); + const info = title.closest('div') as HTMLElement; + expect(info).toHaveTextContent('3 views'); + expect(info).toHaveTextContent(/Expires/); + + expect(screen.getByRole('button', { name: 'Copy link' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Revoke' })).toBeInTheDocument(); + }); + + it('labels an out-of-date share as Expired', async () => { + routeRequest({ + listShares: () => Promise.resolve([makeShare({ expires_at: '2000-01-01T00:00:00Z' })]), + }); + renderDialog(); + + expect(await screen.findByText('Expired')).toBeInTheDocument(); + expect(screen.queryByText(/^Expires/)).not.toBeInTheDocument(); + }); + + it('issues a DELETE to /shares/{token} when a share is revoked', async () => { + routeRequest({ + listShares: () => Promise.resolve([makeShare({ token: 'tok_kill', title: 'Kill me' })]), + }); + renderDialog(); + + await screen.findByText('Kill me'); + fireEvent.click(screen.getByRole('button', { name: 'Revoke' })); + + await waitFor(() => + expect(mockedRequest).toHaveBeenCalledWith( + '/shares/tok_kill', + expect.objectContaining({ method: 'DELETE' }), + ), + ); + }); +}); diff --git a/web/src/features/charging/components/ShareSessionDialog.tsx b/web/src/features/charging/components/ShareSessionDialog.tsx new file mode 100644 index 0000000000..efbdb5845a --- /dev/null +++ b/web/src/features/charging/components/ShareSessionDialog.tsx @@ -0,0 +1,219 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, Trash2, Eye, ExternalLink } from 'lucide-react'; +import { Modal, Button, CopyButton, Toggle, Select, Input } from '@/components/ui'; +import { GlassPanel } from '@/components/ui'; +import { ListSkeleton, AlertBanner } from '@/components/feedback'; +import { + useCreateSessionShareLink, + useSessionShareLinks, + useRevokeSessionShareLink, +} from '@/api/hooks/useSharing'; +import { formatDate } from '@/lib/dateFormat'; + +interface ShareSessionDialogProps { + sessionId: string; + open: boolean; + onClose: () => void; +} + +/** + * ShareSessionDialog — mirrors ShareDriveDialog for charging sessions. + * One opt-in toggle (charge curve + cost); speed/map have no meaning for + * a session and are never sent. + */ +export function ShareSessionDialog({ sessionId, open, onClose }: ShareSessionDialogProps) { + const { t } = useTranslation(); + const createShare = useCreateSessionShareLink(sessionId); + const { data: existingShares, isLoading: sharesLoading, error: sharesError } = useSessionShareLinks(sessionId); + const revokeShare = useRevokeSessionShareLink(sessionId); + + const [shareUrl, setShareUrl] = useState(null); + const [includeTelemetry, setIncludeTelemetry] = useState(false); + const [expiryDays, setExpiryDays] = useState('30'); + const [title, setTitle] = useState(''); + + const expiryOptions = useMemo( + () => [ + { value: '7', label: t('share.expiry7d', '7 days') }, + { value: '30', label: t('share.expiry30d', '30 days') }, + { value: '90', label: t('share.expiry90d', '90 days') }, + { value: '0', label: t('share.expiryNever', 'Never') }, + ], + [t], + ); + + const handleCreate = async () => { + try { + const result = await createShare.mutateAsync({ + title: title.trim() || undefined, + include_telemetry: includeTelemetry, + expires_in_days: Number(expiryDays) || undefined, + }); + // Guard a malformed success (no token) so we never build a broken + // "/s/undefined" link — stay on the form for the user to retry. + if (result?.token) { + setShareUrl(`${window.location.origin}/s/${result.token}`); + } + } catch { + // useCreateSessionShareLink.onError already surfaces a toast; swallow + // the rejection so this click handler doesn't raise an unhandled + // promise rejection and the form stays interactive. + } + }; + + const handleRevoke = async (token: string) => { + try { + await revokeShare.mutateAsync(token); + } catch { + // useRevokeSessionShareLink.onError already notifies; keep the list usable. + } + }; + + const handleClose = () => { + setShareUrl(null); + setTitle(''); + onClose(); + }; + + const shares = existingShares ?? []; + + return ( + +
+ {/* Create new share */} + {!shareUrl ? ( +
+

+ {t('share.sessionDescription', 'Generate a public link to share this charging session. Anyone with the link can view the summary and charge curve — no login required.')} +

+ + setTitle(e.target.value)} + placeholder={t('share.sessionTitlePlaceholder', 'Optional title (e.g., "Baker Supercharger Stop")')} + aria-label={t('share.titleLabel', 'Share title')} + /> + + + + +
+ + +
+ +
+ )} + + {/* Existing shares — always rendered with explicit loading / error / + empty / list states so the section is never a blank void. */} +
+

+ {t('share.existing', 'Active Share Links')} +

+ {sharesLoading ? ( + + ) : sharesError ? ( + + {t('share.loadError', 'Could not load your existing share links. Please try again.')} + + ) : shares.length > 0 ? ( +
+ {shares.map((share) => { + const isExpired = share.expires_at + ? new Date(share.expires_at) < new Date() + : false; + return ( + +
+

+ {share.title ?? t('share.untitled', 'Untitled share')} +

+
+ + + {share.views ?? 0} {t('share.views', 'views')} + + + {isExpired + ? t('share.expired', 'Expired') + : share.expires_at + ? t('share.expiresOn', 'Expires {{date}}', { date: formatDate(share.expires_at) }) + : t('share.noExpiry', 'No expiry')} + +
+
+
+ + +
+
+ ); + })} +
+ ) : ( +

+ {t('share.none', 'No active share links yet.')} +

+ )} +
+
+
+ ); +} diff --git a/web/src/features/charging/components/SitePriceRadar.test.tsx b/web/src/features/charging/components/SitePriceRadar.test.tsx new file mode 100644 index 0000000000..4ab28d22a5 --- /dev/null +++ b/web/src/features/charging/components/SitePriceRadar.test.tsx @@ -0,0 +1,88 @@ +/** + * SitePriceRadar — cheapest-first ranking + spread line. + * `useChargingSiteRanking` is mocked; GlassPanel renders for real. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { ChargingSiteRanking } from '@/api/hooks/useCharging'; + +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +vi.mock('@/hooks/useFormatting', () => ({ + useFormatting: () => ({ + formatCurrency: (amount: number, decimals = 2) => `$${Number(amount ?? 0).toFixed(decimals)}`, + }), +})); + +vi.mock('@/api/hooks/useCharging', () => ({ useChargingSiteRanking: vi.fn() })); + +import { useChargingSiteRanking } from '@/api/hooks/useCharging'; +import { SitePriceRadar } from './SitePriceRadar'; + +const mockRanking = useChargingSiteRanking as unknown as ReturnType; + +const ranking: ChargingSiteRanking = { + sites: [ + { site: 'Cheap SC', visits: 4, total_wh: 100000, total_spend: 30, avg_per_kwh: 0.3, last_visit: '2026-01-10' }, + { site: 'Pricey SC', visits: 2, total_wh: 50000, total_spend: 25, avg_per_kwh: 0.5, last_visit: '2026-01-02' }, + ], + unpriced_count: 1, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRanking.mockReturnValue({ data: ranking, isLoading: false, isError: false, error: null, refetch: vi.fn() }); +}); + +describe('SitePriceRadar', () => { + it('lists cheapest first with per-kWh prices', () => { + render(); + const items = screen.getAllByText(/SC/); + expect(items[0].textContent).toContain('Cheap SC'); + expect(screen.getByText('$0.300')).toBeTruthy(); + expect(screen.getByText('$0.500')).toBeTruthy(); + }); + + it('shows the spread and unpriced count', () => { + render(); + expect(screen.getByText('+1 visits without invoice pricing')).toBeTruthy(); + expect(screen.getByText(/Cheapest stop saves/)).toBeTruthy(); + }); + + it('shows the empty state without priced visits', () => { + mockRanking.mockReturnValue({ + data: { sites: [], unpriced_count: 0 }, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + render(); + expect(screen.getByText('No priced Supercharger visits yet.')).toBeTruthy(); + }); +}); diff --git a/web/src/features/charging/components/SitePriceRadar.tsx b/web/src/features/charging/components/SitePriceRadar.tsx new file mode 100644 index 0000000000..4a724d7dd9 --- /dev/null +++ b/web/src/features/charging/components/SitePriceRadar.tsx @@ -0,0 +1,83 @@ +import { useTranslation } from 'react-i18next'; +import { BadgePercent } from 'lucide-react'; + +import { GlassPanel, PanelTitle, Text, Caption } from '@/components/ui'; +import { Skeleton, EmptyState, QueryError } from '@/components/feedback'; +import { useFormatting } from '@/hooks/useFormatting'; +import { useChargingSiteRanking } from '@/api/hooks/useCharging'; + +interface SitePriceRadarProps { + vin?: string; + enabled?: boolean; +} + +/** + * Price radar: visited Supercharger sites ranked by realized $/kWh, + * cheapest first — so the next road trip favors the cheap stops. + */ +export function SitePriceRadar({ vin, enabled }: SitePriceRadarProps) { + const { t } = useTranslation(); + const { formatCurrency } = useFormatting(); + const { data, isLoading, isError, error, refetch } = useChargingSiteRanking(vin, { enabled }); + + const sites = (data?.sites ?? []).slice(0, 8); + const cheapest = sites[0]?.avg_per_kwh ?? 0; + + return ( + + + + + {isLoading ? ( + + ) : isError ? ( + refetch()} /> + ) : sites.length === 0 ? ( + + ); +} diff --git a/web/src/features/charging/components/WaitOraclePanel.test.tsx b/web/src/features/charging/components/WaitOraclePanel.test.tsx new file mode 100644 index 0000000000..c2b37e501d --- /dev/null +++ b/web/src/features/charging/components/WaitOraclePanel.test.tsx @@ -0,0 +1,154 @@ +/** + * WaitOraclePanel — behaviour coverage. + * + * Data hooks (`useWaitOracleSites` / `useWaitOracleForecast`) are mocked + * and driven per test; shared UI (GlassPanel, Select, ChartContainer, + * Badge, QueryError, EmptyState) is REAL so the render-boundary wiring + * is genuinely exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +// ── i18n stub ── +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +// ── data hooks, driven per test ── +vi.mock('@/api/hooks/useCharging', () => ({ + useWaitOracleSites: vi.fn(), + useWaitOracleForecast: vi.fn(), +})); + +import { useWaitOracleSites, useWaitOracleForecast } from '@/api/hooks/useCharging'; +import { WaitOraclePanel } from './WaitOraclePanel'; + +const mockSites = useWaitOracleSites as unknown as ReturnType; +const mockForecast = useWaitOracleForecast as unknown as ReturnType; + +const sites = [ + { name: 'Kettleman City', sessions: 200, lat: 35.99, lng: -119.96, last_session: '2026-09-10T18:00:00Z' }, + { name: 'Barstow', sessions: 40, lat: 34.9, lng: -117.02, last_session: '2026-09-09T12:00:00Z' }, +]; + +const forecast = { + site: 'Kettleman City', + arrive_at: '2026-09-11T18:00:00Z', + expected_wait_s: 2646, + wait_probability_pct: 73.8, + busyness: 100, + verdict: 'packed', + confidence: 'high', + stalls_estimated: 4, + best_hour_utc: 15, + best_wait_s: 0, + save_s: 2646, + hours: [ + { hour: 15, expected_wait_s: 0, busyness: 14.3 }, + { hour: 18, expected_wait_s: 2646, busyness: 100 }, + ], + evidence: ['1740 sessions over 10.0 weeks', 'median session 30 min'], +}; + +function idle(extra = {}) { + return { + data: undefined, isLoading: false, isFetching: false, error: null, + isError: false, isPending: false, fetchStatus: 'idle', dataUpdatedAt: Date.now(), + refetch: vi.fn(), ...extra, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockSites.mockReturnValue(idle({ data: sites })); + mockForecast.mockReturnValue(idle({ data: forecast })); +}); + +function renderPanel() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +describe('WaitOraclePanel', () => { + it('defaults to the most-visited site with a live arrival', () => { + renderPanel(); + expect(mockForecast).toHaveBeenCalledWith('Kettleman City', null); + }); + + it('renders the forecast with verdict, best hour, and evidence', () => { + renderPanel(); + expect(screen.getByText('Supercharger Wait Oracle')).toBeInTheDocument(); + expect(screen.getByText('44 min expected wait')).toBeInTheDocument(); + expect(screen.getByText('packed')).toBeInTheDocument(); + expect(screen.getByText('Arrive 15:00 UTC instead to save ~44 min.')).toBeInTheDocument(); + expect(screen.getByText(/1740 sessions over 10.0 weeks/)).toBeInTheDocument(); + }); + + it('reforecasts when the site changes', () => { + renderPanel(); + fireEvent.change(screen.getByDisplayValue(/Kettleman City/), { + target: { value: 'Barstow' }, + }); + expect(mockForecast).toHaveBeenLastCalledWith('Barstow', null); + }); + + it('passes an explicit arrival instant for offset presets', () => { + renderPanel(); + fireEvent.click(screen.getByText('+2h')); + const [, arriveAt] = mockForecast.mock.lastCall as [string, string | null]; + expect(arriveAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it('shows a skeleton while sites load', () => { + mockSites.mockReturnValue(idle({ data: undefined, isLoading: true })); + mockForecast.mockReturnValue(idle()); + renderPanel(); + expect(screen.getByRole('status', { name: 'Loading sites…' })).toBeInTheDocument(); + }); + + it('asks for data when no sites exist', () => { + mockSites.mockReturnValue(idle({ data: [] })); + mockForecast.mockReturnValue(idle()); + renderPanel(); + expect(screen.getByText(/No named sites yet/)).toBeInTheDocument(); + expect(mockForecast).toHaveBeenCalledWith(null, null); + }); + + it('surfaces forecast failures with a retry path', () => { + const refetch = vi.fn(); + mockForecast.mockReturnValue(idle({ error: new Error('oracle down'), isError: true, refetch })); + renderPanel(); + fireEvent.click(screen.getByText('Retry')); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/web/src/features/charging/components/WaitOraclePanel.tsx b/web/src/features/charging/components/WaitOraclePanel.tsx new file mode 100644 index 0000000000..3db2196022 --- /dev/null +++ b/web/src/features/charging/components/WaitOraclePanel.tsx @@ -0,0 +1,245 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; +import { + useWaitOracleForecast, + useWaitOracleSites, + type WaitOracleForecast, +} from '@/api/hooks/useCharging'; +import { useDataState } from '@/hooks/useDataState'; +import { + Bar, + BarChart, + Cell, + ChartContainer, + ChartTooltip, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + axisTickSm, + chartGrid, +} from '@/components/charts'; +import { Badge, Button, GlassPanel, Input, PanelTitle, Select, Text } from '@/components/ui'; +import { ChartSkeleton, EmptyState, QueryError } from '@/components/feedback'; +import { fmtNumber } from '@/lib/numberFormat'; + +type ArrivalPreset = 'now' | 60 | 120 | 180; + +const PRESETS: ArrivalPreset[] = ['now', 60, 120, 180]; + +function verdictVariant(verdict: WaitOracleForecast['verdict']) { + switch (verdict) { + case 'quiet': + return 'success' as const; + case 'steady': + return 'info' as const; + case 'busy': + return 'warning' as const; + default: + return 'danger' as const; + } +} + +function barFill(busyness: number): string { + if (busyness >= 75) return '#fb7185'; + if (busyness >= 50) return '#fbbf24'; + if (busyness >= 25) return '#38bdf8'; + return '#34d399'; +} + +function toIsoOrNull(local: string): string | null { + if (!local) return null; + const ms = Date.parse(local); + return Number.isNaN(ms) ? null : new Date(ms).toISOString(); +} + +/** + * Supercharger wait-time oracle: pick a site + arrival, get the expected + * queue wait predicted from fleet history (Erlang-C over hour-of-week + * demand), the best nearby arrival hour, and the full-day wait curve. + */ +export function WaitOraclePanel() { + const { t } = useTranslation(); + const [site, setSite] = useState(null); + const [preset, setPreset] = useState('now'); + const [custom, setCustom] = useState(''); + + const sitesQuery = useWaitOracleSites(); + const sitesState = useDataState(sitesQuery); + const sites = useMemo(() => sitesQuery.data ?? [], [sitesQuery.data]); + const activeSite = site ?? sites[0]?.name ?? null; + + const arriveAt = useMemo(() => { + if (custom) return toIsoOrNull(custom); + if (preset === 'now') return null; + return new Date(Date.now() + preset * 60_000).toISOString(); + }, [custom, preset]); + + const forecastQuery = useWaitOracleForecast(activeSite, arriveAt); + const forecastState = useDataState(forecastQuery); + const forecast = forecastQuery.data ?? null; + + const chartData = useMemo( + () => + (forecast?.hours ?? []).map((h) => ({ + hour: h.hour, + label: `${String(h.hour).padStart(2, '0')}:00`, + wait: (h.expected_wait_s ?? 0) / 60, + busyness: h.busyness, + })), + [forecast?.hours], + ); + + return ( + + + + + {t( + 'wait_oracle.subtitle', + 'Expected queue wait per site and arrival time, predicted from your fleet charging history. All hours UTC.', + )} + + +
+ setCustom(event.target.value)} + /> +
+
+ + {sitesQuery.isLoading ? ( + + ) : sitesState.fatalError ? ( + sitesState.retry?.()} /> + ) : sites.length === 0 ? ( + } + message={t( + 'wait_oracle.noSites', + 'No named sites yet. Sync fleet charging sessions to build wait forecasts.', + )} + /> + ) : forecastQuery.isLoading ? ( + + ) : forecastState.fatalError ? ( + forecastState.retry?.()} /> + ) : forecast ? ( +
+
+
+ + {t('wait_oracle.expectedWait', '{{min}} min expected wait', { + min: fmtNumber((forecast.expected_wait_s ?? 0) / 60, 0), + })} + + {forecast.verdict} + + {t('wait_oracle.confidence', '{{level}} confidence', { + level: forecast.confidence, + })} + +
+ + {t('wait_oracle.waitProb', '{{pct}}% chance of any wait · ~{{stalls}} stalls', { + pct: fmtNumber(forecast.wait_probability_pct, 0), + stalls: forecast.stalls_estimated, + })} + + {(forecast.save_s ?? 0) >= 60 ? ( +
+
+ ) : null} +
    + {forecast.evidence.map((line) => ( + + · {line} + + ))} +
+
+ fmtNumber(v as number, 1), + }, + ]} + height={220} + > + + + {chartGrid} + + + } /> + + {chartData.map((entry) => ( + + ))} + + + + +
+ ) : null} + + ); +} diff --git a/web/src/features/charging/components/cost-analysis/BillVarianceCard.test.tsx b/web/src/features/charging/components/cost-analysis/BillVarianceCard.test.tsx new file mode 100644 index 0000000000..79c24a7b87 --- /dev/null +++ b/web/src/features/charging/components/cost-analysis/BillVarianceCard.test.tsx @@ -0,0 +1,113 @@ +/** + * BillVarianceCard — reconciled / review / missing verdicts. + * + * `useBillVariance` is mocked and driven per test; is stubbed + * to a faithful gate (echoes title, renders children only when data is + * active) so assertions target this component's own derivations. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { BillVarianceReport } from '@/types/charging'; + +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +vi.mock('@/hooks/useFormatting', () => ({ + useFormatting: () => ({ + formatCurrency: (amount: number, decimals = 2) => `$${Number(amount ?? 0).toFixed(decimals)}`, + }), +})); + +vi.mock('@/api/hooks/useCharging', () => ({ useBillVariance: vi.fn() })); + +vi.mock('./CostSection', () => ({ + CostSection: ({ title, children, isEmpty }: { title: string; children?: ReactNode; isEmpty?: boolean }) => ( +
{isEmpty ?

empty

: children}
+ ), +})); + +import { useBillVariance } from '@/api/hooks/useCharging'; +import { BillVarianceCard } from './BillVarianceCard'; + +const mockVariance = useBillVariance as unknown as ReturnType; + +function report(over: Partial = {}): BillVarianceReport { + return { + vehicle_id: 9, + measured_sessions: 40, + measured_energy_wh: 100000, + measured_cost: 35, + invoiced_sessions: 40, + invoiced_energy_wh: 104000, + invoiced_cost: 36.5, + energy_delta_wh: 4000, + energy_delta_pct: 4, + cost_delta: 1.5, + cost_delta_pct: 4.29, + cabinet_loss_pct: 3.85, + verdict: 'reconciled', + explanation: 'Measured and billed DC charging agree.', + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockVariance.mockReturnValue({ data: report(), isLoading: false, error: null, refetch: vi.fn() }); +}); + +describe('BillVarianceCard', () => { + it('renders the reconciled verdict with deltas and explanation', () => { + render(); + expect(screen.getByText('Reconciled')).toBeTruthy(); + expect(screen.getByText('Measured and billed DC charging agree.')).toBeTruthy(); + expect(screen.getByText('40 measured · 40 invoiced DC sessions')).toBeTruthy(); + }); + + it('renders the review verdict when deltas breach tolerance', () => { + mockVariance.mockReturnValue({ + data: report({ verdict: 'review', energy_delta_pct: 30, explanation: 'Billed energy differs.' }), + isLoading: false, + error: null, + refetch: vi.fn(), + }); + render(); + expect(screen.getByText('Needs review')).toBeTruthy(); + expect(screen.getByText('Billed energy differs.')).toBeTruthy(); + }); + + it('renders the missing verdict when no invoices are on file', () => { + mockVariance.mockReturnValue({ + data: report({ verdict: 'missing_data', invoiced_sessions: 0, explanation: 'No Tesla invoices on file.' }), + isLoading: false, + error: null, + refetch: vi.fn(), + }); + render(); + expect(screen.getByText('Missing invoices')).toBeTruthy(); + }); +}); diff --git a/web/src/features/charging/components/cost-analysis/BillVarianceCard.tsx b/web/src/features/charging/components/cost-analysis/BillVarianceCard.tsx new file mode 100644 index 0000000000..6e6828016f --- /dev/null +++ b/web/src/features/charging/components/cost-analysis/BillVarianceCard.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from 'react-i18next'; +import { ReceiptText } from 'lucide-react'; +import { Text, Badge } from '@/components/ui'; +import { useFormatting } from '@/hooks/useFormatting'; +import { fmtNumber, fmtPercent } from '@/lib/numberFormat'; +import { useBillVariance } from '@/api/hooks/useCharging'; +import { CostSection } from './CostSection'; + +interface BillVarianceCardProps { + vehicleId?: number | null; +} + +function VarianceMetric({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function verdictVariant(verdict: string): 'success' | 'warning' | 'neutral' { + switch (verdict) { + case 'reconciled': + return 'success'; + case 'review': + return 'warning'; + default: + return 'neutral'; + } +} + +/** + * Bill truth at fleet scale: reconciles pack-side measured DC totals + * against Tesla cabinet-side invoices. Built on the CostSection idiom + * so it reads as part of Cost Analysis, not a bolt-on. + */ +export function BillVarianceCard({ vehicleId }: BillVarianceCardProps) { + const { t } = useTranslation(); + const { formatCurrency } = useFormatting(); + const { data, isLoading, error, refetch } = useBillVariance(vehicleId ?? undefined); + + return ( + + ); +} diff --git a/web/src/features/charging/components/cost-analysis/index.ts b/web/src/features/charging/components/cost-analysis/index.ts index 7bb710b7fd..086c6b0bb6 100644 --- a/web/src/features/charging/components/cost-analysis/index.ts +++ b/web/src/features/charging/components/cost-analysis/index.ts @@ -10,3 +10,4 @@ export { CostForecastSection } from './CostForecastSection'; export { ForecastDetails } from './ForecastDetails'; export { LifetimeSummary } from './LifetimeSummary'; export { EnvironmentalImpact } from './EnvironmentalImpact'; +export { BillVarianceCard } from './BillVarianceCard'; diff --git a/web/src/features/charging/pages/ChargingDetailPage.test.tsx b/web/src/features/charging/pages/ChargingDetailPage.test.tsx index 09fde9566e..9115e64180 100644 --- a/web/src/features/charging/pages/ChargingDetailPage.test.tsx +++ b/web/src/features/charging/pages/ChargingDetailPage.test.tsx @@ -34,7 +34,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, within } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ReactNode } from 'react'; @@ -156,6 +156,27 @@ vi.mock('@/components/ai/AIChargingDiagnosis', () => ({ AIChargingDiagnosis: () => null, })); +// The share dialog is covered by its own contract tests; here it only needs +// to prove the page wires open/close without firing share-list queries. +vi.mock('../components/ShareSessionDialog', () => ({ + ShareSessionDialog: ({ + sessionId, + open, + onClose, + }: { + sessionId: string + open: boolean + onClose: () => void + }) => + open ? ( +
+ +
+ ) : null, +})); + // ── Data + environment hooks, driven per test. ── vi.mock('@/api/hooks/useCharging', () => ({ useChargingSessionDetail: vi.fn(), @@ -640,3 +661,31 @@ describe('ChargingDetailPage — ongoing session', () => { expect(screen.getByText('No location recorded for this session.')).toBeInTheDocument(); }); }); + +describe('ChargingDetailPage — share dialog wiring', () => { + function renderWithId() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + } /> + + + , + ); + } + + it('opens and closes the session share dialog from the header action', () => { + renderWithId(); + + expect(screen.queryByRole('dialog', { name: 'Share session' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + + const dialog = screen.getByRole('dialog', { name: 'Share session' }); + expect(dialog).toHaveAttribute('data-session-id', '42'); + + fireEvent.click(within(dialog).getByRole('button', { name: 'close share dialog' })); + expect(screen.queryByRole('dialog', { name: 'Share session' })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/features/charging/pages/ChargingDetailPage.tsx b/web/src/features/charging/pages/ChargingDetailPage.tsx index b1fd0cc7b6..ae7de2f753 100644 --- a/web/src/features/charging/pages/ChargingDetailPage.tsx +++ b/web/src/features/charging/pages/ChargingDetailPage.tsx @@ -1,9 +1,9 @@ -import { useMemo, type ReactNode } from 'react'; +import { useMemo, useState, type ReactNode } from 'react'; import { Link, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ArrowLeft, Zap, Battery, BatteryCharging, Clock, Gauge, DollarSign, - MapPin, Activity, Thermometer, Waves, TrendingUp, + MapPin, Activity, Thermometer, Waves, TrendingUp, Share2, } from 'lucide-react'; import type { ChargingSession, ChargeTelemetryReading } from '@/api/types'; @@ -20,7 +20,7 @@ import { chartTokens } from '@/lib/tokens'; import { PageContainer } from '@/components/layout'; import { - GlassPanel, Badge, HelpTooltip, PrintButton, + GlassPanel, Badge, HelpTooltip, PrintButton, Button, SectionTitle, PanelTitle, Text, } from '@/components/ui'; import { @@ -46,6 +46,7 @@ import { import { distanceAddedM, durationMinutes } from '../components/charging-curve/helpers'; import { ChargePhysicsPanel } from '../components/ChargePhysicsPanel'; import { ChargeBillTruthPanel } from '../components/ChargeBillTruthPanel'; +import { ShareSessionDialog } from '../components/ShareSessionDialog'; /* ─── helpers ──────────────────────────────────────────────────── */ @@ -153,6 +154,7 @@ export default function ChargingDetailPage() { ); const { id } = useParams<{ id: string }>(); const sessionId = Number(id); + const [shareDialogOpen, setShareDialogOpen] = useState(false); // ChargingSession distance delta comes through the repo adapter as miles. // Live charging telemetry is canonical SI and is converted only at the @@ -372,6 +374,16 @@ export default function ChargingDetailPage() { actions={
+ {id && ( + + )}
} @@ -1228,6 +1240,13 @@ export default function ChargingDetailPage() {
+ {id && ( + setShareDialogOpen(false)} + /> + )} ); } diff --git a/web/src/features/charging/pages/ChargingListPage.tsx b/web/src/features/charging/pages/ChargingListPage.tsx index 88b523be39..d5e47950bb 100644 --- a/web/src/features/charging/pages/ChargingListPage.tsx +++ b/web/src/features/charging/pages/ChargingListPage.tsx @@ -54,6 +54,7 @@ import { buildContextHref } from '@/lib/contextNavigation'; import type { ChargingSession } from '@/api/types'; import type { OperationalNarrative } from '@/types/operationalNarrative'; import { ChargingSessionCard } from '../components/ChargingSessionCard'; +import { ChargeQueuePlanner } from '../components/ChargeQueuePlanner'; import { computeChargingPeriodStats, priorPeriod, detectChargingAnomalies, detectNotableSessions, dailyChargingTrend, getChargerCategory, @@ -1074,6 +1075,11 @@ export default function ChargingListPage() { + {/* Shared-charger queue planner */} + + + + {/* Overview KPI card */}
diff --git a/web/src/features/charging/pages/CostAnalysisPage.tsx b/web/src/features/charging/pages/CostAnalysisPage.tsx index ef13ce04b9..9e3d9612b5 100644 --- a/web/src/features/charging/pages/CostAnalysisPage.tsx +++ b/web/src/features/charging/pages/CostAnalysisPage.tsx @@ -27,6 +27,7 @@ import { CostForecastSection, LifetimeSummary, EnvironmentalImpact, + BillVarianceCard, } from '../components/cost-analysis'; export default function CostAnalysisPage() { @@ -125,6 +126,11 @@ export default function CostAnalysisPage() {
+ {/* 1b — Bill truth: measured vs Tesla invoices */} + + + + {/* 2 — Cost trends: hero area chart + rate line */}
({ useApplySchedule: vi.fn(), useChargePlans: vi.fn(), useRatePlans: vi.fn(), + // Consumed by the embedded AutopilotPanel (rendered for real). + useAutopilotProfile: vi.fn(), + useSaveAutopilotProfile: vi.fn(), + useAutopilotPreview: vi.fn(), + useAutopilotRun: vi.fn(), + useAutopilotSavings: vi.fn(), +})); + +// Consumed by the embedded ChargePointsPanel (rendered for real). +vi.mock('@/api/hooks/useOcpp', () => ({ + useOcppChargePoints: vi.fn(), + useOcppSessions: vi.fn(), })); import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; -import { useOptimizeCharge, useApplySchedule, useChargePlans, useRatePlans } from '@/api/hooks/useCharging'; +import { + useOptimizeCharge, + useApplySchedule, + useChargePlans, + useRatePlans, + useAutopilotProfile, + useSaveAutopilotProfile, + useAutopilotPreview, + useAutopilotRun, + useAutopilotSavings, +} from '@/api/hooks/useCharging'; +import { useOcppChargePoints, useOcppSessions } from '@/api/hooks/useOcpp'; import SmartChargePage, { planStatusVariant, defaultDepartBy } from './SmartChargePage'; const mockSelected = useSelectedVehicle as unknown as ReturnType; @@ -157,6 +180,13 @@ const mockOptimize = useOptimizeCharge as unknown as ReturnType; const mockApply = useApplySchedule as unknown as ReturnType; const mockPlans = useChargePlans as unknown as ReturnType; const mockRatePlans = useRatePlans as unknown as ReturnType; +const mockAutopilotProfile = useAutopilotProfile as unknown as ReturnType; +const mockSaveAutopilot = useSaveAutopilotProfile as unknown as ReturnType; +const mockAutopilotPreview = useAutopilotPreview as unknown as ReturnType; +const mockAutopilotRun = useAutopilotRun as unknown as ReturnType; +const mockAutopilotSavings = useAutopilotSavings as unknown as ReturnType; +const mockOcppPoints = useOcppChargePoints as unknown as ReturnType; +const mockOcppSessions = useOcppSessions as unknown as ReturnType; function makeQuery(over: Record = {}): any { @@ -267,6 +297,15 @@ beforeEach(() => { mockApply.mockReturnValue(applyState()); mockPlans.mockReturnValue(makeQuery({ data: [] })); mockRatePlans.mockReturnValue(makeQuery({ data: [] })); + // Embedded AutopilotPanel defaults (idle, no stored profile yet). + mockAutopilotProfile.mockReturnValue(makeQuery({ data: undefined })); + mockSaveAutopilot.mockReturnValue(optimizeState()); + mockAutopilotPreview.mockReturnValue({ mutate: vi.fn(), data: null, isPending: false, isError: false, error: null }); + mockAutopilotRun.mockReturnValue({ mutate: vi.fn(), data: null, isPending: false, isError: false, error: null }); + mockAutopilotSavings.mockReturnValue(makeQuery({ data: undefined })); + // Embedded ChargePointsPanel defaults (no charger reporting). + mockOcppPoints.mockReturnValue(makeQuery({ data: [] })); + mockOcppSessions.mockReturnValue(makeQuery({ data: [] })); }); // ───────────────────────────── pure utilities ───────────────────────────── @@ -438,7 +477,9 @@ describe('SmartChargePage — after a successful optimization', () => { expect(within(kpi).getByText('$5.25')).toBeInTheDocument(); // savings expect(within(kpi).getByText('42.0 kWh')).toBeInTheDocument(); // energy expect(within(kpi).getByText(/62%/)).toBeInTheDocument(); // savings_percent delta - expect(screen.queryAllByText('—')).toHaveLength(0); + // Scoped to the KPI band: sibling sections (Autopilot preview placeholders) + // legitimately render '—' until they have their own data. + expect(within(kpi).queryAllByText('—')).toHaveLength(0); }); it('renders the rate-timeline legend incl. the highlighted charge window', () => { diff --git a/web/src/features/charging/pages/SmartChargePage.tsx b/web/src/features/charging/pages/SmartChargePage.tsx index 29c4846c46..20d1cc40a3 100644 --- a/web/src/features/charging/pages/SmartChargePage.tsx +++ b/web/src/features/charging/pages/SmartChargePage.tsx @@ -45,6 +45,8 @@ import { useRatePlans, } from '@/api/hooks/useCharging'; import { RateTimeline } from '../components/RateTimeline'; +import { AutopilotPanel } from '../components/AutopilotPanel'; +import { ChargePointsPanel } from '../components/ChargePointsPanel'; import { AISmartChargeScheduleSuggestion } from '@/components/ai/AISmartChargeScheduleSuggestion'; import type { ChargePlan, OptimizeChargeResponse } from '@/types/charging'; @@ -337,7 +339,12 @@ export default function SmartChargePage() {
- {/* ── 2 · Primary bento — settings control rail + rate-timeline hero ── */} + {/* ── 2 · Autopilot — always-on profile, next-run preview, realized savings ── */} + + + + + {/* ── 3 · Primary bento — settings control rail + rate-timeline hero ── */}
{/* Charge settings (control rail) */} @@ -444,7 +451,7 @@ export default function SmartChargePage() {
- {/* ── 3 · Schedule bento — recommended schedule + alternatives ── */} + {/* ── 4 · Schedule bento — recommended schedule + alternatives ── */}
{/* Recommended schedule + apply */} @@ -557,7 +564,7 @@ export default function SmartChargePage() {
- {/* ── 4 · Detail band — plan history ── */} + {/* ── 5 · Detail band — plan history ── */} @@ -584,6 +591,11 @@ export default function SmartChargePage() { )} + + {/* ── 6 · OCPP band — non-Tesla charge points ── */} + + + ); diff --git a/web/src/features/charging/pages/TeslaChargingHistoryPage.tsx b/web/src/features/charging/pages/TeslaChargingHistoryPage.tsx index a437bb05fa..e7033b28a5 100644 --- a/web/src/features/charging/pages/TeslaChargingHistoryPage.tsx +++ b/web/src/features/charging/pages/TeslaChargingHistoryPage.tsx @@ -15,6 +15,7 @@ import { import { Skeleton, EmptyState, QueryError } from '@/components/feedback'; import { SearchInput, FilterBar, ActiveFilterChips, RangePicker, type FilterChipDescriptor } from '@/components/forms'; import { useFilteredList } from '@/hooks/useFilteredList'; +import { SitePriceRadar } from '../components/SitePriceRadar'; import { useRangeState } from '@/hooks/useRangeState'; import { useUrlEnum, useUrlString } from '@/hooks/useUrlState'; import { @@ -527,6 +528,15 @@ export default function TeslaChargingHistoryPage() { + {/* 2b — Price radar: cheapest visited sites by realized $/kWh. */} + +
+
+ +
+
+
+ {/* 3 — Detail band: full-width sessions table with search + bulk export. */} diff --git a/web/src/features/charging/pages/TeslaChargingSessionsPage.tsx b/web/src/features/charging/pages/TeslaChargingSessionsPage.tsx index 25554682a6..68bce16d8b 100644 --- a/web/src/features/charging/pages/TeslaChargingSessionsPage.tsx +++ b/web/src/features/charging/pages/TeslaChargingSessionsPage.tsx @@ -58,6 +58,8 @@ import { convertEnergyFromSI } from '@/lib/unitConversion'; import { formatCurrencyValue, currencyCodeFromSymbol } from '@/lib/currencyFormat'; import type { OperationalNarrative } from '@/types/operationalNarrative'; +import { WaitOraclePanel } from '../components/WaitOraclePanel'; + const LazyMap = lazy(() => import('./TeslaChargingSessionsMap')); /** @@ -782,6 +784,13 @@ export default function TeslaChargingSessionsPage() { + {/* Wait oracle — forward-looking queue forecast per site */} + +
+ +
+
+ {/* Cost analysis bento — monthly cost hero + charger-type breakdown */}
{ expect(refetch).toHaveBeenCalledTimes(1); }); }); + +describe('BatteryDegradationForecastWidget — horizon outlook', () => { + it('renders the 1/3/5-year points when the outlook is present', () => { + mockDegradation.mockReturnValue(qr({ + data: makeData({ + horizon_outlook: { + points: [ + { years: 1, health_pct: 90.5, confidence_low: 89, confidence_high: 92 }, + { years: 3, health_pct: 86.1, confidence_low: 83, confidence_high: 89 }, + { years: 5, health_pct: 81.7, confidence_low: 77, confidence_high: 86 }, + ], + data_months: 14, + slope_per_year: -2.2, + has_enough_data: true, + }, + }), + })); + renderWidget(STANDARD); + + expect(screen.getByText('1 / 3 / 5-Year Outlook')).toBeTruthy(); + expect(screen.getByText('90.5%')).toBeTruthy(); + expect(screen.getByText('81.7%')).toBeTruthy(); + }); + + it('hides the outlook when absent', () => { + mockDegradation.mockReturnValue(qr({ data: makeData({ horizon_outlook: null }) })); + renderWidget(STANDARD); + + expect(screen.queryByText('1 / 3 / 5-Year Outlook')).toBeNull(); + }); +}); diff --git a/web/src/features/dashboard/widgets/BatteryDegradationForecastWidget.tsx b/web/src/features/dashboard/widgets/BatteryDegradationForecastWidget.tsx index 23b19da570..557f1d2c98 100644 --- a/web/src/features/dashboard/widgets/BatteryDegradationForecastWidget.tsx +++ b/web/src/features/dashboard/widgets/BatteryDegradationForecastWidget.tsx @@ -164,6 +164,33 @@ export default function BatteryDegradationForecastWidget({ vehicleId, size }: Wi /> )} + {/* Horizon outlook: 1/3/5-year twin readout */} + {(data?.horizon_outlook?.points?.length ?? 0) > 0 && ( +
+

+ {t('widget.forecast.horizon', '1 / 3 / 5-Year Outlook')} +

+
    + {(data?.horizon_outlook?.points ?? []).map((p) => ( +
  • +

    + {t('widget.forecast.years', '{{n}} yr', { n: p.years })} +

    +

    + {fmtNumber(p.health_pct, 1)}% +

    +

    + {fmtNumber(p.confidence_low, 0)}–{fmtNumber(p.confidence_high, 0)} +

    +
  • + ))} +
+
+ )} + {/* Risk factors list */} {riskFactors.length > 0 && (
diff --git a/web/src/features/dashboard/widgets/MaintenanceTrackerWidget.test.tsx b/web/src/features/dashboard/widgets/MaintenanceTrackerWidget.test.tsx index 8578a38741..f12730d775 100644 --- a/web/src/features/dashboard/widgets/MaintenanceTrackerWidget.test.tsx +++ b/web/src/features/dashboard/widgets/MaintenanceTrackerWidget.test.tsx @@ -70,7 +70,12 @@ vi.mock('react-i18next', () => ({ vi.mock('@/api/hooks/useVehicleSystems', async (importActual) => { const actual = await importActual(); - return { ...actual, useMaintenance: vi.fn(), useServiceRecords: vi.fn() }; + return { + ...actual, + useMaintenance: vi.fn(), + useServiceRecords: vi.fn(), + useMaintenanceForecast: vi.fn(), + }; }); // useUnits stub — flip the display distance unit (km / mi) per test while the @@ -95,13 +100,14 @@ import MaintenanceTrackerWidget, { urgencyBadgeVariant, urgencyLabel, } from './MaintenanceTrackerWidget'; -import { useMaintenance, useServiceRecords } from '@/api/hooks/useVehicleSystems'; +import { useMaintenance, useServiceRecords, useMaintenanceForecast } from '@/api/hooks/useVehicleSystems'; import { useUnits } from '@/hooks/useUnits'; import type { MaintenanceItem, ServiceRecord } from '@/types/vehicle-systems'; import type { WidgetProps, WidgetSize } from './types'; const mockMaintenance = vi.mocked(useMaintenance); const mockRecords = vi.mocked(useServiceRecords); +const mockForecast = vi.mocked(useMaintenanceForecast); const mockUnits = vi.mocked(useUnits); /** Minimal `UseQueryResult`-shaped stub (incl. the DataFreshness fields). */ @@ -160,6 +166,7 @@ beforeEach(() => { mockUnits.mockReturnValue({ unitPrefs: { distance: 'km' } } as never); mockMaintenance.mockReturnValue(qr({ data: [] })); mockRecords.mockReturnValue(qr({ data: [] })); + mockForecast.mockReturnValue(qr({ data: null })); }); afterEach(() => { @@ -446,3 +453,27 @@ describe('MaintenanceTrackerWidget — null-safety & hardening', () => { expect(container).toHaveTextContent('0 km'); }); }); + +describe('MaintenanceTrackerWidget — forecast banner', () => { + it('renders due counts when the forecast flags items', () => { + mockMaintenance.mockReturnValue(qr({ data: [makeItem()] })); + mockForecast.mockReturnValue( + qr({ data: { overdue_count: 1, due_soon_count: 2, km_per_day: 55.5 } }), + ); + renderWidget(STANDARD); + + expect( + screen.getByRole('status', { name: 'Maintenance forecast status' }), + ).toHaveTextContent('1 overdue · 2 due soon'); + }); + + it('hides the banner when nothing is due', () => { + mockMaintenance.mockReturnValue(qr({ data: [makeItem()] })); + mockForecast.mockReturnValue( + qr({ data: { overdue_count: 0, due_soon_count: 0, km_per_day: 55.5 } }), + ); + renderWidget(STANDARD); + + expect(screen.queryByRole('status', { name: 'Maintenance forecast status' })).toBeNull(); + }); +}); diff --git a/web/src/features/dashboard/widgets/MaintenanceTrackerWidget.tsx b/web/src/features/dashboard/widgets/MaintenanceTrackerWidget.tsx index e774ce7af3..4da7ce89fd 100644 --- a/web/src/features/dashboard/widgets/MaintenanceTrackerWidget.tsx +++ b/web/src/features/dashboard/widgets/MaintenanceTrackerWidget.tsx @@ -4,7 +4,7 @@ import { Wrench, CheckCircle2, Clock } from 'lucide-react'; import { Badge } from '@/components/ui'; import { Timeline } from '@/components/data-display'; import { EmptyState } from '@/components/feedback'; -import { useMaintenance, useServiceRecords } from '@/api/hooks/useVehicleSystems'; +import { useMaintenance, useServiceRecords, useMaintenanceForecast } from '@/api/hooks/useVehicleSystems'; import { useFormatting } from '@/hooks/useFormatting'; import { useUnits } from '@/hooks/useUnits'; import { fmtNumber, fmtInt } from '@/lib/numberFormat'; @@ -70,6 +70,8 @@ export default function MaintenanceTrackerWidget({ size }: WidgetProps) { dataUpdatedAt: recordsUpdatedAt, } = useServiceRecords(); + const { data: forecast } = useMaintenanceForecast(); + const isLoading = maintLoading || recordsLoading; const isCompact = size.cols <= 1; const items = maintenanceItems ?? []; @@ -208,6 +210,28 @@ export default function MaintenanceTrackerWidget({ size }: WidgetProps) {
)} + {/* Wear forecast banner: mileage/time-aware due counts */} + {forecast && (forecast.overdue_count > 0 || forecast.due_soon_count > 0) && ( +
+ 0 ? '#ef4444' : '#f59e0b' }} + aria-hidden="true" + /> +

+ {t('widget.maintenance.forecast', '{{overdue}} overdue · {{soon}} due soon · {{rate}} km/day', { + overdue: fmtInt(forecast.overdue_count), + soon: fmtInt(forecast.due_soon_count), + rate: fmtNumber(forecast.km_per_day ?? 0, 0), + })} +

+
+ )} + {/* Bottom: Recent service records */} {recentRecords.length > 0 ? (
diff --git a/web/src/features/dashboard/widgets/NextChargeDecisionWidget.test.tsx b/web/src/features/dashboard/widgets/NextChargeDecisionWidget.test.tsx new file mode 100644 index 0000000000..e2065abb5d --- /dev/null +++ b/web/src/features/dashboard/widgets/NextChargeDecisionWidget.test.tsx @@ -0,0 +1,128 @@ +/** + * NextChargeDecisionWidget — dashboard tile for the 12-hour energy verdict. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fb?: unknown, opts?: unknown) => { + const options = (opts && typeof opts === 'object' ? opts : undefined) as + | Record + | undefined; + let base = typeof fb === 'string' ? fb : key; + if (options) { + base = base.replace(/{{\s*(\w+)\s*}}/g, (_m, n: string) => + n in options && options[n] != null ? String(options[n]) : `{{${n}}}`, + ); + } + return base; + }, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + Trans: ({ children }: { children?: unknown }) => <>{children as never}, + initReactI18next: { type: '3rdParty', init: () => undefined }, +})); + +vi.mock('@/api/hooks/useVehicles', async (importActual) => { + const actual = await importActual(); + return { ...actual, useVehicles: vi.fn(), useVehicleState: vi.fn() }; +}); + +vi.mock('@/api/hooks/useCharging', async (importActual) => { + const actual = await importActual(); + return { ...actual, useNextChargeDecision: vi.fn() }; +}); + +if (typeof window.matchMedia !== 'function') { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + +import NextChargeDecisionWidget from './NextChargeDecisionWidget'; +import { useVehicles, useVehicleState } from '@/api/hooks/useVehicles'; +import { useNextChargeDecision } from '@/api/hooks/useCharging'; +import type { WidgetSize } from './types'; + +const mockVehicles = vi.mocked(useVehicles); +const mockState = vi.mocked(useVehicleState); +const mockDecision = vi.mocked(useNextChargeDecision); + +const SIZE: WidgetSize = { cols: 2, rows: 2 }; + +function qr(over: Record = {}) { + return { + data: undefined, + isLoading: false, + isError: false, + error: null, + isFetching: false, + isStale: false, + dataUpdatedAt: Date.now(), + refetch: vi.fn(), + ...over, + } as never; +} + +function renderWidget() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +beforeEach(() => { + mockVehicles.mockReturnValue(qr({ data: [{ id: 1 }] })); + mockState.mockReturnValue( + qr({ data: { state: { battery_level: 42 }, live: true } }), + ); + mockDecision.mockReturnValue(qr()); +}); + +afterEach(() => { + cleanup(); +}); + +describe('NextChargeDecisionWidget', () => { + it('shows waiting empty when battery level is missing', () => { + mockState.mockReturnValue(qr({ data: { state: {}, live: true } })); + renderWidget(); + expect(screen.getByText('Waiting for live battery level')).toBeInTheDocument(); + }); + + it('renders skip_dc verdict from the decision hook', () => { + mockDecision.mockReturnValue( + qr({ + data: { + verdict: 'skip_dc', + reason_key: 'skip_dc', + reason: 'Skip Everett — home is cheaper', + current_soc: 42, + target_soc: 80, + kwh_needed: 28.5, + horizon_hours: 12, + home_now_cost: 6.4, + ready_by: '2026-01-16T07:30:00Z', + capped_by_health_guardrail: false, + }, + }), + ); + renderWidget(); + expect(screen.getByText('Skip Everett — home is cheaper')).toBeInTheDocument(); + expect(screen.getByText(/42% → 80%/)).toBeInTheDocument(); + }); +}); diff --git a/web/src/features/dashboard/widgets/NextChargeDecisionWidget.tsx b/web/src/features/dashboard/widgets/NextChargeDecisionWidget.tsx new file mode 100644 index 0000000000..a8d996be90 --- /dev/null +++ b/web/src/features/dashboard/widgets/NextChargeDecisionWidget.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from 'react-i18next'; +import { Zap } from 'lucide-react'; + +import { Badge, Caption, Text } from '@/components/ui'; +import { EmptyState } from '@/components/feedback'; +import { useVehicles, useVehicleState } from '@/api/hooks/useVehicles'; +import { useNextChargeDecision } from '@/api/hooks/useCharging'; +import { useFormatting } from '@/hooks/useFormatting'; +import { fmtNumber } from '@/lib/numberFormat'; +import { WidgetShell } from './WidgetShell'; +import type { WidgetProps } from './types'; +import type { NextChargeVerdict } from '@/types/charging'; + +const VERDICT_BADGE: Record = { + enough: 'success', + wait: 'info', + charge_home_now: 'warning', + supercharger: 'warning', + skip_dc: 'success', +}; + +export default function NextChargeDecisionWidget({ vehicleId }: WidgetProps) { + const { t } = useTranslation(); + const { formatCurrency } = useFormatting(); + const { data: vehicles } = useVehicles(); + const id = vehicleId ?? vehicles?.[0]?.id ?? 0; + const stateQuery = useVehicleState(id); + const soc = stateQuery.data?.state?.battery_level; + const decisionQuery = useNextChargeDecision(id, soc); + const data = decisionQuery.data; + + const loading = stateQuery.isLoading || decisionQuery.isLoading; + const isError = stateQuery.isError || decisionQuery.isError; + + return ( + { + void stateQuery.refetch(); + void decisionQuery.refetch(); + }} + help={{ + i18nKey: 'nextCharge.help', + defaultValue: '12-hour verdict: charge home now, wait for off-peak, Supercharger, or skip DC-fast.', + }} + > + {soc == null || !Number.isFinite(soc) ? ( + } + message={t('nextCharge.waitingSoc', 'Waiting for live battery level')} + className="py-6" + /> + ) : !data ? ( + } + message={t('nextCharge.noData', 'No charge decision yet')} + className="py-6" + /> + ) : ( +
+
+ + {t(`nextCharge.verdict.${data.verdict}`, data.verdict)} + +
+ {data.reason} + + {t('nextCharge.socLine', '{{soc}}% → {{target}}% · {{kwh}} kWh needed', { + soc: data.current_soc, + target: data.target_soc, + kwh: fmtNumber(data.kwh_needed, 1), + })} + + {data.home_now_cost != null ? ( + + {t('nextCharge.homeNow', 'Home now')} {formatCurrency(data.home_now_cost)} + + ) : null} +
+ )} +
+ ); +} diff --git a/web/src/features/dashboard/widgets/VampireDrainWidget.test.tsx b/web/src/features/dashboard/widgets/VampireDrainWidget.test.tsx index b15f740cea..98cdb71cee 100644 --- a/web/src/features/dashboard/widgets/VampireDrainWidget.test.tsx +++ b/web/src/features/dashboard/widgets/VampireDrainWidget.test.tsx @@ -63,15 +63,17 @@ vi.mock('@/api/hooks/useVehicles', () => ({ useVehicles: vi.fn() })); vi.mock('@/api/hooks/useEnergy', () => ({ useVampireDrainStats: vi.fn(), useVampireDrainEvents: vi.fn(), + useVampireDrainWatch: vi.fn(), })); import { useVehicles } from '@/api/hooks/useVehicles'; -import { useVampireDrainStats, useVampireDrainEvents } from '@/api/hooks/useEnergy'; +import { useVampireDrainStats, useVampireDrainEvents, useVampireDrainWatch } from '@/api/hooks/useEnergy'; import VampireDrainWidget, { drainColor, formatDuration } from './VampireDrainWidget'; const mockVehicles = useVehicles as unknown as ReturnType; const mockStats = useVampireDrainStats as unknown as ReturnType; const mockEvents = useVampireDrainEvents as unknown as ReturnType; +const mockWatch = useVampireDrainWatch as unknown as ReturnType; // A minimal fallback-echoing translator for the pure-utility tests. const echo = (_k: string, d: string) => d; @@ -152,9 +154,11 @@ beforeEach(() => { mockVehicles.mockReset(); mockStats.mockReset(); mockEvents.mockReset(); + mockWatch.mockReset(); mockVehicles.mockReturnValue({ data: [{ id: 1 }] }); mockStats.mockReturnValue(makeQuery({ data: makeStats() })); mockEvents.mockReturnValue(makeQuery({ data: [criticalEvent(), lowEvent()] })); + mockWatch.mockReturnValue(makeQuery({ data: null })); }); describe('drainColor (utility)', () => { @@ -365,3 +369,42 @@ describe('VampireDrainWidget — refresh + vehicle resolution', () => { expect(mockEvents).toHaveBeenCalledWith(null, 30); }); }); + +describe('VampireDrainWidget — watchdog strip', () => { + it('hides the strip while the watch query has no data', () => { + renderWidget({ size: { cols: 2, rows: 2 } }); + expect(screen.queryByRole('status', { name: 'Drain watchdog status' })).toBeNull(); + }); + + it('shows the healthy copy when status is ok', () => { + mockWatch.mockReturnValue( + makeQuery({ + data: { + status: 'ok', + threshold_pct_per_day: 3, + breach_streak: 0, + recommendation: 'Parked drain looks healthy. No action needed.', + }, + }), + ); + renderWidget({ size: { cols: 2, rows: 2 } }); + expect(screen.getByText('Watchdog: drain healthy')).toBeTruthy(); + }); + + it('shows the streak and recommendation on breach', () => { + mockWatch.mockReturnValue( + makeQuery({ + data: { + status: 'alert', + threshold_pct_per_day: 3, + breach_streak: 3, + recommendation: 'Check Sentry Mode.', + }, + }), + ); + renderWidget({ size: { cols: 2, rows: 2 } }); + const strip = screen.getByRole('status', { name: 'Drain watchdog status' }); + expect(strip.textContent).toContain('3'); + expect(strip.textContent).toContain('Check Sentry Mode.'); + }); +}); diff --git a/web/src/features/dashboard/widgets/VampireDrainWidget.tsx b/web/src/features/dashboard/widgets/VampireDrainWidget.tsx index b7f04f5b8c..85d755f175 100644 --- a/web/src/features/dashboard/widgets/VampireDrainWidget.tsx +++ b/web/src/features/dashboard/widgets/VampireDrainWidget.tsx @@ -5,7 +5,7 @@ import { StatCard } from '@/components/data-display'; import { EmptyState } from '@/components/feedback'; import { Sparkline } from '@/components/charts'; import { useVehicles } from '@/api/hooks/useVehicles'; -import { useVampireDrainStats, useVampireDrainEvents } from '@/api/hooks/useEnergy'; +import { useVampireDrainStats, useVampireDrainEvents, useVampireDrainWatch } from '@/api/hooks/useEnergy'; import { fmtNumber } from '@/lib/numberFormat'; import { cn } from '@/lib/cn'; import { WidgetShell } from './WidgetShell'; @@ -104,11 +104,21 @@ export default function VampireDrainWidget({ vehicleId, size }: WidgetProps) { .map((e) => e.drain_pct_per_day ?? 0); }, [events]); - const updatedAt = Math.max(statsUpdatedAt ?? 0, eventsUpdatedAt ?? 0); + const { + data: watch, + isFetching: watchFetching, + isStale: watchStale, + isError: watchError, + dataUpdatedAt: watchUpdatedAt, + refetch: refetchWatch, + } = useVampireDrainWatch(idStr); + + const updatedAt = Math.max(statsUpdatedAt ?? 0, eventsUpdatedAt ?? 0, watchUpdatedAt ?? 0); const handleRefresh = () => { refetchStats(); refetchEvents(); + refetchWatch(); }; const hasMeasuredAverage = measuredAverage != null; @@ -130,9 +140,9 @@ export default function VampireDrainWidget({ vehicleId, size }: WidgetProps) { }} loading={isLoading} updatedAt={updatedAt} - isFetching={statsFetching || eventsFetching} - isStale={statsStale || eventsStale} - isError={statsError || eventsError} + isFetching={statsFetching || eventsFetching || watchFetching} + isStale={statsStale || eventsStale || watchStale} + isError={statsError || eventsError || watchError} onRefresh={handleRefresh} > {hasData ? ( @@ -172,6 +182,32 @@ export default function VampireDrainWidget({ vehicleId, size }: WidgetProps) { } /> + {/* Watchdog status strip */} + {watch && ( +
+
+ )} + {/* Wide: sparkline */} {isWide && sparklineData.length > 1 && (
diff --git a/web/src/features/dashboard/widgets/registry/charging.test.ts b/web/src/features/dashboard/widgets/registry/charging.test.ts index 9cadab28f5..fd3a9c8821 100644 --- a/web/src/features/dashboard/widgets/registry/charging.test.ts +++ b/web/src/features/dashboard/widgets/registry/charging.test.ts @@ -56,6 +56,7 @@ const EXPECTED_IDS = [ 'charging-telemetry', 'supercharger-history', 'charge-plans', + 'next-charge-decision', 'charging-session-detail', ] as const; diff --git a/web/src/features/dashboard/widgets/registry/charging.ts b/web/src/features/dashboard/widgets/registry/charging.ts index 025f42d78c..65d8c7d260 100644 --- a/web/src/features/dashboard/widgets/registry/charging.ts +++ b/web/src/features/dashboard/widgets/registry/charging.ts @@ -1,6 +1,6 @@ import { lazy } from 'react'; import { - Zap, BarChart3, DollarSign, Calendar, TrendingUp, Sparkles, Plug, Gauge, Clock, + Zap, BarChart3, DollarSign, Calendar, TrendingUp, Sparkles, Plug, Gauge, Clock, Compass, } from 'lucide-react'; import type { WidgetDef } from '../types'; @@ -137,6 +137,17 @@ export const CHARGING_WIDGETS: WidgetDef[] = [ maxSize: { cols: 4, rows: 40 }, component: lazy(() => import('../ChargePlansWidget')), }, + { + id: 'next-charge-decision', + name: 'Next Charge', + description: '12-hour verdict: charge home now, wait for off-peak, Supercharger, or skip DC-fast', + icon: Compass, + category: 'charging', + defaultSize: { cols: 2, rows: 2 }, + minSize: { cols: 1, rows: 2 }, + maxSize: { cols: 4, rows: 40 }, + component: lazy(() => import('../NextChargeDecisionWidget')), + }, { id: 'charging-session-detail', name: 'Charge Session Detail', diff --git a/web/src/features/driving/components/TripCopilotCard.test.tsx b/web/src/features/driving/components/TripCopilotCard.test.tsx new file mode 100644 index 0000000000..a7ada7d9bb --- /dev/null +++ b/web/src/features/driving/components/TripCopilotCard.test.tsx @@ -0,0 +1,90 @@ +/** + * TripCopilotCard — verdict badges + payload wiring. `useTripConfidence` + * is mocked; GlassPanel/Badge render for real. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { TripConfidence } from '@/types/driving'; + +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +vi.mock('@/api/hooks/useDriving', () => ({ useTripConfidence: vi.fn() })); + +import { useTripConfidence } from '@/api/hooks/useDriving'; +import { TripCopilotCard } from './TripCopilotCard'; + +const mockConfidence = useTripConfidence as unknown as ReturnType; + +function verdict(over: Partial = {}): TripConfidence { + return { + arrival_soc: 48, + usable_kwh: 60, + needed_kwh: 24, + margin_kwh: 28.5, + charge_needed_kwh: 0, + verdict: 'comfortable', + explanation: "You'll arrive with plenty to spare.", + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockConfidence.mockReturnValue({ mutate: vi.fn(), data: null, isPending: false, isError: false, error: null }); +}); + +describe('TripCopilotCard', () => { + it('sends SOC + remaining distance from live form state', () => { + const mutate = vi.fn(); + mockConfidence.mockReturnValue({ mutate, data: null, isPending: false, isError: false, error: null }); + render(); + fireEvent.change(screen.getByLabelText('Remaining (km)'), { target: { value: '150' } }); + fireEvent.click(screen.getByText('Will I make it?')); + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate.mock.calls[0][0]).toMatchObject({ current_soc: 80, remaining_km: 150, min_arrival_soc: 10 }); + }); + + it('renders the comfortable verdict with the arrival detail', () => { + mockConfidence.mockReturnValue({ mutate: vi.fn(), data: verdict(), isPending: false, isError: false, error: null }); + render(); + expect(screen.getByText('You will make it')).toBeTruthy(); + expect(screen.getByText("You'll arrive with plenty to spare.")).toBeTruthy(); + }); + + it('renders the charge-first verdict when short', () => { + mockConfidence.mockReturnValue({ + mutate: vi.fn(), + data: verdict({ verdict: 'charge_now', arrival_soc: -4, explanation: "You won't make it." }), + isPending: false, + isError: false, + error: null, + }); + render(); + expect(screen.getByText('Charge first')).toBeTruthy(); + }); +}); diff --git a/web/src/features/driving/components/TripCopilotCard.tsx b/web/src/features/driving/components/TripCopilotCard.tsx new file mode 100644 index 0000000000..3c3431d5f6 --- /dev/null +++ b/web/src/features/driving/components/TripCopilotCard.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Gauge } from 'lucide-react'; + +import { GlassPanel, PanelTitle, Text, Caption, Button, Input, Badge, ErrorText } from '@/components/ui'; +import { Skeleton, EmptyState } from '@/components/feedback'; +import { useTripConfidence } from '@/api/hooks/useDriving'; +import { fmtNumber } from '@/lib/numberFormat'; + +interface TripCopilotCardProps { + currentSoc: number; + minArrivalSoc: number; +} + +function verdictBadge(verdict: string, t: (k: string, d: string) => string): { label: string; variant: 'success' | 'warning' | 'danger' } { + switch (verdict) { + case 'comfortable': + return { label: t('tripPlanner.copilot.comfortable', 'You will make it'), variant: 'success' }; + case 'tight': + return { label: t('tripPlanner.copilot.tight', 'Tight — drive gently'), variant: 'warning' }; + default: + return { label: t('tripPlanner.copilot.chargeNow', 'Charge first'), variant: 'danger' }; + } +} + +/** + * Trip Copilot: en-route "will I make it" check. Enter the remaining + * distance; the verdict blends current SOC, efficiency, and arrival floor. + */ +export function TripCopilotCard({ currentSoc, minArrivalSoc }: TripCopilotCardProps) { + const { t } = useTranslation(); + const [remainingKm, setRemainingKm] = useState(''); + const check = useTripConfidence(); + const result = check.data ?? null; + + const km = Number(remainingKm); + const canCheck = Number.isFinite(km) && km > 0 && !check.isPending; + + return ( + +
+ + + {result && ( + + {verdictBadge(result.verdict, t).label} + + )} +
+ +
+
+ setRemainingKm(e.target.value)} + /> +
+ +
+ +
+ {check.isPending ? ( + + ) : check.isError ? ( + {(check.error as Error)?.message || t('tripPlanner.copilot.error', 'Confidence check failed')} + ) : !result ? ( + } + message={t('tripPlanner.copilot.empty', 'Enter remaining distance for a live arrival verdict.')} + /> + ) : ( +
+ {result.explanation} + + {t('tripPlanner.copilot.detail', 'Arrival ~{{soc}}% · {{margin}} kWh margin', { + soc: fmtNumber(result.arrival_soc, 0), + margin: fmtNumber(result.margin_kwh, 1), + })} + +
+ )} +
+
+ ); +} diff --git a/web/src/features/driving/components/TripCostCard.test.tsx b/web/src/features/driving/components/TripCostCard.test.tsx new file mode 100644 index 0000000000..519c6c9acf --- /dev/null +++ b/web/src/features/driving/components/TripCostCard.test.tsx @@ -0,0 +1,60 @@ +/** + * TripCostCard — EV vs gas readout; empty state without a plan. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +vi.mock('@/hooks/useFormatting', () => ({ + useFormatting: () => ({ + formatCurrency: (amount: number, decimals = 2) => `$${Number(amount ?? 0).toFixed(decimals)}`, + }), +})); + +import { TripCostCard } from './TripCostCard'; + +describe('TripCostCard', () => { + it('shows the empty state without a comparison', () => { + render(); + expect(screen.getByText('Plan a trip to compare EV charging cost against gasoline.')).toBeTruthy(); + }); + + it('renders EV / gas / saved tiles with the detail line', () => { + render( + , + ); + expect(screen.getByText('$12.00')).toBeTruthy(); + expect(screen.getByText('$36.25')).toBeTruthy(); + expect(screen.getByText('$24.25')).toBeTruthy(); + }); +}); diff --git a/web/src/features/driving/components/TripCostCard.tsx b/web/src/features/driving/components/TripCostCard.tsx new file mode 100644 index 0000000000..2750c28aec --- /dev/null +++ b/web/src/features/driving/components/TripCostCard.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next'; +import { PiggyBank } from 'lucide-react'; + +import { GlassPanel, PanelTitle, Text, Caption } from '@/components/ui'; +import { EmptyState } from '@/components/feedback'; +import { useFormatting } from '@/hooks/useFormatting'; +import { fmtNumber, fmtPercent } from '@/lib/numberFormat'; +import type { TripCostComparison } from '@/types/driving'; + +interface TripCostCardProps { + comparison?: TripCostComparison | null; +} + +/** Door-to-door $ readout: EV charging cost vs the gasoline equivalent. */ +export function TripCostCard({ comparison }: TripCostCardProps) { + const { t } = useTranslation(); + const { formatCurrency } = useFormatting(); + + return ( + + + + + {!comparison ? ( + } + message={t('tripPlanner.cost.empty', 'Plan a trip to compare EV charging cost against gasoline.')} + /> + ) : ( +
+
+
+ {t('tripPlanner.cost.ev', 'EV')} + + {formatCurrency(comparison.ev_cost)} + +
+
+ {t('tripPlanner.cost.gas', 'Gas')} + + {formatCurrency(comparison.gas_cost)} + +
+
+ {t('tripPlanner.cost.saved', 'Saved')} + + {formatCurrency(comparison.savings)} + +
+
+ + {t('tripPlanner.cost.detail', '{{gallons}} gal avoided · {{pct}} cheaper · @ ${{price}}/gal, {{mpg}} mpg', { + gallons: fmtNumber(comparison.gas_gallons, 1), + pct: fmtPercent(comparison.savings_pct, 0), + price: fmtNumber(comparison.gas_price_per_gallon, 2), + mpg: fmtNumber(comparison.gas_mpg, 0), + })} + +
+ )} +
+ ); +} diff --git a/web/src/features/driving/pages/TripPlannerPage.tsx b/web/src/features/driving/pages/TripPlannerPage.tsx index cd4a986085..f837348832 100644 --- a/web/src/features/driving/pages/TripPlannerPage.tsx +++ b/web/src/features/driving/pages/TripPlannerPage.tsx @@ -29,6 +29,8 @@ import { usePlanTrip } from '@/api/hooks/useDriving'; import { useVehicleCommand } from '@/api/hooks/useVehicleCommand'; import { AddressInput } from '../components/AddressInput'; import { SOCRouteChart } from '../components/SOCRouteChart'; +import { TripCopilotCard } from '../components/TripCopilotCard'; +import { TripCostCard } from '../components/TripCostCard'; import { TripLegList } from '../components/TripLegList'; import { TripPlannerMap } from '../components/TripPlannerMap'; import { TripShareImportBanner } from '../components/TripShareImportBanner'; @@ -433,6 +435,14 @@ export default function TripPlannerPage() {
+ {/* Row 4b — Copilot + cost: live arrival verdict and EV-vs-gas readout */} + +
+ + +
+
+ {/* Row 5 — Leg-by-leg breakdown: full-width detail band */} diff --git a/web/src/features/fleet-ops/components/DriverDialog.test.tsx b/web/src/features/fleet-ops/components/DriverDialog.test.tsx new file mode 100644 index 0000000000..c7eb5d13c1 --- /dev/null +++ b/web/src/features/fleet-ops/components/DriverDialog.test.tsx @@ -0,0 +1,77 @@ +/** + * DriverDialog — guardrail fields round-trip into the create payload and + * invalid guardrails block submit. + */ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ mutate: vi.fn(), reset: vi.fn() })); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => (typeof fallback === 'string' ? fallback : key), + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +vi.mock('@/api/hooks/useFleetOps', () => { + const mutation = () => ({ mutate: h.mutate, reset: h.reset, isPending: false, error: null }); + return { useCreateFleetDriver: mutation, useUpdateFleetDriver: mutation }; +}); + +import { DriverDialog } from './DriverDialog'; + +const callbacks = { onClose: vi.fn(), onSaved: vi.fn(), onDelete: vi.fn(), onRefresh: vi.fn() }; + +beforeEach(() => { + vi.clearAllMocks(); + h.mutate.mockClear(); +}); + +describe('DriverDialog guardrails', () => { + it('submits charge cap + curfew in the create payload', () => { + render(); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Teen' } }); + fireEvent.change(screen.getByLabelText('Non-sensitive reference code'), { target: { value: 'T1' } }); + fireEvent.change(screen.getByLabelText('Charge cap (%)'), { target: { value: '80' } }); + fireEvent.change(screen.getByLabelText('Curfew start'), { target: { value: '22:00' } }); + fireEvent.change(screen.getByLabelText('Curfew end'), { target: { value: '06:00' } }); + fireEvent.click(screen.getByText('Save')); + + expect(h.mutate).toHaveBeenCalledTimes(1); + expect(h.mutate.mock.calls[0][0]).toMatchObject({ + display_name: 'Teen', + max_charge_soc: 80, + curfew_start: '22:00', + curfew_end: '06:00', + }); + }); + + it('blocks submit on a half-set curfew', () => { + render(); + fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Teen' } }); + fireEvent.change(screen.getByLabelText('Non-sensitive reference code'), { target: { value: 'T1' } }); + fireEvent.change(screen.getByLabelText('Curfew start'), { target: { value: '22:00' } }); + fireEvent.click(screen.getByText('Save')); + + expect(screen.getByText('Set both curfew start and end, or neither.')).toBeTruthy(); + expect(h.mutate).not.toHaveBeenCalled(); + }); + + it('seeds guardrails when editing an existing driver', () => { + render( + , + ); + expect(screen.getByLabelText('Charge cap (%)')).toHaveProperty('value', '80'); + expect(screen.getByLabelText('Curfew start')).toHaveProperty('value', '22:00'); + }); +}); diff --git a/web/src/features/fleet-ops/components/DriverDialog.tsx b/web/src/features/fleet-ops/components/DriverDialog.tsx index dc5d7ebb93..e142f4c148 100644 --- a/web/src/features/fleet-ops/components/DriverDialog.tsx +++ b/web/src/features/fleet-ops/components/DriverDialog.tsx @@ -32,16 +32,25 @@ export function DriverDialog({ displayName: item?.display_name ?? '', referenceCode: item?.reference_code ?? '', status: item?.status ?? 'active' as DriverStatus, + maxChargeSoc: item?.max_charge_soc != null ? String(item.max_charge_soc) : '', + curfewStart: item?.curfew_start ?? '', + curfewEnd: item?.curfew_end ?? '', })); const [displayName, setDisplayName] = useState(initialValues.displayName); const [referenceCode, setReferenceCode] = useState(initialValues.referenceCode); const [status, setStatus] = useState(initialValues.status); - const [errors, setErrors] = useState>>({}); + const [maxChargeSoc, setMaxChargeSoc] = useState(initialValues.maxChargeSoc); + const [curfewStart, setCurfewStart] = useState(initialValues.curfewStart); + const [curfewEnd, setCurfewEnd] = useState(initialValues.curfewEnd); + const [errors, setErrors] = useState>>({}); const error = createMutation.error ?? updateMutation.error; const pending = createMutation.isPending || updateMutation.isPending; const isDirty = displayName !== initialValues.displayName || referenceCode !== initialValues.referenceCode - || status !== initialValues.status; + || status !== initialValues.status + || maxChargeSoc !== initialValues.maxChargeSoc + || curfewStart !== initialValues.curfewStart + || curfewEnd !== initialValues.curfewEnd; const { requestClose, dialogProps: discardDialogProps } = useDiscardChangesGuard( isDirty, onClose, @@ -68,11 +77,28 @@ export function DriverDialog({ } else if (reference.length > 64) { nextErrors.referenceCode = t('fleetOps.driverDialog.referenceLimit', 'Use 64 characters or fewer.'); } + const cap = maxChargeSoc.trim() === '' ? null : Number(maxChargeSoc); + if (cap != null && (!Number.isInteger(cap) || cap < 20 || cap > 100)) { + nextErrors.guardrails = t('fleetOps.driverDialog.capRange', 'Charge cap must be 20–100%.'); + } + const clockRe = /^([01]\d|2[0-3]):[0-5]\d$/; + if ((curfewStart === '') !== (curfewEnd === '')) { + nextErrors.guardrails = t('fleetOps.driverDialog.curfewPair', 'Set both curfew start and end, or neither.'); + } else if ((curfewStart !== '' && !clockRe.test(curfewStart)) || (curfewEnd !== '' && !clockRe.test(curfewEnd))) { + nextErrors.guardrails = t('fleetOps.driverDialog.curfewClock', 'Curfew times must be HH:MM (24h).'); + } setErrors(nextErrors); if (Object.keys(nextErrors).length > 0) { return; } - const input = { display_name: name, reference_code: reference, status }; + const input = { + display_name: name, + reference_code: reference, + status, + max_charge_soc: cap, + curfew_start: curfewStart === '' ? null : curfewStart, + curfew_end: curfewEnd === '' ? null : curfewEnd, + }; if (item) { updateMutation.mutate( { id: item.id, version: item.version, input }, @@ -128,6 +154,39 @@ export function DriverDialog({ { value: 'inactive', label: t('fleetOps.drivers.inactive', 'Inactive') }, ]} /> +
+ { + setMaxChargeSoc(event.target.value); + setErrors((current) => ({ ...current, guardrails: undefined })); + }} + error={errors.guardrails} + /> + { + setCurfewStart(event.target.value); + setErrors((current) => ({ ...current, guardrails: undefined })); + }} + /> + { + setCurfewEnd(event.target.value); + setErrors((current) => ({ ...current, guardrails: undefined })); + }} + /> +
{item && ( diff --git a/web/src/features/fleet-ops/components/DriverRoster.tsx b/web/src/features/fleet-ops/components/DriverRoster.tsx index 67bc93aab1..5ff3109d91 100644 --- a/web/src/features/fleet-ops/components/DriverRoster.tsx +++ b/web/src/features/fleet-ops/components/DriverRoster.tsx @@ -60,6 +60,16 @@ export function DriverRoster({ ), }, + { + key: 'guardrails', + header: t('fleetOps.drivers.guardrails', 'Guardrails'), + render: (item) => { + const parts: string[] = []; + if (item.max_charge_soc != null) parts.push(`${item.max_charge_soc}%`); + if (item.curfew_start && item.curfew_end) parts.push(`${item.curfew_start}–${item.curfew_end}`); + return parts.length > 0 ? parts.join(' · ') : '—'; + }, + }, { key: 'actions', header: t('common.actions', 'Actions'), diff --git a/web/src/features/maps/components/EfficiencyDetectivePanel.test.tsx b/web/src/features/maps/components/EfficiencyDetectivePanel.test.tsx new file mode 100644 index 0000000000..4817586613 --- /dev/null +++ b/web/src/features/maps/components/EfficiencyDetectivePanel.test.tsx @@ -0,0 +1,99 @@ +/** + * EfficiencyDetectivePanel — verdict badges + attribution detail. + * `useEfficiencyShift` is mocked; GlassPanel/Badge render for real. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import type { EfficiencyShift } from '@/api/hooks/useAnalytics'; + +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +vi.mock('@/api/hooks/useAnalytics', () => ({ useEfficiencyShift: vi.fn() })); + +import { useEfficiencyShift } from '@/api/hooks/useAnalytics'; +import { EfficiencyDetectivePanel } from './EfficiencyDetectivePanel'; + +const mockShift = useEfficiencyShift as unknown as ReturnType; + +function shift(over: Partial = {}): EfficiencyShift { + return { + latest_month: '2025-12', + prior_month: '2025-11', + latest_efficiency: 23, + prior_efficiency: 20, + efficiency_delta_pct: 15, + latest_temp_c: 2, + prior_temp_c: 10, + temp_delta_c: -8, + temp_sensitivity_per_c: -0.3, + temp_attributed_pct: 12, + residual_pct: 3, + verdict: 'colder_weather', + explanation: 'Efficiency worsened and colder weather explains it.', + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockShift.mockReturnValue({ data: shift(), isLoading: false, isError: false, error: null, refetch: vi.fn() }); +}); + +describe('EfficiencyDetectivePanel', () => { + it('renders the verdict badge and explanation', () => { + render(); + expect(screen.getByText('Colder weather')).toBeTruthy(); + expect(screen.getByText('Efficiency worsened and colder weather explains it.')).toBeTruthy(); + }); + + it('renders the driving-pattern verdict when temperature cannot explain the move', () => { + mockShift.mockReturnValue({ + data: shift({ verdict: 'driving_pattern', explanation: 'Check tire pressure.' }), + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + render(); + expect(screen.getByText('Driving pattern')).toBeTruthy(); + expect(screen.getByText('Check tire pressure.')).toBeTruthy(); + }); + + it('hides the attribution detail when data is insufficient', () => { + mockShift.mockReturnValue({ + data: shift({ verdict: 'insufficient_data', explanation: 'Need at least two months.' }), + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + }); + render(); + expect(screen.getByText('Need more data')).toBeTruthy(); + expect(screen.queryByText(/temperature-attributed/)).toBeNull(); + }); +}); diff --git a/web/src/features/maps/components/EfficiencyDetectivePanel.tsx b/web/src/features/maps/components/EfficiencyDetectivePanel.tsx new file mode 100644 index 0000000000..fa788d11b3 --- /dev/null +++ b/web/src/features/maps/components/EfficiencyDetectivePanel.tsx @@ -0,0 +1,76 @@ +import { useTranslation } from 'react-i18next'; +import { Lightbulb } from 'lucide-react'; + +import { GlassPanel, Badge, PanelTitle, Text, Caption } from '@/components/ui'; +import { Skeleton, QueryError } from '@/components/feedback'; +import { fmtNumber } from '@/lib/numberFormat'; +import { useEfficiencyShift } from '@/api/hooks/useAnalytics'; + +interface EfficiencyDetectivePanelProps { + vehicleId: string; +} + +function verdictBadge(verdict: string, t: (k: string, d: string) => string): { label: string; variant: 'success' | 'info' | 'warning' | 'neutral' } { + switch (verdict) { + case 'stable': + return { label: t('tempImpact.detective.stable', 'Stable'), variant: 'success' }; + case 'colder_weather': + return { label: t('tempImpact.detective.colder', 'Colder weather'), variant: 'info' }; + case 'warmer_driving': + return { label: t('tempImpact.detective.warmer', 'Warmer weather'), variant: 'info' }; + case 'driving_pattern': + return { label: t('tempImpact.detective.pattern', 'Driving pattern'), variant: 'warning' }; + default: + return { label: t('tempImpact.detective.insufficient', 'Need more data'), variant: 'neutral' }; + } +} + +/** + * Efficiency detective: latest vs prior month diagnosis with temperature + * attribution. Mounted on TemperatureImpactPage below the KPI band. + */ +export function EfficiencyDetectivePanel({ vehicleId }: EfficiencyDetectivePanelProps) { + const { t } = useTranslation(); + const { data, isLoading, isError, error, refetch } = useEfficiencyShift(vehicleId); + + return ( + +
+ + + {data && ( + + {verdictBadge(data.verdict, t).label} + + )} +
+ + {isLoading ? ( + + ) : isError ? ( + refetch()} /> + ) : !data ? ( + {t('tempImpact.detective.noData', 'Select a vehicle to diagnose efficiency shifts.')} + ) : ( +
+ {data.explanation} + {data.verdict !== 'insufficient_data' && ( + + {t( + 'tempImpact.detective.detail', + '{{delta}}% vs prior month · {{attributed}}% temperature-attributed · {{temp}}°C shift', + { + delta: fmtNumber(data.efficiency_delta_pct, 1), + attributed: fmtNumber(data.temp_attributed_pct, 1), + temp: fmtNumber(data.temp_delta_c, 1), + }, + )} + + )} +
+ )} +
+ ); +} diff --git a/web/src/features/maps/pages/TemperatureImpactPage.tsx b/web/src/features/maps/pages/TemperatureImpactPage.tsx index 046621e3cf..adfcc438e9 100644 --- a/web/src/features/maps/pages/TemperatureImpactPage.tsx +++ b/web/src/features/maps/pages/TemperatureImpactPage.tsx @@ -29,6 +29,7 @@ import { type TemperatureImpactPoint, } from '@/api/hooks/useAnalytics'; import { AICabinTemperatureImpactNarrative } from '@/components/ai/AICabinTemperatureImpactNarrative'; +import { EfficiencyDetectivePanel } from '../components/EfficiencyDetectivePanel'; /* ----------------------------------------------------------------*/ /* Types */ @@ -370,6 +371,13 @@ export default function TemperatureImpactPage() { + {/* ── Detective: month-over-month diagnosis ──────────── */} + {!noVehicle && ( + + + + )} + {/* ── Row A: scatter hero + optimal analysis ───────────── */}
= 90%)', + 'Charge Limit Reached', + 'Range Below 50 km', + 'Charge Complete', + 'Charging Started', + 'Charging Stopped Unexpectedly', + 'Supercharging (DC Fast)', + 'Slow Charge Rate', + 'Drive Started', + 'Drive Ended', + 'Speed Limit Exceeded', + 'High Speed Alert (> 160 km/h)', + 'Reverse Gear Engaged', + 'Odometer Milestone (100k km)', + 'Car Unlocked While Parked', + 'Vehicle Locked', + 'Vehicle Unlocked', + 'Sentry Mode Activated', + 'Door Opened While Parked', + 'Window Left Open', + 'Valet Mode Enabled', + 'Guest Mode Enabled', + 'Cabin Overheat (> 40C)', + 'Cabin Freezing (< 0C)', + 'HVAC Left On While Parked', + 'Climate Keeper Active', + 'Steering Wheel Heater On', + 'Tire Pressure Low', + 'Tire Pressure Soft Warning', + 'Front Left Tire Low (< 2.2 bar)', + 'Arrived at Home', + 'Left Home', + 'Arrived at Work', + 'Navigation Started', + 'Driver Seatbelt Unbuckled', + 'Speed Limit Mode Active', + 'PIN to Drive Disabled', + 'High Motor Temperature (> 80C)', + 'HVIL Fault', + 'High Regenerative Braking', + 'Software Update Available', + 'Software Update Installing', + 'Music Playing', + 'Volume Too High', + 'Powershare Active', +] as const; + +const VALID_OPS = new Set(['=', '!=', '<', '<=', '>', '>=', 'changed', 'between', 'outside']); +const VALID_SEVERITY = new Set(['info', 'warn', 'critical']); + +describe('alertRuleTemplates', () => { + it('keeps the original 47 templates', () => { + const names = new Set(ruleTemplates.map((t) => t.name)); + for (const name of ORIGINAL_NAMES) { + expect(names.has(name), name).toBe(true); + } + expect(ORIGINAL_NAMES).toHaveLength(47); + }); + + it('expands well beyond the original catalogue', () => { + expect(ruleTemplates.length).toBeGreaterThanOrEqual(240); + }); + + it('has unique names', () => { + const names = ruleTemplates.map((t) => t.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('has required fields and valid operators', () => { + for (const tpl of ruleTemplates) { + expect(tpl.name.length).toBeGreaterThan(0); + expect(tpl.category.length).toBeGreaterThan(0); + expect(tpl.signal_name.length).toBeGreaterThan(0); + expect(tpl.message.length).toBeGreaterThan(0); + expect(tpl.cooldown_min).toBeGreaterThan(0); + expect(VALID_OPS.has(tpl.op), `${tpl.name} op ${tpl.op}`).toBe(true); + expect(VALID_SEVERITY.has(tpl.severity), `${tpl.name} severity`).toBe(true); + expect(tpl.icon).toBeTruthy(); + + if (tpl.op === 'between' || tpl.op === 'outside') { + expect(tpl.value_min, tpl.name).toBeTypeOf('number'); + expect(tpl.value_max, tpl.name).toBeTypeOf('number'); + } + } + }); +}); diff --git a/web/src/features/notifications/lib/alertRuleTemplates.ts b/web/src/features/notifications/lib/alertRuleTemplates.ts new file mode 100644 index 0000000000..d17f3260c7 --- /dev/null +++ b/web/src/features/notifications/lib/alertRuleTemplates.ts @@ -0,0 +1,355 @@ +/** + * Curated Alert Studio rule templates. + * + * Thresholds follow the same display-unit convention as the original + * catalogue (percent SOC, km/h labels, bar tire pressure, kW charging) + * so cloned rules stay consistent with existing user rules. + */ +import type { ElementType } from 'react' +import type { AlertRuleInput } from '@/api/hooks/useNotifications' +import { Icons } from '@/lib/icons' + +type Severity = NonNullable +type RuleOp = NonNullable + +export interface RuleTemplate { + name: string + icon: ElementType + category: string + severity: Severity + message: string + cooldown_min: number + signal_name: string + op: RuleOp + value_num?: number + value_text?: string + value_bool?: boolean + value_min?: number + value_max?: number +} + +export const ruleTemplates: RuleTemplate[] = [ + // ── Battery (original) ────────────────────────────────────────── + { name: 'Battery Low (< 20%)', icon: Icons.battery, category: 'Battery', severity: 'warn', message: 'Battery at {{BatteryLevel}}%', cooldown_min: 30, signal_name: 'BatteryLevel', op: '<', value_num: 20 }, + { name: 'Battery Critical (< 10%)', icon: Icons.battery, category: 'Battery', severity: 'critical', message: 'Battery critically low at {{BatteryLevel}}%!', cooldown_min: 15, signal_name: 'BatteryLevel', op: '<', value_num: 10 }, + { name: 'Battery Full (>= 90%)', icon: Icons.battery, category: 'Battery', severity: 'info', message: 'Battery reached {{BatteryLevel}}%', cooldown_min: 60, signal_name: 'BatteryLevel', op: '>=', value_num: 90 }, + { name: 'Charge Limit Reached', icon: Icons.battery, category: 'Battery', severity: 'info', message: 'Battery at charge limit {{ChargeLimitSoc}}%', cooldown_min: 60, signal_name: 'BatteryLevel', op: '>=', value_num: 80 }, + { name: 'Range Below 50 km', icon: Icons.battery, category: 'Battery', severity: 'warn', message: 'Range low: {{RatedRange}} km remaining', cooldown_min: 30, signal_name: 'RatedRange', op: '<', value_num: 50 }, + + // ── Charging (original) ───────────────────────────────────────── + { name: 'Charge Complete', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charging complete at {{BatteryLevel}}%', cooldown_min: 60, signal_name: 'ChargeState', op: '=', value_text: 'Complete' }, + { name: 'Charging Started', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charging started - {{DetailedChargeState}}', cooldown_min: 15, signal_name: 'DetailedChargeState', op: '=', value_text: 'Charging' }, + { name: 'Charging Stopped Unexpectedly', icon: Icons.charging, category: 'Charging', severity: 'warn', message: 'Charging stopped - {{DetailedChargeState}}', cooldown_min: 30, signal_name: 'DetailedChargeState', op: '=', value_text: 'Stopped' }, + { name: 'Supercharging (DC Fast)', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Supercharging at {{DCChargingPower}} kW', cooldown_min: 30, signal_name: 'DCChargingPower', op: '>', value_num: 50 }, + { name: 'Slow Charge Rate', icon: Icons.charging, category: 'Charging', severity: 'warn', message: 'Charging slow: {{ChargeAmps}}A', cooldown_min: 60, signal_name: 'ChargeAmps', op: 'between', value_min: 0.01, value_max: 5 }, + + // ── Driving (original) ────────────────────────────────────────── + { name: 'Drive Started', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Drive started - gear is {{Gear}}', cooldown_min: 5, signal_name: 'Gear', op: '=', value_text: 'D' }, + { name: 'Drive Ended', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Drive ended - gear is {{Gear}}', cooldown_min: 5, signal_name: 'Gear', op: '=', value_text: 'P' }, + { name: 'Speed Limit Exceeded', icon: Icons.speed, category: 'Driving', severity: 'warn', message: 'Speed {{VehicleSpeed}} km/h exceeded limit', cooldown_min: 15, signal_name: 'VehicleSpeed', op: '>', value_num: 120 }, + { name: 'High Speed Alert (> 160 km/h)', icon: Icons.speed, category: 'Driving', severity: 'critical', message: 'Very high speed: {{VehicleSpeed}} km/h!', cooldown_min: 5, signal_name: 'VehicleSpeed', op: '>', value_num: 160 }, + { name: 'Reverse Gear Engaged', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Vehicle in reverse', cooldown_min: 5, signal_name: 'Gear', op: '=', value_text: 'R' }, + { name: 'Odometer Milestone (100k km)', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Odometer: {{Odometer}} km', cooldown_min: 1440, signal_name: 'Odometer', op: '>', value_num: 100000 }, + + // ── Security (original) ───────────────────────────────────────── + { name: 'Car Unlocked While Parked', icon: Icons.locked, category: 'Security', severity: 'critical', message: 'Vehicle is unlocked and parked!', cooldown_min: 30, signal_name: 'Locked', op: '=', value_bool: false }, + { name: 'Vehicle Locked', icon: Icons.locked, category: 'Security', severity: 'info', message: 'Vehicle locked', cooldown_min: 5, signal_name: 'Locked', op: '=', value_bool: true }, + { name: 'Vehicle Unlocked', icon: Icons.locked, category: 'Security', severity: 'info', message: 'Vehicle unlocked', cooldown_min: 5, signal_name: 'Locked', op: '=', value_bool: false }, + { name: 'Sentry Mode Activated', icon: Icons.security, category: 'Security', severity: 'info', message: 'Sentry mode activated', cooldown_min: 30, signal_name: 'SentryMode', op: '=', value_bool: true }, + { name: 'Door Opened While Parked', icon: Icons.locked, category: 'Security', severity: 'warn', message: 'Door opened - {{DoorState}}', cooldown_min: 15, signal_name: 'DoorState', op: '!=', value_text: 'Closed' }, + { name: 'Window Left Open', icon: Icons.vehicle, category: 'Security', severity: 'warn', message: 'Front driver window is {{FdWindow}}', cooldown_min: 60, signal_name: 'FdWindow', op: '!=', value_text: 'Closed' }, + { name: 'Valet Mode Enabled', icon: Icons.security, category: 'Security', severity: 'info', message: 'Valet mode enabled', cooldown_min: 60, signal_name: 'ValetModeEnabled', op: '=', value_bool: true }, + { name: 'Guest Mode Enabled', icon: Icons.security, category: 'Security', severity: 'warn', message: 'Guest mode enabled', cooldown_min: 60, signal_name: 'GuestModeEnabled', op: '=', value_bool: true }, + + // ── Climate (original) ────────────────────────────────────────── + { name: 'Cabin Overheat (> 40C)', icon: Icons.climate, category: 'Climate', severity: 'warn', message: 'Cabin temp: {{InsideTemp}}C', cooldown_min: 30, signal_name: 'InsideTemp', op: '>', value_num: 40 }, + { name: 'Cabin Freezing (< 0C)', icon: Icons.climate, category: 'Climate', severity: 'warn', message: 'Cabin temp: {{InsideTemp}}C - freezing!', cooldown_min: 60, signal_name: 'InsideTemp', op: '<', value_num: 0 }, + { name: 'HVAC Left On While Parked', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'HVAC running while parked', cooldown_min: 30, signal_name: 'HvacPower', op: '=', value_bool: true }, + { name: 'Climate Keeper Active', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Climate keeper: {{ClimateKeeperMode}}', cooldown_min: 60, signal_name: 'ClimateKeeperMode', op: '!=', value_text: 'Off' }, + { name: 'Steering Wheel Heater On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Steering wheel heater level {{HvacSteeringWheelHeatLevel}}', cooldown_min: 30, signal_name: 'HvacSteeringWheelHeatLevel', op: '>', value_num: 0 }, + + // ── Tire Pressure (original) ──────────────────────────────────── + { name: 'Tire Pressure Low', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'Low tire pressure detected', cooldown_min: 60, signal_name: 'TpmsHardWarnings', op: '=', value_bool: true }, + { name: 'Tire Pressure Soft Warning', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'Tire pressure slightly low', cooldown_min: 120, signal_name: 'TpmsSoftWarnings', op: '=', value_bool: true }, + { name: 'Front Left Tire Low (< 2.2 bar)', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'FL tire: {{TpmsPressureFl}} bar', cooldown_min: 60, signal_name: 'TpmsPressureFl', op: '<', value_num: 2.2 }, + + // ── Location (original) ───────────────────────────────────────── + { name: 'Arrived at Home', icon: Icons.vehicle, category: 'Location', severity: 'info', message: 'Vehicle arrived at home', cooldown_min: 15, signal_name: 'LocatedAtHome', op: '=', value_bool: true }, + { name: 'Left Home', icon: Icons.vehicle, category: 'Location', severity: 'info', message: 'Vehicle left home', cooldown_min: 15, signal_name: 'LocatedAtHome', op: '=', value_bool: false }, + { name: 'Arrived at Work', icon: Icons.vehicle, category: 'Location', severity: 'info', message: 'Vehicle arrived at work', cooldown_min: 15, signal_name: 'LocatedAtWork', op: '=', value_bool: true }, + { name: 'Navigation Started', icon: Icons.vehicle, category: 'Location', severity: 'info', message: 'Navigating to {{DestinationName}}', cooldown_min: 10, signal_name: 'DestinationName', op: 'changed' }, + + // ── Safety (original) ─────────────────────────────────────────── + { name: 'Driver Seatbelt Unbuckled', icon: Icons.security, category: 'Safety', severity: 'warn', message: 'Driver seatbelt unbuckled while driving!', cooldown_min: 5, signal_name: 'DriverSeatBelt', op: '=', value_bool: false }, + { name: 'Speed Limit Mode Active', icon: Icons.security, category: 'Safety', severity: 'info', message: 'Speed limit mode active', cooldown_min: 60, signal_name: 'SpeedLimitMode', op: '=', value_bool: true }, + { name: 'PIN to Drive Disabled', icon: Icons.security, category: 'Safety', severity: 'warn', message: 'PIN to Drive has been disabled', cooldown_min: 1440, signal_name: 'PinToDriveEnabled', op: '=', value_bool: false }, + + // ── Motor (original) ──────────────────────────────────────────── + { name: 'High Motor Temperature (> 80C)', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'Motor stator temp: {{DiStatorTempF}}C', cooldown_min: 15, signal_name: 'DiStatorTempF', op: '>', value_num: 80 }, + { name: 'HVIL Fault', icon: Icons.security, category: 'Motor', severity: 'critical', message: 'HV interlock fault detected!', cooldown_min: 5, signal_name: 'Hvil', op: '=', value_text: 'Fault' }, + { name: 'High Regenerative Braking', icon: Icons.charging, category: 'Motor', severity: 'info', message: 'Regen power: {{Power}} kW', cooldown_min: 15, signal_name: 'Power', op: '<', value_num: -50 }, + + // ── Software (original) ───────────────────────────────────────── + { name: 'Software Update Available', icon: Icons.charging, category: 'Software', severity: 'info', message: 'Update available: {{SoftwareUpdateVersion}}', cooldown_min: 1440, signal_name: 'SoftwareUpdateVersion', op: 'changed' }, + { name: 'Software Update Installing', icon: Icons.charging, category: 'Software', severity: 'info', message: 'Installing update: {{SoftwareUpdateInstallationPercentComplete}}%', cooldown_min: 30, signal_name: 'SoftwareUpdateInstallationPercentComplete', op: '>', value_num: 0 }, + + // ── Media (original) ──────────────────────────────────────────── + { name: 'Music Playing', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Now playing: {{MediaNowPlayingTitle}} by {{MediaNowPlayingArtist}}', cooldown_min: 60, signal_name: 'MediaPlaybackStatus', op: '=', value_text: 'Playing' }, + { name: 'Volume Too High', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Volume at {{MediaAudioVolume}}', cooldown_min: 30, signal_name: 'MediaAudioVolume', op: '>', value_num: 8 }, + + // ── Powershare (original) ─────────────────────────────────────── + { name: 'Powershare Active', icon: Icons.charging, category: 'Powershare', severity: 'info', message: 'Powershare active: {{PowershareInstantaneousPowerKW}} kW', cooldown_min: 60, signal_name: 'PowershareStatus', op: 'changed' }, + + // ── Battery (new) ─────────────────────────────────────────────── + { name: 'Battery Low (< 30%)', icon: Icons.battery, category: 'Battery', severity: 'info', message: 'Battery dropped to {{BatteryLevel}}%', cooldown_min: 60, signal_name: 'BatteryLevel', op: '<', value_num: 30 }, + { name: 'Battery Very Low (< 5%)', icon: Icons.battery, category: 'Battery', severity: 'critical', message: 'Battery almost empty: {{BatteryLevel}}%', cooldown_min: 10, signal_name: 'BatteryLevel', op: '<', value_num: 5 }, + { name: 'SOC Below 15%', icon: Icons.battery, category: 'Battery', severity: 'warn', message: 'SOC {{Soc}}% — charge soon', cooldown_min: 30, signal_name: 'Soc', op: '<', value_num: 15 }, + { name: 'Energy Remaining Low', icon: Icons.battery, category: 'Battery', severity: 'warn', message: 'Energy remaining: {{EnergyRemaining}}', cooldown_min: 30, signal_name: 'EnergyRemaining', op: '<', value_num: 8 }, + { name: 'Battery Heater On', icon: Icons.climate, category: 'Battery', severity: 'info', message: 'Battery heater is on', cooldown_min: 60, signal_name: 'BatteryHeaterOn', op: '=', value_bool: true }, + { name: 'BMS Full Charge Complete', icon: Icons.battery, category: 'Battery', severity: 'info', message: 'BMS reports full charge complete', cooldown_min: 120, signal_name: 'BmsFullchargecomplete', op: '=', value_bool: true }, + { name: 'Pack Voltage Low', icon: Icons.battery, category: 'Battery', severity: 'warn', message: 'Pack voltage {{PackVoltage}} V', cooldown_min: 30, signal_name: 'PackVoltage', op: '<', value_num: 320 }, + { name: 'Estimated Range Below 80 km', icon: Icons.battery, category: 'Battery', severity: 'warn', message: 'Estimated range {{EstBatteryRange}}', cooldown_min: 30, signal_name: 'EstBatteryRange', op: '<', value_num: 80 }, + { name: 'Ideal Range Below 100 km', icon: Icons.battery, category: 'Battery', severity: 'info', message: 'Ideal range {{IdealBatteryRange}}', cooldown_min: 60, signal_name: 'IdealBatteryRange', op: '<', value_num: 100 }, + { name: 'Charge Limit Changed', icon: Icons.battery, category: 'Battery', severity: 'info', message: 'Charge limit is now {{ChargeLimitSoc}}%', cooldown_min: 60, signal_name: 'ChargeLimitSoc', op: 'changed' }, + { name: 'Module Temp High (> 45C)', icon: Icons.climate, category: 'Battery', severity: 'warn', message: 'Module max temp {{ModuleTempMax}}C', cooldown_min: 20, signal_name: 'ModuleTempMax', op: '>', value_num: 45 }, + { name: 'Module Temp Low (< 5C)', icon: Icons.climate, category: 'Battery', severity: 'info', message: 'Module min temp {{ModuleTempMin}}C — pack is cold', cooldown_min: 60, signal_name: 'ModuleTempMin', op: '<', value_num: 5 }, + + // ── Charging (new) ────────────────────────────────────────────── + { name: 'Charge Port Door Open', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charge port door is open', cooldown_min: 30, signal_name: 'ChargePortDoorOpen', op: '=', value_bool: true }, + { name: 'Charge Port Latched', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charge port latch: {{ChargePortLatch}}', cooldown_min: 15, signal_name: 'ChargePortLatch', op: 'changed' }, + { name: 'Fast Charger Present', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'DC fast charger detected ({{FastChargerType}})', cooldown_min: 30, signal_name: 'FastChargerPresent', op: '=', value_bool: true }, + { name: 'AC Charging Power High', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'AC charging at {{ACChargingPower}}', cooldown_min: 30, signal_name: 'ACChargingPower', op: '>', value_num: 10 }, + { name: 'Long Time to Full (> 8h)', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Time to full charge: {{TimeToFullCharge}} h', cooldown_min: 120, signal_name: 'TimeToFullCharge', op: '>', value_num: 8 }, + { name: 'Scheduled Charging Pending', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Scheduled charging is pending', cooldown_min: 120, signal_name: 'ScheduledChargingPending', op: '=', value_bool: true }, + { name: 'Scheduled Charging Mode Changed', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Scheduled charging mode: {{ScheduledChargingMode}}', cooldown_min: 60, signal_name: 'ScheduledChargingMode', op: 'changed' }, + { name: 'Charging Cable Changed', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Cable type: {{ChargingCableType}}', cooldown_min: 30, signal_name: 'ChargingCableType', op: 'changed' }, + { name: 'Charge Current Request Maxed', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Requested {{ChargeCurrentRequest}}A of {{ChargeCurrentRequestMax}}A max', cooldown_min: 60, signal_name: 'ChargeCurrentRequest', op: 'changed' }, + { name: 'Charger Voltage Present', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charger voltage {{ChargerVoltage}} V', cooldown_min: 30, signal_name: 'ChargerVoltage', op: '>', value_num: 100 }, + { name: 'Three-Phase Charging', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charger phases: {{ChargerPhases}}', cooldown_min: 60, signal_name: 'ChargerPhases', op: '>=', value_num: 3 }, + { name: 'Charge Port Cold Weather Mode', icon: Icons.climate, category: 'Charging', severity: 'info', message: 'Charge port cold-weather mode on', cooldown_min: 120, signal_name: 'ChargePortColdWeatherMode', op: '=', value_bool: true }, + { name: 'Supercharger Trip Planner', icon: Icons.navigation, category: 'Charging', severity: 'info', message: 'Supercharger session trip planner updated', cooldown_min: 15, signal_name: 'SuperchargerSessionTripPlanner', op: 'changed' }, + { name: 'Hours to Charge Termination', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Estimated hours to end: {{EstimatedHoursToChargeTermination}}', cooldown_min: 60, signal_name: 'EstimatedHoursToChargeTermination', op: '>', value_num: 4 }, + { name: 'Charge Enable Request Off', icon: Icons.charging, category: 'Charging', severity: 'warn', message: 'Charge enable request is off', cooldown_min: 30, signal_name: 'ChargeEnableRequest', op: '=', value_bool: false }, + + // ── Driving (new) ─────────────────────────────────────────────── + { name: 'Neutral Gear', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Vehicle in neutral', cooldown_min: 10, signal_name: 'Gear', op: '=', value_text: 'N' }, + { name: 'Hard Acceleration', icon: Icons.speed, category: 'Driving', severity: 'info', message: 'Pedal {{PedalPosition}}%', cooldown_min: 15, signal_name: 'PedalPosition', op: '>', value_num: 80 }, + { name: 'Hard Braking', icon: Icons.speed, category: 'Driving', severity: 'warn', message: 'Brake pedal position {{BrakePedalPos}}', cooldown_min: 10, signal_name: 'BrakePedalPos', op: '>', value_num: 80 }, + { name: 'Brake Pedal Pressed', icon: Icons.speed, category: 'Driving', severity: 'info', message: 'Brake pedal is pressed', cooldown_min: 15, signal_name: 'BrakePedal', op: '=', value_bool: true }, + { name: 'High Longitudinal Accel', icon: Icons.speed, category: 'Driving', severity: 'info', message: 'Longitudinal accel {{LongitudinalAcceleration}}', cooldown_min: 15, signal_name: 'LongitudinalAcceleration', op: '>', value_num: 4 }, + { name: 'High Lateral Accel', icon: Icons.speed, category: 'Driving', severity: 'warn', message: 'Lateral accel {{LateralAcceleration}} — cornering hard', cooldown_min: 15, signal_name: 'LateralAcceleration', op: '>', value_num: 5 }, + { name: 'Cruise Set Speed High', icon: Icons.speed, category: 'Driving', severity: 'info', message: 'Cruise set to {{CruiseSetSpeed}}', cooldown_min: 30, signal_name: 'CruiseSetSpeed', op: '>', value_num: 120 }, + { name: 'Speed Current Limit', icon: Icons.speed, category: 'Driving', severity: 'info', message: 'Current limit {{CurrentLimitMph}}', cooldown_min: 60, signal_name: 'CurrentLimitMph', op: 'changed' }, + { name: 'Drive Rail On', icon: Icons.bolt, category: 'Driving', severity: 'info', message: 'Drive rail energized', cooldown_min: 15, signal_name: 'DriveRail', op: '=', value_bool: true }, + { name: 'Self-Driving Miles Changed', icon: Icons.navigation, category: 'Driving', severity: 'info', message: 'FSD miles since reset: {{SelfDrivingMilesSinceReset}}', cooldown_min: 1440, signal_name: 'SelfDrivingMilesSinceReset', op: 'changed' }, + { name: 'Trip Miles Since Reset', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Miles since reset: {{MilesSinceReset}}', cooldown_min: 1440, signal_name: 'MilesSinceReset', op: '>', value_num: 500 }, + + // ── Security (new) ────────────────────────────────────────────── + { name: 'Frunk Open', icon: Icons.locked, category: 'Security', severity: 'warn', message: 'Front trunk is open', cooldown_min: 15, signal_name: 'DoorStateFrontTrunk', op: '!=', value_text: 'Closed' }, + { name: 'Trunk Open', icon: Icons.locked, category: 'Security', severity: 'warn', message: 'Rear trunk is open', cooldown_min: 15, signal_name: 'DoorStateRearTrunk', op: '!=', value_text: 'Closed' }, + { name: 'Passenger Door Open', icon: Icons.locked, category: 'Security', severity: 'warn', message: 'Passenger front door: {{DoorStatePassengerFront}}', cooldown_min: 15, signal_name: 'DoorStatePassengerFront', op: '!=', value_text: 'Closed' }, + { name: 'Driver Rear Door Open', icon: Icons.locked, category: 'Security', severity: 'warn', message: 'Driver rear door: {{DoorStateDriverRear}}', cooldown_min: 15, signal_name: 'DoorStateDriverRear', op: '!=', value_text: 'Closed' }, + { name: 'Passenger Rear Door Open', icon: Icons.locked, category: 'Security', severity: 'warn', message: 'Passenger rear door: {{DoorStatePassengerRear}}', cooldown_min: 15, signal_name: 'DoorStatePassengerRear', op: '!=', value_text: 'Closed' }, + { name: 'Front Passenger Window Open', icon: Icons.vehicle, category: 'Security', severity: 'warn', message: 'Front passenger window is {{FpWindow}}', cooldown_min: 60, signal_name: 'FpWindow', op: '!=', value_text: 'Closed' }, + { name: 'Rear Driver Window Open', icon: Icons.vehicle, category: 'Security', severity: 'warn', message: 'Rear driver window is {{RdWindow}}', cooldown_min: 60, signal_name: 'RdWindow', op: '!=', value_text: 'Closed' }, + { name: 'Rear Passenger Window Open', icon: Icons.vehicle, category: 'Security', severity: 'warn', message: 'Rear passenger window is {{RpWindow}}', cooldown_min: 60, signal_name: 'RpWindow', op: '!=', value_text: 'Closed' }, + { name: 'Sentry Mode Off', icon: Icons.security, category: 'Security', severity: 'warn', message: 'Sentry mode is off', cooldown_min: 60, signal_name: 'SentryMode', op: '=', value_bool: false }, + { name: 'Homelink Nearby', icon: Icons.location, category: 'Security', severity: 'info', message: 'Homelink is nearby', cooldown_min: 30, signal_name: 'HomelinkNearby', op: '=', value_bool: true }, + { name: 'Remote Start Enabled', icon: Icons.security, category: 'Security', severity: 'info', message: 'Remote start is enabled', cooldown_min: 30, signal_name: 'RemoteStartEnabled', op: '=', value_bool: true }, + { name: 'Paired Keys Changed', icon: Icons.security, category: 'Security', severity: 'warn', message: 'Paired phone/key fob count: {{PairedPhoneKeyAndKeyFobQty}}', cooldown_min: 60, signal_name: 'PairedPhoneKeyAndKeyFobQty', op: 'changed' }, + { name: 'Service Mode On', icon: Icons.security, category: 'Security', severity: 'warn', message: 'Vehicle is in service mode', cooldown_min: 60, signal_name: 'ServiceMode', op: '=', value_bool: true }, + { name: 'Guest Mobile Access Changed', icon: Icons.security, category: 'Security', severity: 'info', message: 'Guest mobile access: {{GuestModeMobileAccessState}}', cooldown_min: 60, signal_name: 'GuestModeMobileAccessState', op: 'changed' }, + + // ── Climate (new) ─────────────────────────────────────────────── + { name: 'Outside Extreme Heat (> 38C)', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Outside temp {{OutsideTemp}}C', cooldown_min: 120, signal_name: 'OutsideTemp', op: '>', value_num: 38 }, + { name: 'Outside Freezing (< 0C)', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Outside temp {{OutsideTemp}}C', cooldown_min: 120, signal_name: 'OutsideTemp', op: '<', value_num: 0 }, + { name: 'Preconditioning On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Preconditioning is enabled', cooldown_min: 30, signal_name: 'PreconditioningEnabled', op: '=', value_bool: true }, + { name: 'HVAC AC On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'A/C is on', cooldown_min: 30, signal_name: 'HvacACEnabled', op: '=', value_bool: true }, + { name: 'Cabin Overheat Protection', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Cabin overheat protection: {{CabinOverheatProtectionMode}}', cooldown_min: 60, signal_name: 'CabinOverheatProtectionMode', op: 'changed' }, + { name: 'Defrost On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Defrost mode: {{DefrostMode}}', cooldown_min: 30, signal_name: 'DefrostMode', op: '!=', value_text: 'Off' }, + { name: 'Rear Defrost On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Rear defrost is on', cooldown_min: 30, signal_name: 'RearDefrostEnabled', op: '=', value_bool: true }, + { name: 'Cabin Fan High', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Fan speed {{HvacFanSpeed}}', cooldown_min: 30, signal_name: 'HvacFanSpeed', op: '>', value_num: 8 }, + { name: 'Driver Seat Heater On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Driver seat heater {{SeatHeaterLeft}}', cooldown_min: 30, signal_name: 'SeatHeaterLeft', op: '>', value_num: 0 }, + { name: 'Passenger Seat Heater On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Passenger seat heater {{SeatHeaterRight}}', cooldown_min: 30, signal_name: 'SeatHeaterRight', op: '>', value_num: 0 }, + { name: 'Seat Vent On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Seat ventilation is on', cooldown_min: 30, signal_name: 'SeatVentEnabled', op: '=', value_bool: true }, + { name: 'Not Enough Power to Heat', icon: Icons.climate, category: 'Climate', severity: 'warn', message: 'Not enough pack power to heat cabin', cooldown_min: 30, signal_name: 'NotEnoughPowerToHeat', op: '=', value_bool: true }, + { name: 'Wiper Heat On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Wiper heat is on', cooldown_min: 60, signal_name: 'WiperHeatEnabled', op: '=', value_bool: true }, + { name: 'Defrost for Preconditioning', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Defrost-for-preconditioning is on', cooldown_min: 30, signal_name: 'DefrostForPreconditioning', op: '=', value_bool: true }, + + // ── Tire Pressure (new) ───────────────────────────────────────── + { name: 'Front Right Tire Low (< 2.2 bar)', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'FR tire: {{TpmsPressureFr}} bar', cooldown_min: 60, signal_name: 'TpmsPressureFr', op: '<', value_num: 2.2 }, + { name: 'Rear Left Tire Low (< 2.2 bar)', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'RL tire: {{TpmsPressureRl}} bar', cooldown_min: 60, signal_name: 'TpmsPressureRl', op: '<', value_num: 2.2 }, + { name: 'Rear Right Tire Low (< 2.2 bar)', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'RR tire: {{TpmsPressureRr}} bar', cooldown_min: 60, signal_name: 'TpmsPressureRr', op: '<', value_num: 2.2 }, + { name: 'Front Left Tire High (> 3.2 bar)', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'FL tire overinflated: {{TpmsPressureFl}} bar', cooldown_min: 120, signal_name: 'TpmsPressureFl', op: '>', value_num: 3.2 }, + { name: 'TPMS Hard Warning FL', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'Hard TPMS warning — front left', cooldown_min: 60, signal_name: 'TpmsHardWarningsFrontLeft', op: '=', value_bool: true }, + { name: 'TPMS Hard Warning FR', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'Hard TPMS warning — front right', cooldown_min: 60, signal_name: 'TpmsHardWarningsFrontRight', op: '=', value_bool: true }, + { name: 'TPMS Hard Warning RL', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'Hard TPMS warning — rear left', cooldown_min: 60, signal_name: 'TpmsHardWarningsRearLeft', op: '=', value_bool: true }, + { name: 'TPMS Hard Warning RR', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'Hard TPMS warning — rear right', cooldown_min: 60, signal_name: 'TpmsHardWarningsRearRight', op: '=', value_bool: true }, + { name: 'TPMS Soft Warning FL', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'Soft TPMS warning — front left', cooldown_min: 120, signal_name: 'TpmsSoftWarningsFrontLeft', op: '=', value_bool: true }, + + // ── Location (new) ────────────────────────────────────────────── + { name: 'Left Work', icon: Icons.location, category: 'Location', severity: 'info', message: 'Vehicle left work', cooldown_min: 15, signal_name: 'LocatedAtWork', op: '=', value_bool: false }, + { name: 'Arrived at Favorite', icon: Icons.location, category: 'Location', severity: 'info', message: 'Vehicle arrived at a favorite location', cooldown_min: 15, signal_name: 'LocatedAtFavorite', op: '=', value_bool: true }, + { name: 'Left Favorite', icon: Icons.location, category: 'Location', severity: 'info', message: 'Vehicle left a favorite location', cooldown_min: 15, signal_name: 'LocatedAtFavorite', op: '=', value_bool: false }, + { name: 'Arrival in Under 10 Minutes', icon: Icons.navigation, category: 'Location', severity: 'info', message: '{{MinutesToArrival}} min to {{DestinationName}}', cooldown_min: 15, signal_name: 'MinutesToArrival', op: '<', value_num: 10 }, + { name: 'Arrival in Under 5 km', icon: Icons.navigation, category: 'Location', severity: 'info', message: '{{MilesToArrival}} remaining to destination', cooldown_min: 15, signal_name: 'MilesToArrival', op: '<', value_num: 5 }, + { name: 'Heavy Traffic Delay (> 15 min)', icon: Icons.navigation, category: 'Location', severity: 'info', message: 'Traffic delay {{RouteTrafficMinutesDelay}} min', cooldown_min: 20, signal_name: 'RouteTrafficMinutesDelay', op: '>', value_num: 15 }, + { name: 'GPS Lost', icon: Icons.location, category: 'Location', severity: 'warn', message: 'GPS state: {{GpsState}}', cooldown_min: 30, signal_name: 'GpsState', op: 'changed' }, + + // ── Safety / ADAS (new) ───────────────────────────────────────── + { name: 'Passenger Seatbelt Unbuckled', icon: Icons.security, category: 'Safety', severity: 'warn', message: 'Passenger seatbelt unbuckled', cooldown_min: 5, signal_name: 'PassengerSeatBelt', op: '=', value_bool: false }, + { name: 'Automatic Emergency Braking Off', icon: Icons.security, category: 'Safety', severity: 'critical', message: 'Automatic emergency braking is off', cooldown_min: 60, signal_name: 'AutomaticEmergencyBrakingOff', op: '=', value_bool: true }, + { name: 'Forward Collision Warning', icon: Icons.security, category: 'Safety', severity: 'warn', message: 'Forward collision warning: {{ForwardCollisionWarning}}', cooldown_min: 15, signal_name: 'ForwardCollisionWarning', op: 'changed' }, + { name: 'Lane Departure Avoidance Off', icon: Icons.security, category: 'Safety', severity: 'warn', message: 'Lane departure avoidance: {{LaneDepartureAvoidance}}', cooldown_min: 60, signal_name: 'LaneDepartureAvoidance', op: 'changed' }, + { name: 'Emergency Lane Departure', icon: Icons.security, category: 'Safety', severity: 'warn', message: 'Emergency lane departure avoidance: {{EmergencyLaneDepartureAvoidance}}', cooldown_min: 30, signal_name: 'EmergencyLaneDepartureAvoidance', op: 'changed' }, + { name: 'Speed Limit Warning', icon: Icons.speed, category: 'Safety', severity: 'info', message: 'Speed limit warning: {{SpeedLimitWarning}}', cooldown_min: 30, signal_name: 'SpeedLimitWarning', op: 'changed' }, + { name: 'Hazards On', icon: Icons.warning, category: 'Safety', severity: 'warn', message: 'Hazard lights are on', cooldown_min: 15, signal_name: 'LightsHazardsActive', op: '=', value_bool: true }, + { name: 'High Beams On', icon: Icons.vehicle, category: 'Safety', severity: 'info', message: 'High beams are on', cooldown_min: 30, signal_name: 'LightsHighBeams', op: '=', value_bool: true }, + { name: 'Turn Signal On', icon: Icons.vehicle, category: 'Safety', severity: 'info', message: 'Turn signal: {{LightsTurnSignal}}', cooldown_min: 15, signal_name: 'LightsTurnSignal', op: 'changed' }, + { name: 'Blind Spot Camera Auto', icon: Icons.security, category: 'Safety', severity: 'info', message: 'Automatic blind-spot camera: {{AutomaticBlindSpotCamera}}', cooldown_min: 120, signal_name: 'AutomaticBlindSpotCamera', op: 'changed' }, + { name: 'Blind Spot Chime Changed', icon: Icons.security, category: 'Safety', severity: 'info', message: 'Blind-spot collision chime: {{BlindSpotCollisionWarningChime}}', cooldown_min: 120, signal_name: 'BlindSpotCollisionWarningChime', op: 'changed' }, + { name: 'Cruise Follow Distance Changed', icon: Icons.speed, category: 'Safety', severity: 'info', message: 'Follow distance {{CruiseFollowDistance}}', cooldown_min: 30, signal_name: 'CruiseFollowDistance', op: 'changed' }, + + // ── Motor / powertrain (new) ──────────────────────────────────── + { name: 'Rear Motor Temp High (> 80C)', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'Rear stator temp {{DiStatorTempR}}C', cooldown_min: 15, signal_name: 'DiStatorTempR', op: '>', value_num: 80 }, + { name: 'Front Inverter Temp High (> 80C)', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'Front inverter temp {{DiInverterTF}}C', cooldown_min: 15, signal_name: 'DiInverterTF', op: '>', value_num: 80 }, + { name: 'Front Heatsink Temp High (> 75C)', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'Front heatsink {{DiHeatsinkTF}}C', cooldown_min: 15, signal_name: 'DiHeatsinkTF', op: '>', value_num: 75 }, + { name: 'Isolation Resistance Low', icon: Icons.security, category: 'Motor', severity: 'critical', message: 'Isolation resistance {{IsolationResistance}}', cooldown_min: 10, signal_name: 'IsolationResistance', op: '<', value_num: 500 }, + { name: 'High Pack Current', icon: Icons.bolt, category: 'Motor', severity: 'info', message: 'Pack current {{PackCurrent}} A', cooldown_min: 15, signal_name: 'PackCurrent', op: '>', value_num: 400 }, + { name: 'Lifetime Drive Energy Changed', icon: Icons.bolt, category: 'Motor', severity: 'info', message: 'Lifetime drive energy {{LifetimeEnergyUsedDrive}}', cooldown_min: 1440, signal_name: 'LifetimeEnergyUsedDrive', op: 'changed' }, + { name: 'Lifetime Regen Energy Changed', icon: Icons.charging, category: 'Motor', severity: 'info', message: 'Lifetime regen {{LifetimeEnergyGainedRegen}}', cooldown_min: 1440, signal_name: 'LifetimeEnergyGainedRegen', op: 'changed' }, + + // ── Software (new) ────────────────────────────────────────────── + { name: 'Software Download Progress', icon: Icons.charging, category: 'Software', severity: 'info', message: 'Update download {{SoftwareUpdateDownloadPercentComplete}}%', cooldown_min: 30, signal_name: 'SoftwareUpdateDownloadPercentComplete', op: '>', value_num: 0 }, + { name: 'Software Update Scheduled', icon: Icons.charging, category: 'Software', severity: 'info', message: 'Update scheduled at {{SoftwareUpdateScheduledStartTime}}', cooldown_min: 180, signal_name: 'SoftwareUpdateScheduledStartTime', op: 'changed' }, + { name: 'Vehicle Software Version Changed', icon: Icons.charging, category: 'Software', severity: 'info', message: 'Software version {{Version}}', cooldown_min: 1440, signal_name: 'Version', op: 'changed' }, + { name: 'Update Duration Changed', icon: Icons.charging, category: 'Software', severity: 'info', message: 'Expected install {{SoftwareUpdateExpectedDurationMinutes}} min', cooldown_min: 180, signal_name: 'SoftwareUpdateExpectedDurationMinutes', op: 'changed' }, + + // ── Media (new) ───────────────────────────────────────────────── + { name: 'Media Paused', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Playback paused', cooldown_min: 30, signal_name: 'MediaPlaybackStatus', op: '=', value_text: 'Paused' }, + { name: 'Now Playing Changed', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Now playing {{MediaNowPlayingTitle}}', cooldown_min: 15, signal_name: 'MediaNowPlayingTitle', op: 'changed' }, + { name: 'Media Source Changed', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Source: {{MediaPlaybackSource}}', cooldown_min: 30, signal_name: 'MediaPlaybackSource', op: 'changed' }, + { name: 'Volume Muted', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Volume is {{MediaAudioVolume}}', cooldown_min: 30, signal_name: 'MediaAudioVolume', op: '=', value_num: 0 }, + + // ── Powershare (new) ──────────────────────────────────────────── + { name: 'Powershare Hours Low', icon: Icons.charging, category: 'Powershare', severity: 'warn', message: 'Powershare hours left: {{PowershareHoursLeft}}', cooldown_min: 30, signal_name: 'PowershareHoursLeft', op: '<', value_num: 1 }, + { name: 'Powershare Stop Reason', icon: Icons.charging, category: 'Powershare', severity: 'info', message: 'Powershare stopped: {{PowershareStopReason}}', cooldown_min: 30, signal_name: 'PowershareStopReason', op: 'changed' }, + { name: 'Powershare High Power', icon: Icons.charging, category: 'Powershare', severity: 'info', message: 'Powershare {{PowershareInstantaneousPowerKW}} kW', cooldown_min: 30, signal_name: 'PowershareInstantaneousPowerKW', op: '>', value_num: 5 }, + { name: 'Powershare Type Changed', icon: Icons.charging, category: 'Powershare', severity: 'info', message: 'Powershare type {{PowershareType}}', cooldown_min: 60, signal_name: 'PowershareType', op: 'changed' }, + + // ── Energy (ecosystem) ────────────────────────────────────────── + { name: 'AC Energy In High', icon: Icons.bolt, category: 'Energy', severity: 'info', message: 'AC energy in {{ACChargingEnergyIn}}', cooldown_min: 60, signal_name: 'ACChargingEnergyIn', op: '>', value_num: 10 }, + { name: 'DC Energy In High', icon: Icons.bolt, category: 'Energy', severity: 'info', message: 'DC energy in {{DCChargingEnergyIn}}', cooldown_min: 30, signal_name: 'DCChargingEnergyIn', op: '>', value_num: 20 }, + { name: 'Lifetime Energy Used Changed', icon: Icons.bolt, category: 'Energy', severity: 'info', message: 'Lifetime energy {{LifetimeEnergyUsed}}', cooldown_min: 1440, signal_name: 'LifetimeEnergyUsed', op: 'changed' }, + { name: 'Arrival Energy Below 20%', icon: Icons.navigation, category: 'Energy', severity: 'warn', message: 'Expected energy at arrival {{ExpectedEnergyPercentAtTripArrival}}%', cooldown_min: 20, signal_name: 'ExpectedEnergyPercentAtTripArrival', op: '<', value_num: 20 }, + { name: 'Arrival Energy Below 10%', icon: Icons.navigation, category: 'Energy', severity: 'critical', message: 'Arrival SoC only {{ExpectedEnergyPercentAtTripArrival}}%', cooldown_min: 10, signal_name: 'ExpectedEnergyPercentAtTripArrival', op: '<', value_num: 10 }, + { name: 'High Range-Add Rate', icon: Icons.charging, category: 'Energy', severity: 'info', message: 'Range-add rate {{ChargeRateMilePerHour}}', cooldown_min: 30, signal_name: 'ChargeRateMilePerHour', op: '>', value_num: 150 }, + { name: 'DC-DC Converter On', icon: Icons.bolt, category: 'Energy', severity: 'info', message: 'DC-DC converter enabled', cooldown_min: 30, signal_name: 'DCDCEnable', op: '=', value_bool: true }, + { name: 'BMS State Changed', icon: Icons.battery, category: 'Energy', severity: 'info', message: 'BMS state {{BMSState}}', cooldown_min: 30, signal_name: 'BMSState', op: 'changed' }, + { name: 'Brick Voltage High', icon: Icons.battery, category: 'Energy', severity: 'warn', message: 'Max brick {{BrickVoltageMax}} V', cooldown_min: 20, signal_name: 'BrickVoltageMax', op: '>', value_num: 4.2 }, + { name: 'Brick Voltage Low', icon: Icons.battery, category: 'Energy', severity: 'critical', message: 'Min brick {{BrickVoltageMin}} V', cooldown_min: 10, signal_name: 'BrickVoltageMin', op: '<', value_num: 3.2 }, + { name: 'Brick Voltage Spread', icon: Icons.battery, category: 'Energy', severity: 'warn', message: 'Brick max {{BrickVoltageMax}} / min {{BrickVoltageMin}}', cooldown_min: 30, signal_name: 'NumBrickVoltageMax', op: 'changed' }, + { name: 'Module Temp Cell Index Changed', icon: Icons.climate, category: 'Energy', severity: 'info', message: 'Hottest module index {{NumModuleTempMax}}', cooldown_min: 60, signal_name: 'NumModuleTempMax', op: 'changed' }, + + // ── Charging extras (ecosystem) ───────────────────────────────── + { name: 'Charge Port State Changed', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charge port {{ChargePort}}', cooldown_min: 15, signal_name: 'ChargePort', op: 'changed' }, + { name: 'Max Charge Current Changed', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Max request {{ChargeCurrentRequestMax}}A', cooldown_min: 60, signal_name: 'ChargeCurrentRequestMax', op: 'changed' }, + { name: 'Fast Charger Type Changed', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Fast charger type {{FastChargerType}}', cooldown_min: 30, signal_name: 'FastChargerType', op: 'changed' }, + { name: 'Scheduled Charge Start Changed', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Scheduled start {{ScheduledChargingStartTime}}', cooldown_min: 60, signal_name: 'ScheduledChargingStartTime', op: 'changed' }, + { name: 'Scheduled Departure Changed', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Departure {{ScheduledDepartureTime}}', cooldown_min: 60, signal_name: 'ScheduledDepartureTime', op: 'changed' }, + + // ── Climate extras (ecosystem) ────────────────────────────────── + { name: 'Auto Seat Climate Left On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Driver auto seat climate on', cooldown_min: 30, signal_name: 'AutoSeatClimateLeft', op: '=', value_bool: true }, + { name: 'Auto Seat Climate Right On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Passenger auto seat climate on', cooldown_min: 30, signal_name: 'AutoSeatClimateRight', op: '=', value_bool: true }, + { name: 'Driver Seat Cooling On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Driver seat cooling {{ClimateSeatCoolingFrontLeft}}', cooldown_min: 30, signal_name: 'ClimateSeatCoolingFrontLeft', op: '>', value_num: 0 }, + { name: 'Passenger Seat Cooling On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Passenger seat cooling {{ClimateSeatCoolingFrontRight}}', cooldown_min: 30, signal_name: 'ClimateSeatCoolingFrontRight', op: '>', value_num: 0 }, + { name: 'Cabin Overheat Temp Limit Changed', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'COP limit {{CabinOverheatProtectionTemperatureLimit}}', cooldown_min: 120, signal_name: 'CabinOverheatProtectionTemperatureLimit', op: 'changed' }, + { name: 'HVAC Auto Mode Changed', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'HVAC auto {{HvacAutoMode}}', cooldown_min: 30, signal_name: 'HvacAutoMode', op: 'changed' }, + { name: 'HVAC Fan Status Changed', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Fan status {{HvacFanStatus}}', cooldown_min: 30, signal_name: 'HvacFanStatus', op: 'changed' }, + { name: 'Driver Temp Request High', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Left temp request {{HvacLeftTemperatureRequest}}', cooldown_min: 30, signal_name: 'HvacLeftTemperatureRequest', op: '>', value_num: 24 }, + { name: 'Passenger Temp Request Low', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Right temp request {{HvacRightTemperatureRequest}}', cooldown_min: 30, signal_name: 'HvacRightTemperatureRequest', op: '<', value_num: 18 }, + { name: 'Steering Heat Auto On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Steering wheel auto-heat on', cooldown_min: 30, signal_name: 'HvacSteeringWheelHeatAuto', op: '=', value_bool: true }, + { name: 'Rear Display HVAC On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Rear display HVAC enabled', cooldown_min: 30, signal_name: 'RearDisplayHvacEnabled', op: '=', value_bool: true }, + { name: 'Rear Seat Heaters On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Rear seat heaters {{RearSeatHeaters}}', cooldown_min: 30, signal_name: 'RearSeatHeaters', op: '>', value_num: 0 }, + { name: 'Rear Center Seat Heat On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Rear center heater {{SeatHeaterRearCenter}}', cooldown_min: 30, signal_name: 'SeatHeaterRearCenter', op: '>', value_num: 0 }, + { name: 'Rear Left Seat Heat On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Rear left heater {{SeatHeaterRearLeft}}', cooldown_min: 30, signal_name: 'SeatHeaterRearLeft', op: '>', value_num: 0 }, + { name: 'Rear Right Seat Heat On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Rear right heater {{SeatHeaterRearRight}}', cooldown_min: 30, signal_name: 'SeatHeaterRearRight', op: '>', value_num: 0 }, + + // ── Occupancy (ecosystem) ─────────────────────────────────────── + { name: 'Driver Seat Occupied', icon: Icons.vehicle, category: 'Occupancy', severity: 'info', message: 'Driver seat is occupied', cooldown_min: 15, signal_name: 'DriverSeatOccupied', op: '=', value_bool: true }, + { name: 'Driver Seat Empty', icon: Icons.vehicle, category: 'Occupancy', severity: 'info', message: 'Driver seat is empty', cooldown_min: 15, signal_name: 'DriverSeatOccupied', op: '=', value_bool: false }, + { name: 'Driver Door Open', icon: Icons.locked, category: 'Occupancy', severity: 'info', message: 'Driver door {{DoorStateDriverFront}}', cooldown_min: 10, signal_name: 'DoorStateDriverFront', op: '!=', value_text: 'Closed' }, + + // ── Driving extras (ecosystem) ────────────────────────────────── + { name: 'Front Axle Speed High', icon: Icons.speed, category: 'Driving', severity: 'info', message: 'Front axle {{DiAxleSpeedF}}', cooldown_min: 15, signal_name: 'DiAxleSpeedF', op: '>', value_num: 100 }, + { name: 'Rear Axle Speed High', icon: Icons.speed, category: 'Driving', severity: 'info', message: 'Rear axle {{DiAxleSpeedR}}', cooldown_min: 15, signal_name: 'DiAxleSpeedR', op: '>', value_num: 100 }, + { name: 'Front Motor Current High', icon: Icons.bolt, category: 'Motor', severity: 'info', message: 'Front motor current {{DiMotorCurrentF}} A', cooldown_min: 15, signal_name: 'DiMotorCurrentF', op: '>', value_num: 250 }, + { name: 'Rear Motor Current High', icon: Icons.bolt, category: 'Motor', severity: 'info', message: 'Rear motor current {{DiMotorCurrentR}} A', cooldown_min: 15, signal_name: 'DiMotorCurrentR', op: '>', value_num: 250 }, + { name: 'Front Torque High', icon: Icons.speed, category: 'Motor', severity: 'info', message: 'Front torque {{DiTorqueActualF}}', cooldown_min: 15, signal_name: 'DiTorqueActualF', op: '>', value_num: 200 }, + { name: 'Rear Torque High', icon: Icons.speed, category: 'Motor', severity: 'info', message: 'Rear torque {{DiTorqueActualR}}', cooldown_min: 15, signal_name: 'DiTorqueActualR', op: '>', value_num: 200 }, + { name: 'Torque Command High', icon: Icons.speed, category: 'Motor', severity: 'info', message: 'Torque command {{DiSlaveTorqueCmd}}', cooldown_min: 15, signal_name: 'DiSlaveTorqueCmd', op: '>', value_num: 200 }, + { name: 'Motor Torque High', icon: Icons.speed, category: 'Motor', severity: 'info', message: 'Motor torque {{DiTorquemotor}}', cooldown_min: 15, signal_name: 'DiTorquemotor', op: '>', value_num: 250 }, + { name: 'REL Stator Temp High', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'REL stator {{DiStatorTempREL}}C', cooldown_min: 15, signal_name: 'DiStatorTempREL', op: '>', value_num: 80 }, + { name: 'RER Stator Temp High', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'RER stator {{DiStatorTempRER}}C', cooldown_min: 15, signal_name: 'DiStatorTempRER', op: '>', value_num: 80 }, + { name: 'Rear Heatsink Temp High', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'Rear heatsink {{DiHeatsinkTR}}C', cooldown_min: 15, signal_name: 'DiHeatsinkTR', op: '>', value_num: 75 }, + { name: 'REL Heatsink Temp High', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'REL heatsink {{DiHeatsinkTREL}}C', cooldown_min: 15, signal_name: 'DiHeatsinkTREL', op: '>', value_num: 75 }, + { name: 'RER Heatsink Temp High', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'RER heatsink {{DiHeatsinkTRER}}C', cooldown_min: 15, signal_name: 'DiHeatsinkTRER', op: '>', value_num: 75 }, + { name: 'Rear Inverter Temp High', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'Rear inverter {{DiInverterTR}}C', cooldown_min: 15, signal_name: 'DiInverterTR', op: '>', value_num: 80 }, + { name: 'REL Inverter Temp High', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'REL inverter {{DiInverterTREL}}C', cooldown_min: 15, signal_name: 'DiInverterTREL', op: '>', value_num: 80 }, + { name: 'RER Inverter Temp High', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'RER inverter {{DiInverterTRER}}C', cooldown_min: 15, signal_name: 'DiInverterTRER', op: '>', value_num: 80 }, + { name: 'Front Drive Unit State Changed', icon: Icons.bolt, category: 'Motor', severity: 'info', message: 'Front DU {{DiStateF}}', cooldown_min: 30, signal_name: 'DiStateF', op: 'changed' }, + { name: 'Rear Drive Unit State Changed', icon: Icons.bolt, category: 'Motor', severity: 'info', message: 'Rear DU {{DiStateR}}', cooldown_min: 30, signal_name: 'DiStateR', op: 'changed' }, + { name: 'Front Pack Voltage Low', icon: Icons.battery, category: 'Motor', severity: 'warn', message: 'Front Vbat {{DiVBatF}} V', cooldown_min: 20, signal_name: 'DiVBatF', op: '<', value_num: 300 }, + { name: 'Rear Pack Voltage Low', icon: Icons.battery, category: 'Motor', severity: 'warn', message: 'Rear Vbat {{DiVBatR}} V', cooldown_min: 20, signal_name: 'DiVBatR', op: '<', value_num: 300 }, + { name: 'REL Motor Current High', icon: Icons.bolt, category: 'Motor', severity: 'info', message: 'REL current {{DiMotorCurrentREL}} A', cooldown_min: 15, signal_name: 'DiMotorCurrentREL', op: '>', value_num: 250 }, + { name: 'RER Motor Current High', icon: Icons.bolt, category: 'Motor', severity: 'info', message: 'RER current {{DiMotorCurrentRER}} A', cooldown_min: 15, signal_name: 'DiMotorCurrentRER', op: '>', value_num: 250 }, + + // ── Location extras (ecosystem) ───────────────────────────────── + { name: 'GPS Heading Changed', icon: Icons.navigation, category: 'Location', severity: 'info', message: 'Heading {{GpsHeading}}', cooldown_min: 30, signal_name: 'GpsHeading', op: 'changed' }, + { name: 'Route Recalculated', icon: Icons.navigation, category: 'Location', severity: 'info', message: 'Route last updated {{RouteLastUpdated}}', cooldown_min: 10, signal_name: 'RouteLastUpdated', op: 'changed' }, + { name: 'Route Line Updated', icon: Icons.navigation, category: 'Location', severity: 'info', message: 'Nav route geometry updated', cooldown_min: 15, signal_name: 'RouteLine', op: 'changed' }, + { name: 'Destination Latitude Changed', icon: Icons.navigation, category: 'Location', severity: 'info', message: 'New destination lat {{DestinationLocationLatitude}}', cooldown_min: 10, signal_name: 'DestinationLocationLatitude', op: 'changed' }, + { name: 'Origin Changed', icon: Icons.location, category: 'Location', severity: 'info', message: 'Trip origin updated', cooldown_min: 30, signal_name: 'OriginLocationLatitude', op: 'changed' }, + + // ── Tires extras (ecosystem) ──────────────────────────────────── + { name: 'TPMS Soft Warning FR', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'Soft TPMS warning — front right', cooldown_min: 120, signal_name: 'TpmsSoftWarningsFrontRight', op: '=', value_bool: true }, + { name: 'TPMS Soft Warning RL', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'Soft TPMS warning — rear left', cooldown_min: 120, signal_name: 'TpmsSoftWarningsRearLeft', op: '=', value_bool: true }, + { name: 'TPMS Soft Warning RR', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'Soft TPMS warning — rear right', cooldown_min: 120, signal_name: 'TpmsSoftWarningsRearRight', op: '=', value_bool: true }, + { name: 'FL Pressure Stale', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'FL last seen {{TpmsLastSeenPressureTimeFl}}', cooldown_min: 180, signal_name: 'TpmsLastSeenPressureTimeFl', op: 'changed' }, + { name: 'FR Pressure Stale', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'FR last seen {{TpmsLastSeenPressureTimeFr}}', cooldown_min: 180, signal_name: 'TpmsLastSeenPressureTimeFr', op: 'changed' }, + { name: 'RL Pressure Stale', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'RL last seen {{TpmsLastSeenPressureTimeRl}}', cooldown_min: 180, signal_name: 'TpmsLastSeenPressureTimeRl', op: 'changed' }, + { name: 'RR Pressure Stale', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'RR last seen {{TpmsLastSeenPressureTimeRr}}', cooldown_min: 180, signal_name: 'TpmsLastSeenPressureTimeRr', op: 'changed' }, + + // ── Media extras (ecosystem) ──────────────────────────────────── + { name: 'Now Playing Artist Changed', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Artist {{MediaNowPlayingArtist}}', cooldown_min: 15, signal_name: 'MediaNowPlayingArtist', op: 'changed' }, + { name: 'Now Playing Album Changed', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Album {{MediaNowPlayingAlbum}}', cooldown_min: 15, signal_name: 'MediaNowPlayingAlbum', op: 'changed' }, + { name: 'Radio Station Changed', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Station {{MediaNowPlayingStation}}', cooldown_min: 15, signal_name: 'MediaNowPlayingStation', op: 'changed' }, + { name: 'Volume Max Changed', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Volume max {{MediaAudioVolumeMax}}', cooldown_min: 120, signal_name: 'MediaAudioVolumeMax', op: 'changed' }, + { name: 'Volume Increment Changed', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Volume step {{MediaAudioVolumeIncrement}}', cooldown_min: 120, signal_name: 'MediaAudioVolumeIncrement', op: 'changed' }, + { name: 'Track Duration Changed', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Track duration {{MediaNowPlayingDuration}}', cooldown_min: 15, signal_name: 'MediaNowPlayingDuration', op: 'changed' }, + + // ── Display / settings (ecosystem) ────────────────────────────── + { name: 'Center Display Changed', icon: Icons.vehicle, category: 'Display', severity: 'info', message: 'Center display {{CenterDisplay}}', cooldown_min: 30, signal_name: 'CenterDisplay', op: 'changed' }, + { name: '24-Hour Time Setting Changed', icon: Icons.vehicle, category: 'Display', severity: 'info', message: '24-hour time {{Setting24HourTime}}', cooldown_min: 1440, signal_name: 'Setting24HourTime', op: 'changed' }, + { name: 'Distance Unit Changed', icon: Icons.vehicle, category: 'Display', severity: 'info', message: 'Distance unit {{SettingDistanceUnit}}', cooldown_min: 1440, signal_name: 'SettingDistanceUnit', op: 'changed' }, + { name: 'Temperature Unit Changed', icon: Icons.climate, category: 'Display', severity: 'info', message: 'Temp unit {{SettingTemperatureUnit}}', cooldown_min: 1440, signal_name: 'SettingTemperatureUnit', op: 'changed' }, + { name: 'Charge Unit Changed', icon: Icons.charging, category: 'Display', severity: 'info', message: 'Charge unit {{SettingChargeUnit}}', cooldown_min: 1440, signal_name: 'SettingChargeUnit', op: 'changed' }, + { name: 'Tire Pressure Unit Changed', icon: Icons.droplets, category: 'Display', severity: 'info', message: 'TP unit {{SettingTirePressureUnit}}', cooldown_min: 1440, signal_name: 'SettingTirePressureUnit', op: 'changed' }, + + // ── Identity / software extras ────────────────────────────────── + { name: 'Vehicle Name Changed', icon: Icons.vehicle, category: 'Software', severity: 'info', message: 'Vehicle renamed to {{VehicleName}}', cooldown_min: 1440, signal_name: 'VehicleName', op: 'changed' }, + { name: 'Trim Changed', icon: Icons.vehicle, category: 'Software', severity: 'info', message: 'Trim {{Trim}}', cooldown_min: 1440, signal_name: 'Trim', op: 'changed' }, + { name: 'Wheel Type Changed', icon: Icons.vehicle, category: 'Software', severity: 'info', message: 'Wheels {{WheelType}}', cooldown_min: 1440, signal_name: 'WheelType', op: 'changed' }, + { name: 'Efficiency Package Changed', icon: Icons.bolt, category: 'Software', severity: 'info', message: 'Efficiency package {{EfficiencyPackage}}', cooldown_min: 1440, signal_name: 'EfficiencyPackage', op: 'changed' }, + { name: 'Exterior Color Changed', icon: Icons.vehicle, category: 'Software', severity: 'info', message: 'Color {{ExteriorColor}}', cooldown_min: 1440, signal_name: 'ExteriorColor', op: 'changed' }, + { name: 'Roof Color Changed', icon: Icons.vehicle, category: 'Software', severity: 'info', message: 'Roof {{RoofColor}}', cooldown_min: 1440, signal_name: 'RoofColor', op: 'changed' }, + { name: 'Car Type Changed', icon: Icons.vehicle, category: 'Software', severity: 'info', message: 'Car type {{CarType}}', cooldown_min: 1440, signal_name: 'CarType', op: 'changed' }, + + // ── Truck / Cybertruck (ecosystem) ────────────────────────────── + { name: 'Tonneau Mostly Open', icon: Icons.vehicle, category: 'Truck', severity: 'info', message: 'Tonneau open {{TonneauOpenPercent}}%', cooldown_min: 30, signal_name: 'TonneauOpenPercent', op: '>', value_num: 50 }, + { name: 'Tonneau Position Changed', icon: Icons.vehicle, category: 'Truck', severity: 'info', message: 'Tonneau {{TonneauPosition}}', cooldown_min: 15, signal_name: 'TonneauPosition', op: 'changed' }, + { name: 'Tonneau Tent Mode', icon: Icons.vehicle, category: 'Truck', severity: 'info', message: 'Tonneau tent mode is on', cooldown_min: 30, signal_name: 'TonneauTentMode', op: '=', value_bool: true }, + { name: 'Sunroof Installed Changed', icon: Icons.vehicle, category: 'Truck', severity: 'info', message: 'Sunroof installed {{SunroofInstalled}}', cooldown_min: 1440, signal_name: 'SunroofInstalled', op: 'changed' }, + { name: 'Offroad Lightbar Present', icon: Icons.warning, category: 'Truck', severity: 'info', message: 'Off-road lightbar present', cooldown_min: 1440, signal_name: 'OffroadLightbarPresent', op: '=', value_bool: true }, + { name: 'Homelink Device Count Changed', icon: Icons.location, category: 'Security', severity: 'info', message: 'Homelink devices {{HomelinkDeviceCount}}', cooldown_min: 1440, signal_name: 'HomelinkDeviceCount', op: 'changed' }, +] diff --git a/web/src/features/notifications/pages/AlertStudioPage.tsx b/web/src/features/notifications/pages/AlertStudioPage.tsx index 09a4982b31..fbe1618793 100644 --- a/web/src/features/notifications/pages/AlertStudioPage.tsx +++ b/web/src/features/notifications/pages/AlertStudioPage.tsx @@ -5,7 +5,7 @@ * /api/v1/alerts/rules endpoint using the current alert-rule contract. */ -import { useState, useEffect, useMemo, useCallback, useRef, type ElementType, type KeyboardEvent } from 'react' +import { useState, useEffect, useMemo, useCallback, useRef, type KeyboardEvent } from 'react' import { useTranslation } from 'react-i18next' import { type AlertRule, @@ -45,6 +45,7 @@ import { useFormDraft } from '@/hooks/useFormDraft' import { useNavigationGuard } from '@/hooks/useNavigationGuard' import { useUrlString } from '@/hooks/useUrlState' import { alertRuleSchema } from '../schemas/alertRule' +import { ruleTemplates, type RuleTemplate } from '../lib/alertRuleTemplates' import { ComputedMetricEditor } from '../components/ComputedMetricEditor' import { AlertMessageEditor } from '../components/AlertMessageEditor' import { recommendedTriggerMode } from '../lib/recommendedTriggerMode' @@ -67,89 +68,12 @@ type Severity = NonNullable type RuleOp = NonNullable type ValueKind = 'none' | 'number' | 'text' | 'bool' | 'range' -interface RuleTemplate { - name: string - icon: ElementType - category: string - severity: Severity - message: string - cooldown_min: number - signal_name: string - op: RuleOp - value_num?: number - value_text?: string - value_bool?: boolean - value_min?: number - value_max?: number -} - interface SignalDefinition { name: string category: string value_type: SignalValueType } -const ruleTemplates: RuleTemplate[] = [ - { name: 'Battery Low (< 20%)', icon: Icons.battery, category: 'Battery', severity: 'warn', message: 'Battery at {{BatteryLevel}}%', cooldown_min: 30, signal_name: 'BatteryLevel', op: '<', value_num: 20 }, - { name: 'Battery Critical (< 10%)', icon: Icons.battery, category: 'Battery', severity: 'critical', message: 'Battery critically low at {{BatteryLevel}}%!', cooldown_min: 15, signal_name: 'BatteryLevel', op: '<', value_num: 10 }, - { name: 'Battery Full (>= 90%)', icon: Icons.battery, category: 'Battery', severity: 'info', message: 'Battery reached {{BatteryLevel}}%', cooldown_min: 60, signal_name: 'BatteryLevel', op: '>=', value_num: 90 }, - { name: 'Charge Limit Reached', icon: Icons.battery, category: 'Battery', severity: 'info', message: 'Battery at charge limit {{ChargeLimitSoc}}%', cooldown_min: 60, signal_name: 'BatteryLevel', op: '>=', value_num: 80 }, - { name: 'Range Below 50 km', icon: Icons.battery, category: 'Battery', severity: 'warn', message: 'Range low: {{RatedRange}} km remaining', cooldown_min: 30, signal_name: 'RatedRange', op: '<', value_num: 50 }, - - { name: 'Charge Complete', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charging complete at {{BatteryLevel}}%', cooldown_min: 60, signal_name: 'ChargeState', op: '=', value_text: 'Complete' }, - { name: 'Charging Started', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Charging started - {{DetailedChargeState}}', cooldown_min: 15, signal_name: 'DetailedChargeState', op: '=', value_text: 'Charging' }, - { name: 'Charging Stopped Unexpectedly', icon: Icons.charging, category: 'Charging', severity: 'warn', message: 'Charging stopped - {{DetailedChargeState}}', cooldown_min: 30, signal_name: 'DetailedChargeState', op: '=', value_text: 'Stopped' }, - { name: 'Supercharging (DC Fast)', icon: Icons.charging, category: 'Charging', severity: 'info', message: 'Supercharging at {{DCChargingPower}} kW', cooldown_min: 30, signal_name: 'DCChargingPower', op: '>', value_num: 50 }, - { name: 'Slow Charge Rate', icon: Icons.charging, category: 'Charging', severity: 'warn', message: 'Charging slow: {{ChargeAmps}}A', cooldown_min: 60, signal_name: 'ChargeAmps', op: 'between', value_min: 0.01, value_max: 5 }, - - { name: 'Drive Started', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Drive started - gear is {{Gear}}', cooldown_min: 5, signal_name: 'Gear', op: '=', value_text: 'D' }, - { name: 'Drive Ended', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Drive ended - gear is {{Gear}}', cooldown_min: 5, signal_name: 'Gear', op: '=', value_text: 'P' }, - { name: 'Speed Limit Exceeded', icon: Icons.speed, category: 'Driving', severity: 'warn', message: 'Speed {{VehicleSpeed}} km/h exceeded limit', cooldown_min: 15, signal_name: 'VehicleSpeed', op: '>', value_num: 120 }, - { name: 'High Speed Alert (> 160 km/h)', icon: Icons.speed, category: 'Driving', severity: 'critical', message: 'Very high speed: {{VehicleSpeed}} km/h!', cooldown_min: 5, signal_name: 'VehicleSpeed', op: '>', value_num: 160 }, - { name: 'Reverse Gear Engaged', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Vehicle in reverse', cooldown_min: 5, signal_name: 'Gear', op: '=', value_text: 'R' }, - { name: 'Odometer Milestone (100k km)', icon: Icons.vehicle, category: 'Driving', severity: 'info', message: 'Odometer: {{Odometer}} km', cooldown_min: 1440, signal_name: 'Odometer', op: '>', value_num: 100000 }, - - { name: 'Car Unlocked While Parked', icon: Icons.locked, category: 'Security', severity: 'critical', message: 'Vehicle is unlocked and parked!', cooldown_min: 30, signal_name: 'Locked', op: '=', value_bool: false }, - { name: 'Vehicle Locked', icon: Icons.locked, category: 'Security', severity: 'info', message: 'Vehicle locked', cooldown_min: 5, signal_name: 'Locked', op: '=', value_bool: true }, - { name: 'Vehicle Unlocked', icon: Icons.locked, category: 'Security', severity: 'info', message: 'Vehicle unlocked', cooldown_min: 5, signal_name: 'Locked', op: '=', value_bool: false }, - { name: 'Sentry Mode Activated', icon: Icons.security, category: 'Security', severity: 'info', message: 'Sentry mode activated', cooldown_min: 30, signal_name: 'SentryMode', op: '=', value_bool: true }, - { name: 'Door Opened While Parked', icon: Icons.locked, category: 'Security', severity: 'warn', message: 'Door opened - {{DoorState}}', cooldown_min: 15, signal_name: 'DoorState', op: '!=', value_text: 'Closed' }, - { name: 'Window Left Open', icon: Icons.vehicle, category: 'Security', severity: 'warn', message: 'Front driver window is {{FdWindow}}', cooldown_min: 60, signal_name: 'FdWindow', op: '!=', value_text: 'Closed' }, - { name: 'Valet Mode Enabled', icon: Icons.security, category: 'Security', severity: 'info', message: 'Valet mode enabled', cooldown_min: 60, signal_name: 'ValetModeEnabled', op: '=', value_bool: true }, - { name: 'Guest Mode Enabled', icon: Icons.security, category: 'Security', severity: 'warn', message: 'Guest mode enabled', cooldown_min: 60, signal_name: 'GuestModeEnabled', op: '=', value_bool: true }, - - { name: 'Cabin Overheat (> 40C)', icon: Icons.climate, category: 'Climate', severity: 'warn', message: 'Cabin temp: {{InsideTemp}}C', cooldown_min: 30, signal_name: 'InsideTemp', op: '>', value_num: 40 }, - { name: 'Cabin Freezing (< 0C)', icon: Icons.climate, category: 'Climate', severity: 'warn', message: 'Cabin temp: {{InsideTemp}}C - freezing!', cooldown_min: 60, signal_name: 'InsideTemp', op: '<', value_num: 0 }, - { name: 'HVAC Left On While Parked', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'HVAC running while parked', cooldown_min: 30, signal_name: 'HvacPower', op: '=', value_bool: true }, - { name: 'Climate Keeper Active', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Climate keeper: {{ClimateKeeperMode}}', cooldown_min: 60, signal_name: 'ClimateKeeperMode', op: '!=', value_text: 'Off' }, - { name: 'Steering Wheel Heater On', icon: Icons.climate, category: 'Climate', severity: 'info', message: 'Steering wheel heater level {{HvacSteeringWheelHeatLevel}}', cooldown_min: 30, signal_name: 'HvacSteeringWheelHeatLevel', op: '>', value_num: 0 }, - - { name: 'Tire Pressure Low', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'Low tire pressure detected', cooldown_min: 60, signal_name: 'TpmsHardWarnings', op: '=', value_bool: true }, - { name: 'Tire Pressure Soft Warning', icon: Icons.droplets, category: 'Tire Pressure', severity: 'info', message: 'Tire pressure slightly low', cooldown_min: 120, signal_name: 'TpmsSoftWarnings', op: '=', value_bool: true }, - { name: 'Front Left Tire Low (< 2.2 bar)', icon: Icons.droplets, category: 'Tire Pressure', severity: 'warn', message: 'FL tire: {{TpmsPressureFl}} bar', cooldown_min: 60, signal_name: 'TpmsPressureFl', op: '<', value_num: 2.2 }, - - { name: 'Arrived at Home', icon: Icons.vehicle, category: 'Location', severity: 'info', message: 'Vehicle arrived at home', cooldown_min: 15, signal_name: 'LocatedAtHome', op: '=', value_bool: true }, - { name: 'Left Home', icon: Icons.vehicle, category: 'Location', severity: 'info', message: 'Vehicle left home', cooldown_min: 15, signal_name: 'LocatedAtHome', op: '=', value_bool: false }, - { name: 'Arrived at Work', icon: Icons.vehicle, category: 'Location', severity: 'info', message: 'Vehicle arrived at work', cooldown_min: 15, signal_name: 'LocatedAtWork', op: '=', value_bool: true }, - { name: 'Navigation Started', icon: Icons.vehicle, category: 'Location', severity: 'info', message: 'Navigating to {{DestinationName}}', cooldown_min: 10, signal_name: 'DestinationName', op: 'changed' }, - - { name: 'Driver Seatbelt Unbuckled', icon: Icons.security, category: 'Safety', severity: 'warn', message: 'Driver seatbelt unbuckled while driving!', cooldown_min: 5, signal_name: 'DriverSeatBelt', op: '=', value_bool: false }, - { name: 'Speed Limit Mode Active', icon: Icons.security, category: 'Safety', severity: 'info', message: 'Speed limit mode active', cooldown_min: 60, signal_name: 'SpeedLimitMode', op: '=', value_bool: true }, - { name: 'PIN to Drive Disabled', icon: Icons.security, category: 'Safety', severity: 'warn', message: 'PIN to Drive has been disabled', cooldown_min: 1440, signal_name: 'PinToDriveEnabled', op: '=', value_bool: false }, - - { name: 'High Motor Temperature (> 80C)', icon: Icons.climate, category: 'Motor', severity: 'warn', message: 'Motor stator temp: {{DiStatorTempF}}C', cooldown_min: 15, signal_name: 'DiStatorTempF', op: '>', value_num: 80 }, - { name: 'HVIL Fault', icon: Icons.security, category: 'Motor', severity: 'critical', message: 'HV interlock fault detected!', cooldown_min: 5, signal_name: 'Hvil', op: '=', value_text: 'Fault' }, - { name: 'High Regenerative Braking', icon: Icons.charging, category: 'Motor', severity: 'info', message: 'Regen power: {{Power}} kW', cooldown_min: 15, signal_name: 'Power', op: '<', value_num: -50 }, - - { name: 'Software Update Available', icon: Icons.charging, category: 'Software', severity: 'info', message: 'Update available: {{SoftwareUpdateVersion}}', cooldown_min: 1440, signal_name: 'SoftwareUpdateVersion', op: 'changed' }, - { name: 'Software Update Installing', icon: Icons.charging, category: 'Software', severity: 'info', message: 'Installing update: {{SoftwareUpdateInstallationPercentComplete}}%', cooldown_min: 30, signal_name: 'SoftwareUpdateInstallationPercentComplete', op: '>', value_num: 0 }, - - { name: 'Music Playing', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Now playing: {{MediaNowPlayingTitle}} by {{MediaNowPlayingArtist}}', cooldown_min: 60, signal_name: 'MediaPlaybackStatus', op: '=', value_text: 'Playing' }, - { name: 'Volume Too High', icon: Icons.vehicle, category: 'Media', severity: 'info', message: 'Volume at {{MediaAudioVolume}}', cooldown_min: 30, signal_name: 'MediaAudioVolume', op: '>', value_num: 8 }, - - { name: 'Powershare Active', icon: Icons.charging, category: 'Powershare', severity: 'info', message: 'Powershare active: {{PowershareInstantaneousPowerKW}} kW', cooldown_min: 60, signal_name: 'PowershareStatus', op: 'changed' }, -] - const templateCategories = [...new Set(ruleTemplates.map(t => t.category))].sort() const numericOperatorOptions: RuleOp[] = ['=', '!=', '<', '<=', '>', '>=', 'changed', 'between', 'outside'] diff --git a/web/src/features/ownership/components/GhostDrivesPanel.test.tsx b/web/src/features/ownership/components/GhostDrivesPanel.test.tsx new file mode 100644 index 0000000000..7b2d7acf87 --- /dev/null +++ b/web/src/features/ownership/components/GhostDrivesPanel.test.tsx @@ -0,0 +1,100 @@ +/** + * GhostDrivesPanel — behaviour coverage. + * + * The data hook (`useGhostDrives`) is mocked and driven per test; shared UI + * (OwnershipPanel, DataTable, Badge, AlertBanner, Button) is REAL so the + * render-boundary wiring is genuinely exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('@/api/hooks/useOwnership', async () => { + const actual = await vi.importActual( + '@/api/hooks/useOwnership', + ); + return { ...actual, useGhostDrives: vi.fn() }; +}); + +import { useGhostDrives } from '@/api/hooks/useOwnership'; +import { GhostDrivesPanel } from './GhostDrivesPanel'; + +const mockGhosts = useGhostDrives as unknown as ReturnType; + +const ghosts = [ + { + drive_id: 7, + started_at: '2026-09-01T21:14:00Z', + distance_m: 18200, + duration_s: 1500, + cluster_id: 0, + score: 82.5, + confidence_pct: 41, + distance_ratio: 2.4, + reason: 'unattributed drive, 41% confidence, 2.4× typical distance', + }, + { + drive_id: 9, + started_at: '2026-09-02T08:02:00Z', + distance_m: 9400, + duration_s: 900, + cluster_id: 1, + score: 63.0, + confidence_pct: 66, + distance_ratio: 1.3, + reason: 'unattributed drive, 66% confidence', + }, +]; + +const report = { vehicle_id: 42, scanned: 24, ghosts }; + +function idle(extra = {}) { + return { + data: undefined, isLoading: false, isFetching: false, error: null, + refetch: vi.fn(), ...extra, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGhosts.mockReturnValue(idle()); +}); + +describe('GhostDrivesPanel', () => { + it('passes vehicle and window through to the scan query', () => { + render(); + expect(mockGhosts).toHaveBeenCalledWith(42, 30); + }); + + it('reports a clear window when nothing is flagged', () => { + mockGhosts.mockReturnValue(idle({ data: { vehicle_id: 42, scanned: 24, ghosts: [] } })); + render(); + expect(screen.getByText('Ghost-driver alerts')).toBeInTheDocument(); + expect(screen.getByText(/No unknown-driver activity/)).toBeInTheDocument(); + }); + + it('raises the alert banner and lists flagged drives with reasons', () => { + mockGhosts.mockReturnValue(idle({ data: report })); + render(); + expect(screen.getByText('2 of 24 drives look like someone else')).toBeInTheDocument(); + expect(screen.getByText('2 flagged')).toBeInTheDocument(); + expect(screen.getByText('#7')).toBeInTheDocument(); + expect( + screen.getByText('unattributed drive, 41% confidence, 2.4× typical distance'), + ).toBeInTheDocument(); + }); + + it('routes the label action back to the caller with the drive id', () => { + mockGhosts.mockReturnValue(idle({ data: report })); + const onLabel = vi.fn(); + render(); + fireEvent.click(screen.getAllByText('Label')[0]); + expect(onLabel).toHaveBeenCalledWith(7); + }); + + it('surfaces scan failures without crashing the panel', () => { + mockGhosts.mockReturnValue(idle({ error: new Error('boom') })); + render(); + expect(screen.getByText('Ghost scan failed')).toBeInTheDocument(); + expect(screen.getByText('boom')).toBeInTheDocument(); + }); +}); diff --git a/web/src/features/ownership/components/GhostDrivesPanel.tsx b/web/src/features/ownership/components/GhostDrivesPanel.tsx new file mode 100644 index 0000000000..c230c0439c --- /dev/null +++ b/web/src/features/ownership/components/GhostDrivesPanel.tsx @@ -0,0 +1,174 @@ +import { Icons } from '@/lib/icons'; +import { useTranslation } from 'react-i18next'; +import { useGhostDrives } from '@/api/hooks/useOwnership'; +import { useDataState } from '@/hooks/useDataState'; +import { AlertBanner, QueryError } from '@/components/feedback'; +import { Badge, Button, DataTable, Text } from '@/components/ui'; +import type { Column } from '@/components/ui'; +import { useUnits } from '@/hooks/useUnits'; +import { formatDateTime } from '@/lib/dateFormat'; +import { fmtNumber } from '@/lib/numberFormat'; +import type { GhostDrive } from '@/types/ownership'; +import { OwnershipPanel } from './OwnershipPanel'; +import { formatPct, formatSpan } from '../formatters'; + +interface GhostDrivesPanelProps { + vehicleId: number | null; + windowDays: number; + onLabel: (driveId: number) => void; +} + +function scoreTone(score: number): 'warning' | 'info' { + return score >= 80 ? 'warning' : 'info'; +} + +/** + * Ghost-driver alerting: drives that fit no named profile and sit far from + * their cluster centroid. Same detection math as the attribution report, so + * labelling a drive here re-anchors the cluster everywhere else. + */ +export function GhostDrivesPanel({ vehicleId, windowDays, onLabel }: GhostDrivesPanelProps) { + const { t } = useTranslation(); + const units = useUnits(); + const ghostsQuery = useGhostDrives(vehicleId, windowDays); + const ghostsState = useDataState(ghostsQuery); + + const ghosts = ghostsQuery.data?.ghosts ?? []; + const scanned = ghostsQuery.data?.scanned ?? 0; + + const columns: Column[] = [ + { + key: 'drive', + header: t('ownership.ghost.col.drive', 'Drive'), + render: (row) => ( +
+ + #{row.drive_id} + + + {formatDateTime(row.started_at)} + +
+ ), + }, + { + key: 'score', + header: t('ownership.ghost.col.score', 'Ghost score'), + render: (row) => ( +
+
+
+
+ {fmtNumber(row.score, 0)} +
+ ), + sortable: true, + }, + { + key: 'trip', + header: t('ownership.ghost.col.trip', 'Trip'), + render: (row) => ( +
+ {units.formatDistance(row.distance_m)} + + {formatSpan(row.duration_s)} + +
+ ), + }, + { + key: 'deviation', + header: t('ownership.ghost.col.deviation', 'Deviation'), + render: (row) => ( +
+ + {t('ownership.ghost.ratio', '{{ratio}}× typical', { + ratio: fmtNumber(row.distance_ratio, 1), + })} + + + {t('ownership.ghost.confidence', '{{pct}} confidence', { + pct: formatPct(row.confidence_pct, 0), + })} + +
+ ), + sortable: true, + }, + { + key: 'reason', + header: t('ownership.ghost.col.reason', 'Why flagged'), + render: (row) => ( + + {row.reason} + + ), + }, + { + key: 'assign', + header: t('ownership.action.header', 'Actions'), + render: (row) => ( + + ), + }, + ]; + + return ( + 0 ? ( + + + ) : undefined + } + empty={!ghostsState.fatalError && (ghostsQuery.isLoading || ghosts.length === 0)} + emptyMessage={ + ghostsQuery.isLoading + ? t('ownership.ghost.scanning', 'Scanning recent drives…') + : t( + 'ownership.ghost.clear', + 'No unknown-driver activity in this window — every scanned drive fits a known profile.', + ) + } + > + {ghostsState.fatalError ? ( + ghostsState.retry?.()} /> + ) : null} + {ghosts.length > 0 ? ( +
+ + {t( + 'ownership.ghost.alert.body', + 'Valet, teen, thief — or just an unusual trip. Label the drive if you recognise it; the cluster learns from every label.', + )} + +
+ ) : null} + row.drive_id} + tableId="ownership-ghost-drives" + /> +
+ ); +} diff --git a/web/src/features/ownership/components/index.ts b/web/src/features/ownership/components/index.ts index 074a195158..608f7d9893 100644 --- a/web/src/features/ownership/components/index.ts +++ b/web/src/features/ownership/components/index.ts @@ -1,4 +1,5 @@ export { EvidencePanel } from './EvidencePanel'; +export { GhostDrivesPanel } from './GhostDrivesPanel'; export { MoneyInput } from './MoneyInput'; export { MutationError } from './MutationError'; export { OwnershipPanel } from './OwnershipPanel'; diff --git a/web/src/features/ownership/pages/DriverAttributionPage.tsx b/web/src/features/ownership/pages/DriverAttributionPage.tsx index 43f0340d16..21125eb55a 100644 --- a/web/src/features/ownership/pages/DriverAttributionPage.tsx +++ b/web/src/features/ownership/pages/DriverAttributionPage.tsx @@ -39,6 +39,7 @@ import { fmtNumber } from '@/lib/numberFormat'; import type { DriveFingerprint, DriverCluster, DriverProfile } from '@/types/ownership'; import { EvidencePanel, + GhostDrivesPanel, MutationError, OwnershipPanel, StatGrid, @@ -437,6 +438,20 @@ export default function DriverAttributionPage() { )} + + { + setAssignDraft({ + drive_id: driveId, + driver_profile_id: profiles[0]?.id ?? 0, + }); + setAssignOpen(true); + }} + /> + + ({ + useBatteryCertificate: vi.fn(), + useVerifyBatteryCertificate: vi.fn(), +})); + +import { + useBatteryCertificate, + useVerifyBatteryCertificate, +} from '@/api/hooks/useBatteryCertificate'; +import type { BatteryCertificate } from '@/api/hooks/useBatteryCertificate'; +import { BatteryCertificatePanel } from './BatteryCertificatePanel'; + +const mockCert = useBatteryCertificate as unknown as ReturnType; +const mockVerify = useVerifyBatteryCertificate as unknown as ReturnType; + +const certificate: BatteryCertificate = { + issuer: 'teslasync', + version: 1, + vehicle_id: 7, + issued_at: '2026-03-01T12:00:00Z', + expires_at: '2026-03-31T12:00:00Z', + current_soh: 91.5, + estimated_capacity_kwh: 68.625, + original_capacity_kwh: 75, + degradation_rate_pct_per_year: 1.8, + battery_age_months: 36, + total_cycles: 412, + charge_habits_score: 88, + stress_level: 'low', + fast_charge_pct: 12.5, + temp_exposure_score: 82, + temp_exposure_reason: 'garage-kept', +}; + +const SIGNATURE = 'a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00'; + +beforeEach(() => { + vi.clearAllMocks(); + mockCert.mockReturnValue({ + data: { certificate, signature: SIGNATURE }, + isLoading: false, + isError: false, + }); + mockVerify.mockReturnValue({ + mutate: vi.fn(), + data: { valid: true, certificate }, + isPending: false, + isError: false, + }); +}); + +describe('BatteryCertificatePanel', () => { + it('renders the certificate snapshot and signature prefix', () => { + render(); + expect(screen.getByText('Battery Certificate')).toBeInTheDocument(); + expect(screen.getByText('91.5%')).toBeInTheDocument(); + expect(screen.getByText('412')).toBeInTheDocument(); + expect(screen.getByText(/Signature:/)).toBeInTheDocument(); + expect(screen.getByText(/a1b2c3d4/)).toBeInTheDocument(); + }); + + it('shows the verified badge when self-verification succeeds', () => { + render(); + expect(screen.getByText('Signature verified')).toBeInTheDocument(); + }); + + it('offers certificate and signature copies', () => { + render(); + expect(screen.getByText('Copy certificate')).toBeInTheDocument(); + expect(screen.getByText('Copy signature')).toBeInTheDocument(); + }); + + it('renders an empty state when no certificate is available', () => { + mockCert.mockReturnValue({ data: undefined, isLoading: false, isError: true }); + render(); + expect(screen.getByText(/No certificate available/)).toBeInTheDocument(); + }); + + it('surfaces self-verification failures', () => { + mockVerify.mockReturnValue({ + mutate: vi.fn(), + data: undefined, + isPending: false, + isError: true, + }); + render(); + expect(screen.getByText(/Self-verification failed/)).toBeInTheDocument(); + expect(screen.queryByText('Signature verified')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/features/resale-vault/components/BatteryCertificatePanel.tsx b/web/src/features/resale-vault/components/BatteryCertificatePanel.tsx new file mode 100644 index 0000000000..17566dee26 --- /dev/null +++ b/web/src/features/resale-vault/components/BatteryCertificatePanel.tsx @@ -0,0 +1,133 @@ +/** + * Server-signed battery certificate — the shareable resale attestation. + * Issues the signed certificate for the vehicle, renders the buyer-facing + * snapshot, and offers one-click copies of the certificate JSON + signature + * so the seller can paste them into a listing. A self-verification badge + * proves the signature round-trips through the public verify endpoint. + */ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { BadgeCheck, ShieldCheck } from 'lucide-react'; + +import { GlassPanel, PanelTitle, HelperText, Badge, CopyButton, ErrorText } from '@/components/ui'; +import { KVList } from '@/components/data-display'; +import { Skeleton, EmptyState } from '@/components/feedback'; +import { useDateFormat } from '@/hooks/useDateFormat'; +import { useUnits } from '@/hooks/useUnits'; +import { + useBatteryCertificate, + useVerifyBatteryCertificate, +} from '@/api/hooks/useBatteryCertificate'; + +export interface BatteryCertificatePanelProps { + vehicleId: string | null; +} + +export function BatteryCertificatePanel({ vehicleId }: BatteryCertificatePanelProps) { + const { t } = useTranslation(); + const { formatDate } = useDateFormat(); + const { formatEnergy } = useUnits(); + + const certQuery = useBatteryCertificate(vehicleId); + const verifyMutation = useVerifyBatteryCertificate(); + + const issued = certQuery.data ?? null; + const signature = issued?.signature ?? null; + + // Self-verify the just-issued certificate through the same public + // endpoint a buyer would use. Runs once per signature. + useEffect(() => { + if (issued && !verifyMutation.isPending && verifyMutation.data === undefined && !verifyMutation.isError) { + verifyMutation.mutate({ certificate: issued.certificate, signature: issued.signature }); + } + // verifyMutation is stable across renders (TanStack); issued carries the dep. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [issued]); + + const verified = verifyMutation.data?.valid === true; + + return ( + +
+ + + {verified && ( + + + )} +
+ + + {t( + 'resaleVault.certificate.body', + 'A server-signed battery health attestation. Share the certificate and signature with a buyer — they can verify authenticity without an account.', + )} + + + {certQuery.isLoading ? ( + + ) : certQuery.isError || !issued ? ( + + ) : ( + <> + + +
+ + {t('resaleVault.certificate.signature', 'Signature: {{sig}}', { + sig: `${signature?.slice(0, 32)}…`, + })} + +
+ + +
+
+ + {verifyMutation.isError && ( + + {t('resaleVault.certificate.verifyError', 'Self-verification failed — the signature may be stale.')} + + )} + + )} +
+ ); +} diff --git a/web/src/features/resale-vault/components/index.ts b/web/src/features/resale-vault/components/index.ts index cb17f949cf..286c33ae80 100644 --- a/web/src/features/resale-vault/components/index.ts +++ b/web/src/features/resale-vault/components/index.ts @@ -9,6 +9,8 @@ export { EvidenceInventoryPanel } from './EvidenceInventoryPanel'; export type { EvidenceInventoryPanelProps } from './EvidenceInventoryPanel'; export { BatterySummaryPanel } from './BatterySummaryPanel'; export type { BatterySummaryPanelProps } from './BatterySummaryPanel'; +export { BatteryCertificatePanel } from './BatteryCertificatePanel'; +export type { BatteryCertificatePanelProps } from './BatteryCertificatePanel'; export { MaintenanceSummaryPanel } from './MaintenanceSummaryPanel'; export type { MaintenanceSummaryPanelProps } from './MaintenanceSummaryPanel'; export { SoftwareUpdateSummaryPanel } from './SoftwareUpdateSummaryPanel'; diff --git a/web/src/features/resale-vault/pages/WarrantyResaleVaultPage.tsx b/web/src/features/resale-vault/pages/WarrantyResaleVaultPage.tsx index b3cdf04ee8..f0b249856a 100644 --- a/web/src/features/resale-vault/pages/WarrantyResaleVaultPage.tsx +++ b/web/src/features/resale-vault/pages/WarrantyResaleVaultPage.tsx @@ -37,6 +37,7 @@ import { DisclosureProfileBuilder, EvidenceInventoryPanel, BatterySummaryPanel, + BatteryCertificatePanel, MaintenanceSummaryPanel, SoftwareUpdateSummaryPanel, WarrantySummaryPanel, @@ -130,6 +131,7 @@ export default function WarrantyResaleVaultPage() { hasPartialErrors={hasPartialErrors} /> + diff --git a/web/src/features/service-intelligence/components/ClaimDraftPanel.test.tsx b/web/src/features/service-intelligence/components/ClaimDraftPanel.test.tsx new file mode 100644 index 0000000000..73c4e87f01 --- /dev/null +++ b/web/src/features/service-intelligence/components/ClaimDraftPanel.test.tsx @@ -0,0 +1,84 @@ +/** + * ClaimDraftPanel — behaviour coverage. + * + * The data hook (`useClaimDraft`) is mocked and driven per test; shared UI + * (GlassPanel, Badge, Input, Button, CopyButton, PanelState) is REAL so + * the render-boundary wiring is genuinely exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +vi.mock('@/api/hooks/useServiceIntelligence', async () => { + const actual = await vi.importActual( + '@/api/hooks/useServiceIntelligence', + ); + return { ...actual, useClaimDraft: vi.fn() }; +}); + +import { useClaimDraft } from '@/api/hooks/useServiceIntelligence'; +import { ClaimDraftPanel } from './ClaimDraftPanel'; + +const mockDraft = useClaimDraft as unknown as ReturnType; + +const draft = { + subject: 'Service request: Charge rate drops after 60%', + issue: 'Charge rate drops after 60%.', + vehicle: 'Model 3 (2019)', + coverages: [ + { name: 'Battery & Drive Unit', status: 'active', days_remaining: 900 }, + ], + communications: ['TSB SB-21-12-001 (HV Battery): contactor inspection'], + symptoms: ['charge_rate_drop on HV Battery (high, observed 2026-08-01)'], + evidence: ['Charge curve anomaly: taper at 60%'], + ask: 'Please diagnose the issue above under Battery & Drive Unit (900 days left).', + body: 'Subject: Service request: Charge rate drops after 60%\n\n...', + disclaimer: 'Auto-drafted by TeslaSync from your vehicle data.', +}; + +function idle(extra = {}) { + return { + data: undefined, isLoading: false, isFetching: false, error: null, + refetch: vi.fn(), ...extra, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockDraft.mockReturnValue(idle()); +}); + +describe('ClaimDraftPanel', () => { + it('prompts for an issue before any draft exists', () => { + render(); + expect(screen.getByText('Warranty claim draft')).toBeInTheDocument(); + expect(screen.getByText('No ticket yet')).toBeInTheDocument(); + expect( + screen.getByText('Describe an issue and generate a ready-to-paste service ticket.'), + ).toBeInTheDocument(); + }); + + it('submits the typed issue to the draft query', () => { + render(); + fireEvent.change(screen.getByLabelText('Issue description'), { + target: { value: 'Charge rate drops after 60%.' }, + }); + fireEvent.click(screen.getByText('Draft ticket')); + expect(mockDraft).toHaveBeenCalledWith(42, 'Charge rate drops after 60%.', undefined); + }); + + it('renders the draft with coverage badges and copy action', () => { + mockDraft.mockReturnValue(idle({ data: draft })); + render(); + expect(screen.getByText('Service request: Charge rate drops after 60%')).toBeInTheDocument(); + expect(screen.getByText(/Battery & Drive Unit/)).toBeInTheDocument(); + expect(screen.getByText(/TSB SB-21-12-001/)).toBeInTheDocument(); + expect(screen.getByText('Copy ticket text')).toBeInTheDocument(); + expect(screen.getByText('Auto-drafted by TeslaSync from your vehicle data.')).toBeInTheDocument(); + }); + + it('asks for a vehicle when none is selected', () => { + render(); + expect(screen.getByText('Select a vehicle')).toBeInTheDocument(); + expect(screen.getByText('Draft ticket').closest('button')).toHaveProperty('disabled', true); + }); +}); diff --git a/web/src/features/service-intelligence/components/ClaimDraftPanel.tsx b/web/src/features/service-intelligence/components/ClaimDraftPanel.tsx new file mode 100644 index 0000000000..86c834a508 --- /dev/null +++ b/web/src/features/service-intelligence/components/ClaimDraftPanel.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; +import { + GlassPanel, PanelTitle, Badge, Text, Caption, Button, Input, CopyButton, +} from '@/components/ui'; +import { useClaimDraft, type ClaimDraft } from '@/api/hooks/useServiceIntelligence'; +import { useDataState } from '@/hooks/useDataState'; +import { PanelState } from './PanelState'; + +export interface ClaimDraftPanelProps { + vehicleId: number | null; +} + +function coverageVariant(status: string): 'success' | 'warning' | 'danger' { + switch (status) { + case 'active': + return 'success'; + case 'expiring_soon': + return 'warning'; + default: + return 'danger'; + } +} + +function DraftBody({ draft }: { draft: ClaimDraft }) { + const { t } = useTranslation(); + return ( +
+
+ {draft.subject} + {draft.vehicle && {draft.vehicle}} +
+
+ {draft.coverages.map((c) => ( + + {t('serviceIntelligence.claim.coverage', '{{name}} · {{days}}d left', { + name: c.name, days: c.days_remaining, + })} + + ))} +
+ {draft.issue && {draft.issue}} + {draft.ask} + {draft.communications.length > 0 && ( +
    + {draft.communications.map((c, i) => ( +
  • {c}
  • + ))} +
+ )} + + {draft.disclaimer} +
+ ); +} + +export function ClaimDraftPanel({ vehicleId }: ClaimDraftPanelProps) { + const { t } = useTranslation(); + const [issue, setIssue] = useState(''); + const [submitted, setSubmitted] = useState(null); + const query = useClaimDraft(vehicleId, submitted); + const draftState = useDataState(query); + + return ( + + + +
+ setIssue(e.target.value)} + placeholder={t('serviceIntelligence.claim.placeholder', 'Describe the issue (e.g. charge rate drops after 60%)')} + aria-label={t('serviceIntelligence.claim.issueLabel', 'Issue description')} + className="flex-1" + /> + +
+ } + selectTitle={t('serviceIntelligence.common.selectTitle', 'Select a vehicle')} + selectMessage={t( + 'serviceIntelligence.claim.selectMessage', + 'Choose a vehicle to draft a warranty service ticket.', + )} + emptyTitle={t('serviceIntelligence.claim.emptyTitle', 'No ticket yet')} + emptyMessage={t('serviceIntelligence.claim.empty', 'Describe an issue and generate a ready-to-paste service ticket.')} + onRetry={() => void query.refetch()} + > + {query.data && } + +
+ ); +} diff --git a/web/src/features/service-intelligence/components/WarrantyPanel.test.tsx b/web/src/features/service-intelligence/components/WarrantyPanel.test.tsx new file mode 100644 index 0000000000..56734dfcb1 --- /dev/null +++ b/web/src/features/service-intelligence/components/WarrantyPanel.test.tsx @@ -0,0 +1,73 @@ +/** + * WarrantyPanel — coverage countdown rows + assumption disclosure. + * Pure presentational: outlook passed as props. + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: ( + key: string, + fallback?: unknown, + variables?: Record, + ) => { + if (typeof fallback !== 'string') return key; + return Object.entries(variables ?? {}).reduce( + (value, [name, replacement]) => + value.replace(`{{${name}}}`, String(replacement)), + fallback, + ); + }, + }), +})); + +import type { WarrantyOutlook } from '@/api/hooks/useServiceIntelligence'; +import { WarrantyPanel } from './WarrantyPanel'; + +const outlook: WarrantyOutlook = { + vehicle_id: 9, + model: 'Model Y', + model_year: 2024, + assumption: 'Counted from January 1 of the model year.', + coverages: [ + { name: 'Basic Limited', expires_at: '2028-01-01', days_remaining: 400, km_limit: 80467, km_remaining: 50000, status: 'active', basis: 'time' }, + { name: 'Battery & Drive Unit', expires_at: '2032-01-01', days_remaining: 1800, km_limit: 192000, km_remaining: 160000, status: 'active', basis: 'time' }, + ], +}; + +describe('WarrantyPanel', () => { + it('renders coverage rows with countdowns and the assumption', () => { + render( + {}} />, + ); + expect(screen.getByText('Basic Limited')).toBeTruthy(); + expect(screen.getByText('Battery & Drive Unit')).toBeTruthy(); + expect(screen.getByText('Counted from January 1 of the model year.')).toBeTruthy(); + }); + + it('marks expired coverage', () => { + render( + {}} + />, + ); + expect(screen.getByText('Expired')).toBeTruthy(); + }); + + it('prompts to select a vehicle when none is chosen', () => { + render( + {}} />, + ); + expect(screen.getByText('Select a vehicle')).toBeTruthy(); + }); +}); diff --git a/web/src/features/service-intelligence/components/WarrantyPanel.tsx b/web/src/features/service-intelligence/components/WarrantyPanel.tsx new file mode 100644 index 0000000000..1fc5c4ca01 --- /dev/null +++ b/web/src/features/service-intelligence/components/WarrantyPanel.tsx @@ -0,0 +1,97 @@ +import { useTranslation } from 'react-i18next'; +import { ShieldCheck } from 'lucide-react'; +import { GlassPanel, PanelTitle, Badge, Text, Caption } from '@/components/ui'; +import type { WarrantyOutlook } from '@/api/hooks/useServiceIntelligence'; +import { fmtInt, fmtNumber } from '@/lib/numberFormat'; +import { PanelState } from './PanelState'; + +export interface WarrantyPanelProps { + selected: boolean; + loading: boolean; + error: unknown; + outlook: WarrantyOutlook | null; + onRetry: () => void; +} + +function statusVariant(status: string): 'success' | 'warning' | 'danger' { + switch (status) { + case 'active': + return 'success'; + case 'expiring_soon': + return 'warning'; + default: + return 'danger'; + } +} + +function statusLabel(status: string, t: (k: string, d: string) => string): string { + switch (status) { + case 'active': + return t('serviceIntelligence.warranty.active', 'Active'); + case 'expiring_soon': + return t('serviceIntelligence.warranty.expiringSoon', 'Expiring soon'); + default: + return t('serviceIntelligence.warranty.expired', 'Expired'); + } +} + +export function WarrantyPanel({ selected, loading, error, outlook, onRetry }: WarrantyPanelProps) { + const { t } = useTranslation(); + + return ( + + + + } + selectTitle={t('serviceIntelligence.common.selectTitle', 'Select a vehicle')} + selectMessage={t( + 'serviceIntelligence.warranty.selectMessage', + 'Choose a vehicle to count down its warranty coverage.', + )} + emptyTitle={t('serviceIntelligence.warranty.emptyTitle', 'No warranty outlook')} + emptyMessage={t( + 'serviceIntelligence.warranty.empty', + 'Coverage countdown is not available for this vehicle yet.', + )} + onRetry={onRetry} + > +
+ {(outlook?.coverages ?? []).map((c) => ( +
+
+ {c.name} + + {t('serviceIntelligence.warranty.detail', 'ends {{date}} · {{days}} days left', { + date: c.expires_at, + days: fmtInt(Math.max(c.days_remaining, 0)), + })} + {c.km_remaining != null && ( + <> + {' · '} + {t('serviceIntelligence.warranty.kmLeft', '{{km}} km left', { + km: fmtNumber(Math.max(c.km_remaining, 0), 0), + })} + + )} + +
+ + {statusLabel(c.status, t)} + +
+ ))} + {outlook?.assumption && ( + {outlook.assumption} + )} +
+
+
+ ); +} diff --git a/web/src/features/service-intelligence/components/index.ts b/web/src/features/service-intelligence/components/index.ts index bc08fb8c74..c4a17cd548 100644 --- a/web/src/features/service-intelligence/components/index.ts +++ b/web/src/features/service-intelligence/components/index.ts @@ -1,3 +1,4 @@ +export { ClaimDraftPanel } from './ClaimDraftPanel'; export { CommunicationsPanel } from './CommunicationsPanel'; export { CommunicationsCatalogPanel, @@ -10,3 +11,4 @@ export { RecallInventoryPanel } from './RecallInventoryPanel'; export { SourceFreshnessPanel } from './SourceFreshnessPanel'; export { SymptomMatchesPanel } from './SymptomMatchesPanel'; export { VehicleMatchPanel } from './VehicleMatchPanel'; +export { WarrantyPanel } from './WarrantyPanel'; diff --git a/web/src/features/service-intelligence/pages/ServiceIntelligencePage.tsx b/web/src/features/service-intelligence/pages/ServiceIntelligencePage.tsx index 8cb401b4f8..94a9461c0c 100644 --- a/web/src/features/service-intelligence/pages/ServiceIntelligencePage.tsx +++ b/web/src/features/service-intelligence/pages/ServiceIntelligencePage.tsx @@ -7,6 +7,7 @@ import { useCommunicationsCatalogStatus, useImportCommunicationsCatalog, useServiceIntelligence, + useWarrantyOutlook, SudoCanceledError, type OfficialNHTSACommunicationsArtifactURL, } from '@/api/hooks/useServiceIntelligence'; @@ -19,6 +20,7 @@ import { usePageTitle } from '@/hooks/usePageTitle'; import { useSelectedVehicle } from '@/hooks/useSelectedVehicle'; import { + ClaimDraftPanel, CommunicationsPanel, CommunicationsCatalogPanel, EvidenceLimitationsPanel, @@ -26,6 +28,7 @@ import { SourceFreshnessPanel, SymptomMatchesPanel, VehicleMatchPanel, + WarrantyPanel, } from '../components'; export default function ServiceIntelligencePage() { @@ -33,6 +36,7 @@ export default function ServiceIntelligencePage() { const navigate = useNavigate(); const { vehicleId } = useSelectedVehicle(); const query = useServiceIntelligence(vehicleId); + const warrantyQuery = useWarrantyOutlook(vehicleId); const catalogQuery = useCommunicationsCatalogStatus(); const catalogImport = useImportCommunicationsCatalog(); usePageTitle(t('serviceIntelligence.page.title', 'Recall & Service Intelligence')); @@ -126,6 +130,20 @@ export default function ServiceIntelligencePage() { />
+ + void warrantyQuery.refetch()} + /> + + + + + + ({ @@ -201,6 +201,9 @@ vi.mock('@/components/charts', () => ({ h.charts.lineData = data return
}, + ComposedChart: ({ data }: { data: Array> }) => ( +
+ ), Area: () => null, Line: () => null, XAxis: () => null, @@ -299,7 +302,7 @@ function makeLegacy(overrides: Partial = {}): SharedDriveData } function setData( - data: SharedDriveData | SharedDriveDataV1 | undefined, + data: SharedDriveData | SharedDriveDataV1 | SharedSessionData | undefined, opts: { isLoading?: boolean; error?: Error | null } = {}, ) { h.query.current = { @@ -568,3 +571,42 @@ describe('SharedDrivePage — optional cards + empty states', () => { expect(screen.queryByTestId('map-container')).toBeNull() }) }) + +describe('SharedDrivePage — session share branch', () => { + function sessionPayload(): SharedSessionData { + return { + payload_version: 'v2', + share_type: 'charging_session', + title: 'Baker Supercharger Stop', + description: '', + session: { + date: '2026-03-15', + duration_s: 2400, + energy_added_wh: 45000, + start_soc_pct: 20, + end_soc_pct: 80, + charger_type: 'supercharger', + place: 'Baker, CA', + peak_power_w: 250000, + avg_power_w: 67500, + cost: null, + cost_currency: null, + curve: [{ t_s: 0, power_kw: 250, battery_pct: 20, energy_kwh: 0 }], + }, + vehicle: { model: 'Model 3', color: 'White' }, + } + } + + it('renders the session report instead of the drive report', () => { + setData(sessionPayload()) + renderPage() + + expect(screen.getByText('Baker Supercharger Stop')).toBeInTheDocument() + expect(screen.getByText('Shared Charging Report')).toBeInTheDocument() + expect(screen.getByText('20% → 80%')).toBeInTheDocument() + expect(screen.getByTestId('composed-chart')).toHaveAttribute('data-count', '1') + // Drive chrome stays out: no map, no drive header. + expect(screen.queryByTestId('map-container')).toBeNull() + expect(screen.queryByText('Shared Drive Report')).toBeNull() + }) +}) diff --git a/web/src/features/sharing/pages/SharedDrivePage.tsx b/web/src/features/sharing/pages/SharedDrivePage.tsx index 50de6c40c9..8759434cac 100644 --- a/web/src/features/sharing/pages/SharedDrivePage.tsx +++ b/web/src/features/sharing/pages/SharedDrivePage.tsx @@ -30,7 +30,8 @@ import { convertSpeedFromSI, type DistanceUnitPref, } from '@/lib/unitConversion'; -import { normalizeSharedDriveData } from '@/types/sharing'; +import { normalizeSharedDriveData, isSharedSession } from '@/types/sharing'; +import { SharedSessionReport } from './SharedSessionReport'; /* ------------------------------------------------------------------ */ /* Boundary constants */ @@ -150,7 +151,10 @@ export default function SharedDrivePage() { const { token } = useParams<{ token: string }>(); const { t } = useTranslation(); const { data: rawData, isLoading, error } = useSharedDrive(token ?? ''); - const data = useMemo(() => normalizeSharedDriveData(rawData), [rawData]); + // One /s/:token route serves both link kinds: session payloads branch to + // their own report before drive normalization (which would see no drive). + const driveRaw = isSharedSession(rawData) ? undefined : rawData; + const data = useMemo(() => normalizeSharedDriveData(driveRaw), [driveRaw]); const { unitPrefs, formatDistance, formatSpeed } = useUnits(); const distancePref = unitPrefs.distance; const speedPref = unitPrefs.speed; @@ -209,6 +213,11 @@ export default function SharedDrivePage() { return ; } + /* ---- Session share branch ---- */ + if (isSharedSession(rawData)) { + return ; + } + /* ---- Error / expired ---- */ if (error || !data) { return ; diff --git a/web/src/features/sharing/pages/SharedSessionReport.test.tsx b/web/src/features/sharing/pages/SharedSessionReport.test.tsx new file mode 100644 index 0000000000..7e5a6930c5 --- /dev/null +++ b/web/src/features/sharing/pages/SharedSessionReport.test.tsx @@ -0,0 +1,113 @@ +/** + * SharedSessionReport — behaviour coverage. + * + * The report is presentational over a `SharedSessionData` prop; only the + * unit formatters are mocked. Shared UI (GlassPanel, StatCard, charts) is + * REAL so the render-boundary wiring is genuinely exercised. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +vi.mock('@/hooks/useUnits', () => ({ + useUnits: () => ({ + formatEnergy: (wh: number) => `${(wh / 1000).toFixed(1)} kWh`, + formatPower: (w: number) => `${(w / 1000).toFixed(1)} kW`, + }), +})); + +import { SharedSessionReport } from './SharedSessionReport'; +import type { SharedSessionData } from '@/types/sharing'; + +vi.mock('@/components/charts', async () => { + const actual = await vi.importActual('@/components/charts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children?: ReactNode }) => <>{children}, + }; +}); + +const sessionData: SharedSessionData = { + payload_version: 'v2', + share_type: 'charging_session', + title: 'Baker Supercharger Stop', + description: 'Quick top-up on the way north.', + session: { + date: '2026-03-15', + duration_s: 2400, + energy_added_wh: 45000, + start_soc_pct: 20, + end_soc_pct: 80, + charger_type: 'supercharger', + place: 'Baker, CA', + peak_power_w: 250000, + avg_power_w: 67500, + cost: 9.99, + cost_currency: 'USD', + curve: [ + { t_s: 0, power_kw: 250, battery_pct: 20, energy_kwh: 0 }, + { t_s: 1200, power_kw: 120, battery_pct: 55, energy_kwh: 25 }, + { t_s: 2400, power_kw: 60, battery_pct: 80, energy_kwh: 45 }, + ], + }, + vehicle: { model: 'Model 3', color: 'White' }, +}; + +describe('SharedSessionReport', () => { + it('renders the title, place, and stat grid', () => { + render(); + expect(screen.getByText('Baker Supercharger Stop')).toBeInTheDocument(); + expect(screen.getByText('Quick top-up on the way north.')).toBeInTheDocument(); + expect(screen.getByText('Baker, CA')).toBeInTheDocument(); + expect(screen.getByText('45.0 kWh')).toBeInTheDocument(); + expect(screen.getByText('20% → 80%')).toBeInTheDocument(); + expect(screen.getByText('250.0 kW')).toBeInTheDocument(); + }); + + it('renders the vehicle badge and cost when present', () => { + render(); + expect(screen.getByText('Tesla Model 3')).toBeInTheDocument(); + expect(screen.getByText('USD 9.99')).toBeInTheDocument(); + }); + + it('renders the charge curve chart when points exist', () => { + render(); + expect(screen.getByText('Charge Curve')).toBeInTheDocument(); + }); + + it('shows the no-curve fallback when the curve was not shared', () => { + render( + , + ); + expect( + screen.getByText('The charge curve was not included in this share.'), + ).toBeInTheDocument(); + expect(screen.queryByText('Charge Curve')).not.toBeInTheDocument(); + expect(screen.queryByText('Cost')).not.toBeInTheDocument(); + }); + + it('omits optional stats when the session lacks them', () => { + render( + , + ); + expect(screen.queryByText('Tesla Model 3')).not.toBeInTheDocument(); + expect(screen.queryByText('Energy Added')).not.toBeInTheDocument(); + expect(screen.queryByText('Battery')).not.toBeInTheDocument(); + // Duration always renders. + expect(screen.getByText('Duration')).toBeInTheDocument(); + }); +}); diff --git a/web/src/features/sharing/pages/SharedSessionReport.tsx b/web/src/features/sharing/pages/SharedSessionReport.tsx new file mode 100644 index 0000000000..ece02b5ffb --- /dev/null +++ b/web/src/features/sharing/pages/SharedSessionReport.tsx @@ -0,0 +1,239 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Zap, Clock, Battery, Gauge, DollarSign, MapPin } from 'lucide-react'; +import { GlassPanel } from '@/components/ui'; +import { Grid } from '@/components/layout'; +import { StatCard } from '@/components/data-display'; +import { EmptyState } from '@/components/feedback'; +import { + ChartContainer, ChartGradient, chartGrid, axisTick, + ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, + Tooltip, ResponsiveContainer, + AREA_DEFAULTS, +} from '@/components/charts'; +import Logo from '@/components/ui/Logo'; +import { FadeIn } from '@/components/motion'; +import { formatDurationSecondsAsMinutes } from '@/lib/dateFormat'; +import { useUnits } from '@/hooks/useUnits'; +import { fmtNumber } from '@/lib/numberFormat'; +import type { SharedSessionData } from '@/types/sharing'; + +/* ------------------------------------------------------------------ */ +/* SharedSessionReport — public, chrome-less charging-session report */ +/* ------------------------------------------------------------------ */ + +/** + * Renders a `charging_session` share payload. Mirrors SharedDrivePage's + * structure (branded header → title → stat grid → vehicle badge → curve + * chart → footer) so both link kinds read as one product. All quantities + * convert to display units at this render boundary. + */ +export function SharedSessionReport({ data }: { data: SharedSessionData }) { + const { t } = useTranslation(); + const { formatEnergy, formatPower } = useUnits(); + const session = data.session; + + /* ---- Curve data: wire is already kW/kWh; x in minutes ---- */ + const curveData = useMemo( + () => + (session.curve ?? []).map((p) => ({ + minutes: Math.round((p.t_s / 60) * 10) / 10, + power: p.power_kw, + soc: p.battery_pct, + })), + [session.curve], + ); + + const socDelta = + session.start_soc_pct != null && session.end_soc_pct != null + ? Math.round((session.end_soc_pct - session.start_soc_pct) * 10) / 10 + : null; + + return ( +
+ {/* Header */} +
+
+ + + {t('share.sessionHeader', 'Shared Charging Report')} + +
+
+ + {/* Content */} +
+ {/* Title */} + +
+ {/* a11y-landmark-ok: the "share link unavailable" heading lives + in a mutually-exclusive early-return branch, so only one

+ can ever be rendered. */} +

+ {data.title} +

+ {data.description && ( +

{data.description}

+ )} +
+ {session.date} + {session.place && {session.place}} + {session.charger_type && {session.charger_type}} +
+
+
+ + {/* Stats grid */} + + + {session.energy_added_wh != null && ( + } + /> + )} + } + /> + {session.start_soc_pct != null && session.end_soc_pct != null && ( + } + /> + )} + {session.peak_power_w != null && ( + } + /> + )} + {socDelta != null && socDelta > 0 && session.energy_added_wh != null && ( + } + /> + )} + {session.cost != null && ( + } + /> + )} + + + + {/* Vehicle badge */} + {data.vehicle && ( + + +
+ +
+
+

+ Tesla {data.vehicle.model} +

+

{data.vehicle.color}

+
+
+
+ )} + + {/* Charge curve */} + {curveData.length > 0 && ( + + {/* chart-a11y:no-table dense per-sample shared-session trace */} + + + + + + + + `${Math.round(v)} min`} + /> + `${Math.round(v)} kW`} + /> + `${Math.round(v)}%`} + /> + `${fmtNumber(v, 1)} min`} + /> + + + + + + + )} + + {/* No curve fallback */} + {curveData.length === 0 && ( + + } + message={t('share.noCurveData', 'The charge curve was not included in this share.')} + /> + + )} + + {/* Footer */} + +
+

{t('share.footer', 'Shared via TeslaSync — Self-hosted Tesla Fleet Intelligence')}

+ + {t('share.learnMore', 'Learn more →')} + +
+
+
+
+ ); +} diff --git a/web/src/features/system/components/chatbot/ChatMessageItem.test.tsx b/web/src/features/system/components/chatbot/ChatMessageItem.test.tsx index 87d5d97d13..e48c9d009d 100644 --- a/web/src/features/system/components/chatbot/ChatMessageItem.test.tsx +++ b/web/src/features/system/components/chatbot/ChatMessageItem.test.tsx @@ -345,4 +345,31 @@ describe('ChatMessageItem', () => { expect(box.value).toBe(''); expect(screen.getByRole('button', { name: 'Save & resend' })).toBeDisabled(); }); + + it('renders deep-link citations on a completed assistant message', () => { + const message = makeMessage({ + links: [ + { label: 'Cost analysis', path: '/cost-analysis' }, + { label: 'Charging', path: '/charging' }, + ], + }); + renderItem(message); + + const citations = screen.getByLabelText('Sources'); + expect(citations).toBeInTheDocument(); + const cost = screen.getByRole('link', { name: 'Cost analysis' }); + expect(cost).toHaveAttribute('href', '/cost-analysis'); + expect(screen.getByRole('link', { name: 'Charging' })).toHaveAttribute('href', '/charging'); + }); + + it('hides citations while streaming and on user messages', () => { + renderItem(makeMessage({ isStreaming: true, links: [{ label: 'Cost analysis', path: '/cost-analysis' }] })); + expect(screen.queryByLabelText('Sources')).toBeNull(); + + renderItem( + makeMessage({ role: 'user', content: 'hi', links: [{ label: 'Cost analysis', path: '/cost-analysis' }] }), + { isLastAssistant: false, isLastUser: true }, + ); + expect(screen.queryByLabelText('Sources')).toBeNull(); + }); }); diff --git a/web/src/features/system/components/chatbot/ChatMessageItem.tsx b/web/src/features/system/components/chatbot/ChatMessageItem.tsx index 0b86cad0ef..301f31d85b 100644 --- a/web/src/features/system/components/chatbot/ChatMessageItem.tsx +++ b/web/src/features/system/components/chatbot/ChatMessageItem.tsx @@ -5,7 +5,7 @@ import { Button, CopyButton, Text, Textarea } from '@/components/ui'; import { Avatar } from '@/components/data-display'; import { cn } from '@/lib/cn'; import { formatTime } from '@/lib/dateFormat'; -import type { ChatMessage } from '@/api/types'; +import type { ChatLink, ChatMessage } from '@/api/types'; import { HelixEvidenceTrail } from '@/components/ai/HelixEvidenceTrail'; import type { AiToolActivity, AiUsage } from '@/hooks/useAiStream'; import { MarkdownRenderer } from './MarkdownRenderer'; @@ -18,6 +18,8 @@ import { MarkdownRenderer } from './MarkdownRenderer'; */ export interface UIChatMessage extends ChatMessage { isStreaming?: boolean; + /** Deep-link citations from the send response (fresh turns only). */ + links?: ChatLink[] | null; /** Partial reveal during the typewriter animation. Falls back to content. */ streamedText?: string; /** Privacy-safe tool provenance retained for this assistant turn. */ @@ -190,6 +192,19 @@ export function ChatMessageItem({ state={message.isStreaming ? 'streaming' : 'done'} usage={message.aiUsage} /> + {(message.links?.length ?? 0) > 0 && !message.isStreaming && ( + + )}
)} diff --git a/web/src/features/system/pages/ChatbotPage.tsx b/web/src/features/system/pages/ChatbotPage.tsx index 7bde5fc39f..acd5c28d01 100644 --- a/web/src/features/system/pages/ChatbotPage.tsx +++ b/web/src/features/system/pages/ChatbotPage.tsx @@ -215,6 +215,7 @@ export default function ChatbotPage() { created_at: created, isStreaming: true, streamedText: '', + links: data.links ?? [], }; setMessages((prev) => [...prev, assistantMsg]); stream.start(assistantId, data.response); diff --git a/web/src/features/trips/components/JourneyPanel.test.tsx b/web/src/features/trips/components/JourneyPanel.test.tsx new file mode 100644 index 0000000000..cc5fb44fe8 --- /dev/null +++ b/web/src/features/trips/components/JourneyPanel.test.tsx @@ -0,0 +1,184 @@ +/** + * JourneyPanel — behaviour coverage. + * + * Data hooks (`useJourneys` / `useJourney` / `useCreateJourney` / + * `useTransitionJourney`) are mocked and driven per test; shared UI + * (GlassPanel, DataTable, Badge, Select, Input, Button, QueryError, + * EmptyState) is REAL so the render-boundary wiring is genuinely + * exercised. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +// ── i18n stub ── +vi.mock('react-i18next', () => { + const interpolate = (str: string, vars?: Record | null): string => { + if (!vars) return str; + let s = str; + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`{{\\s*${k}\\s*}}`, 'g'), String(v)); + } + return s; + }; + const t = (key: string, second?: unknown, third?: unknown): string => { + if (typeof second === 'string') return interpolate(second, third as Record | undefined); + if (second && typeof second === 'object') { + const bag = second as Record; + const tpl = typeof bag.defaultValue === 'string' ? bag.defaultValue : key; + return interpolate(tpl, bag); + } + return key; + }; + return { + useTranslation: () => ({ t, i18n: { language: 'en', changeLanguage: vi.fn() } }), + Trans: ({ children }: { children?: ReactNode }) => <>{children}, + initReactI18next: { type: '3rdParty', init: () => undefined }, + }; +}); + +// ── data hooks, driven per test ── +vi.mock('@/api/hooks/useJourney', () => ({ + useJourneys: vi.fn(), + useJourney: vi.fn(), + useCreateJourney: vi.fn(), + useTransitionJourney: vi.fn(), +})); + +import { + useJourneys, + useJourney, + useCreateJourney, + useTransitionJourney, +} from '@/api/hooks/useJourney'; +import { JourneyPanel } from './JourneyPanel'; + +const mockList = useJourneys as unknown as ReturnType; +const mockDetail = useJourney as unknown as ReturnType; +const mockCreate = useCreateJourney as unknown as ReturnType; +const mockTransition = useTransitionJourney as unknown as ReturnType; + +const sessions = [ + { + id: 1, vehicle_id: 7, name: 'Tahoe ski trip', + origin_name: 'Home', origin_lat: null, origin_lng: null, + dest_name: 'Tahoe', dest_lat: 39.1, dest_lng: -120.0, + status: 'planned', plan_version: 1, + created_at: '2026-09-10T10:00:00Z', updated_at: '2026-09-10T10:00:00Z', + started_at: null, ended_at: null, + }, + { + id: 2, vehicle_id: 7, name: 'LA run', + origin_name: '', origin_lat: null, origin_lng: null, + dest_name: '', dest_lat: null, dest_lng: null, + status: 'active', plan_version: 3, + created_at: '2026-09-09T10:00:00Z', updated_at: '2026-09-11T08:00:00Z', + started_at: '2026-09-11T08:00:00Z', ended_at: null, + }, +]; + +const detail = { + session: sessions[0], + plans: [ + { id: 11, session_id: 1, version: 1, plan: {}, note: 'initial', created_at: '2026-09-10T10:00:00Z' }, + ], + next_statuses: ['active', 'aborted'], +}; + +function idle(extra = {}) { + return { + data: undefined, isLoading: false, isFetching: false, isError: false, + isPending: false, fetchStatus: 'idle', dataUpdatedAt: Date.now(), + error: null, refetch: vi.fn(), ...extra, + }; +} + +function renderPanel(vehicleId: number | null = 7) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockList.mockReturnValue(idle({ data: sessions })); + mockDetail.mockReturnValue(idle()); + mockCreate.mockReturnValue({ mutate: vi.fn(), isPending: false }); + mockTransition.mockReturnValue({ mutate: vi.fn(), isPending: false }); +}); + +describe('JourneyPanel', () => { + it('lists sessions with routes and statuses', () => { + renderPanel(); + expect(mockList).toHaveBeenCalledWith(7, ''); + expect(screen.getByText('Tahoe ski trip')).toBeInTheDocument(); + expect(screen.getByText('Home → Tahoe')).toBeInTheDocument(); + expect(screen.getByText('planned')).toBeInTheDocument(); + expect(screen.getByText('v1')).toBeInTheDocument(); + }); + + it('shows a skeleton while loading and an empty state without data', () => { + mockList.mockReturnValue(idle({ data: undefined, isLoading: true })); + const { unmount } = renderPanel(); + expect(screen.getByRole('status', { name: 'Loading journeys…' })).toBeInTheDocument(); + unmount(); + + mockList.mockReturnValue(idle({ data: [] })); + renderPanel(); + expect(screen.getByText(/No journeys yet/)).toBeInTheDocument(); + }); + + it('creates a journey from the form', () => { + const mutate = vi.fn(); + mockCreate.mockReturnValue({ mutate, isPending: false }); + renderPanel(); + fireEvent.click(screen.getByText('Plan journey')); + fireEvent.change(screen.getByLabelText('Journey name'), { + target: { value: 'Vegas weekend' }, + }); + fireEvent.change(screen.getByLabelText('Destination'), { + target: { value: 'Las Vegas' }, + }); + fireEvent.click(screen.getByText('Create journey')); + expect(mutate).toHaveBeenCalledWith( + { vehicle_id: 7, name: 'Vegas weekend', origin_name: undefined, dest_name: 'Las Vegas' }, + expect.anything(), + ); + }); + + it('opens a session and offers only server-provided transitions', () => { + mockDetail.mockReturnValue(idle({ data: detail })); + renderPanel(); + fireEvent.click(screen.getAllByText('Open')[0]); + expect(mockDetail).toHaveBeenCalledWith(1); + expect(screen.getByText('Start')).toBeInTheDocument(); + expect(screen.getByText('Abort')).toBeInTheDocument(); + expect(screen.queryByText('Pause')).not.toBeInTheDocument(); + expect(screen.getByText(/v1 · initial/)).toBeInTheDocument(); + }); + + it('fires the transition mutation with the session id and action', () => { + const mutate = vi.fn(); + mockTransition.mockReturnValue({ mutate, isPending: false }); + mockDetail.mockReturnValue(idle({ data: detail })); + renderPanel(); + fireEvent.click(screen.getAllByText('Open')[0]); + fireEvent.click(screen.getByText('Start')); + expect(mutate).toHaveBeenCalledWith({ id: 1, action: 'start' }); + }); + + it('surfaces list failures with a retry path', () => { + const refetch = vi.fn(); + mockList.mockReturnValue(idle({ error: new Error('list down'), isError: true, refetch })); + renderPanel(); + fireEvent.click(screen.getByText('Retry')); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/web/src/features/trips/components/JourneyPanel.tsx b/web/src/features/trips/components/JourneyPanel.tsx new file mode 100644 index 0000000000..44cf74b942 --- /dev/null +++ b/web/src/features/trips/components/JourneyPanel.tsx @@ -0,0 +1,352 @@ +import { type FormEvent, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icons } from '@/lib/icons'; +import { + useCreateJourney, + useJourney, + useJourneys, + useTransitionJourney, + type JourneySession, + type JourneyStatus, + type JourneyTransition, +} from '@/api/hooks/useJourney'; +import { useDataState } from '@/hooks/useDataState'; +import { Badge, Button, DataTable, GlassPanel, Input, PanelTitle, Select, Text } from '@/components/ui'; +import type { Column } from '@/components/ui'; +import { EmptyState, ListSkeleton, QueryError } from '@/components/feedback'; +import { formatDateTime } from '@/lib/dateFormat'; + +const STATUS_FILTERS = ['', 'planned', 'active', 'paused', 'completed', 'aborted'] as const; + +const STATUS_LABEL_KEYS: Record, string> = { + planned: 'journey.status.planned', + active: 'journey.status.active', + paused: 'journey.status.paused', + completed: 'journey.status.completed', + aborted: 'journey.status.aborted', +}; + +const STATUS_LABEL_DEFAULTS: Record, string> = { + planned: 'Planned', + active: 'Active', + paused: 'Paused', + completed: 'Completed', + aborted: 'Aborted', +}; + +function statusVariant(status: JourneyStatus) { + switch (status) { + case 'active': + return 'success' as const; + case 'paused': + return 'warning' as const; + case 'completed': + return 'info' as const; + case 'aborted': + return 'danger' as const; + default: + return 'neutral' as const; + } +} + +function transitionAction(next: JourneyStatus, current: JourneyStatus): JourneyTransition { + if (next === 'active') return current === 'paused' ? 'resume' : 'start'; + if (next === 'paused') return 'pause'; + if (next === 'completed') return 'complete'; + return 'abort'; +} + +const TRANSITION_LABEL_KEYS = { + start: 'journey.transition.start', + pause: 'journey.transition.pause', + resume: 'journey.transition.resume', + complete: 'journey.transition.complete', + abort: 'journey.transition.abort', +} as const; + +const TRANSITION_DEFAULTS = { + start: 'Start', + pause: 'Pause', + resume: 'Resume', + complete: 'Complete', + abort: 'Abort', +} as const; + +function transitionIcon(action: JourneyTransition) { + switch (action) { + case 'start': + case 'resume': + return