carrot hud / cluster - turzx 9.2 in (#369)

* add cluster alpha

* add dep

* add dep

* fix usb timeout

* fix usb timeout

* Improve error handling in USB frame transmission

Refactor error handling and input draining in USB frame methods.

* Refactor JPEG rendering logic in main.py

Refactor JPEG rendering to improve readability and add logging.

* Refactor _send_frame_no_ack method

Refactor _send_frame_no_ack method for better readability and structure.

* Update main.py

* Update main.py

* Update cluster_usb_display.py

* Increase NUM_READERS from 25 to 40

* Add center_clock_text attribute to cluster model

* Add replace import from dataclasses

* Update main.py

* Implement center clock drawing in cluster renderer

Added a new method to draw the center clock on the cluster UI.

* Update cluster_renderer.py

* Simplify input draining condition in USB frame method

Refactor input draining logic to improve readability.

* Update main.py

* Update cluster_renderer.py

* Update main.py

* Update cluster_usb_display.py

* Implement performance profiling in cluster rendering

Added profiling for rendering performance metrics in the cluster renderer.

* Update cluster_renderer.py

* Update cluster_renderer.py

* Update cluster_renderer.py

* Update main.py

* Add CLUSTER_PROFILE_RGBA option to README

Added environment variable for RGBA profile to cluster_run.py command.

* fix replay

* fix replay

* add log

* add log

* add log

* fix

* fix

* fix

* fix

* fix

* fix

* performance

* performance

* performance

* performance

* performance

* performance

* performance

* performance

* performance

* performance

* performance

* process

* process

* process

* process

* remove dummy

* fix ui

* fix ui

* fix ui

* fix usb event monitor

* fix usb event monitor

* fix ui

* fix ui

* fix ui apply font

* fix ui apply font

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix radar point

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* fix ui

* dark mode

* dark mode

* dark mode

* cleanup

* add bsd

* bsd

* lca

* lca

* lca

* lca

* lca

* lca

* lca

* lca

* move speed limit

* profiler

* perfomance

* perfomance

* perfomance

* perfomance

* perfomance

* perfomance

* perfomance

* perfomance

* perfomance

* fps

* perfomance

* params

* monit

* add git info
This commit is contained in:
young
2026-05-25 18:33:50 +09:00
committed by GitHub
parent c8352bdec0
commit a80ce3cdbb
33 changed files with 41347 additions and 1 deletions

View File

@@ -165,6 +165,9 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ShowPlotMode", {PERSISTENT, INT, "0"}},
{"ShowCustomBrightness", {PERSISTENT, INT, "100"}},
{"ShowModelView", {PERSISTENT, INT, "0"}},
{"ClusterHud", {PERSISTENT, INT, "0"}},
{"ClusterHudTheme", {PERSISTENT, INT, "0"}},
{"ClusterHudLiveFps", {PERSISTENT, INT, "0"}},
{"RecordRoadCam", {PERSISTENT, INT, "0"}},
{"HDPuse", {PERSISTENT, INT, "0"}},

View File

@@ -6,7 +6,7 @@
#include <atomic>
#define DEFAULT_SEGMENT_SIZE (1 * 1024 * 1024)
#define NUM_READERS 25//15
#define NUM_READERS 40//15
#define ALIGN(n) ((n + (8 - 1)) & -8)
#define UNUSED(x) (void)x

View File

@@ -0,0 +1,48 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Union, Tuple
from PIL import ImageColor
RGBColor = Tuple[int, int, int]
# Color can be an RGB tuple (RGBColor), or a string in any of these formats:
# - "r, g, b" (e.g. "255, 0, 0"), as is found in the themes' yaml settings
# - any of the formats supported by PIL: https://pillow.readthedocs.io/en/stable/reference/ImageColor.html
#
# For example, here are multiple ways to write the pure red color:
# - (255, 0, 0)
# - "255, 0, 0"
# - "#ff0000"
# - "red"
# - "hsl(0, 100%, 50%)"
Color = Union[str, RGBColor]
def parse_color(color: Color) -> RGBColor:
# even if undocumented, let's be nice and accept a list in lieu of a tuple
if isinstance(color, tuple) or isinstance(color, list):
if len(color) != 3:
raise ValueError("RGB color must have 3 values")
return (int(color[0]), int(color[1]), int(color[2]))
if not isinstance(color, str):
raise ValueError("Color must be either an RGB tuple or a string")
# Try to parse it as our custom "r, g, b" format
rgb = color.split(',')
if len(rgb) == 3:
r, g, b = rgb
try:
rgbcolor = (int(r.strip()), int(g.strip()), int(b.strip()))
except ValueError:
# at least one element can't be converted to int, we continue to
# try parsing as a PIL color
pass
else:
return rgbcolor
# fallback as a PIL color
rgbcolor = ImageColor.getrgb(color)
if len(rgbcolor) == 4:
return (rgbcolor[0], rgbcolor[1], rgbcolor[2])
return rgbcolor

View File

@@ -0,0 +1,723 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
# https://github.com/mathoudebine/turing-smart-screen-python/
#
# Copyright (C) 2021 Matthieu Houdebine (mathoudebine)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import copy
import math
import os
import platform
import queue
import sys
import threading
import time
from abc import ABC, abstractmethod
from enum import IntEnum
from typing import Tuple, List, Optional, Dict
import serial
from PIL import Image, ImageDraw, ImageFont
from library.log import logger
from library.lcd.color import Color, parse_color
class Orientation(IntEnum):
PORTRAIT = 0
LANDSCAPE = 2
REVERSE_PORTRAIT = 1
REVERSE_LANDSCAPE = 3
class LcdComm(ABC):
def __init__(self, com_port: str = "AUTO", display_width: int = 320, display_height: int = 480,
update_queue: Optional[queue.Queue] = None):
self.lcd_serial = None
# String containing absolute path to serial port e.g. "COM3", "/dev/ttyACM1" or "AUTO" for auto-discovery
# Ignored for USB HID screens
self.com_port = com_port
# Display always start in portrait orientation by default
self.orientation = Orientation.PORTRAIT
# Display width in default orientation (portrait)
self.display_width = display_width
# Display height in default orientation (portrait)
self.display_height = display_height
# Queue containing the serial requests to send to the screen. An external thread should run to process requests
# on the queue. If you want serial requests to be done in sequence, set it to None
self.update_queue = update_queue
# Mutex to protect the queue in case a thread want to add multiple requests (e.g. image data) that should not be
# mixed with other requests in-between
self.update_queue_mutex = threading.Lock()
# Create a cache to store opened images, to avoid opening and loading from the filesystem every time
self.image_cache = {} # { key=path, value=PIL.Image }
# Create a cache to store opened fonts, to avoid opening and loading from the filesystem every time
self.font_cache: Dict[
Tuple[str, int], # key=(font, size)
ImageFont.FreeTypeFont # value= a loaded freetype font
] = {}
def get_width(self) -> int:
if self.orientation == Orientation.PORTRAIT or self.orientation == Orientation.REVERSE_PORTRAIT:
return self.display_width
else:
return self.display_height
def get_height(self) -> int:
if self.orientation == Orientation.PORTRAIT or self.orientation == Orientation.REVERSE_PORTRAIT:
return self.display_height
else:
return self.display_width
def openSerial(self):
if self.com_port == 'AUTO':
self.com_port = self.auto_detect_com_port()
if not self.com_port:
logger.error(
"Cannot find COM port automatically, please run Configuration again and select COM port manually")
try:
sys.exit(0)
except:
os._exit(0)
else:
logger.debug(f"Auto detected COM port: {self.com_port}")
else:
logger.debug(f"Static COM port: {self.com_port}")
try:
self.lcd_serial = serial.Serial(self.com_port, 115200, timeout=1, rtscts=True)
except Exception as e:
logger.error(f"Cannot open COM port {self.com_port}: {e}")
try:
sys.exit(0)
except:
os._exit(0)
def closeSerial(self):
if self.lcd_serial is not None:
self.lcd_serial.close()
def serial_write(self, data: bytes):
assert self.lcd_serial is not None
self.lcd_serial.write(data)
def serial_read(self, size: int) -> bytes:
assert self.lcd_serial is not None
return self.lcd_serial.read(size)
def serial_readall(self) -> bytes:
assert self.lcd_serial is not None
return self.lcd_serial.readall()
def serial_flush_input(self):
if self.lcd_serial is not None:
self.lcd_serial.reset_input_buffer()
def WriteData(self, byteBuffer: bytearray):
self.WriteLine(bytes(byteBuffer))
def SendLine(self, line: bytes):
if self.update_queue:
# Queue the request. Mutex is locked by caller to queue multiple lines
self.update_queue.put((self.WriteLine, [line]))
else:
# If no queue for async requests: do request now
self.WriteLine(line)
def WriteLine(self, line: bytes):
try:
self.serial_write(line)
if platform.system() == "Darwin":
# macOS needs the serial buffer to be flushed regularly to avoid bitmap corruption on the display
# See https://github.com/mathoudebine/turing-smart-screen-python/issues/7
self.lcd_serial.flush()
except serial.SerialTimeoutException:
# We timed-out trying to write to our device, slow things down.
logger.warning("(Write line) Too fast! Slow down!")
except serial.SerialException:
# Error writing data to device: close and reopen serial port, try to write again
logger.error(
"SerialException: Failed to send serial data to device. Closing and reopening COM port before retrying once.")
self.closeSerial()
time.sleep(1)
self.openSerial()
self.serial_write(line)
def ReadData(self, readSize: int):
try:
response = self.serial_read(readSize)
# logger.debug("Received: [{}]".format(str(response, 'utf-8')))
return response
except serial.SerialTimeoutException:
# We timed-out trying to read from our device, slow things down.
logger.warning("(Read data) Too fast! Slow down!")
except serial.SerialException:
# Error writing data to device: close and reopen serial port, try to read again
logger.error(
"SerialException: Failed to read serial data from device. Closing and reopening COM port before retrying once.")
self.closeSerial()
time.sleep(1)
self.openSerial()
return self.serial_read(readSize)
@staticmethod
def auto_detect_com_port() -> Optional[str]:
# To implement only for screens that use serial commands
pass
@abstractmethod
def InitializeComm(self):
pass
@abstractmethod
def Reset(self):
pass
@abstractmethod
def Clear(self):
pass
@abstractmethod
def ScreenOff(self):
pass
@abstractmethod
def ScreenOn(self):
pass
@abstractmethod
def SetBrightness(self, level: int):
pass
def SetBackplateLedColor(self, led_color: Tuple[int, int, int] = (255, 255, 255)):
pass
@abstractmethod
def SetOrientation(self, orientation: Orientation):
pass
@abstractmethod
def DisplayPILImage(
self,
image: Image.Image,
x: int = 0, y: int = 0,
image_width: int = 0,
image_height: int = 0
):
pass
def DisplayBitmap(self, bitmap_path: str, x: int = 0, y: int = 0, width: int = 0, height: int = 0):
image = self.open_image(bitmap_path)
# Resize the picture if custom width/height provided
if width != 0 and height != 0:
if width != image.size[0] or height != image.size[1]:
image = image.resize((width, height))
self.DisplayPILImage(image, x, y, width, height)
def DisplayText(
self,
text: str,
x: int = 0,
y: int = 0,
width: int = 0,
height: int = 0,
font: str = "./res/fonts/roboto-mono/RobotoMono-Regular.ttf",
font_size: int = 20,
font_color: Color = (0, 0, 0),
background_color: Color = (255, 255, 255),
background_image: Optional[str] = None,
align: str = 'left',
anchor: str = 'la',
):
# Convert text to bitmap using PIL and display it
# Provide the background image path to display text with transparent background
font_color = parse_color(font_color)
background_color = parse_color(background_color)
assert x <= self.get_width(), 'Text "' + text + '" X coordinate ' + str(x) + ' must be <= display width ' + str(
self.get_width())
assert y <= self.get_height(), 'Text "' + text + '" Y coordinate ' + str(y) + ' must be <= display height ' + str(
self.get_height())
assert len(text) > 0, 'Text must not be empty'
assert font_size > 0, "Font size must be > 0"
# If only width is specified, assume height based on font size (one-line text)
if width > 0 and height == 0:
height = font_size
if background_image is None:
# A text bitmap is created with max width/height by default : text with solid background
text_image = Image.new(
'RGB',
(self.get_width(), self.get_height()),
background_color
)
else:
# The text bitmap is created from provided background image : text with transparent background
text_image = self.open_image(background_image)
# Get text bounding box
ttfont = self.open_font(font, font_size)
d = ImageDraw.Draw(text_image)
if width == 0 or height == 0:
left, top, right, bottom = d.textbbox((x, y), text, font=ttfont, align=align, anchor=anchor)
# textbbox may return float values, which is not good for the bitmap operations below.
# Let's extend the bounding box to the next whole pixel in all directions
left, top = math.floor(left), math.floor(top)
right, bottom = math.ceil(right), math.ceil(bottom)
else:
left, top, right, bottom = x, y, x + width, y + height
if anchor.startswith("m"):
x = int((right + left) / 2)
elif anchor.startswith("r"):
x = right
else:
x = left
if anchor.endswith("m"):
y = int((bottom + top) / 2)
elif anchor.endswith("b"):
y = bottom
else:
y = top
# Draw text onto the background image with specified color & font
d.text((x, y), text, font=ttfont, fill=font_color, align=align, anchor=anchor)
# Restrict the dimensions if they overflow the display size
left = max(left, 0)
top = max(top, 0)
right = min(right, self.get_width())
bottom = min(bottom, self.get_height())
# Crop text bitmap to keep only the text
text_image = text_image.crop(box=(left, top, right, bottom))
self.DisplayPILImage(text_image, left, top)
def DisplayProgressBar(self, x: int, y: int, width: int, height: int, min_value: int = 0, max_value: int = 100,
value: int = 50,
bar_color: Color = (0, 0, 0),
bar_outline: bool = True,
background_color: Color = (255, 255, 255),
background_image: Optional[str] = None,
reverse_direction: Optional[bool] = False):
# Generate a progress bar and display it
# Provide the background image path to display progress bar with transparent background
bar_color = parse_color(bar_color)
background_color = parse_color(background_color)
assert x <= self.get_width(), 'Progress bar X coordinate must be <= display width'
assert y <= self.get_height(), 'Progress bar Y coordinate must be <= display height'
assert x + width <= self.get_width(), 'Progress bar width exceeds display width'
assert y + height <= self.get_height(), 'Progress bar height exceeds display height'
# Don't let the set value exceed our min or max value, this is bad :)
if value < min_value:
value = min_value
elif max_value < value:
value = max_value
assert min_value <= value <= max_value, 'Progress bar value shall be between min and max'
if background_image is None:
# A bitmap is created with solid background
bar_image = Image.new('RGB', (width, height), background_color)
else:
# A bitmap is created from provided background image
bar_image = self.open_image(background_image)
# Crop bitmap to keep only the progress bar background
bar_image = bar_image.crop(box=(x, y, x + width, y + height))
# Draw progress bar. Fill has to be computed from the offset
# into [min_value, max_value], not the raw value; otherwise a
# bar with min_value > 0 (e.g. a 25..95 temperature bar) is
# filled by the wrong fraction. DisplayRadialProgressBar below
# already does this correctly. See issue #954.
if width > height:
bar_filled_width = ((value - min_value) / (max_value - min_value) * width) - 1
if bar_filled_width < 0:
bar_filled_width = 0
else:
bar_filled_height = ((value - min_value) / (max_value - min_value) * height) - 1
if bar_filled_height < 0:
bar_filled_height = 0
draw = ImageDraw.Draw(bar_image)
# most common setting
x1 = 0
y1 = 0
x2 = width - 1
y2 = height - 1
if width > height:
if reverse_direction is True:
x1 = width - 1 - bar_filled_width
else:
x2 = bar_filled_width
else:
if reverse_direction is True:
y2 = bar_filled_height
else:
y1 = height - 1 - bar_filled_height
draw.rectangle([x1, y1, x2, y2], fill=bar_color, outline=bar_color)
if bar_outline:
# Draw outline
draw.rectangle([0, 0, width - 1, height - 1], fill=None, outline=bar_color)
self.DisplayPILImage(bar_image, x, y)
def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
values: List[float],
min_value: float = 0,
max_value: float = 100,
autoscale: bool = False,
line_color: Color = (0, 0, 0),
line_width: int = 2,
graph_axis: bool = True,
axis_color: Color = (0, 0, 0),
axis_font: str = "./res/fonts/roboto/Roboto-Black.ttf",
axis_font_size: int = 10,
background_color: Color = (255, 255, 255),
background_image: Optional[str] = None,
axis_minmax_format: str = "{:0.0f}"):
# Generate a plot graph and display it
# Provide the background image path to display plot graph with transparent background
line_color = parse_color(line_color)
axis_color = parse_color(axis_color)
background_color = parse_color(background_color)
assert x <= self.get_width(), 'Progress bar X coordinate must be <= display width'
assert y <= self.get_height(), 'Progress bar Y coordinate must be <= display height'
assert x + width <= self.get_width(), 'Progress bar width exceeds display width'
assert y + height <= self.get_height(), 'Progress bar height exceeds display height'
if background_image is None:
# A bitmap is created with solid background
graph_image = Image.new('RGB', (width, height), background_color)
else:
# A bitmap is created from provided background image
graph_image = self.open_image(background_image)
# Crop bitmap to keep only the plot graph background
graph_image = graph_image.crop(box=(x, y, x + width, y + height))
# if autoscale is enabled, define new min/max value to "zoom" the graph
if autoscale:
trueMin = max_value
trueMax = min_value
for value in values:
if not math.isnan(value):
if trueMin > value:
trueMin = value
if trueMax < value:
trueMax = value
if trueMin != max_value and trueMax != min_value:
min_value = max(trueMin - 5, min_value)
max_value = min(trueMax + 5, max_value)
step = width / len(values)
# pre compute yScale multiplier value
yScale = (height / (max_value - min_value)) if (max_value - min_value) != 0 else 0
plotsX = []
plotsY = []
count = 0
for value in values:
if not math.isnan(value):
# Don't let the set value exceed our min or max value, this is bad :)
if value < min_value:
value = min_value
elif max_value < value:
value = max_value
assert min_value <= value <= max_value, 'Plot point value shall be between min and max'
plotsX.append(count * step)
plotsY.append(height - (value - min_value) * yScale)
count += 1
# Draw plot graph
draw = ImageDraw.Draw(graph_image)
draw.line(list(zip(plotsX, plotsY)), fill=line_color, width=line_width)
if graph_axis:
# Draw axis
draw.line([0, height - 1, width - 1, height - 1], fill=axis_color)
draw.line([0, 0, 0, height - 1], fill=axis_color)
# Draw Legend
draw.line([0, 0, 1, 0], fill=axis_color)
text = axis_minmax_format.format(max_value)
ttfont = self.open_font(axis_font, axis_font_size)
_, top, right, bottom = ttfont.getbbox(text)
draw.text((2, 0 - top), text,
font=ttfont, fill=axis_color)
text = axis_minmax_format.format(min_value)
_, top, right, bottom = ttfont.getbbox(text)
draw.text((width - 1 - right, height - 2 - bottom), text,
font=ttfont, fill=axis_color)
self.DisplayPILImage(graph_image, x, y)
def DrawRadialDecoration(self, draw: ImageDraw.ImageDraw, angle: float, radius: float, width: float, color: Tuple[int, int, int] = (0, 0, 0)):
i_cos = math.cos(angle*math.pi/180)
i_sin = math.sin(angle*math.pi/180)
x_f = (i_cos * (radius - width/2)) + radius
if math.modf(x_f) == 0.5:
if i_cos > 0:
x_f = math.floor(x_f)
else:
x_f = math.ceil(x_f)
else:
x_f = math.floor(x_f + 0.5)
y_f = (i_sin * (radius - width/2)) + radius
if math.modf(y_f) == 0.5:
if i_sin > 0:
y_f = math.floor(y_f)
else:
y_f = math.ceil(y_f)
else:
y_f = math.floor(y_f + 0.5)
draw.ellipse([x_f - width/2, y_f - width/2, x_f + width/2, y_f - 1 + width/2 - 1], outline=color, fill=color, width=1)
def DisplayRadialProgressBar(self, xc: int, yc: int, radius: int, bar_width: int,
min_value: int = 0,
max_value: int = 100,
angle_start: float = 0,
angle_end: float = 360,
angle_sep: int = 5,
angle_steps: int = 10,
clockwise: bool = True,
value: int = 50,
text: Optional[str] = None,
with_text: bool = True,
font: str = "./res/fonts/roboto/Roboto-Black.ttf",
font_size: int = 20,
font_color: Color = (0, 0, 0),
bar_color: Color = (0, 0, 0),
background_color: Color = (255, 255, 255),
background_image: Optional[str] = None,
custom_bbox: Tuple[int, int, int, int] = (0, 0, 0, 0),
text_offset: Tuple[int, int] = (0,0),
bar_background_color: Color = (0, 0, 0),
draw_bar_background: bool = False,
bar_decoration: str = ""):
# Generate a radial progress bar and display it
# Provide the background image path to display progress bar with transparent background
bar_color = parse_color(bar_color)
background_color = parse_color(background_color)
font_color = parse_color(font_color)
bar_background_color = parse_color(bar_background_color)
if angle_start % 361 == angle_end % 361:
if clockwise:
angle_start += 0.1
else:
angle_end += 0.1
assert xc - radius >= 0 and xc + radius <= self.get_width(), 'Radial is out of screen (left/right)'
assert yc - radius >= 0 and yc + radius <= self.get_height(), 'Radial is out of screen (up/down)'
assert 0 < bar_width <= radius, f'Radial linewidth is {bar_width}, must be > 0 and <= radius'
assert angle_end % 361 != angle_start % 361, f'Invalid angles values, start = {angle_start}, end = {angle_end}'
assert isinstance(angle_steps, int), 'angle_steps value must be an integer'
assert angle_sep >= 0, 'Provide an angle_sep value >= 0'
assert angle_steps > 0, 'Provide an angle_step value > 0'
assert angle_sep * angle_steps < 360, 'Given angle_sep and angle_steps values are not correctly set'
# Don't let the set value exceed our min or max value, this is bad :)
if value < min_value:
value = min_value
elif max_value < value:
value = max_value
assert min_value <= value <= max_value, 'Radial value shall be between min and max'
diameter = 2 * radius
bbox = (xc - radius, yc - radius, xc + radius, yc + radius)
#
if background_image is None:
# A bitmap is created with solid background
bar_image = Image.new('RGB', (diameter, diameter), background_color)
else:
# A bitmap is created from provided background image
bar_image = self.open_image(background_image)
# Crop bitmap to keep only the progress bar background
bar_image = bar_image.crop(box=bbox)
# Draw progress bar
pct = (value - min_value) / (max_value - min_value)
draw = ImageDraw.Draw(bar_image)
# PIL arc method uses angles with
# . 3 o'clock for 0
# . clockwise from angle start to angle end
angle_start %= 361
angle_end %= 361
#
if clockwise:
if angle_end < angle_start:
ecart = 360 - angle_start + angle_end
else:
ecart = angle_end - angle_start
# draw bar background
if draw_bar_background:
if angle_end < angle_start:
angleE = angle_start + ecart
angleS = angle_start
else:
angleS = angle_start
angleE = angle_start + ecart
draw.arc([0, 0, diameter - 1, diameter - 1], angleS, angleE, fill=bar_background_color, width=bar_width)
# draw bar decoration
if bar_decoration == "Ellipse":
self.DrawRadialDecoration(draw = draw, angle = angle_end, radius = radius, width = bar_width, color = bar_background_color)
self.DrawRadialDecoration(draw = draw, angle = angle_start, radius = radius, width = bar_width, color = bar_color)
self.DrawRadialDecoration(draw = draw, angle = angle_start + pct * ecart, radius = radius, width = bar_width, color = bar_color)
#
# solid bar case
if angle_sep == 0:
if angle_end < angle_start:
angleE = angle_start + pct * ecart
angleS = angle_start
else:
angleS = angle_start
angleE = angle_start + pct * ecart
draw.arc([0, 0, diameter - 1, diameter - 1], angleS, angleE,
fill=bar_color, width=bar_width)
# discontinued bar case
else:
angleE = angle_start + pct * ecart
angle_complet = ecart / angle_steps
etapes = int((angleE - angle_start) / angle_complet)
for i in range(etapes):
draw.arc([0, 0, diameter - 1, diameter - 1],
angle_start + i * angle_complet,
angle_start + (i + 1) * angle_complet - angle_sep,
fill=bar_color,
width=bar_width)
draw.arc([0, 0, diameter - 1, diameter - 1],
angle_start + etapes * angle_complet,
angleE,
fill=bar_color,
width=bar_width)
else:
if angle_end < angle_start:
ecart = angle_start - angle_end
else:
ecart = 360 - angle_end + angle_start
# draw bar background
if draw_bar_background:
if angle_end < angle_start:
angleE = angle_start
angleS = angle_start - ecart
else:
angleS = angle_start - ecart
angleE = angle_start
draw.arc([0, 0, diameter - 1, diameter - 1], angleS, angleE, fill=bar_background_color, width=bar_width)
# draw bar decoration
if bar_decoration == "Ellipse":
self.DrawRadialDecoration(draw = draw, angle = angle_end, radius = radius, width = bar_width, color = bar_background_color)
self.DrawRadialDecoration(draw = draw, angle = angle_start, radius = radius, width = bar_width, color = bar_color)
self.DrawRadialDecoration(draw = draw, angle = angle_start - pct * ecart, radius = radius, width = bar_width, color = bar_color)
#
# solid bar case
if angle_sep == 0:
if angle_end < angle_start:
angleE = angle_start
angleS = angle_start - pct * ecart
else:
angleS = angle_start - pct * ecart
angleE = angle_start
draw.arc([0, 0, diameter - 1, diameter - 1], angleS, angleE,
fill=bar_color, width=bar_width)
# discontinued bar case
else:
angleS = angle_start - pct * ecart
angle_complet = ecart / angle_steps
etapes = int((angle_start - angleS) / angle_complet)
for i in range(etapes):
draw.arc([0, 0, diameter - 1, diameter - 1],
angle_start - (i + 1) * angle_complet + angle_sep,
angle_start - i * angle_complet,
fill=bar_color,
width=bar_width)
draw.arc([0, 0, diameter - 1, diameter - 1],
angleS,
angle_start - etapes * angle_complet,
fill=bar_color,
width=bar_width)
# Draw text
if with_text:
if text is None:
text = f"{int(pct * 100 + .5)}%"
ttfont = self.open_font(font, font_size)
left, top, right, bottom = ttfont.getbbox(text)
w, h = right - left, bottom - top
draw.text((radius - w / 2 + text_offset[0], radius - top - h / 2 + text_offset[1]), text,
font=ttfont, fill=font_color)
if custom_bbox[0] != 0 or custom_bbox[1] != 0 or custom_bbox[2] != 0 or custom_bbox[3] != 0:
bar_image = bar_image.crop(box=custom_bbox)
self.DisplayPILImage(bar_image, xc - radius + custom_bbox[0], yc - radius + custom_bbox[1])
# self.DisplayPILImage(bar_image, xc - radius, yc - radius)
# Load image from the filesystem, or get from the cache if it has already been loaded previously
def open_image(self, bitmap_path: str) -> Image.Image:
if bitmap_path not in self.image_cache:
logger.debug("Bitmap " + bitmap_path + " is now loaded in the cache")
self.image_cache[bitmap_path] = Image.open(bitmap_path)
return copy.copy(self.image_cache[bitmap_path])
def open_font(self, name: str, size: int) -> ImageFont.FreeTypeFont:
if (name, size) not in self.font_cache:
self.font_cache[(name, size)] = ImageFont.truetype(name, size)
return self.font_cache[(name, size)]

