{
- if (!this.computed_model_path) {
+ if (!this.computed_model_path && !this.model_override_path) {
this.show_model_not_found = true
}
}, 5000)
@@ -299,6 +307,11 @@ export default Vue.extend({
this.reloadAnnotations()
},
methods: {
+ async refresh_model_override(): Promise {
+ this.model_override_path = await checkModelOverrides()
+ this.override_annotations = await this.loadAnnotationsOverride()
+ this.forceRefreshAnnotations()
+ },
onModelViewerLoad() {
this.redraw()
this.hideIrrelevantParts()
@@ -363,9 +376,13 @@ export default Vue.extend({
if (!this.model_override_path) {
return {}
}
- const candidate_path = this.model_override_path?.replace('glb', 'json')
- const response = await axios.get(candidate_path)
- return response.data?.annotations ?? {}
+ const candidate_path = this.model_override_path.replace('glb', 'json')
+ try {
+ const response = await axios.get(candidate_path)
+ return response.data?.annotations ?? {}
+ } catch {
+ return {}
+ }
},
setAlphas(new_color: number, text = ''): void {
const lower_text = text.toLowerCase()
diff --git a/core/frontend/src/store/customization.ts b/core/frontend/src/store/customization.ts
new file mode 100644
index 0000000000..d8c02436a3
--- /dev/null
+++ b/core/frontend/src/store/customization.ts
@@ -0,0 +1,304 @@
+import {
+ Action, getModule,
+ Module, Mutation, VuexModule,
+} from 'vuex-module-decorators'
+
+import Notifier from '@/libs/notifier'
+import store from '@/store'
+import { BrandingAsset, ModelEntry, ThemeStatus } from '@/types/customization'
+import { customization_service } from '@/types/frontend_services'
+import back_axios from '@/utils/api'
+
+const API_URL = '/customization/v1.0'
+const notifier = new Notifier(customization_service)
+const THEME_CSS_HREF_FRAGMENT = '/userdata/styles/theme_style.css'
+const EMPTY_BRANDING: BrandingAsset = { url: null, size_bytes: null }
+
+function reloadThemeCss(): void {
+ // Force the browser to re-fetch the generated stylesheet so the new
+ // gradient/scrollbar colors take effect immediately.
+ const links = document.querySelectorAll('link[rel="stylesheet"]')
+ links.forEach((link) => {
+ const href = link.getAttribute('href') ?? ''
+ if (href.includes(THEME_CSS_HREF_FRAGMENT)) {
+ const url = href.split('?')[0]
+ link.setAttribute('href', `${url}?t=${Date.now()}`)
+ }
+ })
+}
+
+@Module({ dynamic: true, store, name: 'customization' })
+class CustomizationStore extends VuexModule {
+ themeStatus: ThemeStatus | null = null
+
+ models: ModelEntry[] = []
+
+ logo: BrandingAsset = { ...EMPTY_BRANDING }
+
+ vehicleImage: BrandingAsset = { ...EMPTY_BRANDING }
+
+ themeSaving = false
+
+ themeResetting = false
+
+ modelUploading = false
+
+ logoUploading = false
+
+ vehicleImageUploading = false
+
+ // Cache-buster for branding URLs. Bumped automatically by setLogo/
+ // setVehicleImage so any update to a branding asset yields a new URL.
+ brandingVersion = Date.now()
+
+ get logoUrl(): string | null {
+ if (!this.logo.url) return null
+ return `${this.logo.url}?v=${this.brandingVersion}`
+ }
+
+ get vehicleImageUrl(): string | null {
+ if (!this.vehicleImage.url) return null
+ return `${this.vehicleImage.url}?v=${this.brandingVersion}`
+ }
+
+ @Mutation
+ setThemeStatus(value: ThemeStatus | null): void {
+ this.themeStatus = value
+ }
+
+ @Mutation
+ setModels(value: ModelEntry[]): void {
+ this.models = value
+ }
+
+ @Mutation
+ setLogo(value: BrandingAsset): void {
+ this.logo = value
+ this.brandingVersion = Date.now()
+ }
+
+ @Mutation
+ setVehicleImage(value: BrandingAsset): void {
+ this.vehicleImage = value
+ this.brandingVersion = Date.now()
+ }
+
+ @Mutation
+ setThemeSaving(value: boolean): void {
+ this.themeSaving = value
+ }
+
+ @Mutation
+ setThemeResetting(value: boolean): void {
+ this.themeResetting = value
+ }
+
+ @Mutation
+ setModelUploading(value: boolean): void {
+ this.modelUploading = value
+ }
+
+ @Mutation
+ setLogoUploading(value: boolean): void {
+ this.logoUploading = value
+ }
+
+ @Mutation
+ setVehicleImageUploading(value: boolean): void {
+ this.vehicleImageUploading = value
+ }
+
+ @Action
+ async refreshAll(): Promise {
+ await Promise.all([
+ this.fetchTheme(),
+ this.fetchModels(),
+ this.fetchLogo(),
+ this.fetchVehicleImage(),
+ ])
+ }
+
+ @Action
+ async fetchTheme(): Promise {
+ try {
+ const response = await back_axios({ method: 'get', url: `${API_URL}/theme`, timeout: 10000 })
+ this.setThemeStatus(response.data)
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_GET_THEME', error)
+ }
+ }
+
+ @Action
+ async saveTheme(primary: string): Promise {
+ this.setThemeSaving(true)
+ try {
+ const response = await back_axios({
+ method: 'put',
+ url: `${API_URL}/theme`,
+ data: { primary },
+ timeout: 10000,
+ })
+ this.setThemeStatus(response.data)
+ reloadThemeCss()
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_SAVE_THEME', error)
+ } finally {
+ this.setThemeSaving(false)
+ }
+ }
+
+ @Action
+ async resetTheme(): Promise {
+ this.setThemeResetting(true)
+ try {
+ await back_axios({ method: 'delete', url: `${API_URL}/theme`, timeout: 10000 })
+ await this.fetchTheme()
+ reloadThemeCss()
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_RESET_THEME', error)
+ } finally {
+ this.setThemeResetting(false)
+ }
+ }
+
+ @Action
+ async fetchModels(): Promise {
+ try {
+ const response = await back_axios({ method: 'get', url: `${API_URL}/models`, timeout: 10000 })
+ this.setModels(response.data ?? [])
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_LIST_MODELS', error)
+ }
+ }
+
+ @Action
+ async uploadModel({ file, name }: { file: File; name: string }): Promise {
+ const form_data = new FormData()
+ form_data.append('file', file)
+ this.setModelUploading(true)
+ try {
+ await back_axios({
+ method: 'post',
+ url: `${API_URL}/models`,
+ params: { name },
+ headers: { 'Content-Type': 'multipart/form-data' },
+ data: form_data,
+ timeout: 120000,
+ })
+ await this.fetchModels()
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_UPLOAD_MODEL', error)
+ } finally {
+ this.setModelUploading(false)
+ }
+ }
+
+ @Action
+ async deleteModel(name: string): Promise {
+ try {
+ await back_axios({
+ method: 'delete',
+ url: `${API_URL}/models/${encodeURIComponent(name)}`,
+ timeout: 10000,
+ })
+ await this.fetchModels()
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_DELETE_MODEL', error)
+ }
+ }
+
+ @Action
+ async fetchLogo(): Promise {
+ try {
+ const response = await back_axios({ method: 'get', url: `${API_URL}/branding/logo`, timeout: 10000 })
+ this.setLogo(response.data)
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_GET_LOGO', error)
+ }
+ }
+
+ @Action
+ async uploadLogo(file: File): Promise {
+ const form_data = new FormData()
+ form_data.append('file', file)
+ this.setLogoUploading(true)
+ try {
+ await back_axios({
+ method: 'post',
+ url: `${API_URL}/branding/logo`,
+ headers: { 'Content-Type': 'multipart/form-data' },
+ data: form_data,
+ timeout: 60000,
+ })
+ await this.fetchLogo()
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_UPLOAD_LOGO', error)
+ } finally {
+ this.setLogoUploading(false)
+ }
+ }
+
+ @Action
+ async deleteLogo(): Promise {
+ try {
+ await back_axios({ method: 'delete', url: `${API_URL}/branding/logo`, timeout: 10000 })
+ await this.fetchLogo()
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_DELETE_LOGO', error)
+ }
+ }
+
+ @Action
+ async fetchVehicleImage(): Promise {
+ try {
+ const response = await back_axios({
+ method: 'get',
+ url: `${API_URL}/branding/vehicle-image`,
+ timeout: 10000,
+ })
+ this.setVehicleImage(response.data)
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_GET_VEHICLE_IMAGE', error)
+ }
+ }
+
+ @Action
+ async uploadVehicleImage(file: File): Promise {
+ const form_data = new FormData()
+ form_data.append('file', file)
+ this.setVehicleImageUploading(true)
+ try {
+ await back_axios({
+ method: 'post',
+ url: `${API_URL}/branding/vehicle-image`,
+ headers: { 'Content-Type': 'multipart/form-data' },
+ data: form_data,
+ timeout: 60000,
+ })
+ await this.fetchVehicleImage()
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_UPLOAD_VEHICLE_IMAGE', error)
+ } finally {
+ this.setVehicleImageUploading(false)
+ }
+ }
+
+ @Action
+ async deleteVehicleImage(): Promise {
+ try {
+ await back_axios({
+ method: 'delete',
+ url: `${API_URL}/branding/vehicle-image`,
+ timeout: 10000,
+ })
+ await this.fetchVehicleImage()
+ } catch (error) {
+ notifier.pushBackError('CUSTOMIZATION_DELETE_VEHICLE_IMAGE', error)
+ }
+ }
+}
+
+const customization_store = getModule(CustomizationStore)
+
+export { CustomizationStore }
+export default customization_store
diff --git a/core/frontend/src/types/customization.ts b/core/frontend/src/types/customization.ts
new file mode 100644
index 0000000000..a90d120279
--- /dev/null
+++ b/core/frontend/src/types/customization.ts
@@ -0,0 +1,16 @@
+export interface ThemeStatus {
+ primary: string
+ palette: Record
+ css_url: string
+}
+
+export interface ModelEntry {
+ name: string
+ size_bytes: number
+ url: string
+}
+
+export interface BrandingAsset {
+ url: string | null
+ size_bytes: number | null
+}
diff --git a/core/frontend/src/types/frontend_services.ts b/core/frontend/src/types/frontend_services.ts
index 52d2fa8d9c..5702ae1941 100644
--- a/core/frontend/src/types/frontend_services.ts
+++ b/core/frontend/src/types/frontend_services.ts
@@ -77,6 +77,13 @@ export const commander_service: Service = {
version: '0.1.0',
}
+export const customization_service: Service = {
+ name: 'Customization',
+ description: 'Manage BlueOS visual customization (theme color, 3D model overrides, branding).',
+ company: 'Blue Robotics',
+ version: '0.1.0',
+}
+
export const nmea_injector_service: Service = {
name: 'NMEA Injector',
description: 'Allows management of sockets for NMEA data injectior.',
diff --git a/core/frontend/src/views/SettingsView.vue b/core/frontend/src/views/SettingsView.vue
index a2a62f37f7..20f2aa12c9 100644
--- a/core/frontend/src/views/SettingsView.vue
+++ b/core/frontend/src/views/SettingsView.vue
@@ -93,6 +93,10 @@
+
+
+
+
@@ -492,6 +496,7 @@ import Vue from 'vue'
import SpinningLogo from '@/components/common/SpinningLogo.vue'
import WarningDialog from '@/components/common/WarningDialog.vue'
+import ThemeCustomization from '@/components/customization/ThemeCustomization.vue'
import filebrowser from '@/libs/filebrowser'
import Notifier from '@/libs/notifier'
import settings from '@/libs/settings'
@@ -511,6 +516,7 @@ export default Vue.extend({
name: 'SettingsView',
components: {
+ ThemeCustomization,
SpinningLogo,
WarningDialog,
},
diff --git a/core/frontend/vite.config.js b/core/frontend/vite.config.js
index 07da1c8a4c..cf102f4f32 100644
--- a/core/frontend/vite.config.js
+++ b/core/frontend/vite.config.js
@@ -176,6 +176,9 @@ export default defineConfig(({ command, mode }) => {
'^/commander': {
target: SERVER_ADDRESS,
},
+ '^/customization': {
+ target: SERVER_ADDRESS,
+ },
'^/disk-usage': {
target: SERVER_ADDRESS,
},
diff --git a/core/pyproject.toml b/core/pyproject.toml
index 2aa3335ba1..66a74d5204 100644
--- a/core/pyproject.toml
+++ b/core/pyproject.toml
@@ -12,6 +12,7 @@ dependencies = [
"bridget",
"cable_guy",
"commander",
+ "customization",
"disk_usage",
"nmea_injector",
"pardal",
@@ -56,6 +57,7 @@ beacon = { workspace = true }
bridget = { workspace = true }
cable_guy = { workspace = true }
commander = { workspace = true }
+customization = { workspace = true }
disk_usage = { workspace = true }
nmea_injector = { workspace = true }
pardal = { workspace = true }
diff --git a/core/services/customization/main.py b/core/services/customization/main.py
new file mode 100755
index 0000000000..64ce1ca14e
--- /dev/null
+++ b/core/services/customization/main.py
@@ -0,0 +1,357 @@
+#! /usr/bin/env python3
+
+import asyncio
+import json
+import logging
+import re
+from functools import wraps
+from pathlib import Path
+from typing import Any, List, Optional
+
+from commonwealth.utils.apis import GenericErrorHandlingRoute, PrettyJSONResponse
+from commonwealth.utils.logs import InterceptHandler, init_logger
+from commonwealth.utils.sentry_config import init_sentry_async
+from fastapi import APIRouter, FastAPI, File, HTTPException, UploadFile, status
+from fastapi_versioning import VersionedFastAPI, versioned_api_route
+from loguru import logger
+from pydantic import BaseModel, Field
+from storage import (
+ ALLOWED_IMAGE_EXTENSIONS,
+ ALLOWED_MODEL_EXTENSIONS,
+ BRANDING_DIR,
+ LOGO_BASENAME,
+ MODELS_DIR,
+ THEME_CONFIG_FILE,
+ THEME_FILE,
+ VEHICLE_IMAGE_BASENAME,
+ ensure_dirs,
+ find_branding_file,
+ remove_branding_file,
+ safe_join,
+)
+from theme import derive_palette, render_css
+from uvicorn import Config, Server
+
+SERVICE_NAME = "customization"
+PORT = 9152
+DEFAULT_PRIMARY = "#2699D0"
+HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
+MAX_UPLOAD_BYTES = 200 * 1024 * 1024
+
+logging.basicConfig(handlers=[InterceptHandler()], level=logging.DEBUG)
+init_logger(SERVICE_NAME)
+logger.info("Starting Customization service")
+
+
+class ThemeConfig(BaseModel):
+ primary: str = Field(..., description="Primary color in #RRGGBB format")
+
+
+class ThemeStatus(BaseModel):
+ primary: str = Field(..., description="Current primary color")
+ palette: dict[str, str] = Field(..., description="Derived palette anchors")
+ css_url: str = Field(..., description="URL where the generated CSS is served")
+
+
+class ModelEntry(BaseModel):
+ name: str = Field(..., description="Relative path under modeloverrides/")
+ size_bytes: int = Field(..., description="File size in bytes")
+ url: str = Field(..., description="URL where the model is served")
+
+
+class BrandingAsset(BaseModel):
+ url: Optional[str] = Field(None, description="URL where the asset is served, if present")
+ size_bytes: Optional[int] = Field(None, description="File size in bytes, if present")
+
+
+def to_http_exception(endpoint: Any) -> Any:
+ is_async = asyncio.iscoroutinefunction(endpoint)
+
+ @wraps(endpoint)
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
+ try:
+ if is_async:
+ return await endpoint(*args, **kwargs)
+ return endpoint(*args, **kwargs)
+ except HTTPException as exception:
+ raise exception
+ except ValueError as exception:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exception)) from exception
+ except Exception as exception:
+ logger.exception("Customization endpoint failed")
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exception)) from exception
+
+ return wrapper
+
+
+def load_theme_config() -> ThemeConfig:
+ if THEME_CONFIG_FILE.exists():
+ try:
+ data = json.loads(THEME_CONFIG_FILE.read_text(encoding="utf-8"))
+ return ThemeConfig(**data)
+ except Exception as exception:
+ logger.warning(f"Failed to read theme config, falling back to default: {exception}")
+ return ThemeConfig(primary=DEFAULT_PRIMARY)
+
+
+def save_theme_config(config: ThemeConfig) -> None:
+ ensure_dirs()
+ THEME_CONFIG_FILE.write_text(json.dumps(config.dict(), indent=2), encoding="utf-8")
+
+
+def write_theme_css(primary: str) -> None:
+ ensure_dirs()
+ THEME_FILE.write_text(render_css(primary), encoding="utf-8")
+
+
+async def save_upload(upload: UploadFile, destination: Path) -> int:
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ written = 0
+ chunk_size = 1024 * 1024
+ with destination.open("wb") as out:
+ while True:
+ chunk = await upload.read(chunk_size)
+ if not chunk:
+ break
+ written += len(chunk)
+ if written > MAX_UPLOAD_BYTES:
+ out.close()
+ destination.unlink(missing_ok=True)
+ raise HTTPException(
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
+ detail=f"Upload exceeds {MAX_UPLOAD_BYTES} bytes.",
+ )
+ out.write(chunk)
+ return written
+
+
+theme_router = APIRouter(
+ prefix="/theme",
+ tags=["theme"],
+ route_class=versioned_api_route(1, 0),
+ responses={status.HTTP_404_NOT_FOUND: {"description": "Not found"}},
+)
+
+
+@theme_router.get("", response_model=ThemeStatus, summary="Get the current theme configuration.")
+@to_http_exception
+async def get_theme() -> ThemeStatus:
+ config = load_theme_config()
+ palette = derive_palette(config.primary)
+ if not THEME_FILE.exists():
+ write_theme_css(config.primary)
+ return ThemeStatus(primary=config.primary, palette=palette, css_url="/userdata/styles/theme_style.css")
+
+
+@theme_router.put("", response_model=ThemeStatus, summary="Set the primary color and regenerate the theme CSS.")
+@to_http_exception
+async def set_theme(config: ThemeConfig) -> ThemeStatus:
+ if not HEX_COLOR_RE.match(config.primary):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Primary color must be in #RRGGBB format.",
+ )
+ save_theme_config(config)
+ write_theme_css(config.primary)
+ palette = derive_palette(config.primary)
+ return ThemeStatus(primary=config.primary, palette=palette, css_url="/userdata/styles/theme_style.css")
+
+
+@theme_router.delete("", status_code=status.HTTP_204_NO_CONTENT, summary="Reset the theme to defaults.")
+@to_http_exception
+async def reset_theme() -> None:
+ THEME_CONFIG_FILE.unlink(missing_ok=True)
+ THEME_FILE.unlink(missing_ok=True)
+
+
+models_router = APIRouter(
+ prefix="/models",
+ tags=["models"],
+ route_class=versioned_api_route(1, 0),
+ responses={status.HTTP_404_NOT_FOUND: {"description": "Not found"}},
+)
+
+
+def list_model_files() -> List[ModelEntry]:
+ if not MODELS_DIR.exists():
+ return []
+ entries: List[ModelEntry] = []
+ for path in sorted(MODELS_DIR.rglob("*")):
+ if not path.is_file() or path.suffix.lower() not in ALLOWED_MODEL_EXTENSIONS:
+ continue
+ rel = path.relative_to(MODELS_DIR).as_posix()
+ entries.append(
+ ModelEntry(
+ name=rel,
+ size_bytes=path.stat().st_size,
+ url=f"/userdata/modeloverrides/{rel}",
+ )
+ )
+ return entries
+
+
+@models_router.get("", response_model=List[ModelEntry], summary="List uploaded model overrides.")
+@to_http_exception
+async def list_models() -> List[ModelEntry]:
+ ensure_dirs()
+ return list_model_files()
+
+
+@models_router.post("", response_model=ModelEntry, summary="Upload a model override (.glb).")
+@to_http_exception
+async def upload_model(
+ name: str,
+ file: UploadFile = File(...),
+) -> ModelEntry:
+ ensure_dirs()
+ target = safe_join(MODELS_DIR, name)
+ if target.suffix.lower() not in ALLOWED_MODEL_EXTENSIONS:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Only {sorted(ALLOWED_MODEL_EXTENSIONS)} files are allowed.",
+ )
+ written = await save_upload(file, target)
+ rel = target.relative_to(MODELS_DIR).as_posix()
+ return ModelEntry(name=rel, size_bytes=written, url=f"/userdata/modeloverrides/{rel}")
+
+
+@models_router.delete(
+ "/{name:path}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ summary="Delete a model override.",
+)
+@to_http_exception
+async def delete_model(name: str) -> None:
+ target = safe_join(MODELS_DIR, name)
+ if not target.exists():
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found.")
+ target.unlink()
+
+
+branding_router = APIRouter(
+ prefix="/branding",
+ tags=["branding"],
+ route_class=versioned_api_route(1, 0),
+ responses={status.HTTP_404_NOT_FOUND: {"description": "Not found"}},
+)
+
+
+def branding_asset(basename: str) -> BrandingAsset:
+ existing = find_branding_file(basename)
+ if existing is None:
+ return BrandingAsset()
+ return BrandingAsset(
+ url=f"/userdata/branding/{existing.name}",
+ size_bytes=existing.stat().st_size,
+ )
+
+
+def validate_image_extension(filename: str) -> str:
+ suffix = Path(filename).suffix.lower()
+ if suffix not in ALLOWED_IMAGE_EXTENSIONS:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Only {sorted(ALLOWED_IMAGE_EXTENSIONS)} files are allowed.",
+ )
+ return suffix
+
+
+async def replace_branding_asset(basename: str, file: UploadFile) -> BrandingAsset:
+ ensure_dirs()
+ if not file.filename:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing filename.")
+ suffix = validate_image_extension(file.filename)
+ remove_branding_file(basename)
+ target = safe_join(BRANDING_DIR, f"{basename}{suffix}")
+ written = await save_upload(file, target)
+ return BrandingAsset(url=f"/userdata/branding/{target.name}", size_bytes=written)
+
+
+@branding_router.get("/logo", response_model=BrandingAsset, summary="Get the current custom logo, if any.")
+@to_http_exception
+async def get_logo() -> BrandingAsset:
+ return branding_asset(LOGO_BASENAME)
+
+
+@branding_router.post("/logo", response_model=BrandingAsset, summary="Upload a custom company logo.")
+@to_http_exception
+async def upload_logo(file: UploadFile = File(...)) -> BrandingAsset:
+ return await replace_branding_asset(LOGO_BASENAME, file)
+
+
+@branding_router.delete("/logo", status_code=status.HTTP_204_NO_CONTENT, summary="Remove the custom logo.")
+@to_http_exception
+async def delete_logo() -> None:
+ remove_branding_file(LOGO_BASENAME)
+
+
+@branding_router.get(
+ "/vehicle-image",
+ response_model=BrandingAsset,
+ summary="Get the current custom vehicle image, if any.",
+)
+@to_http_exception
+async def get_vehicle_image() -> BrandingAsset:
+ return branding_asset(VEHICLE_IMAGE_BASENAME)
+
+
+@branding_router.post(
+ "/vehicle-image",
+ response_model=BrandingAsset,
+ summary="Upload a custom vehicle image.",
+)
+@to_http_exception
+async def upload_vehicle_image(file: UploadFile = File(...)) -> BrandingAsset:
+ return await replace_branding_asset(VEHICLE_IMAGE_BASENAME, file)
+
+
+@branding_router.delete(
+ "/vehicle-image",
+ status_code=status.HTTP_204_NO_CONTENT,
+ summary="Remove the custom vehicle image.",
+)
+@to_http_exception
+async def delete_vehicle_image() -> None:
+ remove_branding_file(VEHICLE_IMAGE_BASENAME)
+
+
+fast_api_app = FastAPI(
+ title="Customization API",
+ description="Manage BlueOS visual customization (theme color, 3D model overrides, branding).",
+ default_response_class=PrettyJSONResponse,
+)
+fast_api_app.router.route_class = GenericErrorHandlingRoute
+fast_api_app.include_router(theme_router)
+fast_api_app.include_router(models_router)
+fast_api_app.include_router(branding_router)
+
+app = VersionedFastAPI(
+ fast_api_app,
+ version="1.0.0",
+ prefix_format="/v{major}.{minor}",
+ enable_latest=True,
+)
+
+
+@app.get("/")
+async def root() -> dict[str, str]:
+ return {"service": SERVICE_NAME}
+
+
+async def main() -> None:
+ try:
+ await init_sentry_async(SERVICE_NAME)
+ ensure_dirs()
+ # Make sure the CSS exists on first boot so index.html's doesn't 404
+ if not THEME_FILE.exists():
+ write_theme_css(load_theme_config().primary)
+
+ config = Config(app=app, host="0.0.0.0", port=PORT, log_config=None)
+ server = Server(config)
+ await server.serve()
+ finally:
+ logger.info("Customization service stopped")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/core/services/customization/pyproject.toml b/core/services/customization/pyproject.toml
new file mode 100644
index 0000000000..91b6e856b6
--- /dev/null
+++ b/core/services/customization/pyproject.toml
@@ -0,0 +1,20 @@
+[project]
+name = "customization"
+version = "0.1.0"
+description = "Manage BlueOS visual customization (theme color, 3D model overrides, logo, vehicle image)."
+requires-python = ">=3.11"
+dependencies = [
+ "commonwealth==0.1.0",
+ "fastapi==0.105.0",
+ "fastapi-versioning==0.9.1",
+ "loguru==0.5.3",
+ "pydantic==1.10.12",
+ "python-multipart==0.0.5",
+ "uvicorn==0.18.0",
+]
+
+[tool.uv]
+package = false
+
+[tool.uv.sources]
+commonwealth = { workspace = true }
diff --git a/core/services/customization/storage.py b/core/services/customization/storage.py
new file mode 100644
index 0000000000..5cfe67991a
--- /dev/null
+++ b/core/services/customization/storage.py
@@ -0,0 +1,45 @@
+from pathlib import Path
+from typing import Optional
+
+USERDATA = Path("/usr/blueos/userdata")
+STYLES_DIR = USERDATA / "styles"
+MODELS_DIR = USERDATA / "modeloverrides"
+BRANDING_DIR = USERDATA / "branding"
+
+THEME_FILE = STYLES_DIR / "theme_style.css"
+THEME_CONFIG_FILE = STYLES_DIR / "theme_config.json"
+
+LOGO_BASENAME = "logo"
+VEHICLE_IMAGE_BASENAME = "vehicle_image"
+
+ALLOWED_MODEL_EXTENSIONS = {".glb"}
+ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg", ".gif"}
+
+
+def ensure_dirs() -> None:
+ for directory in (STYLES_DIR, MODELS_DIR, BRANDING_DIR):
+ directory.mkdir(parents=True, exist_ok=True)
+
+
+def safe_join(base: Path, user_path: str) -> Path:
+ base = base.resolve()
+ candidate = (base / user_path).resolve()
+ # Prevents path traversal: "../../etc/passwd" style attacks
+ # https://www.youtube.com/watch?v=RfiQYRn7fBg
+ candidate.relative_to(base)
+ return candidate
+
+
+def find_branding_file(basename: str) -> Optional[Path]:
+ if not BRANDING_DIR.exists():
+ return None
+ for entry in BRANDING_DIR.iterdir():
+ if entry.is_file() and entry.stem == basename:
+ return entry
+ return None
+
+
+def remove_branding_file(basename: str) -> None:
+ existing = find_branding_file(basename)
+ if existing is not None:
+ existing.unlink()
diff --git a/core/services/customization/theme.py b/core/services/customization/theme.py
new file mode 100644
index 0000000000..cfdf510954
--- /dev/null
+++ b/core/services/customization/theme.py
@@ -0,0 +1,124 @@
+import colorsys
+import textwrap
+from typing import Tuple
+
+
+def parse_hex(color: str) -> Tuple[float, float, float]:
+ rgb_hex_digits = 6
+ value = color.lstrip("#")
+ if len(value) != rgb_hex_digits:
+ raise ValueError(f"Invalid hex color: {color}")
+ try:
+ r = int(value[0:2], 16) / 255.0
+ g = int(value[2:4], 16) / 255.0
+ b = int(value[4:6], 16) / 255.0
+ except ValueError as exception:
+ raise ValueError(f"Invalid hex color: {color}") from exception
+ return r, g, b
+
+
+def to_hex(rgb: Tuple[float, float, float]) -> str:
+ r, g, b = (max(0.0, min(1.0, channel)) for channel in rgb)
+ return f"#{int(round(r * 255)):02X}{int(round(g * 255)):02X}{int(round(b * 255)):02X}"
+
+
+def shift_lightness(
+ rgb: Tuple[float, float, float], lightness_factor: float, saturation_factor: float
+) -> Tuple[float, float, float]:
+ """
+ Shift HSL lightness/saturation by relative factors.
+
+ `lightness_factor` and `saturation_factor` are multipliers (e.g. 0.75 means 75% of the original).
+ Saturation is left untouched on near-grayscale inputs to avoid tinting pure greys.
+ """
+ r, g, b = rgb
+ hue, lightness, saturation = colorsys.rgb_to_hls(r, g, b)
+ new_lightness = max(0.0, min(1.0, lightness * lightness_factor))
+ # Skip saturation shifting on near-grayscale colors so pure grey stays grey
+ new_saturation = saturation if saturation < 0.05 else max(0.0, min(1.0, saturation * saturation_factor))
+ return colorsys.hls_to_rgb(hue, new_lightness, new_saturation)
+
+
+def derive_palette(primary_hex: str) -> dict[str, str]:
+ """
+ Derive a 3-anchor BlueOS-style gradient palette from a single primary color.
+
+ Mirrors the relationship between BR_BLUE → MARINER_BLUE → BLUE_WHALE:
+ progressively darker and slightly more saturated. Uses relative shifts so
+ the gradient stays visible across the whole hue spectrum.
+ """
+ rgb = parse_hex(primary_hex)
+ light = rgb
+ mid = shift_lightness(rgb, lightness_factor=0.55, saturation_factor=1.18)
+ dark = shift_lightness(rgb, lightness_factor=0.20, saturation_factor=1.45)
+
+ # Scrollbar derivatives (Vuetify-style darken steps relative to primary)
+ scroll_track = shift_lightness(rgb, lightness_factor=0.65, saturation_factor=1.05)
+ scroll_thumb = shift_lightness(rgb, lightness_factor=0.45, saturation_factor=1.15)
+
+ return {
+ "light": to_hex(light),
+ "mid": to_hex(mid),
+ "dark": to_hex(dark),
+ "scroll_track": to_hex(scroll_track),
+ "scroll_thumb": to_hex(scroll_thumb),
+ }
+
+
+def render_css(primary_hex: str) -> str:
+ palette = derive_palette(primary_hex)
+ light = palette["light"]
+ mid = palette["mid"]
+ dark = palette["dark"]
+ scroll_track = palette["scroll_track"]
+ scroll_thumb = palette["scroll_thumb"]
+
+ # 0x55 ≈ 33% alpha, 0x88 ≈ 53% alpha — same ratios used in App.vue.
+ # Selectors are prefixed with `html` to bump specificity above App.vue's
+ # bundled rules: Vite injects App.vue's CSS link AFTER /userdata/styles/
+ # in , so equal-specificity rules with !important would otherwise win.
+
+ return textwrap.dedent(
+ f"""
+ /* Auto-generated by the BlueOS customization service. Do not edit by hand. */
+ /* Primary color: {primary_hex} */
+
+ html .light-background {{
+ background-color: {light} !important;
+ background-image: linear-gradient(160deg, {light} 0%, {mid} 100%) !important;
+ }}
+
+ html .dark-background {{
+ background-color: {mid} !important;
+ background-image: linear-gradient(160deg, {mid} 0%, {dark} 100%) !important;
+ }}
+
+ html .light-background-glass {{
+ background-color: {light}55 !important;
+ background-image: linear-gradient(160deg, {light}88 0%, {mid}88 100%) !important;
+ backdrop-filter: blur(4.5px) !important;
+ -webkit-backdrop-filter: blur(10px) !important;
+ }}
+
+ html .dark-background-glass {{
+ background-color: {mid}55 !important;
+ background-image: linear-gradient(160deg, {mid}88 0%, {dark}88 100%) !important;
+ backdrop-filter: blur(4.5px) !important;
+ -webkit-backdrop-filter: blur(10px) !important;
+ }}
+
+ html ::-webkit-scrollbar-track {{
+ box-shadow: inset 0 0 1px grey;
+ background: {scroll_track} !important;
+ }}
+
+ html ::-webkit-scrollbar-thumb {{
+ background: {scroll_thumb} !important;
+ transition: visibility 2s;
+ }}
+
+ html ::-webkit-scrollbar-thumb:hover {{
+ background: {light} !important;
+ }}
+ """
+ ).strip()
diff --git a/core/start-blueos-core b/core/start-blueos-core
index 4d067339eb..46877ddbef 100755
--- a/core/start-blueos-core
+++ b/core/start-blueos-core
@@ -145,6 +145,7 @@ SERVICES=(
'recorder',250,0,0,0,"blueos-recorder --recorder-path /usr/blueos/userdata/recorder"
'recorder_extractor',250,0,0,0,"$SERVICES_PATH/recorder_extractor/main.py"
'disk_usage',250,0,0,0,"$SERVICES_PATH/disk_usage/main.py"
+ 'customization',250,0,0,0,"$SERVICES_PATH/customization/main.py"
)
tmux -f /etc/tmux.conf start-server
diff --git a/core/tools/nginx/nginx.conf b/core/tools/nginx/nginx.conf
index 278c31849e..7b5650682d 100644
--- a/core/tools/nginx/nginx.conf
+++ b/core/tools/nginx/nginx.conf
@@ -142,6 +142,14 @@ http {
proxy_buffering off;
}
+ location /customization/ {
+ include cors.conf;
+ proxy_pass http://127.0.0.1:9152/;
+ # multipart .glb uploads can be large
+ client_max_body_size 200M;
+ proxy_request_buffering off;
+ }
+
location /kraken/ {
include cors.conf;
proxy_pass http://127.0.0.1:9134/;