mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
273 lines
14 KiB
Python
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()
|