View File

@@ -0,0 +1,992 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
# https://github.com/mathoudebine/turing-smart-screen-python/
#
# Copyright (C) 2021 Matthieu Houdebine (mathoudebine)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
import platform
import queue
import shutil
import struct
import subprocess
import sys
import time
from io import BytesIO
from pathlib import Path
from typing import Optional
import usb.core
import usb.util
from Crypto.Cipher import DES
from PIL import Image
from library.lcd.lcd_comm import Orientation, LcdComm
from library.log import logger
VENDOR_ID = 0x1cbe
# Map of display supported product IDs and their respective resolution in portrait mode
PRODUCT_ID = {0x0028: (480, 480), # Turing 2.8" round (USB)
0x0046: (320, 960), # Turing 4.6"
0x0050: (720, 1280), # Turing 5.2"
0x0080: (800, 1280), # Turing 8.0"
0x0088: (480, 1920), # Turing 8.8"
0x0092: (462, 1920), # Turing 9.2"
0x0123: (720, 1920), # Turing 12.3"
}
MAX_CHUNK_BYTES = 1024 * 1024 # Data sent to screen cannot exceed 1024MB or there will be a timeout
# Command IDs used by the vendor protocol (subset)
CMD_UPLOAD_JPEG = 101
CMD_UPLOAD_PNG = 102
CMD_GET_H264_CHUNK_SIZE = 17
CMD_PLAY_H264_CHUNK = 121
CMD_GET_STREAM_STATUS = 122
CMD_STOP_STREAM = 123
# Default max payload for frame uploads (device/transport limit)
MAX_IMAGE_PAYLOAD_DEFAULT = MAX_CHUNK_BYTES
def _resp_ok(resp: Optional[bytes]) -> bool:
if not resp:
return False
b1 = resp[1] if len(resp) > 1 else None
b8 = resp[8] if len(resp) > 8 else None
return (b1 == 0xC8) or (b8 == 0xC8)
def send_jpeg(dev, jpeg_data: bytes):
img_size = len(jpeg_data)
cmd_packet = build_command_packet_header(CMD_UPLOAD_JPEG)
cmd_packet[8] = (img_size >> 24) & 0xFF
cmd_packet[9] = (img_size >> 16) & 0xFF
cmd_packet[10] = (img_size >> 8) & 0xFF
cmd_packet[11] = img_size & 0xFF
full_payload = encrypt_command_packet(cmd_packet) + jpeg_data
return write_to_device(dev, full_payload)
def _encode_jpeg_under_limit(image: Image.Image, *, max_bytes: int, quality: int = 95,
subsampling: int = -1, ) -> bytes:
if subsampling not in (-1, 0, 1, 2):
raise ValueError("subsampling must be one of: -1, 0, 1, 2")
img = image
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
elif img.mode == "L":
img = img.convert("RGB")
subs = (2, 1, 0) if subsampling == -1 else (subsampling,)
best = b""
for sub in subs:
q = int(quality)
while q >= 1:
buf = BytesIO()
try:
img.save(buf, format="JPEG", quality=q, optimize=False, progressive=False, subsampling=sub, )
except TypeError:
img.save(buf, format="JPEG", quality=q, optimize=False, progressive=False)
data = buf.getvalue()
if not best or len(data) < len(best):
best = data
if len(data) <= max_bytes:
return data
q = q - 5 if q > 10 else q - 1
raise RuntimeError(f"Could not transcode JPEG under max_bytes: {len(best)} > {max_bytes}")
def send_pil_image_auto(dev, image: Image.Image, *, max_bytes: int = MAX_IMAGE_PAYLOAD_DEFAULT, ) -> None:
# First try PNG (preferred)
png = _encode_png(image)
if len(png) <= max_bytes:
send_image(dev, png)
return
# Fallback to JPEG when over limit (default behavior)
jpg = _encode_jpeg_under_limit(image, max_bytes=max_bytes, quality=90, subsampling=-1)
send_jpeg(dev, jpg)
# ---- MP4 parsing + Annex-B extraction (pure Python fallback) ----
from dataclasses import dataclass
from typing import Iterable, Set
def _u32be(b: bytes, off: int = 0) -> int:
return int.from_bytes(b[off:off + 4], "big", signed=False)
def _u64be(b: bytes, off: int = 0) -> int:
return int.from_bytes(b[off:off + 8], "big", signed=False)
def _iter_mp4_boxes(data: bytes, start: int, end: int) -> Iterable[tuple[bytes, int, int]]:
i = start
while i + 8 <= end:
size = _u32be(data, i)
typ = data[i + 4:i + 8]
hdr = 8
if size == 1:
if i + 16 > end:
break
size = _u64be(data, i + 8)
hdr = 16
elif size == 0:
size = end - i
if size < hdr:
break
j = i + int(size)
if j > end:
break
yield typ, i + hdr, j
i = j
def _mp4_find_box(data: bytes, start: int, end: int, typ: bytes) -> Optional[tuple[int, int]]:
for t, ps, pe in _iter_mp4_boxes(data, start, end):
if t == typ:
return ps, pe
return None
@dataclass
class _Mp4H264Track:
nal_len_size: int
sps_list: list[bytes]
pps_list: list[bytes]
sample_sizes: list[int]
chunk_offsets: list[int]
stsc: list[tuple[int, int, int]] # (first_chunk, samples_per_chunk, sample_desc_idx)
sync_samples: Optional[Set[int]]
def _mp4_parse_avcc(avcc: bytes) -> tuple[int, list[bytes], list[bytes]]:
if len(avcc) < 7:
raise ValueError("avcC too small")
nal_len_size = (avcc[4] & 0x03) + 1
num_sps = avcc[5] & 0x1F
off = 6
sps_list: list[bytes] = []
for _ in range(num_sps):
if off + 2 > len(avcc):
raise ValueError("avcC truncated (SPS length)")
n = int.from_bytes(avcc[off:off + 2], "big")
off += 2
if off + n > len(avcc):
raise ValueError("avcC truncated (SPS data)")
sps_list.append(avcc[off:off + n])
off += n
if off + 1 > len(avcc):
raise ValueError("avcC truncated (PPS count)")
num_pps = avcc[off]
off += 1
pps_list: list[bytes] = []
for _ in range(num_pps):
if off + 2 > len(avcc):
raise ValueError("avcC truncated (PPS length)")
n = int.from_bytes(avcc[off:off + 2], "big")
off += 2
if off + n > len(avcc):
raise ValueError("avcC truncated (PPS data)")
pps_list.append(avcc[off:off + n])
off += n
return nal_len_size, sps_list, pps_list
def _mp4_load_moov(path: str) -> bytes:
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
file_size = f.tell()
f.seek(0, os.SEEK_SET)
while f.tell() + 8 <= file_size:
off0 = f.tell()
hdr = f.read(8)
if len(hdr) < 8:
break
size = _u32be(hdr, 0)
typ = hdr[4:8]
hdr_size = 8
if size == 1:
ext = f.read(8)
if len(ext) < 8:
break
size = _u64be(ext, 0)
hdr_size = 16
elif size == 0:
size = file_size - off0
if size < hdr_size:
break
payload_size = int(size) - hdr_size
if typ == b"moov":
return f.read(payload_size)
f.seek(payload_size, os.SEEK_CUR)
raise ValueError("MP4: moov box not found")
def _mp4_pick_h264_video_track(moov: bytes) -> _Mp4H264Track:
moov_start = 0
moov_end = len(moov)
for t_trak, trak_ps, trak_pe in _iter_mp4_boxes(moov, moov_start, moov_end):
if t_trak != b"trak":
continue
mdia = _mp4_find_box(moov, trak_ps, trak_pe, b"mdia")
if mdia is None:
continue
mdia_ps, mdia_pe = mdia
hdlr = _mp4_find_box(moov, mdia_ps, mdia_pe, b"hdlr")
if hdlr is None:
continue
hdlr_ps, hdlr_pe = hdlr
hdlr_payload = moov[hdlr_ps:hdlr_pe]
if len(hdlr_payload) < 12 or hdlr_payload[8:12] != b"vide":
continue
minf = _mp4_find_box(moov, mdia_ps, mdia_pe, b"minf")
if minf is None:
continue
stbl = _mp4_find_box(moov, minf[0], minf[1], b"stbl")
if stbl is None:
continue
stbl_ps, stbl_pe = stbl
stsd = _mp4_find_box(moov, stbl_ps, stbl_pe, b"stsd")
stsz = _mp4_find_box(moov, stbl_ps, stbl_pe, b"stsz")
stsc = _mp4_find_box(moov, stbl_ps, stbl_pe, b"stsc")
stco = _mp4_find_box(moov, stbl_ps, stbl_pe, b"stco")
co64 = _mp4_find_box(moov, stbl_ps, stbl_pe, b"co64")
stss = _mp4_find_box(moov, stbl_ps, stbl_pe, b"stss")
if stsd is None or stsz is None or stsc is None or (stco is None and co64 is None):
continue
stsd_payload = moov[stsd[0]:stsd[1]]
if len(stsd_payload) < 8:
continue
entry_count = _u32be(stsd_payload, 4)
off = 8
found = False
nal_len_size = 4
sps_list: list[bytes] = []
pps_list: list[bytes] = []
for _ in range(entry_count):
if off + 8 > len(stsd_payload):
break
ent_size = _u32be(stsd_payload, off)
fmt = stsd_payload[off + 4:off + 8]
ent_end = off + int(ent_size)
if ent_size < 8 or ent_end > len(stsd_payload):
break
if fmt in (b"avc1", b"avc3"):
child_start = off + 8 + 78
if child_start < ent_end:
for t2, ps2, pe2 in _iter_mp4_boxes(stsd_payload, child_start, ent_end):
if t2 == b"avcC":
nal_len_size, sps_list, pps_list = _mp4_parse_avcc(stsd_payload[ps2:pe2])
found = True
break
elif fmt in (b"hvc1", b"hev1"):
raise ValueError("MP4 contains HEVC/H.265; device expects H.264")
if found:
break
off = ent_end
if not found:
continue
stsz_payload = moov[stsz[0]:stsz[1]]
if len(stsz_payload) < 12:
continue
fixed_size = _u32be(stsz_payload, 4)
sample_count = _u32be(stsz_payload, 8)
sample_sizes: list[int] = []
if fixed_size:
sample_sizes = [int(fixed_size)] * int(sample_count)
else:
need = 12 + int(sample_count) * 4
if len(stsz_payload) < need:
continue
off2 = 12
for _ in range(int(sample_count)):
sample_sizes.append(int(_u32be(stsz_payload, off2)))
off2 += 4
if stco is not None:
stco_payload = moov[stco[0]:stco[1]]
if len(stco_payload) < 8:
continue
n = _u32be(stco_payload, 4)
need = 8 + int(n) * 4
if len(stco_payload) < need:
continue
chunk_offsets = [int(_u32be(stco_payload, 8 + 4 * i)) for i in range(int(n))]
else:
co64_payload = moov[co64[0]:co64[1]] # type: ignore[index]
if len(co64_payload) < 8:
continue
n = _u32be(co64_payload, 4)
need = 8 + int(n) * 8
if len(co64_payload) < need:
continue
chunk_offsets = [int(_u64be(co64_payload, 8 + 8 * i)) for i in range(int(n))]
stsc_payload = moov[stsc[0]:stsc[1]]
if len(stsc_payload) < 8:
continue
n = _u32be(stsc_payload, 4)
need = 8 + int(n) * 12
if len(stsc_payload) < need:
continue
stsc_entries: list[tuple[int, int, int]] = []
off3 = 8
for _ in range(int(n)):
first_chunk = int(_u32be(stsc_payload, off3))
samples_per_chunk = int(_u32be(stsc_payload, off3 + 4))
desc_idx = int(_u32be(stsc_payload, off3 + 8))
stsc_entries.append((first_chunk, samples_per_chunk, desc_idx))
off3 += 12
stsc_entries.sort(key=lambda x: x[0])
sync_samples: Optional[Set[int]] = None
if stss is not None:
stss_payload = moov[stss[0]:stss[1]]
if len(stss_payload) >= 8:
n2 = _u32be(stss_payload, 4)
need = 8 + int(n2) * 4
if len(stss_payload) >= need:
sync_samples = set(int(_u32be(stss_payload, 8 + 4 * i)) for i in range(int(n2)))
return _Mp4H264Track(nal_len_size=int(nal_len_size), sps_list=sps_list, pps_list=pps_list,
sample_sizes=sample_sizes, chunk_offsets=chunk_offsets, stsc=stsc_entries, sync_samples=sync_samples, )
raise ValueError("MP4: no H.264 video track found")
def _mp4_iter_sample_locations(track: _Mp4H264Track) -> Iterable[tuple[int, int, int]]:
sizes = track.sample_sizes
sample_idx0 = 0
entries = track.stsc
entry_idx = 0
if not sizes:
return
for chunk_idx1, chunk_off in enumerate(track.chunk_offsets, start=1):
while (entry_idx + 1) < len(entries) and chunk_idx1 >= entries[entry_idx + 1][0]:
entry_idx += 1
samples_per_chunk = entries[entry_idx][1]
off = int(chunk_off)
for _ in range(samples_per_chunk):
if sample_idx0 >= len(sizes):
return
sz = int(sizes[sample_idx0])
yield sample_idx0 + 1, off, sz
off += sz
sample_idx0 += 1
def _mp4_extract_h264_annexb(in_path: str, out_path: str, *, repeat_headers: bool = True) -> None:
moov = _mp4_load_moov(in_path)
track = _mp4_pick_h264_video_track(moov)
start_code = b"\x00\x00\x00\x01"
spspps = b"".join(start_code + s for s in track.sps_list) + b"".join(start_code + p for p in track.pps_list)
if not spspps:
raise ValueError("MP4: missing SPS/PPS in avcC")
with open(in_path, "rb") as fin, open(out_path, "wb") as fout:
fout.write(spspps)
nls = int(track.nal_len_size)
if nls not in (1, 2, 3, 4):
raise ValueError(f"MP4: unsupported NAL length size: {nls}")
sync = track.sync_samples
for sample_no, off, sz in _mp4_iter_sample_locations(track):
if repeat_headers and sync is not None and sample_no in sync:
fout.write(spspps)
fin.seek(off, os.SEEK_SET)
data = fin.read(sz)
if len(data) != sz:
raise ValueError("MP4: truncated sample read")
pos = 0
end = len(data)
while pos + nls <= end:
nal_len = int.from_bytes(data[pos:pos + nls], "big")
pos += nls
if nal_len <= 0:
continue
if pos + nal_len > end:
raise ValueError("MP4: invalid NAL length in sample")
fout.write(start_code)
fout.write(data[pos:pos + nal_len])
pos += nal_len
def build_command_packet_header(a0: int) -> bytearray:
packet = bytearray(500)
packet[0] = a0
packet[2] = 0x1A
packet[3] = 0x6D
timestamp = int((time.time() - time.mktime(time.localtime()[:3] + (0, 0, 0, 0, 0, -1))) * 1000)
packet[4:8] = struct.pack('<I', timestamp)
return packet
def encrypt_with_des(key: bytes, data: bytes) -> bytes:
cipher = DES.new(key, DES.MODE_CBC, key)
padded_len = (len(data) + 7) // 8 * 8
padded_data = data.ljust(padded_len, b'\x00')
return cipher.encrypt(padded_data)
def encrypt_command_packet(data: bytearray) -> bytearray:
des_key = b'slv3tuzx'
encrypted = encrypt_with_des(des_key, data)
final_packet = bytearray(512)
final_packet[:len(encrypted)] = encrypted
final_packet[510] = 161
final_packet[511] = 26
return final_packet
def find_usb_device():
dev = None
dev_pid = None
for pid in PRODUCT_ID.keys():
try:
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=pid)
except usb.core.NoBackendError as e:
print("""[ERROR] %s: libusb could not be loaded from your system. Make sure it is installed.
On Linux and BSD, these will generally be available on the distribution's official repositories.
On macOS, libusb 1.0 can easily be installed through Homebrew: brew install libusb
On Windows, manually copy 'external/libusb-1.0/libusb-1.0.dll' to C:\\Windows\\System32""" % str(
e))
try:
sys.exit(0)
except:
os._exit(0)
dev_pid = pid
if dev is not None:
break
if dev is None:
raise ValueError(f'USB device not found')
try:
dev.set_configuration()
except usb.core.USBError as e:
print("Warning: set_configuration() failed:", e)
if platform.system() == "Linux":
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except usb.core.USBError as e:
print("Warning: detach_kernel_driver failed:", e)
return dev, dev_pid
def read_flush(ep_in, max_attempts=5):
"""
Flush the USB IN endpoint by reading available data until timeout or max attempts reached.
"""
for _ in range(max_attempts):
try:
ep_in.read(512, timeout=100)
except usb.core.USBError as e:
if e.errno == 110 or e.args[0] == 'Operation timed out':
break
else:
# print("Flush read error:", e)
break
def write_to_device(dev, data, timeout=2000):
cfg = dev.get_active_configuration()
intf = usb.util.find_descriptor(cfg, bInterfaceNumber=0)
if intf is None:
raise RuntimeError("USB interface 0 not found")
ep_out = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(
e.bEndpointAddress) == usb.util.ENDPOINT_OUT)
ep_in = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(
e.bEndpointAddress) == usb.util.ENDPOINT_IN)
assert ep_out is not None and ep_in is not None, "Could not find USB endpoints"
try:
ep_out.write(data, timeout)
except usb.core.USBError as e:
print("USB write error:", e)
return None
try:
response = ep_in.read(512, timeout)
read_flush(ep_in)
return bytes(response)
except usb.core.USBError as e:
print("USB read error:", e)
return None
def delay_sync(dev):
send_sync_command(dev)
time.sleep(0.2)
def send_sync_command(dev):
print("Sending Sync Command (ID 10)...")
cmd_packet = build_command_packet_header(10)
return write_to_device(dev, encrypt_command_packet(cmd_packet))
def send_restart_device_command(dev):
print("Sending Restart Command (ID 11)...")
return write_to_device(dev, encrypt_command_packet(build_command_packet_header(11)))
def send_brightness_command(dev, brightness: int):
print(f"Sending Brightness Command (ID 14)...")
print(f" Brightness = {brightness}")
cmd_packet = build_command_packet_header(14)
cmd_packet[8] = brightness
return write_to_device(dev, encrypt_command_packet(cmd_packet))
def send_frame_rate_command(dev, frame_rate: int):
print(f"Sending Frame Rate Command (ID 15)...")
print(f" Frame Rate = {frame_rate}")
cmd_packet = build_command_packet_header(15)
cmd_packet[8] = frame_rate
return write_to_device(dev, encrypt_command_packet(cmd_packet))
def format_bytes(val):
if val > 1024 * 1024:
return f"{val / (1024 * 1024):.2f} GB"
else:
return f"{val / 1024:.2f} MB"
def send_refresh_storage_command(dev):
print("Sending Refresh Storage Command (ID 100)...")
response = write_to_device(dev, encrypt_command_packet(build_command_packet_header(100)))
total = format_bytes(int.from_bytes(response[8:12], byteorder='little'))
used = format_bytes(int.from_bytes(response[12:16], byteorder='little'))
valid = format_bytes(int.from_bytes(response[16:20], byteorder='little'))
print(f" Card Total = {total}")
print(f" Card Used = {used}")
print(f" Card Valid = {valid}")
def send_save_settings_command(dev, brightness=0, startup=0, reserved=0, sleep=0, offline=0):
print("Sending Save Settings Command (ID 125)...")
print(f" Brightness: {brightness}")
print(f" Startup Mode: {startup}")
print(f" Reserved: {reserved}")
print(f" Sleep Timeout: {sleep}")
print(f" Offline Mode: {offline}")
cmd_packet = build_command_packet_header(125)
cmd_packet[8] = brightness
cmd_packet[9] = startup
cmd_packet[10] = reserved
cmd_packet[11] = 0
cmd_packet[12] = sleep
cmd_packet[13] = offline
return write_to_device(dev, encrypt_command_packet(cmd_packet))
def send_image(dev, png_data: bytes):
img_size = len(png_data)
cmd_packet = build_command_packet_header(CMD_UPLOAD_PNG)
cmd_packet[8] = (img_size >> 24) & 0xFF
cmd_packet[9] = (img_size >> 16) & 0xFF
cmd_packet[10] = (img_size >> 8) & 0xFF
cmd_packet[11] = img_size & 0xFF
full_payload = encrypt_command_packet(cmd_packet) + png_data
return write_to_device(dev, full_payload)
def clear_image(dev):
img_data = bytearray(
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00,
0x01, 0xe0, 0x00, 0x00, 0x07, 0x80, 0x08, 0x06, 0x00, 0x00, 0x00, 0x16, 0xf0, 0x84, 0xf5, 0x00, 0x00, 0x00,
0x01, 0x73, 0x52, 0x47, 0x42, 0x00, 0xae, 0xce, 0x1c, 0xe9, 0x00, 0x00, 0x00, 0x04, 0x67, 0x41, 0x4d, 0x41,
0x00, 0x00, 0xb1, 0x8f, 0x0b, 0xfc, 0x61, 0x05, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00,
0x0e, 0xc3, 0x00, 0x00, 0x0e, 0xc3, 0x01, 0xc7, 0x6f, 0xa8, 0x64, 0x00, 0x00, 0x0e, 0x0c, 0x49, 0x44, 0x41,
0x54, 0x78, 0x5e, 0xed, 0xc1, 0x01, 0x0d, 0x00, 0x00, 0x00, 0xc2, 0xa0, 0xf7, 0x4f, 0x6d, 0x0f, 0x07, 0x14,
0x00, 0x00, 0x00, 0x00, ] + [0x00] * 3568 + [0x00, 0xf0, 0x66, 0x4a, 0xc8, 0x00, 0x01, 0x11, 0x9d, 0x82, 0x0a,
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60,
0x82])
img_size = len(img_data)
print(f" Chunk Size: {img_size} bytes")
cmd_packet = build_command_packet_header(102)
cmd_packet[8] = (img_size >> 24) & 0xFF
cmd_packet[9] = (img_size >> 16) & 0xFF
cmd_packet[10] = (img_size >> 8) & 0xFF
cmd_packet[11] = img_size & 0xFF
full_payload = encrypt_command_packet(cmd_packet) + img_data
return write_to_device(dev, full_payload)
def delay(dev, rst):
time.sleep(0.05)
print("Sending Delay Command (ID 122)...")
cmd_packet = build_command_packet_header(122)
response = write_to_device(dev, encrypt_command_packet(cmd_packet))
if response and len(response) > 8 and response[8] > rst:
delay(dev, rst)
def extract_h264_from_mp4(mp4_path: str):
input_path = Path(mp4_path)
if not input_path.exists():
raise FileNotFoundError(f"Input file not found: {input_path}")
output_path = input_path.with_suffix(".h264")
if output_path.exists():
print(f"{output_path.name} already exists. Skipping extraction.")
return output_path
# Prefer ffmpeg when available (fast + robust). Fall back to pure-Python MP4->Annex-B extraction.
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
cmd = [ffmpeg, "-y", "-i", str(input_path), "-c:v", "copy", "-bsf:v", "h264_mp4toannexb", "-an", "-f", "h264",
str(output_path), ]
print(f"Extracting H.264 from {input_path.name} with ffmpeg...")
subprocess.run(cmd, check=True)
print(f"Done. Saved as {output_path.name}")
return output_path
print(f"ffmpeg not found; extracting H.264 from {input_path.name} with built-in MP4 parser...")
_mp4_extract_h264_annexb(str(input_path), str(output_path), repeat_headers=True)
print(f"Done. Saved as {output_path.name}")
return output_path
def send_video(dev, video_path, loop=False):
output_path = extract_h264_from_mp4(video_path)
write_to_device(dev, encrypt_command_packet(build_command_packet_header(111)))
write_to_device(dev, encrypt_command_packet(build_command_packet_header(112)))
write_to_device(dev, encrypt_command_packet(build_command_packet_header(13)))
send_brightness_command(dev, 32) # 14
write_to_device(dev, encrypt_command_packet(build_command_packet_header(41)))
clear_image(dev) # 102
send_frame_rate_command(dev, 25) # 15
# Negotiate chunk size if supported
resp = write_to_device(dev, encrypt_command_packet(build_command_packet_header(CMD_GET_H264_CHUNK_SIZE)))
chunk_size = 202752
try:
if resp and len(resp) >= 12:
negotiated = int.from_bytes(resp[8:12], byteorder="big", signed=False)
if 0 < negotiated <= 1024 * 1024:
chunk_size = negotiated
except Exception:
pass
print("Sending Send Video Command (ID 121)...")
try:
while True:
with open(output_path, "rb") as f:
while True:
data = f.read(chunk_size)
if not data:
break
chunksize = len(data)
is_last = f.tell() == os.path.getsize(output_path)
cmd_packet = build_command_packet_header(CMD_PLAY_H264_CHUNK)
cmd_packet[8] = (chunksize >> 24) & 0xFF
cmd_packet[9] = (chunksize >> 16) & 0xFF
cmd_packet[10] = (chunksize >> 8) & 0xFF
cmd_packet[11] = chunksize & 0xFF
if is_last:
cmd_packet[12] = 1
full_payload = encrypt_command_packet(cmd_packet) + data
response = write_to_device(dev, full_payload)
# Flow control (queue depth is usually reported in response[8] to cmd 122)
if response is None:
delay(dev, 2)
else:
# Poll stream status when queue is high
st = write_to_device(dev,
encrypt_command_packet(build_command_packet_header(CMD_GET_STREAM_STATUS)))
if st and len(st) > 8 and st[8] > 3:
delay(dev, 2)
print("Video sent successfully.")
if not loop:
break
except KeyboardInterrupt:
print("\nLoop interrupted by user. Sending reset...")
finally:
write_to_device(dev, encrypt_command_packet(build_command_packet_header(CMD_STOP_STREAM)))
def _encode_png(image: Image.Image) -> bytes:
buffer = BytesIO()
image.save(buffer, format="PNG", compress_level=9)
return buffer.getvalue()
def upload_file(dev, file_path: str) -> bool:
local_path = Path(file_path)
if not local_path.exists():
logger.error("Error: File does not exist: %s", file_path)
return False
ext = local_path.suffix.lower()
if ext == ".png":
device_path = f"/tmp/sdcard/mmcblk0p1/img/{local_path.name}"
logger.info("Uploading PNG: %s%s", file_path, device_path)
elif ext == ".mp4":
h264_path = extract_h264_from_mp4(file_path)
device_path = f"/tmp/sdcard/mmcblk0p1/video/{h264_path.name}"
local_path = h264_path # Update local path to .h264
logger.info("Uploading MP4 as H264: %s%s", local_path, device_path)
else:
logger.error("Error: Unsupported file type. Only .png and .mp4 are allowed.")
return False
if not _open_file_command(dev, device_path):
logger.error("Failed to open remote file for writing.")
return False
if not _write_file_command(dev, str(local_path)):
logger.error("Failed to write file data.")
return False
logger.info("Upload completed successfully.")
return True
def _open_file_command(dev, path: str):
logger.info("Opening remote file: %s", path)
path_bytes = path.encode("ascii")
length = len(path_bytes)
packet = build_command_packet_header(38)
packet[8] = (length >> 24) & 0xFF
packet[9] = (length >> 16) & 0xFF
packet[10] = (length >> 8) & 0xFF
packet[11] = length & 0xFF
packet[12:16] = b"\x00\x00\x00\x00"
packet[16: 16 + length] = path_bytes
return write_to_device(dev, encrypt_command_packet(packet))
def _delete_command(dev, file_path: str):
logger.info("Deleting remote file: %s", file_path)
path_bytes = file_path.encode("ascii")
length = len(path_bytes)
packet = build_command_packet_header(40)
packet[8] = (length >> 24) & 0xFF
packet[9] = (length >> 16) & 0xFF
packet[10] = (length >> 8) & 0xFF
packet[11] = length & 0xFF
packet[12:16] = b"\x00\x00\x00\x00"
packet[16: 16 + length] = path_bytes
return write_to_device(dev, encrypt_command_packet(packet))
def _play_command(dev, file_path: str):
logger.info("Requesting playback for: %s", file_path)
path_bytes = file_path.encode("ascii")
length = len(path_bytes)
packet = build_command_packet_header(98)
packet[8] = (length >> 24) & 0xFF
packet[9] = (length >> 16) & 0xFF
packet[10] = (length >> 8) & 0xFF
packet[11] = length & 0xFF
packet[12:16] = b"\x00\x00\x00\x00"
packet[16: 16 + length] = path_bytes
return write_to_device(dev, encrypt_command_packet(packet))
def _play2_command(dev, file_path: str):
logger.info("Requesting alternate playback for: %s", file_path)
path_bytes = file_path.encode("ascii")
length = len(path_bytes)
packet = build_command_packet_header(110)
packet[8] = (length >> 24) & 0xFF
packet[9] = (length >> 16) & 0xFF
packet[10] = (length >> 8) & 0xFF
packet[11] = length & 0xFF
packet[12:16] = b"\x00\x00\x00\x00"
packet[16: 16 + length] = path_bytes
return write_to_device(dev, encrypt_command_packet(packet))
def _play3_command(dev, file_path: str):
logger.info("Requesting image playback for: %s", file_path)
path_bytes = file_path.encode("ascii")
length = len(path_bytes)
packet = build_command_packet_header(113)
packet[8] = (length >> 24) & 0xFF
packet[9] = (length >> 16) & 0xFF
packet[10] = (length >> 8) & 0xFF
packet[11] = length & 0xFF
packet[12:16] = b"\x00\x00\x00\x00"
packet[16: 16 + length] = path_bytes
return write_to_device(dev, encrypt_command_packet(packet))
def _write_file_command(dev, file_path: str) -> bool:
logger.info("Writing remote file from: %s", file_path)
try:
total_size = Path(file_path).stat().st_size
sent = 0
chunk_index = 0
preferred_cap = min(1024 * 1024, MAX_CHUNK_BYTES)
with open(file_path, "rb") as fh:
while True:
data_chunk = fh.read(preferred_cap)
if not data_chunk:
break
chunk_index += 1
chunk_len = len(data_chunk)
sent += chunk_len
is_last = sent >= total_size
# [8..11]=chunk_capacity, [12..15]=chunk_len, [16]=last_flag, payload=chunk
cmd_packet = build_command_packet_header(39)
cap = preferred_cap
cmd_packet[8] = (cap >> 24) & 0xFF
cmd_packet[9] = (cap >> 16) & 0xFF
cmd_packet[10] = (cap >> 8) & 0xFF
cmd_packet[11] = cap & 0xFF
cmd_packet[12] = (chunk_len >> 24) & 0xFF
cmd_packet[13] = (chunk_len >> 16) & 0xFF
cmd_packet[14] = (chunk_len >> 8) & 0xFF
cmd_packet[15] = chunk_len & 0xFF
if is_last:
cmd_packet[16] = 1
response = write_to_device(dev, encrypt_command_packet(cmd_packet) + data_chunk)
# Fallback: legacy layout uses [8..11]=chunk_len only
if response is None or (not _resp_ok(response)):
legacy_packet = build_command_packet_header(39)
legacy_packet[8] = (chunk_len >> 24) & 0xFF
legacy_packet[9] = (chunk_len >> 16) & 0xFF
legacy_packet[10] = (chunk_len >> 8) & 0xFF
legacy_packet[11] = chunk_len & 0xFF
response = write_to_device(dev, encrypt_command_packet(legacy_packet) + data_chunk)
if response is None:
logger.error("Write command failed at chunk %d", chunk_index)
return False
logger.info("File write completed successfully (%d chunks).", chunk_index)
return True
except FileNotFoundError:
logger.error("File not found: %s", file_path)
return False
except Exception as exc:
logger.error("Error writing file: %s", exc)
return False
# This class is for Turing Smart Screen newer models (4.6" / 5.2" / 8" / 8.8" HW rev 1.x / 9.2" / 12.3")
# These models are not detected as serial ports but as (Win)USB devices
class LcdCommTuringUSB(LcdComm):
def __init__(self, com_port: str = "AUTO", display_width: int = 480, display_height: int = 1920,
update_queue: Optional[queue.Queue] = None):
super().__init__(com_port, display_width, display_height, update_queue)
self.dev, self.dev_pid = find_usb_device()
self.display_width, self.display_height = PRODUCT_ID[self.dev_pid]
# Store the current screen state as an image that will be continuously updated and sent
self.current_state = Image.new("RGBA", (self.get_width(), self.get_height()), (0, 0, 0, 0))
def InitializeComm(self):
send_sync_command(self.dev)
def Reset(self):
# Do not enable the reset command for now on Turing USB models
# send_restart_device_command(self.dev)
pass
def Clear(self):
clear_image(self.dev)
def ScreenOff(self):
# Turing USB models do not implement a "screen off" command (that we know of): use SetBrightness(0) instead
self.Clear()
self.SetBrightness(0)
def ScreenOn(self):
# Turing USB models do not implement a "screen off" command (that we know of): using SetBrightness() instead
self.SetBrightness()
def SetBrightness(self, level: int = 25):
assert 0 <= level <= 100, 'Brightness level must be [0-100]'
converted = int(level / 100 * 102)
send_brightness_command(self.dev, converted)
def SetOrientation(self, orientation: Orientation):
self.orientation = orientation
# Recreate new state with correct width/height now that screen orientation has changed
self.current_state = Image.new("RGBA", (self.get_width(), self.get_height()), (0, 0, 0, 0))
def DisplayPILImage(self, image: Image.Image, x: int = 0, y: int = 0, image_width: int = 0, image_height: int = 0):
# If the image height/width isn't provided, use the native image size
if not image_height:
image_height = image.size[1]
if not image_width:
image_width = image.size[0]
if image.size[1] > self.get_height():
image_height = self.get_height()
if image.size[0] > self.get_width():
image_width = self.get_width()
if image_width != image.size[0] or image_height != image.size[1]:
image = image.crop((0, 0, image_width, image_height))
# Paste new image over existing screen state
self.current_state.paste(image, (x, y))
# Send image data (auto JPEG fallback when payload exceeds device limit)
send_pil_image_auto(self.dev, self.current_state, max_bytes=MAX_IMAGE_PAYLOAD_DEFAULT)

