Files
StarPilot/openpilot/starpilot/ui/home.py
T

273 lines
14 KiB
Python

"""Offroad Home presentation driven exclusively by supplied immutable state.
The Home remains available before device pairing. Pairing uses the native dialog;
other setup, update, and alert flows remain with their existing owners.
"""
import hashlib
import json
import math
from pathlib import Path
import pyray as rl
from openpilot.starpilot.ui import clip
from openpilot.starpilot.ui.home_colors import draw_mode_banner_gradient, mode_atom_color
from openpilot.starpilot.ui.home_geometry import outside_rounded_border
from openpilot.starpilot.ui.home_state import HomeMode, HomeState
from openpilot.starpilot.ui.moon import draw_moon
from openpilot.starpilot.ui.home_stats import DriveStatsDashboard
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
BLUETOOTH_COLORS = {"off": rl.Color(255, 255, 255, 102), "on": rl.Color(255, 255, 255, 230), "connected": rl.Color(0, 130, 252, 230)}
JETLINK_COLORS = {"plugged": rl.Color(160, 160, 160, 255), "connected": rl.Color(70, 200, 110, 255)}
def draw_jetlink(rect: rl.Rectangle, state: str) -> None:
"""Jetlink's home icon: a tablet outline with a link dot."""
color = JETLINK_COLORS.get(state)
if color is None:
return
body = rl.Rectangle(rect.x + rect.width * 0.18, rect.y, rect.width * 0.64, rect.height)
rl.draw_rectangle_rounded_lines_ex(body, 0.3, 8, max(2.0, rect.width * 0.08), color)
rl.draw_circle_v(rl.Vector2(body.x + body.width / 2, body.y + body.height * 0.8), max(2.0, rect.width * 0.07), color)
def validate_home_asset(directory: Path, filename: str) -> Path:
manifest = json.loads(Path(__file__).with_name("home-assets.json").read_text())
entry = next((row for row in manifest["files"] if row["file"] == filename), None)
if entry is None:
raise ValueError(f"Unreviewed Home asset: {filename}")
path = directory / filename
if not path.is_file() or path.stat().st_size != entry["bytes"] or hashlib.sha256(path.read_bytes()).hexdigest() != entry["sha256"]:
raise ValueError(f"Home asset does not match the reviewed manifest: {filename}")
return path
class HomeView:
def __init__(self, fonts: BitmapFonts, assets: Path):
self.fonts = fonts
self.assets = assets
self._pixel_scale = (max(1.0, rl.get_render_width() / max(rl.get_screen_width(), 1)),
max(1.0, rl.get_render_height() / max(rl.get_screen_height(), 1)))
self._textures: dict[tuple, rl.Texture] = {}
self._mode_textures: dict[HomeMode, rl.Texture] = {}
def _texture(self, filename: str, width: int, height: int) -> rl.Texture:
key = filename, width, height
if key not in self._textures:
path = validate_home_asset(self.assets, filename)
image = rl.load_image(str(path))
try:
if image.data == rl.ffi.NULL or image.width <= 0 or image.height <= 0:
raise ValueError(f"Invalid Home image: {filename}")
target_width = min(int(width * self._pixel_scale[0]), image.width)
target_height = min(int(height * self._pixel_scale[1]), image.height)
ratio = min(target_width / image.width, target_height / image.height)
if ratio != 1:
rl.image_resize(image, int(image.width * ratio), int(image.height * ratio))
texture = rl.load_texture_from_image(image)
if texture.id == 0:
raise RuntimeError(f"Unable to load Home texture: {filename}")
finally:
if image.data != rl.ffi.NULL:
rl.unload_image(image)
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
rl.set_texture_wrap(texture, rl.TextureWrap.TEXTURE_WRAP_CLAMP)
texture.width, texture.height = width, height
self._textures[key] = texture
return self._textures[key]
def _icon(self, filename, width, height, x, y, opacity=1.0):
texture = self._texture(filename, width, height)
rl.draw_texture_ex(texture, rl.Vector2(x, y), 0.0, 1.0, rl.Color(255, 255, 255, int(opacity * 255)))
def _bluetooth(self, state, x, y):
"""Like the Wi-Fi icon: dimmed with a slash when off, white when on, blue with a device connected."""
texture = self._texture("icons_mici/settings/bluetooth.png", 38, 38)
color = BLUETOOTH_COLORS.get(state, BLUETOOTH_COLORS["off"])
rl.draw_texture_ex(texture, rl.Vector2(x, y), 0.0, 1.0, color)
if state == "off":
pad = 38 * 0.08
start, end = rl.Vector2(x + pad, y + pad), rl.Vector2(x + 38 - pad, y + 38 - pad)
rl.draw_line_ex(start, end, 9.0, rl.BLACK)
rl.draw_line_ex(start, end, 4.0, color)
def _text(self, text, role, size, x, y, color=rl.WHITE):
self.fonts.draw(text, role, size, x, y, color)
def _measure(self, text, role, size):
return self.fonts.measure(text, role, size)
def _label(self, text, role, size, rect, color=rl.WHITE):
measured = self._measure(text, role, size)
self._text(text, role, size, rect.x, rect.y + (rect.height - measured.height) / 2, color)
def render(self, state: HomeState) -> None:
if self.fonts.profile == Profile.LARGE:
self._large(state)
else:
self._compact(state)
def _large(self, state: HomeState) -> None:
self._sidebar(state)
header = rl.Rectangle(340, 40, 1780, 80)
detail = (" " + state.description if state.description else "") + (" - " + state.model_label if state.model_label else "")
size = 48
brand = self._measure("StarPilot", FontRole.BRAND, size + 2)
model = self._measure(detail, FontRole.MEDIUM, size)
if brand.width + model.width > header.width:
size = max(32, int(size * header.width / (brand.width + model.width)))
brand = self._measure("StarPilot", FontRole.BRAND, size + 2)
model = self._measure(detail, FontRole.MEDIUM, size)
rendered_width = min(brand.width + model.width, header.width)
x = header.x + header.width - rendered_width
self._label("StarPilot", FontRole.BRAND, size + 2, rl.Rectangle(x, header.y, brand.width, header.height))
self._label(detail, FontRole.MEDIUM, size, rl.Rectangle(x + brand.width, header.y, model.width, header.height))
if state.stats is None:
for rect in (rl.Rectangle(340, 145, 1005, 895), rl.Rectangle(1370, 295, 750, 745)):
rl.draw_rectangle_rounded(rect, 0.04, 12, rl.Color(18, 20, 29, 255))
self._label("Drive history unavailable", FontRole.MEDIUM, 36,
rl.Rectangle(rect.x + 30, rect.y + 30, rect.width - 60, 70), rl.GRAY)
else:
dashboard = DriveStatsDashboard(self.fonts, state.stats)
dashboard.render_overview(rl.Rectangle(340, 145, 1005, 895))
dashboard.render_records(rl.Rectangle(1370, 295, 750, 745))
self._mode_banner(state, rl.Rectangle(1370, 145, 750, 125))
if not state.paired:
pair = rl.Rectangle(1370, 300, 750, 130)
rl.draw_rectangle_rounded(pair, 0.16, 10, rl.Color(24, 47, 67, 255))
rl.draw_rectangle_rounded_lines_ex(pair, 0.16, 10, 4, rl.Color(87, 176, 229, 255))
self._label("PAIR DEVICE", FontRole.SEMI_BOLD, 43,
rl.Rectangle(pair.x + 35, pair.y + 10, pair.width - 70, pair.height - 20))
def _sidebar(self, state: HomeState):
self._icon("images/button_settings.png", 200, 117, 50, 35)
self._icon("images/button_home.png", 180, 180, 60, 860)
for index in range(5):
color = rl.WHITE if index < state.network_strength else rl.Color(84, 84, 84, 255)
rl.draw_circle(71 + index * 37, 209, 13, color)
network_labels = {"none": "--", "wifi": "Wi-Fi", "ethernet": "ETH", "cell2G": "2G", "cell3G": "3G", "cell4G": "LTE", "cell5G": "5G"}
self._text(network_labels.get(state.network, "Unknown"), FontRole.NORMAL, 35, 58, 247)
temperature = "--"
if state.temperature_c is not None:
temperature = f"{state.temperature_c:.0f}°C" if state.metric else f"{state.temperature_c * 1.8 + 32:.0f}°F"
metrics = [("TEMP", temperature, rl.Color(201, 34, 49, 255) if state.temperature_high else rl.WHITE, 338),
("VEHICLE" if state.vehicle_online else "NO", "ONLINE" if state.vehicle_online else "PANDA",
rl.WHITE if state.vehicle_online else rl.Color(201, 34, 49, 255), 496),
("CONNECT", state.connection, rl.WHITE if state.connection == "ONLINE" else rl.Color(218, 202, 37, 255), 654)]
for label, value, color, y in metrics:
rect = rl.Rectangle(30, y, 240, 126)
clip.begin_scissor_mode(34, y, 18, 126)
rl.draw_rectangle_rounded(rl.Rectangle(34, y + 4, 100, 118), 0.3, 10, color)
clip.end_scissor_mode()
outside_rounded_border(rect, 0.3, 10, 2, rl.Color(255, 255, 255, 85))
text_y = y + (63 - 2 * 35 * self.fonts.profile.font_scale)
for text in (label, value):
measured = self._measure(text, FontRole.SEMI_BOLD, 35)
text_y += measured.height
self._text(text, FontRole.SEMI_BOLD, 35, 52 + (218 - measured.width) / 2, text_y)
if state.recording_audio:
rl.draw_rectangle_rounded(rl.Rectangle(170, 245, 75, 40), 1, 10, rl.Color(201, 34, 49, 255))
self._icon("icons/microphone.png", 30, 30, 192.5, 250)
def _mode_banner(self, state: HomeState, rect: rl.Rectangle):
clip.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
draw_mode_banner_gradient(rect, state.mode)
outside_rounded_border(rect, 0.19, 10, 5, rl.BLACK)
clip.end_scissor_mode()
line_x = rect.x + rect.width - 130
rl.draw_line_ex(rl.Vector2(line_x, rect.y), rl.Vector2(line_x, rect.y + rect.height), 3, rl.Color(0, 0, 0, 77))
text = {HomeMode.CONDITIONAL_EXPERIMENTAL: "CONDITIONAL EXPERIMENTAL", HomeMode.CONDITIONAL_CHILL: "CONDITIONAL CHILL",
HomeMode.EXPERIMENTAL: "EXPERIMENTAL MODE ON", HomeMode.CHILL: "CHILL MODE ON"}[state.mode]
size = 45
width = self._measure(text, FontRole.NORMAL, size).width
if width > line_x - rect.x - 50:
size = max(32, int(size * (line_x - rect.x - 50) / width))
self._text(text, FontRole.NORMAL, size, int(rect.x + 25), int(rect.y + rect.height / 2 - size * self.fonts.profile.font_scale // 2), rl.BLACK)
if state.experimental_enabled:
self._icon("icons/experimental_grey.png", 80, 80, rect.x + rect.width - 105, rect.y + (rect.height - 80) / 2)
else:
draw_moon(rl.Rectangle(rect.x + rect.width - 97, rect.y + (rect.height - 64) / 2, 64, 64))
def _mode_atom(self, mode: HomeMode, x: float, y: float):
if mode not in self._mode_textures:
path = validate_home_asset(self.assets, "icons_mici/experimental_mode.png")
image = rl.load_image(str(path))
try:
if image.data == rl.ffi.NULL or not 0 < image.width <= 4096 or not 0 < image.height <= 4096:
raise ValueError("Invalid mode atom image")
rl.image_format(image, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8)
pixels = bytearray(rl.ffi.buffer(image.data, image.width * image.height * 4))
for row in range(image.height):
for column in range(image.width):
offset = (row * image.width + column) * 4
if pixels[offset + 3] == 0:
continue
shade = 0.65 + max(pixels[offset:offset + 3]) / 255.0 * 0.35
color = mode_atom_color(mode, column / max(image.width - 1, 1))
pixels[offset:offset + 3] = bytes(round(value * shade) for value in (color.r, color.g, color.b))
rl.ffi.buffer(image.data, len(pixels))[:] = bytes(pixels)
texture = rl.load_texture_from_image(image)
if texture.id == 0:
raise RuntimeError("Unable to load mode atom texture")
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
rl.set_texture_wrap(texture, rl.TextureWrap.TEXTURE_WRAP_CLAMP)
self._mode_textures[mode] = texture
finally:
if image.data != rl.ffi.NULL:
rl.unload_image(image)
texture = self._mode_textures[mode]
rl.draw_texture_pro(texture, rl.Rectangle(0, 0, texture.width, texture.height), rl.Rectangle(x, y, 48, 48), rl.Vector2(0, 0), 0, rl.WHITE)
def _compact(self, state: HomeState):
white = rl.Color(255, 255, 255, 229)
self._text("StarPilot", FontRole.BRAND, 64, 6, 8, white)
version_branch = f"{state.version} {state.branch}".strip()
for text, y, color in ((version_branch, 98, rl.GRAY), (state.model_label, 140, white)):
size = 30
while size > 22 and self._measure(text, FontRole.ROMAN, size).width > 520:
size -= 1
while text and self._measure(text, FontRole.ROMAN, size).width > 520:
text = text[:-2].rstrip('…') + '…'
self._text(text, FontRole.ROMAN, size, 6, y, color)
self._icon("icons_mici/settings.png", 48, 48, 8, 192, 0.9)
if state.network == "wifi":
suffix = "none" if state.network_strength == 0 else "medium" if state.network_strength == 3 else "full" if state.network_strength >= 4 else "low"
self._icon(f"icons_mici/settings/network/wifi_strength_{suffix}.png", 50, 37, 76, 197.5, 0.9)
elif state.network.startswith("cell"):
suffix = {0: "none", 2: "low", 3: "medium", 4: "high", 5: "full"}.get(state.network_strength, "none")
self._icon(f"icons_mici/settings/network/cell_strength_{suffix}.png", 54, 36, 74, 198, 0.9)
else:
self._icon("icons_mici/settings/network/wifi_strength_slash.png", 50, 44, 76, 190.5, 0.9)
x = 146
if state.bluetooth:
self._bluetooth(state.bluetooth, x, 197)
x += 56
if state.mode == HomeMode.CHILL:
draw_moon(rl.Rectangle(x + 5, 197, 38, 38))
else:
self._mode_atom(state.mode, x, 192)
x += 66
if state.gpu_present or state.gpu_state in ("loading", "active", "failed", "uncompiled"):
failed = state.gpu_state in ("failed", "uncompiled")
loading = state.gpu_state == "loading"
icon = "chestnut_orange.png" if failed else "chestnut.png" if loading else "chestnut_green.png"
opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6)) if loading else 1.0
self._icon("icons_mici/" + icon, 68 if failed else 54, 40, x, 197, opacity)
x += 76
if state.jetlink in JETLINK_COLORS:
draw_jetlink(rl.Rectangle(x, 195, 40, 42), state.jetlink)
x += 58
if state.recording_audio:
self._icon("icons_mici/microphone.png", 32, 46, x, 193)
def close(self):
if rl.is_window_ready():
for texture in (*self._textures.values(), *self._mode_textures.values()):
rl.unload_texture(texture)
self._textures.clear()
self._mode_textures.clear()