-
-
-
-
-
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 ›
-
-
-
-
-
-
+
-
-
-
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.
-
-
Understand a drive
-
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 ›
-
-
-
-
-
-
-
-
-
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 ›
-
-
+
+
+Get started
+Install, connect Tesla, enable streaming, then open the catalogue.
+
+
+Connect Tesla
+Fleet API application, scopes, redirect URI, owner consent.
+
+
+Enable streaming
+Fleet Telemetry receiver, TLS, virtual key, signed config.
+
+
+Find a screen
+213 routes grouped like the app sidebar.
+
+
+Docker
+Compose, ports, secrets, local trial vs public host.
+
+
+Operate
+Release verification, secrets, Fleet API budget.
+
+
-
-
-
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 (
+
+
+
+
+ {t('stormguard.title', 'Storm Guardian')}
+
+ {assessment && (
+
+ {assessment.level === 'none' ? (
+
+ ) : (
+
+ )}
+ {levelLabel(t, assessment.level)}
+
+ )}
+
+
+ {!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 })}
+
+ )}
+
+
+
+
+ {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() {
)}
+
+
+
+
+ {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. */}
+