View File

@@ -0,0 +1,38 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
# https://github.com/mathoudebine/turing-smart-screen-python/
#
# Copyright (C) 2021 Matthieu Houdebine (mathoudebine)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# Configure logging format
import locale
import logging
from logging.handlers import RotatingFileHandler
# use current locale for date/time formatting in logs
locale.setlocale(locale.LC_ALL, '')
logging.basicConfig( # format='%(asctime)s [%(levelname)s] %(message)s in %(pathname)s:%(lineno)d',
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
RotatingFileHandler("log.log", maxBytes=1000000, backupCount=0), # Log in textfile max 1MB
logging.StreamHandler() # Log also in console
],
datefmt='%x %X')
logger = logging.getLogger('turing')
logger.setLevel(logging.DEBUG) # Lowest log level : print all messages

View File

@@ -0,0 +1,51 @@
# Carrot Cluster
Standalone raylib cluster UI bundle for openpilot devices.
Run from the openpilot root:
```bash
python selfdrive/carrot/cluster_run.py --output usb
python selfdrive/carrot/cluster_run.py --output usb --profile-render
```
Useful options:
```bash
python selfdrive/carrot/cluster_run.py --output window --width 1920 --height 480
python selfdrive/carrot/cluster_run.py --output usb --live-no-can
python selfdrive/carrot/cluster_run.py --output usb --usb-codec jpeg --usb-jpeg-quality 68
python selfdrive/carrot/cluster_run.py --output usb --fps 10 --usb-jpeg-quality 55 --route-overlay off
python selfdrive/carrot/cluster_run.py --output usb --profile-render --profile-interval 2
```
`--usb-jpeg-encoder auto` tries optional `turbojpeg` first and falls back to
Pillow. Route replay defaults to `--route-overlay compact`, which shows the
right-side qcamera/debug panel. Use `--route-overlay off` for performance tests
that should match live rendering cost more closely.
Manager autostart uses `--fps 20` by default through `CLUSTER_AUTORUN_FPS=20`.
Set `CLUSTER_AUTORUN_FPS=0` only for uncapped performance tests.
Set it to an empty value if autostart should follow `ClusterHudLiveFps`
setting changes while running.
The launcher defaults to `--input live`, subscribes to openpilot cereal services,
and renders live `carState`, `modelV2`, `radarState`, `liveTracks`, and raw
Hyundai CAN-FD radar points when CAN subscription is enabled.
When `--fps` is omitted for live input, `ClusterHudLiveFps` controls the render
limit and is polled about once per second while running: `0` uncapped,
`1` 10 Hz, `2` 20 Hz, and `3` 30 Hz. Explicit `--fps` remains a fixed
override.
The bundled TURZX code includes only the Python vendor library. The openpilot
device uses the system `libusb-1.0.so` through `pyusb`.
The renderer prefers
`/data/openpilot/selfdrive/assets/fonts/KaiGenGothicKR-Bold.ttf` for HUD text.
It falls back to the bundled/addon KaiGen copy, then JetBrainsMono and
system/platform fonts if KaiGen is not present.
USB frame upload runs in no-ACK mode by default because some TURZX panels accept
image data but never return a frame-upload response. Use `--usb-wait-frame-ack`
only when testing a panel/driver combination known to reply after each frame.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,72 @@
# Generated for carrotpilot-cluster raylib rendering.
newmtl body
Ka 0.55 0.58 0.60
Kd 0.62 0.66 0.68
Ks 0.18 0.18 0.18
Ns 28
newmtl wheel
Ka 0.04 0.04 0.04
Kd 0.06 0.065 0.07
Ks 0.05 0.05 0.05
Ns 16
newmtl besi_roda
Ka 0.10 0.10 0.10
Kd 0.13 0.14 0.15
Ks 0.10 0.10 0.10
Ns 18
newmtl light
Ka 0.70 0.86 1.00
Kd 0.74 0.88 1.00
Ks 0.12 0.12 0.12
Ns 24
newmtl stop_light
Ka 0.80 0.08 0.06
Kd 0.90 0.10 0.08
Ks 0.10 0.02 0.02
Ns 18
newmtl riting
Ka 0.90 0.48 0.05
Kd 1.00 0.58 0.08
Ks 0.10 0.06 0.02
Ns 18
newmtl Material
Ka 0.45 0.46 0.47
Kd 0.54 0.56 0.58
Ks 0.10 0.10 0.10
Ns 18
newmtl Material.002
Ka 0.22 0.23 0.24
Kd 0.28 0.30 0.32
Ks 0.06 0.06 0.06
Ns 14
newmtl Material.003
Ka 0.04 0.04 0.04
Kd 0.06 0.065 0.07
Ks 0.04 0.04 0.04
Ns 12
newmtl Material.004
Ka 0.04 0.04 0.04
Kd 0.06 0.065 0.07
Ks 0.04 0.04 0.04
Ns 12
newmtl Material.005
Ka 0.04 0.04 0.04
Kd 0.06 0.065 0.07
Ks 0.04 0.04 0.04
Ns 12
newmtl Material.006
Ka 0.04 0.04 0.04
Kd 0.06 0.065 0.07
Ks 0.04 0.04 0.04
Ns 12

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,240 @@
from __future__ import annotations
import time
from dataclasses import dataclass
DESIGN_WIDTH = 1920
DESIGN_HEIGHT = 480
Color3 = tuple[int, int, int]
Color4 = tuple[int, int, int, int]
@dataclass(frozen=True, slots=True)
class ClusterTheme:
name: str
is_dark: bool
bg: Color3
panel_bg: Color3
text: Color3
muted: Color3
faint: Color3
road: Color3
road_edge: Color3
lane_marking_border: Color4
road_edge_backing: Color4
path_shadow: Color4
path_uncertainty: Color4
path_body: Color4
path_highlight: Color4
world_label_shadow: Color3
world_label_text: Color3
clock_bg: Color4
clock_outline: Color4
clock_text: Color3
gauge_bg: Color3
gauge_midline: Color3
inactive_signal_fill: Color4
inactive_signal_outline: Color3
route_panel_bg: Color3
route_video_bg: Color3
route_video_status: Color3
primary_vehicle: Color3
model_vehicle: Color3
default_vehicle: Color3
CLUSTER_THEME_AUTO = 0
CLUSTER_THEME_DARK = 1
CLUSTER_THEME_LIGHT = 2
CLUSTER_THEME_PARAM = "ClusterHudTheme"
CLUSTER_LIVE_FPS_PARAM = "ClusterHudLiveFps"
AUTO_DARK_START_HOUR = 18
AUTO_LIGHT_START_HOUR = 6
LIGHT_CLUSTER_THEME = ClusterTheme(
name="light",
is_dark=False,
bg=(244, 246, 248),
panel_bg=(250, 251, 252),
text=(20, 24, 28),
muted=(104, 112, 120),
faint=(214, 220, 226),
road=(218, 222, 226),
road_edge=(108, 122, 138),
lane_marking_border=(54, 62, 70, 205),
road_edge_backing=(72, 82, 92, 118),
path_shadow=(56, 72, 88, 70),
path_uncertainty=(112, 169, 255, 74),
path_body=(34, 126, 255, 220),
path_highlight=(222, 239, 255, 238),
world_label_shadow=(245, 248, 252),
world_label_text=(8, 10, 12),
clock_bg=(8, 10, 12, 150),
clock_outline=(255, 255, 255, 72),
clock_text=(255, 255, 255),
gauge_bg=(232, 236, 240),
gauge_midline=(88, 96, 104),
inactive_signal_fill=(195, 202, 209, 92),
inactive_signal_outline=(168, 176, 184),
route_panel_bg=(248, 250, 252),
route_video_bg=(18, 20, 22),
route_video_status=(212, 218, 224),
primary_vehicle=(50, 66, 82),
model_vehicle=(88, 100, 112),
default_vehicle=(70, 78, 88),
)
DARK_CLUSTER_THEME = ClusterTheme(
name="dark",
is_dark=True,
bg=(7, 10, 14),
panel_bg=(18, 23, 29),
text=(238, 242, 247),
muted=(150, 160, 172),
faint=(66, 76, 88),
road=(30, 36, 43),
road_edge=(118, 138, 158),
lane_marking_border=(3, 6, 10, 205),
road_edge_backing=(4, 8, 12, 196),
path_shadow=(0, 0, 0, 110),
path_uncertainty=(92, 154, 255, 82),
path_body=(48, 146, 255, 230),
path_highlight=(220, 238, 255, 245),
world_label_shadow=(0, 0, 0),
world_label_text=(238, 242, 247),
clock_bg=(0, 0, 0, 172),
clock_outline=(238, 242, 247, 72),
clock_text=(255, 255, 255),
gauge_bg=(18, 23, 29),
gauge_midline=(98, 112, 128),
inactive_signal_fill=(74, 86, 100, 92),
inactive_signal_outline=(94, 108, 124),
route_panel_bg=(16, 20, 25),
route_video_bg=(5, 8, 12),
route_video_status=(184, 194, 206),
primary_vehicle=(92, 112, 134),
model_vehicle=(108, 122, 138),
default_vehicle=(84, 96, 110),
)
def normalize_cluster_theme_mode(value: object) -> str:
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("auto", "dark", "light"):
return normalized
try:
value = int(normalized)
except ValueError:
return "auto"
if value == CLUSTER_THEME_DARK:
return "dark"
if value == CLUSTER_THEME_LIGHT:
return "light"
return "auto"
def normalize_cluster_live_fps(value: object) -> float:
if isinstance(value, str):
normalized = value.strip()
try:
value = int(normalized)
except ValueError:
return 0.0
try:
mode = int(value)
except (TypeError, ValueError):
return 0.0
if mode == 1:
return 10.0
if mode == 2:
return 20.0
if mode == 3:
return 30.0
return 0.0
def current_cluster_theme(mode: object = "auto", now: float | None = None) -> ClusterTheme:
normalized = normalize_cluster_theme_mode(mode)
if normalized == "dark":
return DARK_CLUSTER_THEME
if normalized == "light":
return LIGHT_CLUSTER_THEME
local_hour = time.localtime(now).tm_hour if now is not None else time.localtime().tm_hour
if local_hour >= AUTO_DARK_START_HOUR or local_hour < AUTO_LIGHT_START_HOUR:
return DARK_CLUSTER_THEME
return LIGHT_CLUSTER_THEME
BG = LIGHT_CLUSTER_THEME.bg
PANEL_BG = LIGHT_CLUSTER_THEME.panel_bg
TEXT = LIGHT_CLUSTER_THEME.text
MUTED = LIGHT_CLUSTER_THEME.muted
FAINT = LIGHT_CLUSTER_THEME.faint
ROAD = LIGHT_CLUSTER_THEME.road
ROAD_EDGE = LIGHT_CLUSTER_THEME.road_edge
WHITE = (255, 255, 255)
BLUE = (38, 132, 255)
BLUE_SOFT = (168, 207, 255)
GREEN = (20, 188, 104)
AMBER = (244, 172, 54)
RED = (222, 72, 64)
EGO = (32, 89, 179)
CAR_DARK = LIGHT_CLUSTER_THEME.default_vehicle
MAX_SPEED_KPH = 140.0
MAX_ACCEL_MPS2 = 5.0
CONTROLLER_ACCEL_MPS2 = 3.2
CONTROLLER_BRAKE_MPS2 = 5.0
COAST_DECEL_MPS2 = 0.18
DRAG_DECEL_PER_MPS = 0.012
LANE_CHANGE_SECONDS = 4.2
LANE_CHANGE_MIN_SECONDS = 2.2
LANE_CHANGE_MAX_SECONDS = 4.8
LANE_RECENTER_SECONDS = 1.35
MODEL_DIRECT_LANE_RECENTER_SECONDS = 0.85
DEFAULT_LANE_WIDTH_M = 3.6
MAX_STEERING_ANGLE_DEG = 45.0
TURN_SIGNAL_SECONDS = 5.4
TURN_SIGNAL_BLINK_PERIOD_SECONDS = 1.0
TURN_SIGNAL_BLINK_ON_SECONDS = TURN_SIGNAL_BLINK_PERIOD_SECONDS * 0.5
CAMERA_CENTER_X = 1050.0
CAMERA_HORIZON_Y = 30.0
CAMERA_HEIGHT_M = 1.45
CAMERA_FOCAL_X = 240.0
CAMERA_FOCAL_Y = 1050.0
ROAD_NEAR_M = 0.75
ROAD_FAR_M = 90.0
ROAD_CURVE_M_PER_M2 = 0.0042
EGO_FORWARD_M = 4.18
PATH_START_M = 6.70
PATH_END_M = 72.0
PATH_HEIGHT_M = 0.10
PATH_LANE_CHANGE_CURVE_START_M = 6.70
PATH_LANE_CHANGE_CURVE_END_M = 15.50
SURROUND_MAX_YAW_DEG = 180.0
SURROUND_MAX_PITCH_DEG = 18.0
SURROUND_VIEW_SMOOTH_SECONDS = 0.16
SURROUND_CAMERA_DISTANCE_M = 6.3
SURROUND_CAMERA_HEIGHT_M = 2.65
SURROUND_TARGET_FORWARD_M = 7.6
SURROUND_TARGET_HEIGHT_M = 0.25
SURROUND_CENTER_Y = 265.0
SURROUND_FOCAL_X = 315.0
SURROUND_FOCAL_Y = 355.0
SURROUND_ROAD_REAR_M = -70.0
SURROUND_ROAD_FRONT_M = 115.0
SURROUND_ROAD_STEPS = 96
SURROUND_ROAD_NEAR_DEPTH_M = 0.75
VEHICLE_WIDTH_M = 1.82
VEHICLE_LENGTH_M = 4.35
VEHICLE_SURROUND_WIDTH_M = 1.05
VEHICLE_SURROUND_LENGTH_M = 1.85
VEHICLE_HEIGHT_M = 1.35
VEHICLE_SURROUND_HEIGHT_MULTIPLIER = 3.0
VEHICLE_LANE_CHANGE_SLOPE = 0.0
VEHICLE_AA_SCALE = 3
VEHICLE_CORNER_RADIUS_PX = 7.5

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
import time
from typing import Any
from cluster_config import SURROUND_MAX_PITCH_DEG, SURROUND_MAX_YAW_DEG
from cluster_models import SimulatorInput
TRIGGER_DEADZONE = 0.03
STEERING_DEADZONE = 0.06
VIEW_ROTATION_DEADZONE = 0.08
GAMEPAD_WARMUP_SECONDS = 0.6
LEFT_SIGNAL_BUTTONS = (4, 9, 13)
RIGHT_SIGNAL_BUTTONS = (5, 10, 14)
def normalize_signed_axis(axis_value: float | int) -> float:
value = float(axis_value)
if value < -1.0 or value > 1.0:
value = value / 32767.0 if value >= 0 else value / 32768.0
return max(-1.0, min(1.0, value))
def normalize_trigger_axis(axis_value: float | int) -> float:
value = float(axis_value)
if value < -1.0 or value > 1.0:
if value < 0.0:
value = (value + 32768.0) / 65535.0
else:
value = value / 32767.0
elif value < 0.0:
value = (value + 1.0) * 0.5
if value < TRIGGER_DEADZONE:
return 0.0
return max(0.0, min(1.0, value))
def normalize_stick(axis_value: float) -> float:
if abs(axis_value) < STEERING_DEADZONE:
return 0.0
return max(-1.0, min(1.0, axis_value))
def normalize_view_axis(axis_value: float) -> float:
if abs(axis_value) < VIEW_ROTATION_DEADZONE:
return 0.0
return max(-1.0, min(1.0, axis_value))
class DualSenseSimulator:
def __init__(self, controller_index: int):
import pygame
self.pygame: Any = pygame
self.controller: Any | None = None
self.joystick: Any | None = None
self.using_controller_api = False
pygame.init()
pygame.joystick.init()
count = pygame.joystick.get_count()
if count == 0:
pygame.quit()
raise SystemExit("No gamepad found. Use --input random to run without a controller.")
try:
from pygame._sdl2 import controller as sdl_controller
sdl_controller.init()
if sdl_controller.is_controller(controller_index):
self.controller = sdl_controller.Controller(controller_index)
self.using_controller_api = True
except Exception:
self.controller = None
self.using_controller_api = False
if self.controller is None:
self.joystick = pygame.joystick.Joystick(controller_index)
self.joystick.init()
self.throttle = 0.0
self.brake = 0.0
self.steering = 0.0
self.view_rotate_x = 0.0
self.view_rotate_y = 0.0
self.left_signal_requested = False
self.right_signal_requested = False
self.warmup_until = time.perf_counter() + GAMEPAD_WARMUP_SECONDS
def close(self) -> None:
if self.controller is not None:
self.controller.quit()
if self.joystick is not None:
self.joystick.quit()
self.pygame.quit()
def _button_down(self, button_indexes: tuple[int, ...]) -> bool:
if self.using_controller_api:
return self._controller_button_down(button_indexes)
return any(index < self.joystick.get_numbuttons() and self.joystick.get_button(index) for index in button_indexes)
def _controller_button_down(self, button_indexes: tuple[int, ...]) -> bool:
return any(self.controller.get_button(index) for index in button_indexes)
def _hat_left_down(self) -> bool:
if self.joystick is None or self.joystick.get_numhats() == 0:
return False
return self.joystick.get_hat(0)[0] < 0
def _hat_right_down(self) -> bool:
if self.joystick is None or self.joystick.get_numhats() == 0:
return False
return self.joystick.get_hat(0)[0] > 0
def _read_motion(self) -> tuple[float, float, float]:
if self.using_controller_api:
steering = normalize_stick(
normalize_signed_axis(self.controller.get_axis(self.pygame.CONTROLLER_AXIS_LEFTX))
)
brake = normalize_trigger_axis(
self.controller.get_axis(self.pygame.CONTROLLER_AXIS_TRIGGERLEFT)
)
throttle = normalize_trigger_axis(
self.controller.get_axis(self.pygame.CONTROLLER_AXIS_TRIGGERRIGHT)
)
return throttle, brake, steering
steering = normalize_stick(
normalize_signed_axis(self.joystick.get_axis(0) if self.joystick.get_numaxes() > 0 else 0.0)
)
brake = normalize_trigger_axis(
self.joystick.get_axis(4) if self.joystick.get_numaxes() > 4 else 0.0
)
throttle = normalize_trigger_axis(
self.joystick.get_axis(5) if self.joystick.get_numaxes() > 5 else 0.0
)
return throttle, brake, steering
def _read_view_rotation(self) -> tuple[float, float]:
if self.using_controller_api:
x_axis = normalize_view_axis(
normalize_signed_axis(self.controller.get_axis(self.pygame.CONTROLLER_AXIS_RIGHTX))
)
y_axis = normalize_view_axis(
normalize_signed_axis(self.controller.get_axis(self.pygame.CONTROLLER_AXIS_RIGHTY))
)
return x_axis, y_axis
if self.joystick is None:
return 0.0, 0.0
x_axis = normalize_view_axis(
normalize_signed_axis(self.joystick.get_axis(2) if self.joystick.get_numaxes() > 2 else 0.0)
)
y_axis = normalize_view_axis(
normalize_signed_axis(self.joystick.get_axis(3) if self.joystick.get_numaxes() > 3 else 0.0)
)
return x_axis, y_axis
def _read_signal_buttons(self) -> tuple[bool, bool]:
if self.using_controller_api:
left = any(
self.controller.get_button(button)
for button in (
self.pygame.CONTROLLER_BUTTON_DPAD_LEFT,
self.pygame.CONTROLLER_BUTTON_LEFTSHOULDER,
)
)
right = any(
self.controller.get_button(button)
for button in (
self.pygame.CONTROLLER_BUTTON_DPAD_RIGHT,
self.pygame.CONTROLLER_BUTTON_RIGHTSHOULDER,
)
)
return left, right
left = self._button_down(LEFT_SIGNAL_BUTTONS) or self._hat_left_down()
right = self._button_down(RIGHT_SIGNAL_BUTTONS) or self._hat_right_down()
return left, right
def read_input(self) -> SimulatorInput:
self.pygame.event.pump()
self.throttle, self.brake, self.steering = self._read_motion()
self.view_rotate_x, self.view_rotate_y = self._read_view_rotation()
self.left_signal_requested, self.right_signal_requested = self._read_signal_buttons()
if time.perf_counter() < self.warmup_until:
self.throttle = 0.0
self.brake = 0.0
self.steering = 0.0
self.view_rotate_x = 0.0
self.view_rotate_y = 0.0
self.left_signal_requested = False
self.right_signal_requested = False
return SimulatorInput(
throttle=self.throttle,
brake=self.brake,
steering=self.steering,
surround_yaw_deg=self.view_rotate_x * SURROUND_MAX_YAW_DEG,
surround_pitch_deg=-self.view_rotate_y * SURROUND_MAX_PITCH_DEG,
surround_view_active=self.view_rotate_x != 0.0 or self.view_rotate_y != 0.0,
left_signal_requested=self.left_signal_requested,
right_signal_requested=self.right_signal_requested,
)
def status_text(self) -> str:
left = "L" if self.left_signal_requested else "-"
right = "R" if self.right_signal_requested else "-"
return (
f"R2={self.throttle:.2f} L2={self.brake:.2f} "
f"LSX={self.steering:+.2f} RS={self.view_rotate_x:+.2f},{self.view_rotate_y:+.2f} "
f"SIG={left}{right}"
)

