diff --git a/selfdrive/carrot/README.md b/selfdrive/carrot/README.md index 39a9437834..6d11ed006c 100644 --- a/selfdrive/carrot/README.md +++ b/selfdrive/carrot/README.md @@ -11,6 +11,7 @@ selfdrive/carrot/ carrot_functions.py carrot shared functions carrot_man.py carrot manager carrot_serv.py carrot service/runtime + cweb_push.py CWP push client (device IP / git update notify) xiaoge_data.py car data server/ aiohttp backend realtime/ WebSocket realtime transports @@ -25,21 +26,25 @@ carrot_server.py -> server.app.make_app() -> server.features.register_all(app) -> web/ static files + -> on_startup background tasks (broker, hubs, heartbeat, git status, auto update, malloc trim) browser - -> web/index.html - -> web/js/shared/* - -> web/js/pages/* - -> web/js/realtime/* - -> web/js/app.js + -> GET / -> server/features/static.py injects window.__CARROT_BOOTSTRAP__ + -> web/index.html + -> web/js/vendor/* + -> web/js/translations/* + -> web/js/shared/* + shared/ui/* + -> web/js/pages/* + -> web/js/realtime/* + -> web/js/app.js ``` ## Main Entry Points | Path | Role | |---|---| -| `carrot_server.py` | Starts aiohttp web server on port `7000`. | -| `server/app.py` | Backend composition root. Creates app, runtime hubs, background tasks, routes. | +| `carrot_server.py` | Starts aiohttp web server on port `7000`. Core affinity tunable via `CARROT_WEB_CORES`. | +| `server/app.py` | Backend composition root. Creates app, runtime hubs, background tasks, routes, static serving. | | `server/features/__init__.py` | Registers backend feature modules. | | `server/config.py` | Shared backend paths and constants. | | `web/index.html` | Single HTML shell containing all page sections and script/style load order. | @@ -60,6 +65,7 @@ server/ services.py snapshot.py services/ + auto_update.py device_info.py git_state.py git_status.py @@ -71,6 +77,8 @@ server/ ssh_keys.py time_sync.py tmux.py + vision_diag.py + vision_test.py web_settings.py features/ cars.py @@ -83,20 +91,24 @@ server/ stream.py system.py terminal.py + vision_diag.py + vision_test.py web_settings.py ws.py dashcam/ screenrecord/ tools/ + terminal_commands/ ``` ## Backend Core Files | Path | Role | |---|---| -| `server/app.py` | Creates `aiohttp.Application`, `ClientSession`, `RealtimeBroker`, `CameraWsHub`, `RawWsHub`, heartbeat task, git status task, route registration, static serving. | -| `server/config.py` | `WEB_DIR`, settings paths, state paths, dashcam paths, WebRTC URL, tmux session, backup paths. | +| `server/app.py` | Creates `aiohttp.Application` (with request-log middleware + 16MB client max size), `ClientSession`, `RealtimeBroker(repo_flavor="c3")`, `CameraWsHub`, `RawWsHub`. Startup tasks: `heartbeat_loop` (when Params present), `git_status_loop`, `auto_update_loop`, periodic gc + `malloc_trim` loop. Holds `realtime_broker_poll_lock` to serialize SubMaster polls. Adds static `/` after explicit routes. | +| `server/config.py` | `WEB_DIR`/`CSS_DIR`/`JS_DIR`/`ASSETS_DIR`, settings path, state paths (`git.json`, `tool_jobs.json`, `web_settings.json`, `setting_favorites.json`, `setting_profiles.json`), dashcam/screenrecord paths, obfuscated Discord webhooks (dashcam + vision diag), `WEBRTCD_URL`, tmux session, params backup path. | | `server/features/__init__.py` | Calls each feature module's `register(app)`. | +| `server/features/static.py` | `GET /` handler — injects `window.__CARROT_BOOTSTRAP__` (webSettings, deviceLanguage, deviceLanguages) into `index.html`, no-cache headers; serves `/training/` assets. | | `server/live_runtime/broker.py` | Owns the realtime `SubMaster` broker used by `/api/live_runtime`. | | `server/live_runtime/snapshot.py` | Builds live runtime snapshots for frontend polling. | | `server/live_runtime/normalize.py` | Converts raw cereal/openpilot values into JSON-safe values. | @@ -107,38 +119,43 @@ server/ | Path | Used by | Role | |---|---|---| -| `services/params.py` | `features/params.py`, tools backup/restore | Params read/write, bulk backup values. | +| `services/params.py` | `features/params.py`, tools backup/restore | Params read/write, bulk backup values (`HAS_PARAMS` guard). | | `services/settings.py` | `features/settings.py`, static bootstrap | `carrot_settings.json` cache and parsing. | -| `services/git_state.py` | tools, metadata | Persisted git pull state such as last pull time. | -| `services/git_status.py` | `features/tools/routes.py`, startup loop | Cached git fetch/status info for update badges. | +| `services/git_state.py` | tools, auto update, metadata | Persisted git pull state such as last pull time. | +| `services/git_status.py` | `features/tools/routes.py`, auto update, startup loop | Cached git fetch/status info (ahead/behind) for update badges. | +| `services/auto_update.py` | `server/app.py` | Device-side auto `git pull` loop (when `auto_update_git_pull` web setting on); hard reset + pull, no reboot, best-effort CWP "what changed" notify. | | `services/heartbeat.py` | `server/app.py`, `features/system.py` | Heartbeat/register loop and status. | | `services/device_info.py` | `features/system.py` | Device network and calibration helpers. | | `services/time_sync.py` | `features/system.py` | Browser-to-device time sync. | | `services/tmux.py` | `features/terminal.py` | tmux session create, capture, input, clear. | | `services/ssh_keys.py` | `features/ssh_keys.py` | SSH key fetch/store helpers. | | `services/setting_favorites.py` | `features/setting_favorites.py` | Favorite setting names. | -| `services/setting_profiles.py` | `features/setting_profiles.py` | Setting profile CRUD/import/export. | -| `services/web_settings.py` | `features/web_settings.py` | Server-backed web settings. | +| `services/setting_profiles.py` | `features/setting_profiles.py` | Setting profile CRUD/import/export/apply. | +| `services/web_settings.py` | `features/web_settings.py`, static bootstrap, auto update | Server-backed web settings (`web_settings.json`). | +| `services/vision_diag.py` | `features/vision_diag.py` | Server diagnostic snapshot (camerad/encoderd/stream proxy history) + Discord upload of diagnostic bundle. | +| `services/vision_test.py` | `features/vision_test.py`, `services/vision_diag.py` | Standalone camerad + stream encoderd test runner, status/log at `/tmp/carrot-vision-test*`. | ## Backend Feature Routes | Feature | Main paths | Files | |---|---|---| -| Static/bootstrap | `/`, static fallback | `features/static.py` | -| WebRTC proxy | `/stream` | `features/stream.py` | +| Static/bootstrap | `/`, `/training/*`, static fallback | `features/static.py` | +| WebRTC proxy | `POST /stream` | `features/stream.py` | | Raw/camera WebSocket | `/ws/raw/{service}`, `/ws/raw_multiplex`, `/ws/camera/{camera}` | `features/ws.py`, `realtime/transports/*` | -| Settings | `/api/settings` | `features/settings.py`, `services/settings.py` | -| Params | `/api/params_bulk`, `/api/param_set`, restore/backup/download endpoints | `features/params.py`, `services/params.py` | -| Favorites | `/api/setting_favorites` | `features/setting_favorites.py` | -| Profiles | `/api/setting_profiles`, import/export endpoints | `features/setting_profiles.py` | -| Web settings | `/api/web_settings` | `features/web_settings.py`, `services/web_settings.py` | -| SSH keys | `/api/ssh_keys` | `features/ssh_keys.py`, `services/ssh_keys.py` | -| Cars | `/api/cars` | `features/cars.py` | -| System | heartbeat, live runtime, reboot, poweroff, recalibrate, network, calibration | `features/system.py`, `services/device_info.py`, `services/heartbeat.py` | -| Terminal | `/ws/terminal`, `/download/tmux.log` | `features/terminal.py`, `services/tmux.py` | -| Tools | `/api/tools`, `/api/tools/start`, `/api/tools/job`, `/api/tools/jobs`, `/api/tools/git_status` | `features/tools/*`, `services/git_status.py` | -| Dashcam | `/api/dashcam/*` | `features/dashcam/*` | -| Screenrecord | `/api/screenrecord/*` | `features/screenrecord/*` | +| Settings | `GET /api/settings` | `features/settings.py`, `services/settings.py` | +| Params | `GET /api/params_bulk`, `POST /api/param_set`, `POST /api/params_restore`, `POST /api/params_restore_preview`, `POST /api/params_restore_json`, `GET/POST /api/params_qr_dependency(/ensure)`, `GET /api/params_qr_backup`, `GET /download/params_backup.json` | `features/params.py`, `services/params.py` | +| Favorites | `GET/POST /api/setting_favorites` | `features/setting_favorites.py` | +| Profiles | `GET/POST /api/setting_profiles`, `/update`, `/delete`, `/preview`, `/apply` | `features/setting_profiles.py` | +| Web settings | `GET/POST /api/web_settings` | `features/web_settings.py`, `services/web_settings.py` | +| SSH keys | `GET/POST /api/ssh_keys` | `features/ssh_keys.py`, `services/ssh_keys.py` | +| Cars | `GET /api/cars` | `features/cars.py` | +| System | `GET /api/heartbeat_status`, `/api/live_runtime`, `/api/device_network`, `/api/calibration_status`, `/api/regulatory`; `POST /api/reboot`, `/api/poweroff`, `/api/recalibrate`, `/api/set_default`, `/api/time_sync` | `features/system.py`, `services/device_info.py`, `services/heartbeat.py`, `services/time_sync.py` | +| Terminal | `GET /ws/terminal`, `GET /download/tmux.log` | `features/terminal.py`, `services/tmux.py` | +| Tools | `POST /api/tools`, `/api/tools/start`, `/api/tools/jobs/notice`; `GET /api/tools/job`, `/api/tools/jobs`, `/api/tools/git_status`; `DELETE /api/tools/jobs` | `features/tools/*`, `services/git_status.py` | +| Dashcam | `/api/dashcam/*` (routes, segments, thumbnail, preview, video, download, upload) | `features/dashcam/*` | +| Screenrecord | `/api/screenrecord/*` (videos, thumbnail, video, download) | `features/screenrecord/*` | +| Vision diag | `GET /api/vision_diag/server_snapshot`, `POST /api/vision_diag/upload_discord` | `features/vision_diag.py`, `services/vision_diag.py` | +| Vision test | `GET /api/vision_test/status` | `features/vision_test.py`, `services/vision_test.py` | ## Tools Backend @@ -154,10 +171,25 @@ server/features/tools/ | File | Role | |---|---| | `actions.py` | Known tool action names and shell command allowlist. | -| `routes.py` | HTTP routes for sync actions, async jobs, job history, git status. | +| `routes.py` | HTTP routes for sync actions, async jobs, job history/clear/notice, git status. | | `jobs.py` | In-memory/persisted tool job state, log snapshots, async process helpers. | | `dispatcher.py` | Actual action implementations: git pull/sync/reset, branch, logs, backup, reboot, install, shell command. | +## Terminal Commands Backend + +```text +server/terminal_commands/ + README.md + bridge.py + cli.py + registry.py + custom_commands/ + help.py + vision_test.py +``` + +Custom in-terminal commands surfaced through the tmux terminal (see `terminal_commands/README.md`). `vision_test.py` drives the camerad/encoderd test runner from the terminal. + ## Dashcam Backend ```text @@ -210,11 +242,12 @@ realtime/ | Path | Role | |---|---| -| `raw_services.py` | Raw cereal service list definitions. | -| `raw_protocol.py` | Raw capnp packet/protocol helpers. | +| `__init__.py` | Re-exports raw protocol constants/builders and raw service helpers. | +| `raw_services.py` | Raw cereal service list definitions (core/optional, allowlist). | +| `raw_protocol.py` | Raw capnp packet/protocol helpers, hello builders, multiplex framing. | | `raw_runner.py` | Async raw stream runner. | -| `transports/camera_ws.py` | Camera stream lifecycle and WebSocket broadcasting. | -| `transports/raw_ws.py` | Raw service and multiplex WebSocket broadcasting. | +| `transports/camera_ws.py` | Camera stream lifecycle and WebSocket broadcasting (`ws_camera`). | +| `transports/raw_ws.py` | Raw service and multiplex WebSocket broadcasting (service allowlist enforced). | ## Frontend Tree @@ -239,6 +272,8 @@ web/css/ layout.css components.css responsive.css + components/ + nav_hud.css vendor/ plyr.css pages/ @@ -264,6 +299,7 @@ web/css/ | `layout.css` | Page containers, headers, common layout blocks. | | `components.css` | Shared dialogs, buttons, chips, toasts, generic components. | | `responsive.css` | Cross-page responsive adjustments loaded near the end. | +| `components/nav_hud.css` | Carrot Nav HUD V2 top-center guidance card. | | `pages/drive.css` | Drive stage, video, overlay canvas, vision controls. | | `pages/logs.css` | Logs, dashcam, screenrecord views. | | `pages/terminal.css` | Terminal page. | @@ -303,6 +339,7 @@ web/js/shared/ | Path | Scope | |---|---| +| `shared/constants.js` | Global constants — lang storage key/emoji, unit cycle, page transition classes, debug flag. | | `shared/api.js` | JSON API helpers and Params helpers. | | `shared/dom.js` | Shared DOM references from `index.html`. | | `shared/i18n.js` | Language state and UI text rendering. | @@ -357,17 +394,20 @@ web/js/pages/ | `pages/logs/dashcam.js` | Dashcam route/segment list, upload flow. | | `pages/logs/screenrecord.js` | Screenrecord list and thumbnails. | | `pages/terminal.js` | tmux WebSocket terminal UI. | -| `pages/vision_background.js` | Non-realtime page background state. | +| `pages/vision_background.js` | Non-realtime page ambient canvas background. | ### Realtime JS ```text web/js/realtime/ app_realtime.js + carrot_map.js home_drive.js hud_card.js + nav_hud.js raw_capnp.js raw_capnp_worker.js + vision_diag.js vision_raw.js vision_rtc.js vision_state.js @@ -378,11 +418,29 @@ web/js/realtime/ | `realtime/app_realtime.js` | Live runtime polling/raw stream wiring and HUD bridge. | | `realtime/home_drive.js` | Drive/Carrot Vision renderer and overlay canvas. | | `realtime/hud_card.js` | HUD card data rendering. | +| `realtime/carrot_map.js` | Kakao minimap iframe bridge (kmap), `FRAME_VERSION` gated to limit SDK quota. | +| `realtime/nav_hud.js` | Carrot Nav HUD V2 — top-center guidance card from `carrotMan` state, no iframe/network. | | `realtime/raw_capnp.js` | Raw capnp decode entry. | | `realtime/raw_capnp_worker.js` | Worker-side decode entry. | | `realtime/vision_state.js` | Shared vision/HUD state object. | | `realtime/vision_rtc.js` | WebRTC stream client. | | `realtime/vision_raw.js` | Raw WebSocket client and worker bridge. | +| `realtime/vision_diag.js` | Silent WebRTC/video diagnostic recorder (localStorage ring buffer) feeding the vision diag upload. | + +### Translations JS + +```text +web/js/translations/ + registry.js + ko.js + en.js + zh.js +``` + +| Path | Scope | +|---|---| +| `translations/registry.js` | Merges per-language packs over an English/Korean fallback into the shared `CarrotTranslations` API. | +| `translations/{ko,en,zh}.js` | Per-language string/actionLabel/errorMessage/driveMode packs. | ## HTML Page Sections @@ -403,6 +461,7 @@ Common overlay/dialog hosts in `index.html`: | ID | Scope | |---|---| | `driveHudCard` | Shared Drive HUD card DOM. | +| `carrotNavHud` | Nav HUD V2 top-center card host. | | `rtcVideo` | Shared RTC video element. | | `appToastHost` | Toast host. | | `appDialog` | Generic app dialog. | @@ -411,7 +470,7 @@ Common overlay/dialog hosts in `index.html`: ## Load Order Reference -CSS is loaded in `web/index.html` in this shape: +CSS is loaded in `web/index.html` in this order: ```text tokens.css @@ -420,33 +479,47 @@ hud_card.css base.css layout.css components.css -page CSS +pages/logs.css +pages/terminal.css +pages/settings/{base,panels,device}.css +pages/tools/{base,qr,main}.css +pages/drive.css +components/nav_hud.css responsive.css -vendor CSS +vendor/plyr.css ``` -JavaScript is loaded in this shape: +JavaScript is loaded in this order: ```text -vendor/* -translations/* -shared/* -shared/ui/* -pages/* -pages/logs/* -realtime/* +vendor/{plyr.min,qrcode-generator,jsQR}.js +translations/{registry,ko,en,zh}.js +realtime/hud_card.js +shared/{constants,dom,utils,i18n,api,setting_diff,activity}.js +shared/ui/{focus_trap,dialog,viewport,effects,navigation}.js +pages/car.js +pages/setting*.js +pages/tools*.js +pages/branch.js +pages/logs/*.js +pages/terminal.js +realtime/{raw_capnp,vision_state,vision_rtc,vision_raw,app_realtime,vision_diag,carrot_map,nav_hud}.js +pages/vision_background.js +realtime/home_drive.js app.js ``` +Asset URLs carry `?v=` cache-busting query strings; bump the version when a file changes. + ## Page Reference | Page | HTML | JS | CSS | Backend | |---|---|---|---|---| -| Drive/Home | `pageCarrot`, `driveHudCard`, `rtcVideo` | `realtime/*`, `vision_background.js` | `pages/drive.css`, `hud_card.css` | `features/system.py`, `features/ws.py`, `features/stream.py`, `realtime/transports/*` | +| Drive/Home | `pageCarrot`, `driveHudCard`, `carrotNavHud`, `rtcVideo` | `realtime/*`, `vision_background.js` | `pages/drive.css`, `hud_card.css`, `components/nav_hud.css` | `features/system.py`, `features/ws.py`, `features/stream.py`, `features/vision_diag.py`, `realtime/transports/*` | | Setting | `pageSetting` | `setting.js`, `setting_device*.js`, `car.js` | `pages/settings/*` | `features/settings.py`, `features/params.py`, `features/system.py`, `features/setting_*` | | Tools | `pageTools` | `tools.js`, `tools_notifications.js`, `tools_web_settings.js`, `tools_settings_qr.js`, `branch.js` | `pages/tools/*` | `features/tools/*`, `features/system.py`, `features/params.py`, `features/web_settings.py` | | Logs | `pageLogs` | `pages/logs/shared.js`, `dashcam.js`, `screenrecord.js` | `pages/logs.css` | `features/dashcam/*`, `features/screenrecord/*` | -| Terminal | `pageTerminal` | `terminal.js` | `pages/terminal.css` | `features/terminal.py`, `services/tmux.py` | +| Terminal | `pageTerminal` | `terminal.js` | `pages/terminal.css` | `features/terminal.py`, `services/tmux.py`, `terminal_commands/*` | | Car Select | `pageCar`, `appCarPicker` | `car.js` | `layout.css`, `components.css` | `features/cars.py`, Params | | Branch Select | `pageBranch`, `appBranchPicker` | `branch.js` | `components.css`, `pages/tools/*` | `features/tools/*` | @@ -459,7 +532,7 @@ openpilot messaging/cereal -> server/live_runtime/* -> features/system.py /api/live_runtime -> web/js/realtime/app_realtime.js - -> web/js/realtime/hud_card.js + -> web/js/realtime/hud_card.js + nav_hud.js -> web/js/realtime/home_drive.js ``` @@ -473,6 +546,14 @@ openpilot camera encode data -> pageCarrot video/canvas ``` +Vision diagnostics: + +```text +web/js/realtime/vision_diag.js (records RTC/video stats) + -> /api/vision_diag/server_snapshot (features/vision_diag.py) + -> /api/vision_diag/upload_discord -> services/vision_diag.py +``` + ### Setting ```text @@ -518,6 +599,15 @@ services/git_status.py -> tools.js refreshGitPullStatus() ``` +Auto update (no browser needed): + +```text +server/app.py on_startup + -> services/auto_update.py auto_update_loop + -> git_status (behind?) -> hard reset + git pull + -> cweb_push notify (best effort) +``` + ### Logs ```text @@ -541,7 +631,7 @@ dashcam.js web/js/pages/terminal.js -> /ws/terminal -> features/terminal.py - -> services/tmux.py + -> services/tmux.py (+ terminal_commands/*) ``` ### Branch / Git @@ -557,9 +647,10 @@ web/js/pages/branch.js | Need | File | |---|---| | Server startup | `carrot_server.py` | -| App wiring | `server/app.py` | +| App wiring / background tasks | `server/app.py` | | Route registration | `server/features/__init__.py` | | Static/bootstrap payload | `server/features/static.py` | +| Backend paths/constants | `server/config.py` | | Page HTML | `web/index.html` | | Page switching | `web/js/shared/ui/navigation.js` | | Final frontend boot | `web/js/app.js` | @@ -574,12 +665,18 @@ web/js/pages/branch.js | Tools backend actions | `server/features/tools/dispatcher.py` | | Tool job state | `server/features/tools/jobs.py` | | Git status polling | `server/services/git_status.py` | +| Auto update loop | `server/services/auto_update.py` | | Drive realtime UI | `web/js/realtime/*` | +| Nav HUD / minimap | `web/js/realtime/nav_hud.js`, `web/js/realtime/carrot_map.js` | +| Vision diagnostics | `web/js/realtime/vision_diag.js`, `server/features/vision_diag.py` | | Drive backend data | `server/features/system.py`, `server/live_runtime/*` | | Raw WebSocket | `server/features/ws.py`, `realtime/transports/raw_ws.py` | | Camera WebSocket | `realtime/transports/camera_ws.py` | | Logs UI | `web/js/pages/logs/*` | | Dashcam backend | `server/features/dashcam/*` | | Screenrecord backend | `server/features/screenrecord/*` | -| Terminal UI/backend | `web/js/pages/terminal.js`, `server/features/terminal.py` | +| Terminal UI/backend | `web/js/pages/terminal.js`, `server/features/terminal.py`, `server/terminal_commands/*` | +| Translations | `web/js/translations/*` | | Recovery server | `recovery/server.py` | + + diff --git a/selfdrive/carrot/server/app.py b/selfdrive/carrot/server/app.py index 8af58879c7..6726539279 100644 --- a/selfdrive/carrot/server/app.py +++ b/selfdrive/carrot/server/app.py @@ -25,6 +25,7 @@ from .services.git_status import git_status_loop from .services.heartbeat import heartbeat_loop from .services.params import HAS_PARAMS +from .services.popular_values import start_popular_value_upload VISION_DIAG_UPLOAD_MAX_BYTES = 16 * 1024 * 1024 @@ -85,6 +86,7 @@ async def on_startup(app: web.Application) -> None: app["hb_task"] = asyncio.create_task(heartbeat_loop(app)) app["git_status_task"] = asyncio.create_task(git_status_loop()) app["auto_update_task"] = asyncio.create_task(auto_update_loop()) + app["popular_value_upload_task"] = start_popular_value_upload(app) asyncio.create_task(_malloc_trim_loop()) @@ -133,6 +135,16 @@ async def on_cleanup(app: web.Application) -> None: except Exception: pass + popular_value_upload_task = app.get("popular_value_upload_task") + if popular_value_upload_task: + popular_value_upload_task.cancel() + try: + await popular_value_upload_task + except asyncio.CancelledError: + pass + except Exception: + pass + sess = app.get("http") if sess: await sess.close() diff --git a/selfdrive/carrot/server/features/__init__.py b/selfdrive/carrot/server/features/__init__.py index 55e5416e33..0f83b705f4 100644 --- a/selfdrive/carrot/server/features/__init__.py +++ b/selfdrive/carrot/server/features/__init__.py @@ -7,6 +7,7 @@ screenrecord, settings, setting_favorites, + setting_popular_values, setting_profiles, ssh_keys, static, @@ -28,6 +29,7 @@ def register_all(app: web.Application) -> None: settings.register(app) params.register(app) setting_favorites.register(app) + setting_popular_values.register(app) setting_profiles.register(app) web_settings.register(app) ssh_keys.register(app) diff --git a/selfdrive/carrot/server/features/setting_popular_values.py b/selfdrive/carrot/server/features/setting_popular_values.py new file mode 100644 index 0000000000..603d4ac68f --- /dev/null +++ b/selfdrive/carrot/server/features/setting_popular_values.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from aiohttp import web + +from ..services.popular_values import ( + get_popular_value_detail, + read_popular_values_memory, + refresh_popular_values_once, + schedule_popular_value_refresh, +) + + +async def api_setting_popular_values(request: web.Request) -> web.Response: + # Kick a throttled, download-only refresh so opening settings reflects the + # latest fleet values (non-blocking — returns the current cache immediately). + schedule_popular_value_refresh(request.app) + return web.json_response(read_popular_values_memory()) + + +async def api_setting_popular_value_detail(request: web.Request) -> web.Response: + name = request.query.get("name", "") + cache = read_popular_values_memory() + return web.json_response({ + "ok": True, + "car_key_type": cache.get("car_key_type", "CarSelected3"), + "car_key": cache.get("car_key", ""), + "param_name": name, + "detail": get_popular_value_detail(name), + }) + + +async def api_setting_popular_values_refresh(request: web.Request) -> web.Response: + session = request.app.get("http") + if session is None: + return web.json_response(read_popular_values_memory()) + cache = await refresh_popular_values_once(session, upload=True) + return web.json_response(cache) + + +def register(app: web.Application) -> None: + app.router.add_get("/api/setting_popular_values", api_setting_popular_values) + app.router.add_get("/api/setting_popular_values/detail", api_setting_popular_value_detail) + app.router.add_post("/api/setting_popular_values/refresh", api_setting_popular_values_refresh) diff --git a/selfdrive/carrot/server/services/popular_values.py b/selfdrive/carrot/server/services/popular_values.py new file mode 100644 index 0000000000..4a76f360c7 --- /dev/null +++ b/selfdrive/carrot/server/services/popular_values.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import socket +import time +from typing import Any +from urllib.parse import urlparse, urlunparse + +from aiohttp import ClientSession + +from .params import HAS_PARAMS, Params, get_param_values, infer_type_from_setting +from .settings import get_settings_cached + + +DEFAULT_TIMEOUT_S = 4.0 +DEFAULT_RETRY_COUNT = 5 +DEFAULT_RETRY_DELAY_S = 15.0 +_DEFAULT_BASE_URL_KEY = 41 +_DEFAULT_BASE_URL_BYTES = ( + 65, 93, 93, 89, 90, 19, 6, 6, 74, 89, 95, 7, 67, 70, 68, 64, 71, 66, 64, 26, + 28, 29, 7, 69, 64, 95, 76, +) +USER_AGENT = "openpilot-carrot-param-value/1" +# Cloudflare Access service-token (optional). Sent as CF-Access-Client-Id / +# CF-Access-Client-Secret so devices pass when the server sits behind Access. +# Resolution order: env (CARROT_PARAM_VALUE_CF_ID/_SECRET) -> Params +# (CarrotParamValueCfId/CarrotParamValueCfSecret) -> embedded obfuscated default. +_DEFAULT_CF_ACCESS_ID_KEY = 41 +_DEFAULT_CF_ACCESS_ID_BYTES: tuple[int, ...] = ( + 25, 31, 76, 77, 24, 30, 75, 79, 31, 72, 75, 29, 26, 16, 17, 79, 28, 30, 28, 79, + 25, 31, 72, 25, 16, 16, 27, 31, 72, 79, 29, 75, 7, 72, 74, 74, 76, 90, 90, +) +_DEFAULT_CF_ACCESS_SECRET_KEY = 41 +_DEFAULT_CF_ACCESS_SECRET_BYTES: tuple[int, ...] = ( + 74, 31, 25, 29, 17, 79, 16, 74, 24, 24, 31, 29, 16, 24, 25, 76, 29, 76, 29, 26, + 75, 16, 27, 29, 24, 75, 16, 31, 27, 24, 74, 76, 76, 28, 74, 27, 30, 29, 79, 79, + 75, 79, 74, 74, 75, 16, 79, 16, 25, 72, 30, 24, 28, 29, 16, 28, 79, 16, 75, 16, + 31, 75, 76, 31, +) +_popular_values_memory: dict[str, Any] | None = None + + +def _decode_default_base_url() -> str: + return "".join(chr(value ^ _DEFAULT_BASE_URL_KEY) for value in _DEFAULT_BASE_URL_BYTES) + + +def _env_float(name: str, default: float) -> float: + try: + return float(str(os.environ.get(name, "")).strip() or default) + except Exception: + return default + + +def _env_int(name: str, default: int) -> int: + try: + return int(str(os.environ.get(name, "")).strip() or default) + except Exception: + return default + + +def _param_text(params: Params, key: str, default: str = "") -> str: + try: + value = params.get(key) + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + text = str(value or "").strip() + return text or default + except Exception: + return default + + +def _meaningful_device_id(value: str) -> str: + text = str(value or "").strip() + if not text: + return "" + if text.lower() in {"unknown", "none", "null", "unregistereddevice"}: + return "" + return text + + +def _device_id(params: Params) -> str: + for value in ( + _param_text(params, "DongleId"), + _param_text(params, "HardwareSerial"), + ): + value = _meaningful_device_id(value) + if value: + return value + return socket.gethostname() or "comma" + + +def _repo_id_from_remote(remote: str) -> str: + text = str(remote or "").strip() + if not text: + return "" + if text.startswith("git@github.com:"): + text = "https://github.com/" + text.split(":", 1)[1] + parsed = urlparse(text) + path = parsed.path.strip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [part for part in path.split("/") if part] + if len(parts) >= 2: + return f"{parts[-2]}/{parts[-1]}" + return "" + + +def _snapshot_url(params: Params | None = None) -> str: + url = str(os.environ.get("CARROT_PARAM_VALUE_SNAPSHOT_URL", "")).strip() + if not url and params is not None: + url = _param_text(params, "CarrotParamValueSnapshotUrl") + if url: + return url + + base_url = str(os.environ.get("CARROT_PARAM_VALUE_URL", "")).strip() + if not base_url and params is not None: + base_url = _param_text(params, "CarrotParamValueUrl") + if not base_url: + base_url = _decode_default_base_url() + if not base_url: + return "" + return base_url.rstrip("/") + "/api/carrot-settings/snapshot" + + +def _popular_url(params: Params | None = None) -> str: + url = str(os.environ.get("CARROT_PARAM_VALUE_POPULAR_URL", "")).strip() + if not url and params is not None: + url = _param_text(params, "CarrotParamValuePopularUrl") + if url: + return url + + base_url = str(os.environ.get("CARROT_PARAM_VALUE_URL", "")).strip() + if not base_url and params is not None: + base_url = _param_text(params, "CarrotParamValueUrl") + if not base_url: + base_url = _decode_default_base_url() + if base_url: + return base_url.rstrip("/") + "/api/carrot-settings/popular" + + snapshot_url = _snapshot_url(params) + if snapshot_url.endswith("/api/carrot-settings/snapshot"): + return snapshot_url[: -len("/snapshot")] + "/popular" + parsed = urlparse(snapshot_url) + if not parsed.scheme or not parsed.netloc: + return "" + return urlunparse((parsed.scheme, parsed.netloc, "/api/carrot-settings/popular", "", "", "")) + + +def _settings_hash(data: dict[str, Any], param_names: list[str]) -> str: + params = data.get("params", []) + by_name = {str(item.get("name") or ""): item for item in params if isinstance(item, dict)} + catalog = [] + for name in param_names: + item = by_name.get(name, {}) + catalog.append({ + "name": name, + "min": item.get("min"), + "max": item.get("max"), + "default": item.get("default"), + "unit": item.get("unit"), + }) + payload = { + "apilot": data.get("apilot"), + "params": catalog, + } + raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _param_catalog_payload(by_name: dict[str, dict[str, Any]], param_names: list[str]) -> dict[str, dict[str, Any]]: + catalog: dict[str, dict[str, Any]] = {} + for name in param_names: + item = by_name.get(name, {}) + catalog[name] = { + "min": item.get("min"), + "max": item.get("max"), + "default": item.get("default"), + "unit": item.get("unit"), + } + return catalog + + +def _coerce_value(value: Any, setting: dict[str, Any]) -> Any: + kind = infer_type_from_setting(setting) + if kind == "bool": + if isinstance(value, str): + return 1 if value.strip().lower() in {"1", "true", "on", "yes"} else 0 + return 1 if bool(value) else 0 + if kind == "int": + try: + return int(float(value)) + except Exception: + return int(setting.get("default") or 0) + if kind == "float": + try: + return float(value) + except Exception: + return float(setting.get("default") or 0.0) + return value + + +def build_snapshot_payload() -> dict[str, Any] | None: + if not HAS_PARAMS: + return None + + data, _, by_name, _ = get_settings_cached() + param_names = [name for name in by_name.keys() if name] + if not param_names: + return None + + params = Params() + car_key = _param_text(params, "CarSelected3") + if not car_key: + return None + device_id = _device_id(params) + if not device_id: + return None + + defaults = {name: by_name.get(name, {}).get("default", 0) for name in param_names} + raw_values = get_param_values(param_names, defaults) + values = { + name: _coerce_value(raw_values.get(name, defaults.get(name, 0)), by_name.get(name, {})) + for name in param_names + } + + repo_remote = _param_text(params, "GitRemote") + settings_version = data.get("apilot") + try: + settings_version = int(settings_version) + except Exception: + settings_version = None + + return { + "schema_version": 1, + "device_id": device_id, + "repo_id": _repo_id_from_remote(repo_remote), + "repo_remote": repo_remote, + "car_key_type": "CarSelected3", + "car_key": car_key, + "settings_version": settings_version, + "settings_hash": _settings_hash(data, param_names), + "app_commit": _param_text(params, "GitCommit"), + "param_catalog": _param_catalog_payload(by_name, param_names), + "values": values, + } + + +def _current_settings_hash() -> str: + if not HAS_PARAMS: + return "" + try: + data, _, by_name, _ = get_settings_cached() + param_names = [name for name in by_name.keys() if name] + return _settings_hash(data, param_names) if param_names else "" + except Exception: + return "" + + +def _current_car_key() -> str: + if not HAS_PARAMS: + return "" + return _param_text(Params(), "CarSelected3") + + +def _empty_cache(car_key: str = "", settings_hash: str = "") -> dict[str, Any]: + return { + "ok": True, + "source": "empty", + "updated_at": 0, + "fetched_at": 0, + "car_key_type": "CarSelected3", + "car_key": car_key, + "settings_hash": settings_hash, + "popular_values": {}, + } + + +def read_popular_values_memory() -> dict[str, Any]: + data = _popular_values_memory + if not isinstance(data, dict): + return _empty_cache(_current_car_key(), _current_settings_hash()) + current_car_key = _current_car_key() + current_settings_hash = _current_settings_hash() + cached_car_key = str(data.get("car_key") or "") + cached_settings_hash = str(data.get("settings_hash") or "") + if current_car_key and cached_car_key and current_car_key != cached_car_key: + return _empty_cache(current_car_key, current_settings_hash) + if current_settings_hash and cached_settings_hash and current_settings_hash != cached_settings_hash: + return _empty_cache(current_car_key, current_settings_hash) + result = dict(data) + result.setdefault("ok", True) + result.setdefault("source", "memory") + result.setdefault("settings_hash", current_settings_hash) + result.setdefault("popular_values", {}) + return result + + +def store_popular_values_memory(data: dict[str, Any]) -> dict[str, Any]: + global _popular_values_memory + clean = { + "ok": bool(data.get("ok", True)), + "source": "remote", + "fetched_at": int(time.time()), + "car_key_type": str(data.get("car_key_type") or "CarSelected3"), + "car_key": str(data.get("car_key") or ""), + "settings_hash": str(data.get("settings_hash") or _current_settings_hash()), + "popular_values": data.get("popular_values") if isinstance(data.get("popular_values"), dict) else {}, + } + _popular_values_memory = clean + return clean + + +def get_popular_value_detail(name: str) -> dict[str, Any]: + cache = read_popular_values_memory() + values = cache.get("popular_values") + if not isinstance(values, dict): + values = {} + detail = values.get(str(name or "")) + if not isinstance(detail, dict): + return {"top_values": []} + return detail + + +def _cf_access_token(params: Params | None = None) -> tuple[str, str]: + cid = str(os.environ.get("CARROT_PARAM_VALUE_CF_ID", "")).strip() + sec = str(os.environ.get("CARROT_PARAM_VALUE_CF_SECRET", "")).strip() + if params is not None: + if not cid: + cid = _param_text(params, "CarrotParamValueCfId") + if not sec: + sec = _param_text(params, "CarrotParamValueCfSecret") + if not cid: + cid = "".join(chr(v ^ _DEFAULT_CF_ACCESS_ID_KEY) for v in _DEFAULT_CF_ACCESS_ID_BYTES) + if not sec: + sec = "".join(chr(v ^ _DEFAULT_CF_ACCESS_SECRET_KEY) for v in _DEFAULT_CF_ACCESS_SECRET_BYTES) + return cid, sec + + +def _request_headers(params: Params | None = None) -> dict[str, str]: + headers = {"User-Agent": USER_AGENT} + cid, sec = _cf_access_token(params) + if cid and sec: + headers["CF-Access-Client-Id"] = cid + headers["CF-Access-Client-Secret"] = sec + return headers + + +async def _post_snapshot(session: ClientSession, url: str, payload: dict[str, Any], timeout_s: float, headers: dict[str, str]) -> tuple[bool, int, str]: + try: + async with session.post(url, json=payload, timeout=timeout_s, headers=headers) as resp: + text = await resp.text() + return 200 <= resp.status < 300, int(resp.status), text + except Exception as exc: + return False, 0, str(exc) + + +async def download_popular_values_once(session: ClientSession) -> dict[str, Any] | None: + if not HAS_PARAMS: + return None + params = Params() + url = _popular_url(params) + car_key = _param_text(params, "CarSelected3") + settings_hash = _current_settings_hash() + if not url or not car_key: + return None + + timeout_s = max(1.0, _env_float("CARROT_PARAM_VALUE_TIMEOUT_S", DEFAULT_TIMEOUT_S)) + try: + async with session.get( + url, + params={"car_key_type": "CarSelected3", "car_key": car_key, "settings_hash": settings_hash}, + timeout=timeout_s, + headers=_request_headers(params), + ) as resp: + data = await resp.json(content_type=None) + if resp.status < 200 or resp.status >= 300 or not isinstance(data, dict): + print(f"[carrot_param_value] popular download failed status={resp.status}", flush=True) + return None + saved = store_popular_values_memory(data) + print( + f"[carrot_param_value] popular loaded car_key={saved.get('car_key')} params={len(saved.get('popular_values') or {})}", + flush=True, + ) + return saved + except Exception as exc: + print(f"[carrot_param_value] popular download failed: {exc}", flush=True) + return None + + +async def popular_value_upload_once(session: ClientSession) -> bool: + if not HAS_PARAMS: + return False + params = Params() + url = _snapshot_url(params) + if not url: + return False + + try: + payload = build_snapshot_payload() + except Exception as exc: + print(f"[carrot_param_value] snapshot build failed: {exc}", flush=True) + return False + if not payload: + return False + + timeout_s = max(1.0, _env_float("CARROT_PARAM_VALUE_TIMEOUT_S", DEFAULT_TIMEOUT_S)) + retry_count = max(1, _env_int("CARROT_PARAM_VALUE_RETRY_COUNT", DEFAULT_RETRY_COUNT)) + retry_delay_s = max(1.0, _env_float("CARROT_PARAM_VALUE_RETRY_DELAY_S", DEFAULT_RETRY_DELAY_S)) + + headers = _request_headers(params) + for attempt in range(1, retry_count + 1): + ok, status, body = await _post_snapshot(session, url, payload, timeout_s, headers) + if ok: + print( + f"[carrot_param_value] uploaded car_key={payload.get('car_key')} params={len(payload.get('values') or {})}", + flush=True, + ) + return True + print(f"[carrot_param_value] upload failed attempt={attempt}/{retry_count} status={status} body={body[:160]}", flush=True) + if attempt < retry_count: + await asyncio.sleep(retry_delay_s) + return False + + +async def refresh_popular_values_once(session: ClientSession, *, upload: bool = True) -> dict[str, Any]: + uploaded = False + if upload: + uploaded = await popular_value_upload_once(session) + cache = await download_popular_values_once(session) + if cache is None: + cache = read_popular_values_memory() + cache["uploaded"] = uploaded + return cache + + +def start_popular_value_upload(app: Any) -> asyncio.Task | None: + session = app.get("http") + if session is None: + return None + return asyncio.create_task(refresh_popular_values_once(session, upload=True)) + + +_popular_refresh_last_at = 0.0 +_popular_refresh_task: asyncio.Task | None = None + + +def schedule_popular_value_refresh(app: Any, min_interval: float | None = None) -> None: + """Kick a throttled, download-only refresh from the central server. + + Non-blocking and single-flight: called on each settings read so the web + reflects fleet changes within ~min_interval, without polling or websockets. + Does NOT re-upload (that only happens once at boot).""" + global _popular_refresh_last_at, _popular_refresh_task + session = app.get("http") + if session is None: + return + interval = ( + _env_float("CARROT_PARAM_VALUE_REFRESH_MIN_S", 60.0) + if min_interval is None else min_interval + ) + now = time.time() + if now - _popular_refresh_last_at < interval: + return + if _popular_refresh_task is not None and not _popular_refresh_task.done(): + return + _popular_refresh_last_at = now + _popular_refresh_task = asyncio.create_task(download_popular_values_once(session)) diff --git a/selfdrive/carrot/web/css/components.css b/selfdrive/carrot/web/css/components.css index 4a4cf5fb91..f27eb4dfb7 100644 --- a/selfdrive/carrot/web/css/components.css +++ b/selfdrive/carrot/web/css/components.css @@ -805,104 +805,6 @@ body[data-page="terminal"] .app-toast-host { font-family: var(--font-mono); } -/* ── Setting Subnav ───────────────────────────────────────── */ -.setting-subnav-wrap { - margin: 0 0 var(--sp-md); - touch-action: pan-y; - position: sticky; - top: 0; - z-index: 18; - padding-top: clamp(8px, 1.8vw, 12px); - background: var(--md-surface); - isolation: isolate; -} - -.setting-subnav { - --setting-subnav-tab-width: calc((100% - var(--sp-sm) - var(--sp-sm) - var(--sp-sm) - var(--sp-sm)) / 5); - display: flex; - gap: var(--sp-sm); - justify-content: flex-start; - overflow-x: auto; - overscroll-behavior-x: contain; - scrollbar-width: none; - padding: 0 0 10px; - border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 34%, transparent); - scroll-snap-type: x mandatory; -} - -.setting-subnav-wrap:not(.is-scrollable) .setting-subnav { - justify-content: center; -} - -.setting-subnav::before, -.setting-subnav::after { - content: ""; - flex: 0 0 calc((100% - var(--setting-subnav-tab-width)) / 2); -} - -.setting-subnav-wrap:not(.is-scrollable) .setting-subnav::before, -.setting-subnav-wrap:not(.is-scrollable) .setting-subnav::after { - flex-basis: 0; -} - -.setting-subnav::-webkit-scrollbar { - display: none; -} - -.setting-subnav__tab { - flex: 0 0 var(--setting-subnav-tab-width); - appearance: none; - min-height: 34px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 44%, transparent); - border-radius: 8px; - background: var(--md-surface-cont); - color: var(--md-on-surface-var); - padding: 7px 9px; - font-size: 12.5px; - font-weight: 800; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: center; - scroll-snap-align: center; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); - transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.12s ease; -} - -.setting-subnav__tab:not(.is-active) { - color: var(--md-on-surface-var); -} - -.setting-subnav__tab:hover, -.setting-subnav__tab:focus-visible { - border-color: color-mix(in srgb, var(--md-primary) 36%, var(--md-outline-var)); - background: color-mix(in srgb, var(--md-surface-cont-h) 90%, var(--md-primary)); - color: var(--md-on-surface); - outline: none; -} - -.setting-subnav__tab.is-active { - border-color: color-mix(in srgb, var(--md-primary) 48%, var(--md-outline-var)); - background: var(--md-primary-state); - color: var(--md-primary); - font-weight: 850; -} - -.setting-subnav__tab:active { - background: color-mix(in srgb, var(--md-surface-cont-hh) 88%, var(--md-primary)); - color: var(--md-on-surface); - transform: translateY(1px); -} - -.setting-back-swiping .setting-subnav { - scroll-snap-type: none; -} - -.setting-back-swiping .setting-subnav__tab { - scroll-snap-align: none; -} - /* ── Group Buttons (list items) ───────────────────────────── */ .groupBtn { width: 100%; diff --git a/selfdrive/carrot/web/css/pages/settings/device.css b/selfdrive/carrot/web/css/pages/settings/device.css index 47c943c555..a20a99e58c 100644 --- a/selfdrive/carrot/web/css/pages/settings/device.css +++ b/selfdrive/carrot/web/css/pages/settings/device.css @@ -112,10 +112,6 @@ pointer-events: none; } -.page--setting .setting-subnav__tab--favorites { - color: color-mix(in srgb, #bae6fd 82%, var(--md-on-surface-var)); -} - @media (min-width: 769px) { .setting-search-panel { --setting-search-form-width: clamp(360px, 34vw, 540px); @@ -128,67 +124,6 @@ } @media (max-width: 640px) { - .page--setting .setting-subnav-wrap { - margin-bottom: 0; - top: 0; - padding-top: calc(8px + env(safe-area-inset-top, 0px)); - padding-bottom: 18px; - background: var(--md-surface); - border-bottom: 1px solid color-mix(in srgb, var(--md-stroke-soft) 72%, transparent); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.14); - } - - .page--setting .setting-subnav-wrap::after { - display: none; - } - - .page--setting .setting-subnav { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; - justify-content: stretch; - overflow: visible; - padding: 0; - border-bottom: 0; - scroll-snap-type: none; - } - - .page--setting .setting-subnav::before, - .page--setting .setting-subnav::after { - display: none; - } - - .page--setting .setting-subnav__tab { - flex: none; - min-height: 38px; - padding: 7px 6px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 44%, transparent); - border-radius: 8px; - background: var(--md-surface-cont); - color: var(--md-on-surface-var); - font-size: 12.5px; - font-weight: 800; - line-height: 1.15; - white-space: normal; - text-wrap: balance; - scroll-snap-align: none; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); - } - - .page--setting .setting-subnav__tab.is-active { - color: var(--md-primary); - border-color: color-mix(in srgb, var(--md-primary) 48%, var(--md-outline-var)); - background: var(--md-primary-state); - } - - .page--setting .setting-subnav__tab:active { - background: color-mix(in srgb, var(--md-surface-cont-hh) 88%, var(--md-primary)); - } - - .page--setting #settingSubnavWrap { - box-sizing: border-box; - } - .page--setting .settingTop { flex-direction: column; align-items: stretch; @@ -216,7 +151,14 @@ } -@media (max-width: 640px) and (orientation: portrait) { +/* Portrait at ANY width (phone / foldable / tablet / wide window): the + group↔items slide in runSettingScreenSlide() assumes both screens are + stacked as position:absolute; inset:0 inside the host so the transition is + pure transform. That overlap must exist for every portrait width — not just + ≤640px — otherwise the screens fall back to normal flow and the submenu + appears to slide in vertically / only after the slide finishes. Landscape + has its own absolute-overlap block below, so the two together leave no gap. */ +@media (orientation: portrait) { .page--setting #settingScreenItems { padding-top: 0; } @@ -299,13 +241,6 @@ mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 6px), transparent 100%); } - .page--setting #settingSubnavWrap { - display: none !important; - } - - .page--setting #settingSubnavWrap::after { - display: none; - } } @media (max-height: 760px) { @@ -426,10 +361,6 @@ overflow: hidden; } - .page--setting.setting-layout-split #settingSubnavWrap { - display: none !important; - } - .page--setting.setting-layout-split #groupList { display: flex; flex-direction: column; @@ -956,6 +887,49 @@ margin-top: 8px; } +.page--setting #settingScreenItems .setting-popular-value-chip { + display: inline-flex; + align-items: center; + justify-content: flex-start; + flex-wrap: nowrap; + gap: 3px; + min-width: 0; + max-width: min(360px, 52vw); + min-height: 18px; + margin-right: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: color-mix(in srgb, var(--md-on-surface-var) 42%, transparent); + font-size: 11px; + font-weight: 700; + line-height: 1.18; + letter-spacing: 0; + text-align: left; + white-space: nowrap; + box-shadow: none; + opacity: 0.74; +} + +.page--setting #settingScreenItems .setting-popular-value-chip__car { + min-width: 0; + max-width: 170px; + overflow: hidden; + color: inherit; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page--setting #settingScreenItems .setting-popular-value-chip__accent { + color: color-mix(in srgb, var(--md-primary) 56%, var(--md-on-surface-var)); + font-weight: 800; +} + +.page--setting #settingScreenItems .setting-popular-value-chip__label { + color: inherit; +} + .page--setting #settingScreenItems .setting-actions .setting-unit-cycle { margin: 0; } @@ -984,6 +958,195 @@ color: var(--md-on-surface); } +.setting-popular-detail { + display: grid; + gap: 12px; + min-width: min(360px, calc(100vw - 56px)); +} + +.page--setting #settingScreenItems .setting-popular-detail-block { + width: min(520px, 100%); + margin-top: 12px; +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail { + gap: 9px; + min-width: 0; + width: 100%; +} + +.setting-popular-detail__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.setting-popular-detail__name { + color: var(--md-on-surface-var); + font-size: var(--fs-body-sm); + font-weight: 800; + overflow-wrap: anywhere; +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail__name { + color: color-mix(in srgb, var(--md-on-surface-var) 66%, transparent); + font-size: 12px; + font-weight: 780; +} + +.setting-popular-detail__range { + flex: 0 0 auto; + min-height: 20px; + padding: 0 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--md-on-surface-var) 8%, transparent); + color: color-mix(in srgb, var(--md-on-surface-var) 58%, transparent); + font-size: 10px; + font-weight: 820; + line-height: 20px; +} + +.setting-popular-detail__rows { + display: grid; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 32%, transparent); + border-radius: var(--r-md); + background: var(--md-surface-cont); +} + +.setting-popular-detail__row { + display: grid; + grid-template-columns: 52px minmax(0, 1fr) 58px; + align-items: center; + gap: 10px; + width: 100%; + min-height: 38px; + padding: 0 12px; + border-bottom: 1px solid color-mix(in srgb, var(--md-stroke-soft) 40%, transparent); + border-left: 0; + border-right: 0; + border-top: 0; + background: transparent; + color: var(--md-on-surface); + font-family: inherit; + font-size: var(--fs-body-sm); + font-weight: 850; + letter-spacing: 0; + text-align: left; + cursor: pointer; +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail__rows { + gap: 6px; + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail__row { + grid-template-columns: 42px minmax(0, 1fr) 48px; + min-height: 48px; + padding: 8px 11px; + gap: 10px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 16%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--md-surface-cont) 54%, transparent); + color: color-mix(in srgb, var(--md-on-surface) 82%, transparent); + font-size: 12px; + font-weight: 760; +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail__row:active { + border-color: color-mix(in srgb, var(--md-primary) 24%, var(--md-outline-var)); + background: color-mix(in srgb, var(--md-primary) 9%, var(--md-surface-cont)); +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail--single .setting-popular-detail__row { + min-height: 44px; +} + +.setting-popular-detail__row:last-child { + border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 16%, transparent); +} + +.setting-popular-detail__rank, +.setting-popular-detail__count { + color: var(--md-on-surface-var); + font-size: var(--fs-label-sm); + font-weight: 850; +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail__rank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 28px; + border-radius: 999px; + background: color-mix(in srgb, var(--md-primary) 12%, var(--md-surface-cont-h)); + color: color-mix(in srgb, var(--md-on-surface-var) 78%, transparent); + font-size: 10px; + font-weight: 900; +} + +.setting-popular-detail__main { + display: grid; + gap: 7px; + min-width: 0; +} + +.setting-popular-detail__value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail__value { + color: color-mix(in srgb, var(--md-primary) 68%, var(--md-on-surface)); + font-size: 13px; + font-weight: 860; +} + +.setting-popular-detail__count { + text-align: right; +} + +.page--setting #settingScreenItems .setting-popular-detail-block .setting-popular-detail__count { + color: color-mix(in srgb, var(--md-on-surface-var) 66%, transparent); + font-size: 11px; + font-weight: 820; +} + +.setting-popular-detail__bar { + display: block; + width: 100%; + max-width: 260px; + height: 5px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--md-on-surface-var) 10%, transparent); +} + +.setting-popular-detail__bar::after { + content: ""; + display: block; + width: var(--setting-popular-width, 0%); + height: 100%; + border-radius: inherit; + background: color-mix(in srgb, var(--md-primary) 52%, var(--md-on-surface-var)); + opacity: 0.78; +} + +.setting-popular-detail__empty { + color: var(--md-on-surface-var); + font-size: var(--fs-body-sm); + font-weight: 750; +} + .page--setting #settingScreenItems .setting-copy { min-width: 0; max-width: 100%; @@ -1324,7 +1487,6 @@ transform: translateY(1px); } -.page--setting #settingSubnav[hidden], .page--setting #carrotTabContent[hidden], .page--setting #deviceTabContent[hidden], .page--setting #items[hidden], @@ -1336,12 +1498,6 @@ display: none; } -/* The device tab has no top quick-tab subnav (old, unused). Hide the subnav - wrap on the device tab so no empty bar shows at any width / adaptive layout. */ -.page--setting.setting-tab-device #settingSubnavWrap { - display: none !important; -} - .page--setting #deviceItems .ctrl { display: flex; grid-template-columns: none; diff --git a/selfdrive/carrot/web/css/pages/settings/panels.css b/selfdrive/carrot/web/css/pages/settings/panels.css index 0f56c990b5..1d1cd08463 100644 --- a/selfdrive/carrot/web/css/pages/settings/panels.css +++ b/selfdrive/carrot/web/css/pages/settings/panels.css @@ -313,10 +313,6 @@ animation: setting-profile-section-fold 0.24s cubic-bezier(.2, 0, 0, 1) reverse both; } -.setting-subnav__tab--profile { - border-color: color-mix(in srgb, var(--md-primary) 34%, var(--md-outline-var)); -} - .setting-profile-panel { --setting-profile-accent: var(--md-primary, #ffab66); --setting-profile-ink: var(--md-on-surface); diff --git a/selfdrive/carrot/web/css/tokens.css b/selfdrive/carrot/web/css/tokens.css index 87ed08460b..923cfbb4b2 100644 --- a/selfdrive/carrot/web/css/tokens.css +++ b/selfdrive/carrot/web/css/tokens.css @@ -207,7 +207,7 @@ layering must come from this scale. --z-base in-flow content - --z-sticky sticky headers, subnav + --z-sticky sticky headers --z-rail side nav rail --z-nav bottom nav bar (matches existing topbar z:120) --z-fab floating action buttons diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 033a1f3887..e140662601 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -89,17 +89,17 @@ background: color-mix(in srgb, var(--md-primary) 14%, transparent) !important; } - + - + - - + + @@ -212,9 +212,6 @@