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>
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
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
|
||||
|
||||
@@ -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()
|
||||