View File

@@ -0,0 +1,199 @@
from __future__ import annotations
import os
import subprocess
import threading
import time
from pathlib import Path
from cluster_models import GitBranchStatus
GIT_STATUS_REFRESH_SECONDS = 60.0
GIT_COMMAND_TIMEOUT_SECONDS = 4.0
def find_git_root(start: Path) -> Path | None:
path = start.resolve()
candidates = (path, *path.parents)
for candidate in candidates:
if (candidate / ".git").exists():
return candidate
return None
def resolve_git_dir(repo_path: Path) -> Path | None:
git_path = repo_path / ".git"
if git_path.is_dir():
return git_path
if not git_path.is_file():
return None
try:
text = git_path.read_text(encoding="utf-8", errors="replace").strip()
except OSError:
return None
prefix = "gitdir:"
if not text.lower().startswith(prefix):
return None
target = Path(text[len(prefix) :].strip())
if not target.is_absolute():
target = repo_path / target
return target
def read_head_branch(repo_path: Path) -> str | None:
git_dir = resolve_git_dir(repo_path)
if git_dir is None:
return None
try:
head = (git_dir / "HEAD").read_text(encoding="utf-8", errors="replace").strip()
except OSError:
return None
ref_prefix = "ref: refs/heads/"
if head.startswith(ref_prefix):
return head[len(ref_prefix) :]
return head[:12] if head else None
class GitBranchStatusProvider:
def __init__(
self,
start_path: Path,
refresh_interval_s: float = GIT_STATUS_REFRESH_SECONDS,
command_timeout_s: float = GIT_COMMAND_TIMEOUT_SECONDS,
) -> None:
self.repo_path = find_git_root(start_path)
self.refresh_interval_s = max(5.0, float(refresh_interval_s))
self.command_timeout_s = max(0.5, float(command_timeout_s))
initial_branch = read_head_branch(self.repo_path) if self.repo_path is not None else None
initial_detail = "확인 중" if self.repo_path is not None else "저장소 없음"
self._status = GitBranchStatus(initial_branch or "git", "unknown", initial_detail)
self._next_refresh = 0.0
self._lock = threading.Lock()
self._worker: threading.Thread | None = None
def status(self) -> GitBranchStatus:
now = time.monotonic()
with self._lock:
worker_alive = self._worker is not None and self._worker.is_alive()
if self.repo_path is not None and now >= self._next_refresh and not worker_alive:
self._next_refresh = now + self.refresh_interval_s
self._worker = threading.Thread(target=self._refresh, name="cluster-git-status", daemon=True)
self._worker.start()
return self._status
def _refresh(self) -> None:
status = self._read_status()
with self._lock:
self._status = status
def _read_status(self) -> GitBranchStatus:
if self.repo_path is None:
return GitBranchStatus("git", "unknown", "저장소 없음")
branch = self._current_branch()
if branch is None:
return GitBranchStatus(read_head_branch(self.repo_path) or "HEAD", "unknown", "브렌치 아님")
remote_name, remote_branch = self._tracking_branch(branch)
if remote_name is None or remote_branch is None:
return GitBranchStatus(branch, "missing", "원격 설정 없음")
remote_exists = self._remote_branch_exists(remote_name, remote_branch)
if remote_exists is False:
return GitBranchStatus(branch, "missing", "원격에서 삭제됨")
if remote_exists is None:
return GitBranchStatus(branch, "unknown", "원격 확인 실패")
behind_count = self._behind_count(remote_name, remote_branch)
if behind_count is None:
return GitBranchStatus(branch, "unknown", "pull 확인 실패")
if behind_count > 0:
return GitBranchStatus(branch, "pull", f"git pull 가능 +{behind_count}")
return GitBranchStatus(branch, "ok")
def _current_branch(self) -> str | None:
result = self._git("branch", "--show-current", timeout_s=1.0)
if result.returncode == 0:
branch = result.stdout.strip()
if branch:
return branch
return None
def _tracking_branch(self, branch: str) -> tuple[str | None, str | None]:
remotes = self._remote_names()
if not remotes:
return None, None
upstream_result = self._git("for-each-ref", "--format=%(upstream:short)", f"refs/heads/{branch}", timeout_s=1.0)
upstream = upstream_result.stdout.strip() if upstream_result.returncode == 0 else ""
if upstream:
for remote_name in sorted(remotes, key=len, reverse=True):
prefix = f"{remote_name}/"
if upstream.startswith(prefix):
remote_branch = upstream[len(prefix) :]
if remote_branch:
return remote_name, remote_branch
remote_name = "origin" if "origin" in remotes else remotes[0]
return remote_name, branch
def _remote_names(self) -> list[str]:
remotes_result = self._git("remote", timeout_s=1.0)
if remotes_result.returncode != 0:
return []
return [line.strip() for line in remotes_result.stdout.splitlines() if line.strip()]
def _remote_branch_exists(self, remote_name: str, remote_branch: str) -> bool | None:
result = self._git(
"ls-remote",
"--exit-code",
"--heads",
remote_name,
remote_branch,
timeout_s=self.command_timeout_s,
)
if result.returncode == 0:
return True
if result.returncode == 2:
return False
return None
def _behind_count(self, remote_name: str, remote_branch: str) -> int | None:
tracking_ref = f"refs/remotes/{remote_name}/{remote_branch}"
fetch_result = self._git(
"fetch",
"--quiet",
"--prune",
remote_name,
f"+refs/heads/{remote_branch}:{tracking_ref}",
timeout_s=self.command_timeout_s,
)
if fetch_result.returncode != 0:
return None
result = self._git("rev-list", "--count", f"HEAD..{tracking_ref}", timeout_s=1.0)
if result.returncode != 0:
return None
try:
return max(0, int(result.stdout.strip()))
except ValueError:
return None
def _git(self, *args: str, timeout_s: float) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0"
try:
return subprocess.run(
("git", "-C", str(self.repo_path), *args),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env=env,
timeout=timeout_s,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return subprocess.CompletedProcess(args, returncode=124, stdout="", stderr=str(exc))

View File

@@ -0,0 +1,186 @@
from __future__ import annotations
import sys
import time
from pathlib import Path
from typing import Any
from cluster_config import BLUE, DEFAULT_LANE_WIDTH_M
from cluster_models import ClusterUiState, LaneMarking
from cluster_route_replay import RouteLogParser, frame_to_state
from cluster_utils import clamp
def find_openpilot_root(start: Path) -> Path | None:
for path in (start, *start.parents):
if (path / "cereal").exists() and (path / "selfdrive").exists():
return path
nested = path / "openpilot"
if (nested / "cereal").exists() and (nested / "selfdrive").exists():
return nested
return None
OPENPILOT_ROOT = find_openpilot_root(Path(__file__).resolve().parent)
if OPENPILOT_ROOT is not None:
sys.path.insert(0, str(OPENPILOT_ROOT))
LIVE_SERVICES_BASE = (
"carState",
"modelV2",
"radarState",
"liveTracks",
"longitudinalPlan",
"lateralPlan",
"controlsState",
"cameraOdometry",
"drivingModelData",
"navInstruction",
"navInstructionCarrot",
)
LIVE_CAN_SERVICES = ("can",)
class OpenpilotLiveSource:
def __init__(self, include_can: bool = True, timeout_ms: int = 0) -> None:
try:
import cereal.messaging as messaging
except Exception as exc:
raise RuntimeError(
"Openpilot live input requires cereal.messaging. Run from an openpilot environment "
"or use --input route/random for local checks."
) from exc
self.messaging: Any = messaging
self.services = list(LIVE_SERVICES_BASE + (LIVE_CAN_SERVICES if include_can else ()))
self.sm = messaging.SubMaster(self.services)
self.parser = RouteLogParser()
self.timeout_ms = max(0, int(timeout_ms))
self.last_state: ClusterUiState | None = None
self.start_t = time.monotonic()
self.frames = 0
def update(self) -> ClusterUiState:
self.sm.update(self.timeout_ms)
self._update_current_speed()
for service in self.services:
if not self._service_updated(service):
continue
event_t = self._service_time(service)
self._apply_service_update(service, event_t)
if self._service_alive("carState"):
event_t = self._service_time("carState")
frame = self.parser._frame_from_car_state(self.sm["carState"], event_t)
self.last_state = frame_to_state(frame)
self.frames += 1
return self.last_state
self.last_state = standby_state()
return self.last_state
def status_text(self) -> str:
alive = sum(1 for service in self.services if self._service_alive(service))
updated = sum(1 for service in self.services if self._service_updated(service))
can_status = "can" if "can" in self.services else "no-can"
age = time.monotonic() - self.start_t
fps = self.frames / age if age > 0.1 else 0.0
radar_count = len(self.last_state.radar_points) if self.last_state is not None else 0
detected_count = len(self.last_state.detected_vehicles) if self.last_state is not None else 0
return (
f"live {can_status} alive={alive}/{len(self.services)} upd={updated} state={fps:.1f}Hz "
f"radar={radar_count} detected={detected_count}"
)
def close(self) -> None:
return None
def _apply_service_update(self, service: str, event_t: float) -> None:
data = self.sm[service]
if service == "drivingModelData":
self.parser._update_driving_model(data)
elif service == "modelV2":
self.parser._update_model_v2(data, event_t)
elif service == "lateralPlan":
self.parser._update_lateral_plan(data)
elif service in ("navInstruction", "navInstructionCarrot"):
self.parser._update_nav_instruction(data)
elif service == "longitudinalPlan":
self.parser._update_longitudinal_plan(data)
elif service == "controlsState":
self.parser._update_controls_state(data)
elif service == "cameraOdometry":
self.parser._update_camera_odometry(data, self._service_valid(service))
elif service == "radarState":
self.parser._update_radar_state(data, event_t)
elif service == "liveTracks":
self.parser._update_live_tracks(data, event_t)
elif service == "can":
self.parser._update_can_detections(data, event_t)
def _update_current_speed(self) -> None:
if not self._service_alive("carState"):
return
try:
self.parser.current_speed_kph = clamp(float(self.sm["carState"].vEgo) * 3.6, 0.0, 140.0)
except Exception:
return
def _service_time(self, service: str) -> float:
try:
mono_time = self.sm.logMonoTime.get(service, 0)
except AttributeError:
mono_time = 0
return float(mono_time) / 1_000_000_000.0 if mono_time else time.monotonic()
def _service_alive(self, service: str) -> bool:
try:
return bool(self.sm.alive.get(service, False))
except AttributeError:
return False
def _service_updated(self, service: str) -> bool:
try:
return bool(self.sm.updated.get(service, False))
except AttributeError:
return False
def _service_valid(self, service: str) -> bool:
try:
return bool(self.sm.valid.get(service, True))
except AttributeError:
return True
def standby_state() -> ClusterUiState:
return ClusterUiState(
speed_kph=0.0,
accel_mps2=0.0,
steering=0.0,
speed_limit_kph=None,
cruise_kph=None,
cruise_display_state="off",
left_signal=False,
right_signal=False,
left_blindspot=False,
right_blindspot=False,
lane_change=None,
lane_change_phase="idle",
lane_change_progress=0.0,
highlight_lane=None,
highlight_lane_offset=None,
ego_lane_offset=0.0,
road_view_lane_position=0.0,
camera_lane_center_offset_m=None,
lane_width_m=DEFAULT_LANE_WIDTH_M,
steering_angle_deg=None,
surround_yaw_deg=0.0,
surround_pitch_deg=0.0,
surround_view_active=False,
lanes=(
LaneMarking(-0.5, BLUE, "solid", width=7),
LaneMarking(0.5, BLUE, "solid", width=7),
),
)

View File

@@ -0,0 +1,230 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from cluster_config import DEFAULT_LANE_WIDTH_M, WHITE
CruiseDisplayState = Literal["off", "paused", "engaged"]
GitBranchStatusState = Literal["ok", "pull", "missing", "unknown"]
@dataclass(frozen=True, slots=True)
class GitBranchStatus:
branch: str
state: GitBranchStatusState
detail: str = ""
@dataclass(frozen=True, slots=True)
class ModelPathPoint:
forward_m: float
lateral_m: float
lateral_std_m: float | None = None
speed_mps: float | None = None
accel_mps2: float | None = None
orientation_rad: float | None = None
orientation_rate_rps: float | None = None
@dataclass(frozen=True, slots=True)
class ModelRiskPoint:
t_s: float
brake_disengage: float = 0.0
gas_disengage: float = 0.0
steer_override: float = 0.0
hard_brake_3: float = 0.0
hard_brake_4: float = 0.0
hard_brake_5: float = 0.0
gas_press: float = 0.0
brake_press: float = 0.0
@dataclass(frozen=True, slots=True)
class LaneMarking:
offset: float
color: tuple[int, int, int] = WHITE
style: str = "solid"
visible: bool = True
width: int = 5
model_points: tuple[ModelPathPoint, ...] = ()
model_lateral_shift_m: float = 0.0
@dataclass(frozen=True, slots=True)
class RouteOverlay:
video_rgba: bytes | None = None
video_width: int = 0
video_height: int = 0
video_frame_id: str | None = None
video_status: str | None = None
data_lines: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class DetectedVehicle:
label: str
longitudinal_m: float
lateral_m: float
source: str = "route"
probability: float = 1.0
relative_speed_mps: float | None = None
absolute_speed_kph: float | None = None
acceleration_mps2: float | None = None
cut_in: bool = False
primary: bool = False
ttc_s: float | None = None
x_std_m: float | None = None
y_std_m: float | None = None
@dataclass(frozen=True, slots=True)
class RadarPoint:
label: str
longitudinal_m: float
lateral_m: float
source: str
relative_speed_mps: float | None = None
absolute_speed_kph: float | None = None
lateral_speed_mps: float | None = None
relative_accel_mps2: float | None = None
probability: float | None = None
valid: int | None = None
valid_count: int | None = None
in_my_lane: int | None = None
@dataclass(frozen=True, slots=True)
class SimulatorInput:
throttle: float = 0.0
brake: float = 0.0
steering: float = 0.0
steering_angle_deg: float | None = None
camera_lane_center_offset_m: float | None = None
camera_lane_width_m: float = DEFAULT_LANE_WIDTH_M
surround_yaw_deg: float = 0.0
surround_pitch_deg: float = 0.0
surround_view_active: bool = False
left_signal_requested: bool = False
right_signal_requested: bool = False
@dataclass(frozen=True, slots=True)
class ClusterUiState:
speed_kph: float
accel_mps2: float
steering: float
speed_limit_kph: int | None
cruise_kph: int | None
cruise_display_state: CruiseDisplayState
left_signal: bool
right_signal: bool
left_blindspot: bool
right_blindspot: bool
lane_change: str | None
lane_change_phase: str
lane_change_progress: float
highlight_lane: str | None
highlight_lane_offset: float | None
ego_lane_offset: float
road_view_lane_position: float
camera_lane_center_offset_m: float | None
lane_width_m: float
steering_angle_deg: float | None
surround_yaw_deg: float
surround_pitch_deg: float
surround_view_active: bool
lanes: tuple[LaneMarking, ...]
extra_left_lane_visible: bool = False
extra_right_lane_visible: bool = False
left_road_edge_offset: float | None = None
right_road_edge_offset: float | None = None
left_road_edge_points: tuple[ModelPathPoint, ...] = ()
right_road_edge_points: tuple[ModelPathPoint, ...] = ()
left_road_edge_lateral_shift_m: float = 0.0
right_road_edge_lateral_shift_m: float = 0.0
throttle: float = 0.0
brake: float = 0.0
model_path: tuple[ModelPathPoint, ...] = ()
detected_vehicles: tuple[DetectedVehicle, ...] = ()
radar_points: tuple[RadarPoint, ...] = ()
route_overlay: RouteOverlay | None = None
center_clock_text: str | None = None
planned_speed_kph: float | None = None
planned_accel_mps2: float | None = None
planned_curvature_m_inv: float | None = None
should_stop: bool = False
model_confidence: str | None = None
model_turn_speed_kph: float | None = None
engaged_prob: float | None = None
desire_state: tuple[float, ...] = ()
desire_prediction: tuple[tuple[float, ...], ...] = ()
risk_points: tuple[ModelRiskPoint, ...] = ()
brake_disengage_risk: float = 0.0
gas_disengage_risk: float = 0.0
steer_override_risk: float = 0.0
hard_brake_risk: float = 0.0
gas_press_prob: float = 0.0
brake_press_prob: float = 0.0
disengage_risk: float = 0.0
hard_brake_predicted: bool = False
lane_change_available_left: bool | None = None
lane_change_available_right: bool | None = None
lane_change_prob: float = 0.0
left_lane_width_m: float | None = None
right_lane_width_m: float | None = None
left_road_edge_distance_m: float | None = None
right_road_edge_distance_m: float | None = None
left_road_edge_confidence: float = 0.0
right_road_edge_confidence: float = 0.0
frame_age: int | None = None
frame_drop_perc: float | None = None
model_execution_time_ms: float | None = None
vision_speed_mps: float | None = None
vision_yaw_rate_rps: float | None = None
vision_speed_std_mps: float | None = None
vision_yaw_rate_std_rps: float | None = None
camera_calibration_euler: tuple[float, float, float] | None = None
road_transform_trans: tuple[float, float, float] | None = None
road_transform_std: tuple[float, float, float] | None = None
camera_odometry_valid: bool | None = None
longitudinal_plan_source: str | None = None
longitudinal_plan_speeds_kph: tuple[float, ...] = ()
longitudinal_plan_accels_mps2: tuple[float, ...] = ()
longitudinal_plan_jerks_mps3: tuple[float, ...] = ()
longitudinal_plan_fcw: bool = False
longitudinal_plan_should_stop: bool = False
longitudinal_plan_allow_throttle: bool | None = None
longitudinal_plan_allow_brake: bool | None = None
longitudinal_t_follow_s: float | None = None
longitudinal_desired_distance_m: float | None = None
longitudinal_v_target_kph: float | None = None
longitudinal_jerk_target_mps3: float | None = None
lateral_plan_valid: bool | None = None
lateral_plan_use_lane_lines: bool | None = None
lateral_plan_solver_cost: float | None = None
lateral_plan_debug_text: str | None = None
lateral_plan_curvatures: tuple[float, ...] = ()
lateral_plan_curvature_rates: tuple[float, ...] = ()
display_speed_kph: float | None = None
git_status: GitBranchStatus | None = None
@dataclass(frozen=True, slots=True)
class SceneCamera:
active: bool
position_x_m: float
position_y_m: float
position_z_m: float
right_x: float
right_y: float
right_z: float
up_x: float
up_y: float
up_z: float
forward_x: float
forward_y: float
forward_z: float
center_y: float
focal_x: float
focal_y: float

View File

@@ -0,0 +1,177 @@
from __future__ import annotations
import gc
import os
import time
from typing import Any
def _read_proc_status() -> dict[str, str]:
wanted = {
"VmRSS",
"Threads",
"voluntary_ctxt_switches",
"nonvoluntary_ctxt_switches",
}
status: dict[str, str] = {}
try:
with open("/proc/self/status", encoding="utf-8") as handle:
for line in handle:
key, separator, value = line.partition(":")
if separator and key in wanted:
status[key] = value.strip()
except OSError:
pass
return status
def _read_system_cpu_stat() -> tuple[int, int] | None:
try:
with open("/proc/stat", encoding="utf-8") as handle:
fields = handle.readline().split()
except OSError:
return None
if len(fields) < 5 or fields[0] != "cpu":
return None
try:
values = [int(value) for value in fields[1:]]
except ValueError:
return None
total = sum(values)
idle = values[3] + (values[4] if len(values) > 4 else 0)
return total, idle
def _status_kb_to_mb(value: str) -> float | None:
fields = value.split()
if not fields:
return None
try:
return int(fields[0]) / 1024.0
except ValueError:
return None
class ProfileReporter:
def __init__(self, enabled: bool, interval_s: float) -> None:
self.enabled = enabled
self.interval_s = max(0.2, interval_s)
self.samples: dict[str, list[float]] = {}
self.last_report_time = time.perf_counter()
self.last_process_time = time.process_time()
self.last_system_cpu = _read_system_cpu_stat()
self.report_frames = 0
def add(self, name: str, milliseconds: float) -> None:
if not self.enabled:
return
self.samples.setdefault(name, []).append(milliseconds)
def add_elapsed(self, name: str, start_time: float) -> None:
if self.enabled:
self.add(name, (time.perf_counter() - start_time) * 1000.0)
def add_samples(self, samples: tuple[tuple[str, float], ...]) -> None:
if not self.enabled:
return
for name, milliseconds in samples:
self.add(name, milliseconds)
def frame_done(self) -> None:
if self.enabled:
self.report_frames += 1
def maybe_report(self, now: float) -> None:
if not self.enabled or now - self.last_report_time < self.interval_s:
return
elapsed = max(0.001, now - self.last_report_time)
print(f"PROFILE {self.report_frames} frames / {elapsed:.1f}s", flush=True)
runtime_summary = self._runtime_summary(elapsed)
if runtime_summary:
print(f" runtime {runtime_summary}", flush=True)
ordered = sorted(
self.samples.items(),
key=lambda item: sum(item[1]) / max(1, len(item[1])),
reverse=True,
)
for name, values in ordered:
if not values:
continue
average = sum(values) / len(values)
print(
f" {name:<42} avg={average:7.2f}ms "
f"max={max(values):7.2f}ms last={values[-1]:7.2f}ms n={len(values)}",
flush=True,
)
self.samples.clear()
self.report_frames = 0
self.last_report_time = now
def _runtime_summary(self, elapsed: float) -> str:
parts: list[str] = []
process_time = time.process_time()
process_cpu = max(0.0, process_time - self.last_process_time) / elapsed * 100.0
self.last_process_time = process_time
parts.append(f"proc_cpu={process_cpu:5.1f}% one_core")
current_system_cpu = _read_system_cpu_stat()
if self.last_system_cpu is not None and current_system_cpu is not None:
last_total, last_idle = self.last_system_cpu
total, idle = current_system_cpu
total_delta = total - last_total
idle_delta = idle - last_idle
if total_delta > 0:
system_cpu = max(0.0, min(100.0, (total_delta - idle_delta) / total_delta * 100.0))
parts.append(f"sys_cpu={system_cpu:5.1f}% all_cores")
self.last_system_cpu = current_system_cpu
status = _read_proc_status()
rss_mb = _status_kb_to_mb(status.get("VmRSS", ""))
if rss_mb is not None:
parts.append(f"rss={rss_mb:.1f}MB")
threads = status.get("Threads")
if threads is not None:
parts.append(f"threads={threads}")
voluntary = status.get("voluntary_ctxt_switches")
nonvoluntary = status.get("nonvoluntary_ctxt_switches")
if voluntary is not None and nonvoluntary is not None:
parts.append(f"ctx={voluntary}/{nonvoluntary}")
try:
parts.append(f"load1={os.getloadavg()[0]:.2f}")
except (AttributeError, OSError):
pass
return " ".join(parts)
class GcProfileHook:
def __init__(self, profile: ProfileReporter) -> None:
self.profile = profile
self._starts: dict[int, float] = {}
def __call__(self, phase: str, info: dict[str, Any]) -> None:
generation = int(info.get("generation", -1))
if phase == "start":
self._starts[generation] = time.perf_counter()
return
if phase != "stop":
return
start_time = self._starts.pop(generation, None)
if start_time is not None:
self.profile.add(f"gc.gen{generation}", (time.perf_counter() - start_time) * 1000.0)
def freeze_gc_after_init(profile: ProfileReporter) -> None:
freeze = getattr(gc, "freeze", None)
if freeze is None:
return
profile_stage = time.perf_counter()
gc.collect(2)
profile.add_elapsed("gc.freeze_init.collect", profile_stage)
profile_stage = time.perf_counter()
freeze()
profile.add_elapsed("gc.freeze_init.freeze", profile_stage)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
from __future__ import annotations
import math
from cluster_config import (
BLUE,
BLUE_SOFT,
COAST_DECEL_MPS2,
CONTROLLER_ACCEL_MPS2,
CONTROLLER_BRAKE_MPS2,
DEFAULT_LANE_WIDTH_M,
DRAG_DECEL_PER_MPS,
LANE_CHANGE_MAX_SECONDS,
LANE_CHANGE_MIN_SECONDS,
MAX_ACCEL_MPS2,
MAX_SPEED_KPH,
MAX_STEERING_ANGLE_DEG,
MODEL_DIRECT_LANE_RECENTER_SECONDS,
SURROUND_MAX_PITCH_DEG,
SURROUND_MAX_YAW_DEG,
SURROUND_VIEW_SMOOTH_SECONDS,
TURN_SIGNAL_SECONDS,
)
from cluster_models import ClusterUiState, LaneMarking, SimulatorInput
from cluster_utils import clamp, smoothstep
class ClusterSimulator:
def __init__(self) -> None:
self.elapsed = 0.0
self.speed_kph = 0.0
self.accel_mps2 = 0.0
self.steering = 0.0
self.left_signal_until = -999.0
self.right_signal_until = -999.0
self.lane_change_direction: str | None = None
self.lane_change_phase = "idle"
self.lane_change_elapsed = 0.0
self.lane_change_progress = 0.0
self.lane_change_recenter_start_progress = 1.0
self.active_lane_position = 0.0
self.ego_lane_position = 0.0
self.view_lane_position = 0.0
self.target_lane_position = 0.0
self.lane_width_m = DEFAULT_LANE_WIDTH_M
self.camera_lane_center_offset_m: float | None = None
self.steering_angle_deg: float | None = None
self.surround_yaw_deg = 0.0
self.surround_pitch_deg = 0.0
self.surround_view_active = False
def update(self, command: SimulatorInput, dt: float) -> ClusterUiState:
dt = clamp(dt, 0.001, 0.25)
self.elapsed += dt
self._update_steering(command)
self._update_motion(command, dt)
self._update_signals(command)
self._update_lane_change(dt)
self._apply_camera_lane_model(command)
self._update_surround_view(command, dt)
speed_limit_kph = self._speed_limit_for_current_road()
cruise_kph = min(int(speed_limit_kph + 12), int(MAX_SPEED_KPH))
lanes = self._lanes_for_current_state()
left_signal = self.elapsed < self.left_signal_until
right_signal = self.elapsed < self.right_signal_until
highlight_active = self.lane_change_direction is not None and self.lane_change_phase in ("preparing", "changing")
highlight_lane = self.lane_change_direction if highlight_active else None
highlight_lane_offset = (
self.target_lane_position - self.view_lane_position
if highlight_active
else None
)
return ClusterUiState(
speed_kph=self.speed_kph,
accel_mps2=self.accel_mps2,
steering=self.steering,
speed_limit_kph=speed_limit_kph,
cruise_kph=cruise_kph,
cruise_display_state="engaged",
left_signal=left_signal,
right_signal=right_signal,
left_blindspot=False,
right_blindspot=False,
lane_change=self.lane_change_direction,
lane_change_phase=self.lane_change_phase,
lane_change_progress=self.lane_change_progress,
highlight_lane=highlight_lane,
highlight_lane_offset=highlight_lane_offset,
ego_lane_offset=self.ego_lane_position - self.view_lane_position,
road_view_lane_position=self.view_lane_position,
camera_lane_center_offset_m=self.camera_lane_center_offset_m,
lane_width_m=self.lane_width_m,
steering_angle_deg=self.steering_angle_deg,
surround_yaw_deg=self.surround_yaw_deg,
surround_pitch_deg=self.surround_pitch_deg,
surround_view_active=self.surround_view_active,
lanes=lanes,
throttle=command.throttle,
brake=command.brake,
)
def _update_steering(self, command: SimulatorInput) -> None:
if command.steering_angle_deg is None:
target_steering = clamp(command.steering, -1.0, 1.0)
self.steering_angle_deg = target_steering * MAX_STEERING_ANGLE_DEG
else:
self.steering_angle_deg = clamp(
command.steering_angle_deg,
-MAX_STEERING_ANGLE_DEG,
MAX_STEERING_ANGLE_DEG,
)
target_steering = self.steering_angle_deg / MAX_STEERING_ANGLE_DEG
self.steering = self.steering * 0.72 + target_steering * 0.28
def _update_motion(self, command: SimulatorInput, dt: float) -> None:
throttle = clamp(command.throttle, 0.0, 1.0)
brake = clamp(command.brake, 0.0, 1.0)
speed_mps = self.speed_kph / 3.6
accel_mps2 = (
throttle * CONTROLLER_ACCEL_MPS2
- brake * CONTROLLER_BRAKE_MPS2
- speed_mps * DRAG_DECEL_PER_MPS
)
if throttle == 0.0 and brake == 0.0 and speed_mps > 0.0:
accel_mps2 -= COAST_DECEL_MPS2
next_speed_kph = self.speed_kph + accel_mps2 * dt * 3.6
if next_speed_kph <= 0.0:
next_speed_kph = 0.0
accel_mps2 = max(0.0, accel_mps2)
self.speed_kph = clamp(next_speed_kph, 0.0, MAX_SPEED_KPH)
self.accel_mps2 = clamp(accel_mps2, -MAX_ACCEL_MPS2, MAX_ACCEL_MPS2)
def _update_signals(self, command: SimulatorInput) -> None:
if command.left_signal_requested:
self._start_signal("left")
if command.right_signal_requested:
self._start_signal("right")
def _start_signal(self, direction: str) -> None:
if direction == "left":
self.left_signal_until = self.elapsed + TURN_SIGNAL_SECONDS
self.right_signal_until = min(self.right_signal_until, self.elapsed)
else:
self.right_signal_until = self.elapsed + TURN_SIGNAL_SECONDS
self.left_signal_until = min(self.left_signal_until, self.elapsed)
self.lane_change_direction = direction
self.lane_change_phase = "changing"
self.lane_change_elapsed = 0.0
self.lane_change_progress = 0.0
self.lane_change_recenter_start_progress = 1.0
direction_sign = -1.0 if direction == "left" else 1.0
self.target_lane_position = self.active_lane_position + direction_sign
def _update_lane_change(self, dt: float) -> None:
if self.lane_change_phase == "idle":
self.lane_change_progress = 0.0
self.ego_lane_position += (self.active_lane_position - self.ego_lane_position) * min(1.0, dt / 0.8)
self.view_lane_position += (self.active_lane_position - self.view_lane_position) * min(1.0, dt / 0.8)
return
if self.lane_change_phase == "changing":
self.lane_change_elapsed += dt
self.lane_change_progress = clamp(
self.lane_change_elapsed / self._lane_change_duration_seconds(),
0.0,
1.0,
)
direction_sign = self._lane_change_direction_sign()
self.ego_lane_position = (
self.active_lane_position
+ direction_sign * self.lane_change_progress
)
if self.lane_change_progress >= 1.0:
self.active_lane_position = self.target_lane_position
self.ego_lane_position = self.target_lane_position
self.lane_change_phase = "recentering"
self.lane_change_elapsed = 0.0
self.lane_change_recenter_start_progress = 1.0
return
if self.lane_change_phase == "recentering":
self.lane_change_elapsed += dt
self.lane_change_progress = clamp(
self.lane_change_elapsed / MODEL_DIRECT_LANE_RECENTER_SECONDS,
0.0,
1.0,
)
direction_sign = self._lane_change_direction_sign()
recenter_blend = smoothstep(self.lane_change_progress)
start_offset = direction_sign * smoothstep(self.lane_change_recenter_start_progress)
self.view_lane_position = self.active_lane_position - start_offset * (1.0 - recenter_blend)
self.ego_lane_position = self.active_lane_position
if self.lane_change_progress >= 1.0:
self.view_lane_position = self.active_lane_position
self.lane_change_phase = "idle"
self.lane_change_progress = 0.0
self.lane_change_recenter_start_progress = 1.0
self.lane_change_direction = None
def _lane_change_duration_seconds(self) -> float:
return clamp(
LANE_CHANGE_MAX_SECONDS - self.speed_kph * 0.032,
LANE_CHANGE_MIN_SECONDS,
LANE_CHANGE_MAX_SECONDS,
)
def _lane_change_direction_sign(self) -> float:
return -1.0 if self.lane_change_direction == "left" else 1.0
def _apply_camera_lane_model(self, command: SimulatorInput) -> None:
self.lane_width_m = max(2.4, min(4.6, command.camera_lane_width_m))
self.camera_lane_center_offset_m = command.camera_lane_center_offset_m
if command.camera_lane_center_offset_m is None:
return
camera_lane_offset = clamp(
command.camera_lane_center_offset_m / self.lane_width_m,
-1.4,
1.4,
)
observed_ego_position = self.active_lane_position - camera_lane_offset
if self.lane_change_direction is not None:
low = min(self.active_lane_position, self.target_lane_position) - 0.25
high = max(self.active_lane_position, self.target_lane_position) + 0.25
observed_ego_position = clamp(observed_ego_position, low, high)
self.ego_lane_position = self.ego_lane_position * 0.45 + observed_ego_position * 0.55
if self.lane_change_phase == "changing" and self.target_lane_position != self.active_lane_position:
measured_progress = abs(
(self.ego_lane_position - self.active_lane_position)
/ (self.target_lane_position - self.active_lane_position)
)
self.lane_change_progress = max(self.lane_change_progress, clamp(measured_progress, 0.0, 1.0))
if self.lane_change_progress >= 0.98:
self.active_lane_position = self.target_lane_position
self.ego_lane_position = self.target_lane_position
self.lane_change_phase = "recentering"
self.lane_change_elapsed = 0.0
self.lane_change_recenter_start_progress = clamp(self.lane_change_progress, 0.0, 1.0)
def _update_surround_view(self, command: SimulatorInput, dt: float) -> None:
target_yaw = clamp(command.surround_yaw_deg, -SURROUND_MAX_YAW_DEG, SURROUND_MAX_YAW_DEG)
target_pitch = clamp(command.surround_pitch_deg, -SURROUND_MAX_PITCH_DEG, SURROUND_MAX_PITCH_DEG)
if not command.surround_view_active:
target_yaw = 0.0
target_pitch = 0.0
alpha = min(1.0, dt / SURROUND_VIEW_SMOOTH_SECONDS)
self.surround_yaw_deg += (target_yaw - self.surround_yaw_deg) * alpha
self.surround_pitch_deg += (target_pitch - self.surround_pitch_deg) * alpha
self.surround_view_active = (
command.surround_view_active
or abs(self.surround_yaw_deg) > 0.4
or abs(self.surround_pitch_deg) > 0.4
)
def _speed_limit_for_current_road(self) -> int:
if self.speed_kph < 55.0:
return 50
if self.speed_kph < 95.0:
return 80
return 100
def _lanes_for_current_state(self) -> tuple[LaneMarking, ...]:
lane_center = self.active_lane_position
left_inner = BLUE
right_inner = BLUE
markings: list[LaneMarking] = []
if self.lane_change_direction == "left":
left_inner = BLUE
markings.append(
LaneMarking(lane_center - 1.5 - self.view_lane_position, BLUE_SOFT, "solid", width=5)
)
elif self.lane_change_direction == "right":
right_inner = BLUE
markings.extend(
(
LaneMarking(lane_center - 0.5 - self.view_lane_position, left_inner, "solid", width=7),
LaneMarking(lane_center + 0.5 - self.view_lane_position, right_inner, "solid", width=7),
)
)
if self.lane_change_direction == "right":
markings.append(
LaneMarking(lane_center + 1.5 - self.view_lane_position, BLUE_SOFT, "dashed", width=5)
)
return tuple(markings)
class RandomInputSource:
def __init__(self) -> None:
self.elapsed = 0.0
self.next_signal_at = 4.0
self.next_signal_left = True
def update(self, dt: float) -> SimulatorInput:
self.elapsed += dt
throttle = 0.28 + 0.26 * math.sin(self.elapsed * 0.37)
brake = 0.0
if math.sin(self.elapsed * 0.21) < -0.72:
throttle = 0.0
brake = 0.34 + 0.28 * abs(math.sin(self.elapsed * 0.78))
signal_left = False
signal_right = False
if self.elapsed >= self.next_signal_at:
signal_left = self.next_signal_left
signal_right = not self.next_signal_left
self.next_signal_left = not self.next_signal_left
self.next_signal_at = self.elapsed + 8.0
return SimulatorInput(
throttle=clamp(throttle, 0.0, 1.0),
brake=clamp(brake, 0.0, 1.0),
steering=0.38 * math.sin(self.elapsed * 0.42),
left_signal_requested=signal_left,
right_signal_requested=signal_right,
)

View File

@@ -0,0 +1,127 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import time
PROC_STAT_PATH = Path("/proc/stat")
PROC_MEMINFO_PATH = Path("/proc/meminfo")
@dataclass(frozen=True, slots=True)
class SystemStats:
memory_total_bytes: int | None = None
memory_used_bytes: int | None = None
memory_used_percent: float | None = None
cpu_core_percents: tuple[float | None, ...] = ()
class SystemStatsSampler:
def __init__(self, refresh_interval_s: float = 1.0) -> None:
self.refresh_interval_s = max(0.1, float(refresh_interval_s))
self._next_sample_time = 0.0
self._stats = SystemStats()
self._previous_linux_cpu_times: tuple[tuple[int, int], ...] | None = None
def sample(self, now: float | None = None) -> SystemStats:
if now is None:
now = time.perf_counter()
if now < self._next_sample_time:
return self._stats
stats = self._sample_linux()
if stats is not None:
self._stats = stats
self._next_sample_time = now + self.refresh_interval_s
return self._stats
def _sample_linux(self) -> SystemStats | None:
if not PROC_STAT_PATH.exists() and not PROC_MEMINFO_PATH.exists():
return None
memory_total, memory_used, memory_percent = self._read_linux_memory()
cpu_times = self._read_linux_cpu_times()
if cpu_times is None:
cpu_percents: tuple[float | None, ...] = ()
else:
cpu_percents = self._linux_cpu_percents(cpu_times)
self._previous_linux_cpu_times = cpu_times
return SystemStats(
memory_total_bytes=memory_total,
memory_used_bytes=memory_used,
memory_used_percent=memory_percent,
cpu_core_percents=cpu_percents,
)
@staticmethod
def _read_linux_memory() -> tuple[int | None, int | None, float | None]:
try:
values: dict[str, int] = {}
for line in PROC_MEMINFO_PATH.read_text(encoding="utf-8", errors="ignore").splitlines():
parts = line.split()
if len(parts) < 2:
continue
name = parts[0].rstrip(":")
try:
values[name] = int(parts[1]) * 1024
except ValueError:
continue
except OSError:
return None, None, None
total = values.get("MemTotal")
available = values.get("MemAvailable")
if available is None:
free = values.get("MemFree", 0)
buffers = values.get("Buffers", 0)
cached = values.get("Cached", 0)
available = free + buffers + cached
if total is None or total <= 0:
return total, None, None
used = max(0, min(total, total - available))
return total, used, used / total * 100.0
@staticmethod
def _read_linux_cpu_times() -> tuple[tuple[int, int], ...] | None:
try:
lines = PROC_STAT_PATH.read_text(encoding="utf-8", errors="ignore").splitlines()
except OSError:
return None
cpu_times: list[tuple[int, int]] = []
for line in lines:
parts = line.split()
if not parts:
continue
name = parts[0]
if not name.startswith("cpu") or not name[3:].isdigit():
continue
try:
fields = [int(value) for value in parts[1:]]
except ValueError:
continue
if len(fields) < 4:
continue
idle = fields[3] + (fields[4] if len(fields) > 4 else 0)
total = sum(fields)
cpu_times.append((total, idle))
return tuple(cpu_times)
def _linux_cpu_percents(self, cpu_times: tuple[tuple[int, int], ...]) -> tuple[float | None, ...]:
previous = self._previous_linux_cpu_times
if previous is None or len(previous) != len(cpu_times):
return tuple(None for _ in cpu_times)
percents: list[float | None] = []
for (total, idle), (previous_total, previous_idle) in zip(cpu_times, previous, strict=True):
delta_total = total - previous_total
delta_idle = idle - previous_idle
if delta_total <= 0:
percents.append(None)
continue
busy = max(0, min(delta_total, delta_total - delta_idle))
percents.append(busy / delta_total * 100.0)
return tuple(percents)

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from cluster_config import *
from cluster_models import (
ClusterUiState,
DetectedVehicle,
LaneMarking,
ModelPathPoint,
ModelRiskPoint,
RadarPoint,
RouteOverlay,
SceneCamera,
SimulatorInput,
)
from cluster_renderer import ClusterUiRenderer
from cluster_scene import (
CameraSpec,
ClusterScene,
MeshStrip,
Vec3,
VehicleBox,
build_cluster_scene,
)
from cluster_simulator import ClusterSimulator, RandomInputSource
from cluster_utils import (
blink_visible,
clamp,
darken,
lighten,
smoothstep,
)
__all__ = [
"ClusterSimulator",
"ClusterUiRenderer",
"CameraSpec",
"ClusterScene",
"ClusterUiState",
"DetectedVehicle",
"LaneMarking",
"MeshStrip",
"ModelPathPoint",
"ModelRiskPoint",
"RadarPoint",
"RandomInputSource",
"RouteOverlay",
"SceneCamera",
"SimulatorInput",
"Vec3",
"VehicleBox",
"blink_visible",
"build_cluster_scene",
"clamp",
"darken",
"lighten",
"smoothstep",
] + [name for name in globals() if name.isupper()]

View File

@@ -0,0 +1,536 @@
from __future__ import annotations
import sys
import os
import time
from io import BytesIO
from pathlib import Path
from typing import Any
from cluster_utils import clamp
VENDOR_ROOT = Path(__file__).resolve().parent / ".vendor" / "turing-smart-screen-python-main"
VENDOR_LIBRARY = VENDOR_ROOT / "library"
TURZX_USB_VENDOR_ID = 0x1CBE
TURZX_USB_PRODUCT_IDS = {
0x0092: "TURZX 9.2",
0x0123: "TURZX 12.3",
}
HUD_MODE_PRODUCT_IDS = {
1: 0x0092,
2: 0x0123,
}
MAX_CONSECUTIVE_FRAME_ERRORS = 3
USB_COMMAND_TIMEOUT_MS = 2000
USB_FRAME_TIMEOUT_MS = 2000
USB_COMMAND_GAP_S = 0.2
_LIBUSB_DLL_DIR_HANDLE = None
def product_id_for_hud_mode(hud_mode: int) -> int | None:
try:
return HUD_MODE_PRODUCT_IDS.get(int(hud_mode))
except Exception:
return None
def product_label(product_id: int | None) -> str:
if product_id is None:
return "TURZX USB"
return TURZX_USB_PRODUCT_IDS.get(product_id, f"TURZX USB pid=0x{product_id:04x}")
def _add_libusb_search_path_once() -> None:
global _LIBUSB_DLL_DIR_HANDLE
libusb = VENDOR_ROOT / "external" / "libusb-1.0" / "libusb-1.0.dll"
if not libusb.exists():
return
dll_dir = str(libusb.parent)
path_entries = os.environ.get("PATH", "").split(os.pathsep)
if dll_dir not in path_entries:
os.environ["PATH"] = dll_dir + os.pathsep + os.environ.get("PATH", "")
if hasattr(os, "add_dll_directory") and _LIBUSB_DLL_DIR_HANDLE is None:
_LIBUSB_DLL_DIR_HANDLE = os.add_dll_directory(dll_dir)
def find_supported_usb_product(expected_product_id: int | None = None) -> int | None:
if not VENDOR_LIBRARY.exists():
print(f"TURZX vendor library not found: {VENDOR_LIBRARY}", flush=True)
return None
_add_libusb_search_path_once()
try:
import usb.core # type: ignore
except Exception as exc:
print(f"TURZX USB scan unavailable: {exc}", flush=True)
return None
product_ids = [expected_product_id] if expected_product_id is not None else list(TURZX_USB_PRODUCT_IDS)
for product_id in product_ids:
try:
dev = usb.core.find(idVendor=TURZX_USB_VENDOR_ID, idProduct=product_id)
except Exception as exc:
print(f"TURZX USB scan failed for pid=0x{product_id:04x}: {exc}", flush=True)
return None
if dev is not None:
return product_id
return None
class TuringUsbDisplay:
def __init__(
self,
brightness: int = 80,
display_fps: int = 60,
jpeg_quality: int = 82,
jpeg_encoder: str = "auto",
fast_write: bool = False,
wait_for_frame_ack: bool = False,
frame_drain_attempts: int = 2,
frame_drain_timeout_ms: int = 2,
fast_frame_drain_attempts: int = 3,
fast_frame_drain_timeout_ms: int = 2,
) -> None:
self.brightness = int(clamp(brightness, 0, 100))
self.display_fps = int(clamp(display_fps, 0, 255))
self.jpeg_quality = int(clamp(jpeg_quality, 1, 95))
self.jpeg_encoder = jpeg_encoder
self.fast_write = fast_write
self.wait_for_frame_ack = wait_for_frame_ack
self.frame_drain_attempts = max(0, int(frame_drain_attempts))
self.frame_drain_timeout_ms = max(0, int(frame_drain_timeout_ms))
self.fast_frame_drain_attempts = max(0, int(fast_frame_drain_attempts))
self.fast_frame_drain_timeout_ms = max(0, int(fast_frame_drain_timeout_ms))
self.dev = None
self.dev_pid: int | None = None
self.landscape_width = 1920
self.landscape_height = 480
self._send_image = None
self._send_jpeg = None
self._find_usb_device = None
self._product_id = None
self._build_command_packet_header = None
self._encrypt_command_packet = None
self._cmd_upload_jpeg = 101
self._cmd_upload_png = 102
self._ep_out = None
self._ep_in = None
self._dll_dir_handle = None
self._frame_error_count = 0
self._turbojpeg = None
self._turbojpeg_unavailable = False
self._jpeg_buffer = BytesIO()
self.profile_enabled = os.environ.get("CLUSTER_PROFILE_USB") == "1"
self._profile_samples: list[tuple[str, float]] = []
def set_profile_enabled(self, enabled: bool) -> None:
self.profile_enabled = enabled
def clear_profile_samples(self) -> None:
self._profile_samples.clear()
def profile_samples(self) -> tuple[tuple[str, float], ...]:
return tuple(self._profile_samples)
def _profile_start(self) -> float:
return time.perf_counter() if self.profile_enabled else 0.0
def _profile_add(self, name: str, start_time: float) -> None:
if self.profile_enabled:
self._profile_samples.append((name, (time.perf_counter() - start_time) * 1000.0))
def open(self) -> None:
if not VENDOR_LIBRARY.exists():
raise RuntimeError(f"TURZX vendor library not found: {VENDOR_LIBRARY}")
self._add_libusb_search_path()
if str(VENDOR_ROOT) not in sys.path:
sys.path.insert(0, str(VENDOR_ROOT))
from library.lcd.lcd_comm_turing_usb import ( # type: ignore
CMD_UPLOAD_JPEG,
CMD_UPLOAD_PNG,
PRODUCT_ID,
build_command_packet_header,
encrypt_command_packet,
find_usb_device,
send_image,
send_jpeg,
)
self._send_image = send_image
self._send_jpeg = send_jpeg
self._find_usb_device = find_usb_device
self._product_id = PRODUCT_ID
self._build_command_packet_header = build_command_packet_header
self._encrypt_command_packet = encrypt_command_packet
self._cmd_upload_jpeg = CMD_UPLOAD_JPEG
self._cmd_upload_png = CMD_UPLOAD_PNG
self._connect_device()
try:
self._initialize_device()
except RuntimeError:
print("USB display did not respond during init; resetting device once...")
self._reset_and_reconnect()
self._initialize_device()
def _connect_device(self) -> None:
self.dev, self.dev_pid = self._find_usb_device()
self._cache_out_endpoint()
portrait_width, portrait_height = self._product_id[self.dev_pid]
self.landscape_width = portrait_height
self.landscape_height = portrait_width
def _initialize_device(self) -> None:
if self.dev is None:
raise RuntimeError("USB display is not open")
self._send_command(10, "sync")
time.sleep(USB_COMMAND_GAP_S)
if self.display_fps > 0:
self._send_optional_command(15, "frame-rate", {8: self.display_fps})
self._send_optional_command(14, "brightness", {8: int(self.brightness / 100 * 102)})
def _send_command(
self,
command_id: int,
name: str,
fields: dict[int, int] | None = None,
*,
expect_response: bool = True,
) -> bytes:
if self._build_command_packet_header is None or self._encrypt_command_packet is None:
raise RuntimeError("USB command helpers are not initialized")
packet = self._build_command_packet_header(command_id)
if fields:
for index, value in fields.items():
packet[index] = value & 0xFF
print(f"Sending {name} command (ID {command_id})...")
payload = self._encrypt_command_packet(packet)
if not expect_response:
self._write_payload_no_ack(
payload,
f"TURZX USB {name} command write failed",
timeout_ms=USB_COMMAND_TIMEOUT_MS,
)
time.sleep(USB_COMMAND_GAP_S)
self._drain_input(attempts=5)
return b""
return self._write_payload_checked(
payload,
f"TURZX USB {name} command timed out",
timeout_ms=USB_COMMAND_TIMEOUT_MS,
)
def _send_optional_command(self, command_id: int, name: str, fields: dict[int, int] | None = None) -> None:
try:
self._send_command(command_id, name, fields, expect_response=False)
except RuntimeError as exc:
print(f"Warning: optional TURZX USB {name} command skipped: {exc}")
def _reset_and_reconnect(self) -> None:
import usb.util
if self.dev is not None:
try:
self.dev.reset()
except Exception as exc:
print(f"USB reset failed: {exc}")
try:
usb.util.dispose_resources(self.dev)
except Exception:
pass
time.sleep(1.5)
self._connect_device()
def _add_libusb_search_path(self) -> None:
libusb = VENDOR_ROOT / "external" / "libusb-1.0" / "libusb-1.0.dll"
if not libusb.exists():
return
dll_dir = str(libusb.parent)
path_entries = os.environ.get("PATH", "").split(os.pathsep)
if dll_dir not in path_entries:
os.environ["PATH"] = dll_dir + os.pathsep + os.environ.get("PATH", "")
if hasattr(os, "add_dll_directory") and self._dll_dir_handle is None:
self._dll_dir_handle = os.add_dll_directory(dll_dir)
def send_png(self, frame: bytes) -> None:
if self.dev is None or self._send_image is None:
raise RuntimeError("USB display is not open")
try:
self._send_frame(self._cmd_upload_png, frame)
except Exception as exc:
self._handle_frame_error(exc)
def send_jpeg(self, frame: bytes) -> None:
if self.dev is None or self._send_jpeg is None:
raise RuntimeError("USB display is not open")
try:
self._send_frame(self._cmd_upload_jpeg, frame)
except Exception as exc:
self._handle_frame_error(exc)
def encode_jpeg(self, rgba: Any, width: int, height: int) -> bytes:
if self.jpeg_encoder == "turbojpeg" or (
self.jpeg_encoder == "auto" and not self._turbojpeg_unavailable
):
try:
return self._encode_jpeg_turbojpeg(rgba, width, height)
except ImportError:
if self.jpeg_encoder == "turbojpeg":
raise
self._turbojpeg_unavailable = True
except Exception:
if self.jpeg_encoder == "turbojpeg":
raise
self._turbojpeg_unavailable = True
return self._encode_jpeg_pillow(rgba, width, height)
def _encode_jpeg_turbojpeg(self, rgba: Any, width: int, height: int) -> bytes:
profile_stage = self._profile_start()
import numpy as np
import turbojpeg # type: ignore
self._profile_add("usb.encode.turbojpeg_import", profile_stage)
profile_stage = self._profile_start()
rgba_array = np.frombuffer(rgba, dtype=np.uint8).reshape((height, width, 4))
self._profile_add("usb.encode.turbojpeg_rgba_view", profile_stage)
profile_stage = self._profile_start()
jpeg = self._turbojpeg_encode_array(turbojpeg, rgba_array)
self._profile_add("usb.encode.turbojpeg_encode", profile_stage)
return jpeg
def _turbojpeg_encode_array(self, turbojpeg_module, rgba_array) -> bytes:
if hasattr(turbojpeg_module, "TurboJPEG"):
if self._turbojpeg is None:
self._turbojpeg = turbojpeg_module.TurboJPEG()
pixel_format = getattr(turbojpeg_module, "TJPF_RGBA", None)
jpeg_subsample = getattr(turbojpeg_module, "TJSAMP_420", None)
kwargs = {"quality": int(self.jpeg_quality)}
if pixel_format is not None:
kwargs["pixel_format"] = pixel_format
if jpeg_subsample is not None:
kwargs["jpeg_subsample"] = jpeg_subsample
return self._turbojpeg.encode(rgba_array, **kwargs)
compress = getattr(turbojpeg_module, "compress", None)
if compress is not None:
kwargs = {"quality": int(self.jpeg_quality)}
if hasattr(turbojpeg_module, "PF"):
kwargs["pixelformat"] = turbojpeg_module.PF.RGBA
if hasattr(turbojpeg_module, "SAMP"):
kwargs["subsamp"] = turbojpeg_module.SAMP.Y420
return compress(rgba_array, **kwargs)
raise RuntimeError("unsupported turbojpeg Python API")
def _encode_jpeg_pillow(self, rgba: Any, width: int, height: int) -> bytes:
from PIL import Image
profile_stage = self._profile_start()
image = Image.frombuffer("RGB", (width, height), rgba, "raw", "RGBX", 0, 1)
self._profile_add("usb.encode.rgba_to_rgbx_view", profile_stage)
buffer = self._jpeg_buffer
buffer.seek(0)
buffer.truncate(0)
profile_stage = self._profile_start()
image.save(
buffer,
format="JPEG",
quality=self.jpeg_quality,
optimize=False,
progressive=False,
subsampling=2,
)
self._profile_add("usb.encode.jpeg_save", profile_stage)
profile_stage = self._profile_start()
jpeg = buffer.getvalue()
self._profile_add("usb.encode.getvalue", profile_stage)
return jpeg
def _cache_out_endpoint(self) -> None:
import usb.util
cfg = self.dev.get_active_configuration()
intf = usb.util.find_descriptor(cfg, bInterfaceNumber=0)
if intf is None:
raise RuntimeError("USB interface 0 not found")
self._ep_out = usb.util.find_descriptor(
intf,
custom_match=lambda endpoint: usb.util.endpoint_direction(
endpoint.bEndpointAddress
)
== usb.util.ENDPOINT_OUT,
)
if self._ep_out is None:
raise RuntimeError("Could not find USB OUT endpoint")
self._ep_in = usb.util.find_descriptor(
intf,
custom_match=lambda endpoint: usb.util.endpoint_direction(
endpoint.bEndpointAddress
)
== usb.util.ENDPOINT_IN,
)
if self._ep_in is None:
raise RuntimeError("Could not find USB IN endpoint")
def _drain_input(self, attempts: int = 3, timeout_ms: int = 20) -> None:
if self._ep_in is None or attempts <= 0:
return
for _ in range(attempts):
try:
self._ep_in.read(512, timeout_ms)
except Exception:
return
def _clear_endpoint_halt(self) -> None:
if self.dev is None:
return
for endpoint in (self._ep_out, self._ep_in):
if endpoint is None:
continue
try:
self.dev.clear_halt(endpoint.bEndpointAddress)
except Exception:
pass
def _write_payload_checked(self, payload: bytes, error_message: str, timeout_ms: int) -> bytes:
if self._ep_out is None or self._ep_in is None:
raise RuntimeError("USB endpoints are not open")
profile_stage = self._profile_start()
self._clear_endpoint_halt()
self._drain_input()
self._profile_add("usb.write_checked.prepare", profile_stage)
try:
profile_stage = self._profile_start()
self._ep_out.write(payload, timeout_ms)
self._profile_add("usb.write_checked.write", profile_stage)
profile_stage = self._profile_start()
response = bytes(self._ep_in.read(512, timeout_ms))
self._profile_add("usb.write_checked.read_ack", profile_stage)
return response
except Exception as exc:
raise RuntimeError(error_message) from exc
def _write_payload_no_ack(self, payload: bytes, error_message: str, timeout_ms: int) -> None:
if self._ep_out is None:
raise RuntimeError("USB OUT endpoint is not open")
profile_stage = self._profile_start()
self._clear_endpoint_halt()
self._drain_input()
self._profile_add("usb.write_no_ack.prepare", profile_stage)
try:
profile_stage = self._profile_start()
self._ep_out.write(payload, timeout_ms)
self._profile_add("usb.write_no_ack.write", profile_stage)
except Exception as exc:
raise RuntimeError(error_message) from exc
def _build_frame_payload(self, command_id: int, frame: bytes) -> bytes:
if self._build_command_packet_header is None or self._encrypt_command_packet is None:
raise RuntimeError("USB command helpers are not initialized")
frame_size = len(frame)
profile_stage = self._profile_start()
cmd_packet = self._build_command_packet_header(command_id)
cmd_packet[8] = (frame_size >> 24) & 0xFF
cmd_packet[9] = (frame_size >> 16) & 0xFF
cmd_packet[10] = (frame_size >> 8) & 0xFF
cmd_packet[11] = frame_size & 0xFF
payload = self._encrypt_command_packet(cmd_packet) + frame
self._profile_add("usb.frame.build_payload", profile_stage)
return payload
def _send_frame(self, command_id: int, frame: bytes) -> None:
if self.wait_for_frame_ack:
response = (
self._send_frame_fast(command_id, frame)
if self.fast_write
else self._send_frame_ack(command_id, frame)
)
self._check_frame_response(response)
else:
self._send_frame_no_ack(command_id, frame, drain_input=not self.fast_write)
self._frame_error_count = 0
def _send_frame_ack(self, command_id: int, frame: bytes) -> bytes:
return self._write_payload_checked(
self._build_frame_payload(command_id, frame),
"TURZX USB frame upload timed out",
timeout_ms=USB_FRAME_TIMEOUT_MS,
)
def _send_frame_fast(self, command_id: int, frame: bytes) -> bytes:
if self._ep_out is None:
raise RuntimeError("USB OUT endpoint is not open")
profile_stage = self._profile_start()
self._clear_endpoint_halt()
self._drain_input()
self._profile_add("usb.frame_fast.prepare", profile_stage)
profile_stage = self._profile_start()
payload = self._build_frame_payload(command_id, frame)
self._profile_add("usb.frame_fast.payload", profile_stage)
profile_stage = self._profile_start()
self._ep_out.write(payload, USB_FRAME_TIMEOUT_MS)
self._profile_add("usb.frame_fast.write", profile_stage)
profile_stage = self._profile_start()
response = bytes(self._ep_in.read(512, USB_FRAME_TIMEOUT_MS))
self._profile_add("usb.frame_fast.read_ack", profile_stage)
return response
def _send_frame_no_ack(self, command_id: int, frame: bytes, *, drain_input: bool) -> None:
if self._ep_out is None:
raise RuntimeError("USB OUT endpoint is not open")
profile_stage = self._profile_start()
if drain_input:
self._drain_input(
attempts=self.frame_drain_attempts,
timeout_ms=self.frame_drain_timeout_ms,
)
else:
self._drain_input(
attempts=self.fast_frame_drain_attempts,
timeout_ms=self.fast_frame_drain_timeout_ms,
)
self._profile_add("usb.frame_no_ack.drain_input", profile_stage)
profile_stage = self._profile_start()
payload = self._build_frame_payload(command_id, frame)
self._profile_add("usb.frame_no_ack.payload", profile_stage)
profile_stage = self._profile_start()
self._ep_out.write(payload, USB_FRAME_TIMEOUT_MS)
self._profile_add("usb.frame_no_ack.write", profile_stage)
def _check_frame_response(self, response: bytes | None) -> None:
if not response:
raise RuntimeError("TURZX USB frame upload timed out")
self._frame_error_count = 0
def _handle_frame_error(self, exc: Exception) -> None:
self._frame_error_count += 1
print(
f"USB frame upload failed "
f"({self._frame_error_count}/{MAX_CONSECUTIVE_FRAME_ERRORS}): {exc}",
flush=True,
)
try:
self._clear_endpoint_halt()
self._reset_and_reconnect()
self._initialize_device()
except Exception as reset_exc:
print(f"USB recovery failed: {reset_exc}", flush=True)
if self._frame_error_count >= MAX_CONSECUTIVE_FRAME_ERRORS:
raise RuntimeError(
"TURZX USB display is not accepting frame data. "
"Unplug/replug the display, then retry with lower --fps "
"or lower --usb-jpeg-quality."
) from exc

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
import threading
import time
from cluster_usb_display import TuringUsbDisplay
class AsyncJpegUsbPipeline:
def __init__(self, usb_display: TuringUsbDisplay) -> None:
self.usb_display = usb_display
self._condition = threading.Condition()
self._pending_rgba: tuple[bytes, int, int] | None = None
self._closing = False
self._error: BaseException | None = None
self._samples: list[tuple[str, float]] = []
self._thread = threading.Thread(target=self._run, name="cluster-usb-jpeg", daemon=True)
def start(self) -> None:
self._thread.start()
def submit_rgba(self, rgba: bytes, width: int, height: int) -> None:
self.check_error()
with self._condition:
self._pending_rgba = (rgba, width, height)
self._condition.notify()
def wait_for_capacity(self, timeout: float | None = None) -> bool:
deadline = None if timeout is None else time.perf_counter() + max(0.0, timeout)
with self._condition:
while self._pending_rgba is not None and not self._closing and self._error is None:
if deadline is None:
self._condition.wait()
continue
remaining = deadline - time.perf_counter()
if remaining <= 0.0:
return False
self._condition.wait(timeout=remaining)
if self._error is not None:
raise RuntimeError("asynchronous USB JPEG pipeline failed") from self._error
return self._pending_rgba is None
def profile_samples(self) -> tuple[tuple[str, float], ...]:
with self._condition:
samples = tuple(self._samples)
self._samples.clear()
return samples
def check_error(self) -> None:
with self._condition:
error = self._error
if error is not None:
raise RuntimeError("asynchronous USB JPEG pipeline failed") from error
def close(self) -> None:
with self._condition:
self._closing = True
self._condition.notify()
self._thread.join(timeout=3.0)
def _add_sample(self, name: str, start_time: float) -> None:
milliseconds = (time.perf_counter() - start_time) * 1000.0
with self._condition:
self._samples.append((name, milliseconds))
def _add_samples(self, samples: tuple[tuple[str, float], ...]) -> None:
if not samples:
return
with self._condition:
self._samples.extend(samples)
def _take_pending(self) -> tuple[bytes, int, int] | None:
with self._condition:
while self._pending_rgba is None and not self._closing:
self._condition.wait(timeout=0.1)
if self._pending_rgba is None:
return None
pending = self._pending_rgba
self._pending_rgba = None
self._condition.notify_all()
return pending
def _run(self) -> None:
while True:
pending = self._take_pending()
if pending is None:
return
rgba, width, height = pending
try:
self.usb_display.clear_profile_samples()
profile_stage = time.perf_counter()
jpeg = self.usb_display.encode_jpeg(rgba, width, height)
self._add_sample("usb_async.encode_jpeg", profile_stage)
profile_stage = time.perf_counter()
self.usb_display.send_jpeg(jpeg)
self._add_sample("usb_async.send_jpeg", profile_stage)
self._add_samples(self.usb_display.profile_samples())
self.usb_display.clear_profile_samples()
except BaseException as exc:
with self._condition:
self._error = exc
self._condition.notify_all()
return

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from cluster_config import (
TURN_SIGNAL_BLINK_ON_SECONDS,
TURN_SIGNAL_BLINK_PERIOD_SECONDS,
)
def clamp(value: float, low: float, high: float) -> float:
return max(low, min(high, value))
def smoothstep(value: float) -> float:
value = clamp(value, 0.0, 1.0)
return value * value * (3.0 - 2.0 * value)
def blend_color(
color: tuple[int, int, int],
target: tuple[int, int, int],
amount: float,
) -> tuple[int, int, int]:
amount = clamp(amount, 0.0, 1.0)
return tuple(
int(round(channel + (target_channel - channel) * amount))
for channel, target_channel in zip(color, target)
)
def lighten(color: tuple[int, int, int], amount: float) -> tuple[int, int, int]:
return blend_color(color, (255, 255, 255), amount)
def darken(color: tuple[int, int, int], amount: float) -> tuple[int, int, int]:
return blend_color(color, (0, 0, 0), amount)
def blink_visible(now: float, started_at: float, until: float) -> bool:
if now >= until:
return False
elapsed = now - started_at
return elapsed % TURN_SIGNAL_BLINK_PERIOD_SECONDS < TURN_SIGNAL_BLINK_ON_SECONDS

View File

@@ -0,0 +1,626 @@
from __future__ import annotations
import argparse
import gc
from dataclasses import replace
import time
from pathlib import Path
from cluster_config import (
CLUSTER_LIVE_FPS_PARAM,
CLUSTER_THEME_PARAM,
DESIGN_HEIGHT,
DESIGN_WIDTH,
normalize_cluster_live_fps,
normalize_cluster_theme_mode,
)
from cluster_gamepad import DualSenseSimulator
from cluster_git_status import GitBranchStatusProvider
from cluster_live import OpenpilotLiveSource
from cluster_models import RouteOverlay, SimulatorInput
from cluster_profile import GcProfileHook, ProfileReporter, freeze_gc_after_init
from cluster_renderer import ClusterUiRenderer
from cluster_route_replay import RouteReplaySource
from cluster_simulator import ClusterSimulator, RandomInputSource
from cluster_usb_display import TuringUsbDisplay
from cluster_usb_pipeline import AsyncJpegUsbPipeline
DEFAULT_FPS = 0.0
THEME_PARAM_POLL_SECONDS = 1.0
FPS_PARAM_POLL_SECONDS = 1.0
class ClusterThemeParamReader:
def __init__(self) -> None:
self._params = None
try:
from openpilot.common.params import Params
self._params = Params()
except Exception:
pass
def read(self) -> str:
if self._params is None:
return "auto"
try:
return normalize_cluster_theme_mode(self._params.get_int(CLUSTER_THEME_PARAM))
except Exception:
return "auto"
class ClusterLiveFpsParamReader:
def __init__(self) -> None:
self._params = None
try:
from openpilot.common.params import Params
self._params = Params()
except Exception:
pass
def read(self) -> float:
if self._params is None:
return 0.0
try:
return normalize_cluster_live_fps(self._params.get_int(CLUSTER_LIVE_FPS_PARAM))
except Exception:
return 0.0
def route_overlay_for_mode(overlay: RouteOverlay | None, mode: str) -> RouteOverlay | None:
if overlay is None or mode == "off":
return None
if mode == "compact":
return replace(overlay, data_lines=overlay.data_lines[:4])
return overlay
def run_demo(
duration_seconds: float | None,
target_fps: float,
live_fps_param_reader: ClusterLiveFpsParamReader | None,
input_mode: str,
output_mode: str,
controller_index: int,
width: int | None,
height: int | None,
usb_brightness: int,
usb_display_fps: int,
usb_codec: str,
usb_jpeg_quality: int,
usb_jpeg_encoder: str,
usb_fast_write: bool,
usb_wait_frame_ack: bool,
usb_async: bool,
usb_frame_drain_attempts: int,
usb_frame_drain_timeout_ms: int,
usb_fast_drain_attempts: int,
usb_fast_drain_timeout_ms: int,
route_path: Path,
route_log: str,
route_overlay_mode: str,
route_loop: bool,
route_replay_speed: float,
route_start_segment: int | None,
route_max_segments: int | None,
live_include_can: bool,
live_timeout_ms: int,
profile_render: bool,
profile_interval_s: float,
render_msaa: bool,
gc_freeze_init: bool,
theme_mode: str | None,
) -> None:
profile = ProfileReporter(profile_render, profile_interval_s)
gc_hook = GcProfileHook(profile) if profile_render else None
if gc_hook is not None:
gc.callbacks.append(gc_hook)
usb_display: TuringUsbDisplay | None = None
usb_pipeline: AsyncJpegUsbPipeline | None = None
if output_mode in ("usb", "both"):
usb_display = TuringUsbDisplay(
brightness=usb_brightness,
display_fps=usb_display_fps,
jpeg_quality=usb_jpeg_quality,
jpeg_encoder=usb_jpeg_encoder,
fast_write=usb_fast_write,
wait_for_frame_ack=usb_wait_frame_ack,
frame_drain_attempts=usb_frame_drain_attempts,
frame_drain_timeout_ms=usb_frame_drain_timeout_ms,
fast_frame_drain_attempts=usb_fast_drain_attempts,
fast_frame_drain_timeout_ms=usb_fast_drain_timeout_ms,
)
usb_display.set_profile_enabled(profile_render)
profile_stage = time.perf_counter()
usb_display.open()
profile.add_elapsed("usb.open", profile_stage)
profile.add_samples(usb_display.profile_samples())
usb_display.clear_profile_samples()
if usb_async and usb_codec == "jpeg":
usb_pipeline = AsyncJpegUsbPipeline(usb_display)
usb_pipeline.start()
frame_width = width or (usb_display.landscape_width if usb_display is not None else DESIGN_WIDTH)
frame_height = height or (usb_display.landscape_height if usb_display is not None else DESIGN_HEIGHT)
theme_override = normalize_cluster_theme_mode(theme_mode) if theme_mode is not None else None
theme_param_reader = ClusterThemeParamReader() if theme_override is None else None
active_theme_mode = theme_override or (theme_param_reader.read() if theme_param_reader is not None else "auto")
renderer = ClusterUiRenderer(
frame_width,
frame_height,
target_fps=max(0, int(round(target_fps))),
msaa_4x=render_msaa,
theme_mode=active_theme_mode,
)
renderer.set_profile_enabled(profile_render)
git_status_provider = GitBranchStatusProvider(Path(__file__).resolve().parent)
simulator = ClusterSimulator() if input_mode in ("random", "gamepad") else None
controller = DualSenseSimulator(controller_index) if input_mode == "gamepad" else None
random_input = RandomInputSource() if input_mode == "random" else None
live_source = OpenpilotLiveSource(include_can=live_include_can, timeout_ms=live_timeout_ms) if input_mode == "live" else None
route_source = None
if input_mode == "route":
profile_stage = time.perf_counter()
route_source = RouteReplaySource.load(route_path, route_log, route_start_segment, route_max_segments)
profile.add_elapsed("source.route_load_initial", profile_stage)
if route_source is not None:
print(
f"Loaded route replay buffer: {len(route_source.frames)} frames, "
f"{route_source.duration:.1f}s from "
f"{route_source.loaded_file_count}/{len(route_source.source_files)} {route_log} files"
)
start_time = time.perf_counter()
last_frame_time = start_time
last_report_time = start_time
next_theme_param_read = start_time
next_fps_param_read = start_time + FPS_PARAM_POLL_SECONDS
report_frames = 0
frame_interval = 1.0 / target_fps if target_fps > 0 else 0.0
try:
renderer.open(hidden=output_mode == "usb")
profile.add_samples(renderer.profile_samples())
renderer.clear_profile_samples()
if gc_freeze_init:
freeze_gc_after_init(profile)
while True:
frame_start_time = time.perf_counter()
renderer.clear_profile_samples()
if usb_display is not None and usb_pipeline is None:
usb_display.clear_profile_samples()
if usb_pipeline is not None:
usb_pipeline.check_error()
profile.add_samples(usb_pipeline.profile_samples())
if output_mode in ("window", "both") and renderer.should_close():
break
now = time.perf_counter()
if theme_override is None and now >= next_theme_param_read:
next_theme_mode = theme_param_reader.read() if theme_param_reader is not None else "auto"
if next_theme_mode != renderer.theme_mode:
renderer.set_theme_mode(next_theme_mode)
next_theme_param_read = now + THEME_PARAM_POLL_SECONDS
if live_fps_param_reader is not None and now >= next_fps_param_read:
next_target_fps = live_fps_param_reader.read()
if next_target_fps != target_fps:
target_fps = next_target_fps
frame_interval = 1.0 / target_fps if target_fps > 0 else 0.0
renderer.set_target_fps(max(0, int(round(target_fps))))
fps_text = "uncapped" if target_fps == 0 else f"{target_fps:.1f} Hz"
print(f"{CLUSTER_LIVE_FPS_PARAM} updated: {fps_text}", flush=True)
next_fps_param_read = now + FPS_PARAM_POLL_SECONDS
if duration_seconds is not None and now - start_time >= duration_seconds:
break
dt = max(0.001, now - last_frame_time)
last_frame_time = now
if live_source is not None:
profile_stage = time.perf_counter()
state = live_source.update()
state = replace(state, center_clock_text=time.strftime("%H:%M:%S"))
source_status = live_source.status_text()
profile.add_elapsed("source.live_update", profile_stage)
elif route_source is not None:
profile_stage = time.perf_counter()
playback_seconds = (now - start_time) * route_replay_speed
if route_source.is_finished(playback_seconds, route_loop):
break
state = route_source.state_at(
playback_seconds,
route_loop,
include_overlay=route_overlay_mode != "off",
)
state = replace(state, route_overlay=route_overlay_for_mode(state.route_overlay, route_overlay_mode))
source_status = route_source.status_text(playback_seconds, route_loop)
profile.add_elapsed("source.route_update", profile_stage)
elif controller is None:
profile_stage = time.perf_counter()
command = random_input.update(dt) if random_input is not None else SimulatorInput()
source_status = (
f"random R2={command.throttle:.2f} "
f"L2={command.brake:.2f} LSX={command.steering:+.2f}"
)
if simulator is None:
raise RuntimeError("simulator is not available for random input")
state = simulator.update(command, dt)
profile.add_elapsed("source.random_update", profile_stage)
else:
profile_stage = time.perf_counter()
command = controller.read_input()
source_status = controller.status_text()
if simulator is None:
raise RuntimeError("simulator is not available for gamepad input")
state = simulator.update(command, dt)
profile.add_elapsed("source.gamepad_update", profile_stage)
state = replace(state, git_status=git_status_provider.status())
if output_mode in ("window", "both"):
profile_stage = time.perf_counter()
renderer.render_frame(state)
profile.add_elapsed("main.window_render_total", profile_stage)
if usb_display is not None:
if usb_codec == "jpeg":
if usb_pipeline is not None:
profile_stage = time.perf_counter()
usb_pipeline.wait_for_capacity()
profile.add_elapsed("main.usb_async.wait_capacity", profile_stage)
profile.add_samples(usb_pipeline.profile_samples())
profile_stage = time.perf_counter()
rgba, image_width, image_height = renderer.render_to_rgba_bytes(
state,
portrait_upload=True,
)
profile.add_elapsed("main.usb.render_rgba_total", profile_stage)
profile_stage = time.perf_counter()
usb_pipeline.submit_rgba(rgba, image_width, image_height)
profile.add_elapsed("main.usb_async.submit_rgba", profile_stage)
else:
profile_stage = time.perf_counter()
with renderer.render_to_rgba_buffer(state, portrait_upload=True) as (
rgba,
image_width,
image_height,
):
profile.add_elapsed("main.usb.render_rgba_total", profile_stage)
profile_stage = time.perf_counter()
jpeg = usb_display.encode_jpeg(rgba, image_width, image_height)
profile.add_elapsed("main.usb.encode_jpeg", profile_stage)
profile_stage = time.perf_counter()
usb_display.send_jpeg(jpeg)
profile.add_elapsed("main.usb.send_jpeg", profile_stage)
else:
profile_stage = time.perf_counter()
png = renderer.render_to_png_bytes(state, portrait_upload=True)
profile.add_elapsed("main.usb.render_png_total", profile_stage)
profile_stage = time.perf_counter()
usb_display.send_png(png)
profile.add_elapsed("main.usb.send_png", profile_stage)
if usb_pipeline is not None:
profile.add_samples(usb_pipeline.profile_samples())
else:
profile.add_samples(usb_display.profile_samples())
profile.add_samples(renderer.profile_samples())
report_frames += 1
profile.add_elapsed("main.frame_active", frame_start_time)
if frame_interval > 0.0:
elapsed = time.perf_counter() - frame_start_time
remaining = frame_interval - elapsed
if remaining > 0.0:
profile_stage = time.perf_counter()
time.sleep(remaining)
profile.add_elapsed("main.sleep", profile_stage)
now = time.perf_counter()
profile.add_elapsed("main.frame_total", frame_start_time)
profile.frame_done()
profile.maybe_report(now)
if now - last_report_time >= 2.0:
actual_fps = report_frames / (now - last_report_time)
lane_status = state.lane_change or (
"keep" if state.lane_change_phase == "idle" else state.lane_change_phase
)
print(
f"Refresh {actual_fps:.1f} Hz | "
f"speed={state.speed_kph:5.1f} km/h "
f"accel={state.accel_mps2:+.2f} m/s^2 "
f"limit={state.speed_limit_kph} "
f"lane={lane_status}:{state.lane_change_progress:.2f} "
f"ego_offset={state.ego_lane_offset:+.2f} | "
f"output={output_mode}/{usb_codec if usb_display else 'screen'}"
f"{'-fast' if usb_display and usb_fast_write else ''} "
f"{'async ' if usb_pipeline is not None else ''}"
f"theme={renderer.theme_mode} "
f"view_yaw={state.surround_yaw_deg:+.0f} "
f"{source_status}"
)
report_frames = 0
last_report_time = now
finally:
if gc_hook is not None:
try:
gc.callbacks.remove(gc_hook)
except ValueError:
pass
if usb_pipeline is not None:
usb_pipeline.close()
if controller is not None:
controller.close()
if route_source is not None:
route_source.close()
if live_source is not None:
live_source.close()
renderer.close()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument(
"--fps",
type=float,
default=None,
help=(
"Target refresh rate. Use 0 for uncapped/as-fast-as-possible. "
f"Default: 0, except live input reads {CLUSTER_LIVE_FPS_PARAM} when --fps is omitted."
),
)
parser.add_argument(
"--duration",
type=float,
default=None,
help="Run for this many seconds. Omit to refresh until the window is closed.",
)
parser.add_argument(
"--input",
choices=("random", "gamepad", "route", "live"),
default="random",
help="Input source. Use --input live for live openpilot cereal data, or route to replay logs.",
)
parser.add_argument(
"--output",
choices=("usb", "window", "both"),
default="usb",
help="Render target. usb sends frames to the TURZX USB display. Default: usb.",
)
parser.add_argument(
"--controller-index",
type=int,
default=0,
help="pygame joystick index for the DualSense controller.",
)
parser.add_argument("--width", type=int, default=None)
parser.add_argument("--height", type=int, default=None)
parser.add_argument("--usb-brightness", type=int, default=80)
parser.add_argument(
"--usb-display-fps",
type=int,
default=0,
help="Optional TURZX display frame-rate command. Default 0 skips it because some units do not ACK it.",
)
parser.add_argument("--usb-codec", choices=("jpeg", "png"), default="jpeg")
parser.add_argument("--usb-jpeg-quality", type=int, default=68)
parser.add_argument(
"--usb-jpeg-encoder",
choices=("auto", "pillow", "turbojpeg"),
default="auto",
help="JPEG encoder for USB output. auto tries turbojpeg first and falls back to Pillow.",
)
parser.add_argument(
"--usb-fast",
action="store_true",
help="Use short pre-write USB input drain before frame uploads. Useful only after no-ACK USB output is stable.",
)
parser.add_argument(
"--usb-wait-frame-ack",
action="store_true",
help="Wait for a TURZX response after each frame upload. Default skips ACK because some units never reply.",
)
parser.add_argument(
"--usb-async",
action="store_true",
help="Encode and send JPEG USB frames on a background thread to overlap transport with the next render.",
)
parser.add_argument(
"--usb-frame-drain-attempts",
type=int,
default=2,
help="IN endpoint drain read attempts before normal no-ACK frame uploads. Default: 2.",
)
parser.add_argument(
"--usb-frame-drain-timeout-ms",
type=int,
default=2,
help="Per-read IN endpoint drain timeout before normal no-ACK frame uploads. Default: 2.",
)
parser.add_argument(
"--usb-fast-drain-attempts",
type=int,
default=3,
help="IN endpoint drain read attempts before --usb-fast no-ACK frame uploads. Default: 3.",
)
parser.add_argument(
"--usb-fast-drain-timeout-ms",
type=int,
default=2,
help="Per-read IN endpoint drain timeout before --usb-fast no-ACK frame uploads. Default: 2.",
)
parser.add_argument(
"--route",
type=Path,
default=Path("route"),
help="Route directory or log file to replay when --input route is selected.",
)
parser.add_argument(
"--route-log",
choices=("qlog", "rlog"),
default="rlog",
help="Route log type to read. rlog has full corner radar data; qlog is faster but downsampled.",
)
parser.add_argument(
"--route-overlay",
choices=("compact", "full", "off"),
default="compact",
help="Route replay debug overlay. Default compact shows the replay camera/data panel; use off for performance tests.",
)
parser.add_argument(
"--theme",
choices=("auto", "dark", "light"),
default=None,
help=f"HUD theme override. Default reads {CLUSTER_THEME_PARAM}: 0 auto, 1 dark, 2 light.",
)
parser.add_argument(
"--route-loop",
action="store_true",
help="Loop route replay instead of stopping at the end.",
)
parser.add_argument(
"--route-replay-speed",
type=float,
default=1.0,
help="Route playback speed multiplier.",
)
parser.add_argument(
"--route-start-segment",
type=int,
default=None,
help="First segment index to replay.",
)
parser.add_argument(
"--route-max-segments",
type=int,
default=None,
help="Maximum number of route segments to replay.",
)
parser.add_argument(
"--live-no-can",
action="store_true",
help="Disable live CAN subscription. This keeps radarState/modelV2/liveTracks data but skips direct raw CAN-FD parsing.",
)
parser.add_argument(
"--live-timeout-ms",
type=int,
default=0,
help="SubMaster update timeout for --input live. Default 0 keeps rendering responsive.",
)
parser.add_argument(
"--profile-render",
action="store_true",
help="Log render, GPU readback, USB encode/send, and input source timings.",
)
parser.add_argument(
"--profile-interval",
type=float,
default=2.0,
help="Seconds between --profile-render timing summaries. Default: 2.0.",
)
parser.add_argument(
"--render-msaa",
action="store_true",
help="Enable raylib 4x MSAA config hint. Default off for maximum SD845 throughput.",
)
parser.add_argument(
"--no-gc-freeze",
action="store_true",
help="Disable post-init gc.freeze(). Default enabled to avoid long gen2 pauses during USB rendering.",
)
args = parser.parse_args()
args.fps_from_cli = args.fps is not None
if args.fps is None:
args.fps = DEFAULT_FPS
if args.fps < 0:
parser.error("--fps must be 0 or greater")
if (args.width is not None and args.width <= 0) or (args.height is not None and args.height <= 0):
parser.error("--width and --height must be greater than 0")
if not 0 <= args.usb_brightness <= 100:
parser.error("--usb-brightness must be between 0 and 100")
if not 0 <= args.usb_display_fps <= 255:
parser.error("--usb-display-fps must be between 0 and 255")
if not 1 <= args.usb_jpeg_quality <= 95:
parser.error("--usb-jpeg-quality must be between 1 and 95")
if args.usb_async and args.usb_codec != "jpeg":
parser.error("--usb-async only supports --usb-codec jpeg")
if args.usb_frame_drain_attempts < 0 or args.usb_fast_drain_attempts < 0:
parser.error("USB drain attempts must be 0 or greater")
if args.usb_frame_drain_timeout_ms < 0 or args.usb_fast_drain_timeout_ms < 0:
parser.error("USB drain timeouts must be 0 or greater")
if args.input == "route" and args.route_replay_speed <= 0:
parser.error("--route-replay-speed must be greater than 0")
if args.route_start_segment is not None and args.route_start_segment < 0:
parser.error("--route-start-segment must be 0 or greater")
if args.route_max_segments is not None and args.route_max_segments <= 0:
parser.error("--route-max-segments must be greater than 0")
if args.profile_interval <= 0:
parser.error("--profile-interval must be greater than 0")
return args
def main() -> None:
args = parse_args()
target_fps = args.fps
fps_source = "--fps" if args.fps_from_cli else "default"
live_fps_param_reader = None
if args.input == "live" and not args.fps_from_cli:
live_fps_param_reader = ClusterLiveFpsParamReader()
target_fps = live_fps_param_reader.read()
fps_source = CLUSTER_LIVE_FPS_PARAM
fps_text = "uncapped" if target_fps == 0 else f"{target_fps:.1f} Hz"
size_text = (
f"{args.width or 'device'}x{args.height or 'device'}"
if args.output in ("usb", "both")
else f"{args.width or DESIGN_WIDTH}x{args.height or DESIGN_HEIGHT}"
)
print(
f"Refreshing native raylib cluster UI at {fps_text} "
f"input={args.input} output={args.output}: {size_text} fps_source={fps_source}"
)
try:
run_demo(
args.duration,
target_fps,
live_fps_param_reader,
args.input,
args.output,
args.controller_index,
args.width,
args.height,
args.usb_brightness,
args.usb_display_fps,
args.usb_codec,
args.usb_jpeg_quality,
args.usb_jpeg_encoder,
args.usb_fast,
args.usb_wait_frame_ack,
args.usb_async,
args.usb_frame_drain_attempts,
args.usb_frame_drain_timeout_ms,
args.usb_fast_drain_attempts,
args.usb_fast_drain_timeout_ms,
args.route,
args.route_log,
args.route_overlay,
args.route_loop,
args.route_replay_speed,
args.route_start_segment,
args.route_max_segments,
not args.live_no_can,
args.live_timeout_ms,
args.profile_render,
args.profile_interval,
args.render_msaa,
not args.no_gc_freeze,
args.theme,
)
except KeyboardInterrupt:
print("\nStopped.")
except RuntimeError as exc:
raise SystemExit(f"Error: {exc}") from exc
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,8 @@
raylib==5.5.0.4
pygame==2.6.1
Pillow==12.2.0
pycryptodome==3.23.0
pyserial==3.5
pyusb==1.3.1
pycapnp==2.2.2
zstandard==0.25.0

View File

@@ -0,0 +1,296 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import select
import socket
import sys
import time
import traceback
from pathlib import Path
from openpilot.common.params import Params
CARROT_DIR = Path(__file__).resolve().parent
CLUSTER_DIR = CARROT_DIR / "cluster"
OPENPILOT_ROOT = CARROT_DIR.parents[1]
HUD_PARAM = "ClusterHud"
RETRY_INTERVAL_S = 5.0
HUD_CHECK_INTERVAL_S = 5.0
USB_FALLBACK_SCAN_INTERVAL_S = 60.0
NETLINK_KOBJECT_UEVENT = 15
AUTORUN_FPS_ENV = "CLUSTER_AUTORUN_FPS"
AUTORUN_DEFAULT_ENV = {
"CLUSTER_REALTIME": "0",
AUTORUN_FPS_ENV: "20",
}
def _ensure_cluster_paths() -> None:
for path in (OPENPILOT_ROOT, CLUSTER_DIR):
path_text = str(path)
if path_text not in sys.path:
sys.path.insert(0, path_text)
def _read_hud_mode(params: Params) -> int:
try:
return int(params.get_int(HUD_PARAM))
except Exception as exc:
print(f"[cluster_autorun] failed to read {HUD_PARAM}: {exc}", flush=True)
return 0
def _cluster_args() -> list[str]:
args = [
"--input",
"live",
"--output",
"usb",
"--usb-codec",
"jpeg",
"--usb-jpeg-quality",
"68",
"--live-no-can",
]
fps = os.environ.get(AUTORUN_FPS_ENV, "20").strip()
if fps:
args.extend(["--fps", fps])
return args
def _run_cluster_once() -> None:
from selfdrive.carrot import cluster_run
previous_argv = sys.argv[:]
previous_env = {key: os.environ.get(key) for key in AUTORUN_DEFAULT_ENV}
try:
for key, value in AUTORUN_DEFAULT_ENV.items():
os.environ.setdefault(key, value)
sys.argv = [previous_argv[0], *_cluster_args()]
cluster_run.main()
finally:
sys.argv = previous_argv
for key, value in previous_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def _open_usb_uevent_socket() -> socket.socket | None:
if not hasattr(socket, "AF_NETLINK"):
return None
last_error: OSError | None = None
for port_id in (0, os.getpid()):
sock: socket.socket | None = None
try:
sock = socket.socket(
socket.AF_NETLINK,
socket.SOCK_DGRAM,
getattr(socket, "NETLINK_KOBJECT_UEVENT", NETLINK_KOBJECT_UEVENT),
)
sock.bind((port_id, 1))
sock.setblocking(False)
return sock
except OSError as exc:
last_error = exc
try:
if sock is not None:
sock.close()
except Exception:
pass
print(f"[cluster_autorun] USB event monitor unavailable: {last_error}", flush=True)
return None
def _decode_uevent(payload: bytes) -> dict[str, str]:
event: dict[str, str] = {}
for part in payload.decode("utf-8", errors="replace").split("\0"):
if not part:
continue
if "=" in part:
key, value = part.split("=", 1)
event[key] = value
elif "@" in part:
event.setdefault("ACTION", part.split("@", 1)[0])
return event
def _parse_hex_int(value: str | None) -> int | None:
if not value:
return None
try:
return int(value, 16)
except ValueError:
return None
def _usb_uevent_matches(payload: bytes, expected_product_id: int) -> bool:
from cluster_usb_display import TURZX_USB_VENDOR_ID
event = _decode_uevent(payload)
if event.get("SUBSYSTEM") != "usb":
return False
action = event.get("ACTION")
if action not in ("add", "bind", "change", "move"):
return False
product = event.get("PRODUCT")
if product:
parts = product.split("/")
if len(parts) >= 2:
vendor_id = _parse_hex_int(parts[0])
product_id = _parse_hex_int(parts[1])
return vendor_id == TURZX_USB_VENDOR_ID and product_id == expected_product_id
vendor_id = _parse_hex_int(event.get("ID_VENDOR_ID"))
product_id = _parse_hex_int(event.get("ID_MODEL_ID"))
if vendor_id is not None or product_id is not None:
return vendor_id == TURZX_USB_VENDOR_ID and product_id == expected_product_id
return event.get("DEVTYPE") == "usb_device"
def _wait_for_usb_uevent(sock: socket.socket | None, timeout_s: float, expected_product_id: int) -> bool:
if timeout_s <= 0:
return False
if sock is None:
time.sleep(timeout_s)
return False
try:
readable, _, _ = select.select([sock], [], [], timeout_s)
except (OSError, ValueError) as exc:
print(f"[cluster_autorun] USB event wait failed: {exc}", flush=True)
time.sleep(timeout_s)
return False
if not readable:
return False
matched = False
while True:
try:
payload = sock.recv(8192)
except BlockingIOError:
return matched
except OSError as exc:
print(f"[cluster_autorun] USB event read failed: {exc}", flush=True)
return matched
matched = _usb_uevent_matches(payload, expected_product_id) or matched
def _wait_for_supported_usb_device(params: Params, expected_product_id: int, reason: str) -> int | None:
from cluster_usb_display import find_supported_usb_product, product_id_for_hud_mode, product_label
print(
f"[cluster_autorun] {product_label(expected_product_id)} {reason}; "
"waiting for USB event",
flush=True,
)
usb_events = _open_usb_uevent_socket()
if usb_events is None:
print(
f"[cluster_autorun] falling back to USB scan every {USB_FALLBACK_SCAN_INTERVAL_S:.0f}s",
flush=True,
)
else:
print(
f"[cluster_autorun] fallback USB scan every {USB_FALLBACK_SCAN_INTERVAL_S:.0f}s",
flush=True,
)
next_hud_check = time.monotonic()
next_fallback_scan = time.monotonic() + USB_FALLBACK_SCAN_INTERVAL_S
try:
while True:
now = time.monotonic()
if now >= next_hud_check:
hud_mode = _read_hud_mode(params)
current_product_id = product_id_for_hud_mode(hud_mode)
if current_product_id is None:
print(f"[cluster_autorun] {HUD_PARAM}={hud_mode}; stopping cluster HUD", flush=True)
return None
if current_product_id != expected_product_id:
expected_product_id = current_product_id
next_fallback_scan = now
next_hud_check = now + HUD_CHECK_INTERVAL_S
now = time.monotonic()
if now >= next_fallback_scan:
found_product_id = find_supported_usb_product(expected_product_id)
if found_product_id is not None:
return found_product_id
next_fallback_scan = now + USB_FALLBACK_SCAN_INTERVAL_S
wait_s = max(0.1, min(next_hud_check, next_fallback_scan) - time.monotonic())
if _wait_for_usb_uevent(usb_events, wait_s, expected_product_id):
found_product_id = find_supported_usb_product(expected_product_id)
if found_product_id is not None:
return found_product_id
finally:
if usb_events is not None:
usb_events.close()
def main() -> None:
_ensure_cluster_paths()
from cluster_usb_display import find_supported_usb_product, product_id_for_hud_mode, product_label
params = Params()
hud_mode = _read_hud_mode(params)
expected_product_id = product_id_for_hud_mode(hud_mode)
if expected_product_id is None:
print(f"[cluster_autorun] {HUD_PARAM}={hud_mode}; HUD disabled", flush=True)
return
found_product_id = find_supported_usb_product(expected_product_id)
if found_product_id is None:
found_product_id = _wait_for_supported_usb_device(
params,
expected_product_id,
"not found at startup",
)
if found_product_id is None:
return
print(f"[cluster_autorun] found {product_label(found_product_id)}; starting cluster HUD", flush=True)
while True:
hud_mode = _read_hud_mode(params)
expected_product_id = product_id_for_hud_mode(hud_mode)
if expected_product_id is None:
print(f"[cluster_autorun] {HUD_PARAM}={hud_mode}; stopping cluster HUD", flush=True)
return
if find_supported_usb_product(expected_product_id) is None:
found_product_id = _wait_for_supported_usb_device(
params,
expected_product_id,
"disconnected",
)
if found_product_id is None:
return
print(f"[cluster_autorun] found {product_label(found_product_id)}; starting cluster HUD", flush=True)
try:
_run_cluster_once()
print(
f"[cluster_autorun] cluster HUD exited; retrying in {RETRY_INTERVAL_S:.0f}s",
flush=True,
)
except Exception as exc:
print(
f"[cluster_autorun] cluster HUD failed: {exc}; retrying in {RETRY_INTERVAL_S:.0f}s",
flush=True,
)
traceback.print_exc()
time.sleep(RETRY_INTERVAL_S)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
from __future__ import annotations
import locale
import os
import sys
from pathlib import Path
CARROT_DIR = Path(__file__).resolve().parent
BUNDLE_DIR = CARROT_DIR / "cluster"
OPENPILOT_ROOT = CARROT_DIR.parents[1]
for path in (OPENPILOT_ROOT, BUNDLE_DIR):
path_text = str(path)
if path_text not in sys.path:
sys.path.insert(0, path_text)
def configure_cluster_locale() -> None:
for candidate in ("C.UTF-8", "C"):
try:
locale.setlocale(locale.LC_ALL, candidate)
except locale.Error:
continue
os.environ["LC_ALL"] = candidate
os.environ["LC_CTYPE"] = candidate
os.environ["LANG"] = candidate
return
def configure_cluster_realtime() -> None:
realtime_enabled = os.environ.get("CLUSTER_REALTIME", "0").strip().lower() in ("1", "true", "yes", "on")
if not realtime_enabled:
return
try:
from openpilot.common.realtime import config_realtime_process
cores_text = os.environ.get("CLUSTER_REALTIME_CORES", "0,1,2,3")
cores = [int(core.strip()) for core in cores_text.split(",") if core.strip()]
priority = int(os.environ.get("CLUSTER_REALTIME_PRIORITY", "55"))
config_realtime_process(cores, priority)
print(f"[cluster_run] realtime enabled cores={cores} priority={priority}", flush=True)
except Exception as exc:
print(f"[cluster_run] failed to configure realtime process: {exc}", flush=True)
def main() -> None:
configure_cluster_locale()
args = sys.argv[1:]
if "--input" not in args:
args = ["--input", "live", *args]
sys.argv = [sys.argv[0], *args]
configure_cluster_realtime()
from main import main as cluster_main
cluster_main()
if __name__ == "__main__":
main()

View File

@@ -2272,6 +2272,54 @@
"max": 3,
"default": 0,
"unit": 1
},
{
"group": "화면",
"egroup": "DISPLAY",
"cgroup": "显示",
"name": "ClusterHud",
"title": "외부 HUD 활성화(0)",
"descr": "0: 끄기\n1: TURZX 9.2인치\n2: TURZX 12.3인치",
"etitle": "External HUD Display(0)",
"edescr": "0: Off\n1: TURZX 9.2 inch\n2: TURZX 12.3 inch",
"ctitle": "外部 HUD 显示(0)",
"cdescr": "0: 关闭\n1: TURZX 9.2 英寸\n2: TURZX 12.3 英寸",
"min": 0,
"max": 2,
"default": 0,
"unit": 1
},
{
"group": "화면",
"egroup": "DISPLAY",
"cgroup": "显示",
"name": "ClusterHudTheme",
"title": "외부 HUD 테마(0)",
"descr": "0: 자동\n1: 다크\n2: 라이트",
"etitle": "External HUD Theme(0)",
"edescr": "0: Auto\n1: Dark\n2: Light",
"ctitle": "外部 HUD 主题(0)",
"cdescr": "0: 自动\n1: 深色\n2: 浅色",
"min": 0,
"max": 2,
"default": 0,
"unit": 1
},
{
"group": "화면",
"egroup": "DISPLAY",
"cgroup": "显示",
"name": "ClusterHudLiveFps",
"title": "외부 HUD 라이브 FPS 제한(0)",
"descr": "0: 무제한\n1: 10 FPS\n2: 20 FPS\n3: 30 FPS",
"etitle": "External HUD Live FPS Limit(0)",
"edescr": "0: Uncapped\n1: 10 FPS\n2: 20 FPS\n3: 30 FPS",
"ctitle": "外部 HUD 实时 FPS 限制(0)",
"cdescr": "0: 不限制\n1: 10 FPS\n2: 20 FPS\n3: 30 FPS",
"min": 0,
"max": 3,
"default": 0,
"unit": 1
}
]

View File

@@ -85,6 +85,12 @@ def enable_xiaoge_data(started, params, CP: car.CarParams) -> bool:
def enable_webrtc(started, params, CP: car.CarParams) -> bool:
return params.get_int("DisableDM") == 2
def enable_cluster_hud(started, params, CP: car.CarParams) -> bool:
try:
return params.get_int("ClusterHud") in (1, 2)
except Exception:
return False
procs = [
DaemonProcess("manage_athenad", "system.athena.manage_athenad", "AthenadPid"),
@@ -142,6 +148,7 @@ procs = [
PythonProcess("carrot_man", "selfdrive.carrot.carrot_man", always_run),#, enabled=not PC),
PythonProcess("carrot_server", "selfdrive.carrot.carrot_server", always_run),
PythonProcess("carrot_cluster", "selfdrive.carrot.cluster_autorun", enable_cluster_hud, restart_if_crash=True),
#Xiaoge data broadcaster (conditional on ShareData param)
PythonProcess("xiaoge_data", "selfdrive.carrot.xiaoge_data", enable_xiaoge_data),