version: sunnypilot v2026.002.000 (staging) date: 2026-07-21T07:44:02 master commit: 2c334ede443d7391d27575af8c854a095ba702a8
150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
"""
|
|
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 requests
|
|
import threading
|
|
import time
|
|
import pyray as rl
|
|
|
|
from openpilot.common.api import api_get
|
|
from openpilot.common.constants import CV
|
|
from openpilot.common.params import Params
|
|
from openpilot.common.swaglog import cloudlog
|
|
from openpilot.selfdrive.ui.lib.api_helpers import get_token
|
|
from openpilot.selfdrive.ui.ui_state import ui_state, device
|
|
from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
|
|
from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
|
|
from openpilot.system.ui.lib.multilang import tr
|
|
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
|
from openpilot.system.ui.widgets import Widget
|
|
|
|
|
|
class TripsLayout(Widget):
|
|
PARAM_KEY = "ApiCache_DriveStats"
|
|
UPDATE_INTERVAL = 30 # seconds
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._params = Params()
|
|
self._session = requests.Session()
|
|
self._stats = self._get_stats()
|
|
|
|
self._icon_distance = gui_app.texture("icons/road.png", 100, 100, keep_aspect_ratio=True)
|
|
self._icon_drives = gui_app.texture("icons_mici/wheel.png", 80, 80, keep_aspect_ratio=True)
|
|
self._icon_hours = gui_app.texture("../../sunnypilot/selfdrive/assets/icons/clock.png", 80, 80, keep_aspect_ratio=True)
|
|
|
|
self._running = True
|
|
self._update_thread = threading.Thread(target=self._update_loop, daemon=True)
|
|
self._update_thread.start()
|
|
|
|
def __del__(self):
|
|
self._running = False
|
|
try:
|
|
if self._update_thread and self._update_thread.is_alive():
|
|
self._update_thread.join(timeout=1.0)
|
|
except Exception:
|
|
pass
|
|
|
|
def _get_stats(self):
|
|
stats = self._params.get(self.PARAM_KEY)
|
|
if not stats:
|
|
return {}
|
|
try:
|
|
return stats
|
|
except Exception:
|
|
cloudlog.exception(f"Failed to decode drive stats: {stats}")
|
|
return {}
|
|
|
|
def _fetch_drive_stats(self):
|
|
try:
|
|
dongle_id = self._params.get("DongleId")
|
|
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
|
|
return
|
|
identity_token = get_token(dongle_id)
|
|
response = api_get(f"v1.1/devices/{dongle_id}/stats", access_token=identity_token, session=self._session)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
self._stats = data
|
|
self._params.put(self.PARAM_KEY, data)
|
|
except Exception as e:
|
|
cloudlog.error(f"Failed to fetch drive stats: {e}")
|
|
|
|
def _update_loop(self):
|
|
while self._running:
|
|
if not ui_state.started and device._awake:
|
|
self._fetch_drive_stats()
|
|
time.sleep(self.UPDATE_INTERVAL)
|
|
|
|
def _render_stat_group(self, x, y, width, height, title, data, is_metric):
|
|
# Card Background
|
|
rl.draw_rectangle_rounded(rl.Rectangle(x, y, width, height), 0.05, 10, rl.Color(30, 30, 30, 255))
|
|
|
|
# Title
|
|
title_font = gui_app.font(FontWeight.BOLD)
|
|
rl.draw_text_ex(title_font, title, rl.Vector2(x + 60, y + 30), 50 * FONT_SCALE, 0, rl.Color(200, 200, 200, 255))
|
|
|
|
# Internal content area
|
|
# Center the content block (Icon + Value + Unit) vertically
|
|
content_y = y + (height / 2) - (140 * FONT_SCALE)
|
|
col_width = width / 3
|
|
|
|
# Values
|
|
number_font = gui_app.font(FontWeight.BOLD)
|
|
unit_font = gui_app.font(FontWeight.NORMAL)
|
|
number_base_size = 92
|
|
unit_base_size = 55
|
|
number_size = number_base_size * FONT_SCALE
|
|
unit_size = unit_base_size * FONT_SCALE
|
|
color_unit = rl.Color(160, 160, 160, 255)
|
|
|
|
routes = int(data.get("routes", 0))
|
|
distance = data.get("distance", 0)
|
|
distance_str = str(int(distance * CV.MPH_TO_KPH)) if is_metric else str(int(distance))
|
|
hours = int(data.get("minutes", 0) / 60)
|
|
|
|
dist_unit = tr("KM") if is_metric else tr("Miles")
|
|
|
|
def draw_col(col_idx, icon, value, unit):
|
|
col_x = x + (col_width * col_idx)
|
|
center_x = col_x + (col_width / 2)
|
|
|
|
# Icon
|
|
icon_x = center_x - (icon.width / 2)
|
|
icon_y = content_y + 60
|
|
rl.draw_texture_ex(icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE)
|
|
|
|
# Value
|
|
val_size = measure_text_cached(number_font, value, number_base_size)
|
|
rl.draw_text_ex(number_font, value, rl.Vector2(center_x - val_size.x / 1.65, content_y + 145 * FONT_SCALE), number_size, 0, rl.WHITE)
|
|
|
|
# Unit
|
|
unit_size_vec = measure_text_cached(unit_font, unit, unit_base_size)
|
|
rl.draw_text_ex(unit_font, unit, rl.Vector2(center_x - unit_size_vec.x / 1.65, content_y + 255 * FONT_SCALE), unit_size, 0, color_unit)
|
|
|
|
draw_col(0, self._icon_drives, str(routes), tr("Drives"))
|
|
draw_col(1, self._icon_distance, distance_str, dist_unit)
|
|
draw_col(2, self._icon_hours, str(hours), tr("Hours"))
|
|
|
|
return y + height
|
|
|
|
def _render(self, rect: rl.Rectangle):
|
|
x = rect.x
|
|
y = rect.y
|
|
w = rect.width
|
|
|
|
spacing = 30
|
|
available_h = rect.height - 30
|
|
card_height = available_h / 2
|
|
|
|
is_metric = self._params.get_bool("IsMetric")
|
|
|
|
all_time = self._stats.get("all", {})
|
|
week = self._stats.get("week", {})
|
|
|
|
y = self._render_stat_group(x, y, w, card_height, tr("ALL TIME"), all_time, is_metric)
|
|
y += spacing
|
|
y = self._render_stat_group(x, y, w, card_height, tr("PAST WEEK"), week, is_metric)
|