This commit is contained in:
firestar5683
2026-09-14 11:39:02 -05:00
parent 8d73b7b679
commit b4dbc18a14
17 changed files with 857 additions and 90 deletions
+2
View File
@@ -195,10 +195,12 @@ class HudRenderer(Widget):
controls_state = sm['controlsState']
car_state = sm['carState']
rivian_lateral_mode.update()
starpilot_car_state = sm['starpilotCarState'] if sm.valid.get('starpilotCarState', False) else None
self._wheel_tint = get_wheel_tint(
getattr(car_state, "brakePressed", False),
rivian_lateral_mode.wheel_tint,
ui_state.ui_params.get_bool("ShowBrakeStatus"),
getattr(starpilot_car_state, "brakeLights", False),
)
v_cruise_cluster = car_state.vCruiseCluster
+5 -2
View File
@@ -16,8 +16,9 @@ from openpilot.starpilot.common.experimental_state import (
BRAKE_WHEEL_COLOR = rl.Color(255, 0, 0, 255)
def get_wheel_tint(brake_pressed: bool, mode_tint: rl.Color | None, brake_status_enabled: bool) -> rl.Color | None:
return BRAKE_WHEEL_COLOR if brake_status_enabled and brake_pressed else mode_tint
def get_wheel_tint(brake_pressed: bool, mode_tint: rl.Color | None, brake_status_enabled: bool,
brake_lights: bool = False) -> rl.Color | None:
return BRAKE_WHEEL_COLOR if brake_status_enabled and (brake_pressed or brake_lights) else mode_tint
class ExpButton(Widget):
@@ -111,10 +112,12 @@ class ExpButton(Widget):
texture = self._txt_exp if exp_mode else self._txt_wheel
color = self._white_color
tint = None
starpilot_car_state = ui_state.sm["starpilotCarState"] if getattr(ui_state.sm, "valid", {}).get("starpilotCarState", False) else None
wheel_tint = get_wheel_tint(
getattr(ui_state.sm["carState"], "brakePressed", False),
self.wheel_tint,
self._params.get_bool("ShowBrakeStatus"),
getattr(starpilot_car_state, "brakeLights", False),
)
if wheel_tint is not None:
tint = rl.Color(wheel_tint.r, wheel_tint.g, wheel_tint.b, self._white_color.a)
@@ -311,6 +311,26 @@ def test_non_mici_wheel_icon_turns_red_when_brakes_are_pressed(monkeypatch):
assert (texture_color.r, texture_color.g, texture_color.b, texture_color.a) == (255, 0, 0, 255)
def test_non_mici_wheel_icon_uses_reported_brake_lights(monkeypatch):
module, draws = load_exp_button(monkeypatch)
button = module.ExpButton(192, 144)
button.wheel_tint = FakeColor(0x4D, 0x9D, 0xFF, 255)
class FakeUiSubMaster(dict):
pass
module.ui_state.sm = FakeUiSubMaster(module.ui_state.sm)
module.ui_state.sm.valid = {"starpilotCarState": True}
module.ui_state.sm["starpilotCarState"] = SimpleNamespace(brakeLights=True)
module.ui_state.ui_params.get_bool = lambda key, *args, **kwargs: key == "ShowBrakeStatus"
button._update_state()
button._render(FakeRectangle(0, 0, 192, 192))
assert len(draws["textures"]) == 1
texture_color = draws["textures"][0][-1]
assert (texture_color.r, texture_color.g, texture_color.b, texture_color.a) == (255, 0, 0, 255)
def test_non_mici_wheel_icon_brake_tint_is_disabled_by_default(monkeypatch):
module, draws = load_exp_button(monkeypatch)
button = module.ExpButton(192, 144)
+26 -1
View File
@@ -52,6 +52,12 @@ MACH_E_DIRECTION_CHANGE_MIN_PREVIEW_CURVATURE = 0.0005
MACH_E_DIRECTION_CHANGE_FULL_PREVIEW_CURVATURE = 0.002
MACH_E_DIRECTION_CHANGE_MIN_LAG_CURVATURE = 0.0008
MACH_E_DIRECTION_CHANGE_FULL_LAG_CURVATURE = 0.0015
MACH_E_LOW_SPEED_DIRECTION_CHANGE_START_SPEED = 1.8
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_SPEED = 2.0
MACH_E_LOW_SPEED_DIRECTION_CHANGE_HOLD_SPEED = 2.8
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FADE_SPEED = 3.5
MACH_E_LOW_SPEED_DIRECTION_CHANGE_MIN_CURVATURE = 0.0004
MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_CURVATURE = 0.0006
FORD_CURVATURE_LOOKAHEAD = {
CAR.FORD_EXPLORER_MK6: 0.20,
}
@@ -261,6 +267,21 @@ class FordLateralController:
))
return preview_weight * lag_weight
@staticmethod
def _low_speed_direction_change_weight(v_ego: float, desired: float) -> float:
speed_weight = float(np.interp(
v_ego,
[MACH_E_LOW_SPEED_DIRECTION_CHANGE_START_SPEED, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_SPEED,
MACH_E_LOW_SPEED_DIRECTION_CHANGE_HOLD_SPEED, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FADE_SPEED],
[0.0, 1.0, 1.0, 0.0],
))
curvature_weight = float(np.interp(
abs(desired),
[MACH_E_LOW_SPEED_DIRECTION_CHANGE_MIN_CURVATURE, MACH_E_LOW_SPEED_DIRECTION_CHANGE_FULL_CURVATURE],
[0.0, 1.0],
))
return speed_weight * curvature_weight
def _manual_turn(self, CC, CS) -> bool:
if not CC.latActive:
self.human_turn.reset()
@@ -329,11 +350,15 @@ class FordLateralController:
turn_in_predicted = self._predicted_curvature(v_ego, lookahead + MACH_E_TURN_IN_LOOKAHEAD_EXTRA)
direction_change_predicted = turn_in_predicted
direction_change_weight = 0.0
if v_ego > MACH_E_DIRECTION_CHANGE_MIN_SPEED and not CS.out.steeringPressed and not self._lane_change()[0]:
direction_change_speed_weight = float(v_ego > MACH_E_DIRECTION_CHANGE_MIN_SPEED)
if direction_change_speed_weight == 0.0:
direction_change_speed_weight = self._low_speed_direction_change_weight(v_ego, desired)
if direction_change_speed_weight > 0.0 and not CS.out.steeringPressed and not self._lane_change()[0]:
direction_change_lookahead_extra = self._direction_change_lookahead_extra(v_ego)
if direction_change_lookahead_extra > MACH_E_TURN_IN_LOOKAHEAD_EXTRA:
direction_change_predicted = self._predicted_curvature(v_ego, lookahead + direction_change_lookahead_extra)
direction_change_weight = self._direction_change_preview_weight(desired, direction_change_predicted, current)
direction_change_weight *= direction_change_speed_weight
if direction_change_weight > 0.0:
predicted = float(np.interp(direction_change_weight, [0.0, 1.0], [predicted, direction_change_predicted]))
allow_opposite_preview = True
+63
View File
@@ -199,6 +199,21 @@ def test_mach_e_direction_change_lookahead_extra_fades_by_speed(controller, spee
assert controller._direction_change_lookahead_extra(speed) == pytest.approx(expected)
@pytest.mark.parametrize("speed,desired,expected", (
(1.8, 0.0010, 0.0),
(1.9, 0.0010, 0.5),
(2.0, 0.0010, 1.0),
(2.8, 0.0010, 1.0),
(3.15, 0.0010, 0.5),
(3.5, 0.0010, 0.0),
(2.5, 0.0004, 0.0),
(2.5, 0.0005, 0.5),
(2.5, 0.0006, 1.0),
))
def test_mach_e_low_speed_direction_change_weight(controller, speed, desired, expected):
assert controller._low_speed_direction_change_weight(speed, desired) == pytest.approx(expected)
@pytest.mark.parametrize("sign", (1.0, -1.0))
def test_mach_e_direction_change_preview_leads_a_lagging_unwind(controller, sign):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
@@ -257,6 +272,30 @@ def test_mach_e_direction_change_preview_uses_far_path_when_unwind_lags(controll
pytest.approx(0.003), True)]
def test_mach_e_direction_change_preview_leads_low_speed_handoff(controller, monkeypatch):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.sm["liveDelay"].lateralDelay = 0.4
controller.desired_curvature_last = 0.002
blend_inputs = []
monkeypatch.setattr(
controller, "_predicted_curvature",
lambda _v_ego, lookahead: 0.002 if lookahead < 1.0 else -0.002,
)
monkeypatch.setattr(
controller, "_blend_and_scale",
lambda desired, predicted, v_ego, current, allow_opposite_preview=False:
blend_inputs.append((desired, predicted, v_ego, current, allow_opposite_preview)) or (0.0, 1),
)
controller.update(
SimpleNamespace(latActive=True), car_state(speed=2.5, curvature=0.003),
SimpleNamespace(curvature=0.0015),
)
assert blend_inputs == [(pytest.approx(0.0015), pytest.approx(-0.002), pytest.approx(2.5),
pytest.approx(0.003), True)]
def test_mach_e_direction_change_preview_uses_extended_horizon_at_medium_speed(controller, monkeypatch):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.sm["liveDelay"].lateralDelay = 0.4
@@ -310,8 +349,12 @@ def test_mach_e_extended_direction_horizon_does_not_replace_turn_in_preview(cont
@pytest.mark.parametrize("speed,steering_pressed,lane_change", (
(1.8, False, False),
(3.5, False, False),
(8.0, False, False),
(9.0, False, False),
(2.5, True, False),
(2.5, False, True),
(15.0, True, False),
(15.0, False, True),
))
@@ -340,6 +383,26 @@ def test_non_mach_e_direction_change_preview_is_unchanged(controller):
desired=0.0015, preview=-0.002, current=0.003) == 0.0
def test_non_mach_e_bypasses_low_speed_direction_change_preview(controller, monkeypatch):
controller.desired_curvature_last = 0.002
lookaheads = []
monkeypatch.setattr(
controller, "_predicted_curvature",
lambda _v_ego, lookahead: lookaheads.append(lookahead) or -0.002,
)
monkeypatch.setattr(
controller, "_low_speed_direction_change_weight",
lambda *_args: pytest.fail("low-speed direction-change preview must remain Mach-E-only"),
)
controller.update(
SimpleNamespace(latActive=True), car_state(speed=2.5, curvature=0.003),
SimpleNamespace(curvature=0.0015),
)
assert lookaheads == [pytest.approx(0.2)]
def test_mach_e_turn_in_preview_uses_extra_model_horizon(controller, monkeypatch):
controller.CP.carFingerprint = CAR.FORD_MUSTANG_MACH_E_MK1
controller.sm["liveDelay"].lateralDelay = 0.4
@@ -2842,8 +2842,8 @@
{
"key": "ShowBrakeStatus",
"label": "Show Brake Status",
"description": "Tint the on-screen steering-wheel icon red while the car reports that the brake pedal is pressed.",
"picker_description": "Tints the on-screen steering-wheel icon red while braking.",
"description": "Tint the on-screen steering-wheel icon red while the car reports that its brake lights are on.",
"picker_description": "Tints the on-screen steering-wheel icon red while the brake lights are on.",
"data_type": "bool",
"ui_type": "toggle",
"galaxy_only": true,
@@ -360,7 +360,8 @@ ul { list-style: none; margin: 0; padding: 0; }
}
.gx-appbar .gx-appbar__back,
.gx-appbar .gx-theme-toggle {
.gx-appbar .gx-theme-toggle,
.gx-appbar .gx-appbar__pin {
background: var(--glass-bg);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
@@ -464,6 +465,7 @@ ul { list-style: none; margin: 0; padding: 0; }
.gx-icon-btn i { font-size: 1.3rem; }
.gx-back-btn { display: none; }
.gx-menu-btn { display: inline-flex; }
.gx-appbar .gx-appbar__pin { display: none; }
.gx-searchwrap {
align-items: center;
@@ -1297,6 +1299,336 @@ button.gx-chip:hover {
letter-spacing: -0.02em;
}
.gx-drawer__pin {
color: var(--text-muted);
margin-left: auto;
}
.gx-drawer__pin:hover,
.gx-drawer__pin[aria-pressed="true"] {
color: var(--primary);
}
.gx-language-card {
margin-bottom: var(--sp-4);
}
.gx-language-card__row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
}
.gx-language-card__label {
align-items: center;
display: flex;
flex: 1 1 360px;
gap: var(--sp-4);
min-width: 220px;
}
.gx-language-card__select {
margin-left: var(--sp-2);
max-width: 220px;
}
.gx-language-card__hint {
margin-left: auto;
}
@media (min-width: 768px) {
.gx-app.gx-nav-pinned .gx-content {
margin-left: 320px;
width: calc(100% - 320px);
}
.gx-app.gx-nav-pinned .gx-appbar {
margin-left: 320px;
width: calc(100% - 320px);
}
}
@media (max-width: 767px) {
.gx-language-card__label {
flex-basis: 100%;
}
.gx-language-card__hint {
margin-left: 0;
width: 100%;
}
}
/* The destination view is a map-first screen. Search and route details float
over the map, matching the classic navigation experience. */
.gx-navigation-view {
margin: calc(-1 * var(--sp-4));
min-height: calc(100dvh - var(--appbar-height));
position: relative;
}
.gx-navigation-stage {
background: var(--surface-container-low);
min-height: calc(100dvh - var(--appbar-height));
overflow: hidden;
position: relative;
width: 100%;
}
.gx-navigation-map {
height: calc(100dvh - var(--appbar-height));
min-height: 520px;
width: 100%;
}
.gx-navigation-map .mapboxgl-canvas {
outline: none;
}
/* Route details use a light Mapbox popup over the map. Keep every text span
readable when the route is changed, without affecting the destination cards
or the classic navigation view. */
.gx-navigation-stage .route-tooltip .mapboxgl-popup-content,
.gx-navigation-stage .route-tooltip .custom-tooltip,
.gx-navigation-stage .route-tooltip .tooltip-row,
.gx-navigation-stage .route-tooltip .tooltip-row .label,
.gx-navigation-stage .route-tooltip .tooltip-row .value {
color: #000 !important;
}
.gx-navigation-stage .route-tooltip .mapboxgl-popup-content {
background: #fff !important;
}
.gx-navigation-overlay {
inset: 0;
padding: var(--sp-4);
pointer-events: none;
position: absolute;
z-index: 2;
}
.gx-navigation-overlay > * {
pointer-events: auto;
}
.gx-navigation-search,
.gx-navigation-summary,
.gx-navigation-recent,
.gx-navigation-error {
max-width: min(500px, calc(100vw - 32px));
}
.gx-navigation-search {
margin: 0;
overflow: visible;
padding: var(--sp-2);
}
.gx-navigation-search__row {
align-items: center;
display: flex;
gap: var(--sp-2);
}
.gx-navigation-search__row > i {
color: var(--primary);
font-size: 1.15rem;
padding-left: var(--sp-2);
}
.gx-navigation-search .gx-field {
background: transparent;
border: 0;
box-shadow: none;
flex: 1;
min-width: 0;
padding-left: var(--sp-1);
}
.gx-navigation-send {
color: var(--primary);
flex: 0 0 auto;
}
.gx-navigation-status,
.gx-navigation-recent__title {
color: var(--text-muted);
font-size: var(--fs-sm);
padding: var(--sp-2) var(--sp-3) var(--sp-1);
}
.gx-navigation-suggestions {
background: var(--card-scroll-bg);
border-top: 1px solid var(--outline);
display: grid;
gap: 2px;
margin: var(--sp-2) calc(-1 * var(--sp-2)) calc(-1 * var(--sp-2));
max-height: 300px;
overflow-y: auto;
padding: var(--sp-2);
}
.gx-navigation-suggestion {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--radius-sm);
color: var(--on-surface);
cursor: pointer;
display: flex;
gap: var(--sp-3);
justify-content: space-between;
min-height: 52px;
padding: var(--sp-2) var(--sp-3);
text-align: left;
width: 100%;
}
.gx-navigation-suggestion:hover,
.gx-navigation-suggestion:focus-visible {
background: var(--glass-active-bg);
}
.gx-navigation-suggestion > span {
display: grid;
gap: 2px;
min-width: 0;
}
.gx-navigation-suggestion strong,
.gx-navigation-suggestion small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gx-navigation-suggestion small {
color: var(--text-muted);
}
.gx-navigation-summary {
margin: var(--sp-4) 0 0;
padding: var(--sp-3);
}
.gx-navigation-summary__title {
background: var(--surface-container-high);
border-radius: var(--radius-md);
font-size: var(--fs-lg);
font-weight: var(--fw-bold);
overflow: hidden;
padding: var(--sp-2) var(--sp-3);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.gx-navigation-summary__rows {
display: grid;
gap: var(--sp-1);
padding: var(--sp-3) var(--sp-2);
}
.gx-navigation-summary__rows > div {
align-items: center;
display: grid;
gap: var(--sp-2);
grid-template-columns: 28px 90px 1fr;
min-height: 28px;
}
.gx-navigation-summary__rows strong {
font-weight: var(--fw-bold);
}
.gx-navigation-summary__icon {
font-size: 1.15rem;
text-align: center;
}
.gx-navigation-summary__actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
justify-content: center;
}
.gx-navigation-summary__actions .gx-btn {
flex: 1 1 190px;
min-width: 0;
}
.gx-btn--success { background: var(--success); color: var(--on-secondary); }
.gx-btn--success:hover { background: #6df3e9; }
.gx-btn--danger { background: var(--error); color: var(--on-error); }
.gx-btn--danger:hover { background: #ff7492; }
.gx-btn--favorite { background: var(--accent-rose); color: #fff; }
.gx-btn--favorite:hover,
.gx-btn--favorite.active { background: #ed4f94; }
.gx-navigation-recent {
margin: var(--sp-4) 0 0;
padding: var(--sp-2);
}
.gx-navigation-recent .gx-navigation-suggestion + .gx-navigation-suggestion {
border-top: 1px solid var(--outline);
}
.gx-navigation-error {
color: var(--error);
margin: var(--sp-4) 0 0;
padding: var(--sp-3);
}
.gx-navigation-empty {
align-items: center;
display: flex;
flex-direction: column;
gap: var(--sp-3);
justify-content: center;
margin: 0;
min-height: calc(100dvh - var(--appbar-height));
padding: var(--sp-6);
text-align: center;
}
.gx-navigation-empty p {
color: var(--text-muted);
margin: 0;
}
.gx-navigation-tabs {
pointer-events: auto;
position: absolute;
right: var(--sp-4);
top: var(--sp-4);
z-index: 3;
}
@media (max-width: 767px) {
.gx-navigation-view {
margin: calc(-1 * var(--sp-3));
}
.gx-navigation-overlay {
padding: var(--sp-3);
}
.gx-navigation-tabs {
bottom: calc(var(--bottomnav-height) + var(--sp-3));
left: var(--sp-3);
right: auto;
top: auto;
}
.gx-navigation-summary__rows > div {
grid-template-columns: 28px 82px 1fr;
}
}
.gx-nav-section { margin-bottom: var(--sp-3); }
.gx-nav-section__title {
@@ -1868,6 +2200,7 @@ button.gx-chip:hover {
.gx-back-btn { display: inline-flex; }
.gx-appbar__search { flex: 1; min-width: 0; }
.gx-status-pill { display: none; }
.gx-appbar .gx-appbar__pin { display: inline-flex; }
.gx-content { padding-left: var(--sp-3); padding-right: var(--sp-3); }
}
@@ -185,6 +185,7 @@ export const api = {
getNavigation() { return request("/api/navigation") },
setNavigation(body) { return request("/api/navigation", { method: "POST", data: body }) },
clearNavigation() { return request("/api/navigation", { method: "DELETE" }) },
getNavigationFavorites() { return request("/api/navigation/favorite", { cache: "no-store" }) },
mapboxSuggest(query, accessToken, sessionToken, context = {}) {
const params = new URLSearchParams({ access_token: accessToken, session_token: sessionToken, q: query, limit: "4", ...context })
@@ -207,6 +208,7 @@ export const api = {
getNavigationKeys() { return request("/api/navigation_key") },
setNavigationKey(body) { return request("/api/navigation_key", { method: "POST", data: body }) },
navigationFavorite(body) { return request("/api/navigation/favorite", { method: "POST", data: body }) },
deleteNavigationFavorite(body) { return request("/api/navigation/favorite", { method: "DELETE", data: body }) },
deleteNavigationKey(type) { return request(`/api/navigation_key?type=${encodeURIComponent(type)}`, { method: "DELETE" }) },
async systemMonitor(signal) {
@@ -6,7 +6,7 @@ import { Tools } from "./views/Tools.js"
import { Recordings } from "./views/Recordings.js"
import { Logs } from "./views/Logs.js"
import { Tuning } from "./views/Tuning.js"
import { Navigation } from "./views/Navigation.js?v=nav-destination-2"
import { Navigation } from "./views/Navigation.js?v=nav-destination-3"
import { Vehicle } from "./views/Vehicle.js"
import { Bluetooth } from "./views/Bluetooth.js"
import { SystemTools } from "./views/SystemTools.js"
@@ -22,6 +22,7 @@ import { ModelLaboratory } from "./views/ModelLaboratory.js"
import { Cameras } from "./views/Cameras.js"
import { store, initRouter, navigate } from "./store.js"
import { showSnackbar } from "./api.js"
import { installDomTranslator } from "./i18n.js"
window.__galaxyVue = { createApp, h }
@@ -87,6 +88,7 @@ const app = createApp({
})
app.mount("#galaxy-app")
installDomTranslator(document.getElementById("galaxy-app"))
initRouter()
@@ -1,4 +1,4 @@
import { store, navigate, goBack, toolHref, toggleTheme } from "../store.js"
import { store, navigate, goBack, toolHref, toggleTheme, toggleNavPinned } from "../store.js"
import { api } from "../api.js"
import { usePolling } from "../composables.js"
import { languageState, setLanguage, t } from "../i18n.js"
@@ -40,6 +40,7 @@ export const AppShell = {
online() { return store.online },
statusLabel() { return store.online ? t(store.deviceStatus, store.deviceStatus) : t("Offline") },
isLight() { return store.theme === "light" },
navPinned() { return store.navPinned },
drawerOpen: {
get() { return store.drawerOpen },
set(v) { store.drawerOpen = v },
@@ -59,7 +60,7 @@ export const AppShell = {
},
methods: {
tr(key, fallback = key) { return t(key, fallback) },
closeDrawer() { store.drawerOpen = false },
closeDrawer() { if (!store.navPinned) store.drawerOpen = false },
back() { goBack() },
async refreshStatus() {
try {
@@ -84,6 +85,7 @@ export const AppShell = {
this.$nextTick(() => { const el = this.$refs.searchInput; if (el) el.focus() })
},
themeToggle() { toggleTheme() },
toggleNavPin() { toggleNavPinned() },
navTo(link) {
this.closeDrawer()
navigate(toolHref(link))
@@ -107,7 +109,7 @@ export const AppShell = {
this.statusPoll?.destroy()
},
template: `
<div class="gx-app">
<div class="gx-app" :class="{ 'gx-nav-pinned': navPinned }">
<header class="gx-appbar">
<button type="button" class="gx-icon-btn gx-appbar__back gx-back-btn" :aria-label="tr('Back')" @click="back">
<i class="bi bi-arrow-left"></i>
@@ -138,15 +140,25 @@ export const AppShell = {
:title="isLight ? tr('Dark mode') : tr('Light mode')" @click="themeToggle">
<i class="bi" :class="isLight ? 'bi-moon-stars-fill' : 'bi-sun-fill'"></i>
</button>
<button type="button" class="gx-icon-btn gx-appbar__pin" :aria-pressed="navPinned"
:aria-label="navPinned ? tr('Unpin navigation') : tr('Pin navigation')"
:title="navPinned ? tr('Unpin navigation') : tr('Pin navigation')" @click="toggleNavPin">
<i class="bi" :class="navPinned ? 'bi-pin-angle-fill' : 'bi-pin-angle'"></i>
</button>
</header>
<transition name="gx-fade">
<div v-if="store.drawerOpen" class="gx-underlay" @click="closeDrawer"></div>
<div v-if="store.drawerOpen && !navPinned" class="gx-underlay" @click="closeDrawer"></div>
</transition>
<aside class="gx-drawer" :class="{ open: store.drawerOpen }">
<aside class="gx-drawer" :class="{ open: store.drawerOpen || navPinned }">
<div class="gx-drawer__header">
<img class="gx-logo" src="/assets/images/main_logo.png" alt="Galaxy logo" />
<span class="gx-drawer-title">{{ tr("Galaxy") }}</span>
<button type="button" class="gx-icon-btn gx-drawer__pin" :aria-pressed="navPinned"
:aria-label="navPinned ? tr('Unpin navigation') : tr('Pin navigation')"
:title="navPinned ? tr('Unpin navigation') : tr('Pin navigation')" @click.stop="toggleNavPin">
<i class="bi" :class="navPinned ? 'bi-pin-angle-fill' : 'bi-pin-angle'"></i>
</button>
</div>
<div class="gx-nav-section">
<div class="gx-nav-section__title">{{ tr("Main") }}</div>
@@ -45,19 +45,19 @@ export const LanguageSelector = {
},
},
template: `
<div class="gx-card" style="margin-bottom:16px;">
<div class="gx-card gx-language-card">
<div class="gx-section__header">
<i class="bi bi-translate"></i>
<span class="gx-section__title">{{ tr("Language") }}</span>
</div>
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<label style="display:flex; align-items:center; gap:10px; flex:1; min-width:220px;">
<div class="gx-language-card__row">
<label class="gx-language-card__label">
<span>{{ tr("Select language") }}</span>
<select class="gx-field" style="max-width:220px;" :value="selected" :disabled="saving" @change="change">
<select class="gx-field gx-language-card__select" :value="selected" :disabled="saving" @change="change">
<option v-for="option in languages" :key="option.value" :value="option.value">{{ tr(option.label, option.label) }}</option>
</select>
</label>
<small class="gx-row__desc">{{ tr("Galaxy uses English when no language is selected.") }}</small>
<small class="gx-row__desc gx-language-card__hint">{{ tr("Galaxy uses English when no language is selected.") }}</small>
</div>
<p v-if="error" class="gx-row__desc" style="color:var(--danger); margin:8px 0 0;">{{ error }}</p>
</div>
@@ -1,5 +1,12 @@
import { api, showSnackbar } from "../api.js"
import { getMapboxSearchContext, addRouteToMap, removeRouteFromMap } from "../../../components/navigation/navigation_utilities.js?v=nav-search-context-2"
import {
getMapboxSearchContext,
addRouteToMap,
removeRouteFromMap,
formatSecondsToHuman,
formatMetersToHuman,
formatMetersToMiles,
} from "../../../components/navigation/navigation_utilities.js?v=nav-search-context-2"
const MAPBOX_STYLE = "mapbox://styles/frogsgomoo/cmcfv151j000o01rcdxebhl76"
@@ -83,6 +90,9 @@ export const NavigationDestinationPanel = {
recentDestinations: [],
favorites: [],
destination: null,
routeSummary: null,
navigationStarted: false,
isMetric: false,
mapboxPublic: "",
language: "",
lastPosition: null,
@@ -107,6 +117,15 @@ export const NavigationDestinationPanel = {
return true
}).slice(0, 10)
},
favoriteDestination() {
const destination = coordinates(this.destination)
if (!destination) return null
return this.favorites.find((favorite) => {
const favoriteCoordinates = coordinates(favorite)
return favoriteCoordinates && Math.abs(favoriteCoordinates.latitude - destination.latitude) < 0.00001 && Math.abs(favoriteCoordinates.longitude - destination.longitude) < 0.00001
}) || null
},
isFavorite() { return !!this.favoriteDestination },
},
async mounted() {
await this.load()
@@ -121,6 +140,13 @@ export const NavigationDestinationPanel = {
},
methods: {
secondaryLabel,
isPlaceFavorite(place) {
const placeCoordinates = coordinates(place)
return !!placeCoordinates && this.favorites.some((favorite) => {
const favoriteCoordinates = coordinates(favorite)
return favoriteCoordinates && Math.abs(favoriteCoordinates.latitude - placeCoordinates.latitude) < 0.00001 && Math.abs(favoriteCoordinates.longitude - placeCoordinates.longitude) < 0.00001
})
},
async load() {
try {
const [nav, favoritePayload] = await Promise.all([
@@ -129,6 +155,7 @@ export const NavigationDestinationPanel = {
])
this.mapboxPublic = String(nav?.mapboxPublic || "").trim()
this.language = String(nav?.language || "").trim()
this.isMetric = !!nav?.isMetric
this.lastPosition = coordinates(nav?.lastPosition)
this.favorites = Array.isArray(favoritePayload?.favorites) ? favoritePayload.favorites : []
this.recentDestinations = parseJson(nav?.previousDestinations, [])
@@ -136,8 +163,10 @@ export const NavigationDestinationPanel = {
const savedDestination = coordinates(nav?.destination) || coordinates(saved)
if (savedDestination) {
const raw = saved || nav?.destination || {}
this.destination = { ...raw, ...savedDestination, name: labelFor(raw) || "Current destination" }
const savedName = String(raw?.name || raw?.text || "").trim()
this.destination = { ...raw, ...savedDestination, name: savedName || labelFor(raw) || "Current destination" }
this.query = this.destination.name
this.navigationStarted = true
}
} catch (e) {
this.error = e?.message || "Failed to load navigation."
@@ -179,6 +208,9 @@ export const NavigationDestinationPanel = {
},
onInput(event) {
this.destination = null
this.routeSummary = null
this.navigationStarted = false
if (this.map) removeRouteFromMap(this.map)
this.searchRequest += 1
this.searching = false
this.error = ""
@@ -205,7 +237,8 @@ export const NavigationDestinationPanel = {
}
},
async resolvePlace(place) {
const placeLabel = labelFor(place) || this.query.trim()
const primaryLabel = String(place?.name || place?.text || "").trim()
const placeLabel = primaryLabel || labelFor(place) || this.query.trim()
let coords = coordinates(place?.geometry?.coordinates) || coordinates(place)
if (!coords && place?.mapbox_id) {
const payload = await api.mapboxRetrieve(place.mapbox_id, this.mapboxPublic, this.sessionToken)
@@ -216,7 +249,7 @@ export const NavigationDestinationPanel = {
coords = coordinates(payload?.features?.[0]?.geometry?.coordinates)
}
if (!coords) throw new Error("Could not determine that location.")
return { ...coords, name: placeLabel, place_name: placeLabel }
return { ...coords, name: primaryLabel || placeLabel, place_name: labelFor(place) || placeLabel }
},
async chooseSuggestion(place) {
this.searching = true
@@ -248,6 +281,7 @@ export const NavigationDestinationPanel = {
this.destination = place || await this.resolveQuery()
if (!this.destination) throw new Error("Enter a destination first.")
await api.setNavigation(this.destination)
this.navigationStarted = true
this.query = this.destination.name
this.suggestions = []
await this.previewDestination(this.destination)
@@ -259,12 +293,60 @@ export const NavigationDestinationPanel = {
this.loadingRoute = false
}
},
async cancelNavigation() {
try {
await api.clearNavigation()
this.navigationStarted = false
this.destination = null
this.routeSummary = null
this.query = ""
this.suggestions = []
if (this.map) removeRouteFromMap(this.map)
this.destinationMarker?.remove()
this.destinationMarker = null
showSnackbar("Navigation cancelled.")
} catch (e) {
showSnackbar(e?.message || "Could not cancel navigation.", "error")
}
},
async toggleFavorite() {
const destination = coordinates(this.destination)
if (!destination) return
const favorite = this.favoriteDestination
try {
if (favorite) {
await api.deleteNavigationFavorite(favorite)
showSnackbar("Removed from favorites.")
} else {
await api.navigationFavorite({
name: this.destination.name || this.query || "Favorite destination",
longitude: destination.longitude,
latitude: destination.latitude,
routeId: this.routeSummary?.routeId || null,
})
showSnackbar("Added to favorites.")
}
const payload = await api.getNavigationFavorites()
this.favorites = Array.isArray(payload?.favorites) ? payload.favorites : []
} catch (e) {
showSnackbar(e?.message || "Could not update favorites.", "error")
}
},
formatDistance(value) {
return this.isMetric ? formatMetersToHuman(value, true) : formatMetersToMiles(value)
},
formatDuration(value) { return formatSecondsToHuman(value) },
formatEta(value) {
const eta = new Date(Date.now() + Number(value || 0) * 1000)
return eta.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
},
async previewDestination(place) {
if (!this.mapReady || !this.map || !place) return
const mapboxgl = window.mapboxgl
this.destinationMarker?.remove()
this.destinationMarker = new mapboxgl.Marker({ color: "#9d72ff" }).setLngLat([place.longitude, place.latitude]).addTo(this.map)
if (!this.lastPosition) {
this.routeSummary = null
this.map.flyTo({ center: [place.longitude, place.latitude], zoom: 14 })
return
}
@@ -272,48 +354,72 @@ export const NavigationDestinationPanel = {
const payload = await api.mapboxDirections(this.lastPosition, place, this.mapboxPublic)
const routes = Array.isArray(payload?.routes) ? payload.routes : []
if (routes.length) {
const route = routes[0]
this.routeSummary = {
distance: Number(route.distance) || 0,
duration: Number(route.duration) || 0,
routeId: "main",
}
removeRouteFromMap(this.map)
addRouteToMap(this.map, routes, [this.lastPosition.longitude, this.lastPosition.latitude], [place.longitude, place.latitude], () => {}, true, () => "main")
} else {
this.routeSummary = null
this.map.fitBounds([[this.lastPosition.longitude, this.lastPosition.latitude], [place.longitude, place.latitude]], { padding: 80, duration: 500 })
}
} catch (e) {
this.routeSummary = null
this.map.fitBounds([[this.lastPosition.longitude, this.lastPosition.latitude], [place.longitude, place.latitude]], { padding: 80, duration: 500 })
}
},
usePlace(place) { this.chooseSuggestion(place) },
},
template: `
<div style="display:grid; gap:12px;">
<section class="gx-card">
<div class="gx-section__header"><i class="bi bi-geo-alt-fill"></i><span class="gx-section__title">Navigation Destination</span></div>
<div style="padding:var(--sp-3); display:grid; gap:8px;">
<p v-if="!hasMapbox && !loading" class="gx-row__desc" style="margin:0;">Add a Mapbox public key in <a href="#/navigation/keys">App Keys</a> to search destinations and show the map.</p>
<div style="display:flex; gap:8px;">
<input class="gx-field" style="flex:1;" v-model="query" @input="onInput" @keyup.enter="setDestination()" placeholder="Search an address or place" autocomplete="off" />
<button type="button" class="gx-btn" :disabled="loadingRoute || searching || !query.trim()" @click="setDestination()"><i class="bi bi-send"></i> {{ loadingRoute ? 'Setting...' : 'Send' }}</button>
<div class="gx-navigation-stage">
<div v-if="loading || !hasMapbox" class="gx-navigation-empty gx-card">
<div class="gx-loading">{{ loading ? 'Loading navigation...' : 'Map unavailable until a Mapbox key is configured.' }}</div>
<p v-if="!hasMapbox && !loading">Add a Mapbox public key in <a href="#/navigation/keys">App Keys</a> to search destinations and show the map.</p>
</div>
<div v-else ref="map" class="gx-navigation-map"></div>
<div v-if="hasMapbox && !loading" class="gx-navigation-overlay">
<section class="gx-navigation-search gx-card">
<div class="gx-navigation-search__row">
<i class="bi bi-search" aria-hidden="true"></i>
<input class="gx-field" v-model="query" @input="onInput" @keyup.enter="setDestination()" placeholder="Search here" aria-label="Search for a destination" autocomplete="off" />
<button type="button" class="gx-icon-btn gx-navigation-send" :disabled="loadingRoute || searching || !query.trim()" @click="setDestination()" aria-label="Send destination" title="Send destination"><i class="bi bi-send-fill"></i></button>
</div>
<div v-if="searching" class="gx-row__desc">Searching...</div>
<div v-if="suggestions.length" style="display:grid; gap:4px;">
<button v-for="place in suggestions" :key="place.mapbox_id || place.id || place.name" type="button" class="gx-row" style="text-align:left; cursor:pointer;" @click="chooseSuggestion(place)">
<span class="gx-row__info"><span class="gx-row__label">{{ place.name || place.text || place.place_name || 'Unnamed location' }}</span><span class="gx-row__desc">{{ secondaryLabel(place) }}</span></span>
<div v-if="searching" class="gx-navigation-status">Searching...</div>
<div v-if="suggestions.length" class="gx-navigation-suggestions">
<button v-for="place in suggestions" :key="place.mapbox_id || place.id || place.name" type="button" class="gx-navigation-suggestion" @click="chooseSuggestion(place)">
<span><strong>{{ place.name || place.text || place.place_name || 'Unnamed location' }}</strong><small>{{ secondaryLabel(place) }}</small></span>
<i class="bi bi-chevron-right"></i>
</button>
</div>
<div v-if="recentPlaces.length && !suggestions.length && !query" style="display:grid; gap:4px;">
<div class="gx-row__desc">Recent and favorite destinations</div>
<button v-for="place in recentPlaces" :key="place.id || place.name" type="button" class="gx-row" style="text-align:left; cursor:pointer;" @click="usePlace(place)">
<span class="gx-row__info"><span class="gx-row__label">{{ place.name || place.place_name }}</span><span class="gx-row__desc">{{ secondaryLabel(place) }}</span></span>
<i class="bi bi-clock-history"></i>
</button>
</section>
<section v-if="destination" class="gx-navigation-summary gx-card">
<div class="gx-navigation-summary__title">{{ destination.name || query || 'Destination' }}</div>
<div v-if="routeSummary" class="gx-navigation-summary__rows">
<div><span class="gx-navigation-summary__icon">🛣️</span><span>Distance:</span><strong>{{ formatDistance(routeSummary.distance) }}</strong></div>
<div><span class="gx-navigation-summary__icon">⌛</span><span>Duration:</span><strong>{{ formatDuration(routeSummary.duration) }}</strong></div>
<div><span class="gx-navigation-summary__icon">🕗</span><span>ETA:</span><strong>{{ formatEta(routeSummary.duration) }}</strong></div>
</div>
<p v-if="error" class="gx-row__desc" style="color:var(--error); margin:0;">{{ error }}</p>
</div>
</section>
<section class="gx-card" style="overflow:hidden;">
<div v-if="loading || !hasMapbox" class="gx-loading" style="min-height:280px; display:grid; place-items:center;">{{ loading ? 'Loading navigation...' : 'Map unavailable until a Mapbox key is configured.' }}</div>
<div v-else ref="map" style="height:380px; width:100%; min-height:280px;"></div>
</section>
<div class="gx-navigation-summary__actions">
<button v-if="navigationStarted" type="button" class="gx-btn gx-btn--danger" @click="cancelNavigation"><i class="bi bi-x-lg"></i> Cancel Navigation</button>
<button v-else type="button" class="gx-btn gx-btn--success" :disabled="loadingRoute" @click="setDestination(destination)"><i class="bi bi-sign-turn-right"></i> {{ loadingRoute ? 'Calculating...' : 'Start Navigation' }}</button>
<button type="button" class="gx-btn gx-btn--favorite" :class="{ active: isFavorite }" @click="toggleFavorite"><i class="bi" :class="isFavorite ? 'bi-heart-fill' : 'bi-heart'"></i> {{ isFavorite ? 'Unfavorite' : 'Favorite' }}</button>
</div>
</section>
<section v-if="recentPlaces.length && !suggestions.length && !query && !destination" class="gx-navigation-recent gx-card">
<div class="gx-navigation-recent__title">Recent and favorite destinations</div>
<button v-for="place in recentPlaces" :key="place.id || place.name" type="button" class="gx-navigation-suggestion" @click="usePlace(place)">
<span><strong>{{ place.name || place.place_name }}</strong><small>{{ secondaryLabel(place) }}</small></span>
<i class="bi" :class="isPlaceFavorite(place) ? 'bi-heart-fill' : 'bi-clock-history'"></i>
</button>
</section>
<p v-if="error" class="gx-navigation-error gx-card">{{ error }}</p>
</div>
</div>
`,
}
@@ -105,6 +105,111 @@ const TRANSLATIONS = {
},
}
// The settings catalog is shared with the native UI, so most of its labels
// arrive from the device rather than this bundle. These common section names
// and controls keep the Galaxy settings screen translated as well.
const MORE_TRANSLATIONS = {
es: {
Favorites: "Favoritos", "Lateral (Steering)": "Lateral (Dirección)",
"Longitudinal (Speed & Following)": "Longitudinal (Velocidad y seguimiento)",
"Vision Speed Limits": "Límites de velocidad por visión", "Visual (Display & UI)": "Visual (Pantalla e interfaz)",
"Sounds & Alerts": "Sonidos y alertas", Vehicle: "Vehículo", "Wheel Controls": "Controles del volante",
"Device & Data": "Dispositivo y datos", Developer: "Desarrollador", "Advanced Lateral Tuning": "Ajuste lateral avanzado",
"Advanced steering control changes to fine-tune how openpilot drives.": "Cambios avanzados en el control de la dirección para ajustar cómo conduce openpilot.",
"Always On Lateral": "Lateral siempre activo", "openpilot's steering remains active even when the accelerator or brake pedals are pressed.": "La dirección de openpilot permanece activa incluso cuando se pisan el acelerador o los frenos.",
"Lane Changes": "Cambios de carril", "Allow openpilot to change lanes.": "Permitir que openpilot cambie de carril.",
"Lateral Tuning": "Ajuste lateral", "Miscellaneous steering control changes to fine-tune how openpilot drives.": "Cambios diversos del control de la dirección para ajustar cómo conduce openpilot.",
"Quality of Life": "Calidad de vida", "Steering control changes to fine-tune how openpilot drives.": "Cambios del control de la dirección para ajustar cómo conduce openpilot.",
"Enable V-ASM": "Activar V-ASM", "Favorites": "Favoritos", "Device & Data": "Dispositivo y datos",
},
fr: {
Favorites: "Favoris", "Lateral (Steering)": "Latéral (Direction)",
"Longitudinal (Speed & Following)": "Longitudinal (Vitesse et suivi)",
"Vision Speed Limits": "Limites de vitesse par vision", "Visual (Display & UI)": "Visuel (Affichage et interface)",
"Sounds & Alerts": "Sons et alertes", Vehicle: "Véhicule", "Wheel Controls": "Commandes au volant",
"Device & Data": "Appareil et données", Developer: "Développeur", "Advanced Lateral Tuning": "Réglage latéral avancé",
"Advanced steering control changes to fine-tune how openpilot drives.": "Modifications avancées de la direction pour régler finement le comportement dopenpilot.",
"Always On Lateral": "Direction latérale toujours active", "openpilot's steering remains active even when the accelerator or brake pedals are pressed.": "La direction dopenpilot reste active même lorsque laccélérateur ou les freins sont enfoncés.",
"Lane Changes": "Changements de voie", "Allow openpilot to change lanes.": "Autoriser openpilot à changer de voie.",
"Lateral Tuning": "Réglage latéral", "Miscellaneous steering control changes to fine-tune how openpilot drives.": "Divers réglages de direction pour ajuster finement le comportement dopenpilot.",
"Quality of Life": "Confort dutilisation", "Steering control changes to fine-tune how openpilot drives.": "Réglages de direction pour ajuster finement le comportement dopenpilot.",
"Enable V-ASM": "Activer V-ASM",
},
ko: {
Favorites: "즐겨찾기", "Lateral (Steering)": "횡방향 (조향)",
"Longitudinal (Speed & Following)": "종방향 (속도 및 추종)",
"Vision Speed Limits": "비전 속도 제한", "Visual (Display & UI)": "시각 (디스플레이 및 UI)",
"Sounds & Alerts": "소리 및 경고", Vehicle: "차량", "Wheel Controls": "휠 컨트롤",
"Device & Data": "장치 및 데이터", Developer: "개발자", "Advanced Lateral Tuning": "고급 횡방향 튜닝",
"Advanced steering control changes to fine-tune how openpilot drives.": "openpilot의 주행 방식을 세밀하게 조정하는 고급 조향 제어 변경입니다.",
"Always On Lateral": "항상 활성화된 횡방향 제어", "openpilot's steering remains active even when the accelerator or brake pedals are pressed.": "가속 페달이나 브레이크 페달을 밟아도 openpilot 조향이 계속 활성화됩니다.",
"Lane Changes": "차선 변경", "Allow openpilot to change lanes.": "openpilot이 차선을 변경하도록 허용합니다.",
"Lateral Tuning": "횡방향 튜닝", "Miscellaneous steering control changes to fine-tune how openpilot drives.": "openpilot의 주행을 세밀하게 조정하는 기타 조향 제어 변경입니다.",
"Quality of Life": "편의 기능", "Steering control changes to fine-tune how openpilot drives.": "openpilot의 주행을 세밀하게 조정하는 조향 제어 변경입니다.",
"Enable V-ASM": "V-ASM 활성화",
},
"zh-CHS": {
Favorites: "收藏", "Lateral (Steering)": "横向(转向)",
"Longitudinal (Speed & Following)": "纵向(速度和跟车)", "Vision Speed Limits": "视觉限速",
"Visual (Display & UI)": "视觉(显示和界面)", "Sounds & Alerts": "声音和提醒", Vehicle: "车辆",
"Wheel Controls": "方向盘控制", "Device & Data": "设备和数据", Developer: "开发者", "Advanced Lateral Tuning": "高级横向调校",
"Advanced steering control changes to fine-tune how openpilot drives.": "用于精细调整 openpilot 驾驶方式的高级转向控制设置。",
"Always On Lateral": "始终启用横向控制", "openpilot's steering remains active even when the accelerator or brake pedals are pressed.": "即使踩下加速或制动踏板,openpilot 转向仍保持启用。",
"Lane Changes": "变道", "Allow openpilot to change lanes.": "允许 openpilot 变道。", "Lateral Tuning": "横向调校",
"Miscellaneous steering control changes to fine-tune how openpilot drives.": "用于精细调整 openpilot 驾驶方式的其他转向控制设置。",
"Quality of Life": "使用体验", "Steering control changes to fine-tune how openpilot drives.": "用于精细调整 openpilot 驾驶方式的转向控制设置。",
"Enable V-ASM": "启用 V-ASM",
},
}
Object.keys(MORE_TRANSLATIONS).forEach((code) => Object.assign(TRANSLATIONS[code], MORE_TRANSLATIONS[code]))
// A word-level fallback covers the many device-provided descriptions and the
// older Galaxy views that still contain literal English labels. Exact phrases
// above always win; this fallback only runs for a non-English selection.
const TERM_TRANSLATIONS = {
es: {
"Advanced": "Avanzado", "Always On": "Siempre activo", "Lateral": "Lateral", "Steering": "Dirección", "Longitudinal": "Longitudinal", "Speed": "Velocidad", "Following": "Seguimiento", "Vision": "Visión", "Limits": "Límites", "Visual": "Visual", "Display": "Pantalla", "Sounds": "Sonidos", "Alerts": "Alertas", "Vehicle": "Vehículo", "Wheel": "Volante", "Controls": "Controles", "Device": "Dispositivo", "Data": "Datos", "Developer": "Desarrollador", "Favorites": "Favoritos", "Main": "Principal", "Tools": "Herramientas", "Recordings": "Grabaciones", "Cameras": "Cámaras", "Monitoring": "monitoreo", "Logs": "Registros", "Diagnostics": "diagnósticos", "Model": "Modelo", "Manager": "administrador", "Navigation": "Navegación", "Maps": "mapas", "System": "Sistema", "Laboratory": "Laboratorio", "Plots": "Gráficas", "Testing": "Pruebas", "Ground": "Área", "Theme": "Tema", "Maker": "creador", "Home": "Inicio", "Toggles": "Interruptores", "Install": "Instalar", "Update": "Actualizar", "Available": "disponible", "Loading": "Cargando", "Error": "Error", "Retry": "Reintentar", "Save": "Guardar", "Cancel": "Cancelar", "Close": "Cerrar", "Delete": "Eliminar", "All": "todo", "Search": "Buscar", "Clear": "Borrar", "Manage": "Administrar", "Connected": "Conectado", "Disconnect": "Desconectar", "Connect": "Conectar", "Pair": "Emparejar", "Refresh": "Actualizar", "Status": "Estado", "Samples": "Muestras", "Duration": "Duración", "Distance": "Distancia", "drives": "viajes", "hours": "horas", "engaged": "activado", "Onroad": "En carretera", "Offroad": "Fuera de carretera", "Enabled": "Activado", "Disabled": "Desactivado", "Default": "Predeterminado", "Working": "Procesando", "Run": "Ejecutar", "Reset": "Restablecer", "Download": "Descargar", "Network": "Red", "Current": "Actual", "Change": "Cambiar", "Changes": "Cambios", "Allow": "Permitir", "Use": "Usar", "Show": "Mostrar", "Hide": "Ocultar", "Enable": "Activar", "Disable": "Desactivar", "Automatic": "Automático", "Settings": "Configuración", "Language": "Idioma",
},
fr: {
"Advanced": "Avancé", "Always On": "Toujours actif", "Lateral": "Latéral", "Steering": "Direction", "Longitudinal": "Longitudinal", "Speed": "Vitesse", "Following": "Suivi", "Vision": "Vision", "Limits": "Limites", "Visual": "Visuel", "Display": "Affichage", "Sounds": "Sons", "Alerts": "Alertes", "Vehicle": "Véhicule", "Wheel": "Volant", "Controls": "Commandes", "Device": "Appareil", "Data": "Données", "Developer": "Développeur", "Favorites": "Favoris", "Main": "Principal", "Tools": "Outils", "Recordings": "Enregistrements", "Cameras": "Caméras", "Monitoring": "surveillance", "Logs": "Journaux", "Diagnostics": "diagnostics", "Model": "Modèle", "Manager": "gestionnaire", "Navigation": "Navigation", "Maps": "cartes", "System": "Système", "Laboratory": "Laboratoire", "Plots": "Graphiques", "Testing": "Tests", "Ground": "Zone", "Theme": "Thème", "Maker": "créateur", "Home": "Accueil", "Toggles": "Options", "Install": "Installer", "Update": "Mettre à jour", "Available": "disponible", "Loading": "Chargement", "Error": "Erreur", "Retry": "Réessayer", "Save": "Enregistrer", "Cancel": "Annuler", "Close": "Fermer", "Delete": "Supprimer", "All": "tout", "Search": "Rechercher", "Clear": "Effacer", "Manage": "Gérer", "Connected": "Connecté", "Disconnect": "Déconnecter", "Connect": "Connecter", "Pair": "Associer", "Refresh": "Actualiser", "Status": "État", "Samples": "Échantillons", "Duration": "Durée", "Distance": "Distance", "drives": "trajets", "hours": "heures", "engaged": "activé", "Onroad": "En route", "Offroad": "Hors route", "Enabled": "Activé", "Disabled": "Désactivé", "Default": "Par défaut", "Working": "En cours", "Run": "Exécuter", "Reset": "Réinitialiser", "Download": "Télécharger", "Network": "Réseau", "Current": "Actuel", "Change": "Modifier", "Changes": "Modifications", "Allow": "Autoriser", "Use": "Utiliser", "Show": "Afficher", "Hide": "Masquer", "Enable": "Activer", "Disable": "Désactiver", "Automatic": "Automatique", "Settings": "Paramètres", "Language": "Langue",
},
ko: {
"Advanced": "고급", "Always On": "항상 활성화", "Lateral": "횡방향", "Steering": "조향", "Longitudinal": "종방향", "Speed": "속도", "Following": "추종", "Vision": "비전", "Limits": "제한", "Visual": "시각", "Display": "디스플레이", "Sounds": "소리", "Alerts": "경고", "Vehicle": "차량", "Wheel": "휠", "Controls": "제어", "Device": "장치", "Data": "데이터", "Developer": "개발자", "Favorites": "즐겨찾기", "Main": "메인", "Tools": "도구", "Recordings": "녹화", "Cameras": "카메라", "Monitoring": "모니터링", "Logs": "로그", "Diagnostics": "진단", "Model": "모델", "Manager": "관리자", "Navigation": "내비게이션", "Maps": "지도", "System": "시스템", "Laboratory": "연구소", "Plots": "플롯", "Testing": "테스트", "Ground": "공간", "Theme": "테마", "Maker": "제작", "Home": "홈", "Toggles": "토글", "Install": "설치", "Update": "업데이트", "Available": "사용 가능", "Loading": "로드 중", "Error": "오류", "Retry": "재시도", "Save": "저장", "Cancel": "취소", "Close": "닫기", "Delete": "삭제", "All": "모두", "Search": "검색", "Clear": "지우기", "Manage": "관리", "Connected": "연결됨", "Disconnect": "연결 해제", "Connect": "연결", "Pair": "페어링", "Refresh": "새로 고침", "Status": "상태", "Samples": "샘플", "Duration": "시간", "Distance": "거리", "drives": "주행", "hours": "시간", "engaged": "활성화", "Onroad": "주행 중", "Offroad": "오프로드", "Enabled": "활성화", "Disabled": "비활성화", "Default": "기본값", "Working": "처리 중", "Run": "실행", "Reset": "초기화", "Download": "다운로드", "Network": "네트워크", "Current": "현재", "Change": "변경", "Changes": "변경 사항", "Allow": "허용", "Use": "사용", "Show": "표시", "Hide": "숨기기", "Enable": "활성화", "Disable": "비활성화", "Automatic": "자동", "Settings": "설정", "Language": "언어",
},
"zh-CHS": {
"Advanced": "高级", "Always On": "始终启用", "Lateral": "横向", "Steering": "转向", "Longitudinal": "纵向", "Speed": "速度", "Following": "跟车", "Vision": "视觉", "Limits": "限制", "Visual": "视觉", "Display": "显示", "Sounds": "声音", "Alerts": "提醒", "Vehicle": "车辆", "Wheel": "方向盘", "Controls": "控制", "Device": "设备", "Data": "数据", "Developer": "开发者", "Favorites": "收藏", "Main": "主菜单", "Tools": "工具", "Recordings": "录制", "Cameras": "摄像头", "Monitoring": "监控", "Logs": "日志", "Diagnostics": "诊断", "Model": "模型", "Manager": "管理器", "Navigation": "导航", "Maps": "地图", "System": "系统", "Laboratory": "实验室", "Plots": "图表", "Testing": "测试", "Ground": "区域", "Theme": "主题", "Maker": "制作器", "Home": "主页", "Toggles": "开关", "Install": "安装", "Update": "更新", "Available": "可用", "Loading": "加载中", "Error": "错误", "Retry": "重试", "Save": "保存", "Cancel": "取消", "Close": "关闭", "Delete": "删除", "All": "全部", "Search": "搜索", "Clear": "清除", "Manage": "管理", "Connected": "已连接", "Disconnect": "断开连接", "Connect": "连接", "Pair": "配对", "Refresh": "刷新", "Status": "状态", "Samples": "样本", "Duration": "时长", "Distance": "距离", "drives": "驾驶次数", "hours": "小时", "engaged": "已启用", "Onroad": "行驶中", "Offroad": "非行驶", "Enabled": "已启用", "Disabled": "已停用", "Default": "默认", "Working": "处理中", "Run": "运行", "Reset": "重置", "Download": "下载", "Network": "网络", "Current": "当前", "Change": "更改", "Changes": "更改内容", "Allow": "允许", "Use": "使用", "Show": "显示", "Hide": "隐藏", "Enable": "启用", "Disable": "停用", "Automatic": "自动", "Settings": "设置", "Language": "语言",
},
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
const TERM_REPLACERS = Object.fromEntries(Object.entries(TERM_TRANSLATIONS).map(([code, terms]) => [
code,
Object.entries(terms)
.sort(([a], [b]) => b.length - a.length)
.map(([source, target]) => [new RegExp(`(^|[^A-Za-z])${escapeRegExp(source)}(?=$|[^A-Za-z])`, "gi"), target, source.match(/[A-Za-z]+/g)?.length || 1]),
]))
function translateText(value) {
const source = String(value ?? "")
const exact = TRANSLATIONS[languageState?.code]?.[source]
if (exact) return exact
if (!languageState || languageState.code === "en" || /https?:\/\//i.test(source)) return source
let translated = source
let replacedWords = 0
for (const [pattern, replacement, wordCount] of TERM_REPLACERS[languageState.code] || []) {
translated = translated.replace(pattern, (_, prefix) => {
replacedWords += wordCount
return `${prefix}${replacement}`
})
}
const sourceWords = source.match(/[A-Za-z]+/g)?.length || 0
return sourceWords >= 4 && replacedWords / sourceWords < 0.8 ? source : translated
}
function storageValue() {
try { return window.localStorage.getItem(STORAGE_KEY) || "en" } catch (e) { return "en" }
}
@@ -116,17 +221,92 @@ export function normalizeLanguage(value) {
export const languageState = reactive({ code: normalizeLanguage(storageValue()) })
const translatedTextNodes = new WeakMap()
const translatedAttributes = new WeakMap()
let domObserver = null
const TRANSLATABLE_ATTRIBUTES = ["aria-label", "placeholder", "title"]
function canTranslateNode(node) {
const parent = node?.parentElement
return !!parent && !parent.closest("script, style, textarea, pre, [data-no-translate]")
}
function translateTextNode(node) {
if (!canTranslateNode(node)) return
const current = node.nodeValue || ""
if (!current.trim()) return
let state = translatedTextNodes.get(node)
if (!state) {
state = { source: current, output: current }
translatedTextNodes.set(node, state)
} else if (current !== state.output) {
// Vue replaced the source text (for example, a device-provided label).
state.source = current
}
const output = translateText(state.source)
if (output !== current) node.nodeValue = output
state.output = output
}
function translateElementAttributes(element) {
if (!element || element.matches("script, style, textarea, pre, [data-no-translate]")) return
let state = translatedAttributes.get(element)
if (!state) {
state = {}
translatedAttributes.set(element, state)
}
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
if (!element.hasAttribute(attribute)) continue
const current = element.getAttribute(attribute) || ""
const previous = state[attribute]
if (!previous) state[attribute] = { source: current, output: current }
else if (current !== previous.output) previous.source = current
const entry = state[attribute]
const output = translateText(entry.source)
if (output !== current) element.setAttribute(attribute, output)
entry.output = output
}
}
export function translateDom(root = (typeof document !== "undefined" ? document.getElementById("galaxy-app") : null)) {
if (!root || typeof document === "undefined") return
translateElementAttributes(root)
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node
while ((node = walker.nextNode())) translateTextNode(node)
root.querySelectorAll("*").forEach(translateElementAttributes)
}
export function installDomTranslator(root = (typeof document !== "undefined" ? document.getElementById("galaxy-app") : null)) {
if (!root || typeof MutationObserver === "undefined") return
translateDom(root)
domObserver?.disconnect()
domObserver = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "characterData") translateTextNode(record.target)
else if (record.type === "attributes") translateElementAttributes(record.target)
else record.addedNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) translateTextNode(node)
else if (node.nodeType === Node.ELEMENT_NODE) translateDom(node)
})
}
})
domObserver.observe(root, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: TRANSLATABLE_ATTRIBUTES })
}
export function setLanguage(value) {
const code = normalizeLanguage(value)
languageState.code = code
try { window.localStorage.setItem(STORAGE_KEY, code) } catch (e) { /* storage can be unavailable in private webviews */ }
if (typeof document !== "undefined") document.documentElement.lang = code === "zh-CHS" ? "zh-CN" : code
if (typeof document !== "undefined") translateDom(document.getElementById("galaxy-app"))
return code
}
export function t(key, fallback = key) {
const source = String(key ?? "")
return TRANSLATIONS[languageState.code]?.[source] || fallback || source
const translated = translateText(source)
return translated !== source ? translated : fallback || source
}
setLanguage(languageState.code)
@@ -1,15 +1,21 @@
import { reactive } from "vue"
const THEME_KEY = "galaxy-theme"
const NAV_PINNED_KEY = "galaxy-nav-pinned"
function initialTheme() {
return localStorage.getItem(THEME_KEY) || "dark"
}
function initialNavPinned() {
try { return localStorage.getItem(NAV_PINNED_KEY) === "true" } catch (e) { return false }
}
export const store = reactive({
route: "/",
params: {},
drawerOpen: false,
navPinned: initialNavPinned(),
search: "",
snackbar: null,
online: false,
@@ -29,6 +35,16 @@ export function toggleTheme() {
setTheme(store.theme === "dark" ? "light" : "dark")
}
export function setNavPinned(pinned) {
store.navPinned = Boolean(pinned)
try { localStorage.setItem(NAV_PINNED_KEY, String(store.navPinned)) } catch (e) {}
}
export function toggleNavPinned() {
setNavPinned(!store.navPinned)
if (store.navPinned) store.drawerOpen = true
}
export function parseHash(hash) {
const raw = hash.replace(/^#/, "") || "/"
const [pathname, queryString] = raw.split("?")
@@ -1,4 +1,4 @@
import { NavigationDestinationPanel } from "../components/NavigationDestinationPanel.js?v=nav-destination-2"
import { NavigationDestinationPanel } from "../components/NavigationDestinationPanel.js?v=nav-destination-3"
import { MapsPanel } from "../components/MapsPanel.js"
import { NavigationKeysPanel } from "../components/NavigationKeysPanel.js"
import { SpeedLimitsPanel } from "../components/SpeedLimitsPanel.js"
@@ -22,26 +22,18 @@ export const Navigation = {
})
},
template: `
<div class="gx-view">
<h2 style="margin-top:0;">Navigation & Maps</h2>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'nav'">
<NavigationDestinationPanel />
<div class="gx-navigation-view">
<NavigationDestinationPanel />
<div class="gx-navigation-tabs"><GalaxyTabs :items="TABS" :active="tab" @select="selectTab" /></div>
</div>
</template>
<template v-if="tab === 'maps'">
<MapsPanel />
</template>
<template v-if="tab === 'keys'">
<NavigationKeysPanel />
</template>
<template v-if="tab === 'speeds'">
<SpeedLimitsPanel />
</template>
</div>
<div v-else class="gx-view">
<h2 style="margin-top:0;">Navigation & Maps</h2>
<GalaxyTabs :items="TABS" :active="tab" @select="selectTab" />
<template v-if="tab === 'maps'"><MapsPanel /></template>
<template v-if="tab === 'keys'"><NavigationKeysPanel /></template>
<template v-if="tab === 'speeds'"><SpeedLimitsPanel /></template>
</div>
`,
}
@@ -142,8 +142,6 @@ export const Settings = {
<div>
<h2 style="margin-top:0;">{{ tr("Toggles") }}</h2>
<LanguageSelector :device-value="String(values.LanguageSetting || '')" />
<DevModeBanner :hidden-count="hiddenAdvancedCount" :dev-mode-on="devModeOn" />
<div v-if="loading" class="gx-loading">{{ tr("Loading configuration...") }}</div>
@@ -191,6 +189,8 @@ export const Settings = {
</template>
<div v-else class="gx-empty">{{ tr("No settings available.") }}</div>
<LanguageSelector v-if="route === '/settings' && !loading" :device-value="String(values.LanguageSetting || '')" />
</div>
`,
}
@@ -39,6 +39,7 @@ export const SystemTools = {
branchLoading: true,
branchListFallback: false,
branchListError: "",
advancedVersionPickerOpen: false,
otherBranchesOpen: false,
branchBusy: false,
@@ -57,7 +58,7 @@ export const SystemTools = {
created() {
this.poll = usePolling(() => this.loadFastStatus(), {
interval: 1000,
enabled: () => !this.fastStatus || !!this.fastStatus.running,
enabled: () => this.statusPollingNeeded,
})
this.poll.start()
},
@@ -76,14 +77,17 @@ export const SystemTools = {
return branches
},
branchSwitchBlocked() {
return this.branchLoading || this.isOnroad || !!this.fastStatus?.isOnroad || !!this.fastStatus?.running || !!this.busy
return this.branchLoading || this.isOnroad || !!this.fastStatus?.isOnroad || this.updateInProgress || !!this.busy
},
statusRebooting() { return String(this.fastStatus?.stage || "").trim().toLowerCase() === "rebooting" },
updateInProgress() { return !!this.fastStatus?.running || this.statusRebooting },
statusPollingNeeded() { return !this.fastStatus || this.updateInProgress },
versionChoices() { return this.targetBranch === "StarPilot" ? releaseVersions(this.versionCommits) : this.versionCommits },
installVersionBlocked() {
return this.branchSwitchBlocked || this.branchBusy || !this.branches.includes(this.targetBranch) ||
(this.versionMode === "earlier" && (this.versionLoading || !/^[a-f0-9]{40}$/.test(this.selectedCommit) || !this.versionChoices.some(commit => commit.sha === this.selectedCommit)))
},
updateAvailable() { return this.checkedForUpdates && !!this.fastStatus?.updateAvailable && !this.fastStatus?.running },
updateAvailable() { return this.checkedForUpdates && !!this.fastStatus?.updateAvailable && !this.updateInProgress },
factoryResetStatus() {
const s = this.fastStatus
if (!s || String(s?.lastMode || "").trim() !== "factory-reset") return null
@@ -352,13 +356,13 @@ export const SystemTools = {
}
},
async checkUpdates() {
if (this.busy) return
if (this.busy || this.updateInProgress) return
this.busy = "check"
try {
await this.loadFastStatus({ throwOnError: true })
this.checkedForUpdates = true
const st = this.fastStatus
if (st?.running) showSnackbar("An update is already running.")
if (this.updateInProgress) showSnackbar("An update is already running.")
else if (st?.updateAvailable) showSnackbar(st?.message || "Update available.")
else showSnackbar(st?.message || "No update available — you're up to date.")
} catch (e) {
@@ -368,7 +372,7 @@ export const SystemTools = {
}
},
async setAutomaticUpdates(enabled) {
if (this.autoUpdateBusy || this.isOnroad || this.fastStatus?.running || !this.fastStatus) return
if (this.autoUpdateBusy || this.isOnroad || this.updateInProgress || !this.fastStatus) return
const previous = !!this.fastStatus.automaticUpdates
this.autoUpdateBusy = true
this.fastStatus = { ...this.fastStatus, automaticUpdates: !!enabled }
@@ -384,7 +388,7 @@ export const SystemTools = {
},
async applyFastUpdate() {
if (this.busy || this.isOnroad) return
if (this.fastStatus?.running) { showSnackbar("Fast update is already running."); return }
if (this.updateInProgress) { showSnackbar("Fast update is already running."); return }
if (!this.checkedForUpdates || !this.updateAvailable) {
showSnackbar("No update available. Run \"Check for Updates\" first.", "error")
return
@@ -400,7 +404,7 @@ export const SystemTools = {
await this.runUpdate("fast")
},
async runUpdate(action) {
if (this.busy) return
if (this.busy || this.updateInProgress) return
this.busy = action
try {
if (action === "rollback") {
@@ -503,17 +507,17 @@ export const SystemTools = {
<div class="gx-section__header">
<i class="bi bi-arrow-repeat"></i>
<span class="gx-section__title">Update Status</span>
<span v-if="fastStatus.running" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ fastStatus.progressPercent }}%</span>
<span v-if="updateInProgress" class="gx-chip" style="background:var(--primary);color:var(--on-primary);">{{ statusRebooting ? 'Reconnecting…' : fastStatus.progressPercent + '%' }}</span>
<span v-else-if="updateAvailable" class="gx-chip" style="background:var(--warning);color:var(--black);">Update available</span>
<span v-else-if="checkedForUpdates" class="gx-chip">Up to date</span>
<span v-else class="gx-chip">Not checked</span>
</div>
<div style="padding: var(--sp-3); display:grid; gap:6px;">
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Installed branch</span><span class="gx-row__value">{{ fastStatus.branch || currentBranch || '' }}</span></div>
<div v-if="fastStatus.running" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
<div v-if="updateInProgress" class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Stage</span><span class="gx-row__value">{{ fastStatus.stage }} · {{ fastStatus.progressLabel }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Local</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.localCommit) }}</span></div>
<div class="gx-row" style="border-top:none; min-height:0; padding:4px 0;"><span class="gx-row__label">Remote</span><span class="gx-row__value" style="font-family:monospace;">{{ shortCommit(fastStatus.remoteCommit) }}</span></div>
<div v-if="fastStatus.running" class="gx-update-progress" role="progressbar" aria-label="Update progress"
<div v-if="updateInProgress" class="gx-update-progress" role="progressbar" aria-label="Update progress"
:aria-valuenow="Math.round(fastStatus.progressPercent || 0)" aria-valuemin="0" aria-valuemax="100">
<div class="gx-update-progress__track">
<div class="gx-update-progress__fill" :class="{ 'gx-update-progress__fill--error': fastStatus.stage === 'error' }"
@@ -526,7 +530,7 @@ export const SystemTools = {
<small v-if="fastStatus.progressDetail">{{ fastStatus.progressDetail }}</small>
</div>
<div v-if="fastStatus.message" class="gx-note">{{ fastStatus.message }}</div>
<div v-if="fastStatus.warning && (fastStatus.running || fastStatus.updateAvailable)" class="gx-note gx-note--danger">{{ fastStatus.warning }}</div>
<div v-if="fastStatus.warning && (updateInProgress || fastStatus.updateAvailable)" class="gx-note gx-note--danger">{{ fastStatus.warning }}</div>
<div v-if="fastStatus.agnosUpdate?.available && fastStatus.agnosUpdate?.warnings?.length" style="margin-top:4px;">
<div v-for="w in fastStatus.agnosUpdate.warnings" :key="w" class="gx-note gx-note--danger"><i class="bi bi-exclamation-triangle-fill"></i> {{ w }}</div>
</div>
@@ -541,7 +545,7 @@ export const SystemTools = {
</div>
<label class="gx-switch">
<input type="checkbox" :checked="!!fastStatus?.automaticUpdates"
:disabled="!fastStatus || isOnroad || autoUpdateBusy || !!fastStatus?.running"
:disabled="!fastStatus || isOnroad || autoUpdateBusy || updateInProgress"
@change="setAutomaticUpdates($event.target.checked)" />
<span class="gx-switch__track"></span>
<span class="gx-switch__thumb"></span>
@@ -549,9 +553,15 @@ export const SystemTools = {
</div>
</div>
<div class="gx-card" style="margin-bottom:12px;">
<div class="gx-section__header"><i class="bi bi-git-branch"></i><span class="gx-section__title">Install a Version</span></div>
<div style="padding: var(--sp-3);">
<details class="gx-card" style="margin-bottom:12px;" @toggle="advancedVersionPickerOpen = $event.target.open">
<summary class="gx-section__header" style="list-style:none;">
<i class="bi bi-sliders"></i><span class="gx-section__title">Advanced update options</span>
<i class="bi" :class="advancedVersionPickerOpen ? 'bi-chevron-up' : 'bi-chevron-down'" aria-hidden="true"></i>
</summary>
<div v-if="advancedVersionPickerOpen">
<div class="gx-section__header" style="cursor:default;"><i class="bi bi-git-branch"></i><span class="gx-section__title">Install a Version</span></div>
<div style="padding: var(--sp-3);">
<p class="gx-note" style="margin-top:0; overflow-wrap:anywhere;">Most people should stay on the latest version. Use this only to install a specific branch or historical version while troubleshooting.</p>
<p class="gx-note" style="margin-top:0; overflow-wrap:anywhere;">Installed branch: <strong>{{ currentBranch || 'Unknown' }}</strong></p>
<GalaxySelect id="gx-primary-branch" class="gx-field gx-field--full" aria-label="Target branch"
:value="primaryBranchValue" :disabled="branchSwitchBlocked || branchBusy" @change="onPrimaryBranchSelect">
@@ -592,23 +602,24 @@ export const SystemTools = {
<p>Pinned version: <strong>{{ fastStatus.versionPin.branch }} · {{ shortCommit(fastStatus.versionPin.commit) }}</strong><br>Automatic updates were paused at installation.</p>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="branchSwitchBlocked || branchBusy || !branches.includes(fastStatus.versionPin.branch)" @click="returnToLatest">Return to Latest</button>
</div>
</div>
</div>
</div>
</details>
<div style="display:flex; gap:8px; margin-top:12px; flex-wrap:wrap;">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || !!fastStatus?.running" @click="checkUpdates">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || updateInProgress" @click="checkUpdates">
<i v-if="busy === 'check'" class="bi bi-arrow-repeat gx-spin"></i>
<i v-else class="bi bi-search"></i> {{ busy === 'check' ? 'Checking...' : 'Check for Updates' }}
</button>
<button v-if="updateAvailable" type="button" class="gx-btn" :disabled="!!busy || isOnroad" @click="applyFastUpdate">
<i class="bi bi-arrow-up-circle"></i> {{ busy === 'fast' ? 'Updating...' : 'Update Now' }}
</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('recover')">Recover</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad" @click="runUpdate('rollback')">Rollback</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || updateInProgress" @click="runUpdate('recover')">Recover</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!!busy || isOnroad || updateInProgress" @click="runUpdate('rollback')">Rollback</button>
</div>
<p class="gx-note">Check for Updates scans for a newer commit. Use <strong>Update Now</strong> to install it.</p>
<p class="gx-note"><strong>Recover</strong> continues an update that was interrupted (for example, by power loss mid-install). <strong>Rollback</strong> returns the device to the previously installed version if the current one has a problem.</p>
<p v-if="checkedForUpdates && !updateAvailable && !fastStatus?.running" class="gx-note">
<p v-if="checkedForUpdates && !updateAvailable && !updateInProgress" class="gx-note">
The device is up to date. Update becomes available only after a check finds a newer commit.
</p>
</template>