From 7da287b068e2d6a3aa0df36b3751d47bc17f5a68 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 07:14:57 -0700 Subject: [PATCH 01/40] fix: null safety for Charging page and API latest handlers - Fix TypeError 'Cannot read properties of undefined (reading toFixed)' in Charging.tsx by using typeof checks and nullish coalescing - Add nil checks to all Latest() API handlers (motor, climate, security, media, location) to return 404 instead of 200 with null body - Prevents frontend crash when API returns null/undefined for nullable fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/climate_handler.go | 4 ++++ internal/api/location_snapshot_handler.go | 4 ++++ internal/api/media_handler.go | 4 ++++ internal/api/motor_handler.go | 4 ++++ internal/api/security_handler.go | 4 ++++ web/src/pages/Charging.tsx | 12 ++++++------ 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/internal/api/climate_handler.go b/internal/api/climate_handler.go index 308ef6e8..8938abae 100644 --- a/internal/api/climate_handler.go +++ b/internal/api/climate_handler.go @@ -48,5 +48,9 @@ func (h *ClimateHandler) Latest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get climate data") return } + if snap == nil { + writeError(w, http.StatusNotFound, "no climate data available") + return + } writeJSON(w, http.StatusOK, snap) } diff --git a/internal/api/location_snapshot_handler.go b/internal/api/location_snapshot_handler.go index 1518ef94..b028dd89 100644 --- a/internal/api/location_snapshot_handler.go +++ b/internal/api/location_snapshot_handler.go @@ -48,5 +48,9 @@ func (h *LocationSnapshotHandler) Latest(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, "failed to get location snapshot") return } + if snap == nil { + writeError(w, http.StatusNotFound, "no location data available") + return + } writeJSON(w, http.StatusOK, snap) } diff --git a/internal/api/media_handler.go b/internal/api/media_handler.go index 1b8e7cae..cb46cc45 100644 --- a/internal/api/media_handler.go +++ b/internal/api/media_handler.go @@ -48,5 +48,9 @@ func (h *MediaHandler) Latest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get media data") return } + if snap == nil { + writeError(w, http.StatusNotFound, "no media data available") + return + } writeJSON(w, http.StatusOK, snap) } diff --git a/internal/api/motor_handler.go b/internal/api/motor_handler.go index b605ff83..2f3407d6 100644 --- a/internal/api/motor_handler.go +++ b/internal/api/motor_handler.go @@ -48,5 +48,9 @@ func (h *MotorHandler) Latest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get motor data") return } + if snap == nil { + writeError(w, http.StatusNotFound, "no motor data available") + return + } writeJSON(w, http.StatusOK, snap) } diff --git a/internal/api/security_handler.go b/internal/api/security_handler.go index 78733f50..313bc78f 100644 --- a/internal/api/security_handler.go +++ b/internal/api/security_handler.go @@ -48,5 +48,9 @@ func (h *SecurityHandler) Latest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get security data") return } + if evt == nil { + writeError(w, http.StatusNotFound, "no security data available") + return + } writeJSON(w, http.StatusOK, evt) } diff --git a/web/src/pages/Charging.tsx b/web/src/pages/Charging.tsx index 051c2e9e..b1fc934e 100644 --- a/web/src/pages/Charging.tsx +++ b/web/src/pages/Charging.tsx @@ -94,14 +94,14 @@ function SessionCard({ session, convertDistance, distanceUnit }: { session: Char {batteryGain > 0 && +{batteryGain}%}
- {session.charge_energy_added.toFixed(1)} kWh + {(session.charge_energy_added ?? 0).toFixed(1)} kWh {formatDuration(session.duration_min)} {session.charger_power !== null && {session.charger_power} kW peak} {avgRate && ~{avgRate} kW avg} - {session.cost !== null && ${session.cost.toFixed(2)}} - {costPerKwh !== null && (${costPerKwh.toFixed(3)}/kWh)} - {efficiency !== null && {efficiency.toFixed(1)}% eff} - {rangeGained !== null && rangeGained > 0 && +{rangeGained.toFixed(0)} {distanceUnit}} + {typeof session.cost === 'number' && ${session.cost.toFixed(2)}} + {typeof costPerKwh === 'number' && (${costPerKwh.toFixed(3)}/kWh)} + {typeof efficiency === 'number' && {efficiency.toFixed(1)}% eff} + {typeof rangeGained === 'number' && rangeGained > 0 && +{rangeGained.toFixed(0)} {distanceUnit}}
{chargerSpec && (
@@ -167,7 +167,7 @@ export default function Charging() { if (!sessions) return [] return sessions.slice(0, 20).reverse().map(s => ({ date: new Date(s.start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), - energy: parseFloat(s.charge_energy_added.toFixed(1)), + energy: parseFloat((s.charge_energy_added ?? 0).toFixed(1)), cost: s.cost ?? 0, })) }, [sessions]) From 4ea1244d53575b2b8698e76c8a08aa74ad49ebe9 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 07:18:15 -0700 Subject: [PATCH 02/40] fix: null safety for Energy and Dashboard pages - Fix Energy.tsx: guard .toFixed() calls on session.cost, charge_energy_added, and charger breakdown data - Fix Dashboard.tsx: guard .toFixed() on inside_temp, outside_temp, charge_rate, and time_to_full_charge which can be null from API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/pages/Dashboard.tsx | 10 +++++----- web/src/pages/Energy.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index f93fbe4c..fa8f1844 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -64,7 +64,7 @@ function FleetVehicleStrip({ vehicle, state }: { vehicle: Vehicle; state?: Vehic

Temp

-

{convertTemp(state.inside_temp).toFixed(0)}°

+

{state.inside_temp != null ? `${convertTemp(state.inside_temp).toFixed(0)}°` : '—'}

) : ( @@ -353,11 +353,11 @@ export default function Dashboard() {

Rate

-

{convertDistance(primaryState.charge_rate).toFixed(0)} {distanceUnit}/h

+

{convertDistance(primaryState.charge_rate ?? 0).toFixed(0)} {distanceUnit}/h

Time to Full

-

{primaryState.time_to_full_charge > 0 ? `${primaryState.time_to_full_charge.toFixed(1)}h` : '—'}

+

{primaryState.time_to_full_charge != null && primaryState.time_to_full_charge > 0 ? `${primaryState.time_to_full_charge.toFixed(1)}h` : '—'}

@@ -366,8 +366,8 @@ export default function Dashboard() { {/* Quick telemetry grid */}
{[ - { icon: Thermometer, label: 'Inside', value: `${convertTemp(primaryState.inside_temp).toFixed(1)}${tempUnit}`, color: '#f97316' }, - { icon: Thermometer, label: 'Outside', value: `${convertTemp(primaryState.outside_temp).toFixed(1)}${tempUnit}`, color: '#3b82f6' }, + { icon: Thermometer, label: 'Inside', value: `${primaryState.inside_temp != null ? convertTemp(primaryState.inside_temp).toFixed(1) : '—'}${primaryState.inside_temp != null ? tempUnit : ''}`, color: '#f97316' }, + { icon: Thermometer, label: 'Outside', value: `${primaryState.outside_temp != null ? convertTemp(primaryState.outside_temp).toFixed(1) : '—'}${primaryState.outside_temp != null ? tempUnit : ''}`, color: '#3b82f6' }, { icon: Navigation, label: 'Odometer', value: `${Math.round(convertDistance(primaryState.odometer)).toLocaleString()} ${distanceUnit}`, color: '#a855f7' }, { icon: primaryState.is_locked ? Lock : Unlock, label: 'Status', value: primaryState.is_locked ? 'Locked' : 'Unlocked', color: primaryState.is_locked ? '#10b981' : '#f59e0b' }, { icon: Shield, label: 'Sentry', value: primaryState.sentry_mode ? 'Active' : 'Off', color: primaryState.sentry_mode ? '#ef4444' : '#374151' }, diff --git a/web/src/pages/Energy.tsx b/web/src/pages/Energy.tsx index a0c0fb5f..a7a0b820 100644 --- a/web/src/pages/Energy.tsx +++ b/web/src/pages/Energy.tsx @@ -319,8 +319,8 @@ export default function Energy() { {b.count} sessions
- {b.energy.toFixed(1)} kWh - ${b.cost.toFixed(2)} + {(b.energy ?? 0).toFixed(1)} kWh + ${(b.cost ?? 0).toFixed(2)} ${b.energy > 0 ? (b.cost / b.energy).toFixed(3) : '0'}/kWh
@@ -360,7 +360,7 @@ export default function Energy() { {new Date(s.start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - {s.charge_energy_added.toFixed(1)} kWh + {(s.charge_energy_added ?? 0).toFixed(1)} kWh {s.start_battery_level}% @@ -376,8 +376,8 @@ export default function Energy() { {s.fast_charger_type?.toLowerCase().includes('tesla') ? 'Supercharger' : s.fast_charger_type || 'AC'} - {s.cost !== null ? `$${s.cost.toFixed(2)}` : '—'} - {s.cost !== null && s.charge_energy_added > 0 ? `$${(s.cost / s.charge_energy_added).toFixed(3)}` : '—'} + {typeof s.cost === 'number' ? `$${s.cost.toFixed(2)}` : '—'} + {typeof s.cost === 'number' && s.charge_energy_added > 0 ? `$${(s.cost / s.charge_energy_added).toFixed(3)}` : '—'} ))} From b624ff260ef16c3f8afc0bc996b69dbcd86c15ef Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 08:00:05 -0700 Subject: [PATCH 03/40] fix: increase telemetry DB write timeout from 5s to 30s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5-second context timeout was too short for 12+ sequential DB operations in the telemetry goroutine. When the first operation (UpdateState) exceeded 5s, the context was cancelled and ALL subsequent tracking functions (motor, climate, location, etc.) silently failed — no data was persisted to snapshot tables. Also add logging for VIN lookup failures which were previously completely silent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/telemetry_handler.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index 7f1a7ca7..9821fb1e 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -161,6 +161,9 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa // Find vehicle by VIN and store position var vehicleID int64 err := h.db.Pool.QueryRow(ctx, "SELECT id FROM vehicles WHERE vin = $1", vin).Scan(&vehicleID) + if err != nil { + log.Warn().Err(err).Str("vin", vin).Msg("telemetry: vehicle not found or DB error") + } if err == nil && pos != nil { pos.VehicleID = vehicleID if err := h.posRepo.Insert(ctx, pos); err != nil { @@ -233,7 +236,7 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa // --- Async writes: state tracking, mileage, tire pressure, vehicle health --- if vehicleID > 0 { go func() { - bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Update vehicle to online/healthy From ac85f3e81a483de39e0754ca6dc44ca281e0ea28 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 09:05:38 -0700 Subject: [PATCH 04/40] feat: add migration 17 for comprehensive telemetry tables The comprehensive telemetry panels feature (#27) added new columns and tables to the Go code but no database migration was created. This caused all API endpoints to return 500 errors: - column 'hvac_ac_enabled' does not exist - column 'di_torque_actual_f' does not exist - column 'homelink_device_count' does not exist - relation 'location_snapshots' does not exist - relation 'media_snapshots' does not exist Adds 34 columns to motor_snapshots, 21 to climate_snapshots, 17 to security_events. Creates 7 new tables: location_snapshots, media_snapshots, safety_snapshots, user_preference_snapshots, vehicle_config_snapshots, tire_pressure_snapshots, charging_telemetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../000017_comprehensive_telemetry.down.sql | 87 ++++++ .../000017_comprehensive_telemetry.up.sql | 275 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 migrations/000017_comprehensive_telemetry.down.sql create mode 100644 migrations/000017_comprehensive_telemetry.up.sql diff --git a/migrations/000017_comprehensive_telemetry.down.sql b/migrations/000017_comprehensive_telemetry.down.sql new file mode 100644 index 00000000..1042e537 --- /dev/null +++ b/migrations/000017_comprehensive_telemetry.down.sql @@ -0,0 +1,87 @@ +-- Reverse migration 17: Comprehensive telemetry panels + +DROP TABLE IF EXISTS charging_telemetry; +DROP TABLE IF EXISTS tire_pressure_snapshots; +DROP TABLE IF EXISTS vehicle_config_snapshots; +DROP TABLE IF EXISTS user_preference_snapshots; +DROP TABLE IF EXISTS safety_snapshots; +DROP TABLE IF EXISTS media_snapshots; +DROP TABLE IF EXISTS location_snapshots; + +-- Remove columns from security_events +ALTER TABLE security_events DROP COLUMN IF EXISTS homelink_device_count; +ALTER TABLE security_events DROP COLUMN IF EXISTS guest_mode_mobile_access_state; +ALTER TABLE security_events DROP COLUMN IF EXISTS driver_seat_occupied; +ALTER TABLE security_events DROP COLUMN IF EXISTS center_display; +ALTER TABLE security_events DROP COLUMN IF EXISTS speed_limit_mode; +ALTER TABLE security_events DROP COLUMN IF EXISTS valet_mode_enabled; +ALTER TABLE security_events DROP COLUMN IF EXISTS service_mode; +ALTER TABLE security_events DROP COLUMN IF EXISTS current_limit_mph; +ALTER TABLE security_events DROP COLUMN IF EXISTS paired_phone_key_count; +ALTER TABLE security_events DROP COLUMN IF EXISTS lights_hazards_active; +ALTER TABLE security_events DROP COLUMN IF EXISTS lights_high_beams; +ALTER TABLE security_events DROP COLUMN IF EXISTS lights_turn_signal; +ALTER TABLE security_events DROP COLUMN IF EXISTS tonneau_position; +ALTER TABLE security_events DROP COLUMN IF EXISTS tonneau_open_percent; +ALTER TABLE security_events DROP COLUMN IF EXISTS tonneau_tent_mode; +ALTER TABLE security_events DROP COLUMN IF EXISTS driver_seat_belt; +ALTER TABLE security_events DROP COLUMN IF EXISTS passenger_seat_belt; + +-- Remove columns from climate_snapshots +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS hvac_ac_enabled; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS hvac_auto_mode; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS hvac_fan_status; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS hvac_steering_wheel_heat_auto; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS hvac_steering_wheel_heat_level; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS climate_keeper_mode; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS cabin_overheat_protection_temp_limit; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS defrost_for_preconditioning; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS seat_heater_left; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS seat_heater_right; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS seat_heater_rear_left; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS seat_heater_rear_center; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS seat_heater_rear_right; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS seat_vent_enabled; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS climate_seat_cooling_front_left; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS climate_seat_cooling_front_right; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS auto_seat_climate_left; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS auto_seat_climate_right; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS rear_defrost_enabled; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS rear_display_hvac_enabled; +ALTER TABLE climate_snapshots DROP COLUMN IF EXISTS wiper_heat_enabled; + +-- Remove columns from motor_snapshots +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_torque_actual_f; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_torque_actual_r; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_torque_actual_rel; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_torque_actual_rer; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_axle_speed_f; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_axle_speed_rel; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_axle_speed_rer; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_state_f; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_state_rel; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_state_rer; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_stator_temp_f; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_stator_temp_rel; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_stator_temp_rer; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_heatsink_t_f; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_heatsink_t_r; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_heatsink_t_rel; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_heatsink_t_rer; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_inverter_t_f; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_inverter_t_r; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_inverter_t_rel; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_inverter_t_rer; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_motor_current_f; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_motor_current_r; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_motor_current_rel; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_motor_current_rer; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_v_bat_f; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_v_bat_r; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_v_bat_rel; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_v_bat_rer; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS di_slave_torque_cmd; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS hvil; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS brake_pedal_pos; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS cruise_set_speed; +ALTER TABLE motor_snapshots DROP COLUMN IF EXISTS drive_rail; diff --git a/migrations/000017_comprehensive_telemetry.up.sql b/migrations/000017_comprehensive_telemetry.up.sql new file mode 100644 index 00000000..1b7cce01 --- /dev/null +++ b/migrations/000017_comprehensive_telemetry.up.sql @@ -0,0 +1,275 @@ +-- Migration 17: Comprehensive telemetry panels +-- Adds columns to motor_snapshots, climate_snapshots, security_events +-- Creates new tables: location_snapshots, media_snapshots, safety_snapshots, +-- user_preference_snapshots, vehicle_config_snapshots, tire_pressure_snapshots, +-- charging_telemetry + +-- ============================================================ +-- ALTER motor_snapshots: add multi-motor + extended powertrain columns +-- ============================================================ +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_torque_actual_f DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_torque_actual_r DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_torque_actual_rel DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_torque_actual_rer DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_axle_speed_f DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_axle_speed_rel DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_axle_speed_rer DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_state_f VARCHAR(20); +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_state_rel VARCHAR(20); +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_state_rer VARCHAR(20); +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_stator_temp_f DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_stator_temp_rel DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_stator_temp_rer DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_heatsink_t_f DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_heatsink_t_r DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_heatsink_t_rel DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_heatsink_t_rer DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_inverter_t_f DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_inverter_t_r DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_inverter_t_rel DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_inverter_t_rer DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_motor_current_f DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_motor_current_r DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_motor_current_rel DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_motor_current_rer DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_v_bat_f DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_v_bat_r DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_v_bat_rel DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_v_bat_rer DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS di_slave_torque_cmd DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS hvil VARCHAR(20); +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS brake_pedal_pos DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS cruise_set_speed DOUBLE PRECISION; +ALTER TABLE motor_snapshots ADD COLUMN IF NOT EXISTS drive_rail BOOLEAN; + +-- ============================================================ +-- ALTER climate_snapshots: add extended HVAC/seat/vent columns +-- ============================================================ +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS hvac_ac_enabled BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS hvac_auto_mode BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS hvac_fan_status INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS hvac_steering_wheel_heat_auto BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS hvac_steering_wheel_heat_level INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS climate_keeper_mode VARCHAR(20); +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS cabin_overheat_protection_temp_limit DOUBLE PRECISION; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS defrost_for_preconditioning BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS seat_heater_left INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS seat_heater_right INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS seat_heater_rear_left INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS seat_heater_rear_center INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS seat_heater_rear_right INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS seat_vent_enabled BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS climate_seat_cooling_front_left INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS climate_seat_cooling_front_right INTEGER; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS auto_seat_climate_left BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS auto_seat_climate_right BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS rear_defrost_enabled BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS rear_display_hvac_enabled BOOLEAN; +ALTER TABLE climate_snapshots ADD COLUMN IF NOT EXISTS wiper_heat_enabled BOOLEAN; + +-- ============================================================ +-- ALTER security_events: add extended security columns +-- ============================================================ +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS homelink_device_count INTEGER; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS guest_mode_mobile_access_state VARCHAR(20); +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS driver_seat_occupied BOOLEAN; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS center_display VARCHAR(20); +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS speed_limit_mode VARCHAR(20); +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS valet_mode_enabled BOOLEAN; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS service_mode BOOLEAN; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS current_limit_mph DOUBLE PRECISION; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS paired_phone_key_count INTEGER; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS lights_hazards_active BOOLEAN; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS lights_high_beams BOOLEAN; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS lights_turn_signal VARCHAR(10); +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS tonneau_position VARCHAR(20); +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS tonneau_open_percent DOUBLE PRECISION; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS tonneau_tent_mode BOOLEAN; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS driver_seat_belt BOOLEAN; +ALTER TABLE security_events ADD COLUMN IF NOT EXISTS passenger_seat_belt BOOLEAN; + +-- ============================================================ +-- CREATE location_snapshots +-- ============================================================ +CREATE TABLE IF NOT EXISTS location_snapshots ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + destination_name VARCHAR(255), + destination_lat DOUBLE PRECISION, + destination_lon DOUBLE PRECISION, + origin_lat DOUBLE PRECISION, + origin_lon DOUBLE PRECISION, + miles_to_arrival DOUBLE PRECISION, + minutes_to_arrival DOUBLE PRECISION, + route_line TEXT, + route_traffic_delay_min DOUBLE PRECISION, + located_at_home BOOLEAN, + located_at_work BOOLEAN, + located_at_favorite BOOLEAN, + gps_state VARCHAR(20), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_location_snapshots_vehicle_time ON location_snapshots (vehicle_id, created_at DESC); + +-- ============================================================ +-- CREATE media_snapshots +-- ============================================================ +CREATE TABLE IF NOT EXISTS media_snapshots ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + now_playing_title VARCHAR(255), + now_playing_artist VARCHAR(255), + now_playing_album VARCHAR(255), + now_playing_station VARCHAR(255), + now_playing_duration INTEGER, + now_playing_elapsed INTEGER, + playback_status VARCHAR(20), + playback_source VARCHAR(50), + audio_volume INTEGER, + audio_volume_max INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_media_snapshots_vehicle_time ON media_snapshots (vehicle_id, created_at DESC); + +-- ============================================================ +-- CREATE safety_snapshots +-- ============================================================ +CREATE TABLE IF NOT EXISTS safety_snapshots ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + automatic_blind_spot_camera BOOLEAN, + automatic_emergency_braking_off BOOLEAN, + blind_spot_collision_warning BOOLEAN, + cruise_follow_distance VARCHAR(10), + emergency_lane_departure_avoidance BOOLEAN, + forward_collision_warning BOOLEAN, + lane_departure_avoidance BOOLEAN, + speed_limit_warning VARCHAR(20), + pin_to_drive_enabled BOOLEAN, + miles_since_reset DOUBLE PRECISION, + self_driving_miles_since_reset DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_safety_snapshots_vehicle_time ON safety_snapshots (vehicle_id, created_at DESC); + +-- ============================================================ +-- CREATE user_preference_snapshots +-- ============================================================ +CREATE TABLE IF NOT EXISTS user_preference_snapshots ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + setting_24hr_time BOOLEAN, + setting_charge_unit VARCHAR(10), + setting_distance_unit VARCHAR(10), + setting_temperature_unit VARCHAR(10), + setting_tire_pressure_unit VARCHAR(10), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_pref_snapshots_vehicle_time ON user_preference_snapshots (vehicle_id, created_at DESC); + +-- ============================================================ +-- CREATE vehicle_config_snapshots +-- ============================================================ +CREATE TABLE IF NOT EXISTS vehicle_config_snapshots ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + car_type VARCHAR(50), + trim VARCHAR(50), + exterior_color VARCHAR(50), + roof_color VARCHAR(50), + wheel_type VARCHAR(50), + rear_seat_heaters BOOLEAN, + sunroof_installed BOOLEAN, + efficiency_package BOOLEAN, + europe_vehicle BOOLEAN, + right_hand_drive BOOLEAN, + remote_start_enabled BOOLEAN, + charge_port VARCHAR(20), + offroad_lightbar_present BOOLEAN, + version VARCHAR(50), + vehicle_name VARCHAR(100), + software_update_version VARCHAR(50), + software_update_download_pct INTEGER, + software_update_install_pct INTEGER, + software_update_expected_duration INTEGER, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_vehicle_config_snapshots_vehicle_time ON vehicle_config_snapshots (vehicle_id, created_at DESC); + +-- ============================================================ +-- CREATE tire_pressure_snapshots +-- ============================================================ +CREATE TABLE IF NOT EXISTS tire_pressure_snapshots ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + front_left DOUBLE PRECISION, + front_right DOUBLE PRECISION, + rear_left DOUBLE PRECISION, + rear_right DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_tire_pressure_snapshots_vehicle_time ON tire_pressure_snapshots (vehicle_id, created_at DESC); + +-- ============================================================ +-- CREATE charging_telemetry +-- ============================================================ +CREATE TABLE IF NOT EXISTS charging_telemetry ( + id BIGSERIAL PRIMARY KEY, + vehicle_id BIGINT NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE, + battery_level INTEGER, + soc DOUBLE PRECISION, + charge_state VARCHAR(30), + detailed_charge_state VARCHAR(30), + charge_limit_soc INTEGER, + charge_amps DOUBLE PRECISION, + charge_current_request DOUBLE PRECISION, + charge_current_request_max DOUBLE PRECISION, + charge_enable_request BOOLEAN, + charger_voltage DOUBLE PRECISION, + charger_phases INTEGER, + charge_rate_mph DOUBLE PRECISION, + dc_charging_power DOUBLE PRECISION, + dc_charging_energy_in DOUBLE PRECISION, + ac_charging_power DOUBLE PRECISION, + ac_charging_energy_in DOUBLE PRECISION, + energy_remaining DOUBLE PRECISION, + est_battery_range DOUBLE PRECISION, + ideal_battery_range DOUBLE PRECISION, + rated_range DOUBLE PRECISION, + pack_voltage DOUBLE PRECISION, + pack_current DOUBLE PRECISION, + charge_port_door_open BOOLEAN, + charge_port_latch VARCHAR(20), + charge_port_cold_weather_mode BOOLEAN, + charging_cable_type VARCHAR(30), + fast_charger_present BOOLEAN, + fast_charger_type VARCHAR(30), + time_to_full_charge DOUBLE PRECISION, + estimated_hours_to_charge DOUBLE PRECISION, + scheduled_charging_mode VARCHAR(20), + scheduled_charging_pending BOOLEAN, + preconditioning_enabled BOOLEAN, + brick_voltage_max DOUBLE PRECISION, + brick_voltage_min DOUBLE PRECISION, + num_brick_voltage_max INTEGER, + num_brick_voltage_min INTEGER, + module_temp_max DOUBLE PRECISION, + module_temp_min DOUBLE PRECISION, + num_module_temp_max INTEGER, + num_module_temp_min INTEGER, + battery_heater_on BOOLEAN, + not_enough_power_to_heat BOOLEAN, + bms_state VARCHAR(20), + bms_fullcharge_complete BOOLEAN, + dcdc_enable BOOLEAN, + isolation_resistance DOUBLE PRECISION, + lifetime_energy_used DOUBLE PRECISION, + supercharger_session_trip_planner BOOLEAN, + powershare_status VARCHAR(20), + powershare_type VARCHAR(20), + powershare_stop_reason VARCHAR(50), + powershare_hours_left DOUBLE PRECISION, + powershare_power_kw DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_charging_telemetry_vehicle_time ON charging_telemetry (vehicle_id, created_at DESC); From 53e101c2c9925234a275b7c98baa96de1f1de186 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 09:20:50 -0700 Subject: [PATCH 05/40] fix: throttle telemetry DB writes to every 10s per vehicle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With fleet telemetry batching every 100ms, each batch was spawning a goroutine with 12+ sequential DB operations. This saturated the 25-conn pool and caused context deadline exceeded on every write — including vehicle state updates and health checks. Now snapshot writes (motor, climate, security, charging, location, etc.) are throttled to once every 10 seconds per vehicle. This reduces DB write load by ~50-100x while still providing near-real-time data for the UI panels. Vehicle state updates are also gated to prevent the UpdateState call from consuming connections on every batch. SSE/MQTT broadcasting and signal counting remain unthrottled for real-time UI updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/telemetry_handler.go | 34 ++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index 9821fb1e..0fc85e61 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -43,6 +43,10 @@ type TelemetryHandler struct { // Per-vehicle streaming health tracking mu sync.RWMutex streamingState map[string]*VehicleStreamState // keyed by VIN + + // Per-vehicle write throttling to prevent DB overload + lastWriteMu sync.RWMutex + lastWriteAt map[string]time.Time // keyed by VIN } // VehicleStreamState tracks streaming health per vehicle. @@ -94,6 +98,7 @@ func NewTelemetryHandler(db *database.DB, mc *mqtt.Client, hub *EventHub, staleT alertEvaluator: NewTelemetryAlertEvaluator(db, eventBus), staleTimeout: staleTimeout, streamingState: make(map[string]*VehicleStreamState), + lastWriteAt: make(map[string]time.Time), } } @@ -234,18 +239,37 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa } // --- Async writes: state tracking, mileage, tire pressure, vehicle health --- + // Throttle snapshot writes to once every 10 seconds per vehicle to prevent + // DB connection pool exhaustion from high-frequency telemetry batches. if vehicleID > 0 { + const snapshotWriteInterval = 10 * time.Second + h.lastWriteMu.RLock() + lastWrite := h.lastWriteAt[vin] + h.lastWriteMu.RUnlock() + shouldWrite := time.Since(lastWrite) >= snapshotWriteInterval + + if shouldWrite { + h.lastWriteMu.Lock() + h.lastWriteAt[vin] = time.Now() + h.lastWriteMu.Unlock() + } + go func() { bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Update vehicle to online/healthy - if err := h.vehicleRepo.UpdateState(bgCtx, vehicleID, "online", true); err != nil { - log.Warn().Err(err).Int64("vehicle_id", vehicleID).Msg("telemetry: failed to update vehicle state") + // Always update vehicle state (lightweight, single UPDATE) + if shouldWrite { + if err := h.vehicleRepo.UpdateState(bgCtx, vehicleID, "online", true); err != nil { + log.Warn().Err(err).Int64("vehicle_id", vehicleID).Msg("telemetry: failed to update vehicle state") + } + h.trackStateTransition(bgCtx, vehicleID, signals) } - // Track vehicle state transitions (online/driving/charging) - h.trackStateTransition(bgCtx, vehicleID, signals) + // Throttled snapshot writes — only run every 10s per vehicle + if !shouldWrite { + return + } // Update daily mileage from odometer readings h.trackMileage(bgCtx, vehicleID, signals) From 235e13c093f35bf817a65c05595fbcc95da6c18e Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:01:29 -0700 Subject: [PATCH 06/40] fix: relax telemetry tracking gate conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vehicle in standby/charging mode sends signals like ChargeRateMilePerHour, BatteryLevel, Soc, OutsideTemp, Gear, Odometer — but the tracking functions only gated on a narrow set of signals, causing all writes to be skipped during standby. Expanded gate conditions: - trackCharging: add BatteryLevel, Soc, ChargeRateMilePerHour, ChargeAmps - trackClimate: add OutsideTemp - trackMotor: add Gear, Odometer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/telemetry_handler.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index 0fc85e61..a23f1996 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -698,7 +698,9 @@ func (h *TelemetryHandler) trackMotor(ctx context.Context, vehicleID int64, sign _, hasSpeed := signals["VehicleSpeed"] _, hasPedal := signals["PedalPosition"] _, hasAccel := signals["LateralAcceleration"] - if !hasTorque && !hasSpeed && !hasPedal && !hasAccel { + _, hasGear := signals["Gear"] + _, hasOdometer := signals["Odometer"] + if !hasTorque && !hasSpeed && !hasPedal && !hasAccel && !hasGear && !hasOdometer { return } @@ -887,9 +889,10 @@ func (h *TelemetryHandler) trackMotor(ctx context.Context, vehicleID int64, sign // trackClimate stores climate/HVAC snapshots when relevant signals arrive. func (h *TelemetryHandler) trackClimate(ctx context.Context, vehicleID int64, signals map[string]interface{}) { _, hasInside := signals["InsideTemp"] + _, hasOutside := signals["OutsideTemp"] _, hasHvac := signals["HvacPower"] _, hasFan := signals["HvacFanSpeed"] - if !hasInside && !hasHvac && !hasFan { + if !hasInside && !hasOutside && !hasHvac && !hasFan { return } @@ -1145,7 +1148,11 @@ func (h *TelemetryHandler) trackCharging(ctx context.Context, vehicleID int64, s _, hasDetailedCharge := signals["DetailedChargeState"] _, hasDCPower := signals["DCChargingPower"] _, hasACPower := signals["ACChargingPower"] - if !hasChargeState && !hasDetailedCharge && !hasDCPower && !hasACPower { + _, hasBatteryLevel := signals["BatteryLevel"] + _, hasSoc := signals["Soc"] + _, hasChargeRate := signals["ChargeRateMilePerHour"] + _, hasChargeAmps := signals["ChargeAmps"] + if !hasChargeState && !hasDetailedCharge && !hasDCPower && !hasACPower && !hasBatteryLevel && !hasSoc && !hasChargeRate && !hasChargeAmps { return } From 101c7a8f6653ad4f30e75fa441e06beb7ee1dae5 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:06:43 -0700 Subject: [PATCH 07/40] fix: detect vehicle state from telemetry signals instead of hardcoding 'online' - Extract detectVehicleState() helper used by both UpdateState and trackStateTransition - Add ChargeRateMilePerHour and ChargeAmps as charging state indicators - Vehicle now correctly shows 'charging' when charge rate > 0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/telemetry_handler.go | 37 ++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index a23f1996..e851fef7 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -260,7 +260,8 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa // Always update vehicle state (lightweight, single UPDATE) if shouldWrite { - if err := h.vehicleRepo.UpdateState(bgCtx, vehicleID, "online", true); err != nil { + detectedState := h.detectVehicleState(signals) + if err := h.vehicleRepo.UpdateState(bgCtx, vehicleID, detectedState, true); err != nil { log.Warn().Err(err).Int64("vehicle_id", vehicleID).Msg("telemetry: failed to update vehicle state") } h.trackStateTransition(bgCtx, vehicleID, signals) @@ -307,30 +308,46 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa } } -// trackStateTransition detects the vehicle state from signals and records transitions. -func (h *TelemetryHandler) trackStateTransition(ctx context.Context, vehicleID int64, signals map[string]interface{}) { - // Determine current state from signals - newState := "online" +// detectVehicleState determines the vehicle state from telemetry signals. +func (h *TelemetryHandler) detectVehicleState(signals map[string]interface{}) string { + // Check for driving state if speed, ok := signals["VehicleSpeed"]; ok && toFloat(speed) > 0 { - newState = "driving" - } else if gear, ok := signals["Gear"]; ok { + return "driving" + } + if gear, ok := signals["Gear"]; ok { gs := fmt.Sprintf("%v", gear) if gs == "D" || gs == "R" { - newState = "driving" + return "driving" } } + + // Check for charging state if cs, ok := signals["ChargeState"]; ok { csStr := fmt.Sprintf("%v", cs) if csStr == "Charging" || csStr == "Starting" { - newState = "charging" + return "charging" } } if dcs, ok := signals["DetailedChargeState"]; ok { dcsStr := fmt.Sprintf("%v", dcs) if dcsStr == "Charging" || dcsStr == "Starting" { - newState = "charging" + return "charging" } } + // Infer charging from rate/amps when ChargeState isn't sent + if rate, ok := signals["ChargeRateMilePerHour"]; ok && toFloat(rate) > 0 { + return "charging" + } + if amps, ok := signals["ChargeAmps"]; ok && toFloat(amps) > 0 { + return "charging" + } + + return "online" +} + +// trackStateTransition detects the vehicle state from signals and records transitions. +func (h *TelemetryHandler) trackStateTransition(ctx context.Context, vehicleID int64, signals map[string]interface{}) { + newState := h.detectVehicleState(signals) currentState, _ := h.stateRepo.GetCurrentState(ctx, vehicleID) if currentState == newState { From fb75ef797a4d67a70d46e34e661f6c84163be310 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:28:01 -0700 Subject: [PATCH 08/40] fix: align alert read field name between backend and frontend Backend returns 'is_read' (JSON) but frontend expected 'read', causing alerts to always appear unread. Updated all frontend references to use 'is_read'. Also fixed chatbot SQL query using wrong column name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/chatbot_handler.go | 2 +- web/src/api.ts | 2 +- web/src/components/Layout.tsx | 2 +- web/src/pages/Alerts.tsx | 18 +++++++++--------- web/src/pages/Dashboard.tsx | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/api/chatbot_handler.go b/internal/api/chatbot_handler.go index 552d81c8..dd29ad00 100644 --- a/internal/api/chatbot_handler.go +++ b/internal/api/chatbot_handler.go @@ -342,7 +342,7 @@ func (h *ChatbotHandler) queryAlerts(ctx context.Context) string { if err := h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM alerts`).Scan(&total); err != nil { return "I couldn't retrieve alert data right now." } - if err := h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM alerts WHERE read = false`).Scan(&unread); err != nil { + if err := h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM alerts WHERE is_read = false`).Scan(&unread); err != nil { return "I couldn't retrieve alert data right now." } return fmt.Sprintf("You have **%d alert%s** total, **%d unread**.", total, plural(total), unread) diff --git a/web/src/api.ts b/web/src/api.ts index 35bf5a7c..69c2667b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -184,7 +184,7 @@ export interface Alert { severity: 'info' | 'warning' | 'critical' title: string message: string - read: boolean + is_read: boolean created_at: string } diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index aa9ef7f8..519b6ae0 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -194,7 +194,7 @@ export default function Layout() { enabled: !!primaryVehicle, refetchInterval: 60_000, }) - const unreadAlerts = alerts?.filter(a => !a.read).length ?? 0 + const unreadAlerts = alerts?.filter(a => !a.is_read).length ?? 0 const onlineVehicles = vehicles?.filter(v => v.state === 'online').length ?? 0 const isConnected = !!primaryState?.live diff --git a/web/src/pages/Alerts.tsx b/web/src/pages/Alerts.tsx index 24f2e7e7..c99238c3 100644 --- a/web/src/pages/Alerts.tsx +++ b/web/src/pages/Alerts.tsx @@ -309,7 +309,7 @@ function AlertCard({ alert, onMarkRead }: { alert: Alert; onMarkRead: () => void return (
@@ -319,12 +319,12 @@ function AlertCard({ alert, onMarkRead }: { alert: Alert; onMarkRead: () => void
-

+

{alert.title}

{alert.message}

- {!alert.read && ( + {!alert.is_read && ( )}
@@ -334,8 +334,8 @@ function AlertCard({ alert, onMarkRead }: { alert: Alert; onMarkRead: () => void {alert.severity} {alert.type.replace(/_/g, ' ')} - {!alert.read && ( - )} @@ -1150,16 +1150,16 @@ export default function Alerts() { // ─ Computed ─ const filteredAlerts = useMemo(() => alerts?.filter(a => { - if (filter === 'unread') return !a.read + if (filter === 'unread') return !a.is_read if (filter === 'critical') return a.severity === 'critical' return true }) ?? [], [alerts, filter]) - const unreadCount = useMemo(() => alerts?.filter(a => !a.read).length ?? 0, [alerts]) - const criticalCount = useMemo(() => alerts?.filter(a => a.severity === 'critical' && !a.read).length ?? 0, [alerts]) + const unreadCount = useMemo(() => alerts?.filter(a => !a.is_read).length ?? 0, [alerts]) + const criticalCount = useMemo(() => alerts?.filter(a => a.severity === 'critical' && !a.is_read).length ?? 0, [alerts]) const infoCount = useMemo(() => alerts?.filter(a => a.severity === 'info').length ?? 0, [alerts]) const warningCount = useMemo(() => alerts?.filter(a => a.severity === 'warning').length ?? 0, [alerts]) - const readCount = useMemo(() => alerts?.filter(a => a.read).length ?? 0, [alerts]) + const readCount = useMemo(() => alerts?.filter(a => a.is_read).length ?? 0, [alerts]) const totalCount = alerts?.length ?? 0 const alertsByType = useMemo(() => { diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index fa8f1844..eb72b83a 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -189,7 +189,7 @@ export default function Dashboard() { const totalCount = vehicles?.length ?? 0 const totalDistance = analytics?.total_distance_km ?? 0 const totalEnergy = analytics?.total_energy_kwh ?? 0 - const unreadAlerts = alerts?.filter(a => !a.read).length ?? 0 + const unreadAlerts = alerts?.filter(a => !a.is_read).length ?? 0 // Last-updated timestamp state const [, setTick] = useState(0) From baa16696780f0abbd06483243468a38bd2af8b03 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:33:13 -0700 Subject: [PATCH 09/40] fix: return 200 null instead of 404 for empty latest endpoints 404 causes React Query to retry infinitely, resulting in the Security page flickering/skeleton looping. Changed all 5 latest handlers (security, climate, motor, media, location) to return 200 with null body when no data exists yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/climate_handler.go | 2 +- internal/api/location_snapshot_handler.go | 2 +- internal/api/media_handler.go | 2 +- internal/api/motor_handler.go | 2 +- internal/api/security_handler.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/api/climate_handler.go b/internal/api/climate_handler.go index 8938abae..59b30d74 100644 --- a/internal/api/climate_handler.go +++ b/internal/api/climate_handler.go @@ -49,7 +49,7 @@ func (h *ClimateHandler) Latest(w http.ResponseWriter, r *http.Request) { return } if snap == nil { - writeError(w, http.StatusNotFound, "no climate data available") + writeJSON(w, http.StatusOK, nil) return } writeJSON(w, http.StatusOK, snap) diff --git a/internal/api/location_snapshot_handler.go b/internal/api/location_snapshot_handler.go index b028dd89..d549a705 100644 --- a/internal/api/location_snapshot_handler.go +++ b/internal/api/location_snapshot_handler.go @@ -49,7 +49,7 @@ func (h *LocationSnapshotHandler) Latest(w http.ResponseWriter, r *http.Request) return } if snap == nil { - writeError(w, http.StatusNotFound, "no location data available") + writeJSON(w, http.StatusOK, nil) return } writeJSON(w, http.StatusOK, snap) diff --git a/internal/api/media_handler.go b/internal/api/media_handler.go index cb46cc45..e76c75f7 100644 --- a/internal/api/media_handler.go +++ b/internal/api/media_handler.go @@ -49,7 +49,7 @@ func (h *MediaHandler) Latest(w http.ResponseWriter, r *http.Request) { return } if snap == nil { - writeError(w, http.StatusNotFound, "no media data available") + writeJSON(w, http.StatusOK, nil) return } writeJSON(w, http.StatusOK, snap) diff --git a/internal/api/motor_handler.go b/internal/api/motor_handler.go index 2f3407d6..05a598ba 100644 --- a/internal/api/motor_handler.go +++ b/internal/api/motor_handler.go @@ -49,7 +49,7 @@ func (h *MotorHandler) Latest(w http.ResponseWriter, r *http.Request) { return } if snap == nil { - writeError(w, http.StatusNotFound, "no motor data available") + writeJSON(w, http.StatusOK, nil) return } writeJSON(w, http.StatusOK, snap) diff --git a/internal/api/security_handler.go b/internal/api/security_handler.go index 313bc78f..fa0c3d5d 100644 --- a/internal/api/security_handler.go +++ b/internal/api/security_handler.go @@ -49,7 +49,7 @@ func (h *SecurityHandler) Latest(w http.ResponseWriter, r *http.Request) { return } if evt == nil { - writeError(w, http.StatusNotFound, "no security data available") + writeJSON(w, http.StatusOK, nil) return } writeJSON(w, http.StatusOK, evt) From cd2bc9891ca986fa7e6283089bcb4acaa6906b0f Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:43:37 -0700 Subject: [PATCH 10/40] fix: phase 1 audit - frontend crashes and safety - Fix division-by-zero in Charging, Drives, BatteryHealth, BatteryCells - Fix React hooks-in-loop violation in Vehicles.tsx (FleetSummary, BatteryComparison) - Fix InsightsEngine null safety on trend data access - Add retry:1 to Layout sidebar queries to prevent backend hammering - Invalidate vehicle state caches on vehicle delete - Add nil check to tire_pressure_handler Latest() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/tire_pressure_handler.go | 4 ++ web/src/components/InsightsEngine.tsx | 4 +- web/src/components/Layout.tsx | 4 +- web/src/pages/BatteryCells.tsx | 2 +- web/src/pages/BatteryHealth.tsx | 4 +- web/src/pages/Charging.tsx | 4 +- web/src/pages/Drives.tsx | 2 +- web/src/pages/Vehicles.tsx | 59 ++++++++++++++++++--------- 8 files changed, 54 insertions(+), 29 deletions(-) diff --git a/internal/api/tire_pressure_handler.go b/internal/api/tire_pressure_handler.go index 102e9aab..49bde1bf 100644 --- a/internal/api/tire_pressure_handler.go +++ b/internal/api/tire_pressure_handler.go @@ -48,5 +48,9 @@ func (h *TirePressureHandler) Latest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to get tire pressure") return } + if snap == nil { + writeJSON(w, http.StatusOK, nil) + return + } writeJSON(w, http.StatusOK, snap) } diff --git a/web/src/components/InsightsEngine.tsx b/web/src/components/InsightsEngine.tsx index 41d7896e..f0b56143 100644 --- a/web/src/components/InsightsEngine.tsx +++ b/web/src/components/InsightsEngine.tsx @@ -146,8 +146,8 @@ function analyzeBatteryHealth(report: BatteryReport): Insight | null { let yearlyRate = degradation if (trend.length >= 2) { - const first = trend[0].capacity_pct - const last = trend[trend.length - 1].capacity_pct + const first = trend[0]?.capacity_pct ?? 0 + const last = trend[trend.length - 1]?.capacity_pct ?? 0 const months = trend.length yearlyRate = months > 0 ? ((first - last) / months) * 12 : degradation } diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 519b6ae0..10e2947a 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -185,8 +185,8 @@ export default function Layout() { const { data: updateCheck } = useQuery({ queryKey: ['update-check'], queryFn: checkForUpdates, staleTime: 3600_000, refetchInterval: 3600_000 }) // Live data for sidebar - const { data: alerts } = useQuery({ queryKey: ['alerts-sidebar'], queryFn: () => getAlerts(50), refetchInterval: 30_000 }) - const { data: vehicles } = useQuery({ queryKey: ['vehicles-sidebar'], queryFn: getVehicles, refetchInterval: 60_000 }) + const { data: alerts } = useQuery({ queryKey: ['alerts-sidebar'], queryFn: () => getAlerts(50), refetchInterval: 30_000, retry: 1 }) + const { data: vehicles } = useQuery({ queryKey: ['vehicles-sidebar'], queryFn: getVehicles, refetchInterval: 60_000, retry: 1 }) const primaryVehicle = vehicles?.[0] const { data: primaryState } = useQuery({ queryKey: ['primary-state-sidebar', primaryVehicle?.id], diff --git a/web/src/pages/BatteryCells.tsx b/web/src/pages/BatteryCells.tsx index c060eb84..185bf05d 100644 --- a/web/src/pages/BatteryCells.tsx +++ b/web/src/pages/BatteryCells.tsx @@ -436,7 +436,7 @@ export default function BatteryCells() { // Charging habit tip based on session data if (sessions && sessions.length > 0) { const dcCount = sessions.filter(s => s.fast_charger_type).length - const dcPct = (dcCount / sessions.length) * 100 + const dcPct = sessions.length > 0 ? (dcCount / sessions.length) * 100 : 0 if (dcPct > 50) { tips.push({ icon: , diff --git a/web/src/pages/BatteryHealth.tsx b/web/src/pages/BatteryHealth.tsx index 5e23dae1..b8d9eb82 100644 --- a/web/src/pages/BatteryHealth.tsx +++ b/web/src/pages/BatteryHealth.tsx @@ -63,7 +63,7 @@ export default function BatteryHealth() { if (trendData.length < 2) return [] const first = trendData[0].capacity_pct const last = trendData[trendData.length - 1].capacity_pct - const ratePerMonth = (first - last) / trendData.length + const ratePerMonth = trendData.length > 1 ? (first - last) / (trendData.length - 1) : 0 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] const now = new Date() return Array.from({ length: 24 }, (_, i) => { @@ -81,7 +81,7 @@ export default function BatteryHealth() { if (!sessions || sessions.length === 0) return null const startLevels = sessions.map(s => s.start_battery_level) const endLevels = sessions.filter(s => s.end_battery_level).map(s => s.end_battery_level!) - const avgStart = startLevels.reduce((a, b) => a + b, 0) / startLevels.length + const avgStart = startLevels.length > 0 ? startLevels.reduce((a, b) => a + b, 0) / startLevels.length : 0 const avgEnd = endLevels.length > 0 ? endLevels.reduce((a, b) => a + b, 0) / endLevels.length : 80 const chargesAbove90 = endLevels.filter(l => l > 90).length const chargesBelow10 = startLevels.filter(l => l < 10).length diff --git a/web/src/pages/Charging.tsx b/web/src/pages/Charging.tsx index b1fc934e..177d34b4 100644 --- a/web/src/pages/Charging.tsx +++ b/web/src/pages/Charging.tsx @@ -286,14 +286,14 @@ export default function Charging() { // Enhanced statistics const enhancedStats = useMemo(() => { if (!sessions || sessions.length === 0 || !stats) return null - const avgDuration = stats.totalDuration / stats.count + const avgDuration = stats.count > 0 ? stats.totalDuration / stats.count : 0 const chargerTypes = sessions.reduce>((acc, s) => { const t = s.fast_charger_type || 'AC/Home' acc[t] = (acc[t] || 0) + 1 return acc }, {}) const mostCommonType = Object.entries(chargerTypes).sort((a, b) => b[1] - a[1])[0] - const avgCostPerSession = stats.totalCost / stats.count + const avgCostPerSession = stats.count > 0 ? stats.totalCost / stats.count : 0 return { avgDuration, mostCommonType, avgCostPerSession } }, [sessions, stats]) diff --git a/web/src/pages/Drives.tsx b/web/src/pages/Drives.tsx index 51588931..e7b826aa 100644 --- a/web/src/pages/Drives.tsx +++ b/web/src/pages/Drives.tsx @@ -170,7 +170,7 @@ export default function Drives() { return drives .filter(d => d.speed_max && d.duration_min > 0) .map(d => { - const avgSpd = d.distance / (d.duration_min / 60) + const avgSpd = d.duration_min > 0 ? d.distance / (d.duration_min / 60) : 0 const eff = getEfficiency(d) return eff ? { speed: Math.round(avgSpd), efficiency: Math.round(eff) } : null }) diff --git a/web/src/pages/Vehicles.tsx b/web/src/pages/Vehicles.tsx index 4eb92dd3..4a0a6028 100644 --- a/web/src/pages/Vehicles.tsx +++ b/web/src/pages/Vehicles.tsx @@ -113,18 +113,27 @@ function VehicleCard({ vehicle, onDelete }: { vehicle: Vehicle; onDelete: (v: Ve } function FleetSummary({ vehicles }: { vehicles: Vehicle[] }) { - // Gather all states - const stateQueries = vehicles.map(v => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const { data } = useQuery({ - queryKey: ['vehicle-state', v.id], - queryFn: () => getVehicleState(v.id), - refetchInterval: 30_000, - }) - return data?.state + // Batch-fetch all vehicle states in a single query + const { data: allStates } = useQuery({ + queryKey: ['fleet-vehicle-states', vehicles.map(v => v.id).sort()], + queryFn: async () => { + const entries = await Promise.all( + vehicles.map(async v => { + try { + const data = await getVehicleState(v.id) + return data?.state ?? null + } catch { + return null + } + }) + ) + return entries + }, + enabled: vehicles.length > 0, + refetchInterval: 30_000, }) - const states = stateQueries.filter(Boolean) + const states = (allStates ?? []).filter(Boolean) const avgBattery = states.length > 0 ? states.reduce((s, st) => s + (st?.battery_level ?? 0), 0) / states.length : 0 const totalRange = states.reduce((s, st) => s + (st?.rated_range ?? 0), 0) const chargingCount = states.filter(st => st?.is_charging).length @@ -160,17 +169,26 @@ function FleetSummary({ vehicles }: { vehicles: Vehicle[] }) { // Battery comparison bar chart function BatteryComparison({ vehicles }: { vehicles: Vehicle[] }) { - const stateQueries = vehicles.map(v => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const { data } = useQuery({ - queryKey: ['vehicle-state', v.id], - queryFn: () => getVehicleState(v.id), - refetchInterval: 30_000, - }) - return { vehicle: v, state: data?.state } + const { data: allStates } = useQuery({ + queryKey: ['fleet-battery-states', vehicles.map(v => v.id).sort()], + queryFn: async () => { + const entries = await Promise.all( + vehicles.map(async v => { + try { + const data = await getVehicleState(v.id) + return { vehicle: v, state: data?.state ?? null } + } catch { + return { vehicle: v, state: null } + } + }) + ) + return entries + }, + enabled: vehicles.length > 0, + refetchInterval: 30_000, }) - const bars = stateQueries.filter(q => q.state) + const bars = (allStates ?? []).filter(q => q.state) if (bars.length === 0) return null @@ -220,6 +238,9 @@ export default function Vehicles() { mutationFn: deleteVehicle, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vehicles'] }) + queryClient.invalidateQueries({ queryKey: ['vehicle-state'] }) + queryClient.invalidateQueries({ queryKey: ['fleet-vehicle-states'] }) + queryClient.invalidateQueries({ queryKey: ['fleet-battery-states'] }) setDeleteTarget(null) }, }) From def01cf29fcd69c62a52f3fc142f8c79f6fd740b Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:49:28 -0700 Subject: [PATCH 11/40] fix: phase 2-3 audit - backend, telemetry, router fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - analytics_handler: check DB errors instead of ignoring, reduce limit 10K→2K - alert_handler: check GetByID error after UpdateRule - search_handler: remove duplicate rows.Close() (defer handles it) - tire_pressure_handler: nil check on Latest (prev commit) Telemetry: - Fix TOCTTOU race in write throttle - use single Lock for check+set - Log alert rule DB query failures instead of silent swallow Router: - Wire missing /api-keys routes (List, Create, Delete, Revoke) Database: - geofence_repo: wrap Update in transaction, check errors instead of _, _ - vehicle_state_repo: distinguish ErrNoRows from real DB errors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/alert_handler.go | 7 ++++++- internal/api/analytics_handler.go | 18 +++++++++++++++--- internal/api/router.go | 11 +++++++++++ internal/api/search_handler.go | 3 --- internal/api/telemetry_alerts.go | 1 + internal/api/telemetry_handler.go | 7 ++----- internal/database/geofence_repo.go | 24 ++++++++++++++++++------ internal/database/vehicle_state_repo.go | 6 +++++- 8 files changed, 58 insertions(+), 19 deletions(-) diff --git a/internal/api/alert_handler.go b/internal/api/alert_handler.go index a073b291..7ca3da62 100644 --- a/internal/api/alert_handler.go +++ b/internal/api/alert_handler.go @@ -85,7 +85,12 @@ func (h *AlertHandler) UpdateRule(w http.ResponseWriter, r *http.Request) { return } - rule, _ := h.alertRuleRepo.GetByID(r.Context(), id) + rule, err := h.alertRuleRepo.GetByID(r.Context(), id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("failed to fetch updated alert rule") + writeError(w, http.StatusInternalServerError, "rule updated but failed to retrieve") + return + } writeJSON(w, http.StatusOK, rule) } diff --git a/internal/api/analytics_handler.go b/internal/api/analytics_handler.go index 08966cb6..5cfc1d24 100644 --- a/internal/api/analytics_handler.go +++ b/internal/api/analytics_handler.go @@ -117,9 +117,21 @@ func (h *AnalyticsHandler) Fleet(w http.ResponseWriter, r *http.Request) { var batteryTrend []batteryPoint for _, v := range vehicles { - drives, _ := h.driveRepo.GetByVehicle(r.Context(), v.ID, 10000, 0, time.Time{}, time.Time{}) - sessions, _ := h.chargingRepo.GetByVehicle(r.Context(), v.ID, 10000, 0, time.Time{}, time.Time{}) - batSnaps, _ := h.batteryRepo.GetByVehicle(r.Context(), v.ID, 365) + drives, err := h.driveRepo.GetByVehicle(r.Context(), v.ID, 2000, 0, cutoff, time.Time{}) + if err != nil { + log.Error().Err(err).Int64("vehicleID", v.ID).Msg("analytics: failed to get drives") + drives = nil + } + sessions, err := h.chargingRepo.GetByVehicle(r.Context(), v.ID, 2000, 0, cutoff, time.Time{}) + if err != nil { + log.Error().Err(err).Int64("vehicleID", v.ID).Msg("analytics: failed to get charging sessions") + sessions = nil + } + batSnaps, err := h.batteryRepo.GetByVehicle(r.Context(), v.ID, 365) + if err != nil { + log.Error().Err(err).Int64("vehicleID", v.ID).Msg("analytics: failed to get battery snapshots") + batSnaps = nil + } var dist float64 var driveCount int diff --git a/internal/api/router.go b/internal/api/router.go index 6b39210b..85f6a9b9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -111,6 +111,7 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie backupHandler := NewBackupHandler(db) auditHandler := NewAuditHandler(db) apiCallLogHandler := NewAPICallLogHandler(db) + apiKeyHandler := NewAPIKeyHandler(db) telemetryHandler := opt.TelemetryHandler if telemetryHandler == nil { telemetryHandler = NewTelemetryHandler(db, mqttClient, eventHub, 5*time.Minute) @@ -345,6 +346,16 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Get("/stats", apiCallLogHandler.Stats) }) + // API Keys + r.Route("/api-keys", func(r chi.Router) { + r.Get("/", apiKeyHandler.List) + r.Post("/", apiKeyHandler.Create) + r.Route("/{id}", func(r chi.Router) { + r.Delete("/", apiKeyHandler.Delete) + r.Post("/revoke", apiKeyHandler.Revoke) + }) + }) + // Fleet Telemetry ingestion r.Route("/telemetry", func(r chi.Router) { r.Post("/", telemetryHandler.TelemetryIngest) diff --git a/internal/api/search_handler.go b/internal/api/search_handler.go index 38ee9e9c..ad94ec3c 100644 --- a/internal/api/search_handler.go +++ b/internal/api/search_handler.go @@ -53,7 +53,6 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { "model": model, }) } - rows.Close() } // Search drives by start/end address @@ -84,7 +83,6 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { "address": addressName, }) } - rows2.Close() } // Search visited locations @@ -112,7 +110,6 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) { "visit_count": visitCount, }) } - rows3.Close() } if results == nil { diff --git a/internal/api/telemetry_alerts.go b/internal/api/telemetry_alerts.go index b4ee6cf3..6db64d24 100644 --- a/internal/api/telemetry_alerts.go +++ b/internal/api/telemetry_alerts.go @@ -30,6 +30,7 @@ func NewTelemetryAlertEvaluator(db *database.DB, eventBus *events.Bus) *Telemetr func (e *TelemetryAlertEvaluator) Evaluate(ctx context.Context, vehicleID int64, vin string, signals map[string]interface{}) { rules, err := e.alertRuleRepo.GetAll(ctx) if err != nil { + log.Warn().Err(err).Msg("telemetry: failed to load alert rules, skipping evaluation") return } diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index e851fef7..5e864bf8 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -243,16 +243,13 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa // DB connection pool exhaustion from high-frequency telemetry batches. if vehicleID > 0 { const snapshotWriteInterval = 10 * time.Second - h.lastWriteMu.RLock() + h.lastWriteMu.Lock() lastWrite := h.lastWriteAt[vin] - h.lastWriteMu.RUnlock() shouldWrite := time.Since(lastWrite) >= snapshotWriteInterval - if shouldWrite { - h.lastWriteMu.Lock() h.lastWriteAt[vin] = time.Now() - h.lastWriteMu.Unlock() } + h.lastWriteMu.Unlock() go func() { bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) diff --git a/internal/database/geofence_repo.go b/internal/database/geofence_repo.go index 9a76226b..0d6cd933 100644 --- a/internal/database/geofence_repo.go +++ b/internal/database/geofence_repo.go @@ -65,19 +65,31 @@ func (r *GeofenceRepo) GetByID(ctx context.Context, id int64) (*models.Geofence, func (r *GeofenceRepo) Update(ctx context.Context, g *models.Geofence) error { now := time.Now().UTC() + tx, err := r.db.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + // Close the old rate period if cost changed - _, _ = r.db.Pool.Exec(ctx, + if _, err := tx.Exec(ctx, `UPDATE geofence_electricity_rates SET effective_to = $2 WHERE geofence_id = $1 AND effective_to IS NULL`, - g.ID, now) + g.ID, now); err != nil { + return err + } // Insert new rate period if cost is set if g.CostPerKwh != nil { - _, _ = r.db.Pool.Exec(ctx, + if _, err := tx.Exec(ctx, `INSERT INTO geofence_electricity_rates (geofence_id, cost_per_kwh, effective_from) VALUES ($1, $2, $3)`, - g.ID, *g.CostPerKwh, now) + g.ID, *g.CostPerKwh, now); err != nil { + return err + } } query := `UPDATE geofences SET name=$2, latitude=$3, longitude=$4, radius=$5, cost_per_kwh=$6, updated_at=$7 WHERE id=$1` - _, err := r.db.Pool.Exec(ctx, query, g.ID, g.Name, g.Latitude, g.Longitude, g.Radius, g.CostPerKwh, now) - return err + if _, err := tx.Exec(ctx, query, g.ID, g.Name, g.Latitude, g.Longitude, g.Radius, g.CostPerKwh, now); err != nil { + return err + } + return tx.Commit(ctx) } func (r *GeofenceRepo) Delete(ctx context.Context, id int64) error { diff --git a/internal/database/vehicle_state_repo.go b/internal/database/vehicle_state_repo.go index 1c4640b9..4eed8f1c 100644 --- a/internal/database/vehicle_state_repo.go +++ b/internal/database/vehicle_state_repo.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/jackc/pgx/v5" "github.com/ev-dev-labs/teslasync/internal/models" ) @@ -114,7 +115,10 @@ func (r *VehicleStateRepo) GetCurrentState(ctx context.Context, vehicleID int64) var state string err := r.db.Pool.QueryRow(ctx, query, vehicleID).Scan(&state) if err != nil { - return "", nil // no current state is fine + if err == pgx.ErrNoRows { + return "", nil // no current state is fine + } + return "", err // real DB error } return state, nil } From 9bba5abf59ad6727f5fb7cfd46ce5828b8a3ebb3 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:52:35 -0700 Subject: [PATCH 12/40] fix: align TS interfaces with Go models - add 72 missing fields - MotorSnapshot: add 34 fields (di_torque_actual, di_axle_speed, di_state, di_stator_temp, di_heatsink_t, di_inverter_t, di_motor_current, di_v_bat for all motor positions, plus hvil, brake_pedal_pos, cruise_set_speed, drive_rail) - ClimateSnapshot: add 21 fields (hvac_ac_enabled, seat heaters (5 positions), seat cooling/vent, steering wheel heat, climate keeper, defrost, wiper heat, rear display hvac) - SecurityEvent: add 17 fields (valet mode, speed limit, seat belts, tonneau, lights, guest mode mobile access, center display, paired phone keys) - AlertRule: remove phantom notify_push/notify_mqtt (not in Go model) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/api.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/web/src/api.ts b/web/src/api.ts index 69c2667b..e9d2c992 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -195,8 +195,6 @@ export interface AlertRule { enabled: boolean threshold: number vehicle_id: number | null - notify_push: boolean - notify_mqtt: boolean created_at: string updated_at: string } @@ -345,6 +343,40 @@ export interface MotorSnapshot { longitudinal_accel?: number vehicle_speed?: number gear?: string + di_torque_actual_f?: number + di_torque_actual_r?: number + di_torque_actual_rel?: number + di_torque_actual_rer?: number + di_axle_speed_f?: number + di_axle_speed_rel?: number + di_axle_speed_rer?: number + di_state_f?: string + di_state_rel?: string + di_state_rer?: string + di_stator_temp_f?: number + di_stator_temp_rel?: number + di_stator_temp_rer?: number + di_heatsink_t_f?: number + di_heatsink_t_r?: number + di_heatsink_t_rel?: number + di_heatsink_t_rer?: number + di_inverter_t_f?: number + di_inverter_t_r?: number + di_inverter_t_rel?: number + di_inverter_t_rer?: number + di_motor_current_f?: number + di_motor_current_r?: number + di_motor_current_rel?: number + di_motor_current_rer?: number + di_v_bat_f?: number + di_v_bat_r?: number + di_v_bat_rel?: number + di_v_bat_rer?: number + di_slave_torque_cmd?: number + hvil?: string + brake_pedal_pos?: number + cruise_set_speed?: number + drive_rail?: boolean created_at: string } @@ -360,6 +392,27 @@ export interface ClimateSnapshot { cabin_overheat_mode?: string defrost_mode?: boolean battery_heater_on?: boolean + hvac_ac_enabled?: boolean + hvac_auto_mode?: string + hvac_fan_status?: number + hvac_steering_wheel_heat_auto?: boolean + hvac_steering_wheel_heat_level?: number + climate_keeper_mode?: string + cabin_overheat_protection_temp_limit?: string + defrost_for_preconditioning?: boolean + seat_heater_left?: number + seat_heater_right?: number + seat_heater_rear_left?: number + seat_heater_rear_center?: number + seat_heater_rear_right?: number + seat_vent_enabled?: boolean + climate_seat_cooling_front_left?: number + climate_seat_cooling_front_right?: number + auto_seat_climate_left?: boolean + auto_seat_climate_right?: boolean + rear_defrost_enabled?: boolean + rear_display_hvac_enabled?: boolean + wiper_heat_enabled?: boolean created_at: string } @@ -375,6 +428,23 @@ export interface SecurityEvent { rp_window?: string homelink_nearby?: boolean guest_mode?: boolean + homelink_device_count?: number + guest_mode_mobile_access_state?: string + driver_seat_occupied?: boolean + center_display?: string + speed_limit_mode?: boolean + valet_mode_enabled?: boolean + service_mode?: boolean + current_limit_mph?: number + paired_phone_key_count?: number + lights_hazards_active?: boolean + lights_high_beams?: boolean + lights_turn_signal?: string + tonneau_position?: string + tonneau_open_percent?: number + tonneau_tent_mode?: string + driver_seat_belt?: boolean + passenger_seat_belt?: boolean created_at: string } From ee3ccd45659804724e5a7c133904dbb63a75a969 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:54:49 -0700 Subject: [PATCH 13/40] fix: telemetry memory leak and mutex cleanup - Add StartCleanup() goroutine that removes stale streaming state entries every 10 minutes (3x past stale timeout threshold) - Cleans both streamingState and lastWriteAt maps to prevent unbounded growth - Simplify lastWriteMu from RWMutex to Mutex (only uses Lock now) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/telemetry_handler.go | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index 5e864bf8..0e52cc16 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -45,7 +45,7 @@ type TelemetryHandler struct { streamingState map[string]*VehicleStreamState // keyed by VIN // Per-vehicle write throttling to prevent DB overload - lastWriteMu sync.RWMutex + lastWriteMu sync.Mutex lastWriteAt map[string]time.Time // keyed by VIN } @@ -102,6 +102,44 @@ func NewTelemetryHandler(db *database.DB, mc *mqtt.Client, hub *EventHub, staleT } } +// StartCleanup runs periodic cleanup of stale streaming state entries. +// Call this once at startup; it stops when ctx is cancelled. +func (h *TelemetryHandler) StartCleanup(ctx context.Context) { + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + h.cleanupStaleEntries() + } + } + }() +} + +func (h *TelemetryHandler) cleanupStaleEntries() { + now := time.Now() + cutoff := 3 * h.staleTimeout // remove entries 3x past stale timeout + + h.mu.Lock() + for vin, state := range h.streamingState { + if now.Sub(state.LastReceived) > cutoff { + delete(h.streamingState, vin) + } + } + h.mu.Unlock() + + h.lastWriteMu.Lock() + for vin, lastWrite := range h.lastWriteAt { + if now.Sub(lastWrite) > cutoff { + delete(h.lastWriteAt, vin) + } + } + h.lastWriteMu.Unlock() +} + type telemetrySignal struct { Name string `json:"name"` Value interface{} `json:"value"` From ee1b9efd9cfa65e537f469a944d9c48681228a4d Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 10:56:19 -0700 Subject: [PATCH 14/40] fix: remaining audit items - TS fields, query keys, dashboard staleTime - Position: add fan_status field - Geofence: add created_at, updated_at fields - NotificationLog: add scheduled_at, latency_ms fields - Dashboard: sort vehicle IDs in query key for cache stability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/api.ts | 7 +++++-- web/src/pages/Dashboard.tsx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/api.ts b/web/src/api.ts index e9d2c992..e9051033 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -56,9 +56,8 @@ export interface Position { outside_temp: number | null is_climate_on: boolean | null created_at: string + fan_status?: number } - -export interface Drive { id: number vehicle_id: number start_date: string @@ -110,6 +109,8 @@ export interface Geofence { longitude: number radius: number cost_per_kwh: number | null + created_at: string + updated_at: string } export interface AppSettings { @@ -274,6 +275,8 @@ export interface NotificationLog { error: string created_at: string sent_at: string | null + scheduled_at?: string + latency_ms?: number } export interface NotificationStats { diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index eb72b83a..ca15b5e2 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -134,7 +134,7 @@ export default function Dashboard() { // Get states for all other vehicles const otherVehicles = vehicles?.slice(1) ?? [] const { data: otherStates } = useQuery({ - queryKey: ['other-vehicle-states', otherVehicles.map(v => v.id)], + queryKey: ['other-vehicle-states', otherVehicles.map(v => v.id).sort()], queryFn: async () => { const entries = await Promise.all( otherVehicles.map(async v => { From 80e15880a543034611ad9be9bfcbd0c84c08605d Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 11:04:29 -0700 Subject: [PATCH 15/40] feat: telemetry-first data source for vehicle state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When fleet telemetry is actively streaming for a vehicle, CurrentState() now builds the VehicleState from DB tables (positions, climate_snapshots, security_events, charging_telemetry) instead of making a Tesla API call. Priority order: 1. Fleet Telemetry (if IsVehicleStreaming) → build from DB snapshots 2. Fleet API (if token valid) → live Tesla API call 3. Cached position (if API suspended/unavailable) Benefits: - ~150x faster response (DB query vs Tesla API roundtrip) - Saves Tesla API quota (no unnecessary calls for streaming vehicles) - Prevents vehicle wake-up from API polling - data_source field in response indicates source for debugging The response now includes 'data_source' field: 'fleet_telemetry', 'fleet_api', or 'cached'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/router.go | 3 + internal/api/vehicle_handler.go | 166 ++++++++++++++++++++++++++++---- 2 files changed, 150 insertions(+), 19 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 85f6a9b9..d4415cc3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -118,6 +118,9 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie } devToolsHandler := NewDevToolsHandler(teslaClient, WithDB(db), WithMQTTClient(mqttClient), WithConfig(cfg)) + // Wire telemetry handler into vehicle handler for streaming-aware state + vehicleHandler.SetTelemetryHandler(telemetryHandler) + // Health check r.Get("/healthz", HealthHandler(db)) r.Get("/readyz", ReadyHandler(db, teslaClient)) diff --git a/internal/api/vehicle_handler.go b/internal/api/vehicle_handler.go index de382894..4b8adc5d 100644 --- a/internal/api/vehicle_handler.go +++ b/internal/api/vehicle_handler.go @@ -11,21 +11,35 @@ import ( // VehicleHandler handles vehicle-related HTTP requests. type VehicleHandler struct { - vehicleRepo *database.VehicleRepo - positionRepo *database.PositionRepo - settingsRepo *database.SettingsRepo - teslaClient *tesla.Client + vehicleRepo *database.VehicleRepo + positionRepo *database.PositionRepo + settingsRepo *database.SettingsRepo + climateRepo *database.ClimateRepo + securityRepo *database.SecurityRepo + chargingTelRepo *database.ChargingTelemetryRepo + stateRepo *database.VehicleStateRepo + teslaClient *tesla.Client + telemetryHandler *TelemetryHandler } func NewVehicleHandler(db *database.DB, tc *tesla.Client) *VehicleHandler { return &VehicleHandler{ - vehicleRepo: database.NewVehicleRepo(db), - positionRepo: database.NewPositionRepo(db), - settingsRepo: database.NewSettingsRepo(db), - teslaClient: tc, + vehicleRepo: database.NewVehicleRepo(db), + positionRepo: database.NewPositionRepo(db), + settingsRepo: database.NewSettingsRepo(db), + climateRepo: database.NewClimateRepo(db), + securityRepo: database.NewSecurityRepo(db), + chargingTelRepo: database.NewChargingTelemetryRepo(db), + stateRepo: database.NewVehicleStateRepo(db), + teslaClient: tc, } } +// SetTelemetryHandler wires the telemetry handler for streaming-aware state resolution. +func (h *VehicleHandler) SetTelemetryHandler(th *TelemetryHandler) { + h.telemetryHandler = th +} + func (h *VehicleHandler) List(w http.ResponseWriter, r *http.Request) { vehicles, err := h.vehicleRepo.GetAll(r.Context()) if err != nil { @@ -146,15 +160,30 @@ func (h *VehicleHandler) CurrentState(w http.ResponseWriter, r *http.Request) { return } - // Return cached data when API is suspended or no valid token + // PRIMARY: If fleet telemetry is streaming for this vehicle, build state from DB + if h.telemetryHandler != nil && h.telemetryHandler.IsVehicleStreaming(vehicle.VIN) { + state := h.buildStateFromDB(r, vehicle) + if state != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "state": state, + "live": true, + "data_source": "fleet_telemetry", + }) + return + } + // If DB state build failed, fall through to API + } + + // FALLBACK: Use Tesla Fleet API suspended, _ := h.settingsRepo.IsAPISuspended(r.Context()) if suspended || !h.teslaClient.HasValidToken() { pos, _ := h.positionRepo.GetLatest(r.Context(), id) writeJSON(w, http.StatusOK, map[string]interface{}{ - "vehicle": vehicle, - "position": pos, - "live": false, - "suspended": suspended, + "vehicle": vehicle, + "position": pos, + "live": false, + "suspended": suspended, + "data_source": "cached", }) return } @@ -163,10 +192,11 @@ func (h *VehicleHandler) CurrentState(w http.ResponseWriter, r *http.Request) { if err != nil { pos, _ := h.positionRepo.GetLatest(r.Context(), id) writeJSON(w, http.StatusOK, map[string]interface{}{ - "vehicle": vehicle, - "position": pos, - "live": false, - "error": err.Error(), + "vehicle": vehicle, + "position": pos, + "live": false, + "error": err.Error(), + "data_source": "cached", }) return } @@ -197,11 +227,109 @@ func (h *VehicleHandler) CurrentState(w http.ResponseWriter, r *http.Request) { state.Power = float64(data.DriveState.Power) writeJSON(w, http.StatusOK, map[string]interface{}{ - "state": state, - "live": true, + "state": state, + "live": true, + "data_source": "fleet_api", }) } +// buildStateFromDB constructs a VehicleState from the latest DB records +// written by fleet telemetry. Returns nil if no data available. +func (h *VehicleHandler) buildStateFromDB(r *http.Request, vehicle *models.Vehicle) *models.VehicleState { + ctx := r.Context() + + pos, err := h.positionRepo.GetLatest(ctx, vehicle.ID) + if err != nil || pos == nil { + return nil + } + + // Determine vehicle state from state history + currentState, _ := h.stateRepo.GetCurrentState(ctx, vehicle.ID) + if currentState == "" { + currentState = "online" + } + + state := &models.VehicleState{ + VehicleID: vehicle.ID, + State: currentState, + Latitude: pos.Latitude, + Longitude: pos.Longitude, + BatteryLevel: pos.BatteryLvl, + Odometer: pos.Odometer, + } + + // Fill from position if available + if pos.Speed != nil { + state.Speed = float64(*pos.Speed) + } + if pos.Power != nil { + state.Power = float64(*pos.Power) + } + if pos.RatedRange != nil { + state.RatedRange = *pos.RatedRange + } + if pos.IdealRange != nil { + state.IdealRange = *pos.IdealRange + } + if pos.InsideTemp != nil { + state.InsideTemp = *pos.InsideTemp + } + if pos.OutsideTemp != nil { + state.OutsideTemp = *pos.OutsideTemp + } + if pos.IsClimate != nil { + state.IsClimateOn = *pos.IsClimate + } + + // Enrich with climate snapshot (more detailed than position) + if climate, err := h.climateRepo.GetLatest(ctx, vehicle.ID); err == nil && climate != nil { + if climate.InsideTemp != nil { + state.InsideTemp = *climate.InsideTemp + } + if climate.OutsideTemp != nil { + state.OutsideTemp = *climate.OutsideTemp + } + state.IsClimateOn = (climate.HvacPower != nil && *climate.HvacPower > 0) + } + + // Enrich with security snapshot + if sec, err := h.securityRepo.GetLatest(ctx, vehicle.ID); err == nil && sec != nil { + if sec.Locked != nil { + state.IsLocked = *sec.Locked + } + if sec.SentryMode != nil { + state.SentryMode = *sec.SentryMode + } + } + + // Enrich with charging telemetry + if currentState == "charging" { + state.IsCharging = true + if ct, err := h.chargingTelRepo.GetLatest(ctx, vehicle.ID); err == nil && ct != nil { + if ct.ChargeRateMph != nil { + state.ChargeRate = *ct.ChargeRateMph + } + power := 0.0 + if ct.DCChargingPower != nil && *ct.DCChargingPower > 0 { + power = *ct.DCChargingPower + } else if ct.ACChargingPower != nil { + power = *ct.ACChargingPower + } else if ct.ChargeAmps != nil && ct.ChargerVoltage != nil { + power = (*ct.ChargeAmps * *ct.ChargerVoltage) / 1000.0 + } + state.ChargerPower = power + if ct.TimeToFullCharge != nil { + state.TimeToFullChg = *ct.TimeToFullCharge + } + } + } + + // Software version not available from telemetry — leave empty + state.SoftwareVersion = "" + + return state +} + func (h *VehicleHandler) Wake(w http.ResponseWriter, r *http.Request) { if suspended, _ := h.settingsRepo.IsAPISuspended(r.Context()); suspended { writeError(w, http.StatusConflict, "Tesla API calls are suspended") From 2a0dba90f23daa5b07a28855890635dddad21934 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 11:09:20 -0700 Subject: [PATCH 16/40] feat: add PostgreSQL unit conversion functions for Grafana MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 18 adds SQL functions that mirror frontend conversion logic: - convert_distance(val, 'mi') — km → miles - convert_speed(val, 'mi') — km/h → mph - convert_temp(val, 'F') — °C → °F - convert_efficiency(val, 'mi') — Wh/km → Wh/mi - convert_pressure(val, 'mi') — bar → psi When target is NULL, reads user preference from settings table. Also adds unit_*() helpers that return the label ('mph', '°F', etc). Grafana usage: SELECT convert_temp(inside_temp) FROM climate_snapshots; SELECT convert_distance(distance) || ' ' || unit_distance() FROM drives; Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../000018_unit_conversion_functions.down.sql | 11 ++ .../000018_unit_conversion_functions.up.sql | 105 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 migrations/000018_unit_conversion_functions.down.sql create mode 100644 migrations/000018_unit_conversion_functions.up.sql diff --git a/migrations/000018_unit_conversion_functions.down.sql b/migrations/000018_unit_conversion_functions.down.sql new file mode 100644 index 00000000..ce5fd891 --- /dev/null +++ b/migrations/000018_unit_conversion_functions.down.sql @@ -0,0 +1,11 @@ +-- Reverse migration 18: Remove unit conversion functions +DROP FUNCTION IF EXISTS convert_distance(DOUBLE PRECISION, TEXT); +DROP FUNCTION IF EXISTS convert_speed(DOUBLE PRECISION, TEXT); +DROP FUNCTION IF EXISTS convert_temp(DOUBLE PRECISION, TEXT); +DROP FUNCTION IF EXISTS convert_efficiency(DOUBLE PRECISION, TEXT); +DROP FUNCTION IF EXISTS convert_pressure(DOUBLE PRECISION, TEXT); +DROP FUNCTION IF EXISTS unit_distance(); +DROP FUNCTION IF EXISTS unit_speed(); +DROP FUNCTION IF EXISTS unit_temp(); +DROP FUNCTION IF EXISTS unit_efficiency(); +DROP FUNCTION IF EXISTS unit_pressure(); diff --git a/migrations/000018_unit_conversion_functions.up.sql b/migrations/000018_unit_conversion_functions.up.sql new file mode 100644 index 00000000..e45ecafb --- /dev/null +++ b/migrations/000018_unit_conversion_functions.up.sql @@ -0,0 +1,105 @@ +-- Migration 18: Unit conversion SQL functions +-- Provides consistent unit conversion for Grafana dashboards and any direct SQL queries. +-- All base data is stored in metric (km, °C, bar, Wh/km). These functions convert +-- to the user's preferred units by reading from the settings table or accepting +-- a target unit parameter. + +-- Distance: km → mi +CREATE OR REPLACE FUNCTION convert_distance(val DOUBLE PRECISION, target TEXT DEFAULT NULL) +RETURNS DOUBLE PRECISION LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF target IS NULL THEN + SELECT unit_of_length INTO target FROM settings WHERE id = 1; + END IF; + IF target = 'mi' THEN + RETURN val * 0.621371; + END IF; + RETURN val; +END; +$$; + +-- Speed: km/h → mph +CREATE OR REPLACE FUNCTION convert_speed(val DOUBLE PRECISION, target TEXT DEFAULT NULL) +RETURNS DOUBLE PRECISION LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF target IS NULL THEN + SELECT unit_of_length INTO target FROM settings WHERE id = 1; + END IF; + IF target = 'mi' THEN + RETURN val * 0.621371; + END IF; + RETURN val; +END; +$$; + +-- Temperature: °C → °F +CREATE OR REPLACE FUNCTION convert_temp(val DOUBLE PRECISION, target TEXT DEFAULT NULL) +RETURNS DOUBLE PRECISION LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF target IS NULL THEN + SELECT unit_of_temp INTO target FROM settings WHERE id = 1; + END IF; + IF target = 'F' THEN + RETURN val * 9.0 / 5.0 + 32.0; + END IF; + RETURN val; +END; +$$; + +-- Efficiency: Wh/km → Wh/mi +CREATE OR REPLACE FUNCTION convert_efficiency(val DOUBLE PRECISION, target TEXT DEFAULT NULL) +RETURNS DOUBLE PRECISION LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF target IS NULL THEN + SELECT unit_of_length INTO target FROM settings WHERE id = 1; + END IF; + IF target = 'mi' THEN + RETURN val * 1.60934; + END IF; + RETURN val; +END; +$$; + +-- Pressure: bar → psi +CREATE OR REPLACE FUNCTION convert_pressure(val DOUBLE PRECISION, target TEXT DEFAULT NULL) +RETURNS DOUBLE PRECISION LANGUAGE plpgsql IMMUTABLE AS $$ +BEGIN + IF target IS NULL THEN + SELECT unit_of_length INTO target FROM settings WHERE id = 1; + END IF; + IF target = 'mi' THEN + RETURN val * 14.5038; + END IF; + RETURN val; +END; +$$; + +-- Helper: returns the user's preferred distance unit label +CREATE OR REPLACE FUNCTION unit_distance() +RETURNS TEXT LANGUAGE SQL STABLE AS $$ + SELECT CASE unit_of_length WHEN 'mi' THEN 'mi' ELSE 'km' END FROM settings WHERE id = 1; +$$; + +-- Helper: returns the user's preferred speed unit label +CREATE OR REPLACE FUNCTION unit_speed() +RETURNS TEXT LANGUAGE SQL STABLE AS $$ + SELECT CASE unit_of_length WHEN 'mi' THEN 'mph' ELSE 'km/h' END FROM settings WHERE id = 1; +$$; + +-- Helper: returns the user's preferred temperature unit label +CREATE OR REPLACE FUNCTION unit_temp() +RETURNS TEXT LANGUAGE SQL STABLE AS $$ + SELECT CASE unit_of_temp WHEN 'F' THEN '°F' ELSE '°C' END FROM settings WHERE id = 1; +$$; + +-- Helper: returns the user's preferred efficiency unit label +CREATE OR REPLACE FUNCTION unit_efficiency() +RETURNS TEXT LANGUAGE SQL STABLE AS $$ + SELECT CASE unit_of_length WHEN 'mi' THEN 'Wh/mi' ELSE 'Wh/km' END FROM settings WHERE id = 1; +$$; + +-- Helper: returns the user's preferred pressure unit label +CREATE OR REPLACE FUNCTION unit_pressure() +RETURNS TEXT LANGUAGE SQL STABLE AS $$ + SELECT CASE unit_of_length WHEN 'mi' THEN 'psi' ELSE 'bar' END FROM settings WHERE id = 1; +$$; From 7597af32b0877da56369c857519f6fc6a96de049 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 11:12:29 -0700 Subject: [PATCH 17/40] fix: restore missing Drive interface declaration in api.ts Adding fan_status field accidentally dropped the 'export interface Drive {' line, causing TS1128 build error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/api.ts b/web/src/api.ts index e9051033..e031c40b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -58,6 +58,8 @@ export interface Position { created_at: string fan_status?: number } + +export interface Drive { id: number vehicle_id: number start_date: string From 20412cf02d1fe97f61fb2defe91fa14567fdd5da Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 11:16:52 -0700 Subject: [PATCH 18/40] fix: resolve TS compile errors from interface changes - Alerts.tsx: remove notify_push/notify_mqtt from createAlertRule call (fields removed from AlertRule interface) - api.ts: make Geofence created_at/updated_at optional (server-generated, not sent on create/update) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/api.ts | 4 ++-- web/src/pages/Alerts.tsx | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/web/src/api.ts b/web/src/api.ts index e031c40b..a58a2da4 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -111,8 +111,8 @@ export interface Geofence { longitude: number radius: number cost_per_kwh: number | null - created_at: string - updated_at: string + created_at?: string + updated_at?: string } export interface AppSettings { diff --git a/web/src/pages/Alerts.tsx b/web/src/pages/Alerts.tsx index c99238c3..1a09666e 100644 --- a/web/src/pages/Alerts.tsx +++ b/web/src/pages/Alerts.tsx @@ -559,8 +559,6 @@ function CreateRuleModal({ open, onClose, vehicles, channels }: { threshold: parseFloat(f.threshold) || 0, vehicle_id: f.vehicle_id === 'all' ? null : parseInt(f.vehicle_id), enabled: f.enabled, - notify_push: f.notify_push, - notify_mqtt: f.notify_mqtt, }) }, onSuccess: () => { From 92fd2fea1c797fd1128e19350e4eb701394542d1 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 11:40:56 -0700 Subject: [PATCH 19/40] fix: use charging telemetry for battery level and charging state detection buildStateFromDB now: - Always checks charging_telemetry (not gated on state=='charging') - Overrides stale position battery_level with fresh charging telemetry value - Detects charging from ChargeRateMph/ChargeAmps/ChargeState in telemetry - Uses charging telemetry range values when available - Sets state to 'charging' and is_charging=true based on telemetry data Fixes: Dashboard showing 59% (stale position) instead of 75% (real from charging_telemetry), showing 'Online' instead of 'Charging' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/vehicle_handler.go | 44 +++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/internal/api/vehicle_handler.go b/internal/api/vehicle_handler.go index 4b8adc5d..640e566a 100644 --- a/internal/api/vehicle_handler.go +++ b/internal/api/vehicle_handler.go @@ -302,17 +302,51 @@ func (h *VehicleHandler) buildStateFromDB(r *http.Request, vehicle *models.Vehic } } - // Enrich with charging telemetry - if currentState == "charging" { - state.IsCharging = true - if ct, err := h.chargingTelRepo.GetLatest(ctx, vehicle.ID); err == nil && ct != nil { + // Enrich with charging telemetry (always check — may have fresher battery data) + if ct, err := h.chargingTelRepo.GetLatest(ctx, vehicle.ID); err == nil && ct != nil { + // Use charging telemetry battery level if fresher than position + if ct.BatteryLevel != nil && (ct.CreatedAt.After(pos.CreatedAt) || state.BatteryLevel == 0) { + state.BatteryLevel = int(*ct.BatteryLevel) + } + if ct.Soc != nil && state.BatteryLevel == 0 { + state.BatteryLevel = int(*ct.Soc) + } + // Override range from charging telemetry if available + if ct.RatedRange != nil { + state.RatedRange = *ct.RatedRange + } + if ct.EstBatteryRange != nil && state.RatedRange == 0 { + state.RatedRange = *ct.EstBatteryRange + } + if ct.IdealBatteryRange != nil { + state.IdealRange = *ct.IdealBatteryRange + } + + // Detect charging from telemetry data + isCharging := false + if ct.ChargeRateMph != nil && *ct.ChargeRateMph > 0 { + isCharging = true + } + if ct.ChargeAmps != nil && *ct.ChargeAmps > 0 { + isCharging = true + } + if ct.ChargeState != nil { + cs := *ct.ChargeState + if cs == "Charging" || cs == "Starting" { + isCharging = true + } + } + + if isCharging { + state.IsCharging = true + state.State = "charging" if ct.ChargeRateMph != nil { state.ChargeRate = *ct.ChargeRateMph } power := 0.0 if ct.DCChargingPower != nil && *ct.DCChargingPower > 0 { power = *ct.DCChargingPower - } else if ct.ACChargingPower != nil { + } else if ct.ACChargingPower != nil && *ct.ACChargingPower > 0 { power = *ct.ACChargingPower } else if ct.ChargeAmps != nil && ct.ChargerVoltage != nil { power = (*ct.ChargeAmps * *ct.ChargerVoltage) / 1000.0 From 07fff8798d326f46a96a26e213af091f36edd405 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 11:45:35 -0700 Subject: [PATCH 20/40] fix: fall back to Fleet API when telemetry data is sparse/stale Vehicle may report IsStreaming=true but only send 1 signal (e.g. ChargeRateMilePerHour while charging). This left position data 17h stale and all panels empty because the worker skipped the vehicle. buildStateFromDB now checks if position is >5min old. If so, falls through to Fleet API to get complete fresh data. Charging telemetry enrichment still applies when fresh. This ensures Dashboard always shows accurate battery%, temps, lock status etc. regardless of how many telemetry signals the vehicle sends. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/vehicle_handler.go | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/internal/api/vehicle_handler.go b/internal/api/vehicle_handler.go index 640e566a..110ec78d 100644 --- a/internal/api/vehicle_handler.go +++ b/internal/api/vehicle_handler.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "time" "github.com/rs/zerolog/log" "github.com/ev-dev-labs/teslasync/internal/database" @@ -160,8 +161,10 @@ func (h *VehicleHandler) CurrentState(w http.ResponseWriter, r *http.Request) { return } - // PRIMARY: If fleet telemetry is streaming for this vehicle, build state from DB - if h.telemetryHandler != nil && h.telemetryHandler.IsVehicleStreaming(vehicle.VIN) { + // PRIMARY: If fleet telemetry is streaming for this vehicle, try to build state from DB + // but fall through to API if core data (position) is stale + telemetryStreaming := h.telemetryHandler != nil && h.telemetryHandler.IsVehicleStreaming(vehicle.VIN) + if telemetryStreaming { state := h.buildStateFromDB(r, vehicle) if state != nil { writeJSON(w, http.StatusOK, map[string]interface{}{ @@ -171,10 +174,10 @@ func (h *VehicleHandler) CurrentState(w http.ResponseWriter, r *http.Request) { }) return } - // If DB state build failed, fall through to API + // If DB state build failed (stale/missing data), fall through to API } - // FALLBACK: Use Tesla Fleet API + // FALLBACK: Use Tesla Fleet API (also used when telemetry data is stale) suspended, _ := h.settingsRepo.IsAPISuspended(r.Context()) if suspended || !h.teslaClient.HasValidToken() { pos, _ := h.positionRepo.GetLatest(r.Context(), id) @@ -234,7 +237,8 @@ func (h *VehicleHandler) CurrentState(w http.ResponseWriter, r *http.Request) { } // buildStateFromDB constructs a VehicleState from the latest DB records -// written by fleet telemetry. Returns nil if no data available. +// written by fleet telemetry. Returns nil if position data is stale (>5 min) +// or missing, signaling the caller to fall back to Fleet API. func (h *VehicleHandler) buildStateFromDB(r *http.Request, vehicle *models.Vehicle) *models.VehicleState { ctx := r.Context() @@ -243,6 +247,16 @@ func (h *VehicleHandler) buildStateFromDB(r *http.Request, vehicle *models.Vehic return nil } + // If position is stale (>5 min), telemetry isn't providing full data — fall back to API + if time.Since(pos.CreatedAt) > 5*time.Minute { + // Check if charging telemetry is fresh even if position isn't + ct, ctErr := h.chargingTelRepo.GetLatest(ctx, vehicle.ID) + if ctErr != nil || ct == nil || time.Since(ct.CreatedAt) > 5*time.Minute { + return nil // all data stale, use API + } + // Charging telemetry is fresh — build state from it + stale position as base + } + // Determine vehicle state from state history currentState, _ := h.stateRepo.GetCurrentState(ctx, vehicle.ID) if currentState == "" { From 10783bb3d3068a5a506518090b63d8e7e6019dc4 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 12:21:20 -0700 Subject: [PATCH 21/40] fix: battery SOC, charging detection, voltage precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State endpoint fixes: - Battery: use Soc when BatteryLevel is nil AND position is stale (was checking BatteryLevel==0 which missed stale 59% from position) - Charging detection: add ChargerVoltage>0, DCChargingPower>0, ACChargingPower>0 as indicators. Also treat fresh charging_telemetry record (<2min) as proof of charging - Lock status: remains false when no security snapshot exists (vehicle doesn't send Locked signal while sleeping/charging) — will be populated by Fleet API fallback when needed EnergyFlow page: - Add .toFixed(1) to charger_voltage and charge_amps display (was showing 112.001999... instead of 112.0) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/vehicle_handler.go | 28 +++++++++++++++++++++------- web/src/pages/EnergyFlow.tsx | 4 ++-- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/internal/api/vehicle_handler.go b/internal/api/vehicle_handler.go index 110ec78d..8da812b9 100644 --- a/internal/api/vehicle_handler.go +++ b/internal/api/vehicle_handler.go @@ -318,12 +318,13 @@ func (h *VehicleHandler) buildStateFromDB(r *http.Request, vehicle *models.Vehic // Enrich with charging telemetry (always check — may have fresher battery data) if ct, err := h.chargingTelRepo.GetLatest(ctx, vehicle.ID); err == nil && ct != nil { - // Use charging telemetry battery level if fresher than position - if ct.BatteryLevel != nil && (ct.CreatedAt.After(pos.CreatedAt) || state.BatteryLevel == 0) { - state.BatteryLevel = int(*ct.BatteryLevel) - } - if ct.Soc != nil && state.BatteryLevel == 0 { - state.BatteryLevel = int(*ct.Soc) + // Use charging telemetry battery level / SOC if fresher than position + if ct.CreatedAt.After(pos.CreatedAt) { + if ct.BatteryLevel != nil { + state.BatteryLevel = int(*ct.BatteryLevel) + } else if ct.Soc != nil { + state.BatteryLevel = int(*ct.Soc) + } } // Override range from charging telemetry if available if ct.RatedRange != nil { @@ -336,7 +337,7 @@ func (h *VehicleHandler) buildStateFromDB(r *http.Request, vehicle *models.Vehic state.IdealRange = *ct.IdealBatteryRange } - // Detect charging from telemetry data + // Detect charging from telemetry data — check multiple indicators isCharging := false if ct.ChargeRateMph != nil && *ct.ChargeRateMph > 0 { isCharging = true @@ -344,12 +345,25 @@ func (h *VehicleHandler) buildStateFromDB(r *http.Request, vehicle *models.Vehic if ct.ChargeAmps != nil && *ct.ChargeAmps > 0 { isCharging = true } + if ct.ChargerVoltage != nil && *ct.ChargerVoltage > 0 { + isCharging = true + } + if ct.DCChargingPower != nil && *ct.DCChargingPower > 0 { + isCharging = true + } + if ct.ACChargingPower != nil && *ct.ACChargingPower > 0 { + isCharging = true + } if ct.ChargeState != nil { cs := *ct.ChargeState if cs == "Charging" || cs == "Starting" { isCharging = true } } + // Fresh charging telemetry record itself implies charging + if time.Since(ct.CreatedAt) < 2*time.Minute { + isCharging = true + } if isCharging { state.IsCharging = true diff --git a/web/src/pages/EnergyFlow.tsx b/web/src/pages/EnergyFlow.tsx index ec07c962..b9edc0ac 100644 --- a/web/src/pages/EnergyFlow.tsx +++ b/web/src/pages/EnergyFlow.tsx @@ -312,11 +312,11 @@ export default function EnergyFlow() {

Charger Voltage

-

{latest?.charger_voltage != null ? `${latest.charger_voltage} V` : '--'}

+

{latest?.charger_voltage != null ? `${latest.charger_voltage.toFixed(1)} V` : '--'}

Charge Amps

-

{latest?.charge_amps != null ? `${latest.charge_amps} A` : '--'}

+

{latest?.charge_amps != null ? `${latest.charge_amps.toFixed(1)} A` : '--'}

{/* Power flow chart */} From f16f88ce3789e5002d202b31f52f3127880f36ed Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 12:25:08 -0700 Subject: [PATCH 22/40] fix: don't discard telemetry signals when GPS location is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractPosition() required GPS Location to create a position record. While charging/parked, vehicle doesn't send Location but DOES send Soc, OutsideTemp, BatteryLevel, ChargerVoltage, etc. All these signals were silently discarded because the GPS gate returned nil. Now creates position records when battery, temp, speed, or odometer signals are present even without GPS. Also expanded trackCharging gate conditions to include ChargerVoltage, EstBatteryRange, IdealBatteryRange, EnergyRemaining, PackVoltage, PackCurrent, ChargeLimitSoc — signals the vehicle actually sends while charging but were being rejected by the gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/telemetry_handler.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index 0e52cc16..9a5f165b 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -570,7 +570,20 @@ func (h *TelemetryHandler) extractPosition(signals map[string]interface{}) *mode } if !hasLocation { - return nil + // No GPS location — still create position if we have battery/temp/speed data + hasBattery := false + if _, ok := signals["BatteryLevel"]; ok { + hasBattery = true + } else if _, ok := signals["Soc"]; ok { + hasBattery = true + } + _, hasTemp := signals["InsideTemp"] + _, hasOutTemp := signals["OutsideTemp"] + _, hasSpeed := signals["VehicleSpeed"] + _, hasOdometer := signals["Odometer"] + if !hasBattery && !hasTemp && !hasOutTemp && !hasSpeed && !hasOdometer { + return nil + } } // Driving signals @@ -1204,7 +1217,17 @@ func (h *TelemetryHandler) trackCharging(ctx context.Context, vehicleID int64, s _, hasSoc := signals["Soc"] _, hasChargeRate := signals["ChargeRateMilePerHour"] _, hasChargeAmps := signals["ChargeAmps"] - if !hasChargeState && !hasDetailedCharge && !hasDCPower && !hasACPower && !hasBatteryLevel && !hasSoc && !hasChargeRate && !hasChargeAmps { + _, hasChargerVoltage := signals["ChargerVoltage"] + _, hasEstRange := signals["EstBatteryRange"] + _, hasIdealRange := signals["IdealBatteryRange"] + _, hasEnergyRemaining := signals["EnergyRemaining"] + _, hasPackVoltage := signals["PackVoltage"] + _, hasPackCurrent := signals["PackCurrent"] + _, hasChargeLimitSoc := signals["ChargeLimitSoc"] + if !hasChargeState && !hasDetailedCharge && !hasDCPower && !hasACPower && + !hasBatteryLevel && !hasSoc && !hasChargeRate && !hasChargeAmps && + !hasChargerVoltage && !hasEstRange && !hasIdealRange && !hasEnergyRemaining && + !hasPackVoltage && !hasPackCurrent && !hasChargeLimitSoc { return } From 8acaf80b5e4856ce0fe9e7f5771daedaea326d40 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 12:33:19 -0700 Subject: [PATCH 23/40] fix: apply user unit preferences to sidebar and Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar vehicle card: use convertDistance + distanceUnit for range (was hardcoded 'km') - Dashboard: fix hardcoded 'mi' on miles_to_arrival — now converts to user's preferred unit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/components/Layout.tsx | 4 +++- web/src/pages/Dashboard.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 10e2947a..8437c5d4 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -54,6 +54,7 @@ import Logo from './Logo' import OnboardingWizard from './OnboardingWizard' import { getAlerts, getVehicles, getVehicleState, getVersionInfo, checkForUpdates } from '../api' import { useRealtimeEvents } from '../hooks/useRealtimeEvents' +import { useSettings } from '../hooks/useSettings' const navI18nKeys: Record = { 'Dashboard': 'nav.dashboard', @@ -179,6 +180,7 @@ export default function Layout() { // SSE connection status const { connected: sseConnected } = useRealtimeEvents() + const { convertDistance, distanceUnit } = useSettings() // Version info const { data: versionInfo } = useQuery({ queryKey: ['version-info'], queryFn: getVersionInfo, staleTime: 60_000, refetchInterval: 60_000 }) @@ -343,7 +345,7 @@ export default function Layout() { style={{ boxShadow: `0 0 6px ${primaryState.state.battery_level > 20 ? 'rgba(16,185,129,0.5)' : 'rgba(239,68,68,0.5)'}` }} />

{primaryVehicle.display_name || 'Vehicle'}

-

{primaryState.state.battery_level}% · {Math.round(primaryState.state.rated_range)} km

+

{primaryState.state.battery_level}% · {Math.round(convertDistance(primaryState.state.rated_range))} {distanceUnit}

diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index ca15b5e2..7ececaef 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -916,7 +916,7 @@ export default function Dashboard() {
Distance - {locationData.miles_to_arrival != null ? `${locationData.miles_to_arrival.toFixed(1)} mi` : '—'} + {locationData.miles_to_arrival != null ? `${convertDistance(locationData.miles_to_arrival * 1.60934).toFixed(1)} ${distanceUnit}` : '—'}
From 6df4e44ba77d4e449b1dbac66f93c2aec659feab Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 12:35:42 -0700 Subject: [PATCH 24/40] fix: writeJSON writes 'null' body instead of empty body for nil data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When data is nil, writeJSON wrote nothing (empty body). Frontend res.json() throws SyntaxError parsing empty string, React Query treats it as error, retries every 3s → infinite skeleton loop. Now writes literal 'null' JSON which parses correctly to null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/helpers.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index c129604a..2fa054f2 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -15,6 +15,8 @@ func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.WriteHeader(status) if data != nil { _ = json.NewEncoder(w).Encode(data) + } else { + _, _ = w.Write([]byte("null")) } } From 1aa9fe3175126335d96a31bd64462de301b8149d Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 12:38:33 -0700 Subject: [PATCH 25/40] feat: add gas price settings for EV vs ICE cost comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 19 adds to settings table: - gas_price_per_unit (default 3.50) — price per gallon or liter - gas_unit (gallon/liter) — fuel volume unit - gas_efficiency_mpg (default 25) — comparison ICE vehicle MPG Updated: Go model, settings repo (SELECT/INSERT/Upsert), TS interface, Settings page UI with gas price, unit selector, and comparison MPG fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/database/settings_repo.go | 36 ++++++++++++++++----------- internal/models/models.go | 3 +++ migrations/000019_gas_price.down.sql | 4 +++ migrations/000019_gas_price.up.sql | 4 +++ web/src/api.ts | 3 +++ web/src/pages/Settings.tsx | 37 ++++++++++++++++++++++++++++ 6 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 migrations/000019_gas_price.down.sql create mode 100644 migrations/000019_gas_price.up.sql diff --git a/internal/database/settings_repo.go b/internal/database/settings_repo.go index d85d21f5..146dc5ed 100644 --- a/internal/database/settings_repo.go +++ b/internal/database/settings_repo.go @@ -17,23 +17,26 @@ func NewSettingsRepo(db *DB) *SettingsRepo { } func (r *SettingsRepo) Get(ctx context.Context) (*models.Settings, error) { - query := `SELECT id, unit_of_length, unit_of_temp, preferred_range, language, base_cost_per_kwh, api_suspended, theme, mode, custom_primary, custom_accent FROM settings WHERE id = 1` + query := `SELECT id, unit_of_length, unit_of_temp, preferred_range, language, base_cost_per_kwh, api_suspended, theme, mode, custom_primary, custom_accent, gas_price_per_unit, gas_unit, gas_efficiency_mpg FROM settings WHERE id = 1` s := &models.Settings{} err := r.db.Pool.QueryRow(ctx, query).Scan( &s.ID, &s.UnitOfLength, &s.UnitOfTemp, &s.PreferredRange, &s.Language, &s.BaseCostPerKWh, &s.APISuspended, - &s.Theme, &s.Mode, &s.CustomPrimary, &s.CustomAccent, + &s.Theme, &s.Mode, &s.CustomPrimary, &s.CustomAccent, &s.GasPricePerUnit, &s.GasUnit, &s.GasEfficiencyMPG, ) if err == pgx.ErrNoRows { return &models.Settings{ - ID: 1, - UnitOfLength: "km", - UnitOfTemp: "C", - PreferredRange: "rated", - Language: "en", - Theme: "neon-cyan", - Mode: "dark", - CustomPrimary: "#00b4d8", - CustomAccent: "#e63946", + ID: 1, + UnitOfLength: "km", + UnitOfTemp: "C", + PreferredRange: "rated", + Language: "en", + Theme: "neon-cyan", + Mode: "dark", + CustomPrimary: "#00b4d8", + CustomAccent: "#e63946", + GasPricePerUnit: 3.50, + GasUnit: "gallon", + GasEfficiencyMPG: 25, }, nil } return s, err @@ -41,8 +44,8 @@ func (r *SettingsRepo) Get(ctx context.Context) (*models.Settings, error) { func (r *SettingsRepo) Upsert(ctx context.Context, s *models.Settings) error { query := ` - INSERT INTO settings (id, unit_of_length, unit_of_temp, preferred_range, language, base_cost_per_kwh, api_suspended, theme, mode, custom_primary, custom_accent) - VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO settings (id, unit_of_length, unit_of_temp, preferred_range, language, base_cost_per_kwh, api_suspended, theme, mode, custom_primary, custom_accent, gas_price_per_unit, gas_unit, gas_efficiency_mpg) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (id) DO UPDATE SET unit_of_length = EXCLUDED.unit_of_length, unit_of_temp = EXCLUDED.unit_of_temp, @@ -53,9 +56,12 @@ func (r *SettingsRepo) Upsert(ctx context.Context, s *models.Settings) error { theme = EXCLUDED.theme, mode = EXCLUDED.mode, custom_primary = EXCLUDED.custom_primary, - custom_accent = EXCLUDED.custom_accent` + custom_accent = EXCLUDED.custom_accent, + gas_price_per_unit = EXCLUDED.gas_price_per_unit, + gas_unit = EXCLUDED.gas_unit, + gas_efficiency_mpg = EXCLUDED.gas_efficiency_mpg` _, err := r.db.Pool.Exec(ctx, query, s.UnitOfLength, s.UnitOfTemp, s.PreferredRange, s.Language, s.BaseCostPerKWh, s.APISuspended, - s.Theme, s.Mode, s.CustomPrimary, s.CustomAccent) + s.Theme, s.Mode, s.CustomPrimary, s.CustomAccent, s.GasPricePerUnit, s.GasUnit, s.GasEfficiencyMPG) return err } diff --git a/internal/models/models.go b/internal/models/models.go index 92abcb70..a830b5ca 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -171,6 +171,9 @@ type Settings struct { Mode string `json:"mode" db:"mode"` // dark, light, oled, midnight CustomPrimary string `json:"custom_primary" db:"custom_primary"` // hex color for custom theme CustomAccent string `json:"custom_accent" db:"custom_accent"` // hex color for custom theme + GasPricePerUnit float64 `json:"gas_price_per_unit" db:"gas_price_per_unit"` // price per gallon/liter + GasUnit string `json:"gas_unit" db:"gas_unit"` // gallon, liter + GasEfficiencyMPG float64 `json:"gas_efficiency_mpg" db:"gas_efficiency_mpg"` // equivalent ICE car MPG for comparison } // VehicleState represents a snapshot of vehicle state at a point in time. diff --git a/migrations/000019_gas_price.down.sql b/migrations/000019_gas_price.down.sql new file mode 100644 index 00000000..b030e2ef --- /dev/null +++ b/migrations/000019_gas_price.down.sql @@ -0,0 +1,4 @@ +-- Reverse migration 19 +ALTER TABLE settings DROP COLUMN IF EXISTS gas_price_per_unit; +ALTER TABLE settings DROP COLUMN IF EXISTS gas_unit; +ALTER TABLE settings DROP COLUMN IF EXISTS gas_efficiency_mpg; diff --git a/migrations/000019_gas_price.up.sql b/migrations/000019_gas_price.up.sql new file mode 100644 index 00000000..1d802d09 --- /dev/null +++ b/migrations/000019_gas_price.up.sql @@ -0,0 +1,4 @@ +-- Migration 19: Add gas price fields for EV vs ICE cost comparison +ALTER TABLE settings ADD COLUMN IF NOT EXISTS gas_price_per_unit DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS gas_unit VARCHAR(10) NOT NULL DEFAULT 'gallon'; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS gas_efficiency_mpg DOUBLE PRECISION NOT NULL DEFAULT 25; diff --git a/web/src/api.ts b/web/src/api.ts index a58a2da4..443974a2 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -126,6 +126,9 @@ export interface AppSettings { mode: string custom_primary: string custom_accent: string + gas_price_per_unit: number + gas_unit: string + gas_efficiency_mpg: number } export interface VehicleState { diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 2adedde4..38f7fa22 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -44,6 +44,9 @@ export default function Settings() { mode: 'dark', custom_primary: '#00b4d8', custom_accent: '#e63946', + gas_price_per_unit: 3.50, + gas_unit: 'gallon', + gas_efficiency_mpg: 25, }) const [saved, setSaved] = useState(false) const [customPrimary, setCustomPrimary] = useState(() => localStorage.getItem('teslasync-custom-primary') || '#00b4d8') @@ -369,6 +372,40 @@ export default function Settings() { />
+ + +
+
+ $ + setForm({ ...form, gas_price_per_unit: parseFloat(e.target.value) || 0 })} + className="glass-input w-full pl-7 pr-3 py-2.5 text-sm" + /> +
+ +
+
+ + + setForm({ ...form, gas_efficiency_mpg: parseFloat(e.target.value) || 0 })} + className="glass-input w-full px-3 py-2.5 text-sm" + placeholder="Average MPG of equivalent gas car" + /> +
)} From be8bbfcdfad1fd82b595158f09c1eb664fa040f1 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 12:39:56 -0700 Subject: [PATCH 26/40] feat: gas price history tracking for period-accurate comparisons Adds gas_price_history table with effective_from/effective_to periods. When gas price is changed in Settings, the old period is closed and a new one is opened. Old charging sessions compare with the gas price that was active during that period. Includes gas_price_at(timestamp) SQL function for Grafana: SELECT g.price_per_unit FROM gas_price_at(session.start_date) g; Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/settings_handler.go | 18 +++++++++++++++++- migrations/000019_gas_price.down.sql | 2 ++ migrations/000019_gas_price.up.sql | 26 ++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go index e0903090..3bf3fec4 100644 --- a/internal/api/settings_handler.go +++ b/internal/api/settings_handler.go @@ -12,10 +12,11 @@ import ( // SettingsHandler handles user settings. type SettingsHandler struct { settingsRepo *database.SettingsRepo + db *database.DB } func NewSettingsHandler(db *database.DB) *SettingsHandler { - return &SettingsHandler{settingsRepo: database.NewSettingsRepo(db)} + return &SettingsHandler{settingsRepo: database.NewSettingsRepo(db), db: db} } func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) { @@ -61,6 +62,21 @@ func (h *SettingsHandler) Update(w http.ResponseWriter, r *http.Request) { return } + // Record gas price change in history if price or unit changed + if s.GasPricePerUnit > 0 { + oldSettings, _ := h.settingsRepo.Get(r.Context()) + if oldSettings == nil || oldSettings.GasPricePerUnit != s.GasPricePerUnit || + oldSettings.GasUnit != s.GasUnit || oldSettings.GasEfficiencyMPG != s.GasEfficiencyMPG { + // Close previous period + h.db.Pool.Exec(r.Context(), + `UPDATE gas_price_history SET effective_to = NOW() WHERE effective_to IS NULL`) + // Insert new period + h.db.Pool.Exec(r.Context(), + `INSERT INTO gas_price_history (price_per_unit, unit, efficiency_mpg, effective_from) VALUES ($1, $2, $3, NOW())`, + s.GasPricePerUnit, s.GasUnit, s.GasEfficiencyMPG) + } + } + if err := h.settingsRepo.Upsert(r.Context(), &s); err != nil { log.Error().Err(err).Msg("failed to update settings") writeError(w, http.StatusInternalServerError, "failed to update settings") diff --git a/migrations/000019_gas_price.down.sql b/migrations/000019_gas_price.down.sql index b030e2ef..7c5aeb15 100644 --- a/migrations/000019_gas_price.down.sql +++ b/migrations/000019_gas_price.down.sql @@ -1,4 +1,6 @@ -- Reverse migration 19 +DROP FUNCTION IF EXISTS gas_price_at(TIMESTAMPTZ); +DROP TABLE IF EXISTS gas_price_history; ALTER TABLE settings DROP COLUMN IF EXISTS gas_price_per_unit; ALTER TABLE settings DROP COLUMN IF EXISTS gas_unit; ALTER TABLE settings DROP COLUMN IF EXISTS gas_efficiency_mpg; diff --git a/migrations/000019_gas_price.up.sql b/migrations/000019_gas_price.up.sql index 1d802d09..bac2c5a7 100644 --- a/migrations/000019_gas_price.up.sql +++ b/migrations/000019_gas_price.up.sql @@ -2,3 +2,29 @@ ALTER TABLE settings ADD COLUMN IF NOT EXISTS gas_price_per_unit DOUBLE PRECISION NOT NULL DEFAULT 0; ALTER TABLE settings ADD COLUMN IF NOT EXISTS gas_unit VARCHAR(10) NOT NULL DEFAULT 'gallon'; ALTER TABLE settings ADD COLUMN IF NOT EXISTS gas_efficiency_mpg DOUBLE PRECISION NOT NULL DEFAULT 25; + +-- Gas price history — tracks price changes over time so old sessions +-- compare with the price that was active during that period +CREATE TABLE IF NOT EXISTS gas_price_history ( + id BIGSERIAL PRIMARY KEY, + price_per_unit DOUBLE PRECISION NOT NULL, + unit VARCHAR(10) NOT NULL DEFAULT 'gallon', + efficiency_mpg DOUBLE PRECISION NOT NULL DEFAULT 25, + effective_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + effective_to TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_gas_price_history_effective ON gas_price_history (effective_from, effective_to); + +-- Helper function: get gas price active at a given timestamp +CREATE OR REPLACE FUNCTION gas_price_at(ts TIMESTAMPTZ DEFAULT NOW()) +RETURNS TABLE(price_per_unit DOUBLE PRECISION, unit TEXT, efficiency_mpg DOUBLE PRECISION) +LANGUAGE SQL STABLE AS $$ + SELECT h.price_per_unit, h.unit::TEXT, h.efficiency_mpg + FROM gas_price_history h + WHERE h.effective_from <= ts + AND (h.effective_to IS NULL OR h.effective_to > ts) + ORDER BY h.effective_from DESC + LIMIT 1; +$$; From aad7a8dcab1e33cc0a6a627f5c0ca54b4834cdf0 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 13:03:24 -0700 Subject: [PATCH 27/40] feat: add EIA gas price auto-poll feature Add automated gas price polling from the U.S. Energy Information Administration (EIA) API for EV vs ICE cost comparison. Backend: - Add GasPriceConfig to config with env vars (GAS_PRICE_ENABLED, GAS_PRICE_POLL_INTERVAL, GAS_PRICE_API_KEY) - Create GasPriceWorker with time.Ticker pattern, runtime stop/resume via channels, and persistent state across restarts - Create gas_price_handler with 5 endpoints: GET /status, POST /poll, POST /toggle, PUT /config, GET /history - Wire worker in main.go with SafeGoLoop pattern - Add migration 20 for gas_price_poll_state table Helm: - Add gasPrice section to values.yaml (enabled, pollInterval, apiKey) - Add GAS_PRICE_ENABLED/POLL_INTERVAL to configmap.yaml - Add GAS_PRICE_API_KEY to secret.yaml Frontend: - Add GasPriceStatus/GasPriceHistory interfaces and API functions - Add Gas Price Auto-Poll section to Settings page with toggle, interval selector, poll-now button, and price display Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/teslasync/main.go | 14 + helm/teslasync/templates/configmap.yaml | 4 + helm/teslasync/templates/secret.yaml | 3 + helm/teslasync/values.yaml | 17 + internal/api/gas_price_handler.go | 153 ++++++++ internal/api/router.go | 16 +- internal/config/config.go | 14 + internal/worker/gas_price_worker.go | 358 ++++++++++++++++++ .../000020_gas_price_poll_state.down.sql | 1 + migrations/000020_gas_price_poll_state.up.sql | 8 + web/src/api.ts | 34 ++ web/src/hooks/useSettings.ts | 3 + web/src/pages/Settings.tsx | 94 ++++- 13 files changed, 716 insertions(+), 3 deletions(-) create mode 100644 internal/api/gas_price_handler.go create mode 100644 internal/worker/gas_price_worker.go create mode 100644 migrations/000020_gas_price_poll_state.down.sql create mode 100644 migrations/000020_gas_price_poll_state.up.sql diff --git a/cmd/teslasync/main.go b/cmd/teslasync/main.go index 20cb8e78..6cd48bb7 100644 --- a/cmd/teslasync/main.go +++ b/cmd/teslasync/main.go @@ -199,6 +199,19 @@ func main() { }) log.Info().Msg("maintenance worker started") + // Gas price worker — polls EIA API for US average gasoline price + var gasPriceWorker *worker.GasPriceWorker + if cfg.GasPrice.APIKey != "" { + gasPriceWorker = worker.NewGasPriceWorker(db, cfg.GasPrice) + resilience.SafeGoLoop(ctx, "gas-price-worker", func(loopCtx context.Context) { + gasPriceWorker.Start(loopCtx) + }) + log.Info(). + Bool("enabled", cfg.GasPrice.Enabled). + Str("poll_interval", cfg.GasPrice.PollInterval). + Msg("gas price worker started") + } + // Periodic component health checker — creates system alerts on state changes alertRepo := database.NewAlertRepo(db) notifRepo := database.NewNotificationRepo(db) @@ -301,6 +314,7 @@ func main() { AppVersion: Version, Encryptor: encryptor, TelemetryHandler: telemetryHandler, + GasPriceWorker: gasPriceWorker, }) server := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), diff --git a/helm/teslasync/templates/configmap.yaml b/helm/teslasync/templates/configmap.yaml index 232aac13..034bf0a5 100644 --- a/helm/teslasync/templates/configmap.yaml +++ b/helm/teslasync/templates/configmap.yaml @@ -44,3 +44,7 @@ data: {{- if $proxyURL }} TESLA_COMMAND_PROXY_URL: {{ $proxyURL | quote }} {{- end }} + {{- if .Values.gasPrice }} + GAS_PRICE_ENABLED: {{ .Values.gasPrice.enabled | default false | quote }} + GAS_PRICE_POLL_INTERVAL: {{ .Values.gasPrice.pollInterval | default "7d" | quote }} + {{- end }} diff --git a/helm/teslasync/templates/secret.yaml b/helm/teslasync/templates/secret.yaml index bf5470a9..038fd469 100644 --- a/helm/teslasync/templates/secret.yaml +++ b/helm/teslasync/templates/secret.yaml @@ -20,3 +20,6 @@ stringData: {{- if .Values.encryption.key }} ENCRYPTION_KEY: {{ .Values.encryption.key | quote }} {{- end }} + {{- if and .Values.gasPrice .Values.gasPrice.apiKey }} + GAS_PRICE_API_KEY: {{ .Values.gasPrice.apiKey | quote }} + {{- end }} diff --git a/helm/teslasync/values.yaml b/helm/teslasync/values.yaml index fa3ed10b..380910b0 100644 --- a/helm/teslasync/values.yaml +++ b/helm/teslasync/values.yaml @@ -404,6 +404,23 @@ config: # Internal: http://teslasync-web.teslasync.svc.cluster.local webEndpoint: "" +# ───────────────────────────────────────────────────────────────────────────── +# Gas Price Auto-Poll (EIA API) +# +# Automatically fetches the US average regular gasoline price from the +# U.S. Energy Information Administration (EIA) API. Used for EV vs ICE +# cost comparison in the dashboard. +# +# Requires a free EIA API key: https://www.eia.gov/opendata/register.php +# ───────────────────────────────────────────────────────────────────────────── +gasPrice: + # -- Enable automatic gas price polling on startup. + enabled: false + # -- Poll interval: "daily", "7d", "15d", "30d" + pollInterval: "7d" + # -- EIA API key (required for gas price polling). + apiKey: "" + # ───────────────────────────────────────────────────────────────────────────── # Tesla API credentials # ───────────────────────────────────────────────────────────────────────────── diff --git a/internal/api/gas_price_handler.go b/internal/api/gas_price_handler.go new file mode 100644 index 00000000..b27ae761 --- /dev/null +++ b/internal/api/gas_price_handler.go @@ -0,0 +1,153 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/rs/zerolog/log" + "github.com/ev-dev-labs/teslasync/internal/database" + "github.com/ev-dev-labs/teslasync/internal/worker" +) + +// GasPriceHandler handles gas price auto-poll management endpoints. +type GasPriceHandler struct { + db *database.DB + worker *worker.GasPriceWorker +} + +// NewGasPriceHandler creates a new GasPriceHandler. +func NewGasPriceHandler(db *database.DB, w *worker.GasPriceWorker) *GasPriceHandler { + return &GasPriceHandler{db: db, worker: w} +} + +// gasPriceHistoryRow represents a row from gas_price_history. +type gasPriceHistoryRow struct { + ID int64 `json:"id"` + PricePerUnit float64 `json:"price_per_unit"` + Unit string `json:"unit"` + EfficiencyMPG float64 `json:"efficiency_mpg"` + EffectiveFrom time.Time `json:"effective_from"` + EffectiveTo *time.Time `json:"effective_to"` + CreatedAt time.Time `json:"created_at"` +} + +// Status returns the current gas price poll status. +// GET /api/v1/gas-price/status +func (h *GasPriceHandler) Status(w http.ResponseWriter, r *http.Request) { + status := h.worker.Status() + writeJSON(w, http.StatusOK, status) +} + +// Poll triggers an immediate gas price poll. +// POST /api/v1/gas-price/poll +func (h *GasPriceHandler) Poll(w http.ResponseWriter, r *http.Request) { + go h.worker.Poll(r.Context()) + log.Info().Msg("gas price manual poll triggered") + writeJSON(w, http.StatusOK, map[string]string{"status": "poll_triggered"}) +} + +// Toggle starts or stops auto-polling at runtime. +// POST /api/v1/gas-price/toggle +func (h *GasPriceHandler) Toggle(w http.ResponseWriter, r *http.Request) { + var body struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if body.Enabled { + h.worker.Resume() + } else { + h.worker.Stop() + } + + // Persist the toggle state + h.persistToggle(r, body.Enabled) + + log.Info().Bool("enabled", body.Enabled).Msg("gas price auto-poll toggled") + writeJSON(w, http.StatusOK, map[string]bool{"enabled": body.Enabled}) +} + +// UpdateConfig updates the poll interval. +// PUT /api/v1/gas-price/config +func (h *GasPriceHandler) UpdateConfig(w http.ResponseWriter, r *http.Request) { + var body struct { + PollInterval string `json:"poll_interval"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + valid := map[string]bool{"daily": true, "7d": true, "15d": true, "30d": true} + if !valid[body.PollInterval] { + writeError(w, http.StatusBadRequest, "poll_interval must be one of: daily, 7d, 15d, 30d") + return + } + + h.worker.SetPollInterval(body.PollInterval) + + // Persist interval change + _, err := h.db.Pool.Exec(r.Context(), ` + INSERT INTO gas_price_poll_state (id, poll_interval) + VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET poll_interval = EXCLUDED.poll_interval + `, body.PollInterval) + if err != nil { + log.Warn().Err(err).Msg("gas price: failed to persist interval change") + } + + log.Info().Str("poll_interval", body.PollInterval).Msg("gas price poll interval updated") + writeJSON(w, http.StatusOK, map[string]string{"poll_interval": body.PollInterval}) +} + +// History returns gas_price_history records. +// GET /api/v1/gas-price/history +func (h *GasPriceHandler) History(w http.ResponseWriter, r *http.Request) { + limit, offset := pagination(r) + + rows, err := h.db.Pool.Query(r.Context(), + `SELECT id, price_per_unit, unit, efficiency_mpg, effective_from, effective_to, created_at + FROM gas_price_history + ORDER BY effective_from DESC + LIMIT $1 OFFSET $2`, limit, offset) + if err != nil { + log.Error().Err(err).Msg("failed to query gas price history") + writeError(w, http.StatusInternalServerError, "failed to query gas price history") + return + } + defer rows.Close() + + var history []gasPriceHistoryRow + for rows.Next() { + var row gasPriceHistoryRow + if err := rows.Scan(&row.ID, &row.PricePerUnit, &row.Unit, &row.EfficiencyMPG, + &row.EffectiveFrom, &row.EffectiveTo, &row.CreatedAt); err != nil { + log.Error().Err(err).Msg("failed to scan gas price history row") + writeError(w, http.StatusInternalServerError, "failed to read gas price history") + return + } + history = append(history, row) + } + + if history == nil { + history = []gasPriceHistoryRow{} + } + + writeJSON(w, http.StatusOK, history) +} + +// persistToggle saves the enabled state to the database. +func (h *GasPriceHandler) persistToggle(r *http.Request, enabled bool) { + _, err := h.db.Pool.Exec(r.Context(), ` + INSERT INTO gas_price_poll_state (id, enabled) + VALUES (1, $1) + ON CONFLICT (id) DO UPDATE SET enabled = EXCLUDED.enabled + `, enabled) + if err != nil { + log.Warn().Err(err).Msg("gas price: failed to persist toggle state") + } +} diff --git a/internal/api/router.go b/internal/api/router.go index d4415cc3..92178c20 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -19,13 +19,15 @@ import ( "github.com/ev-dev-labs/teslasync/internal/mqtt" "github.com/ev-dev-labs/teslasync/internal/resilience" "github.com/ev-dev-labs/teslasync/internal/tesla" + "github.com/ev-dev-labs/teslasync/internal/worker" ) // RouterOptions holds optional parameters for NewRouter. type RouterOptions struct { AppVersion string Encryptor *crypto.Encryptor - TelemetryHandler *TelemetryHandler // If set, reuses existing handler (for hybrid mode wiring) + TelemetryHandler *TelemetryHandler // If set, reuses existing handler (for hybrid mode wiring) + GasPriceWorker *worker.GasPriceWorker // If set, enables gas price management endpoints } // NewRouter creates and configures the main HTTP router with all API routes, @@ -186,6 +188,18 @@ func NewRouter(db *database.DB, teslaClient *tesla.Client, mqttClient *mqtt.Clie r.Put("/settings", settingsHandler.Update) r.Post("/settings/suspend-api", settingsHandler.ToggleAPISuspend) + // Gas Price Auto-Poll + if opt.GasPriceWorker != nil { + gasPriceHandler := NewGasPriceHandler(db, opt.GasPriceWorker) + r.Route("/gas-price", func(r chi.Router) { + r.Get("/status", gasPriceHandler.Status) + r.Post("/poll", gasPriceHandler.Poll) + r.Post("/toggle", gasPriceHandler.Toggle) + r.Put("/config", gasPriceHandler.UpdateConfig) + r.Get("/history", gasPriceHandler.History) + }) + } + // Alerts r.Route("/alerts", func(r chi.Router) { r.Get("/", alertHandler.List) diff --git a/internal/config/config.go b/internal/config/config.go index 5783a74a..16d914fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,14 @@ type Config struct { Auth AuthConfig Retention RetentionConfig FleetTelemetry FleetTelemetryConfig + GasPrice GasPriceConfig +} + +// GasPriceConfig controls automated gas price polling from the EIA API. +type GasPriceConfig struct { + Enabled bool + PollInterval string // "daily", "7d", "15d", "30d" + APIKey string } type FleetTelemetryConfig struct { @@ -184,6 +192,12 @@ func Load() (*Config, error) { StaleTimeout: envDuration("FLEET_TELEMETRY_STALE_TIMEOUT", 5*time.Minute), FallbackPollInterval: envDuration("FLEET_TELEMETRY_FALLBACK_POLL_INTERVAL", 60*time.Second), }, + + GasPrice: GasPriceConfig{ + Enabled: envBool("GAS_PRICE_ENABLED", false), + PollInterval: envStr("GAS_PRICE_POLL_INTERVAL", "7d"), + APIKey: envStr("GAS_PRICE_API_KEY", ""), + }, } return cfg, nil diff --git a/internal/worker/gas_price_worker.go b/internal/worker/gas_price_worker.go new file mode 100644 index 00000000..8a711e7e --- /dev/null +++ b/internal/worker/gas_price_worker.go @@ -0,0 +1,358 @@ +package worker + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" + "github.com/ev-dev-labs/teslasync/internal/config" + "github.com/ev-dev-labs/teslasync/internal/database" +) + +// eiaResponse models the JSON returned by the EIA petroleum price API. +type eiaResponse struct { + Response struct { + Data []struct { + Value string `json:"value"` + Period string `json:"period"` + } `json:"data"` + } `json:"response"` +} + +// GasPriceWorker polls the EIA API for the latest US average gasoline price. +type GasPriceWorker struct { + db *database.DB + cfg config.GasPriceConfig + + mu sync.Mutex + pollInterval string + lastPollTime time.Time + lastPrice float64 + running atomic.Bool + + // stopCh is used to signal the ticker loop to stop. + stopCh chan struct{} + // resumeCh is used to signal the ticker loop to resume. + resumeCh chan struct{} +} + +// NewGasPriceWorker creates a new gas price polling worker. +func NewGasPriceWorker(db *database.DB, cfg config.GasPriceConfig) *GasPriceWorker { + return &GasPriceWorker{ + db: db, + cfg: cfg, + pollInterval: cfg.PollInterval, + stopCh: make(chan struct{}, 1), + resumeCh: make(chan struct{}, 1), + } +} + +// Start runs the gas price polling loop. It blocks until ctx is cancelled. +func (w *GasPriceWorker) Start(ctx context.Context) { + // Restore persisted state from the database + w.restoreState(ctx) + + if !w.IsRunning() && w.cfg.Enabled { + w.running.Store(true) + } + + interval := w.tickerDuration() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + log.Info(). + Bool("enabled", w.cfg.Enabled). + Str("poll_interval", w.pollInterval). + Dur("ticker_duration", interval). + Msg("gas price worker started") + + // Initial poll shortly after startup if enabled + if w.IsRunning() { + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + w.Poll(ctx) + } + } + + for { + select { + case <-ctx.Done(): + return + case <-w.stopCh: + w.running.Store(false) + log.Info().Msg("gas price auto-poll stopped") + // Wait for resume or context cancellation + select { + case <-ctx.Done(): + return + case <-w.resumeCh: + w.running.Store(true) + // Reset ticker with current interval + ticker.Reset(w.tickerDuration()) + log.Info().Msg("gas price auto-poll resumed") + } + case <-ticker.C: + if w.IsRunning() { + w.Poll(ctx) + } + } + } +} + +// Poll fetches the latest gas price from the EIA API and records it. +func (w *GasPriceWorker) Poll(ctx context.Context) { + if w.cfg.APIKey == "" { + log.Warn().Msg("gas price poll skipped: no EIA API key configured") + return + } + + url := fmt.Sprintf( + "https://api.eia.gov/v2/petroleum/pri/grt/data/?api_key=%s&frequency=weekly&data[]=value&facets[product][]=EPM0&facets[duession][]=PG&sort[0][column]=period&sort[0][direction]=desc&length=1", + w.cfg.APIKey, + ) + + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + log.Error().Err(err).Msg("gas price poll: failed to create request") + return + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Error().Err(err).Msg("gas price poll: request failed") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("gas price poll: non-200 response from EIA") + return + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + log.Error().Err(err).Msg("gas price poll: failed to read response body") + return + } + + var eia eiaResponse + if err := json.Unmarshal(body, &eia); err != nil { + log.Error().Err(err).Msg("gas price poll: failed to parse EIA response") + return + } + + if len(eia.Response.Data) == 0 { + log.Warn().Msg("gas price poll: EIA returned empty data") + return + } + + priceStr := eia.Response.Data[0].Value + period := eia.Response.Data[0].Period + price, err := strconv.ParseFloat(priceStr, 64) + if err != nil { + log.Error().Err(err).Str("value", priceStr).Msg("gas price poll: failed to parse price value") + return + } + + // Record in gas_price_history (close current period, insert new) + if err := w.recordPrice(ctx, price); err != nil { + log.Error().Err(err).Float64("price", price).Msg("gas price poll: failed to record price") + return + } + + // Update settings table with the new price + if err := w.updateSettingsPrice(ctx, price); err != nil { + log.Error().Err(err).Float64("price", price).Msg("gas price poll: failed to update settings") + return + } + + w.mu.Lock() + w.lastPollTime = time.Now() + w.lastPrice = price + w.mu.Unlock() + + // Persist poll state + w.persistState(ctx) + + log.Info(). + Float64("price", price). + Str("period", period). + Msg("gas price poll: updated successfully") +} + +// recordPrice closes the current gas_price_history period and inserts a new one. +func (w *GasPriceWorker) recordPrice(ctx context.Context, price float64) error { + tx, err := w.db.Pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) + + // Close current active period + if _, err := tx.Exec(ctx, + `UPDATE gas_price_history SET effective_to = NOW() WHERE effective_to IS NULL`); err != nil { + return fmt.Errorf("close period: %w", err) + } + + // Get current efficiency from settings + var efficiencyMPG float64 + var gasUnit string + err = tx.QueryRow(ctx, + `SELECT gas_unit, gas_efficiency_mpg FROM settings WHERE id = 1`).Scan(&gasUnit, &efficiencyMPG) + if err != nil { + // Defaults if settings not found + gasUnit = "gallon" + efficiencyMPG = 25 + } + + // Insert new period + if _, err := tx.Exec(ctx, + `INSERT INTO gas_price_history (price_per_unit, unit, efficiency_mpg, effective_from) VALUES ($1, $2, $3, NOW())`, + price, gasUnit, efficiencyMPG); err != nil { + return fmt.Errorf("insert period: %w", err) + } + + return tx.Commit(ctx) +} + +// updateSettingsPrice updates the gas_price_per_unit in the settings table. +func (w *GasPriceWorker) updateSettingsPrice(ctx context.Context, price float64) error { + _, err := w.db.Pool.Exec(ctx, + `UPDATE settings SET gas_price_per_unit = $1 WHERE id = 1`, price) + return err +} + +// Stop signals the worker to stop auto-polling. +func (w *GasPriceWorker) Stop() { + select { + case w.stopCh <- struct{}{}: + default: + } +} + +// Resume signals the worker to resume auto-polling. +func (w *GasPriceWorker) Resume() { + select { + case w.resumeCh <- struct{}{}: + default: + } +} + +// IsRunning returns whether the worker is currently auto-polling. +func (w *GasPriceWorker) IsRunning() bool { + return w.running.Load() +} + +// SetPollInterval updates the poll interval at runtime. +func (w *GasPriceWorker) SetPollInterval(interval string) { + w.mu.Lock() + w.pollInterval = interval + w.mu.Unlock() +} + +// Status returns the current worker state. +func (w *GasPriceWorker) Status() GasPriceStatus { + w.mu.Lock() + defer w.mu.Unlock() + return GasPriceStatus{ + Enabled: w.IsRunning(), + PollInterval: w.pollInterval, + LastPollTime: w.lastPollTime, + CurrentPrice: w.lastPrice, + } +} + +// GasPriceStatus holds the polling status for the API response. +type GasPriceStatus struct { + Enabled bool `json:"enabled"` + PollInterval string `json:"poll_interval"` + LastPollTime time.Time `json:"last_poll_time"` + CurrentPrice float64 `json:"current_price"` +} + +// tickerDuration converts the poll interval string to a time.Duration. +func (w *GasPriceWorker) tickerDuration() time.Duration { + w.mu.Lock() + interval := w.pollInterval + w.mu.Unlock() + + switch interval { + case "daily": + return 24 * time.Hour + case "7d": + return 7 * 24 * time.Hour + case "15d": + return 15 * 24 * time.Hour + case "30d": + return 30 * 24 * time.Hour + default: + return 7 * 24 * time.Hour + } +} + +// persistState saves the worker's poll state to the database so it survives restarts. +func (w *GasPriceWorker) persistState(ctx context.Context) { + w.mu.Lock() + lastPoll := w.lastPollTime + lastPrice := w.lastPrice + interval := w.pollInterval + w.mu.Unlock() + + running := w.IsRunning() + + _, err := w.db.Pool.Exec(ctx, ` + INSERT INTO gas_price_poll_state (id, enabled, poll_interval, last_poll_time, last_price) + VALUES (1, $1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET + enabled = EXCLUDED.enabled, + poll_interval = EXCLUDED.poll_interval, + last_poll_time = EXCLUDED.last_poll_time, + last_price = EXCLUDED.last_price + `, running, interval, lastPoll, lastPrice) + if err != nil { + log.Warn().Err(err).Msg("gas price worker: failed to persist state") + } +} + +// restoreState loads persisted poll state from the database. +func (w *GasPriceWorker) restoreState(ctx context.Context) { + var enabled bool + var interval string + var lastPoll time.Time + var lastPrice float64 + + err := w.db.Pool.QueryRow(ctx, + `SELECT enabled, poll_interval, last_poll_time, last_price FROM gas_price_poll_state WHERE id = 1`, + ).Scan(&enabled, &interval, &lastPoll, &lastPrice) + if err != nil { + // Table may not exist yet or no row — use config defaults + return + } + + w.mu.Lock() + w.pollInterval = interval + w.lastPollTime = lastPoll + w.lastPrice = lastPrice + w.mu.Unlock() + + w.running.Store(enabled) + log.Info(). + Bool("enabled", enabled). + Str("poll_interval", interval). + Time("last_poll_time", lastPoll). + Float64("last_price", lastPrice). + Msg("gas price worker: restored persisted state") +} diff --git a/migrations/000020_gas_price_poll_state.down.sql b/migrations/000020_gas_price_poll_state.down.sql new file mode 100644 index 00000000..ee538af1 --- /dev/null +++ b/migrations/000020_gas_price_poll_state.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS gas_price_poll_state; diff --git a/migrations/000020_gas_price_poll_state.up.sql b/migrations/000020_gas_price_poll_state.up.sql new file mode 100644 index 00000000..3e7fee9c --- /dev/null +++ b/migrations/000020_gas_price_poll_state.up.sql @@ -0,0 +1,8 @@ +-- Gas price auto-poll state persistence +CREATE TABLE IF NOT EXISTS gas_price_poll_state ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled BOOLEAN NOT NULL DEFAULT false, + poll_interval VARCHAR(10) NOT NULL DEFAULT '7d', + last_poll_time TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01T00:00:00Z', + last_price DOUBLE PRECISION NOT NULL DEFAULT 0 +); diff --git a/web/src/api.ts b/web/src/api.ts index 443974a2..2efd9efe 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1322,3 +1322,37 @@ export interface TelemetryStatus { export const getTelemetryStatus = () => request('/telemetry') + +// --- Gas Price Auto-Poll --- +export interface GasPriceStatus { + enabled: boolean + poll_interval: string + last_poll_time: string + current_price: number +} + +export interface GasPriceHistory { + id: number + price_per_unit: number + unit: string + efficiency_mpg: number + effective_from: string + effective_to: string | null + created_at: string +} + +/** Fetches current gas price poll status. */ +export const getGasPriceStatus = () => + request('/gas-price/status') +/** Triggers an immediate gas price poll from the EIA API. */ +export const pollGasPrice = () => + request<{ status: string }>('/gas-price/poll', { method: 'POST' }) +/** Toggles gas price auto-polling on or off. */ +export const toggleGasPrice = (enabled: boolean) => + request<{ enabled: boolean }>('/gas-price/toggle', { method: 'POST', body: JSON.stringify({ enabled }) }) +/** Updates the gas price poll interval. */ +export const updateGasPriceConfig = (pollInterval: string) => + request<{ poll_interval: string }>('/gas-price/config', { method: 'PUT', body: JSON.stringify({ poll_interval: pollInterval }) }) +/** Fetches gas price history records. */ +export const getGasPriceHistory = (limit = 50, offset = 0) => + request(`/gas-price/history?limit=${limit}&offset=${offset}`) diff --git a/web/src/hooks/useSettings.ts b/web/src/hooks/useSettings.ts index 14da433a..bbb2b508 100644 --- a/web/src/hooks/useSettings.ts +++ b/web/src/hooks/useSettings.ts @@ -12,6 +12,9 @@ const defaults: AppSettings = { mode: 'dark', custom_primary: '#00b4d8', custom_accent: '#e63946', + gas_price_per_unit: 0, + gas_unit: 'gallon', + gas_efficiency_mpg: 25, } /** diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 38f7fa22..1194cdfe 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { getSettings, updateSettings, toggleAPISuspend, getAuthURL, getAuthStatus, refreshAuth, disconnectAuth, syncVehicles, getVehicles, getVersionInfo, AppSettings, Vehicle } from '../api' +import { getSettings, updateSettings, toggleAPISuspend, getAuthURL, getAuthStatus, refreshAuth, disconnectAuth, syncVehicles, getVehicles, getVersionInfo, getGasPriceStatus, pollGasPrice, toggleGasPrice, updateGasPriceConfig, AppSettings, Vehicle } from '../api' import { useState, useEffect } from 'react' -import { Settings as SettingsIcon, Save, ExternalLink, RefreshCw, Car, Shield, CheckCircle, XCircle, Globe, Palette, Download, Sun, Moon, Monitor, Sparkles, Pause, Play, Link } from 'lucide-react' +import { Settings as SettingsIcon, Save, ExternalLink, RefreshCw, Car, Shield, CheckCircle, XCircle, Globe, Palette, Download, Sun, Moon, Monitor, Sparkles, Pause, Play, Link, Fuel, Zap } from 'lucide-react' import { PageHeader, GlassPanel, FadeIn, Skeleton } from '../components/ui' import { motion, AnimatePresence } from 'framer-motion' import { useTheme, type ThemeId, type ModeId } from '../components/ThemeProvider' @@ -30,6 +30,7 @@ export default function Settings() { const { data: auth } = useQuery({ queryKey: ['auth-status'], queryFn: getAuthStatus }) const { data: vehicles } = useQuery({ queryKey: ['vehicles'], queryFn: getVehicles }) const { data: version } = useQuery({ queryKey: ['version'], queryFn: getVersionInfo, staleTime: 60_000 }) + const { data: gasPriceStatus, refetch: refetchGasPrice } = useQuery({ queryKey: ['gas-price-status'], queryFn: getGasPriceStatus, retry: false, refetchInterval: 30_000 }) const { themeId, modeId, setTheme, setMode, setCustomColors, themes: allThemes, modes: allModes } = useTheme() const toast = useToast() @@ -435,6 +436,95 @@ export default function Settings() { + {/* Gas Price Auto-Poll */} + + +
+
+ +
+
+

Gas Price Auto-Poll

+

Automatically fetch US average gas prices from EIA

+
+
+ +
+ {/* Toggle auto-polling */} + + + + + {/* Poll interval dropdown */} + + + +
+ + {/* Current price & last polled */} +
+
+

Current Price

+

+ {gasPriceStatus?.current_price ? `$${gasPriceStatus.current_price.toFixed(3)}/gal` : '—'} +

+
+
+

Last Polled

+

+ {gasPriceStatus?.last_poll_time && gasPriceStatus.last_poll_time !== '0001-01-01T00:00:00Z' + ? new Date(gasPriceStatus.last_poll_time).toLocaleString() + : 'Never'} +

+
+
+ + {/* Poll Now button */} +
+ +

+ Source: U.S. Energy Information Administration +

+
+
+
+ {/* Theme & Appearance */} From 1622d1736e852f5d4c2eb3a5b9206e8b26685b90 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 13:22:20 -0700 Subject: [PATCH 28/40] fix: EIA API URL typo and context cancellation in gas price worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed facets[duession] → facets[duoarea][]=NUS (US national average) - Use context.Background() instead of request context for poll HTTP call (request context gets cancelled when handler returns, killing the poll) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/worker/gas_price_worker.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/worker/gas_price_worker.go b/internal/worker/gas_price_worker.go index 8a711e7e..2c9076bf 100644 --- a/internal/worker/gas_price_worker.go +++ b/internal/worker/gas_price_worker.go @@ -116,11 +116,11 @@ func (w *GasPriceWorker) Poll(ctx context.Context) { } url := fmt.Sprintf( - "https://api.eia.gov/v2/petroleum/pri/grt/data/?api_key=%s&frequency=weekly&data[]=value&facets[product][]=EPM0&facets[duession][]=PG&sort[0][column]=period&sort[0][direction]=desc&length=1", + "https://api.eia.gov/v2/petroleum/pri/grt/data/?api_key=%s&frequency=weekly&data[]=value&facets[product][]=EPM0&facets[duoarea][]=NUS&sort[0][column]=period&sort[0][direction]=desc&length=1", w.cfg.APIKey, ) - reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) From a404949e76ce6ed6bf3dcaa17f47d7dc707e22db Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 13:35:56 -0700 Subject: [PATCH 29/40] fix: correct EIA API endpoint path and product facet Path: /petroleum/pri/gnd/data/ (not /pri/grt/) Product: EMM_EPMR_PTE_NUS_DPG (US avg regular gasoline, weekly, $/gal) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/worker/gas_price_worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/worker/gas_price_worker.go b/internal/worker/gas_price_worker.go index 2c9076bf..1e6aac06 100644 --- a/internal/worker/gas_price_worker.go +++ b/internal/worker/gas_price_worker.go @@ -116,7 +116,7 @@ func (w *GasPriceWorker) Poll(ctx context.Context) { } url := fmt.Sprintf( - "https://api.eia.gov/v2/petroleum/pri/grt/data/?api_key=%s&frequency=weekly&data[]=value&facets[product][]=EPM0&facets[duoarea][]=NUS&sort[0][column]=period&sort[0][direction]=desc&length=1", + "https://api.eia.gov/v2/petroleum/pri/gnd/data/?api_key=%s&frequency=weekly&data[]=value&facets[product][]=EMM_EPMR_PTE_NUS_DPG&sort[0][column]=period&sort[0][direction]=desc&length=1", w.cfg.APIKey, ) From 5f920a2329af41ff686484e514ad4e20fcda4d09 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 13:37:33 -0700 Subject: [PATCH 30/40] fix: correct EIA API facets - product=EPMR + duoarea=NUS Verified live: returns US Regular Gasoline .961/gal (2026-03-23). Previous facet EMM_EPMR_PTE_NUS_DPG was the series ID, not the product facet code. Need both product and duoarea facets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/worker/gas_price_worker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/worker/gas_price_worker.go b/internal/worker/gas_price_worker.go index 1e6aac06..add98f3b 100644 --- a/internal/worker/gas_price_worker.go +++ b/internal/worker/gas_price_worker.go @@ -116,7 +116,7 @@ func (w *GasPriceWorker) Poll(ctx context.Context) { } url := fmt.Sprintf( - "https://api.eia.gov/v2/petroleum/pri/gnd/data/?api_key=%s&frequency=weekly&data[]=value&facets[product][]=EMM_EPMR_PTE_NUS_DPG&sort[0][column]=period&sort[0][direction]=desc&length=1", + "https://api.eia.gov/v2/petroleum/pri/gnd/data/?api_key=%s&frequency=weekly&data[]=value&facets[product][]=EPMR&facets[duoarea][]=NUS&sort[0][column]=period&sort[0][direction]=desc&length=1", w.cfg.APIKey, ) From 6af3f9e09dfc7db2d442cf3ad7bfc82cb7cf9942 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 13:49:59 -0700 Subject: [PATCH 31/40] fix: merge recent charging telemetry records for complete state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vehicle sends different signals in each batch (Soc in one, PackVoltage in next, ChargerVoltage in another). GetLatest returns only the newest record which may have just PackVoltage — losing Soc, ChargeRate, Power. GetLatestMerged looks at last 20 records and fills nil fields from older records to build a composite view. Now charger_power, charge_rate, battery_level, time_to_full all show correctly even when the latest single record is sparse. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/vehicle_handler.go | 3 +- internal/database/charging_telemetry_repo.go | 30 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/api/vehicle_handler.go b/internal/api/vehicle_handler.go index 8da812b9..5f2c5827 100644 --- a/internal/api/vehicle_handler.go +++ b/internal/api/vehicle_handler.go @@ -317,7 +317,8 @@ func (h *VehicleHandler) buildStateFromDB(r *http.Request, vehicle *models.Vehic } // Enrich with charging telemetry (always check — may have fresher battery data) - if ct, err := h.chargingTelRepo.GetLatest(ctx, vehicle.ID); err == nil && ct != nil { + // Merge last 20 records to get composite view (vehicle sends different signals per batch) + if ct, err := h.chargingTelRepo.GetLatestMerged(ctx, vehicle.ID, 20); err == nil && ct != nil { // Use charging telemetry battery level / SOC if fresher than position if ct.CreatedAt.After(pos.CreatedAt) { if ct.BatteryLevel != nil { diff --git a/internal/database/charging_telemetry_repo.go b/internal/database/charging_telemetry_repo.go index a5ac0414..e00d9b6a 100644 --- a/internal/database/charging_telemetry_repo.go +++ b/internal/database/charging_telemetry_repo.go @@ -80,3 +80,33 @@ func (r *ChargingTelemetryRepo) GetLatest(ctx context.Context, vehicleID int64) } return snaps[0], nil } + +// GetLatestMerged returns a composite of the most recent charging telemetry +// by merging the last N records. The vehicle sends different signals in +// different batches, so the latest single record may be sparse. This fills +// in nil fields from older records within the lookback window. +func (r *ChargingTelemetryRepo) GetLatestMerged(ctx context.Context, vehicleID int64, lookback int) (*models.ChargingTelemetry, error) { + snaps, err := r.GetByVehicle(ctx, vehicleID, lookback) + if err != nil || len(snaps) == 0 { + return nil, err + } + merged := *snaps[0] // start with the newest + for _, s := range snaps[1:] { + if merged.BatteryLevel == nil && s.BatteryLevel != nil { merged.BatteryLevel = s.BatteryLevel } + if merged.Soc == nil && s.Soc != nil { merged.Soc = s.Soc } + if merged.ChargeState == nil && s.ChargeState != nil { merged.ChargeState = s.ChargeState } + if merged.ChargeAmps == nil && s.ChargeAmps != nil { merged.ChargeAmps = s.ChargeAmps } + if merged.ChargerVoltage == nil && s.ChargerVoltage != nil { merged.ChargerVoltage = s.ChargerVoltage } + if merged.ChargeRateMph == nil && s.ChargeRateMph != nil { merged.ChargeRateMph = s.ChargeRateMph } + if merged.DCChargingPower == nil && s.DCChargingPower != nil { merged.DCChargingPower = s.DCChargingPower } + if merged.ACChargingPower == nil && s.ACChargingPower != nil { merged.ACChargingPower = s.ACChargingPower } + if merged.EstBatteryRange == nil && s.EstBatteryRange != nil { merged.EstBatteryRange = s.EstBatteryRange } + if merged.IdealBatteryRange == nil && s.IdealBatteryRange != nil { merged.IdealBatteryRange = s.IdealBatteryRange } + if merged.RatedRange == nil && s.RatedRange != nil { merged.RatedRange = s.RatedRange } + if merged.TimeToFullCharge == nil && s.TimeToFullCharge != nil { merged.TimeToFullCharge = s.TimeToFullCharge } + if merged.PackVoltage == nil && s.PackVoltage != nil { merged.PackVoltage = s.PackVoltage } + if merged.PackCurrent == nil && s.PackCurrent != nil { merged.PackCurrent = s.PackCurrent } + if merged.ChargeLimitSoc == nil && s.ChargeLimitSoc != nil { merged.ChargeLimitSoc = s.ChargeLimitSoc } + } + return &merged, nil +} From 173eb614c6f237490dfb65d6c9100d964a2d8371 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 14:24:08 -0700 Subject: [PATCH 32/40] fix: accumulate signals across batches within throttle window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signals arrive as individual MQTT messages, each creating a separate batch. The 10s write throttle only writes on the first batch, losing all signals from subsequent batches (climate, security, motor, tire). Added signal accumulator per vehicle: signals from all batches within the throttle window are merged into a single map. When the throttle timer fires, ALL accumulated signals are written together. Before: 37 signals → only BatteryLevel written, 5 tables empty After: 37 signals → all 37 written, all 6 tables populated Verified locally with Docker compose + mosquitto_pub test suite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/telemetry_handler.go | 73 ++++++++++++++++++++++++------- publish_test.sh | 31 +++++++++++++ 2 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 publish_test.sh diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index 9a5f165b..ffa815fd 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -47,6 +47,10 @@ type TelemetryHandler struct { // Per-vehicle write throttling to prevent DB overload lastWriteMu sync.Mutex lastWriteAt map[string]time.Time // keyed by VIN + + // Per-vehicle signal accumulator — merges signals across batches within throttle window + accumulatedSignalsMu sync.Mutex + accumulatedSignals map[string]map[string]interface{} // keyed by VIN } // VehicleStreamState tracks streaming health per vehicle. @@ -97,8 +101,9 @@ func NewTelemetryHandler(db *database.DB, mc *mqtt.Client, hub *EventHub, staleT sessionTracker: NewTelemetrySessionTracker(db, eventBus), alertEvaluator: NewTelemetryAlertEvaluator(db, eventBus), staleTimeout: staleTimeout, - streamingState: make(map[string]*VehicleStreamState), - lastWriteAt: make(map[string]time.Time), + streamingState: make(map[string]*VehicleStreamState), + lastWriteAt: make(map[string]time.Time), + accumulatedSignals: make(map[string]map[string]interface{}), } } @@ -138,6 +143,18 @@ func (h *TelemetryHandler) cleanupStaleEntries() { } } h.lastWriteMu.Unlock() + + h.accumulatedSignalsMu.Lock() + for vin := range h.accumulatedSignals { + // Clean up accumulated signals for VINs no longer streaming + h.mu.RLock() + _, still := h.streamingState[vin] + h.mu.RUnlock() + if !still { + delete(h.accumulatedSignals, vin) + } + } + h.accumulatedSignalsMu.Unlock() } type telemetrySignal struct { @@ -279,8 +296,21 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa // --- Async writes: state tracking, mileage, tire pressure, vehicle health --- // Throttle snapshot writes to once every 10 seconds per vehicle to prevent // DB connection pool exhaustion from high-frequency telemetry batches. + // Signals are accumulated across batches within the throttle window so that + // individual MQTT messages don't cause data loss. if vehicleID > 0 { const snapshotWriteInterval = 10 * time.Second + + // Accumulate signals from this batch + h.accumulatedSignalsMu.Lock() + if h.accumulatedSignals[vin] == nil { + h.accumulatedSignals[vin] = make(map[string]interface{}) + } + for k, v := range signals { + h.accumulatedSignals[vin][k] = v + } + h.accumulatedSignalsMu.Unlock() + h.lastWriteMu.Lock() lastWrite := h.lastWriteAt[vin] shouldWrite := time.Since(lastWrite) >= snapshotWriteInterval @@ -293,52 +323,61 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Always update vehicle state (lightweight, single UPDATE) + // Drain accumulated signals for this write cycle + var writeSignals map[string]interface{} if shouldWrite { - detectedState := h.detectVehicleState(signals) + h.accumulatedSignalsMu.Lock() + writeSignals = h.accumulatedSignals[vin] + h.accumulatedSignals[vin] = make(map[string]interface{}) + h.accumulatedSignalsMu.Unlock() + } + + // Always update vehicle state (lightweight, single UPDATE) + if shouldWrite && writeSignals != nil { + detectedState := h.detectVehicleState(writeSignals) if err := h.vehicleRepo.UpdateState(bgCtx, vehicleID, detectedState, true); err != nil { log.Warn().Err(err).Int64("vehicle_id", vehicleID).Msg("telemetry: failed to update vehicle state") } - h.trackStateTransition(bgCtx, vehicleID, signals) + h.trackStateTransition(bgCtx, vehicleID, writeSignals) } // Throttled snapshot writes — only run every 10s per vehicle - if !shouldWrite { + if !shouldWrite || writeSignals == nil { return } // Update daily mileage from odometer readings - h.trackMileage(bgCtx, vehicleID, signals) + h.trackMileage(bgCtx, vehicleID, writeSignals) // Store tire pressure snapshots - h.trackTirePressure(bgCtx, vehicleID, signals) + h.trackTirePressure(bgCtx, vehicleID, writeSignals) // Store motor/powertrain snapshots - h.trackMotor(bgCtx, vehicleID, signals) + h.trackMotor(bgCtx, vehicleID, writeSignals) // Store climate/HVAC snapshots - h.trackClimate(bgCtx, vehicleID, signals) + h.trackClimate(bgCtx, vehicleID, writeSignals) // Store security events - h.trackSecurity(bgCtx, vehicleID, signals) + h.trackSecurity(bgCtx, vehicleID, writeSignals) // Store charging telemetry - h.trackCharging(bgCtx, vehicleID, signals) + h.trackCharging(bgCtx, vehicleID, writeSignals) // Store media snapshots - h.trackMedia(bgCtx, vehicleID, signals) + h.trackMedia(bgCtx, vehicleID, writeSignals) // Store vehicle config snapshots - h.trackVehicleConfig(bgCtx, vehicleID, signals) + h.trackVehicleConfig(bgCtx, vehicleID, writeSignals) // Store location/navigation snapshots - h.trackLocation(bgCtx, vehicleID, signals) + h.trackLocation(bgCtx, vehicleID, writeSignals) // Store safety settings snapshots - h.trackSafety(bgCtx, vehicleID, signals) + h.trackSafety(bgCtx, vehicleID, writeSignals) // Store user preference snapshots - h.trackUserPreferences(bgCtx, vehicleID, signals) + h.trackUserPreferences(bgCtx, vehicleID, writeSignals) }() } } diff --git a/publish_test.sh b/publish_test.sh new file mode 100644 index 00000000..12884078 --- /dev/null +++ b/publish_test.sh @@ -0,0 +1,31 @@ +#!/bin/sh +MQTT_API="http://mqtt-emqx.mqtt.svc.cluster.local:18083/api/v5/publish" +AUTH="Authorization: Basic YWRtaW46cHVibGlj" +VIN="7SAYGDEF7PF924551" + +pub() { + wget -qO- --post-data="{\"topic\":\"telemetry/$VIN/v/$1\",\"payload\":\"$2\",\"qos\":1}" --header="Content-Type: application/json" --header="$AUTH" "$MQTT_API" 2>/dev/null && echo "OK: $1" || echo "FAIL: $1" +} + +pub BatteryLevel 80 +pub Soc 80.5 +pub ChargeAmps 32 +pub ChargerVoltage 240.5 +pub ChargeRateMilePerHour 30.5 +pub ACChargingPower 7.68 +pub EstBatteryRange 250.3 +pub IdealBatteryRange 280.1 +pub RatedRange 260.0 +pub EnergyRemaining 55.2 +pub PackVoltage 390.5 +pub TimeToFullCharge 2.5 +pub InsideTemp 22.5 +pub OutsideTemp 18.3 +pub HvacFanSpeed 3 +pub Locked true +pub SentryMode true +pub TpmsPressureFl 2.9 +pub TpmsPressureFr 3.0 +pub TpmsPressureRl 2.85 +pub TpmsPressureRr 2.95 +pub Odometer 15234.5 From b5742bed4e40e90f09c950a8c80314f9397bfca1 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 14:50:29 -0700 Subject: [PATCH 33/40] fix: remove test files with real VIN, add to gitignore Test scripts with VIN should not be in the repo. Added to .gitignore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 +++ internal/api/telemetry_handler.go | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 6a6ca91d..1982e686 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ __pycache__/ # Secrets *.pem *.key + +test_*.py +publish_test.sh diff --git a/internal/api/telemetry_handler.go b/internal/api/telemetry_handler.go index ffa815fd..f3097dad 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -313,8 +313,13 @@ func (h *TelemetryHandler) ProcessSignals(ctx context.Context, vin string, signa h.lastWriteMu.Lock() lastWrite := h.lastWriteAt[vin] - shouldWrite := time.Since(lastWrite) >= snapshotWriteInterval - if shouldWrite { + isFirstSignal := lastWrite.IsZero() + shouldWrite := !isFirstSignal && time.Since(lastWrite) >= snapshotWriteInterval + if isFirstSignal { + // First signal for this vehicle — start the throttle timer but don't write yet. + // Let the accumulator collect signals for the full interval first. + h.lastWriteAt[vin] = time.Now() + } else if shouldWrite { h.lastWriteAt[vin] = time.Now() } h.lastWriteMu.Unlock() @@ -1136,6 +1141,8 @@ func (h *TelemetryHandler) trackSecurity(ctx context.Context, vehicleID int64, s return } + log.Debug().Int64("vehicle_id", vehicleID).Bool("locked", hasLocked).Bool("sentry", hasSentry).Bool("door", hasDoor).Bool("window", hasWindow).Msg("telemetry: trackSecurity gate passed") + ev := &models.SecurityEvent{VehicleID: vehicleID} if v, ok := signals["Locked"]; ok { b := toBool(v) @@ -1243,6 +1250,8 @@ func (h *TelemetryHandler) trackSecurity(ctx context.Context, vehicleID int64, s } if err := h.securityRepo.Insert(ctx, ev); err != nil { log.Warn().Err(err).Int64("vehicle_id", vehicleID).Msg("telemetry: failed to store security event") + } else { + log.Debug().Int64("vehicle_id", vehicleID).Int64("id", ev.ID).Msg("telemetry: security event stored") } } From d17b8a24ddd1e8b38c0a07486331f8485e7ac447 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 15:34:48 -0700 Subject: [PATCH 34/40] chore: clean .gitignore, add test file exclusions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 72 ++++++++++++++++-------------------------------------- 1 file changed, 21 insertions(+), 51 deletions(-) diff --git a/.gitignore b/.gitignore index 1982e686..12a63885 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,13 @@ # Binaries -bin/ *.exe - -# Go -coverage.out -vendor/ - -# Frontend -web/node_modules/ -web/dist/ - -# Environment -.env -.env.local +*.exe~ +*.dll +*.so +*.dylib # IDE -.vscode/ .idea/ +.vscode/ *.swp *.swo @@ -24,48 +15,27 @@ web/dist/ .DS_Store Thumbs.db -# Docker volumes -postgres_data/ -grafana_data/ -mosquitto_data/ -redis_data/ - -# Temporary -tmp/ -*.log - -# Plan tracking -.plan/ - -# Helm -helm/teslasync/charts/ -*.tgz - -# Docs build output -docs/.vitepress/dist/ -docs/.vitepress/cache/ -docs/node_modules/ - -# Test coverage -*.out +# Go +vendor/ coverage/ -# Go debug -__debug_bin* -dlv - -# Python (for any scripts) -__pycache__/ -*.pyc -.venv/ - -# Terraform (future) -.terraform/ -*.tfstate* +# Node +node_modules/ +dist/ +.env.local +.env.*.local -# Secrets +# TeslaSync +certs/ *.pem *.key +.env + +# Python +__pycache__/ +# Test files test_*.py +test_*.js publish_test.sh +package-lock.json From 772f55a0df915fe75f22d681ec9d850f9f0cb6fe Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 16:06:02 -0700 Subject: [PATCH 35/40] fix: null safety for all .toFixed() calls in Energy page Added ?? 0 guards to CostComparisonCard props and all metric display values. Prevents 'Cannot read properties of undefined (toFixed)' crash. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/pages/Energy.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/web/src/pages/Energy.tsx b/web/src/pages/Energy.tsx index a7a0b820..73d5ab6b 100644 --- a/web/src/pages/Energy.tsx +++ b/web/src/pages/Energy.tsx @@ -13,7 +13,7 @@ import { ChartTooltip, axisTickSm, chartGrid } from '../components/Charts' import { useSettings } from '../hooks/useSettings' function CostComparisonCard({ label, evCost, gasCost, icon }: { label: string; evCost: number; gasCost: number; icon: React.ReactNode }) { - const savings = gasCost - evCost + const savings = (gasCost ?? 0) - (evCost ?? 0) const savingsPct = gasCost > 0 ? (savings / gasCost * 100) : 0 return ( @@ -24,17 +24,17 @@ function CostComparisonCard({ label, evCost, gasCost, icon }: { label: string; e

EV Cost

-

${evCost.toFixed(2)}

+

${(evCost ?? 0).toFixed(2)}

Gas Equivalent

-

${gasCost.toFixed(2)}

+

${(gasCost ?? 0).toFixed(2)}

- Saving ${savings.toFixed(2)} - {savingsPct.toFixed(0)}% less + Saving ${(savings ?? 0).toFixed(2)} + {(savingsPct ?? 0).toFixed(0)}% less
) @@ -153,11 +153,11 @@ export default function Energy() { {[ { label: `Cost per ${distanceUnit}`, value: `$${(totalDistance > 0 ? totalCost / convertDistance(totalDistance) : 0).toFixed(3)}`, color: 'text-neon-cyan' }, - { label: 'Cost per kWh', value: `$${costPerKwh.toFixed(3)}`, color: 'text-neon-green' }, - { label: 'Total Distance', value: `${convertDistance(totalDistance).toFixed(0)} ${distanceUnit}`, color: 'text-[var(--text-primary)]' }, + { label: 'Cost per kWh', value: `$${(costPerKwh ?? 0).toFixed(3)}`, color: 'text-neon-green' }, + { label: 'Total Distance', value: `${convertDistance(totalDistance ?? 0).toFixed(0)} ${distanceUnit}`, color: 'text-[var(--text-primary)]' }, { label: 'Sessions', value: `${sessions?.length ?? 0}`, color: 'text-neon-purple' }, - { label: 'Monthly Est.', value: `$${monthlyProjectedCost.toFixed(2)}`, color: 'text-neon-amber' }, - { label: 'Yearly Est.', value: `$${yearlyProjectedCost.toFixed(2)}`, color: 'text-neon-red' }, + { label: 'Monthly Est.', value: `$${(monthlyProjectedCost ?? 0).toFixed(2)}`, color: 'text-neon-amber' }, + { label: 'Yearly Est.', value: `$${(yearlyProjectedCost ?? 0).toFixed(2)}`, color: 'text-neon-red' }, ].map(m => ( From 975942bf734ce39f171e77f58d2503e9dd47abd8 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 16:45:56 -0700 Subject: [PATCH 36/40] fix: map motor temp/current fields to DrivetrainHealth cards Motor cards were hardcoded with null for all temps/currents. Now maps: - Front Motor: di_stator_temp_f, di_heatsink_t_f, di_inverter_t_f, di_motor_current_f - Rear Motor: di_stator_temp, di_heatsink_t_r, di_inverter_t_r, di_motor_current_r - Rear-Left: di_stator_temp_rel, di_heatsink_t_rel, di_inverter_t_rel, di_motor_current_rel - Rear-Right: di_stator_temp_rer, di_heatsink_t_rer, di_inverter_t_rer, di_motor_current_rer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- web/src/pages/DrivetrainHealth.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/pages/DrivetrainHealth.tsx b/web/src/pages/DrivetrainHealth.tsx index 9edd655d..07841c60 100644 --- a/web/src/pages/DrivetrainHealth.tsx +++ b/web/src/pages/DrivetrainHealth.tsx @@ -257,10 +257,10 @@ export default function DrivetrainHealth() { /* ── Motor positions: map available data to motor cards ── */ const motors = useMemo(() => [ - { name: 'Front Motor', stator: null, heatsink: null, inverter: null, current: null }, - { name: 'Rear Motor', stator: latest?.di_stator_temp ?? null, heatsink: null, inverter: null, current: null }, - { name: 'Rear-Left Motor', stator: null, heatsink: null, inverter: null, current: null }, - { name: 'Rear-Right Motor', stator: null, heatsink: null, inverter: null, current: null }, + { name: 'Front Motor', stator: latest?.di_stator_temp_f ?? null, heatsink: latest?.di_heatsink_t_f ?? null, inverter: latest?.di_inverter_t_f ?? null, current: latest?.di_motor_current_f ?? null }, + { name: 'Rear Motor', stator: latest?.di_stator_temp ?? null, heatsink: latest?.di_heatsink_t_r ?? null, inverter: latest?.di_inverter_t_r ?? null, current: latest?.di_motor_current_r ?? null }, + { name: 'Rear-Left Motor', stator: latest?.di_stator_temp_rel ?? null, heatsink: latest?.di_heatsink_t_rel ?? null, inverter: latest?.di_inverter_t_rel ?? null, current: latest?.di_motor_current_rel ?? null }, + { name: 'Rear-Right Motor', stator: latest?.di_stator_temp_rer ?? null, heatsink: latest?.di_heatsink_t_rer ?? null, inverter: latest?.di_inverter_t_rer ?? null, current: latest?.di_motor_current_rer ?? null }, ], [latest]) /* ── Drive state helpers ── */ From 9c318fa604938037889aaecab67effa9e7c73ca7 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 16:49:12 -0700 Subject: [PATCH 37/40] fix: parseDateRange end date includes full day (23:59:59) End date '2026-03-30' was parsed as 00:00:00, excluding sessions created later that day. Now adds 24h-1s to include the full day. Fixes Charging page showing 'No charging sessions yet' when sessions exist on the end date. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/api/helpers.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 2fa054f2..1f39f50c 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -89,6 +89,7 @@ func urlParamInt64(r *http.Request, key string) (int64, error) { } // parseDateRange extracts optional start/end date query params (format: 2006-01-02). +// End date is set to end of day (23:59:59) to include the full day. func parseDateRange(r *http.Request) (startTime, endTime time.Time) { if s := r.URL.Query().Get("start"); s != "" { if t, err := time.Parse("2006-01-02", s); err == nil { @@ -97,7 +98,7 @@ func parseDateRange(r *http.Request) (startTime, endTime time.Time) { } if s := r.URL.Query().Get("end"); s != "" { if t, err := time.Parse("2006-01-02", s); err == nil { - endTime = t + endTime = t.Add(24*time.Hour - time.Second) // end of day } } return From bd7abf0a76460573755ee09c3a647e9bc47ced88 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 17:44:35 -0700 Subject: [PATCH 38/40] chore: add test seed SQL, update gitignore - seed_snapshots.sql: realistic data for integration testing (motor temps/currents, climate, security, charging, tire pressure) - .gitignore: exclude package.json (Playwright test dependency) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 ++ seed_snapshots.sql | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 seed_snapshots.sql diff --git a/.gitignore b/.gitignore index 12a63885..e53dc55d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ test_*.py test_*.js publish_test.sh package-lock.json + +package.json diff --git a/seed_snapshots.sql b/seed_snapshots.sql new file mode 100644 index 00000000..278643fd --- /dev/null +++ b/seed_snapshots.sql @@ -0,0 +1,31 @@ +-- TeslaSync test seed data +-- All string values use single quotes (standard SQL) + +-- Motor snapshots with all temperature/current fields +INSERT INTO motor_snapshots (vehicle_id,di_state,di_stator_temp,vehicle_speed,gear, + di_stator_temp_f,di_stator_temp_rel,di_heatsink_t_f,di_heatsink_t_r, + di_inverter_t_f,di_inverter_t_r,di_motor_current_f,di_motor_current_r, + di_v_bat_f,di_v_bat_r,lateral_accel,longitudinal_accel,created_at) +VALUES (1,'standby',35.2,0,'P',34.8,33.5,28.1,27.5,30.2,29.8,0.5,0.3,390.2,390.1,0.01,0.02,NOW()); + +-- Climate snapshots +INSERT INTO climate_snapshots (vehicle_id,inside_temp,outside_temp,hvac_fan_speed, + hvac_left_temp_request,hvac_right_temp_request,cabin_overheat_mode,defrost_mode, + battery_heater_on,hvac_ac_enabled,created_at) +VALUES (1,22.5,18.3,3,21.0,22.0,'FanOnly',false,false,true,NOW()); + +-- Security events +INSERT INTO security_events (vehicle_id,locked,sentry_mode,door_state, + fd_window,fp_window,rd_window,rp_window,homelink_nearby,guest_mode,created_at) +VALUES (1,true,true,'ClosedAll','Closed','Closed','Closed','Closed',true,false,NOW()); + +-- Charging telemetry +INSERT INTO charging_telemetry (vehicle_id,battery_level,soc,charge_state,charge_amps, + charger_voltage,charger_phases,charge_rate_mph,ac_charging_power,est_battery_range, + ideal_battery_range,rated_range,energy_remaining,pack_voltage,pack_current, + time_to_full_charge,charge_limit_soc,created_at) +VALUES (1,80,80.5,'Charging',32,240.5,1,30.5,7.68,250.3,280.1,260.0,55.2,390.5,20.1,2.5,90,NOW()); + +-- Tire pressure +INSERT INTO tire_pressure_snapshots (vehicle_id,front_left,front_right,rear_left,rear_right,created_at) +VALUES (1,2.9,3.0,2.85,2.95,NOW()); From 3199e00cead0ff12c77c9873f4cc1616e3a70526 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 18:53:09 -0700 Subject: [PATCH 39/40] Update Grafana dashboards to use PostgreSQL unit conversion functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all hardcoded unit conversions and raw metric values in 26 dashboard JSON files with calls to the PostgreSQL conversion functions from migration 18: - convert_distance() for distance/range/odometer fields - convert_speed() for speed/speed_max/vehicle_speed fields - convert_temp() for inside/outside/stator/heatsink/inverter temp fields - convert_pressure() for tire pressure fields - convert_efficiency() for Wh/km computed efficiency values Also: - Replace inline CASE WHEN unit_length conversions with function calls - Wrap computed speed (distance/time) and efficiency (Wh/km) expressions - Update SQL aliases to remove hardcoded unit labels (km/h, °C, PSI, etc.) - Neutralize Grafana unit fields (celsius, velocitykmh, pressurepsi, km) to prevent double-conversion - Update byName override matchers to match new alias names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- grafana/dashboards/battery-health.json | 8 +++--- grafana/dashboards/charging-sessions.json | 4 +-- grafana/dashboards/charging-stats.json | 2 +- grafana/dashboards/charging.json | 2 +- grafana/dashboards/climate-hvac.json | 14 +++++------ grafana/dashboards/comfort-media.json | 2 +- grafana/dashboards/cost-analysis.json | 2 +- grafana/dashboards/drive-detail.json | 16 ++++++------ grafana/dashboards/drives.json | 6 ++--- grafana/dashboards/drivetrain-thermal.json | 10 ++++---- grafana/dashboards/efficiency.json | 18 +++++++------- grafana/dashboards/energy-flow.json | 6 ++--- grafana/dashboards/fleet-overview.json | 8 +++--- grafana/dashboards/locations.json | 2 +- grafana/dashboards/mileage.json | 26 ++++++++++---------- grafana/dashboards/motor-performance.json | 12 ++++----- grafana/dashboards/projected-range.json | 12 ++++----- grafana/dashboards/security-access.json | 2 +- grafana/dashboards/software-updates.json | 2 +- grafana/dashboards/statistics.json | 4 +-- grafana/dashboards/timeline.json | 2 +- grafana/dashboards/tire-pressure.json | 22 ++++++++--------- grafana/dashboards/trips.json | 4 +-- grafana/dashboards/vampire-drain.json | 4 +-- grafana/dashboards/vehicle-intelligence.json | 4 +-- grafana/dashboards/vehicle-overview.json | 26 ++++++++++---------- 26 files changed, 110 insertions(+), 110 deletions(-) diff --git a/grafana/dashboards/battery-health.json b/grafana/dashboards/battery-health.json index 350a281c..4fd07b4c 100644 --- a/grafana/dashboards/battery-health.json +++ b/grafana/dashboards/battery-health.json @@ -303,7 +303,7 @@ } ] }, - "unit": "km" + "unit": "none" } }, "gridPos": { @@ -363,7 +363,7 @@ } ] }, - "unit": "celsius" + "unit": "none" } }, "gridPos": { @@ -394,7 +394,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(avg_cell_temp_c::numeric, 1) AS \"°C\" FROM battery_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT ROUND(avg_cell_temp_c::numeric, 1) AS \"Temp\" FROM battery_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -805,4 +805,4 @@ "uid": "teslasync-battery-health", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/charging-sessions.json b/grafana/dashboards/charging-sessions.json index f4f317c1..6cdc7338 100644 --- a/grafana/dashboards/charging-sessions.json +++ b/grafana/dashboards/charging-sessions.json @@ -152,7 +152,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n cs.id AS \"🔗 ID\",\n cs.start_date AS \"📅 Date\",\n CASE\n WHEN cs.fast_charger_type IS NOT NULL AND cs.fast_charger_type != '' THEN '⚡ ' || cs.fast_charger_type\n WHEN cs.charger_power > 20 THEN '🔌 DC Fast'\n ELSE '🏠 AC Level 2'\n END AS \"🏷️ Type\",\n cs.start_battery_level AS \"🔋 Start\",\n cs.end_battery_level AS \"🔋 End\",\n (cs.end_battery_level - cs.start_battery_level) AS \"📈 Gained\",\n ROUND(cs.charge_energy_added::numeric, 1) AS \"⚡ kWh\",\n ROUND(cs.charger_power::numeric, 1) AS \"🔌 kW\",\n cs.charger_voltage AS \"⚡ V\",\n cs.charger_phases AS \"🔌 Ph\",\n COALESCE(cs.conn_charge_cable, '-') AS \"🔌 Cable\",\n ROUND(cs.duration_min::numeric) AS \"⏱️ Min\",\n ROUND(CASE WHEN cs.cost > 0 THEN cs.cost ELSE cs.charge_energy_added * (SELECT COALESCE(base_cost_per_kwh, 0.12) FROM settings WHERE id = 1) END::numeric, 2) AS \"💰 $\",\n ROUND(CASE WHEN '${unit_length}' = 'mi' THEN (cs.end_range_km - cs.start_range_km) * 0.621371 ELSE cs.end_range_km - cs.start_range_km END::numeric, 1) AS \"📏 Range+\",\n COALESCE(a.display_name, a.city, '-') AS \"📍 Location\"\nFROM charging_sessions cs\nLEFT JOIN addresses a ON cs.address_id = a.id\nWHERE cs.vehicle_id = ${vehicle_id} AND $__timeFilter(cs.start_date)\nORDER BY cs.start_date DESC\nLIMIT 200", + "rawSql": "SELECT\n cs.id AS \"🔗 ID\",\n cs.start_date AS \"📅 Date\",\n CASE\n WHEN cs.fast_charger_type IS NOT NULL AND cs.fast_charger_type != '' THEN '⚡ ' || cs.fast_charger_type\n WHEN cs.charger_power > 20 THEN '🔌 DC Fast'\n ELSE '🏠 AC Level 2'\n END AS \"🏷️ Type\",\n cs.start_battery_level AS \"🔋 Start\",\n cs.end_battery_level AS \"🔋 End\",\n (cs.end_battery_level - cs.start_battery_level) AS \"📈 Gained\",\n ROUND(cs.charge_energy_added::numeric, 1) AS \"⚡ kWh\",\n ROUND(cs.charger_power::numeric, 1) AS \"🔌 kW\",\n cs.charger_voltage AS \"⚡ V\",\n cs.charger_phases AS \"🔌 Ph\",\n COALESCE(cs.conn_charge_cable, '-') AS \"🔌 Cable\",\n ROUND(cs.duration_min::numeric) AS \"⏱️ Min\",\n ROUND(CASE WHEN cs.cost > 0 THEN cs.cost ELSE cs.charge_energy_added * (SELECT COALESCE(base_cost_per_kwh, 0.12) FROM settings WHERE id = 1) END::numeric, 2) AS \"💰 $\",\n ROUND(convert_distance(cs.end_range_km - cs.start_range_km)::numeric, 1) AS \"📏 Range+\",\n COALESCE(a.display_name, a.city, '-') AS \"📍 Location\"\nFROM charging_sessions cs\nLEFT JOIN addresses a ON cs.address_id = a.id\nWHERE cs.vehicle_id = ${vehicle_id} AND $__timeFilter(cs.start_date)\nORDER BY cs.start_date DESC\nLIMIT 200", "refId": "A" } ], @@ -778,4 +778,4 @@ "uid": "teslasync-charging-sessions", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/charging-stats.json b/grafana/dashboards/charging-stats.json index 74989411..d1f22e57 100644 --- a/grafana/dashboards/charging-stats.json +++ b/grafana/dashboards/charging-stats.json @@ -827,4 +827,4 @@ "uid": "teslasync-charging-stats", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/charging.json b/grafana/dashboards/charging.json index 227b2192..d4aeff1e 100644 --- a/grafana/dashboards/charging.json +++ b/grafana/dashboards/charging.json @@ -178,4 +178,4 @@ "uid": "teslasync-charging", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/climate-hvac.json b/grafana/dashboards/climate-hvac.json index 8b19c631..df2afc1f 100644 --- a/grafana/dashboards/climate-hvac.json +++ b/grafana/dashboards/climate-hvac.json @@ -73,7 +73,7 @@ } ] }, - "unit": "celsius" + "unit": "none" }, "overrides": [ { @@ -138,7 +138,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n created_at AS time,\n inside_temp AS \"Cabin Temp (°C)\",\n outside_temp AS \"Outside Temp (°C)\"\nFROM climate_snapshots\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\nORDER BY created_at", + "rawSql": "SELECT\n created_at AS time,\n convert_temp(inside_temp) AS \"Cabin Temp\",\n convert_temp(outside_temp) AS \"Outside Temp\"\nFROM climate_snapshots\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\nORDER BY created_at", "refId": "A" } ], @@ -347,7 +347,7 @@ } ] }, - "unit": "celsius" + "unit": "none" }, "overrides": [] }, @@ -381,7 +381,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n created_at AS time,\n inside_temp AS \"Actual Cabin\",\n hvac_left_temp_request AS \"Left Target\",\n hvac_right_temp_request AS \"Right Target\"\nFROM climate_snapshots\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\nORDER BY created_at", + "rawSql": "SELECT\n created_at AS time,\n convert_temp(inside_temp) AS \"Actual Cabin\",\n convert_temp(hvac_left_temp_request) AS \"Left Target\",\n convert_temp(hvac_right_temp_request) AS \"Right Target\"\nFROM climate_snapshots\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\nORDER BY created_at", "refId": "A" } ], @@ -476,7 +476,7 @@ } ] }, - "unit": "celsius", + "unit": "none", "min": -10, "max": 50 }, @@ -510,7 +510,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n AVG(inside_temp) AS \"Avg Cabin Temp\"\nFROM climate_snapshots\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)", + "rawSql": "SELECT\n convert_temp(AVG(inside_temp)) AS \"Avg Cabin Temp\"\nFROM climate_snapshots\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)", "refId": "A" } ], @@ -732,4 +732,4 @@ "uid": "teslasync-climate-hvac", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/comfort-media.json b/grafana/dashboards/comfort-media.json index 310faf5c..361161b8 100644 --- a/grafana/dashboards/comfort-media.json +++ b/grafana/dashboards/comfort-media.json @@ -694,4 +694,4 @@ "uid": "teslasync-comfort-media", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/cost-analysis.json b/grafana/dashboards/cost-analysis.json index 3c91e5e5..b41598fd 100644 --- a/grafana/dashboards/cost-analysis.json +++ b/grafana/dashboards/cost-analysis.json @@ -630,4 +630,4 @@ "uid": "teslasync-cost-analysis", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/drive-detail.json b/grafana/dashboards/drive-detail.json index 4ee2e955..6cd6e66f 100644 --- a/grafana/dashboards/drive-detail.json +++ b/grafana/dashboards/drive-detail.json @@ -88,7 +88,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n ROUND(distance::numeric, 1) AS \"🛣️ Distance (km)\",\n ROUND(duration_min::numeric) AS \"⏱️ Duration (min)\",\n ROUND(speed_max::numeric) AS \"🏎️ Max Speed (km/h)\",\n start_battery_level AS \"🔋 Start %\",\n end_battery_level AS \"🔋 End %\",\n ROUND(outside_temp_avg::numeric, 1) AS \"🌡️ Temp (°C)\"\nFROM drives WHERE id = ${drive_id}", + "rawSql": "SELECT\n ROUND(convert_distance(distance)::numeric, 1) AS \"🛣️ Distance\",\n ROUND(duration_min::numeric) AS \"⏱️ Duration (min)\",\n ROUND(convert_speed(speed_max)::numeric) AS \"🏎️ Max Speed\",\n start_battery_level AS \"🔋 Start %\",\n end_battery_level AS \"🔋 End %\",\n ROUND(convert_temp(outside_temp_avg)::numeric, 1) AS \"🌡️ Temp\"\nFROM drives WHERE id = ${drive_id}", "refId": "A" } ], @@ -127,7 +127,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT p.latitude, p.longitude, p.speed, p.battery_level, p.created_at AS time FROM positions p JOIN drives d ON p.vehicle_id = d.vehicle_id AND p.created_at >= d.start_date AND p.created_at <= COALESCE(d.end_date, NOW()) WHERE d.id = ${drive_id} ORDER BY p.created_at", + "rawSql": "SELECT p.latitude, p.longitude, convert_speed(p.speed) AS speed, p.battery_level, p.created_at AS time FROM positions p JOIN drives d ON p.vehicle_id = d.vehicle_id AND p.created_at >= d.start_date AND p.created_at <= COALESCE(d.end_date, NOW()) WHERE d.id = ${drive_id} ORDER BY p.created_at", "refId": "A" } ], @@ -202,7 +202,7 @@ "color": { "mode": "palette-classic" }, - "unit": "velocitykmh" + "unit": "none" }, "overrides": [] }, @@ -233,7 +233,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT p.created_at AS time, p.speed AS \"Speed (km/h)\"\nFROM positions p\nJOIN drives d ON p.vehicle_id = d.vehicle_id AND p.created_at >= d.start_date AND p.created_at <= COALESCE(d.end_date, NOW())\nWHERE d.id = ${drive_id}\nORDER BY p.created_at", + "rawSql": "SELECT p.created_at AS time, convert_speed(p.speed) AS \"Speed\"\nFROM positions p\nJOIN drives d ON p.vehicle_id = d.vehicle_id AND p.created_at >= d.start_date AND p.created_at <= COALESCE(d.end_date, NOW())\nWHERE d.id = ${drive_id}\nORDER BY p.created_at", "refId": "A" } ], @@ -300,7 +300,7 @@ "color": { "mode": "palette-classic" }, - "unit": "celsius" + "unit": "none" }, "overrides": [] }, @@ -331,7 +331,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT p.created_at AS time, p.inside_temp AS \"Cabin (°C)\", p.outside_temp AS \"Outside (°C)\"\nFROM positions p\nJOIN drives d ON p.vehicle_id = d.vehicle_id AND p.created_at >= d.start_date AND p.created_at <= COALESCE(d.end_date, NOW())\nWHERE d.id = ${drive_id}\nORDER BY p.created_at", + "rawSql": "SELECT p.created_at AS time, convert_temp(p.inside_temp) AS \"Cabin\", convert_temp(p.outside_temp) AS \"Outside\"\nFROM positions p\nJOIN drives d ON p.vehicle_id = d.vehicle_id AND p.created_at >= d.start_date AND p.created_at <= COALESCE(d.end_date, NOW())\nWHERE d.id = ${drive_id}\nORDER BY p.created_at", "refId": "A" } ], @@ -428,7 +428,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT m.created_at AS time, m.di_torque AS \"Torque (Nm)\", m.vehicle_speed AS \"Speed (km/h)\"\nFROM motor_snapshots m\nJOIN drives d ON m.vehicle_id = d.vehicle_id AND m.created_at >= d.start_date AND m.created_at <= COALESCE(d.end_date, NOW())\nWHERE d.id = ${drive_id}\nORDER BY m.created_at", + "rawSql": "SELECT m.created_at AS time, m.di_torque AS \"Torque (Nm)\", convert_speed(m.vehicle_speed) AS \"Speed\"\nFROM motor_snapshots m\nJOIN drives d ON m.vehicle_id = d.vehicle_id AND m.created_at >= d.start_date AND m.created_at <= COALESCE(d.end_date, NOW())\nWHERE d.id = ${drive_id}\nORDER BY m.created_at", "refId": "A" } ], @@ -573,4 +573,4 @@ "uid": "teslasync-drive-detail", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/drives.json b/grafana/dashboards/drives.json index 18a2d409..be11bfa1 100644 --- a/grafana/dashboards/drives.json +++ b/grafana/dashboards/drives.json @@ -71,7 +71,7 @@ { "matcher": { "id": "byName", - "options": "\ud83d\udcc5 Date" + "options": "📅 Date" }, "properties": [ { @@ -153,7 +153,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n d.id AS \"🔗 ID\",\n d.start_date AS \"📅 Date\",\n CASE\n WHEN d.distance > 100 THEN '🛣️ Road Trip'\n WHEN d.distance > 30 THEN '🚗 Drive'\n WHEN d.distance > 0 THEN '🏙️ Short'\n ELSE '🅿️ Parked'\n END AS \"🏷️ Type\",\n ROUND(d.distance::numeric, 1) AS \"📏 Distance\",\n ROUND(d.duration_min::numeric) AS \"⏱️ Duration\",\n ROUND((d.distance / NULLIF(d.duration_min, 0) * 60)::numeric, 1) AS \"🚗 Avg Speed\",\n ROUND(d.speed_max::numeric) AS \"🏎️ Max Speed\",\n CASE\n WHEN d.speed_max > 130 THEN '🔴'\n WHEN d.speed_max > 100 THEN '🟡'\n ELSE '🟢'\n END AS \"🚦 Speed\",\n d.start_battery_level AS \"🔋 Start %\",\n d.end_battery_level AS \"🔋 End %\",\n CASE\n WHEN (d.start_battery_level - COALESCE(d.end_battery_level, d.start_battery_level)) > 20 THEN '⚠️ High'\n WHEN (d.start_battery_level - COALESCE(d.end_battery_level, d.start_battery_level)) > 10 THEN '📊 Medium'\n ELSE '✅ Low'\n END AS \"📊 Usage\",\n ROUND(d.start_range_km::numeric) AS \"📏 Range Start\",\n ROUND(d.end_range_km::numeric) AS \"📏 Range End\",\n ROUND(d.outside_temp_avg::numeric, 1) AS \"🌡️ Temp °C\"\nFROM drives d\nWHERE d.vehicle_id = ${vehicle_id}\n AND $__timeFilter(d.start_date)\nORDER BY d.start_date DESC\nLIMIT 200", + "rawSql": "SELECT\n d.id AS \"🔗 ID\",\n d.start_date AS \"📅 Date\",\n CASE\n WHEN d.distance > 100 THEN '🛣️ Road Trip'\n WHEN d.distance > 30 THEN '🚗 Drive'\n WHEN d.distance > 0 THEN '🏙️ Short'\n ELSE '🅿️ Parked'\n END AS \"🏷️ Type\",\n ROUND(convert_distance(d.distance)::numeric, 1) AS \"📏 Distance\",\n ROUND(d.duration_min::numeric) AS \"⏱️ Duration\",\n ROUND(convert_speed(d.distance / NULLIF(d.duration_min, 0) * 60)::numeric, 1) AS \"🚗 Avg Speed\",\n ROUND(convert_speed(d.speed_max)::numeric) AS \"🏎️ Max Speed\",\n CASE\n WHEN d.speed_max > 130 THEN '🔴'\n WHEN d.speed_max > 100 THEN '🟡'\n ELSE '🟢'\n END AS \"🚦 Speed\",\n d.start_battery_level AS \"🔋 Start %\",\n d.end_battery_level AS \"🔋 End %\",\n CASE\n WHEN (d.start_battery_level - COALESCE(d.end_battery_level, d.start_battery_level)) > 20 THEN '⚠️ High'\n WHEN (d.start_battery_level - COALESCE(d.end_battery_level, d.start_battery_level)) > 10 THEN '📊 Medium'\n ELSE '✅ Low'\n END AS \"📊 Usage\",\n ROUND(convert_distance(d.start_range_km)::numeric) AS \"📏 Range Start\",\n ROUND(convert_distance(d.end_range_km)::numeric) AS \"📏 Range End\",\n ROUND(convert_temp(d.outside_temp_avg)::numeric, 1) AS \"🌡️ Temp\"\nFROM drives d\nWHERE d.vehicle_id = ${vehicle_id}\n AND $__timeFilter(d.start_date)\nORDER BY d.start_date DESC\nLIMIT 200", "refId": "A" } ], @@ -255,4 +255,4 @@ "uid": "teslasync-drives", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/drivetrain-thermal.json b/grafana/dashboards/drivetrain-thermal.json index 921f9da8..d54242b6 100644 --- a/grafana/dashboards/drivetrain-thermal.json +++ b/grafana/dashboards/drivetrain-thermal.json @@ -85,7 +85,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT di_stator_temp_f AS \"Front Stator °C\", di_stator_temp_r AS \"Rear Stator °C\", di_heatsink_t_f AS \"Front Heatsink °C\", di_heatsink_t_r AS \"Rear Heatsink °C\", di_inverter_t_f AS \"Front Inverter °C\", di_inverter_t_r AS \"Rear Inverter °C\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT convert_temp(di_stator_temp_f) AS \"Front Stator\", convert_temp(di_stator_temp_r) AS \"Rear Stator\", convert_temp(di_heatsink_t_f) AS \"Front Heatsink\", convert_temp(di_heatsink_t_r) AS \"Rear Heatsink\", convert_temp(di_inverter_t_f) AS \"Front Inverter\", convert_temp(di_inverter_t_r) AS \"Rear Inverter\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -165,7 +165,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT created_at AS time, di_stator_temp_f AS \"Front Stator\", di_stator_temp_r AS \"Rear Stator\", di_stator_temp_rel AS \"Rear L Stator\", di_stator_temp_rer AS \"Rear R Stator\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", + "rawSql": "SELECT created_at AS time, convert_temp(di_stator_temp_f) AS \"Front Stator\", convert_temp(di_stator_temp_r) AS \"Rear Stator\", convert_temp(di_stator_temp_rel) AS \"Rear L Stator\", convert_temp(di_stator_temp_rer) AS \"Rear R Stator\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", "refId": "A" } ], @@ -245,7 +245,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT created_at AS time, di_heatsink_t_f AS \"Front Heatsink\", di_heatsink_t_r AS \"Rear Heatsink\", di_heatsink_t_rel AS \"Rear L Heatsink\", di_heatsink_t_rer AS \"Rear R Heatsink\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", + "rawSql": "SELECT created_at AS time, convert_temp(di_heatsink_t_f) AS \"Front Heatsink\", convert_temp(di_heatsink_t_r) AS \"Rear Heatsink\", convert_temp(di_heatsink_t_rel) AS \"Rear L Heatsink\", convert_temp(di_heatsink_t_rer) AS \"Rear R Heatsink\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", "refId": "A" } ], @@ -325,7 +325,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT created_at AS time, di_inverter_t_f AS \"Front Inverter\", di_inverter_t_r AS \"Rear Inverter\", di_inverter_t_rel AS \"Rear L Inverter\", di_inverter_t_rer AS \"Rear R Inverter\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", + "rawSql": "SELECT created_at AS time, convert_temp(di_inverter_t_f) AS \"Front Inverter\", convert_temp(di_inverter_t_r) AS \"Rear Inverter\", convert_temp(di_inverter_t_rel) AS \"Rear L Inverter\", convert_temp(di_inverter_t_rer) AS \"Rear R Inverter\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", "refId": "A" } ], @@ -587,4 +587,4 @@ "uid": "teslasync-drivetrain-thermal", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/efficiency.json b/grafana/dashboards/efficiency.json index af7c9cca..118b4daf 100644 --- a/grafana/dashboards/efficiency.json +++ b/grafana/dashboards/efficiency.json @@ -85,7 +85,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(((SUM(start_range_km - end_range_km) * 1000) / NULLIF(SUM(distance), 0))::numeric, 1) AS \"⚡ Wh/km\"\nFROM drives WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 1", + "rawSql": "SELECT ROUND(convert_efficiency((SUM(start_range_km - end_range_km) * 1000) / NULLIF(SUM(distance), 0))::numeric, 1) AS \"⚡ Efficiency\"\nFROM drives WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 1", "refId": "A" } ], @@ -144,7 +144,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(MIN(((start_range_km - end_range_km) * 1000 / NULLIF(distance, 0)))::numeric, 1) AS \"⚡ Wh/km\"\nFROM drives WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 5", + "rawSql": "SELECT ROUND(MIN((convert_efficiency((start_range_km - end_range_km) * 1000 / NULLIF(distance, 0))))::numeric, 1) AS \"⚡ Efficiency\"\nFROM drives WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 5", "refId": "A" } ], @@ -203,7 +203,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(MAX(((start_range_km - end_range_km) * 1000 / NULLIF(distance, 0)))::numeric, 1) AS \"⚡ Wh/km\"\nFROM drives WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 5", + "rawSql": "SELECT ROUND(MAX((convert_efficiency((start_range_km - end_range_km) * 1000 / NULLIF(distance, 0))))::numeric, 1) AS \"⚡ Efficiency\"\nFROM drives WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 5", "refId": "A" } ], @@ -232,7 +232,7 @@ } ] }, - "unit": "celsius" + "unit": "none" } }, "gridPos": { @@ -263,7 +263,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(AVG(outside_temp_avg)::numeric, 1) AS \"°C\"\nFROM drives WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND outside_temp_avg IS NOT NULL", + "rawSql": "SELECT ROUND(convert_temp(AVG(outside_temp_avg))::numeric, 1) AS \"Temp\"\nFROM drives WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND outside_temp_avg IS NOT NULL", "refId": "A" } ], @@ -343,7 +343,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n $__timeGroup(start_date, '1d') AS time,\n ROUND(((SUM(start_range_km - end_range_km) * 1000) / NULLIF(SUM(distance), 0))::numeric, 1) AS \"⚡ Wh/km\"\nFROM drives\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 1\nGROUP BY time ORDER BY time", + "rawSql": "SELECT\n $__timeGroup(start_date, '1d') AS time,\n ROUND(convert_efficiency((SUM(start_range_km - end_range_km) * 1000) / NULLIF(SUM(distance), 0))::numeric, 1) AS \"⚡ Efficiency\"\nFROM drives\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 1\nGROUP BY time ORDER BY time", "refId": "A" } ], @@ -423,7 +423,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n start_date AS time,\n ROUND((distance / NULLIF(duration_min, 0) * 60)::numeric, 1) AS \"🚗 Avg Speed\",\n ROUND(((start_range_km - end_range_km) * 1000 / NULLIF(distance, 0))::numeric, 1) AS \"⚡ Wh/km\"\nFROM drives\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 5\nORDER BY start_date", + "rawSql": "SELECT\n start_date AS time,\n ROUND(convert_speed(distance / NULLIF(duration_min, 0) * 60)::numeric, 1) AS \"🚗 Avg Speed\",\n ROUND((convert_efficiency((start_range_km - end_range_km) * 1000 / NULLIF(distance, 0)))::numeric, 1) AS \"⚡ Efficiency\"\nFROM drives\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 5\nORDER BY start_date", "refId": "A" } ], @@ -503,7 +503,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n start_date AS time,\n ROUND(outside_temp_avg::numeric, 1) AS \"🌡️ Temp\",\n ROUND(((start_range_km - end_range_km) * 1000 / NULLIF(distance, 0))::numeric, 1) AS \"⚡ Wh/km\"\nFROM drives\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 5 AND outside_temp_avg IS NOT NULL\nORDER BY start_date", + "rawSql": "SELECT\n start_date AS time,\n ROUND(convert_temp(outside_temp_avg)::numeric, 1) AS \"🌡️ Temp\",\n ROUND((convert_efficiency((start_range_km - end_range_km) * 1000 / NULLIF(distance, 0)))::numeric, 1) AS \"⚡ Efficiency\"\nFROM drives\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(start_date) AND distance > 5 AND outside_temp_avg IS NOT NULL\nORDER BY start_date", "refId": "A" } ], @@ -605,4 +605,4 @@ "uid": "teslasync-efficiency", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/energy-flow.json b/grafana/dashboards/energy-flow.json index e2e487a5..fa23ac2a 100644 --- a/grafana/dashboards/energy-flow.json +++ b/grafana/dashboards/energy-flow.json @@ -325,7 +325,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT created_at AS time, battery_level AS \"SOC %\", est_battery_range AS \"Est Range\", rated_range AS \"Rated Range\" FROM charging_telemetry WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", + "rawSql": "SELECT created_at AS time, battery_level AS \"SOC %\", convert_distance(est_battery_range) AS \"Est Range\", convert_distance(rated_range) AS \"Rated Range\" FROM charging_telemetry WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", "refId": "A" } ], @@ -485,7 +485,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT created_at AS time, module_temp_max AS \"Max Module °C\", module_temp_min AS \"Min Module °C\" FROM charging_telemetry WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) AND module_temp_max IS NOT NULL ORDER BY created_at", + "rawSql": "SELECT created_at AS time, convert_temp(module_temp_max) AS \"Max Module Temp\", convert_temp(module_temp_min) AS \"Min Module Temp\" FROM charging_telemetry WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) AND module_temp_max IS NOT NULL ORDER BY created_at", "refId": "A" } ], @@ -705,4 +705,4 @@ "uid": "teslasync-energy-flow", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/fleet-overview.json b/grafana/dashboards/fleet-overview.json index 81d037b2..76a6570d 100644 --- a/grafana/dashboards/fleet-overview.json +++ b/grafana/dashboards/fleet-overview.json @@ -76,7 +76,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n v.display_name AS \"🚗 Vehicle\",\n v.model AS \"🚗 Model\",\n v.state AS \"📊 State\",\n (SELECT ROUND(p.odometer::numeric, 1) FROM positions p WHERE p.vehicle_id = v.id ORDER BY p.created_at DESC LIMIT 1) AS \"📏 Odometer\",\n (SELECT p.battery_level FROM positions p WHERE p.vehicle_id = v.id ORDER BY p.created_at DESC LIMIT 1) AS \"🔋 Battery %\",\n (SELECT COUNT(*) FROM drives d WHERE d.vehicle_id = v.id) AS \"🛣️ Total Drives\",\n (SELECT ROUND(SUM(d2.distance)::numeric, 1) FROM drives d2 WHERE d2.vehicle_id = v.id) AS \"📏 Total Dist\",\n (SELECT COUNT(*) FROM charging_sessions cs WHERE cs.vehicle_id = v.id) AS \"⚡ Charges\",\n (SELECT ROUND(SUM(cs2.charge_energy_added)::numeric, 1) FROM charging_sessions cs2 WHERE cs2.vehicle_id = v.id) AS \"⚡ Total kWh\",\n (SELECT su.version FROM software_updates su WHERE su.vehicle_id = v.id AND su.status = 'installed' ORDER BY su.installed_at DESC LIMIT 1) AS \"📦 Version\"\nFROM vehicles v\nORDER BY v.display_name", + "rawSql": "SELECT\n v.display_name AS \"🚗 Vehicle\",\n v.model AS \"🚗 Model\",\n v.state AS \"📊 State\",\n (SELECT ROUND(convert_distance(p.odometer)::numeric, 1) FROM positions p WHERE p.vehicle_id = v.id ORDER BY p.created_at DESC LIMIT 1) AS \"📏 Odometer\",\n (SELECT p.battery_level FROM positions p WHERE p.vehicle_id = v.id ORDER BY p.created_at DESC LIMIT 1) AS \"🔋 Battery %\",\n (SELECT COUNT(*) FROM drives d WHERE d.vehicle_id = v.id) AS \"🛣️ Total Drives\",\n (SELECT ROUND(convert_distance(SUM(d2.distance))::numeric, 1) FROM drives d2 WHERE d2.vehicle_id = v.id) AS \"📏 Total Dist\",\n (SELECT COUNT(*) FROM charging_sessions cs WHERE cs.vehicle_id = v.id) AS \"⚡ Charges\",\n (SELECT ROUND(SUM(cs2.charge_energy_added)::numeric, 1) FROM charging_sessions cs2 WHERE cs2.vehicle_id = v.id) AS \"⚡ Total kWh\",\n (SELECT su.version FROM software_updates su WHERE su.vehicle_id = v.id AND su.status = 'installed' ORDER BY su.installed_at DESC LIMIT 1) AS \"📦 Version\"\nFROM vehicles v\nORDER BY v.display_name", "refId": "A" } ], @@ -114,7 +114,7 @@ } ] }, - "unit": "km" + "unit": "none" }, "overrides": [] }, @@ -145,7 +145,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT v.display_name AS \"🚗 Vehicle\", ROUND(SUM(d.distance)::numeric, 1) AS \"📏 Distance\"\nFROM drives d JOIN vehicles v ON d.vehicle_id = v.id\nGROUP BY v.display_name ORDER BY 2 DESC", + "rawSql": "SELECT v.display_name AS \"🚗 Vehicle\", ROUND(convert_distance(SUM(d.distance))::numeric, 1) AS \"📏 Distance\"\nFROM drives d JOIN vehicles v ON d.vehicle_id = v.id\nGROUP BY v.display_name ORDER BY 2 DESC", "refId": "A" } ], @@ -316,4 +316,4 @@ "uid": "teslasync-fleet-overview", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/locations.json b/grafana/dashboards/locations.json index b1fc4be4..3e57898b 100644 --- a/grafana/dashboards/locations.json +++ b/grafana/dashboards/locations.json @@ -606,4 +606,4 @@ "uid": "teslasync-locations", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/mileage.json b/grafana/dashboards/mileage.json index 26a18287..54749ead 100644 --- a/grafana/dashboards/mileage.json +++ b/grafana/dashboards/mileage.json @@ -55,7 +55,7 @@ } ] }, - "unit": "km" + "unit": "none" } }, "gridPos": { @@ -86,7 +86,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(CASE WHEN '${unit_length}' = 'mi' THEN SUM(distance_km) * 0.621371 ELSE SUM(distance_km) END::numeric, 1) AS \"Distance\" FROM daily_mileage WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)", + "rawSql": "SELECT ROUND(convert_distance(SUM(distance_km))::numeric, 1) AS \"Distance\" FROM daily_mileage WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)", "refId": "A" } ], @@ -115,7 +115,7 @@ } ] }, - "unit": "km" + "unit": "none" } }, "gridPos": { @@ -146,7 +146,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(CASE WHEN '${unit_length}' = 'mi' THEN AVG(distance_km) * 0.621371 ELSE AVG(distance_km) END::numeric, 1) AS \"Distance\" FROM daily_mileage WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date) AND distance_km > 0", + "rawSql": "SELECT ROUND(convert_distance(AVG(distance_km))::numeric, 1) AS \"Distance\" FROM daily_mileage WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date) AND distance_km > 0", "refId": "A" } ], @@ -175,7 +175,7 @@ } ] }, - "unit": "km" + "unit": "none" } }, "gridPos": { @@ -206,7 +206,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(CASE WHEN '${unit_length}' = 'mi' THEN MAX(distance_km) * 0.621371 ELSE MAX(distance_km) END::numeric, 1) AS \"Distance\" FROM daily_mileage WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)", + "rawSql": "SELECT ROUND(convert_distance(MAX(distance_km))::numeric, 1) AS \"Distance\" FROM daily_mileage WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)", "refId": "A" } ], @@ -312,7 +312,7 @@ } ] }, - "unit": "km" + "unit": "none" }, "overrides": [] }, @@ -346,7 +346,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n date::timestamptz AS time,\n CASE WHEN '${unit_length}' = 'mi' THEN ROUND((distance_km * 0.621371)::numeric, 1) ELSE ROUND(distance_km::numeric, 1) END AS \"📏 Distance\"\nFROM daily_mileage\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)\nORDER BY date", + "rawSql": "SELECT\n date::timestamptz AS time,\n ROUND(convert_distance(distance_km)::numeric, 1) AS \"📏 Distance\"\nFROM daily_mileage\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)\nORDER BY date", "refId": "A" } ], @@ -393,7 +393,7 @@ } ] }, - "unit": "km" + "unit": "none" }, "overrides": [] }, @@ -427,7 +427,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n date::timestamptz AS time,\n CASE WHEN '${unit_length}' = 'mi' THEN ROUND((SUM(distance_km) OVER (ORDER BY date) * 0.621371)::numeric, 1) ELSE ROUND((SUM(distance_km) OVER (ORDER BY date))::numeric, 1) END AS \"Cumulative\"\nFROM daily_mileage\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)\nORDER BY date", + "rawSql": "SELECT\n date::timestamptz AS time,\n ROUND(convert_distance(SUM(distance_km) OVER (ORDER BY date))::numeric, 1) AS \"Cumulative\"\nFROM daily_mileage\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)\nORDER BY date", "refId": "A" } ], @@ -465,7 +465,7 @@ } ] }, - "unit": "km" + "unit": "none" }, "overrides": [] }, @@ -496,7 +496,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n TO_CHAR(DATE_TRUNC('month', date), 'YYYY-MM') AS \"📅 Month\",\n ROUND(CASE WHEN '${unit_length}' = 'mi' THEN SUM(distance_km) * 0.621371 ELSE SUM(distance_km) END::numeric, 1) AS \"📏 Distance\"\nFROM daily_mileage\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)\nGROUP BY DATE_TRUNC('month', date)\nORDER BY DATE_TRUNC('month', date)", + "rawSql": "SELECT\n TO_CHAR(DATE_TRUNC('month', date), 'YYYY-MM') AS \"📅 Month\",\n ROUND(convert_distance(SUM(distance_km))::numeric, 1) AS \"📏 Distance\"\nFROM daily_mileage\nWHERE vehicle_id = ${vehicle_id} AND $__timeFilter(date)\nGROUP BY DATE_TRUNC('month', date)\nORDER BY DATE_TRUNC('month', date)", "refId": "A" } ], @@ -598,4 +598,4 @@ "uid": "teslasync-mileage", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/motor-performance.json b/grafana/dashboards/motor-performance.json index 482dc09d..a3b230c7 100644 --- a/grafana/dashboards/motor-performance.json +++ b/grafana/dashboards/motor-performance.json @@ -233,7 +233,7 @@ } ] }, - "unit": "celsius" + "unit": "none" }, "overrides": [] }, @@ -267,7 +267,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT created_at AS time, di_stator_temp AS \"Stator Temp (°C)\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", + "rawSql": "SELECT created_at AS time, convert_temp(di_stator_temp) AS \"Stator Temp\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", "refId": "A" } ], @@ -475,7 +475,7 @@ } ] }, - "unit": "velocitykmh" + "unit": "none" }, "overrides": [] }, @@ -509,7 +509,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT created_at AS time, vehicle_speed AS \"Speed (km/h)\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", + "rawSql": "SELECT created_at AS time, convert_speed(vehicle_speed) AS \"Speed\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at", "refId": "A" } ], @@ -633,7 +633,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT AVG(di_torque) AS \"Avg Torque (Nm)\", MAX(di_torque) AS \"Peak Torque (Nm)\", AVG(di_stator_temp) AS \"Avg Stator Temp (°C)\", MAX(lateral_accel) AS \"Peak Lateral G\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at)", + "rawSql": "SELECT AVG(di_torque) AS \"Avg Torque (Nm)\", MAX(di_torque) AS \"Peak Torque (Nm)\", convert_temp(AVG(di_stator_temp)) AS \"Avg Stator Temp\", MAX(lateral_accel) AS \"Peak Lateral G\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at)", "refId": "A" } ], @@ -736,4 +736,4 @@ "uid": "teslasync-motor-performance", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/projected-range.json b/grafana/dashboards/projected-range.json index a44da8e9..134f37bc 100644 --- a/grafana/dashboards/projected-range.json +++ b/grafana/dashboards/projected-range.json @@ -73,7 +73,7 @@ } ] }, - "unit": "km" + "unit": "none" }, "overrides": [] }, @@ -107,7 +107,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n created_at AS time,\n CASE WHEN '${unit_length}' = 'mi' THEN ROUND((rated_range * 0.621371)::numeric, 1) ELSE ROUND(rated_range::numeric, 1) END AS \"Rated Range\"\nFROM positions\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\n AND rated_range IS NOT NULL\n AND battery_level >= 95\nORDER BY created_at", + "rawSql": "SELECT\n created_at AS time,\n ROUND(convert_distance(rated_range)::numeric, 1) AS \"Rated Range\"\nFROM positions\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\n AND rated_range IS NOT NULL\n AND battery_level >= 95\nORDER BY created_at", "refId": "A" } ], @@ -187,7 +187,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n created_at AS time,\n battery_level AS \"🔋 Battery %\",\n rated_range AS \"Rated Range km\"\nFROM positions\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\n AND rated_range IS NOT NULL\nORDER BY created_at", + "rawSql": "SELECT\n created_at AS time,\n battery_level AS \"🔋 Battery %\",\n convert_distance(rated_range) AS \"Rated Range\"\nFROM positions\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\n AND rated_range IS NOT NULL\nORDER BY created_at", "refId": "A" } ], @@ -234,7 +234,7 @@ } ] }, - "unit": "km" + "unit": "none" }, "overrides": [] }, @@ -268,7 +268,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n $__timeGroup(created_at, '1d') AS time,\n ROUND(AVG(rated_range / NULLIF(battery_level, 0) * 100)::numeric, 1) AS \"Projected Range km\"\nFROM positions\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\n AND rated_range IS NOT NULL\n AND battery_level > 10\nGROUP BY time\nORDER BY time", + "rawSql": "SELECT\n $__timeGroup(created_at, '1d') AS time,\n ROUND(convert_distance(AVG(rated_range / NULLIF(battery_level, 0) * 100))::numeric, 1) AS \"Projected Range\"\nFROM positions\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\n AND rated_range IS NOT NULL\n AND battery_level > 10\nGROUP BY time\nORDER BY time", "refId": "A" } ], @@ -370,4 +370,4 @@ "uid": "teslasync-projected-range", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/security-access.json b/grafana/dashboards/security-access.json index 55262423..c3a98a51 100644 --- a/grafana/dashboards/security-access.json +++ b/grafana/dashboards/security-access.json @@ -632,4 +632,4 @@ "uid": "teslasync-security-access", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/software-updates.json b/grafana/dashboards/software-updates.json index 7717d868..a8263054 100644 --- a/grafana/dashboards/software-updates.json +++ b/grafana/dashboards/software-updates.json @@ -178,4 +178,4 @@ "uid": "teslasync-software-updates", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/statistics.json b/grafana/dashboards/statistics.json index 6c6422a0..bec74664 100644 --- a/grafana/dashboards/statistics.json +++ b/grafana/dashboards/statistics.json @@ -76,7 +76,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "WITH monthly_drives AS (\n SELECT\n DATE_TRUNC('month', d.start_date) AS month,\n d.vehicle_id,\n COUNT(*) AS drives,\n ROUND(SUM(d.distance)::numeric, 1) AS distance_km,\n ROUND(SUM(d.duration_min)::numeric) AS duration_min,\n ROUND((SUM(d.distance) / NULLIF(SUM(d.duration_min), 0) * 60)::numeric, 1) AS avg_speed,\n ROUND(MAX(d.speed_max)::numeric) AS top_speed\n FROM drives d\n WHERE d.vehicle_id = ${vehicle_id}\n GROUP BY DATE_TRUNC('month', d.start_date), d.vehicle_id\n),\nmonthly_charges AS (\n SELECT\n DATE_TRUNC('month', cs.start_date) AS month,\n cs.vehicle_id,\n COUNT(*) AS charges,\n ROUND(SUM(cs.charge_energy_added)::numeric, 1) AS energy_kwh,\n ROUND(SUM(CASE WHEN cs.cost IS NOT NULL THEN cs.cost END)::numeric, 2) AS cost\n FROM charging_sessions cs\n WHERE cs.vehicle_id = ${vehicle_id}\n GROUP BY DATE_TRUNC('month', cs.start_date), cs.vehicle_id\n)\nSELECT\n TO_CHAR(md.month, 'YYYY-MM') AS \"📅 Month\",\n md.drives AS \"🛣️ Drives\",\n md.distance_km AS \"📏 Distance\",\n md.duration_min AS \"⏱️ Duration\",\n md.avg_speed AS \"🚗 Avg Speed\",\n md.top_speed AS \"🏎️ Top Speed\",\n COALESCE(mc.charges, 0) AS \"⚡ Charges\",\n mc.energy_kwh AS \"⚡ Energy\",\n mc.cost AS \"💰 Cost\"\nFROM monthly_drives md\nLEFT JOIN monthly_charges mc ON mc.month = md.month AND mc.vehicle_id = md.vehicle_id\nORDER BY md.month DESC", + "rawSql": "WITH monthly_drives AS (\n SELECT\n DATE_TRUNC('month', d.start_date) AS month,\n d.vehicle_id,\n COUNT(*) AS drives,\n ROUND(convert_distance(SUM(d.distance))::numeric, 1) AS distance_km,\n ROUND(SUM(d.duration_min)::numeric) AS duration_min,\n ROUND(convert_speed(SUM(d.distance) / NULLIF(SUM(d.duration_min), 0) * 60)::numeric, 1) AS avg_speed,\n ROUND(convert_speed(MAX(d.speed_max))::numeric) AS top_speed\n FROM drives d\n WHERE d.vehicle_id = ${vehicle_id}\n GROUP BY DATE_TRUNC('month', d.start_date), d.vehicle_id\n),\nmonthly_charges AS (\n SELECT\n DATE_TRUNC('month', cs.start_date) AS month,\n cs.vehicle_id,\n COUNT(*) AS charges,\n ROUND(SUM(cs.charge_energy_added)::numeric, 1) AS energy_kwh,\n ROUND(SUM(CASE WHEN cs.cost IS NOT NULL THEN cs.cost END)::numeric, 2) AS cost\n FROM charging_sessions cs\n WHERE cs.vehicle_id = ${vehicle_id}\n GROUP BY DATE_TRUNC('month', cs.start_date), cs.vehicle_id\n)\nSELECT\n TO_CHAR(md.month, 'YYYY-MM') AS \"📅 Month\",\n md.drives AS \"🛣️ Drives\",\n md.distance_km AS \"📏 Distance\",\n md.duration_min AS \"⏱️ Duration\",\n md.avg_speed AS \"🚗 Avg Speed\",\n md.top_speed AS \"🏎️ Top Speed\",\n COALESCE(mc.charges, 0) AS \"⚡ Charges\",\n mc.energy_kwh AS \"⚡ Energy\",\n mc.cost AS \"💰 Cost\"\nFROM monthly_drives md\nLEFT JOIN monthly_charges mc ON mc.month = md.month AND mc.vehicle_id = md.vehicle_id\nORDER BY md.month DESC", "refId": "A" } ], @@ -178,4 +178,4 @@ "uid": "teslasync-statistics", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/timeline.json b/grafana/dashboards/timeline.json index 2b336c02..853c4df8 100644 --- a/grafana/dashboards/timeline.json +++ b/grafana/dashboards/timeline.json @@ -360,4 +360,4 @@ "uid": "teslasync-timeline", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/tire-pressure.json b/grafana/dashboards/tire-pressure.json index 166b6e01..e2f479a2 100644 --- a/grafana/dashboards/tire-pressure.json +++ b/grafana/dashboards/tire-pressure.json @@ -73,7 +73,7 @@ } ] }, - "unit": "pressurepsi" + "unit": "none" }, "overrides": [] }, @@ -107,7 +107,7 @@ "editorMode": "code", "format": "time_series", "rawQuery": true, - "rawSql": "SELECT\n created_at AS time,\n front_left AS \"Front Left PSI\",\n front_right AS \"Front Right PSI\",\n rear_left AS \"Rear Left PSI\",\n rear_right AS \"Rear Right PSI\"\nFROM tire_pressure_snapshots\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\nORDER BY created_at", + "rawSql": "SELECT\n created_at AS time,\n convert_pressure(front_left) AS \"Front Left\",\n convert_pressure(front_right) AS \"Front Right\",\n convert_pressure(rear_left) AS \"Rear Left\",\n convert_pressure(rear_right) AS \"Rear Right\"\nFROM tire_pressure_snapshots\nWHERE vehicle_id = ${vehicle_id}\n AND $__timeFilter(created_at)\nORDER BY created_at", "refId": "A" } ], @@ -136,7 +136,7 @@ } ] }, - "unit": "pressurepsi" + "unit": "none" } }, "gridPos": { @@ -167,7 +167,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT front_left AS \"Front Left PSI\" FROM tire_pressure_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT convert_pressure(front_left) AS \"Front Left\" FROM tire_pressure_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -196,7 +196,7 @@ } ] }, - "unit": "pressurepsi" + "unit": "none" } }, "gridPos": { @@ -227,7 +227,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT front_right AS \"Front Right PSI\" FROM tire_pressure_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT convert_pressure(front_right) AS \"Front Right\" FROM tire_pressure_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -256,7 +256,7 @@ } ] }, - "unit": "pressurepsi" + "unit": "none" } }, "gridPos": { @@ -287,7 +287,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT rear_left AS \"Rear Left PSI\" FROM tire_pressure_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT convert_pressure(rear_left) AS \"Rear Left\" FROM tire_pressure_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -316,7 +316,7 @@ } ] }, - "unit": "pressurepsi" + "unit": "none" } }, "gridPos": { @@ -347,7 +347,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT rear_right AS \"Rear Right PSI\" FROM tire_pressure_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT convert_pressure(rear_right) AS \"Rear Right\" FROM tire_pressure_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -449,4 +449,4 @@ "uid": "teslasync-tire-pressure", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/trips.json b/grafana/dashboards/trips.json index 404330b4..ded89dfc 100644 --- a/grafana/dashboards/trips.json +++ b/grafana/dashboards/trips.json @@ -76,7 +76,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n t.name AS \"✈️ Trip\",\n t.start_date AS \"📅 Start\",\n t.end_date AS \"📅 End\",\n ROUND(t.total_distance_km::numeric, 1) AS \"📏 Distance\",\n ROUND(t.total_energy_kwh::numeric, 1) AS \"⚡ Energy\",\n ROUND(t.total_cost::numeric, 2) AS \"💰 Cost\",\n t.drive_count AS \"🛣️ Drives\",\n t.charge_count AS \"⚡ Charges\",\n ROUND((t.total_energy_kwh * 1000 / NULLIF(t.total_distance_km, 0))::numeric, 1) AS \"⚡ Wh/km\"\nFROM trips t\nWHERE t.vehicle_id = ${vehicle_id}\nORDER BY t.start_date DESC\nLIMIT 50", + "rawSql": "SELECT\n t.name AS \"✈️ Trip\",\n t.start_date AS \"📅 Start\",\n t.end_date AS \"📅 End\",\n ROUND(convert_distance(t.total_distance_km)::numeric, 1) AS \"📏 Distance\",\n ROUND(t.total_energy_kwh::numeric, 1) AS \"⚡ Energy\",\n ROUND(t.total_cost::numeric, 2) AS \"💰 Cost\",\n t.drive_count AS \"🛣️ Drives\",\n t.charge_count AS \"⚡ Charges\",\n ROUND((convert_efficiency(t.total_energy_kwh * 1000 / NULLIF(t.total_distance_km, 0)))::numeric, 1) AS \"⚡ Efficiency\"\nFROM trips t\nWHERE t.vehicle_id = ${vehicle_id}\nORDER BY t.start_date DESC\nLIMIT 50", "refId": "A" } ], @@ -178,4 +178,4 @@ "uid": "teslasync-trips", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/vampire-drain.json b/grafana/dashboards/vampire-drain.json index 1d8843af..37333a41 100644 --- a/grafana/dashboards/vampire-drain.json +++ b/grafana/dashboards/vampire-drain.json @@ -394,7 +394,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT\n vde.start_date AS \"📅 Date\",\n vde.start_battery AS \"🔋 Start %\",\n vde.end_battery AS \"🔋 End %\",\n vde.battery_lost AS \"📉 Lost %\",\n ROUND(vde.range_lost_km::numeric, 1) AS \"📉 Range Lost\",\n ROUND(vde.duration_hours::numeric, 1) AS \"⏱️ Hours\",\n ROUND(vde.drain_rate_pct_per_hour::numeric, 2) AS \"🧛 Rate %/hr\",\n ROUND(vde.outside_temp_avg::numeric, 1) AS \"🌡️ Temp\",\n CASE WHEN vde.sentry_mode THEN 'Yes' ELSE 'No' END AS \"🛡️ Sentry\"\nFROM vampire_drain_events vde\nWHERE vde.vehicle_id = ${vehicle_id}\n AND $__timeFilter(vde.start_date)\nORDER BY vde.start_date DESC\nLIMIT 100", + "rawSql": "SELECT\n vde.start_date AS \"📅 Date\",\n vde.start_battery AS \"🔋 Start %\",\n vde.end_battery AS \"🔋 End %\",\n vde.battery_lost AS \"📉 Lost %\",\n ROUND(convert_distance(vde.range_lost_km)::numeric, 1) AS \"📉 Range Lost\",\n ROUND(vde.duration_hours::numeric, 1) AS \"⏱️ Hours\",\n ROUND(vde.drain_rate_pct_per_hour::numeric, 2) AS \"🧛 Rate %/hr\",\n ROUND(convert_temp(vde.outside_temp_avg)::numeric, 1) AS \"🌡️ Temp\",\n CASE WHEN vde.sentry_mode THEN 'Yes' ELSE 'No' END AS \"🛡️ Sentry\"\nFROM vampire_drain_events vde\nWHERE vde.vehicle_id = ${vehicle_id}\n AND $__timeFilter(vde.start_date)\nORDER BY vde.start_date DESC\nLIMIT 100", "refId": "A" } ], @@ -496,4 +496,4 @@ "uid": "teslasync-vampire-drain", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/vehicle-intelligence.json b/grafana/dashboards/vehicle-intelligence.json index 649b3fe6..6a7d0a3a 100644 --- a/grafana/dashboards/vehicle-intelligence.json +++ b/grafana/dashboards/vehicle-intelligence.json @@ -262,7 +262,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT active_route_destination AS \"Destination\", active_route_miles_to_arrival AS \"Miles to Arrival\", active_route_minutes_to_arrival AS \"Minutes to Arrival\", active_route_energy_at_arrival AS \"Energy at Arrival %\", active_route_traffic_minutes_delay AS \"Traffic Delay Min\", created_at AS \"Time\" FROM location_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at DESC LIMIT 10", + "rawSql": "SELECT active_route_destination AS \"Destination\", convert_distance(active_route_miles_to_arrival) AS \"Distance to Arrival\", active_route_minutes_to_arrival AS \"Minutes to Arrival\", active_route_energy_at_arrival AS \"Energy at Arrival %\", active_route_traffic_minutes_delay AS \"Traffic Delay Min\", created_at AS \"Time\" FROM location_snapshots WHERE vehicle_id = ${vehicle_id} AND $__timeFilter(created_at) ORDER BY created_at DESC LIMIT 10", "refId": "A" } ], @@ -663,4 +663,4 @@ "uid": "teslasync-vehicle-intelligence", "version": 1, "weekStart": "" -} \ No newline at end of file +} diff --git a/grafana/dashboards/vehicle-overview.json b/grafana/dashboards/vehicle-overview.json index 87229fc9..303dd390 100644 --- a/grafana/dashboards/vehicle-overview.json +++ b/grafana/dashboards/vehicle-overview.json @@ -124,7 +124,7 @@ } ] }, - "unit": "km" + "unit": "none" } }, "gridPos": { @@ -155,7 +155,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(rated_range::numeric, 1) AS \"Range km\" FROM positions WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT ROUND(convert_distance(rated_range)::numeric, 1) AS \"Range\" FROM positions WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -184,7 +184,7 @@ } ] }, - "unit": "km" + "unit": "none" } }, "gridPos": { @@ -215,7 +215,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(odometer::numeric, 1) AS \"📏 Odometer\" FROM positions WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT ROUND(convert_distance(odometer)::numeric, 1) AS \"📏 Odometer\" FROM positions WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -303,7 +303,7 @@ } ] }, - "unit": "celsius" + "unit": "none" } }, "gridPos": { @@ -334,7 +334,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(outside_temp::numeric, 1) AS \"°C\" FROM positions WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT ROUND(convert_temp(outside_temp)::numeric, 1) AS \"Temp\" FROM positions WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -481,7 +481,7 @@ } ] }, - "unit": "km" + "unit": "none" } }, "gridPos": { @@ -512,7 +512,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND(SUM(distance)::numeric, 1) AS \"km\" FROM drives WHERE vehicle_id = ${vehicle_id}", + "rawSql": "SELECT ROUND(convert_distance(SUM(distance))::numeric, 1) AS \"Distance\" FROM drives WHERE vehicle_id = ${vehicle_id}", "refId": "A" } ], @@ -751,7 +751,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT ROUND((SUM(charge_energy_added) * 1000 / NULLIF(SUM(d.distance), 0))::numeric, 1) AS \"⚡ Wh/km\" FROM charging_sessions cs JOIN drives d ON d.vehicle_id = cs.vehicle_id WHERE cs.vehicle_id = ${vehicle_id}", + "rawSql": "SELECT ROUND((convert_efficiency(SUM(charge_energy_added) * 1000 / NULLIF(SUM(d.distance), 0)))::numeric, 1) AS \"⚡ Efficiency\" FROM charging_sessions cs JOIN drives d ON d.vehicle_id = cs.vehicle_id WHERE cs.vehicle_id = ${vehicle_id}", "refId": "A" } ], @@ -851,7 +851,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT d.start_date AS \"📅 Date\",\n COALESCE(sa.display_name, 'Unknown') AS \"📍 From\",\n COALESCE(ea.display_name, 'Unknown') AS \"📍 To\",\n ROUND(d.distance::numeric, 1) AS \"📏 Distance\",\n ROUND(d.duration_min::numeric) AS \"⏱️ Duration\",\n d.start_battery_level AS \"🔋 Start %\",\n d.end_battery_level AS \"🔋 End %\"\nFROM drives d\nLEFT JOIN addresses sa ON d.start_address_id = sa.id\nLEFT JOIN addresses ea ON d.end_address_id = ea.id\nWHERE d.vehicle_id = ${vehicle_id}\nORDER BY d.start_date DESC LIMIT 1", + "rawSql": "SELECT d.start_date AS \"📅 Date\",\n COALESCE(sa.display_name, 'Unknown') AS \"📍 From\",\n COALESCE(ea.display_name, 'Unknown') AS \"📍 To\",\n ROUND(convert_distance(d.distance)::numeric, 1) AS \"📏 Distance\",\n ROUND(d.duration_min::numeric) AS \"⏱️ Duration\",\n d.start_battery_level AS \"🔋 Start %\",\n d.end_battery_level AS \"🔋 End %\"\nFROM drives d\nLEFT JOIN addresses sa ON d.start_address_id = sa.id\nLEFT JOIN addresses ea ON d.end_address_id = ea.id\nWHERE d.vehicle_id = ${vehicle_id}\nORDER BY d.start_date DESC LIMIT 1", "refId": "A" } ], @@ -960,7 +960,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT di_torque AS \"Torque (Nm)\", di_stator_temp AS \"Stator Temp (°C)\", vehicle_speed AS \"Speed (km/h)\", gear AS \"Gear\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT di_torque AS \"Torque (Nm)\", convert_temp(di_stator_temp) AS \"Stator Temp\", convert_speed(vehicle_speed) AS \"Speed\", gear AS \"Gear\" FROM motor_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -1019,7 +1019,7 @@ "editorMode": "code", "format": "table", "rawQuery": true, - "rawSql": "SELECT inside_temp AS \"Cabin (°C)\", outside_temp AS \"Outside (°C)\", hvac_power AS \"HVAC (kW)\", hvac_fan_speed AS \"Fan Speed\" FROM climate_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", + "rawSql": "SELECT convert_temp(inside_temp) AS \"Cabin\", convert_temp(outside_temp) AS \"Outside\", hvac_power AS \"HVAC (kW)\", hvac_fan_speed AS \"Fan Speed\" FROM climate_snapshots WHERE vehicle_id = ${vehicle_id} ORDER BY created_at DESC LIMIT 1", "refId": "A" } ], @@ -1180,4 +1180,4 @@ "uid": "teslasync-vehicle-overview", "version": 1, "weekStart": "" -} \ No newline at end of file +} From eec9bc484c163f19d85339170eb036dbb4ed8fc5 Mon Sep 17 00:00:00 2001 From: Atul Gupta Date: Mon, 30 Mar 2026 22:49:45 -0700 Subject: [PATCH 40/40] Add comprehensive TeslaSync seed SQL Add seed_comprehensive.sql: a large, realistic dataset generator for TeslaSync (Model Y) covering 2020-01-01 to 2026-03-31. The script truncates existing tables, creates monthly position partitions, seeds vehicle, tokens, settings, addresses, geofences and electricity rates, and generates extensive time series data (hourly positions ~54k rows), drives, charging sessions, charging telemetry, vehicle states, daily mileage, motor/climate/security/tire/battery snapshots, alerts and rules, notification channels/preferences/logs/metrics, visited locations, trips, gas price history, software updates, API/audit logs, API keys, command logs, vampire drain events, and other auxiliary tables. Includes setval calls for sequences and comments with docker psql instructions; uses fake tokens/keys for testing. Intended for local/dev testing and data visualization. --- seed_comprehensive.sql | 814 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 814 insertions(+) create mode 100644 seed_comprehensive.sql diff --git a/seed_comprehensive.sql b/seed_comprehensive.sql new file mode 100644 index 00000000..e0455807 --- /dev/null +++ b/seed_comprehensive.sql @@ -0,0 +1,814 @@ +-- TeslaSync Comprehensive Seed Data +-- Generates realistic Tesla Model Y data from 2020-01-01 to 2026-03-31 +-- Run: docker cp seed_comprehensive.sql teslasync-postgres:/tmp/seed.sql +-- docker exec teslasync-postgres psql -U teslasync -d teslasync -f /tmp/seed.sql +BEGIN; + +-- ============================================================ +-- CLEANUP: Truncate all tables in dependency order +-- ============================================================ +TRUNCATE TABLE + notification_logs, notification_metrics, notification_preferences, notification_schedules, + notification_channels, charging_telemetry, motor_snapshots, climate_snapshots, + security_events, location_snapshots, media_snapshots, safety_snapshots, + user_preference_snapshots, vehicle_config_snapshots, tire_pressure_snapshots, + trip_drives, trips, visited_locations, daily_mileage, vampire_drain_events, + vehicle_states, battery_snapshots, command_logs, alerts, alert_rules, + api_call_logs, audit_logs, api_keys, export_jobs, chatbot_messages, + software_updates, gas_price_history, geofence_electricity_rates, geofences, + drives, charging_sessions, positions, addresses, tokens, settings, + tesla_public_key, gas_price_poll_state, vehicles +CASCADE; + +-- ============================================================ +-- 1. VEHICLE +-- ============================================================ +INSERT INTO vehicles (id, vehicle_id, vin, display_name, model, trim_badging, exterior_color, wheel_type, state, healthy, created_at, updated_at) +VALUES (1, 1098765432, 'TESTVIN0000000001', 'Test Model Y', 'Model Y', 'Long Range', 'PearlWhite', 'Gemini19', 'online', true, '2020-01-01'::timestamptz, NOW()); +SELECT setval('vehicles_id_seq', 1); + +-- ============================================================ +-- 2. TOKENS (single row, expires 30 days from now) +-- ============================================================ +INSERT INTO tokens (id, access_token, refresh_token, expires_at, created_at, updated_at) +VALUES (1, + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJ0ZXNsYXN5bmMtc2VlZCIsInN1YiI6InRlc3R1c2VyIiwiZXhwIjoxNzQzMDAwMDAwfQ.fake_signature', + 'rt_fake_refresh_token_for_seed_data_only_abc123def456', + NOW() + INTERVAL '30 days', NOW(), NOW()); + +-- ============================================================ +-- 3. SETTINGS (mi, F, rated, $0.12/kWh, gas $3.96/gallon) +-- ============================================================ +INSERT INTO settings (id, unit_of_length, unit_of_temp, preferred_range, language, base_cost_per_kwh, api_suspended, theme, mode, custom_primary, custom_accent, gas_price_per_unit, gas_unit, gas_efficiency_mpg) +VALUES (1, 'mi', 'F', 'rated', 'en', 0.12, false, 'neon-cyan', 'dark', '#00b4d8', '#e63946', 3.96, 'gallon', 25); + +-- ============================================================ +-- 4. TESLA PUBLIC KEY (single row) +-- ============================================================ +INSERT INTO tesla_public_key (id, public_key_pem, fingerprint, created_at) +VALUES (1, + '-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfake0seed0key0data0for0test\npurposes0only0not0a0real0key0abc123456789\n-----END PUBLIC KEY-----', + 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', + '2020-01-01'::timestamptz); + +-- ============================================================ +-- 5. ADDRESSES (~10 realistic SF-area locations) +-- ============================================================ +INSERT INTO addresses (id, display_name, latitude, longitude, name, house_number, road, city, county, state, country, postcode, created_at) VALUES + (1, 'Home', 37.7749, -122.4194, 'Home', '123', 'Market St', 'San Francisco', 'San Francisco', 'CA', 'US', '94105', '2020-01-01'), + (2, 'Work', 37.7851, -122.4094, 'Office', '456', 'Montgomery St', 'San Francisco', 'San Francisco', 'CA', 'US', '94104', '2020-01-01'), + (3, 'Tesla Supercharger', 37.7577, -122.3887, 'Supercharger', '888', 'Brannan St', 'San Francisco', 'San Francisco', 'CA', 'US', '94107', '2020-01-01'), + (4, 'Westfield Mall', 37.7841, -122.4070, 'Westfield Centre', '865', 'Market St', 'San Francisco', 'San Francisco', 'CA', 'US', '94103', '2020-01-01'), + (5, 'Gym - 24 Hour Fitness', 37.7694, -122.4293, 'Gym', '100', 'Masonic Ave', 'San Francisco', 'San Francisco', 'CA', 'US', '94117', '2020-01-01'), + (6, 'Grocery - Whole Foods', 37.7636, -122.4218, 'Whole Foods', '399', '4th St', 'San Francisco', 'San Francisco', 'CA', 'US', '94107', '2020-01-01'), + (7, 'Golden Gate Park', 37.7694, -122.4862, 'Park', NULL, 'John F Kennedy Dr','San Francisco', 'San Francisco', 'CA', 'US', '94118', '2020-01-01'), + (8, 'Napa Valley', 38.2975, -122.2869, 'Napa Valley', NULL, 'Silverado Trail', 'Napa', 'Napa', 'CA', 'US', '94558', '2020-01-01'), + (9, 'Half Moon Bay', 37.4636, -122.4286, 'Half Moon Bay', NULL, 'Cabrillo Hwy', 'Half Moon Bay', 'San Mateo', 'CA', 'US', '94019', '2020-01-01'), + (10,'Palo Alto Supercharger', 37.4419, -122.1430, 'Supercharger', '100', 'El Camino Real', 'Palo Alto', 'Santa Clara', 'CA', 'US', '94301', '2020-01-01'); +SELECT setval('addresses_id_seq', 10); + +-- ============================================================ +-- 6. GEOFENCES with electricity rates +-- ============================================================ +INSERT INTO geofences (id, name, latitude, longitude, radius, cost_per_kwh, created_at, updated_at) VALUES + (1, 'Home', 37.7749, -122.4194, 100, 0.12, '2020-01-01', '2020-01-01'), + (2, 'Work', 37.7851, -122.4094, 150, 0.15, '2020-01-01', '2020-01-01'), + (3, 'SF Supercharger', 37.7577, -122.3887, 50, 0.31, '2020-01-01', '2020-01-01'), + (4, 'PA Supercharger', 37.4419, -122.1430, 50, 0.31, '2020-01-01', '2020-01-01'); +SELECT setval('geofences_id_seq', 4); + +INSERT INTO geofence_electricity_rates (geofence_id, cost_per_kwh, effective_from, effective_to) VALUES + (1, 0.10, '2020-01-01', '2022-06-30'), + (1, 0.12, '2022-07-01', NULL), + (2, 0.15, '2020-01-01', NULL), + (3, 0.28, '2020-01-01', '2023-12-31'), + (3, 0.31, '2024-01-01', NULL), + (4, 0.31, '2020-01-01', NULL); + +-- ============================================================ +-- 7. CREATE POSITION PARTITIONS (monthly from 2020-01 to 2026-03) +-- ============================================================ +DO $$ +DECLARE + m DATE := '2020-01-01'; +BEGIN + WHILE m <= '2026-03-01' LOOP + PERFORM create_monthly_partition('positions', m); + m := m + INTERVAL '1 month'; + END LOOP; +END $$; + +-- ============================================================ +-- 8. POSITIONS — 1 per hour, 2020-01-01 to 2026-03-31 +-- ~54,000+ rows generated via generate_series +-- Realistic: lat/lng jitter, speed based on hour, seasonal temp, +-- battery cycling (drain during day, charge at night) +-- ============================================================ +INSERT INTO positions (vehicle_id, latitude, longitude, speed, power, heading, elevation, odometer, ideal_range, rated_range, battery_level, inside_temp, outside_temp, fan_status, is_climate_on, created_at) +SELECT + 1 AS vehicle_id, + -- Lat: home area with jitter; driving hours get bigger offsets + 37.7749 + CASE + WHEN EXTRACT(DOW FROM ts) IN (0,6) AND EXTRACT(HOUR FROM ts) BETWEEN 10 AND 16 THEN (random() - 0.5) * 0.08 + WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 OR EXTRACT(HOUR FROM ts) BETWEEN 17 AND 18 THEN (random() - 0.5) * 0.03 + ELSE (random() - 0.5) * 0.005 + END AS latitude, + -122.4194 + CASE + WHEN EXTRACT(DOW FROM ts) IN (0,6) AND EXTRACT(HOUR FROM ts) BETWEEN 10 AND 16 THEN (random() - 0.5) * 0.12 + WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 OR EXTRACT(HOUR FROM ts) BETWEEN 17 AND 18 THEN (random() - 0.5) * 0.04 + ELSE (random() - 0.5) * 0.005 + END AS longitude, + -- Speed: 0 parked/sleeping, 30-75 driving + CASE + WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 OR EXTRACT(HOUR FROM ts) BETWEEN 17 AND 18 THEN 30 + random() * 45 + WHEN EXTRACT(DOW FROM ts) IN (0,6) AND EXTRACT(HOUR FROM ts) BETWEEN 10 AND 16 THEN 25 + random() * 50 + ELSE 0 + END AS speed, + -- Power: correlates with speed + CASE + WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 OR EXTRACT(HOUR FROM ts) BETWEEN 17 AND 18 THEN 10 + random() * 40 + WHEN EXTRACT(DOW FROM ts) IN (0,6) AND EXTRACT(HOUR FROM ts) BETWEEN 10 AND 16 THEN 8 + random() * 35 + WHEN EXTRACT(HOUR FROM ts) BETWEEN 22 AND 23 OR EXTRACT(HOUR FROM ts) BETWEEN 0 AND 5 THEN -(3 + random() * 5) -- charging negative + ELSE 0.5 + END AS power, + (random() * 360)::int AS heading, + 15 + random() * 30 AS elevation, + -- Odometer: starts at 1000 mi, adds ~40mi/day = ~1.67/hr + 1000.0 + (EXTRACT(EPOCH FROM (ts - '2020-01-01'::timestamptz)) / 3600.0) * 1.67 AS odometer, + -- Ideal/Rated range track battery + (20 + 60 * (0.5 + 0.5 * sin(EXTRACT(HOUR FROM ts) * 3.14159 / 12.0 - 1.5))) * 5.0 AS ideal_range, + (20 + 60 * (0.5 + 0.5 * sin(EXTRACT(HOUR FROM ts) * 3.14159 / 12.0 - 1.5))) * 4.8 AS rated_range, + -- Battery: cycles daily — high in morning (post-charge), low in evening + GREATEST(20, LEAST(90, (55 + 35 * sin(EXTRACT(HOUR FROM ts) * 3.14159 / 12.0 - 1.5))::int)) AS battery_level, + -- Inside temp: 20-24°C + 20 + random() * 4 AS inside_temp, + -- Outside temp: seasonal variation (Jan=10, Jul=25, sinusoidal) + 10 + 7.5 * sin((EXTRACT(DOY FROM ts) - 80) * 2 * 3.14159 / 365.0) + (random() - 0.5) * 4 AS outside_temp, + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 7 AND 20 THEN (1 + random() * 4)::int ELSE 0 END AS fan_status, + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 7 AND 20 THEN true ELSE false END AS is_climate_on, + ts AS created_at +FROM generate_series('2020-01-01'::timestamptz, '2026-03-31 23:00'::timestamptz, '1 hour') AS ts; + +-- ============================================================ +-- 9. DRIVES — ~2/weekday (commute), ~1/weekend +-- ============================================================ +INSERT INTO drives (vehicle_id, start_date, end_date, start_address_id, end_address_id, distance, duration_min, + start_range_km, end_range_km, speed_max, power_max, power_min, + start_battery_level, end_battery_level, inside_temp_avg, outside_temp_avg) +-- Morning commute (weekdays) +SELECT 1, d + TIME '08:00' + (random() * INTERVAL '30 min'), + d + TIME '08:30' + (random() * INTERVAL '30 min'), + 1, 2, -- Home → Work + 8 + random() * 7, -- 8-15 km + 20 + random() * 20, -- 20-40 min + 350 - random() * 50, 310 - random() * 50, + 80 + random() * 55, -- speed_max 80-135 km/h + 120 + random() * 80, -- power_max + -(40 + random() * 30), -- power_min (regen) + GREATEST(20, LEAST(90, (75 + random() * 15)::int)), + GREATEST(20, LEAST(90, (65 + random() * 10)::int)), + 21 + random() * 3, + 10 + 7.5 * sin((EXTRACT(DOY FROM d) - 80) * 2 * 3.14159 / 365.0) + (random() - 0.5) * 3 +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '1 day') d +WHERE EXTRACT(DOW FROM d) BETWEEN 1 AND 5 +UNION ALL +-- Evening commute (weekdays) +SELECT 1, d + TIME '17:00' + (random() * INTERVAL '45 min'), + d + TIME '17:40' + (random() * INTERVAL '40 min'), + 2, 1, -- Work → Home + 8 + random() * 7, + 25 + random() * 25, + 280 - random() * 40, 250 - random() * 40, + 75 + random() * 50, + 110 + random() * 70, + -(35 + random() * 25), + GREATEST(20, LEAST(90, (55 + random() * 15)::int)), + GREATEST(20, LEAST(90, (45 + random() * 10)::int)), + 21 + random() * 3, + 10 + 7.5 * sin((EXTRACT(DOY FROM d) - 80) * 2 * 3.14159 / 365.0) + (random() - 0.5) * 3 +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '1 day') d +WHERE EXTRACT(DOW FROM d) BETWEEN 1 AND 5 +UNION ALL +-- Weekend trip (longer) +SELECT 1, d + TIME '10:00' + (random() * INTERVAL '2 hours'), + d + TIME '11:30' + (random() * INTERVAL '2 hours'), + 1, (ARRAY[4,5,6,7,8,9])[1 + (random()*5)::int], -- Home → various + 20 + random() * 60, -- 20-80 km + 30 + random() * 60, -- 30-90 min + 380 - random() * 50, 300 - random() * 80, + 90 + random() * 60, + 140 + random() * 100, + -(50 + random() * 40), + GREATEST(20, LEAST(90, (80 + random() * 10)::int)), + GREATEST(20, LEAST(90, (55 + random() * 15)::int)), + 21 + random() * 3, + 10 + 7.5 * sin((EXTRACT(DOY FROM d) - 80) * 2 * 3.14159 / 365.0) + (random() - 0.5) * 3 +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '1 day') d +WHERE EXTRACT(DOW FROM d) IN (0, 6); + +-- ============================================================ +-- 10. CHARGING SESSIONS — nightly home + occasional supercharger +-- ============================================================ +-- Nightly home charging (every day) +INSERT INTO charging_sessions (vehicle_id, start_date, end_date, address_id, + charge_energy_added, charge_energy_used, start_battery_level, end_battery_level, + start_range_km, end_range_km, charger_phases, charger_voltage, charger_actual_current, + charger_power, fast_charger_type, fast_charger_brand, conn_charge_cable, cost, duration_min) +SELECT 1, + d + TIME '22:00' + (random() * INTERVAL '30 min'), + d + TIME '22:00' + INTERVAL '4 hours' + (random() * INTERVAL '2 hours'), + 1, -- Home + 25 + random() * 20, -- 25-45 kWh added + 27 + random() * 22, -- slightly more used than added + GREATEST(20, LEAST(60, (35 + random() * 20)::int)), -- start 35-55% + GREATEST(75, LEAST(90, (80 + random() * 10)::int)), -- end 80-90% + 200 + random() * 100, -- start range + 380 + random() * 50, -- end range + 1, 240, 32, -- single phase, 240V, 32A + 7.68, -- 7.68 kW wall connector + NULL, NULL, 'SAE', + (25 + random() * 20) * 0.12, -- cost at $0.12/kWh + 240 + random() * 120 -- 4-6 hours +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '1 day') d; + +-- Supercharger sessions (~2x per month = every 15 days) +INSERT INTO charging_sessions (vehicle_id, start_date, end_date, address_id, + charge_energy_added, charge_energy_used, start_battery_level, end_battery_level, + start_range_km, end_range_km, charger_phases, charger_voltage, charger_actual_current, + charger_power, fast_charger_type, fast_charger_brand, conn_charge_cable, cost, duration_min) +SELECT 1, + d + TIME '14:00' + (random() * INTERVAL '2 hours'), + d + TIME '14:30' + (random() * INTERVAL '30 min'), + CASE WHEN random() > 0.5 THEN 3 ELSE 10 END, -- SF or PA supercharger + 40 + random() * 25, -- 40-65 kWh + 42 + random() * 27, + GREATEST(10, LEAST(35, (15 + random() * 15)::int)), + GREATEST(75, LEAST(90, (80 + random() * 10)::int)), + 80 + random() * 100, + 370 + random() * 60, + 3, 400, (150 + random() * 100)::int, + 120 + random() * 80, -- 120-200 kW + 'Tesla', 'Tesla', 'IEC', + (40 + random() * 25) * 0.31, -- supercharger rate + 25 + random() * 15 -- 25-40 min +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '15 days') d; + +-- ============================================================ +-- 11. CHARGING TELEMETRY — last 30 days, ~1 record per 10 min during charging +-- ============================================================ +INSERT INTO charging_telemetry (vehicle_id, battery_level, soc, charge_state, detailed_charge_state, + charge_limit_soc, charge_amps, charge_current_request, charge_current_request_max, + charge_enable_request, charger_voltage, charger_phases, charge_rate_mph, + dc_charging_power, dc_charging_energy_in, ac_charging_power, ac_charging_energy_in, + energy_remaining, est_battery_range, ideal_battery_range, rated_range, + pack_voltage, pack_current, charge_port_door_open, charge_port_latch, + charge_port_cold_weather_mode, charging_cable_type, fast_charger_present, fast_charger_type, + time_to_full_charge, estimated_hours_to_charge, scheduled_charging_mode, scheduled_charging_pending, + preconditioning_enabled, brick_voltage_max, brick_voltage_min, num_brick_voltage_max, + num_brick_voltage_min, module_temp_max, module_temp_min, num_module_temp_max, num_module_temp_min, + battery_heater_on, not_enough_power_to_heat, bms_state, bms_fullcharge_complete, + dcdc_enable, isolation_resistance, lifetime_energy_used, supercharger_session_trip_planner, + powershare_status, powershare_type, powershare_stop_reason, powershare_hours_left, powershare_power_kw, + created_at) +SELECT 1, + -- battery_level ramps up over the charging session + LEAST(90, (40 + (rn * 2))::int), + LEAST(0.90, 0.40 + rn * 0.02), + 'Charging', 'AC_Charging', + 90, 32, 32, 32, + true, 240, 1, 28 + random() * 4, + 0, 0, 7.5 + random() * 0.5, rn * 1.25, + 75.0 - rn * 1.0, 200 + rn * 8, 210 + rn * 8, 205 + rn * 8, + 395 + random() * 10, 31 + random() * 2, true, 'Engaged', + false, 'SAE', false, NULL, + GREATEST(0.1, (5.0 - rn * 0.2)), GREATEST(0.1, (5.0 - rn * 0.2)), + 'Off', false, + false, 4.18 + random() * 0.02, 4.15 + random() * 0.02, 48, 48, + 25 + random() * 5, 23 + random() * 5, 4, 4, + false, false, 'Charging', false, + true, 1500 + random() * 200, 45000 + EXTRACT(EPOCH FROM d - '2026-03-01'::date) / 86400.0 * 35, + false, 'Inactive', 'None', 'None', 0, 0, + d + TIME '22:00' + (rn * INTERVAL '10 min') +FROM generate_series('2026-03-01'::date, '2026-03-31'::date, '1 day') d +CROSS JOIN generate_series(0, 29) rn -- 30 records per session (~5 hours) +WHERE d + TIME '22:00' + (rn * INTERVAL '10 min') <= d + INTERVAL '1 day' + TIME '03:00'; + +-- ============================================================ +-- 12. VEHICLE STATES — cycle through states realistically +-- ============================================================ +INSERT INTO vehicle_states (vehicle_id, state, start_date, end_date, duration_min, created_at) +-- Each day: asleep → online → driving → online → driving → charging → asleep +SELECT 1, s.state, + d + s.start_offset, + d + s.end_offset, + EXTRACT(EPOCH FROM (s.end_offset - s.start_offset)) / 60.0, + d + s.start_offset +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '1 day') d +CROSS JOIN (VALUES + ('asleep', TIME '00:00', TIME '07:30'), + ('online', TIME '07:30', TIME '08:00'), + ('driving', TIME '08:00', TIME '08:45'), + ('online', TIME '08:45', TIME '17:00'), + ('driving', TIME '17:00', TIME '17:45'), + ('online', TIME '17:45', TIME '22:00'), + ('charging', TIME '22:00', TIME '23:59') +) AS s(state, start_offset, end_offset); + +-- ============================================================ +-- 13. DAILY MILEAGE — every day, 20-80 km +-- ============================================================ +INSERT INTO daily_mileage (vehicle_id, date, distance_km, odometer_start, odometer_end, drive_count, energy_used_kwh) +SELECT 1, d::date, + CASE WHEN EXTRACT(DOW FROM d) IN (0,6) THEN 40 + random() * 40 ELSE 20 + random() * 30 END, + 1600.0 + (d::date - '2020-01-01'::date) * 64.0, -- ~40mi/day in km + 1600.0 + (d::date - '2020-01-01'::date) * 64.0 + CASE WHEN EXTRACT(DOW FROM d) IN (0,6) THEN 40 + random()*40 ELSE 20 + random()*30 END, + CASE WHEN EXTRACT(DOW FROM d) IN (0,6) THEN 1 ELSE 2 END, + CASE WHEN EXTRACT(DOW FROM d) IN (0,6) THEN 8 + random() * 8 ELSE 4 + random() * 6 END +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '1 day') d; + +-- ============================================================ +-- 14. MOTOR SNAPSHOTS — last 90 days, during driving hours +-- ============================================================ +INSERT INTO motor_snapshots (vehicle_id, di_state, di_torque, di_axle_speed, di_stator_temp, pedal_position, + brake_pedal, lateral_accel, longitudinal_accel, vehicle_speed, gear, + di_torque_actual_f, di_torque_actual_r, di_torque_actual_rel, di_torque_actual_rer, + di_axle_speed_f, di_axle_speed_rel, di_axle_speed_rer, + di_state_f, di_state_rel, di_state_rer, + di_stator_temp_f, di_stator_temp_rel, di_stator_temp_rer, + di_heatsink_t_f, di_heatsink_t_r, di_heatsink_t_rel, di_heatsink_t_rer, + di_inverter_t_f, di_inverter_t_r, di_inverter_t_rel, di_inverter_t_rer, + di_motor_current_f, di_motor_current_r, di_motor_current_rel, di_motor_current_rer, + di_v_bat_f, di_v_bat_r, di_v_bat_rel, di_v_bat_rer, + di_slave_torque_cmd, hvil, brake_pedal_pos, cruise_set_speed, drive_rail, created_at) +SELECT 1, 'drive', + 50 + random() * 250, -- torque 50-300 Nm + 500 + random() * 5000, -- axle speed + 30 + random() * 50, -- stator temp 30-80°C + 10 + random() * 80, -- pedal 10-90% + false, (random()-0.5)*2, random()*3, + 30 + random() * 75, -- speed 30-105 km/h + 'D', + -- dual motor torques + 20+random()*100, 30+random()*150, 0, 0, + 500+random()*5000, 0, 0, + 'drive', 'inactive', 'inactive', + 30+random()*50, 25+random()*10, 25+random()*10, + 25+random()*25, 25+random()*25, 22+random()*10, 22+random()*10, + 28+random()*17, 28+random()*17, 26+random()*8, 26+random()*8, + 10+random()*190, 10+random()*190, 0, 0, + 390+random()*15, 390+random()*15, 0, 0, + 30+random()*100, 'OK', 0, 105, true, + ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '15 min') ts +WHERE EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 + OR EXTRACT(HOUR FROM ts) BETWEEN 17 AND 18 + OR (EXTRACT(DOW FROM ts) IN (0,6) AND EXTRACT(HOUR FROM ts) BETWEEN 10 AND 16); + +-- ============================================================ +-- 15. CLIMATE SNAPSHOTS — last 90 days +-- ============================================================ +INSERT INTO climate_snapshots (vehicle_id, inside_temp, outside_temp, hvac_power, hvac_fan_speed, + hvac_left_temp_request, hvac_right_temp_request, cabin_overheat_mode, defrost_mode, + battery_heater_on, hvac_ac_enabled, hvac_auto_mode, hvac_fan_status, hvac_steering_wheel_heat_auto, + hvac_steering_wheel_heat_level, climate_keeper_mode, cabin_overheat_protection_temp_limit, + defrost_for_preconditioning, seat_heater_left, seat_heater_right, seat_heater_rear_left, + seat_heater_rear_center, seat_heater_rear_right, seat_vent_enabled, + climate_seat_cooling_front_left, climate_seat_cooling_front_right, + auto_seat_climate_left, auto_seat_climate_right, rear_defrost_enabled, + rear_display_hvac_enabled, wiper_heat_enabled, created_at) +SELECT 1, + 20 + random() * 4, + 10 + 7.5 * sin((EXTRACT(DOY FROM ts) - 80) * 2 * 3.14159 / 365.0) + (random()-0.5)*4, + 1.5 + random() * 3.5, + (1 + random() * 4)::int, + 21.0, 21.0, 'On', false, + -- winter: battery heater on + CASE WHEN EXTRACT(MONTH FROM ts) IN (12,1,2) THEN true ELSE false END, + true, true, (1 + random()*4)::int, true, + CASE WHEN EXTRACT(MONTH FROM ts) IN (12,1,2) THEN 2 ELSE 0 END, + 'off', 40, + false, + -- seat heaters in winter months + CASE WHEN EXTRACT(MONTH FROM ts) IN (12,1,2,3) THEN (1+random()*2)::int ELSE 0 END, + CASE WHEN EXTRACT(MONTH FROM ts) IN (12,1,2,3) THEN (1+random()*2)::int ELSE 0 END, + 0, 0, 0, + CASE WHEN EXTRACT(MONTH FROM ts) IN (6,7,8,9) THEN true ELSE false END, + CASE WHEN EXTRACT(MONTH FROM ts) IN (6,7,8,9) THEN (1+random()*2)::int ELSE 0 END, + CASE WHEN EXTRACT(MONTH FROM ts) IN (6,7,8,9) THEN (1+random()*2)::int ELSE 0 END, + true, true, false, false, + CASE WHEN EXTRACT(MONTH FROM ts) IN (12,1,2) THEN true ELSE false END, + ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '30 min') ts +WHERE EXTRACT(HOUR FROM ts) BETWEEN 7 AND 22; + +-- ============================================================ +-- 16. SECURITY EVENTS — last 90 days +-- ============================================================ +INSERT INTO security_events (vehicle_id, locked, sentry_mode, door_state, fd_window, fp_window, rd_window, rp_window, + homelink_nearby, guest_mode, homelink_device_count, guest_mode_mobile_access_state, + driver_seat_occupied, center_display, speed_limit_mode, valet_mode_enabled, service_mode, + current_limit_mph, paired_phone_key_count, lights_hazards_active, lights_high_beams, + lights_turn_signal, tonneau_position, tonneau_open_percent, tonneau_tent_mode, + driver_seat_belt, passenger_seat_belt, created_at) +SELECT 1, + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 18 THEN true ELSE EXTRACT(HOUR FROM ts) > 22 OR EXTRACT(HOUR FROM ts) < 7 END, + -- Sentry on when parked away from home (work hours) + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 9 AND 17 AND EXTRACT(DOW FROM ts) BETWEEN 1 AND 5 THEN true ELSE false END, + 'Closed', 'Closed', 'Closed', 'Closed', 'Closed', + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 19 AND 23 THEN true ELSE false END, -- homelink near home + false, 1, 'off', + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 18 THEN false ELSE false END, + 'On', 'off', false, false, + 85, 2, false, false, + 'off', NULL, NULL, false, + false, false, + ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '1 hour') ts; + +-- ============================================================ +-- 17. TIRE PRESSURE SNAPSHOTS — last 90 days, every 4 hours +-- ============================================================ +INSERT INTO tire_pressure_snapshots (vehicle_id, front_left, front_right, rear_left, rear_right, created_at) +SELECT 1, + 2.9 + random() * 0.3, + 2.9 + random() * 0.3, + 2.85 + random() * 0.35, + 2.85 + random() * 0.35, + ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '4 hours') ts; + +-- ============================================================ +-- 18. BATTERY HEALTH SNAPSHOTS — monthly, 100% → ~92% by 2026 +-- ============================================================ +INSERT INTO battery_snapshots (vehicle_id, health_score, capacity_kwh, degradation_pct, est_range_km, cycle_count, avg_cell_temp_c, created_at) +SELECT 1, + GREATEST(91, 100.0 - (EXTRACT(EPOCH FROM (m - '2020-01-01'::timestamptz)) / (365.25*86400)) * 1.3), + 75.0 * GREATEST(0.91, 1.0 - (EXTRACT(EPOCH FROM (m - '2020-01-01'::timestamptz)) / (365.25*86400)) * 0.013), + LEAST(9, (EXTRACT(EPOCH FROM (m - '2020-01-01'::timestamptz)) / (365.25*86400)) * 1.3), + 480 * GREATEST(0.91, 1.0 - (EXTRACT(EPOCH FROM (m - '2020-01-01'::timestamptz)) / (365.25*86400)) * 0.013), + (EXTRACT(EPOCH FROM (m - '2020-01-01'::timestamptz)) / (365.25*86400) * 200)::int, + 25 + random() * 10, + m +FROM generate_series('2020-01-01'::timestamptz, '2026-03-01'::timestamptz, '1 month') m; + +-- ============================================================ +-- 19. ALERTS — mix of types over time +-- ============================================================ +INSERT INTO alerts (vehicle_id, type, severity, title, message, is_read, created_at) +-- Battery low alerts (~monthly) +SELECT 1, 'battery_low', 'warning', 'Battery Below 20%', + 'Battery level dropped to ' || (15 + (random()*5)::int) || '%. Consider charging soon.', + true, d + (random() * INTERVAL '12 hours') +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '30 days') d +UNION ALL +-- Charging complete (~daily) +SELECT 1, 'charging_complete', 'info', 'Charging Complete', + 'Vehicle charged to ' || (85 + (random()*5)::int) || '%. Range: ' || (380 + (random()*40)::int) || ' km.', + true, d + TIME '04:00' + (random() * INTERVAL '2 hours') +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '7 days') d +UNION ALL +-- Sentry events (~weekly) +SELECT 1, 'sentry', 'warning', 'Sentry Mode Event', + 'Motion detected near vehicle at ' || (ARRAY['Work', 'Mall', 'Gym', 'Grocery'])[1+(random()*3)::int] || '.', + CASE WHEN d > '2026-03-01'::date THEN false ELSE true END, + d + TIME '14:00' + (random() * INTERVAL '4 hours') +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '7 days') d +UNION ALL +-- Speed alerts (~quarterly) +SELECT 1, 'speed_limit', 'critical', 'Speed Limit Exceeded', + 'Vehicle exceeded 120 km/h. Max speed: ' || (125 + (random()*20)::int) || ' km/h.', + true, d + TIME '10:00' + (random() * INTERVAL '6 hours') +FROM generate_series('2020-03-01'::date, '2026-03-31'::date, '90 days') d; + +-- ============================================================ +-- 20. ALERT RULES +-- ============================================================ +INSERT INTO alert_rules (name, type, enabled, threshold, vehicle_id, created_at, updated_at) VALUES + ('Low Battery Alert', 'battery_low', true, 20, 1, '2020-01-01', '2020-01-01'), + ('Battery Full Alert', 'battery_full', true, 95, 1, '2020-01-01', '2020-01-01'), + ('Sentry Mode Event', 'sentry', true, 0, 1, '2020-01-01', '2020-01-01'), + ('Speed Alert', 'speed', true, 120, 1, '2020-01-01', '2020-01-01'), + ('Geofence Alert', 'geofence', true, 0, 1, '2020-01-01', '2020-01-01'), + ('Software Update', 'software', true, 0, 1, '2020-01-01', '2020-01-01'); + +-- ============================================================ +-- 21. NOTIFICATION CHANNELS +-- ============================================================ +INSERT INTO notification_channels (id, name, type, config, enabled, created_at, updated_at) VALUES + (1, 'Primary Webhook', 'webhook', '{"url":"https://hooks.example.com/teslasync","secret":"whsec_seed123"}', true, '2020-01-01', NOW()); +SELECT setval('notification_channels_id_seq', 1); + +-- ============================================================ +-- 22. NOTIFICATION PREFERENCES +-- ============================================================ +INSERT INTO notification_preferences (channel_id, event_type, enabled, created_at) VALUES + (1, 'battery_low', true, '2020-01-01'), + (1, 'charging_complete', true, '2020-01-01'), + (1, 'sentry_event', true, '2020-01-01'), + (1, 'speed_limit', true, '2020-01-01'), + (1, 'software_update', true, '2020-01-01'); + +-- ============================================================ +-- 23. NOTIFICATION SCHEDULES +-- ============================================================ +INSERT INTO notification_schedules (channel_id, title, message, cron_expr, scheduled_at, last_run_at, next_run_at, enabled, created_at, updated_at) VALUES + (1, 'Daily Battery Report', 'Battery and charging summary for the day', '0 8 * * *', NULL, NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', true, '2020-01-01', NOW()); + +-- ============================================================ +-- 24. NOTIFICATION LOGS — some recent sent notifications +-- ============================================================ +INSERT INTO notification_logs (channel_id, alert_id, title, message, status, error, scheduled_at, latency_ms, created_at, sent_at) +SELECT 1, a.id, a.title, a.message, 'sent', NULL, NULL, + 50 + (random() * 200)::int, + a.created_at, a.created_at + INTERVAL '1 second' +FROM alerts a +WHERE a.created_at > NOW() - INTERVAL '90 days' +LIMIT 50; + +-- ============================================================ +-- 25. NOTIFICATION METRICS — last 90 days +-- ============================================================ +INSERT INTO notification_metrics (channel_id, date, total_sent, total_failed, avg_latency_ms) +SELECT 1, d, (1 + random() * 5)::int, CASE WHEN random() > 0.9 THEN 1 ELSE 0 END, (80 + random() * 150)::int +FROM generate_series((CURRENT_DATE - 90), CURRENT_DATE, '1 day') d; + +-- ============================================================ +-- 26. VISITED LOCATIONS — top locations with visit counts +-- ============================================================ +INSERT INTO visited_locations (vehicle_id, address_id, visit_count, total_duration_min, last_visited, created_at) VALUES + (1, 1, 2283, 2283 * 480, NOW() - INTERVAL '1 hour', '2020-01-01'), + (1, 2, 1630, 1630 * 510, NOW() - INTERVAL '3 hours', '2020-01-01'), + (1, 3, 152, 152 * 35, NOW() - INTERVAL '10 days', '2020-01-01'), + (1, 5, 420, 420 * 90, NOW() - INTERVAL '2 days', '2020-01-01'), + (1, 6, 310, 310 * 45, NOW() - INTERVAL '4 days', '2020-01-01'), + (1, 4, 180, 180 * 120, NOW() - INTERVAL '7 days', '2020-01-01'), + (1, 7, 95, 95 * 180, NOW() - INTERVAL '14 days', '2020-01-01'), + (1, 8, 25, 25 * 360, NOW() - INTERVAL '30 days', '2020-01-01'), + (1, 9, 35, 35 * 240, NOW() - INTERVAL '21 days', '2020-01-01'), + (1, 10, 48, 48 * 30, NOW() - INTERVAL '15 days', '2020-01-01'); + +-- ============================================================ +-- 27. TRIPS — monthly groupings +-- ============================================================ +INSERT INTO trips (vehicle_id, name, start_date, end_date, total_distance_km, total_energy_kwh, total_cost, drive_count, charge_count, created_at) +SELECT 1, + to_char(m, 'Mon YYYY') || ' Summary', + m, + m + INTERVAL '1 month' - INTERVAL '1 second', + 1200 + random() * 800, -- 1200-2000 km/month + 200 + random() * 150, -- 200-350 kWh/month + (200 + random() * 150) * 0.12, + CASE WHEN EXTRACT(MONTH FROM m) IN (12,1) THEN 45 + (random()*10)::int ELSE 55 + (random()*10)::int END, + 28 + (random() * 5)::int, + m +FROM generate_series('2020-01-01'::timestamptz, '2026-03-01'::timestamptz, '1 month') m; + +-- ============================================================ +-- 28. GAS PRICE HISTORY — quarterly from 2020 ($2.50 → $3.96) +-- ============================================================ +INSERT INTO gas_price_history (price_per_unit, unit, efficiency_mpg, effective_from, effective_to, created_at) +VALUES + (2.50, 'gallon', 25, '2020-01-01', '2020-03-31', '2020-01-01'), + (2.10, 'gallon', 25, '2020-04-01', '2020-06-30', '2020-04-01'), + (2.30, 'gallon', 25, '2020-07-01', '2020-09-30', '2020-07-01'), + (2.25, 'gallon', 25, '2020-10-01', '2020-12-31', '2020-10-01'), + (2.55, 'gallon', 25, '2021-01-01', '2021-03-31', '2021-01-01'), + (2.90, 'gallon', 25, '2021-04-01', '2021-06-30', '2021-04-01'), + (3.10, 'gallon', 25, '2021-07-01', '2021-09-30', '2021-07-01'), + (3.25, 'gallon', 25, '2021-10-01', '2021-12-31', '2021-10-01'), + (3.50, 'gallon', 25, '2022-01-01', '2022-03-31', '2022-01-01'), + (4.50, 'gallon', 25, '2022-04-01', '2022-06-30', '2022-04-01'), + (4.80, 'gallon', 25, '2022-07-01', '2022-09-30', '2022-07-01'), + (3.80, 'gallon', 25, '2022-10-01', '2022-12-31', '2022-10-01'), + (3.60, 'gallon', 25, '2023-01-01', '2023-03-31', '2023-01-01'), + (3.70, 'gallon', 25, '2023-04-01', '2023-06-30', '2023-04-01'), + (3.90, 'gallon', 25, '2023-07-01', '2023-09-30', '2023-07-01'), + (3.50, 'gallon', 25, '2023-10-01', '2023-12-31', '2023-10-01'), + (3.30, 'gallon', 25, '2024-01-01', '2024-03-31', '2024-01-01'), + (3.55, 'gallon', 25, '2024-04-01', '2024-06-30', '2024-04-01'), + (3.70, 'gallon', 25, '2024-07-01', '2024-09-30', '2024-07-01'), + (3.45, 'gallon', 25, '2024-10-01', '2024-12-31', '2024-10-01'), + (3.40, 'gallon', 25, '2025-01-01', '2025-03-31', '2025-01-01'), + (3.60, 'gallon', 25, '2025-04-01', '2025-06-30', '2025-04-01'), + (3.80, 'gallon', 25, '2025-07-01', '2025-09-30', '2025-07-01'), + (3.75, 'gallon', 25, '2025-10-01', '2025-12-31', '2025-10-01'), + (3.96, 'gallon', 25, '2026-01-01', NULL, '2026-01-01'); + +-- ============================================================ +-- 29. GAS PRICE POLL STATE +-- ============================================================ +INSERT INTO gas_price_poll_state (id, enabled, poll_interval, last_poll_time, last_price) +VALUES (1, true, '7d', NOW() - INTERVAL '2 days', 3.96); + +-- ============================================================ +-- 30. SOFTWARE UPDATES — quarterly version history +-- ============================================================ +INSERT INTO software_updates (vehicle_id, version, status, scheduled_at, installed_at, created_at) VALUES + (1, '2020.4.1', 'installed', '2020-02-01', '2020-02-01 03:00', '2020-01-25'), + (1, '2020.12.5', 'installed', '2020-04-15', '2020-04-15 02:30', '2020-04-10'), + (1, '2020.24.6', 'installed', '2020-07-01', '2020-07-01 03:15', '2020-06-25'), + (1, '2020.36.11', 'installed', '2020-10-01', '2020-10-01 02:45', '2020-09-25'), + (1, '2020.48.26', 'installed', '2021-01-05', '2021-01-05 03:00', '2020-12-28'), + (1, '2021.4.18', 'installed', '2021-04-01', '2021-04-01 02:30', '2021-03-25'), + (1, '2021.12.25', 'installed', '2021-07-01', '2021-07-01 03:10', '2021-06-25'), + (1, '2021.36.5', 'installed', '2021-10-01', '2021-10-01 02:50', '2021-09-25'), + (1, '2021.44.25', 'installed', '2022-01-10', '2022-01-10 03:00', '2022-01-05'), + (1, '2022.8.3', 'installed', '2022-04-01', '2022-04-01 02:30', '2022-03-25'), + (1, '2022.20.7', 'installed', '2022-07-01', '2022-07-01 03:20', '2022-06-25'), + (1, '2022.36.1', 'installed', '2022-10-01', '2022-10-01 02:40', '2022-09-25'), + (1, '2022.44.2', 'installed', '2023-01-05', '2023-01-05 03:00', '2022-12-28'), + (1, '2023.6.9', 'installed', '2023-04-01', '2023-04-01 02:30', '2023-03-25'), + (1, '2023.20.4', 'installed', '2023-07-01', '2023-07-01 03:10', '2023-06-25'), + (1, '2023.32.9', 'installed', '2023-10-01', '2023-10-01 02:50', '2023-09-25'), + (1, '2023.44.30', 'installed', '2024-01-10', '2024-01-10 03:00', '2024-01-05'), + (1, '2024.2.7', 'installed', '2024-04-01', '2024-04-01 02:30', '2024-03-25'), + (1, '2024.14.5', 'installed', '2024-07-01', '2024-07-01 03:15', '2024-06-25'), + (1, '2024.26.8', 'installed', '2024-10-01', '2024-10-01 02:45', '2024-09-25'), + (1, '2024.38.3', 'installed', '2025-01-05', '2025-01-05 03:00', '2024-12-28'), + (1, '2025.2.6', 'installed', '2025-04-01', '2025-04-01 02:30', '2025-03-25'), + (1, '2025.10.1', 'installed', '2025-07-01', '2025-07-01 03:10', '2025-06-25'), + (1, '2025.20.3', 'installed', '2025-10-01', '2025-10-01 02:50', '2025-09-25'), + (1, '2026.2.1', 'installed', '2026-01-10', '2026-01-10 03:00', '2026-01-05'), + (1, '2026.8.4', 'available', NULL, NULL, '2026-03-20'); + +-- ============================================================ +-- 31. API CALL LOGS — recent 30 days +-- ============================================================ +INSERT INTO api_call_logs (method, url, status_code, request_body, response_body, duration_ms, error, source, created_at) +SELECT + (ARRAY['GET','GET','GET','POST','GET'])[1+(random()*4)::int], + (ARRAY[ + 'https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/1098765432/vehicle_data', + 'https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles', + 'https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/1098765432/data_request/charge_state', + 'https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/1098765432/command/wake_up', + 'https://fleet-api.prd.na.vn.cloud.tesla.com/api/1/vehicles/1098765432/data_request/drive_state' + ])[1+(random()*4)::int], + CASE WHEN random() > 0.95 THEN 429 WHEN random() > 0.98 THEN 500 ELSE 200 END, + '{}', + '{"response":{"id":1098765432,"state":"online"}}', + (50 + random() * 500)::int, + CASE WHEN random() > 0.95 THEN 'rate_limit_exceeded' ELSE NULL END, + 'tesla_api', + ts +FROM generate_series(NOW() - INTERVAL '30 days', NOW(), '15 min') ts; + +-- ============================================================ +-- 32. AUDIT LOGS — recent activity +-- ============================================================ +INSERT INTO audit_logs (action, resource, details, ip, created_at) +SELECT + (ARRAY['login', 'view_dashboard', 'update_settings', 'view_drives', 'view_charging', 'export_data', 'view_alerts'])[1+(random()*6)::int], + (ARRAY['auth', 'dashboard', 'settings', 'drives', 'charging', 'export', 'alerts'])[1+(random()*6)::int], + 'User performed action from web UI', + '192.168.1.' || (2 + (random()*253)::int), + ts +FROM generate_series(NOW() - INTERVAL '30 days', NOW(), '2 hours') ts; + +-- ============================================================ +-- 33. API KEYS +-- ============================================================ +INSERT INTO api_keys (name, key_hash, key_prefix, permissions, last_used_at, created_at, expires_at) VALUES + ('Grafana Dashboard', 'a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd', 'tsk_grf_', 'read', NOW() - INTERVAL '1 hour', '2020-06-01', '2027-06-01'), + ('Home Assistant', 'b2c3d4e5f67890123456789012345678901234567890123456789012345678ef', 'tsk_ha_', 'read', NOW() - INTERVAL '6 hours', '2021-01-15', '2027-01-15'); + +-- ============================================================ +-- 34. COMMAND LOGS — recent commands +-- ============================================================ +INSERT INTO command_logs (vehicle_id, command, params, status, error, created_at) VALUES + (1, 'wake_up', '', 'success', '', NOW() - INTERVAL '2 hours'), + (1, 'honk_horn', '', 'success', '', NOW() - INTERVAL '1 day'), + (1, 'flash_lights', '', 'success', '', NOW() - INTERVAL '1 day'), + (1, 'door_lock', '', 'success', '', NOW() - INTERVAL '3 days'), + (1, 'door_unlock', '', 'success', '', NOW() - INTERVAL '3 days'), + (1, 'climate_on', '{"temp": 21}', 'success', '', NOW() - INTERVAL '5 days'), + (1, 'climate_off', '', 'success', '', NOW() - INTERVAL '5 days'), + (1, 'set_charge_limit', '{"percent": 90}', 'success', '', NOW() - INTERVAL '7 days'), + (1, 'charge_start', '', 'success', '', NOW() - INTERVAL '7 days'), + (1, 'charge_stop', '', 'success', '', NOW() - INTERVAL '7 days'), + (1, 'set_sentry_mode', '{"on": true}', 'success', '', NOW() - INTERVAL '10 days'), + (1, 'actuate_trunk', '{"which": "rear"}', 'success', '', NOW() - INTERVAL '14 days'); + +-- ============================================================ +-- 35. VAMPIRE DRAIN EVENTS — overnight drain samples +-- ============================================================ +INSERT INTO vampire_drain_events (vehicle_id, start_date, end_date, start_battery, end_battery, battery_lost, range_lost_km, duration_hours, drain_rate_pct_per_hour, outside_temp_avg, sentry_mode, created_at) +SELECT 1, + d + TIME '00:00', d + TIME '07:00', + (75 + random() * 15)::int, + (73 + random() * 15)::int, + (1 + random() * 3)::int, + (5 + random() * 15), + 7, + (0.15 + random() * 0.3), + 10 + 7.5 * sin((EXTRACT(DOY FROM d) - 80) * 2 * 3.14159 / 365.0), + CASE WHEN EXTRACT(DOW FROM d) BETWEEN 1 AND 5 THEN false ELSE random() > 0.5 END, + d +FROM generate_series('2020-01-01'::date, '2026-03-31'::date, '7 days') d; + +-- ============================================================ +-- 36. LOCATION SNAPSHOTS — last 90 days +-- ============================================================ +INSERT INTO location_snapshots (vehicle_id, destination_name, destination_lat, destination_lon, origin_lat, origin_lon, + miles_to_arrival, minutes_to_arrival, route_line, route_traffic_delay_min, + located_at_home, located_at_work, located_at_favorite, gps_state, created_at) +SELECT 1, + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 THEN 'Work' + WHEN EXTRACT(HOUR FROM ts) BETWEEN 17 AND 18 THEN 'Home' + ELSE NULL END, + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 THEN 37.7851 ELSE 37.7749 END, + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 THEN -122.4094 ELSE -122.4194 END, + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 THEN 37.7749 ELSE 37.7851 END, + CASE WHEN EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 THEN -122.4194 ELSE -122.4094 END, + CASE WHEN EXTRACT(HOUR FROM ts) IN (8,17) THEN 3 + random() * 5 ELSE 0 END, + CASE WHEN EXTRACT(HOUR FROM ts) IN (8,17) THEN 15 + random() * 25 ELSE 0 END, + NULL, + CASE WHEN EXTRACT(HOUR FROM ts) IN (8,17) THEN random() * 5 ELSE 0 END, + EXTRACT(HOUR FROM ts) NOT BETWEEN 8 AND 17, + EXTRACT(HOUR FROM ts) BETWEEN 9 AND 17, + false, 'Valid', ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '1 hour') ts; + +-- ============================================================ +-- 37. MEDIA SNAPSHOTS — last 90 days, during driving +-- ============================================================ +INSERT INTO media_snapshots (vehicle_id, now_playing_title, now_playing_artist, now_playing_album, + now_playing_station, now_playing_duration, now_playing_elapsed, playback_status, playback_source, + audio_volume, audio_volume_max, created_at) +SELECT 1, + (ARRAY['Shape of You','Blinding Lights','Bohemian Rhapsody','Hotel California','Starboy','Levitating','Watermelon Sugar','Bad Guy','Rolling in the Deep','Uptown Funk'])[1+(random()*9)::int], + (ARRAY['Ed Sheeran','The Weeknd','Queen','Eagles','The Weeknd','Dua Lipa','Harry Styles','Billie Eilish','Adele','Bruno Mars'])[1+(random()*9)::int], + 'Greatest Hits', + 'Spotify', + 180 + (random() * 120)::int, + (random() * 180)::int, + 'Playing', 'Spotify', + (4 + random() * 7)::int, 11, + ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '30 min') ts +WHERE EXTRACT(HOUR FROM ts) BETWEEN 8 AND 9 OR EXTRACT(HOUR FROM ts) BETWEEN 17 AND 18 + OR (EXTRACT(DOW FROM ts) IN (0,6) AND EXTRACT(HOUR FROM ts) BETWEEN 10 AND 16); + +-- ============================================================ +-- 38. SAFETY SNAPSHOTS — last 90 days +-- ============================================================ +INSERT INTO safety_snapshots (vehicle_id, automatic_blind_spot_camera, automatic_emergency_braking_off, + blind_spot_collision_warning, cruise_follow_distance, emergency_lane_departure_avoidance, + forward_collision_warning, lane_departure_avoidance, speed_limit_warning, + pin_to_drive_enabled, miles_since_reset, self_driving_miles_since_reset, created_at) +SELECT 1, true, false, true, '3', true, true, true, 'Display', + false, + 1000 + (EXTRACT(EPOCH FROM ts - (NOW() - INTERVAL '90 days')) / 86400) * 40, + 500 + (EXTRACT(EPOCH FROM ts - (NOW() - INTERVAL '90 days')) / 86400) * 20, + ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '6 hours') ts; + +-- ============================================================ +-- 39. USER PREFERENCE SNAPSHOTS — last 90 days +-- ============================================================ +INSERT INTO user_preference_snapshots (vehicle_id, setting_24hr_time, setting_charge_unit, + setting_distance_unit, setting_temperature_unit, setting_tire_pressure_unit, created_at) +SELECT 1, false, 'mi', 'mi', 'F', 'PSI', ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '1 day') ts; + +-- ============================================================ +-- 40. VEHICLE CONFIG SNAPSHOTS — last 90 days +-- ============================================================ +INSERT INTO vehicle_config_snapshots (vehicle_id, car_type, trim, exterior_color, roof_color, wheel_type, + rear_seat_heaters, sunroof_installed, efficiency_package, europe_vehicle, right_hand_drive, + remote_start_enabled, charge_port, offroad_lightbar_present, version, vehicle_name, + software_update_version, software_update_download_pct, software_update_install_pct, + software_update_expected_duration, created_at) +SELECT 1, 'modely', 'Long Range', 'PearlWhite', 'Glass', 'Gemini19', + true, false, false, false, false, + true, 'US', false, NULL, 'Test Model Y', + '2026.2.1', 100, 100, 0, + ts +FROM generate_series(NOW() - INTERVAL '90 days', NOW(), '1 day') ts; + +-- ============================================================ +-- 41. CHATBOT MESSAGES — sample conversation +-- ============================================================ +INSERT INTO chatbot_messages (session_id, role, content, created_at) VALUES + ('sess_001', 'user', 'How much did I spend on charging last month?', NOW() - INTERVAL '2 days'), + ('sess_001', 'assistant', 'Based on your charging data, you spent approximately $38.50 on home charging and $12.40 on Supercharging last month, totaling $50.90.', NOW() - INTERVAL '2 days' + INTERVAL '5 seconds'), + ('sess_001', 'user', 'What is my battery health?', NOW() - INTERVAL '2 days' + INTERVAL '1 minute'), + ('sess_001', 'assistant', 'Your battery health is at 92.3% after 6 years of ownership. Degradation of 7.7% is well within the expected range for a Model Y Long Range with ~1,200 charge cycles.', NOW() - INTERVAL '2 days' + INTERVAL '1 minute 5 seconds'); + +-- ============================================================ +-- 42. EXPORT JOBS — a completed export +-- ============================================================ +INSERT INTO export_jobs (id, type, format, status, vehicle_id, start_date, end_date, file_name, file_data, file_size, record_count, error_message, created_at, updated_at, completed_at) VALUES + ('exp_2026_q1', 'drives', 'csv', 'completed', 1, '2026-01-01', '2026-03-31', 'drives_2026_q1.csv', NULL, 245000, 450, NULL, NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day'); + +-- ============================================================ +-- 43. TRIP_DRIVES — link some drives to trips +-- ============================================================ +INSERT INTO trip_drives (trip_id, drive_id) +SELECT t.id, d.id +FROM trips t +JOIN drives d ON d.start_date >= t.start_date AND d.start_date < t.end_date AND d.vehicle_id = t.vehicle_id +WHERE d.id % 10 = 0 -- sample ~10% of drives per trip to keep it manageable +LIMIT 500; + +COMMIT;