diff --git a/.gitignore b/.gitignore index 6a6ca91d..e53dc55d 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,45 +15,29 @@ 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 +# Node +node_modules/ +dist/ +.env.local +.env.*.local -# Python (for any scripts) +# TeslaSync +certs/ +*.pem +*.key +.env + +# Python __pycache__/ -*.pyc -.venv/ -# Terraform (future) -.terraform/ -*.tfstate* +# Test files +test_*.py +test_*.js +publish_test.sh +package-lock.json -# Secrets -*.pem -*.key +package.json 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/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 +} 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/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/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/internal/api/climate_handler.go b/internal/api/climate_handler.go index 308ef6e8..59b30d74 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 { + writeJSON(w, http.StatusOK, nil) + return + } writeJSON(w, http.StatusOK, snap) } 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/helpers.go b/internal/api/helpers.go index c129604a..1f39f50c 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")) } } @@ -87,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 { @@ -95,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 diff --git a/internal/api/location_snapshot_handler.go b/internal/api/location_snapshot_handler.go index 1518ef94..d549a705 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 { + 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 1b8e7cae..e76c75f7 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 { + 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 b605ff83..05a598ba 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 { + writeJSON(w, http.StatusOK, nil) + return + } writeJSON(w, http.StatusOK, snap) } diff --git a/internal/api/router.go b/internal/api/router.go index 6b39210b..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, @@ -111,12 +113,16 @@ 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) } 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)) @@ -182,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) @@ -345,6 +363,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/security_handler.go b/internal/api/security_handler.go index 78733f50..fa0c3d5d 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 { + writeJSON(w, http.StatusOK, nil) + return + } writeJSON(w, http.StatusOK, evt) } 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/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 7f1a7ca7..f3097dad 100644 --- a/internal/api/telemetry_handler.go +++ b/internal/api/telemetry_handler.go @@ -43,6 +43,14 @@ 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.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. @@ -93,10 +101,62 @@ 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), + streamingState: make(map[string]*VehicleStreamState), + lastWriteAt: make(map[string]time.Time), + accumulatedSignals: make(map[string]map[string]interface{}), } } +// 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() + + 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 { Name string `json:"name"` Value interface{} `json:"value"` @@ -161,6 +221,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 { @@ -231,79 +294,139 @@ 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] + 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() + 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 - 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") + // Drain accumulated signals for this write cycle + var writeSignals map[string]interface{} + if shouldWrite { + 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, writeSignals) } - // Track vehicle state transitions (online/driving/charging) - h.trackStateTransition(bgCtx, vehicleID, signals) + // Throttled snapshot writes โ€” only run every 10s per vehicle + 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) }() } } -// 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 { @@ -491,7 +614,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 @@ -671,7 +807,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 } @@ -860,9 +998,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 } @@ -1002,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) @@ -1109,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") } } @@ -1118,7 +1261,21 @@ 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"] + _, 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 } 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/internal/api/vehicle_handler.go b/internal/api/vehicle_handler.go index de382894..5f2c5827 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" @@ -11,21 +12,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 +161,32 @@ 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, 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{}{ + "state": state, + "live": true, + "data_source": "fleet_telemetry", + }) + return + } + // If DB state build failed (stale/missing data), fall through to 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) 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 +195,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 +230,169 @@ 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 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() + + pos, err := h.positionRepo.GetLatest(ctx, vehicle.ID) + if err != nil || pos == nil { + 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 == "" { + 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 (always check โ€” may have fresher battery data) + // 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 { + 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 { + 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 โ€” check multiple indicators + isCharging := false + if ct.ChargeRateMph != nil && *ct.ChargeRateMph > 0 { + isCharging = true + } + 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 + 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 && *ct.ACChargingPower > 0 { + 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") 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/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 +} 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/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/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 } 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/internal/worker/gas_price_worker.go b/internal/worker/gas_price_worker.go new file mode 100644 index 00000000..add98f3b --- /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/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, + ) + + reqCtx, cancel := context.WithTimeout(context.Background(), 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/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); 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; +$$; diff --git a/migrations/000019_gas_price.down.sql b/migrations/000019_gas_price.down.sql new file mode 100644 index 00000000..7c5aeb15 --- /dev/null +++ b/migrations/000019_gas_price.down.sql @@ -0,0 +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 new file mode 100644 index 00000000..bac2c5a7 --- /dev/null +++ b/migrations/000019_gas_price.up.sql @@ -0,0 +1,30 @@ +-- 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; + +-- 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; +$$; 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/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 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; 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()); diff --git a/web/src/api.ts b/web/src/api.ts index 35bf5a7c..2efd9efe 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -56,6 +56,7 @@ export interface Position { outside_temp: number | null is_climate_on: boolean | null created_at: string + fan_status?: number } export interface Drive { @@ -110,6 +111,8 @@ export interface Geofence { longitude: number radius: number cost_per_kwh: number | null + created_at?: string + updated_at?: string } export interface AppSettings { @@ -123,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 { @@ -184,7 +190,7 @@ export interface Alert { severity: 'info' | 'warning' | 'critical' title: string message: string - read: boolean + is_read: boolean created_at: string } @@ -195,8 +201,6 @@ export interface AlertRule { enabled: boolean threshold: number vehicle_id: number | null - notify_push: boolean - notify_mqtt: boolean created_at: string updated_at: string } @@ -276,6 +280,8 @@ export interface NotificationLog { error: string created_at: string sent_at: string | null + scheduled_at?: string + latency_ms?: number } export interface NotificationStats { @@ -345,6 +351,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 +400,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 +436,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 } @@ -1244,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/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 aa9ef7f8..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,14 +180,15 @@ 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 }) 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], @@ -194,7 +196,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 @@ -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/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/Alerts.tsx b/web/src/pages/Alerts.tsx index 24f2e7e7..1a09666e 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 && ( - )} @@ -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: () => { @@ -1150,16 +1148,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/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 051c2e9e..177d34b4 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]) @@ -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/Dashboard.tsx b/web/src/pages/Dashboard.tsx index f93fbe4c..7ececaef 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)}ยฐ` : 'โ€”'}

) : ( @@ -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 => { @@ -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) @@ -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' }, @@ -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}` : 'โ€”'}
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/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 โ”€โ”€ */ diff --git a/web/src/pages/Energy.tsx b/web/src/pages/Energy.tsx index a0c0fb5f..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 => ( @@ -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)}` : 'โ€”'} ))} 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 */} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 2adedde4..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() @@ -44,6 +45,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 +373,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" + /> + )} @@ -398,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 */} 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) }, })