Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ A Tesla Model Y style driving dashboard for iPhone: a big speedometer over a liv
Apple Map that follows your heading, fed by **your** car's data through the
**Tesla Fleet API** with realtime updates over WebSocket.

![Voltpit dashboard: speedometer, gear selector and estimated range over a live map of Kungsholmen, Stockholm, with battery and power cards](docs/images/dashboard.png)

```mermaid
flowchart LR
car["Tesla vehicle<br/>(your Model Y)"]
Expand Down
77 changes: 67 additions & 10 deletions backend/src/sources/SimulatorSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,74 @@ export class SimulatorSource implements VehicleSource {
private targetMph = 35;
private battery = 82;

// A loop around a few blocks (San Francisco). Heading is derived from motion.
// A real driving loop through Kungsholmen, Stockholm, road-snapped via the
// OSRM routing engine so every waypoint sits exactly on the street network.
// Heading is derived from motion; the dense point spacing keeps the
// straight-line interpolation hugging the actual roads instead of cutting
// across blocks.
private readonly route: Waypoint[] = [
{ lat: 37.7766, lng: -122.4172 },
{ lat: 37.7793, lng: -122.4131 },
{ lat: 37.7821, lng: -122.4089 },
{ lat: 37.7848, lng: -122.4048 },
{ lat: 37.7861, lng: -122.4006 },
{ lat: 37.784, lng: -122.3964 },
{ lat: 37.7807, lng: -122.3983 },
{ lat: 37.7779, lng: -122.4039 },
{ lat: 37.7758, lng: -122.4101 },
{ lat: 59.332886, lng: 18.029528 },
{ lat: 59.332911, lng: 18.029421 },
{ lat: 59.333366, lng: 18.029718 },
{ lat: 59.333816, lng: 18.027946 },
{ lat: 59.333857, lng: 18.027621 },
{ lat: 59.333941, lng: 18.027829 },
{ lat: 59.334627, lng: 18.028234 },
{ lat: 59.334674, lng: 18.030906 },
{ lat: 59.334501, lng: 18.032073 },
{ lat: 59.33448, lng: 18.032249 },
{ lat: 59.334438, lng: 18.032571 },
{ lat: 59.33425, lng: 18.033999 },
{ lat: 59.334222, lng: 18.034217 },
{ lat: 59.334167, lng: 18.034649 },
{ lat: 59.334045, lng: 18.035576 },
{ lat: 59.334178, lng: 18.035646 },
{ lat: 59.335868, lng: 18.036487 },
{ lat: 59.33641, lng: 18.036753 },
{ lat: 59.336095, lng: 18.038496 },
{ lat: 59.335871, lng: 18.039536 },
{ lat: 59.336095, lng: 18.038496 },
{ lat: 59.33641, lng: 18.036753 },
{ lat: 59.335868, lng: 18.036487 },
{ lat: 59.334178, lng: 18.035646 },
{ lat: 59.334045, lng: 18.035576 },
{ lat: 59.33374, lng: 18.037922 },
{ lat: 59.333701, lng: 18.038228 },
{ lat: 59.333546, lng: 18.039415 },
{ lat: 59.333327, lng: 18.041095 },
{ lat: 59.333031, lng: 18.043382 },
{ lat: 59.332847, lng: 18.044821 },
{ lat: 59.332791, lng: 18.045256 },
{ lat: 59.332615, lng: 18.045202 },
{ lat: 59.331531, lng: 18.044551 },
{ lat: 59.3309, lng: 18.044183 },
{ lat: 59.330418, lng: 18.043943 },
{ lat: 59.329984, lng: 18.043713 },
{ lat: 59.329803, lng: 18.043617 },
{ lat: 59.329075, lng: 18.043218 },
{ lat: 59.329018, lng: 18.043057 },
{ lat: 59.329062, lng: 18.042725 },
{ lat: 59.329235, lng: 18.041428 },
{ lat: 59.32937, lng: 18.040408 },
{ lat: 59.329499, lng: 18.039452 },
{ lat: 59.329685, lng: 18.03808 },
{ lat: 59.329766, lng: 18.037499 },
{ lat: 59.32994, lng: 18.036324 },
{ lat: 59.330307, lng: 18.034694 },
{ lat: 59.330678, lng: 18.032906 },
{ lat: 59.330754, lng: 18.032501 },
{ lat: 59.331082, lng: 18.031082 },
{ lat: 59.331159, lng: 18.030781 },
{ lat: 59.331749, lng: 18.031086 },
{ lat: 59.331926, lng: 18.031186 },
{ lat: 59.332074, lng: 18.031272 },
{ lat: 59.332224, lng: 18.031211 },
{ lat: 59.332169, lng: 18.030796 },
{ lat: 59.332108, lng: 18.028968 },
{ lat: 59.332107, lng: 18.028808 },
{ lat: 59.332236, lng: 18.02867 },
{ lat: 59.332911, lng: 18.029421 },
{ lat: 59.332886, lng: 18.029528 },
];

constructor(
Expand Down
Binary file added docs/images/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 20 additions & 4 deletions ios/TeslaDash/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,17 +209,21 @@ struct DashboardView: View {
}
}

/// Range-style readout at the foot of the pod (battery %, the only
/// energy figure the Fleet API gives us).
/// Estimated-range readout at the foot of the pod. Battery % already lives
/// in the right-hand card, so here we surface the derived driving range
/// instead of repeating the same number.
private var rangeReadout: some View {
HStack(spacing: 8) {
Image(systemName: "car.fill")
Image(systemName: "road.lanes")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color(red: 0.45, green: 0.85, blue: 1.0))
Text(batteryText)
Text(rangeText)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white)
Text("est. range")
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.45))
}
}

Expand Down Expand Up @@ -288,6 +292,18 @@ struct DashboardView: View {
(stream.state?.batteryLevel ?? 100) <= 15 ? .red : Color(red: 0.45, green: 0.85, blue: 1.0)
}

/// Estimated driving range derived from battery level. The Fleet API only
/// gives us a charge percentage, so we scale it against a nominal full
/// range (Model Y Long Range: ~330 mi / 530 km) and render it in the
/// configured unit.
private var rangeText: String {
guard let level = stream.state?.batteryLevel else { return "—" }
let metric = stream.state?.usesMetric ?? false
let fullRange = metric ? 530.0 : 330.0
let estimate = Int((Double(level) / 100.0 * fullRange).rounded())
return "\(estimate) \(metric ? "km" : "mi")"
}

private var powerTitle: String { (stream.state?.power ?? 0) < 0 ? "Regen" : "Power" }

private var powerText: String {
Expand Down
35 changes: 28 additions & 7 deletions ios/TeslaDash/Views/MapView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct MapView: UIViewRepresentable {
var heading: Double?
var moving: Bool

private static let defaultCenter = CLLocationCoordinate2D(latitude: 37.7766, longitude: -122.4172)
private static let defaultCenter = CLLocationCoordinate2D(latitude: 59.332886, longitude: 18.029528)
// Tight, steep camera so buildings rise up for a street-level 3D feel.
private static let cameraDistance: CLLocationDistance = 320
private static let pitch: CGFloat = 60
Expand Down Expand Up @@ -55,11 +55,6 @@ struct MapView: UIViewRepresentable {
func updateUIView(_ map: MKMapView, context: Context) {
guard let coordinate else { return }

// Move the puck to the new position.
if let marker = context.coordinator.marker {
marker.coordinate = coordinate
}

let bearing = moving ? (heading ?? map.camera.heading) : map.camera.heading

// Follow the car but keep whatever zoom (camera distance) the driver
Expand All @@ -70,11 +65,37 @@ struct MapView: UIViewRepresentable {
pitch: map.camera.pitch,
heading: bearing
)
map.setCamera(camera, animated: true)

// The backend streams one discrete position per update, but the
// cadence varies by data source: the simulator pushes every ~250 ms,
// the Tesla Fleet API poll lands every ~2.5 s, and Fleet Telemetry is
// sub-second. A fixed glide tuned to one source would hop-and-freeze on
// the others, so the animation duration tracks the measured gap since
// the previous update and spreads the move evenly across it. That turns
// every source into a continuous glide instead of a jump. Clamped so a
// first fix or a long offline gap cannot produce an absurd crawl.
let now = Date()
let gap = context.coordinator.lastUpdate.map { now.timeIntervalSince($0) } ?? 0.3
context.coordinator.lastUpdate = now
let duration = min(max(gap, 0.15), 2.5)

// .beginFromCurrentState lets each new update smoothly redirect the
// in-flight motion rather than stopping and restarting it.
UIView.animate(
withDuration: duration,
delay: 0,
options: [.curveLinear, .allowUserInteraction, .beginFromCurrentState]
) {
context.coordinator.marker?.coordinate = coordinate
map.setCamera(camera, animated: false)
}
}

final class Coordinator: NSObject, MKMapViewDelegate {
var marker: CarAnnotation?
// Timestamp of the previous position update, used to size each glide to
// the real interval between updates regardless of data source.
var lastUpdate: Date?

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard annotation is CarAnnotation else { return nil }
Expand Down
Loading