From 701ddeef490ebe85d333cbbebd21fca73f094bf6 Mon Sep 17 00:00:00 2001 From: rav4kumar Date: Wed, 4 Nov 2026 12:19:10 -0700 Subject: [PATCH] trip-summary --- openpilot/common/params_keys.h | 1 + openpilot/selfdrive/ui/layouts/main.py | 2 + openpilot/selfdrive/ui/mici/layouts/main.py | 2 + .../ui/sunnypilot/layouts/settings/visuals.py | 5 + .../ui/sunnypilot/layouts/trip_summary.py | 199 ++++++++++++++++++ openpilot/selfdrive/ui/sunnypilot/ui_state.py | 2 + .../sunnypilot/sunnylink/settings_ui.json | 6 + .../settings_ui_src/pages/visuals.yaml | 4 + 8 files changed, 221 insertions(+) create mode 100644 openpilot/selfdrive/ui/sunnypilot/layouts/trip_summary.py diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 366296769b..00612365de 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -200,6 +200,7 @@ inline static std::unordered_map keys = { {"ShowAdvancedControls", {PERSISTENT | BACKUP, BOOL, "0"}}, {"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}}, {"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}}, + {"TripSummary", {PERSISTENT | BACKUP, BOOL, "1"}}, {"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}}, // MADS params diff --git a/openpilot/selfdrive/ui/layouts/main.py b/openpilot/selfdrive/ui/layouts/main.py index 2e002b0318..9064afbca9 100644 --- a/openpilot/selfdrive/ui/layouts/main.py +++ b/openpilot/selfdrive/ui/layouts/main.py @@ -10,6 +10,7 @@ from openpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow from openpilot.selfdrive.ui.body.layouts.onroad import BodyLayout +from openpilot.selfdrive.ui.sunnypilot.layouts.trip_summary import TripSummary if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.layouts.settings.settings import SettingsLayoutSP as SettingsLayout @@ -31,6 +32,7 @@ class MainLayout(Widget): self._sidebar = Sidebar() self._current_mode = MainState.HOME self._prev_onroad = False + self._trip_summary = TripSummary() # Initialize layouts self._home_layout = HomeLayout() diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 306feb28c5..598567c615 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -10,6 +10,7 @@ from openpilot.selfdrive.ui.body.layouts.onroad import BodyLayout from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.lib.application import gui_app +from openpilot.selfdrive.ui.sunnypilot.layouts.trip_summary import TripSummary if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout @@ -57,6 +58,7 @@ class MiciMainLayout(Scroller): self._setup_callbacks() gui_app.add_nav_stack_tick(self._handle_transitions) + self._trip_summary = TripSummary() gui_app.push_widget(self) # Start onboarding if terms or training not completed, make sure to push after self diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/visuals.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/visuals.py index 84be5a26ab..0e493c8fb7 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/visuals.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/visuals.py @@ -72,6 +72,11 @@ class VisualsLayout(Widget): "It is the driver's responsibility to observe their environment and make decisions accordingly."), None, ), + "TripSummary": ( + lambda: tr("Trip Summary"), + tr("Show a summary of distance and engagement when you shift into park after a drive."), + None, + ), "TrueVEgoUI": ( lambda: tr("Speedometer: Always Display True Speed"), tr("For applicable vehicles, always display the true vehicle current speed from wheel speed sensors."), diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/trip_summary.py b/openpilot/selfdrive/ui/sunnypilot/layouts/trip_summary.py new file mode 100644 index 0000000000..4d33e4bf61 --- /dev/null +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/trip_summary.py @@ -0,0 +1,199 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import time +import pyray as rl + +from opendbc.car.structs import car + +from openpilot.common.constants import CV +from openpilot.selfdrive.ui.onroad.augmented_road_view import BORDER_COLORS +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus +from openpilot.system.ui.lib.application import gui_app, FontWeight +from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.widgets import Widget + +AUTO_DISMISS = 20. +FADE_IN, FADE_OUT = 0.35, 0.3 +LAT = (UIStatus.ENGAGED, UIStatus.LAT_ONLY) +LONG = (UIStatus.ENGAGED, UIStatus.LONG_ONLY) +GEAR = car.CarState.GearShifter +FORWARD = (GEAR.drive, GEAR.sport, GEAR.low, GEAR.brake, GEAR.eco, GEAR.manumatic) + + +class TripTracker: + def __init__(self): + self.reset() + + def reset(self): + self.segments: list[list] = [] + self.seconds = 0. + self.takeovers = 0 + self._t = None + self._status = None + + def update(self, driving: bool): + now = time.monotonic() + if not driving: + self._t = None + self._status = None + return + dt = 0. if self._t is None else min(now - self._t, 0.5) + self._t = now + self.seconds += dt + if self._status not in (None, UIStatus.DISENGAGED) and ui_state.status == UIStatus.DISENGAGED: + self.takeovers += 1 + self._status = ui_state.status + d = max(ui_state.sm["carState"].vEgo, 0.) * dt + if d <= 0.: + return + if self.segments and self.segments[-1][0] == ui_state.status: + self.segments[-1][1] += d + else: + self.segments.append([ui_state.status, d]) + + def total(self, statuses=None) -> float: + return sum(m for s, m in self.segments if statuses is None or s in statuses) + + +class TripSummary: + def __init__(self): + self._trip = TripTracker() + self._dialog: TripSummaryDialog | None = None + self._gear = None + self._drove = False + gui_app.add_nav_stack_tick(self._tick) + + def _tick(self): + gear = ui_state.sm["carState"].gearShifter if ui_state.started else None + self._trip.update(gear not in (None, GEAR.park)) + self._drove |= gear in FORWARD + if gear == self._gear: + return + self._gear = gear + + if gear not in (None, GEAR.park): + self._pop() + elif gear == GEAR.park and self._drove and ui_state.trip_summary and not ui_state.is_body and self._trip.total() > 0.: + self._dialog = TripSummaryDialog(self._trip, gui_app.get_active_widget(), self._pop) + gui_app.push_widget(self._dialog) + if gear is None: + self._trip.reset() + if gear in (None, GEAR.park): + self._drove = False + + def _pop(self): + if self._dialog is not None and gui_app.get_active_widget() is self._dialog: + gui_app.pop_widget() + self._dialog = None + + +def _ease(t: float) -> float: + t = min(max(t, 0.), 1.) + return 1 - (1 - t) ** 3 + + +def _fade(c, a: float) -> rl.Color: + r, g, b, al = c if isinstance(c, tuple) else (c.r, c.g, c.b, c.a) + return rl.Color(r, g, b, int(al * a)) + + +class TripSummaryDialog(Widget): + def __init__(self, trip: TripTracker, below: Widget | None, on_closed): + super().__init__() + self._below = below + self._on_closed = on_closed + self._segments = segments = trip.segments + total = sum(m for _, m in segments) + + def pct(statuses): + return 100 * sum(m for s, m in segments if s in statuses) / total + + dist = total * (CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH) / 3600 + dist_fmt = "{:.1f}" if dist < 9.95 else "{:.0f}" + self._stats = [ + (dist, dist_fmt, "km" if ui_state.is_metric else "mi", "driven"), + (pct(LAT + LONG), "{:.0f}", "%", "engaged"), + (pct(LAT), "{:.0f}", "%", "lateral"), + (pct(LONG), "{:.0f}", "%", "longitudinal"), + ] + mins = round(trip.seconds / 60) + self._details = [ + (f"{mins // 60}h {mins % 60}" if mins >= 60 else f"{mins}", "min"), + (f"{trip.takeovers}", "takeover" if trip.takeovers == 1 else "takeovers"), + ] + self._bold = gui_app.font(FontWeight.BOLD) + self._semi = gui_app.font(FontWeight.SEMI_BOLD) + self._normal = gui_app.font(FontWeight.NORMAL) + self._big = gui_app.big_ui() + self._shown_t = rl.get_time() + self._closing_t: float | None = None + self.set_click_callback(self.close) + + def close(self): + if self._closing_t is None: + self._closing_t = rl.get_time() + + def _render(self, rect: rl.Rectangle): + t = rl.get_time() - self._shown_t + if self._closing_t is None and t > AUTO_DISMISS: + self.close() + out = 0. if self._closing_t is None else (rl.get_time() - self._closing_t) / FADE_OUT + if out >= 1.: + self._on_closed() + return + bg = 1 - _ease(out) + a = _ease(t / FADE_IN) * bg + + if self._big and self._below is not None and bg < 1.: + self._below.render(rect) + rl.draw_rectangle_rec(rect, _fade(rl.BLACK, bg)) + + k = rect.height / 1080 * (1.0 if self._big else 1.22) + pad = 120 * k + x, w = rect.x + pad, rect.width - 2 * pad + y = rect.y + (rect.height - 850 * k) / 2 + + rl.draw_text_ex(self._bold, "trip summary", rl.Vector2(x, y), int(150 * k), 0, _fade(rl.WHITE, a)) + y += 260 * k + + col_w = w / len(self._stats) + for i, (target, fmt, unit, label) in enumerate(self._stats): + cx = x + col_w * (i + 0.5) + v = measure_text_cached(self._bold, fmt.format(target), int(170 * k)) + u = measure_text_cached(self._semi, unit, int(80 * k)) + vx = cx - (v.x + 10 * k + u.x) / 2 + rl.draw_text_ex(self._bold, fmt.format(target), rl.Vector2(vx, y), int(170 * k), 0, _fade(rl.WHITE, a)) + rl.draw_text_ex(self._semi, unit, rl.Vector2(vx + v.x + 10 * k, y + 75 * k), int(80 * k), 0, _fade(rl.WHITE, a)) + lb = measure_text_cached(self._normal, label, int(70 * k)) + rl.draw_text_ex(self._normal, label, rl.Vector2(cx - lb.x / 2, y + 180 * k), int(70 * k), 0, _fade(rl.Color(200, 200, 200, 255), a)) + y += 330 * k + + bar = rl.Rectangle(x, y, w, 90 * k) + total = sum(m for _, m in self._segments) + sx = bar.x + for status, m in self._segments: + sw = bar.width * m / total + rl.draw_rectangle_rec(rl.Rectangle(sx, bar.y, min(sw + 1, bar.x + bar.width - sx), bar.height), _fade(BORDER_COLORS[status], a)) + sx += sw + rl.draw_rectangle_rounded_lines_ex(bar, 1.0, 20, 30 * k, _fade(rl.BLACK, a)) + + y += (90 + 70) * k + + size = int(90 * k) + parts = [] + for i, (value, unit) in enumerate(self._details): + if i: + parts.append((" ", self._normal, None)) + parts += [(value, self._bold, rl.WHITE), (" " + unit, self._normal, rl.Color(170, 170, 170, 255))] + widths = [measure_text_cached(f, t, size).x for t, f, _ in parts] + lx = x + (w - sum(widths)) / 2 + for (text, font, color), tw in zip(parts, widths, strict=True): + if color is None: + rl.draw_circle(int(lx + tw / 2), int(y + size * 0.6), 12 * k, _fade(rl.Color(110, 110, 110, 255), a)) + else: + rl.draw_text_ex(font, text, rl.Vector2(lx, y), size, 0, _fade(color, a)) + lx += tw diff --git a/openpilot/selfdrive/ui/sunnypilot/ui_state.py b/openpilot/selfdrive/ui/sunnypilot/ui_state.py index 73fbaff838..46ff6380f0 100644 --- a/openpilot/selfdrive/ui/sunnypilot/ui_state.py +++ b/openpilot/selfdrive/ui/sunnypilot/ui_state.py @@ -49,6 +49,7 @@ class UIStateSP: self.model_runner_tinygrad: bool = False self.active_runner_tinygrad: bool = False self.blindspot: bool = False + self.trip_summary: bool = True self.chevron_metrics = None self.custom_interactive_timeout: int = 0 self.developer_ui = None @@ -187,6 +188,7 @@ class UIStateSP: self.standstill_timer = self.params.get_bool("StandstillTimer") self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled") self.torque_bar = self.params.get_bool("TorqueBar") + self.trip_summary = self.params.get_bool("TripSummary") self.enforce_torque_control = self.params.get_bool("EnforceTorqueControl") self.custom_torque_params = self.params.get_bool("CustomTorqueParams") self.torque_override_enabled = self.params.get_bool("TorqueParamsOverrideEnabled") diff --git a/openpilot/sunnypilot/sunnylink/settings_ui.json b/openpilot/sunnypilot/sunnylink/settings_ui.json index 340815faa7..4e96380ba6 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui.json +++ b/openpilot/sunnypilot/sunnylink/settings_ui.json @@ -1412,6 +1412,12 @@ } ] }, + { + "key": "TripSummary", + "widget": "toggle", + "title": "Trip Summary", + "description": "Show a summary of distance and engagement when you shift into park after a drive." + }, { "key": "RocketFuel", "widget": "toggle", diff --git a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml index a253c64122..934907e999 100644 --- a/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml +++ b/openpilot/sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml @@ -39,6 +39,10 @@ sections: description: Show a timer on the HUD when the car is at a standstill. visibility: - $ref: '#/macros/hide_on_mici' + - key: TripSummary + widget: toggle + title: Trip Summary + description: Show a summary of distance and engagement when you shift into park after a drive. - key: RocketFuel widget: toggle title: Real-time Acceleration Bar