feat: add Tesla 3D car image display on dashboard

Display Tesla car renders that match the vehicle's actual configuration
(model, color, wheels) based on data from TeslamateAPI.

- Add CarExterior model to parse exterior_color, wheel_type from API
- Create CarImageResolver to map TeslamateAPI values to compositor codes
- Download 80 pre-rendered car images (Model 3 & Y, 10 colors, various wheels)
- Add Python asset fetching script using Tesla compositor API
- Display car image at top of dashboard

The car image is dynamically selected based on:
- Model (3, Y)
- Exterior color (MidnightSilver -> PMNG, etc.)
- Wheel type (Pinwheel18CapKit -> W38B, etc.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Davide Ferrari
2025-12-20 18:52:55 +01:00
parent 8bcdac4edf
commit ff7be06dda
85 changed files with 452 additions and 2 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -17,12 +17,20 @@ data class CarsData(
data class CarData(
@Json(name = "car_id") val carId: Int,
@Json(name = "name") val name: String? = null,
@Json(name = "car_details") val carDetails: CarDetails? = null
@Json(name = "car_details") val carDetails: CarDetails? = null,
@Json(name = "car_exterior") val carExterior: CarExterior? = null
) {
val displayName: String?
get() = name
}
@JsonClass(generateAdapter = true)
data class CarExterior(
@Json(name = "exterior_color") val exteriorColor: String? = null,
@Json(name = "spoiler_type") val spoilerType: String? = null,
@Json(name = "wheel_type") val wheelType: String? = null
)
@JsonClass(generateAdapter = true)
data class CarDetails(
@Json(name = "model") val model: String? = null,
@@ -0,0 +1,172 @@
package com.matedroid.domain.model
/**
* Resolves Tesla car image assets based on vehicle configuration.
*
* Maps TeslamateAPI values (e.g., "MidnightSilver", "Pinwheel18CapKit")
* to Tesla compositor codes (e.g., "PMNG", "W38B") to construct asset paths.
*/
object CarImageResolver {
// Model code mappings (TeslamateAPI -> Compositor)
private val MODEL_CODES = mapOf(
"3" to "m3",
"Y" to "my",
"S" to "ms",
"X" to "mx"
)
// Color code mappings (TeslamateAPI -> Compositor)
// Keys are normalized (lowercase, no spaces)
private val COLOR_CODES = mapOf(
"black" to "PBSB",
"solidblack" to "PBSB",
"obsidianblack" to "PMBL",
"midnightsilver" to "PMNG",
"midnightsilvermetallic" to "PMNG",
"silver" to "PMSS",
"silvermetallic" to "PMSS",
"white" to "PPSW",
"pearlwhite" to "PPSW",
"pearlwhitemulticoat" to "PPSW",
"deepblue" to "PPSB",
"deepbluemetallic" to "PPSB",
"blue" to "PPSB",
"red" to "PPMR",
"redmulticoat" to "PPMR",
"quicksilver" to "PN00",
"stealthgrey" to "PN01",
"stealthgray" to "PN01",
"midnightcherryred" to "PR00",
"ultrared" to "PR01"
)
// Wheel code mappings per model
// Keys are patterns that match the start of the TeslamateAPI wheel type
private val WHEEL_PATTERNS_M3 = listOf(
"pinwheel18" to "W38B",
"aero18" to "W38B",
"aeroturbine19" to "W39B",
"stiletto19" to "W39B",
"sport19" to "W39B",
"performance20" to "W32P",
"19" to "W39B", // Default 19" to Sport
"20" to "W32P", // Default 20" to Performance
"18" to "W38B" // Default 18" to Aero
)
private val WHEEL_PATTERNS_MY = listOf(
"pinwheel18" to "WY18B",
"aero18" to "WY18B",
"gemini19" to "WY19B",
"aeroturbine19" to "WY19B",
"stiletto19" to "WY19B",
"sport19" to "WY19B",
"apollo19" to "WY9S",
"induction20" to "WY0S",
"performance20" to "WY20P",
"uberturbine21" to "WY1S",
"21" to "WY1S", // Default 21" to Uberturbine
"20" to "WY0S", // Default 20" to Induction
"19" to "WY19B", // Default 19" to Sport
"18" to "WY18B" // Default 18" to Aero
)
// Default wheels per model (most common configuration)
private val DEFAULT_WHEELS = mapOf(
"m3" to "W38B", // 18" Aero
"my" to "WY19B" // 19" Sport
)
// Default color
private const val DEFAULT_COLOR = "PPSW" // Pearl White
/**
* Get the asset path for a car image based on its configuration.
*
* @param model The car model from TeslamateAPI (e.g., "3", "Y")
* @param exteriorColor The exterior color from TeslamateAPI (e.g., "MidnightSilver")
* @param wheelType The wheel type from TeslamateAPI (e.g., "Pinwheel18CapKit")
* @return The asset path (e.g., "car_images/m3_PMNG_W38B.png")
*/
fun getAssetPath(
model: String?,
exteriorColor: String?,
wheelType: String?
): String {
val modelCode = mapModel(model) ?: return getDefaultAssetPath(model)
val colorCode = mapColor(exteriorColor) ?: DEFAULT_COLOR
val wheelCode = mapWheel(modelCode, wheelType) ?: DEFAULT_WHEELS[modelCode] ?: "W38B"
return "car_images/${modelCode}_${colorCode}_${wheelCode}.png"
}
/**
* Get the default asset path for a model when configuration is unavailable.
*/
fun getDefaultAssetPath(model: String?): String {
val modelCode = mapModel(model) ?: "m3"
val defaultWheel = DEFAULT_WHEELS[modelCode] ?: "W38B"
return "car_images/${modelCode}_${DEFAULT_COLOR}_${defaultWheel}.png"
}
/**
* Check if a specific asset exists (for fallback logic).
* This should be called with actual asset checking from the AssetManager.
*/
fun getFallbackAssetPath(
model: String?,
exteriorColor: String?,
wheelType: String?,
assetExists: (String) -> Boolean
): String {
// Try exact match first
val exactPath = getAssetPath(model, exteriorColor, wheelType)
if (assetExists(exactPath)) return exactPath
// Try with default wheel
val modelCode = mapModel(model) ?: "m3"
val colorCode = mapColor(exteriorColor) ?: DEFAULT_COLOR
val defaultWheelPath = "car_images/${modelCode}_${colorCode}_${DEFAULT_WHEELS[modelCode]}.png"
if (assetExists(defaultWheelPath)) return defaultWheelPath
// Try with default color
val wheelCode = mapWheel(modelCode, wheelType) ?: DEFAULT_WHEELS[modelCode] ?: "W38B"
val defaultColorPath = "car_images/${modelCode}_${DEFAULT_COLOR}_${wheelCode}.png"
if (assetExists(defaultColorPath)) return defaultColorPath
// Fall back to complete default
return getDefaultAssetPath(model)
}
private fun mapModel(model: String?): String? {
return model?.let { MODEL_CODES[it.uppercase()] ?: MODEL_CODES[it] }
}
private fun mapColor(color: String?): String? {
if (color == null) return null
val normalized = color.lowercase().replace(" ", "").replace("-", "").replace("_", "")
return COLOR_CODES[normalized]
}
private fun mapWheel(modelCode: String, wheelType: String?): String? {
if (wheelType == null) return null
val normalized = wheelType.lowercase().replace(" ", "").replace("-", "").replace("_", "")
val patterns = when (modelCode) {
"m3" -> WHEEL_PATTERNS_M3
"my" -> WHEEL_PATTERNS_MY
else -> WHEEL_PATTERNS_M3 // Default to Model 3 patterns
}
// Find the first pattern that matches the start of the wheel type
for ((pattern, code) in patterns) {
if (normalized.startsWith(pattern)) {
return code
}
}
return null
}
}
@@ -1,7 +1,9 @@
package com.matedroid.ui.screens.dashboard
import android.graphics.BitmapFactory
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -52,6 +54,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -69,8 +73,10 @@ import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import com.matedroid.data.api.models.BatteryDetails
import com.matedroid.data.api.models.CarExterior
import com.matedroid.data.api.models.CarGeodata
import com.matedroid.data.api.models.CarStatus
import com.matedroid.domain.model.CarImageResolver
import com.matedroid.data.api.models.CarStatusDetails
import com.matedroid.data.api.models.Units
import com.matedroid.domain.model.UnitFormatter
@@ -132,6 +138,8 @@ fun DashboardScreen(
DashboardContent(
status = uiState.carStatus!!,
units = uiState.units,
carModel = uiState.selectedCarModel,
carExterior = uiState.selectedCarExterior,
onNavigateToCharges = {
uiState.selectedCarId?.let { onNavigateToCharges(it) }
},
@@ -229,6 +237,8 @@ private fun ErrorContent(message: String) {
private fun DashboardContent(
status: CarStatus,
units: Units? = null,
carModel: String? = null,
carExterior: CarExterior? = null,
onNavigateToCharges: () -> Unit = {},
onNavigateToDrives: () -> Unit = {},
onNavigateToBattery: () -> Unit = {},
@@ -245,6 +255,12 @@ private fun DashboardContent(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Car image
CarImageCard(
carModel = carModel,
carExterior = carExterior
)
// Compact status indicators (right-aligned)
StatusIndicatorsRow(status, units)
@@ -271,6 +287,56 @@ private fun DashboardContent(
}
}
@Composable
private fun CarImageCard(
carModel: String?,
carExterior: CarExterior?,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val assetPath = remember(carModel, carExterior) {
CarImageResolver.getAssetPath(
model = carModel,
exteriorColor = carExterior?.exteriorColor,
wheelType = carExterior?.wheelType
)
}
val bitmap = remember(assetPath) {
try {
context.assets.open(assetPath).use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
} catch (e: Exception) {
// Try fallback to default
try {
val fallbackPath = CarImageResolver.getDefaultAssetPath(carModel)
context.assets.open(fallbackPath).use { inputStream ->
BitmapFactory.decodeStream(inputStream)
}
} catch (e2: Exception) {
null
}
}
}
if (bitmap != null) {
Box(
modifier = modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "Car image",
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.Fit
)
}
}
}
@Composable
private fun StatusIndicatorsRow(status: CarStatus, units: Units?) {
Row(
@@ -3,6 +3,7 @@ package com.matedroid.ui.screens.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.matedroid.data.api.models.CarData
import com.matedroid.data.api.models.CarExterior
import com.matedroid.data.api.models.CarStatus
import com.matedroid.data.api.models.Units
import com.matedroid.data.repository.ApiResult
@@ -26,8 +27,17 @@ data class DashboardUiState(
val units: Units? = null,
val error: String? = null
) {
private val selectedCar: CarData?
get() = cars.find { it.carId == selectedCarId }
val selectedCarEfficiency: Double?
get() = cars.find { it.carId == selectedCarId }?.carDetails?.efficiency
get() = selectedCar?.carDetails?.efficiency
val selectedCarModel: String?
get() = selectedCar?.carDetails?.model
val selectedCarExterior: CarExterior?
get() = selectedCar?.carExterior
}
@HiltViewModel
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["httpx", "rich"]
# ///
"""
Tesla Car Image Asset Fetcher
Downloads Tesla car 3D renders from the Tesla compositor service
for use in the MateDroid Android app.
Usage:
./fetch_tesla_assets.py [--output-dir PATH] [--dry-run]
"""
import argparse
import asyncio
import sys
from pathlib import Path
import httpx
from rich.console import Console
from rich.progress import Progress, TaskID
console = Console()
# Tesla Compositor Configuration
COMPOSITOR_BASE_URL = "https://static-assets.tesla.com/v1/compositor/"
IMAGE_VIEW = "STUD_3QTR" # 3/4 front view
IMAGE_SIZE = 800
BACKGROUND_OPT = 1 # Transparent background
# Model configurations
MODELS = {
"m3": {
"name": "Model 3",
"wheels": ["W38B", "W39B", "W32P"], # Aero 18", Sport 19", Performance 20"
},
"my": {
"name": "Model Y",
"wheels": ["WY18B", "WY19B", "WY20P", "WY0S", "WY1S"], # Various Model Y wheels
},
}
# Color codes - common across models
COLORS = {
"PBSB": "Solid Black",
"PMNG": "Midnight Silver Metallic",
"PMSS": "Silver Metallic",
"PPSW": "Pearl White Multi-Coat",
"PPSB": "Deep Blue Metallic",
"PPMR": "Red Multi-Coat",
"PN00": "Quicksilver",
"PN01": "Stealth Grey",
"PR00": "Midnight Cherry Red",
"PR01": "Ultra Red",
}
def build_compositor_url(model: str, color: str, wheel: str) -> str:
"""Build the Tesla compositor URL for a specific configuration."""
options = f"{color},{wheel}"
return (
f"{COMPOSITOR_BASE_URL}"
f"?model={model}"
f"&view={IMAGE_VIEW}"
f"&size={IMAGE_SIZE}"
f"&options={options}"
f"&bkba_opt={BACKGROUND_OPT}"
)
def get_output_filename(model: str, color: str, wheel: str) -> str:
"""Generate the output filename for an asset."""
return f"{model}_{color}_{wheel}.png"
async def download_image(
client: httpx.AsyncClient,
url: str,
output_path: Path,
progress: Progress,
task_id: TaskID,
description: str,
) -> bool:
"""Download a single image from the compositor."""
try:
response = await client.get(url, follow_redirects=True)
response.raise_for_status()
# Verify it's actually a PNG
content = response.content
if not content.startswith(b'\x89PNG'):
console.print(f"[yellow]Warning: {description} - Not a valid PNG[/yellow]")
return False
output_path.write_bytes(content)
progress.update(task_id, advance=1)
return True
except httpx.HTTPStatusError as e:
console.print(f"[red]Error: {description} - HTTP {e.response.status_code}[/red]")
progress.update(task_id, advance=1)
return False
except Exception as e:
console.print(f"[red]Error: {description} - {e}[/red]")
progress.update(task_id, advance=1)
return False
async def download_all_assets(output_dir: Path, dry_run: bool = False) -> tuple[int, int]:
"""Download all asset combinations."""
output_dir.mkdir(parents=True, exist_ok=True)
# Build list of all downloads
downloads = []
for model_code, model_config in MODELS.items():
for color_code in COLORS:
for wheel_code in model_config["wheels"]:
filename = get_output_filename(model_code, color_code, wheel_code)
url = build_compositor_url(model_code, color_code, wheel_code)
output_path = output_dir / filename
description = f"{model_config['name']} {COLORS[color_code]} {wheel_code}"
downloads.append((url, output_path, description))
total = len(downloads)
console.print(f"\n[bold]Tesla Car Asset Fetcher[/bold]")
console.print(f"Total images to download: {total}")
console.print(f"Output directory: {output_dir}\n")
if dry_run:
console.print("[yellow]Dry run mode - showing what would be downloaded:[/yellow]\n")
for url, output_path, description in downloads:
console.print(f" {description}")
console.print(f" -> {output_path.name}")
console.print(f" URL: {url}\n")
return total, 0
# Download with progress bar
success_count = 0
async with httpx.AsyncClient(timeout=30.0) as client:
with Progress() as progress:
task_id = progress.add_task("[cyan]Downloading...", total=total)
# Process in batches to avoid overwhelming the server
batch_size = 5
for i in range(0, len(downloads), batch_size):
batch = downloads[i:i + batch_size]
tasks = [
download_image(client, url, output_path, progress, task_id, description)
for url, output_path, description in batch
]
results = await asyncio.gather(*tasks)
success_count += sum(results)
# Small delay between batches
if i + batch_size < len(downloads):
await asyncio.sleep(0.5)
return total, success_count
def main():
parser = argparse.ArgumentParser(
description="Download Tesla car images from compositor service"
)
parser.add_argument(
"--output-dir",
type=Path,
default=Path(__file__).parent.parent / "app/src/main/assets/car_images",
help="Output directory for images",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be downloaded without actually downloading",
)
args = parser.parse_args()
total, success = asyncio.run(download_all_assets(args.output_dir, args.dry_run))
if not args.dry_run:
console.print(f"\n[bold]Download complete![/bold]")
console.print(f" Success: {success}/{total}")
if success < total:
console.print(f" [yellow]Failed: {total - success}[/yellow]")
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()