"""Validated onroad colors and independent logical-pixel layouts.""" import copy import json import math import re from functools import lru_cache from typing import Any, NotRequired, TypedDict from openpilot.starpilot.saved_source import read_saved from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint from openpilot.starpilot.ui.large_speed_geometry import confirmation_bounds, confirmation_footprint from openpilot.starpilot.ui.onroad_stopped_timer import timer_size from openpilot.starpilot.ui.large_speed_geometry import (WIDTH, CARD_HEIGHT, ACTION_WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y, GAP, SOURCE_ROW_HEIGHT, sources_extent) PARAM_KEY = "OnroadCustomizations" MAX_BYTES = 16384 # Legacy saved layouts retain this field even though the paired speed widget uses fixed alignment. SPEED_ALIGNMENTS = ("left", "center", "right") PALETTE = {"cardFill": "#000000A6", "cardBorder": "#C4CDD0B4", "text": "#FFFFFFFF"} ROAD_COLORS = {"path": "#30FF9CFF", "pathEdge": "#00FF40FF", "laneLines": "#FFFFFFFF"} PATH_MODES = ("default", "color", "rainbow", "acceleration") WHEEL_SIZES = {"large": (144, 192, 240), "compact": (40, 50, 70)} DM_SIZES = {"large": (64, 128, 192), "compact": (40, 60, 90)} WIDGET_SIZES = {"steering_wheel": WHEEL_SIZES, "driver_monitor": DM_SIZES} class Bounds(TypedDict): x: float y: float width: float height: float class WidgetPlacement(TypedDict): x: float y: float enabled: bool size: NotRequired[int] width: NotRequired[int] height: NotRequired[int] opacity: NotRequired[int] class Widget(TypedDict): label: str kind: str width: float height: float default: WidgetPlacement bounds: NotRequired[Bounds] visualInsetTop: NotRequired[float] visualInsetLeft: NotRequired[float] layer: NotRequired[str] note: NotRequired[str] defaultAnchor: NotRequired[str] colors: NotRequired[dict[str, str | None]] resizable: NotRequired[dict[str, int]] sourcesExtent: NotRequired[dict[str, float]] confirmation: NotRequired[Bounds] confirmationObstacle: NotRequired[Bounds] box: NotRequired[dict[str, int]] opacity: NotRequired[dict[str, int]] anchorShift: NotRequired[float] expandable: NotRequired[bool] class LayoutProfile(TypedDict): label: str width: float height: float bounds: Bounds widgets: dict[str, Widget] reservedZones: list[Bounds] protectedWidget: NotRequired[str] inputZones: NotRequired[list[dict[str, Any]]] inputZonePriority: NotRequired[str] widgetOrder: NotRequired[list[str]] runtimePreviewWidgets: NotRequired[list[str]] def _widget(label: str, kind: str, width: float, height: float, x: float, y: float) -> Widget: return {"label": label, "kind": kind, "width": width, "height": height, "default": {"x": x, "y": y, "enabled": True}} PROFILES: dict[str, LayoutProfile] = { "large": {"label": "Large UI", "width": 2160, "height": 1080, "bounds": {"x": 30, "y": 30, "width": 1800, "height": 1020}, "reservedZones": [], "widgets": { "current_speed": _widget("Current speed", "current_speed", 580, 300, 640, 30), "cruise_limits": _widget("Cruise and speed limit", "cruise_limits", WIDTH, CARD_HEIGHT, 88, 75), "speed_limit_actions": _widget("Speed limit actions", "speed_limit_actions", ACTION_WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y), "steering_wheel": _widget("Steering wheel", "steering_wheel", 192, 192, 1588, 75), "driver_monitor": _widget("Driver monitoring", "driver_monitor", 128, 128, 120, 840)}}, "compact": {"label": "Small UI", "width": 536, "height": 240, "bounds": {"x": 0, "y": 0, "width": 476, "height": 240}, "protectedWidget": "speed_limit_actions", "reservedZones": [], "widgets": { "max_speed": _widget("Set speed", "max_speed", 146, 162, 0, 0), "speed_limit": _widget("Speed limit", "speed_limit", 118, 132, 330, 20), "speed_limit_actions": _widget("Speed limit actions", "speed_limit_actions", 184, 120, 146, 44), "steering_wheel": _widget("Steering wheel", "steering_wheel", 50, 50, 21, 176), "driver_monitor": _widget("Driver monitoring", "driver_monitor", 60, 60, 16, 10)}}} for _profile in PROFILES: PROFILES[_profile]['widgets']['speed_limit_actions']['note'] = ( 'Accept / Reject appear inside a pending limit after tapping it. This placement controls Toyota guidance and re-adoption.') _owner = 'cruise_limits' if _profile == 'large' else 'speed_limit' PROFILES[_profile]['widgets'][_owner]['confirmation'] = confirmation_footprint(_profile) SPEED_LAYOUT_V7 = { 'large': {'cruise_limits': (320, 210, 88, 75), 'speed_limit_actions': (320, 108, 88, 307)}, 'compact': {'max_speed': (162, 162, 0, 0), 'speed_limit': (118, 132, 174, 12), 'speed_limit_actions': (282, 54, 174, 180)}, } COMPACT_SPEED_LAYOUT_V8 = { 'max_speed': (146, 114, 0, 0), 'speed_limit': (264, 82, 158, 18), 'speed_limit_actions': (440, 52, 16, 122), } for profile, sizes in DM_SIZES.items(): PROFILES[profile]['widgets']['driver_monitor']['default']['size'] = sizes[1] LEGACY_WIDGETS = {"large": {"current_speed", "cruise_limits", "steering_wheel"}, "compact": {"max_speed", "speed_limit", "steering_wheel"}} DM_WIDGETS = {profile: keys | {"driver_monitor"} for profile, keys in LEGACY_WIDGETS.items()} for _profile, _data in PROFILES.items(): _bounds = _data["bounds"] _x, _y, _width, _height = maximum_footprint(_bounds["x"], _bounds["y"], _bounds["width"], _bounds["height"], _data["width"]) _data["widgets"]["torque_bar"] = { **_widget("Torque bar", "torque_bar", _width, _height, _x, _y), "layer": "underlay", "note": "Torque output, or steering-effort estimate on angle-controlled vehicles"} ACTIONLESS_WIDGETS = {profile: set(data["widgets"]) - {"speed_limit_actions"} for profile, data in PROFILES.items()} RAILLESS_WIDGETS = {profile: set(data["widgets"]) for profile, data in PROFILES.items()} RAIL_WIDGETS = ("model_confidence", "conditional_mode", "following_distance") for _index, (_key, _label) in enumerate(zip(RAIL_WIDGETS, ("Model confidence", "CEM and driving mode", "Following distance"), strict=True)): PROFILES["compact"]["widgets"][_key] = { **_widget(_label, _key, 60, 80, 476, _index * 80), "bounds": {"x": 0, "y": 0, "width": 536, "height": 240}, } # Keep layouts saved before camera widgets valid when the registry grows. CAMERALESS_WIDGETS = {profile: set(data["widgets"]) for profile, data in PROFILES.items()} CAMERA_WIDGETS = ("pip_left", "pip_right") for _profile, (_size, _margin) in {"large": (600, 24), "compact": (80, 16)}.items(): _bounds = PROFILES[_profile]["bounds"] for _side, _key in zip(("left", "right"), CAMERA_WIDGETS, strict=True): _x = _bounds["x"] + (_margin if _side == "left" else _bounds["width"] - _margin - _size) _y = _bounds["y"] + _bounds["height"] - _margin - _size PROFILES[_profile]["widgets"][_key] = { **_widget(f"PiP {_side} side camera", "pip_camera", _size, _size, _x, _y), "layer": "underlay"} MODE_WIDGET = "driving_mode_descriptions" for _profile, _geometry in {"large": (580, 68, 640, 344), "compact": (168, 44, 154, 0)}.items(): _mode_widget = _widget("Driving Mode Descriptions", MODE_WIDGET, *_geometry) _mode_widget["default"]["enabled"] = False PROFILES[_profile]["widgets"][MODE_WIDGET] = _mode_widget # It starts disabled so an upgrade never adds a new onroad element without the # driver's choice; validation below adds the placement to older documents. CLOCK_WIDGET = "clock" for _profile, _geometry in {"large": (300, 72, 780, 430), "compact": (118, 20, 330, 3)}.items(): _clock_widget = _widget("Clock", CLOCK_WIDGET, *_geometry) _clock_widget["default"]["enabled"] = False PROFILES[_profile]["widgets"][CLOCK_WIDGET] = _clock_widget STOPPED_TIMER_WIDGET = "stopped_timer" for _profile, (_x, _y) in {"compact": (18, 48), "large": (600, 520)}.items(): _timer_widget = _widget("Stopped timer", STOPPED_TIMER_WIDGET, *timer_size(_profile), _x, _y) _timer_widget["default"]["enabled"] = False _timer_widget["note"] = "Counts how long you have been stopped, after the first minute of the drive" PROFILES[_profile]["widgets"][STOPPED_TIMER_WIDGET] = _timer_widget NAV_CARD_WIDGET, NAV_MAP_WIDGET = "nav_card", "nav_map" NAV_CARD_SIZE = (560, 195) MAP_MIN_SIZE = (280, 200) MAP_OPACITY = (15, 100, 50) # percent: min, max, default _nav_card = _widget("Turn-by-turn", NAV_CARD_WIDGET, *NAV_CARD_SIZE, 1230, 415) _nav_card["note"] = "The next maneuver while a route is active" PROFILES["large"]["widgets"][NAV_CARD_WIDGET] = _nav_card _nav_map = _widget("Map overlay", NAV_MAP_WIDGET, 560, 420, 1230, 625) _nav_map["default"].update(enabled=False, width=560, height=420, opacity=MAP_OPACITY[2]) _nav_map["box"] = {"minWidth": MAP_MIN_SIZE[0], "maxWidth": 1800, "minHeight": MAP_MIN_SIZE[1], "maxHeight": 1020} _nav_map["opacity"] = dict(zip(("min", "max", "default"), MAP_OPACITY, strict=True)) _nav_map["note"] = ("Road lines and your route over the camera. Drag the corner to resize; opacity sets how much shows. " + "Turning it on here turns it off in the other screen's layout.") PROFILES["large"]["widgets"][NAV_MAP_WIDGET] = _nav_map JETLINK_WIDGET = "jetlink_status" JETLINK_SIZE = (420, 84) JETLINK_OPACITY = (0, 100, 85) # percent: min, max, default _jetlink = _widget("Jetlink status", JETLINK_WIDGET, *JETLINK_SIZE, 30, 650) _jetlink["default"].update(enabled=False, opacity=JETLINK_OPACITY[2]) _jetlink["opacity"] = dict(zip(("min", "max", "default"), JETLINK_OPACITY, strict=True)) _jetlink["note"] = "Shown while Jetlink is on: connection, device and host processing time. Opacity sets the card background." PROFILES["large"]["widgets"][JETLINK_WIDGET] = _jetlink # Restore the Dom road graphic in the gap between the speed card and main speed. # Center the road gauge above the driver monitor, below the speed-limit controls. # It has no background and appears only with a current selected source. CEM_GAUGE_WIDGET = 'cem_gauge' # The gauge draws centered in a 344 x 300 frame; its box is the part it can actually fill, at this offset inside # that frame. Version 6 and older layouts saved the frame's corner and move by this offset when they load. CEM_GAUGE_FRAME = (344, 300) CEM_GAUGE_INSET = (44, 24) CEM_GAUGE_SIZE = (256, 276) PROFILES['large']['widgets'][CEM_GAUGE_WIDGET] = _widget('CEM road gauge', CEM_GAUGE_WIDGET, *CEM_GAUGE_SIZE, 56, 536) ROAD_BASE_WIDTH = 1860 SIDEBAR_EXTRA = 300 ANCHOR_SHIFT = {"steering_wheel": 1.0, NAV_CARD_WIDGET: 1.0, NAV_MAP_WIDGET: 1.0, "current_speed": .5, "torque_bar": .5, MODE_WIDGET: .5, CLOCK_WIDGET: .5} for _key, _widget_data in PROFILES["large"]["widgets"].items(): if _key in ANCHOR_SHIFT: _widget_data["anchorShift"] = ANCHOR_SHIFT[_key] _base = PROFILES["large"]["bounds"] _widget_data["bounds"] = {**_base, "x": _base["x"] - ANCHOR_SHIFT.get(_key, 0) * SIDEBAR_EXTRA, "width": _base["width"] + SIDEBAR_EXTRA} _widget_data["expandable"] = True # Older documents retain every saved placement and gain this independent widget. OPTIONAL_WIDGETS = frozenset({MODE_WIDGET, CLOCK_WIDGET, STOPPED_TIMER_WIDGET, CEM_GAUGE_WIDGET, NAV_CARD_WIDGET, NAV_MAP_WIDGET, JETLINK_WIDGET}) # The torque arc grows when the developer sidebar closes; protect both road widths. _torque = PROFILES['large']['widgets']['torque_bar'] _bounds = PROFILES['large']['bounds'] _x, _y, _w, _h = maximum_footprint(_bounds['x'], _bounds['y'], _bounds['width'] + SIDEBAR_EXTRA, _bounds['height'], 2160) _left, _top = min(_x, _torque['default']['x']), min(_y, _torque['default']['y']) _torque['confirmationObstacle'] = { 'x': _left - _torque['default']['x'], 'y': _top - _torque['default']['y'], 'width': max(_x + _w, _torque['default']['x'] + _torque['width']) - _left, 'height': max(_y + _h, _torque['default']['y'] + _torque['height']) - _top} # These editor sprites use the runtime components and their canonical assets/fonts. RUNTIME_PREVIEW_WIDGETS = { "large": ("current_speed", "cruise_limits", CEM_GAUGE_WIDGET, "steering_wheel", "driver_monitor", "speed_limit_actions"), "compact": ("max_speed", "speed_limit", "steering_wheel", "conditional_mode", "following_distance", "model_confidence", "driver_monitor", "speed_limit_actions"), } _FRAME_COLORS = {"cardFill": "#00000000", "cardBorder": "#00000000"} _ACTION_COLORS = {"cardFill": "#0C1820EB", "cardBorder": "#A6DFBEFF", "text": "#FFFFFFFF"} WIDGET_COLORS = { "large": { MODE_WIDGET: {"text": "#FFFFFFFF"}, CLOCK_WIDGET: {"text": "#FFFFFFFF"}, CEM_GAUGE_WIDGET: {}, "current_speed": {"text": None}, "cruise_limits": {"cardFill": "#10191FFF", "cardBorder": "#35434DFF", "text": None}, "speed_limit_actions": {"cardFill": "#10191FFF", "cardBorder": "#35434DFF", "text": "#F1F6F3FF"}, "steering_wheel": {"cardFill": None, "cardBorder": "#00000000"}, "driver_monitor": _FRAME_COLORS, }, "compact": { MODE_WIDGET: {"text": "#FFFFFFFF"}, CLOCK_WIDGET: {"text": "#FFFFFFFF"}, "max_speed": {"text": None}, "speed_limit_actions": {**_ACTION_COLORS, "text": "#F1F6F3FF"}, "driver_monitor": _FRAME_COLORS, "model_confidence": _FRAME_COLORS, "conditional_mode": _FRAME_COLORS, "following_distance": {**_FRAME_COLORS, "text": "#FFFFFFFF"}, }, } # Back-to-front order used when a layout first opts into custom layers. DEFAULT_WIDGET_ORDER = { "large": (NAV_MAP_WIDGET, "pip_left", "pip_right", "cruise_limits", "speed_limit_actions", CEM_GAUGE_WIDGET, "current_speed", "steering_wheel", "torque_bar", "driver_monitor", MODE_WIDGET, CLOCK_WIDGET, STOPPED_TIMER_WIDGET, NAV_CARD_WIDGET, JETLINK_WIDGET), "compact": ("pip_left", "pip_right", "speed_limit", "max_speed", "steering_wheel", "torque_bar", "speed_limit_actions", "driver_monitor", *RAIL_WIDGETS, MODE_WIDGET, CLOCK_WIDGET, STOPPED_TIMER_WIDGET), } def validate_widget_order(order, widgets): if (type(order) is not list or any(type(key) is not str for key in order) or len(order) != len(widgets) or set(order) != set(widgets)): raise ValueError("Invalid widget layer order") return list(order) def widget_order(document, profile): return document.get("widgetOrder", {}).get(str(profile), DEFAULT_WIDGET_ORDER[str(profile)]) def widget_bounds(profile, widget): return PROFILES[profile]["widgets"][widget].get("bounds", PROFILES[profile]["bounds"]) def customization_metadata(): profiles = copy.deepcopy(PROFILES) profiles["large"]["widgets"]["driver_monitor"]["defaultAnchor"] = "driver_side" # The editor grows the cruise box by these while speed-limit sources are shown. profiles["large"]["widgets"]["cruise_limits"]["sourcesExtent"] = { "inline": sources_extent({'speedSources': True}), "card": sources_extent({'speedSources': True, 'speedSourcesCard': True}), 'actionGap': GAP} profiles["large"]["inputZones"] = [{"id": "favorites_drawer", "label": "Quick Select drawer", "x": 30, "y": 900, "width": 150, "height": 150}] profiles["compact"]["inputZones"] = [ {"id": f"favorite_{index + 1}", "label": f"Quick Select {index + 1}", "x": index * 476 / 3, "y": 0, "width": 476 / 3, "height": 240} for index in range(3)] for profile in profiles.values(): profile["inputZonePriority"] = "Enabled Quick Select slots; speed-limit controls and steering wheel take priority" for name, profile in profiles.items(): for key, widget in profile["widgets"].items(): widget["colors"] = dict(WIDGET_COLORS[name].get(key, {})) for key, sizes in WIDGET_SIZES.items(): minimum, default, maximum = sizes[name] profile["widgets"][key]["resizable"] = {"min": minimum, "default": default, "max": maximum} profile["widgets"][key]["default"]["size"] = default for profile, data in profiles.items(): data["widgetOrder"] = list(DEFAULT_WIDGET_ORDER[profile]) data["runtimePreviewWidgets"] = list(RUNTIME_PREVIEW_WIDGETS[profile]) return {"profiles": profiles, "roadColorFields": [ {"id": key, "label": label, "default": ROAD_COLORS[key]} for key, label in (("path", "Path"), ("pathEdge", "Path edges"), ("laneLines", "Outer lane lines"))], "paletteFields": [ {"id": key, "label": label, "default": PALETTE[key]} for key, label in (("cardFill", "Card fill"), ("cardBorder", "Card border"), ("text", "Text"))]} def default_document(): return {"version": 12, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES}, "widgetColors": {profile: {} for profile in PROFILES}, "layouts": { profile: {key: {**widget["default"], **({"size": WIDGET_SIZES[key][profile][1]} if key in WIDGET_SIZES else {})} for key, widget in data["widgets"].items()} for profile, data in PROFILES.items()}} def validate_map_box(placement, widget): box, opacity = widget["box"], widget["opacity"] if (type(placement["width"]) is not int or not box["minWidth"] <= placement["width"] <= box["maxWidth"] or type(placement["height"]) is not int or not box["minHeight"] <= placement["height"] <= box["maxHeight"] or type(placement["opacity"]) is not int or not opacity["min"] <= placement["opacity"] <= opacity["max"]): raise ValueError("Invalid map overlay size or opacity") def migrate_speed_layout(layout, profile, bounds, old_gap=232, *, previous=None, widgets=None): widgets = PROFILES[profile]['widgets'] if widgets is None else widgets if profile == 'large': card, actions = layout['cruise_limits'], layout['speed_limit_actions'] if actions['x'] == card['x'] and actions['y'] - card['y'] == old_gap: actions.update(x=card['x'] + ACTION_X - 88, y=card['y'] + ACTION_Y - 75) gauge = layout[CEM_GAUGE_WIDGET] if (gauge['x'], gauge['y']) == (480, 99): default = widgets[CEM_GAUGE_WIDGET]['default'] gauge.update(x=default['x'], y=default['y']) for key, (_, _, old_x, old_y) in (previous or SPEED_LAYOUT_V7[profile]).items(): saved, widget = layout[key], widgets[key] area = widget.get('bounds', bounds) if (saved['x'], saved['y']) == (old_x, old_y): saved.update(x=widget['default']['x'], y=widget['default']['y']) for axis, extent in (('x', 'width'), ('y', 'height')): saved[axis] = max(area[axis], min(saved[axis], area[axis] + area[extent] - widget[extent])) def _migrate_action_collisions(layout): actions = layout['speed_limit_actions'] area = PROFILES['compact']['widgets']['speed_limit_actions'] # Move only placements made invalid by the new protected action area. for key, height in _compact_action_collisions(layout): saved, widget = layout[key], PROFILES['compact']['widgets'][key] width = saved['size'] if key in WIDGET_SIZES else widget['width'] candidates = ((actions['x'] - width, saved['y']), (actions['x'] + area['width'], saved['y']), (saved['x'], actions['y'] - height), (saved['x'], actions['y'] + area['height'])) valid = [(x, y) for x, y in candidates if 0 <= x <= 476 - width and 0 <= y <= 240 - height] if valid: x, y = min(valid, key=lambda xy: (xy[0] - saved['x']) ** 2 + (xy[1] - saved['y']) ** 2) saved.update(x=x, y=y) def _compact_action_collisions(layout): widgets = PROFILES['compact']['widgets'] actions, area = layout['speed_limit_actions'], widgets['speed_limit_actions'] for key, widget in widgets.items(): saved = layout[key] if (not saved['enabled'] or key in ('speed_limit_actions', 'speed_limit', 'stopped_timer') or widget.get('layer') == 'underlay'): continue width, height = (saved['size'],) * 2 if key in WIDGET_SIZES else (widget['width'], widget['height']) if (saved['x'] < actions['x'] + area['width'] and saved['x'] + width > actions['x'] and saved['y'] < actions['y'] + area['height'] and saved['y'] + height > actions['y']): yield key, height def migrate_large_sign_layout(layout, bounds, *, widgets=None, extra_height=0): widgets = PROFILES['large']['widgets'] if widgets is None else widgets card, actions = layout['cruise_limits'], layout['speed_limit_actions'] # Only the old attached arrangement follows the sign; independently placed actions stay put. paired = actions['x'] == card['x'] + 12 and actions['y'] == card['y'] + 173 card['y'] = min(card['y'], bounds['y'] + bounds['height'] - widgets['cruise_limits']['height'] - extra_height) if paired: actions['x'] = card['x'] + ACTION_X - 88 actions['y'] = min(card['y'] + ACTION_Y - 75, bounds['y'] + bounds['height'] - widgets['speed_limit_actions']['height']) def place_attached_slc_actions(layout, bounds, extra_height): card, actions = layout['cruise_limits'], layout['speed_limit_actions'] if (actions['x'], actions['y']) == (card['x'] + ACTION_X - 88, card['y'] + ACTION_Y - 75): # Save the actual position so drawing, editor crops and input share coordinates. dy = CARD_HEIGHT + extra_height + GAP if extra_height else ACTION_Y - 75 card['y'] = min(card['y'], bounds['y'] + bounds['height'] - ACTION_HEIGHT - dy) actions['y'] = card['y'] + dy def confirmation_layout_valid(layout, profile, widgets, bounds, *, extra_height=0): owner = 'cruise_limits' if profile == 'large' else 'speed_limit' bounds = widgets[owner].get('bounds', bounds) at = layout[owner] if not at['enabled'] or not layout['speed_limit_actions']['enabled']: return True left, top, right, bottom = confirmation_bounds(profile, at['x'], at['y'], opened=True) if extra_height: left, right = min(left, at['x']), max(right, at['x'] + widgets[owner]['width']) bottom = max(bottom, at['y'] + widgets[owner]['height'] + extra_height) if left < bounds['x'] or top < bounds['y'] or right > bounds['x'] + bounds['width'] or bottom > bounds['y'] + bounds['height']: return False for key in (('torque_bar',) if profile == 'large' else ('max_speed', 'torque_bar')): other, area = layout[key], widgets[key] area = area.get('confirmationObstacle', {'x': 0, 'y': 0, 'width': area['width'], 'height': area['height']}) ox, oy = other['x'] + area['x'], other['y'] + area['y'] if (other['enabled'] and left < ox + area['width'] and ox < right and top < oy + area['height'] and oy < bottom): return False return True def migrate_confirmation_layout(layout, profile, widgets, bounds, *, extra_height=0): owner = 'cruise_limits' if profile == 'large' else 'speed_limit' bounds = widgets[owner].get('bounds', bounds) area, at = confirmation_footprint(profile), layout[owner] if not at['enabled'] or not layout['speed_limit_actions']['enabled']: return actions = layout['speed_limit_actions'] attached = profile == 'large' and actions['x'] == at['x'] + ACTION_X - 88 and actions['y'] == at['y'] + ACTION_Y - 75 for axis, extent in (('x', 'width'), ('y', 'height')): low = max(bounds[axis], bounds[axis] - area[axis]) high = min(bounds[axis] + bounds[extent] - widgets[owner][extent], bounds[axis] + bounds[extent] - area[axis] - area[extent]) if axis == 'y': high = min(high, bounds['y'] + bounds['height'] - widgets[owner]['height'] - extra_height) at[axis] = max(math.ceil(low), min(at[axis], math.floor(high))) if not confirmation_layout_valid(layout, profile, widgets, bounds, extra_height=extra_height): # Preserve independently placed guidance; only the speed cluster needs room for the chooser. at.update(x=widgets[owner]['default']['x'], y=widgets[owner]['default']['y']) if not confirmation_layout_valid(layout, profile, widgets, bounds, extra_height=extra_height): for key in (('torque_bar',) if profile == 'large' else ('max_speed', 'torque_bar')): layout[key].update(x=widgets[key]['default']['x'], y=widgets[key]['default']['y']) if attached: actions.update(x=at['x'] + ACTION_X - 88, y=at['y'] + ACTION_Y - 75) def validate_document(value): if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 13): raise ValueError("Invalid customization version") fields = {"version", "palette", "layouts"} | ({"widgetColors"} if value["version"] >= 2 else set()) if value["version"] >= 3: fields.add("roadColors") if value['version'] >= 4 and 'speedSources' in value: fields.add('speedSources') if type(value['speedSources']) is not bool: raise ValueError('Invalid speed source drawer preference') if value['version'] >= 4 and 'speedSourcesCard' in value: fields.add('speedSourcesCard') if type(value['speedSourcesCard']) is not bool: raise ValueError('Invalid speed source card preference') if value['version'] >= 4 and 'speedAlign' in value: fields.add('speedAlign') if value['speedAlign'] not in SPEED_ALIGNMENTS: raise ValueError('Invalid speed card alignment') if value['version'] >= 4 and 'widgetOrder' in value: fields.add('widgetOrder') if 'clock24Hour' in value: fields.add('clock24Hour') if type(value['clock24Hour']) is not bool: raise ValueError('Invalid clock format preference') if 'showMaxSpeed' in value: fields.add('showMaxSpeed') if type(value['showMaxSpeed']) is not bool: raise ValueError('Invalid MAX speed preference') if set(value) != fields: raise ValueError("Invalid customization fields") palette, layouts = value["palette"], value["layouts"] if type(palette) is not dict or set(palette) != set(PALETTE) or type(layouts) is not dict or set(layouts) != set(PROFILES): raise ValueError("Invalid customization fields") if any(all(type(layouts[profile]) is dict and (set(layouts[profile]) - {"vasm", *OPTIONAL_WIDGETS, *CAMERA_WIDGETS}) == keys for profile, keys in shape.items()) for shape in (LEGACY_WIDGETS, DM_WIDGETS, ACTIONLESS_WIDGETS)): layouts = copy.deepcopy(layouts) cruise = layouts["large"]["cruise_limits"] if (type(cruise) is not dict or set(cruise) != {"x", "y", "enabled"} or type(cruise["enabled"]) is not bool or any(type(cruise[axis]) not in (int, float) for axis in ("x", "y"))): raise ValueError("Invalid widget placement") try: if not all(math.isfinite(cruise[axis]) for axis in ("x", "y")): raise ValueError("Invalid widget placement") except OverflowError as exc: raise ValueError("Invalid widget placement") from exc cruise_default = PROFILES["large"]["widgets"]["cruise_limits"]["default"] actions_default = ({'x': 88, 'y': 500 if value['version'] < 5 else 447 if value['version'] < 6 else 307} if value['version'] < 8 else PROFILES["large"]["widgets"]["speed_limit_actions"]["default"]) layouts["large"]["speed_limit_actions"] = { "x": actions_default["x"] + cruise["x"] - cruise_default["x"], "y": actions_default["y"] + cruise["y"] - cruise_default["y"], "enabled": cruise["enabled"], } if value['version'] == 8 and 'speed_limit_actions' not in layouts['compact']: _, _, x, y = COMPACT_SPEED_LAYOUT_V8['speed_limit_actions'] layouts['compact']['speed_limit_actions'] = {'x': x, 'y': y, 'enabled': True} for profile in PROFILES: for key in ("driver_monitor", "torque_bar", "speed_limit_actions"): layouts[profile].setdefault(key, dict(PROFILES[profile]["widgets"][key]["default"])) if all(type(layouts[profile]) is dict and (set(layouts[profile]) - {"vasm", *OPTIONAL_WIDGETS, *CAMERA_WIDGETS}) == keys for profile, keys in RAILLESS_WIDGETS.items()): layouts = copy.deepcopy(layouts) for key in RAIL_WIDGETS: layouts["compact"][key] = dict(PROFILES["compact"]["widgets"][key]["default"]) if all(type(layouts[profile]) is dict and (set(layouts[profile]) - {"vasm", *OPTIONAL_WIDGETS}) == keys for profile, keys in CAMERALESS_WIDGETS.items()): layouts = copy.deepcopy(layouts) for profile in PROFILES: has_legacy = "vasm" in layouts[profile] legacy = layouts[profile].pop("vasm", None) if has_legacy and (type(legacy) is not dict or set(legacy) != {"x", "y", "enabled"} or type(legacy["enabled"]) is not bool or any(type(legacy[axis]) not in (int, float) or not math.isfinite(legacy[axis]) for axis in ("x", "y"))): raise ValueError("Invalid legacy camera placement") for side, key in enumerate(CAMERA_WIDGETS): widget = PROFILES[profile]["widgets"][key] position = dict(widget["default"]) if legacy is not None: bounds = widget_bounds(profile, key) position.update(enabled=legacy["enabled"], x=max(bounds["x"], min(legacy["x"], bounds["x"] + bounds["width"] - 2 * widget["width"])) + side * widget["width"], y=max(bounds["y"], min(legacy["y"], bounds["y"] + bounds["height"] - widget["height"]))) layouts[profile][key] = position layouts = copy.deepcopy(layouts) for profile, data in PROFILES.items(): if type(layouts[profile]) is dict: monitor = layouts[profile].get('driver_monitor') if type(monitor) is dict and 'size' not in monitor: # Tighten the old 192px box around its 128px icon without moving the icon. if profile == 'large': for axis in ('x', 'y'): if type(monitor.get(axis)) in (int, float): monitor[axis] += 32 monitor['size'] = DM_SIZES[profile][1] missing = set(data["widgets"]) - set(layouts[profile]) if missing and missing <= OPTIONAL_WIDGETS: for optional in missing: layouts[profile][optional] = dict(data["widgets"][optional]["default"]) if value['version'] < 11: old_defaults = {'large': {'speed_limit_actions': (100, 296), CEM_GAUGE_WIDGET: (56, 452)}, 'compact': {'speed_limit_actions': (128, 186), 'clock': (168, 92), MODE_WIDGET: (162, 22)}} result = default_document() for key, color in palette.items(): if type(color) is not str or re.fullmatch(r"#[0-9a-fA-F]{8}", color) is None: raise ValueError("Colors must be #RRGGBBAA") result["palette"][key] = color.upper() if value["version"] >= 2: colors = value["widgetColors"] if type(colors) is not dict or set(colors) != set(PROFILES): raise ValueError("Invalid widget color profiles") for profile, widgets in colors.items(): if type(widgets) is not dict or not widgets.keys() <= WIDGET_COLORS[profile].keys(): raise ValueError("Invalid widget color IDs") for key, overrides in widgets.items(): if type(overrides) is not dict or not overrides.keys() <= WIDGET_COLORS[profile][key].keys(): raise ValueError("Invalid widget color fields") if any(type(color) is not str or re.fullmatch(r"#[0-9a-fA-F]{8}", color) is None for color in overrides.values()): raise ValueError("Colors must be #RRGGBBAA") if overrides: result["widgetColors"][profile][key] = {field: color.upper() for field, color in overrides.items()} if value["version"] >= 3: colors = value["roadColors"] if type(colors) is not dict or set(colors) != set(PROFILES): raise ValueError("Invalid road color profiles") for profile, overrides in colors.items(): if type(overrides) is not dict or not overrides.keys() <= ROAD_COLORS.keys() | {"pathMode"}: raise ValueError("Invalid road color fields") for key, color in overrides.items(): if key == "pathMode": if type(color) is not str or color not in PATH_MODES: raise ValueError("Invalid path color mode") elif type(color) is not str or re.fullmatch(r"#[0-9a-fA-F]{8}", color) is None: raise ValueError("Colors must be #RRGGBBAA") result["roadColors"][profile][key] = color if key == "pathMode" else color.upper() for profile, data in PROFILES.items(): layout = layouts[profile] if type(layout) is not dict or set(layout) != set(data["widgets"]): raise ValueError("Invalid widget IDs") for key, widget in data["widgets"].items(): placement = layout[key] bounds = widget_bounds(profile, key) if value['version'] < 8 and key in SPEED_LAYOUT_V7[profile]: bounds = data['bounds'] scalable = key == 'driver_monitor' or key == 'steering_wheel' and value['version'] >= 4 expected = {"x", "y", "enabled"} | ({"size"} if scalable else set()) if widget.get("box"): expected |= {"width", "height", "opacity"} elif widget.get("opacity"): expected.add("opacity") if type(placement) is not dict or set(placement) != expected or type(placement["enabled"]) is not bool: raise ValueError("Invalid widget placement") if widget.get("box"): validate_map_box(placement, widget) elif widget.get("opacity"): low, high = widget["opacity"]["min"], widget["opacity"]["max"] if type(placement["opacity"]) is not int or not low <= placement["opacity"] <= high: raise ValueError("Invalid widget opacity") size = WHEEL_SIZES[profile][1] if scalable: size = placement["size"] minimum, _, maximum = WIDGET_SIZES[key][profile] if type(size) is not int or not minimum <= size <= maximum: raise ValueError("Invalid widget size") # Only a gauge the saved layout actually placed moves; one filled in from defaults is already in the new box. saved_layout = value.get("layouts", {}).get(profile) if type(value.get("layouts")) is dict else None old_gauge = key == CEM_GAUGE_WIDGET and value["version"] < 7 and type(saved_layout) is dict and key in saved_layout for axis, extent in (("x", "width"), ("y", "height")): number = placement[axis] legacy = ({'cruise_limits': (176, 483), 'speed_limit_actions': (176, 58)} if value['version'] < 5 else {'cruise_limits': (280, 412), 'speed_limit_actions': (280, 96)}) dimensions = legacy.get(key) if value['version'] < 6 and profile == 'large' else None if dimensions is None and value['version'] < 8: dimensions = SPEED_LAYOUT_V7[profile].get(key) if value['version'] == 8 and profile == 'compact': dimensions = COMPACT_SPEED_LAYOUT_V8.get(key) if dimensions is None and value['version'] < 10 and profile == 'large' and key == 'cruise_limits': dimensions = (WIDTH, 132) if dimensions is None and value['version'] < 11 and key == 'speed_limit_actions': default = widget['default'] current_default = (placement['x'], placement['y']) == (default['x'], default['y']) if not current_default: dimensions = (576, 148) if profile == 'large' else (348, 54) dimension = (dimensions[0 if axis == 'x' else 1] if dimensions else size if key in WIDGET_SIZES else placement[extent] if widget.get("box") else widget[extent]) if old_gauge: dimension = CEM_GAUGE_FRAME[0 if axis == 'x' else 1] if type(number) not in (int, float) or not math.isfinite(number) or not bounds[axis] <= number <= bounds[axis] + bounds[extent] - dimension: raise ValueError("Widget outside profile bounds") result["layouts"][profile][key] = {**placement, **({"size": size} if key in WIDGET_SIZES else {})} if old_gauge: result["layouts"][profile][key].update(x=placement["x"] + CEM_GAUGE_INSET[0], y=placement["y"] + CEM_GAUGE_INSET[1]) if value['version'] < 11: for key, old in old_defaults[profile].items(): saved = result['layouts'][profile][key] if (saved['x'], saved['y']) == old: default = data['widgets'][key]['default'] saved.update(x=default['x'], y=default['y']) if profile == "compact" and value['version'] >= 11 and any(_compact_action_collisions(result['layouts'][profile])): raise ValueError("Widget overlaps protected speed limit actions") if 'widgetOrder' in value: orders = value['widgetOrder'] if type(orders) is not dict or not orders.keys() <= PROFILES.keys(): raise ValueError("Invalid widget layer profiles") result['widgetOrder'] = {} for profile, order in orders.items(): if type(order) is list: order = [*order, *(key for key in PROFILES[profile]['widgets'] if key in OPTIONAL_WIDGETS and key not in order)] result['widgetOrder'][profile] = validate_widget_order(order, PROFILES[profile]['widgets']) if 'speedSources' in value: result['speedSources'] = value['speedSources'] if 'speedSourcesCard' in value: result['speedSourcesCard'] = value['speedSourcesCard'] if 'speedAlign' in value: result['speedAlign'] = value['speedAlign'] if 'clock24Hour' in value: result['clock24Hour'] = value['clock24Hour'] if 'showMaxSpeed' in value: result['showMaxSpeed'] = value['showMaxSpeed'] if value['version'] < 10: migrate_large_sign_layout(result['layouts']['large'], PROFILES['large']['bounds'], extra_height=sources_extent(result)) if value['version'] < 8: if value['version'] < 5 and value['palette'] != PALETTE: colors = result['widgetColors']['large'].setdefault('cruise_limits', {}) for key in ('cardFill', 'cardBorder'): colors.setdefault(key, value['palette'][key]) old_gap = 425 if value['version'] < 5 else 372 if value['version'] < 6 else 232 for profile in PROFILES: migrate_speed_layout(result['layouts'][profile], profile, PROFILES[profile]['bounds'], old_gap) elif value['version'] == 8: migrate_speed_layout(result['layouts']['compact'], 'compact', PROFILES['compact']['bounds'], previous=COMPACT_SPEED_LAYOUT_V8) if value['version'] < 11: for profile, data in PROFILES.items(): actions = result['layouts'][profile]['speed_limit_actions'] widget, bounds = data['widgets']['speed_limit_actions'], widget_bounds(profile, 'speed_limit_actions') for axis, extent in (('x', 'width'), ('y', 'height')): actions[axis] = min(actions[axis], bounds[axis] + bounds[extent] - widget[extent]) _migrate_action_collisions(result['layouts']['compact']) for profile, data in PROFILES.items(): migrate_confirmation_layout(result['layouts'][profile], profile, data['widgets'], data['bounds'], extra_height=sources_extent(result) if profile == 'large' else 0) return validate_document(result) for profile, data in PROFILES.items(): if value['version'] < 12: if profile == 'large' and result.get('speedSources') is True: card, actions = result['layouts'][profile]['cruise_limits'], result['layouts'][profile]['speed_limit_actions'] old_extra = SOURCE_ROW_HEIGHT + (GAP if result.get('speedSourcesCard') is True else 0) if actions['x'] == card['x'] + ACTION_X - 88 and actions['y'] == card['y'] + ACTION_Y - 75 + old_extra: actions['y'] = card['y'] + ACTION_Y - 75 migrate_confirmation_layout(result['layouts'][profile], profile, data['widgets'], data['bounds'], extra_height=sources_extent(result) if profile == 'large' else 0) elif not confirmation_layout_valid(result['layouts'][profile], profile, data['widgets'], data['bounds'], extra_height=sources_extent(result) if profile == 'large' else 0): raise ValueError('Speed-limit confirmation overlaps set speed, torque, or screen bounds') place_attached_slc_actions(result['layouts']['large'], PROFILES['large']['bounds'], sources_extent(result)) if len(json.dumps(result, separators=(",", ":")).encode()) > MAX_BYTES: raise ValueError("Customization too large") return result def _unique_object(pairs): result = {} for key, value in pairs: if key in result: raise ValueError("Duplicate customization field") result[key] = value return result def decode_document(raw): return validate_document(json.loads(raw, object_pairs_hook=_unique_object)) def read_customization(params): try: raw, readable = read_saved(params, PARAM_KEY, MAX_BYTES) return decode_document(raw) if readable and raw is not None else default_document() except (AttributeError, OSError, TypeError, ValueError, UnicodeError, RecursionError): return default_document() def placement(document, profile, widget): return document['layouts'][str(profile)][widget] def fit_to_road(document, viewport_width): """The document with expandable Big-screen widgets kept inside the road that is showing.""" layout = document["layouts"].get("large") if layout is None: return document extra, moved = viewport_width - ROAD_BASE_WIDTH, {} for key, widget in PROFILES["large"]["widgets"].items(): placed = layout.get(key) if placed is None or not widget.get("expandable"): continue shift = widget.get("anchorShift", 0) * extra width = widget_size(document, "large", key)[0] if (area := widget.get('confirmation')) and placed['enabled'] and layout['speed_limit_actions']['enabled']: width = max(width, area['x'] + area['width']) x = max(30 - shift, min(placed["x"], viewport_width - 30 - width - shift)) if x != placed["x"]: moved[key] = {**placed, "x": x} if not moved: return document return {**document, "layouts": {**document["layouts"], "large": {**layout, **moved}}} def widget_size(document, profile, widget): default = PROFILES[str(profile)]["widgets"][widget] position = placement(document, profile, widget) if widget == "cruise_limits": return default["width"], default["height"] + sources_extent(document) if default.get("box"): return position["width"], position["height"] return (position.get("size", WIDGET_SIZES[widget][str(profile)][1]),) * 2 if widget in WIDGET_SIZES else (default["width"], default["height"]) def offset(document, profile, widget): position = placement(document, profile, widget) default = PROFILES[str(profile)]["widgets"][widget]["default"] return position["x"] - default["x"], position["y"] - default["y"] def widget_palette(document, profile, widget): defaults = WIDGET_COLORS[str(profile)].get(widget, {}) colors = {key: document["palette"][key] if value is None else value for key, value in defaults.items()} colors.update(document.get("widgetColors", {}).get(str(profile), {}).get(widget, {})) return colors def rgba(document, key, profile=None, widget=None): if profile is None: color = document["palette"][key] else: assert widget is not None default = WIDGET_COLORS[str(profile)][widget][key] color = document.get("widgetColors", {}).get(str(profile), {}).get(widget, {}).get(key, default or document["palette"][key]) return _rgba_value(color) @lru_cache(maxsize=256) def _rgba_value(color): return tuple(int(color[index:index + 2], 16) for index in (1, 3, 5, 7))