Allow Full Widget Editing and Arrangement on 3X, add AA only widgets to 3X screen

This commit is contained in:
Danny
2026-10-20 10:51:02 -07:00
committed by Prabhaav Pillai
parent 77347170f8
commit 4366e6ac7a
35 changed files with 880 additions and 99 deletions
@@ -97,6 +97,24 @@ class OnroadLayoutOwner:
failed = self._save_display(display, rows, session_valid)
return {**self.snapshot(), "failed": failed}
def disable_map(self, session_valid: Callable[[], bool]) -> bool:
"""Turn the Big-screen map off when Android Auto has taken it; True when it was on."""
raw, readable = read_saved(self.params, PARAM_KEY, MAX_BYTES)
if not readable or raw is None:
return False
try:
document = json.loads(raw)
placed = document["layouts"]["large"]["nav_map"]
except (ValueError, KeyError, TypeError):
return False
if type(placed) is not dict or placed.get("enabled") is not True:
return False
placed["enabled"] = False
result = commit_exact(self.params, key=PARAM_KEY, max_bytes=MAX_BYTES,
raw=json.dumps(document, separators=(",", ":"), allow_nan=False).encode(), expected=raw,
authorized=lambda: session_valid() and self.parked(), temp_prefix=".onroad-layout-")
return bool(result.committed and result.verified)
def _save_display(self, display, rows, session_valid) -> list[str]:
changes = [key for key, value in display.items() if value != rows[key].value]
# Lead info can change only while the Lead indicator is shown.
@@ -120,6 +120,33 @@ class ProjectionLayoutOwner:
raise LayoutChanged('Projection layout could not be saved; reload while parked')
return self.snapshot()
def disable_map(self, *, session_valid):
"""Turn the Android Auto map off when the Big screen has taken it; True when it was on."""
raw, readable = read_saved(self.source, DOCUMENT_KEY, MAX_BYTES)
if not readable or raw is None:
return False
try:
document = json.loads(raw)
placed = document['widgets']['nav_map']
except (ValueError, KeyError, TypeError):
return False
if type(placed) is not dict or placed.get('enabled') is not True:
return False
placed['enabled'] = False
screen_raw, screen_readable, _ = self._screen()
def authorized():
if not session_valid() or not self.parked():
return False
current, current_readable, _ = self._screen()
return current_readable and current == screen_raw
self.source.prepare()
result = commit_exact(self.source, key=DOCUMENT_KEY, max_bytes=MAX_BYTES,
raw=json.dumps(document, separators=(',', ':'), allow_nan=False).encode(), expected=raw,
authorized=authorized, temp_prefix='.projection-layout-')
return bool(result.committed and result.verified)
# ./dev galaxy has no car and no writable /data: give it a private screen and edit it with Android Auto off.
HOST_SCREEN = {'width': 1920, 'height': 1080, 'margin_width': 0, 'margin_height': 0, 'fps': 60, 'config_index': 0}
+10
View File
@@ -1840,6 +1840,11 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
try:
with effect_lock:
result = projection_layout_owner().save(payload, session_valid=lambda: self.settings_session() == identity)
if result['document']['widgets'].get('nav_map', {}).get('enabled') is True:
try:
result['mapDisabledElsewhere'] = bool(layout_owner().disable_map(lambda: self.settings_session() == identity))
except (OSError, RuntimeError, ValueError, LayoutChanged):
result['mapDisabledElsewhere'] = False
except LayoutChanged as error:
self.json(409, {'error': str(error)})
except ValueError as error:
@@ -1860,6 +1865,11 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
try:
with effect_lock:
result = layout_owner().save(payload, session_valid=lambda: self.settings_session() == identity)
if result['document']['layouts']['large'].get('nav_map', {}).get('enabled') is True:
try:
result['mapDisabledElsewhere'] = bool(projection_layout_owner().disable_map(session_valid=lambda: self.settings_session() == identity))
except (OSError, RuntimeError, ValueError, LayoutChanged):
result['mapDisabledElsewhere'] = False
except LayoutChanged as error:
self.json(409, {'error': str(error)})
except ValueError as error:
@@ -108,6 +108,26 @@ class TestProjectionLayoutOwner(unittest.TestCase):
self.assertTrue(self.owner.snapshot()['document']['signalBorder'])
self.assertEqual(self.owner.snapshot()['document'], saved['document'])
def test_disable_map_turns_only_the_map_off_and_only_when_it_was_on(self):
self.screen()
self.assertFalse(self.owner.disable_map(session_valid=lambda: True), 'nothing saved yet')
payload = self.payload()
payload['document']['widgets']['nav_map']['enabled'] = True
payload['document']['widgets']['nav_card']['x'] -= 5
self.owner.save(payload, session_valid=lambda: True)
self.assertFalse(self.owner.disable_map(session_valid=lambda: False), 'a stale session cannot change it')
self.assertTrue(self.owner.snapshot()['document']['widgets']['nav_map']['enabled'])
self.parked = False
self.assertFalse(self.owner.disable_map(session_valid=lambda: True), 'only while parked')
self.parked = True
self.assertTrue(self.owner.disable_map(session_valid=lambda: True))
saved = self.owner.snapshot()
self.assertTrue(saved['valid'])
expected = copy.deepcopy(payload['document'])
expected['widgets']['nav_map']['enabled'] = False
self.assertEqual(saved['document'], expected)
self.assertFalse(self.owner.disable_map(session_valid=lambda: True))
def test_disabled_retains_layout_but_unavailable(self):
self.screen()
self.owner.save(self.payload(), session_valid=lambda: True)
@@ -0,0 +1,54 @@
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { OnroadLayoutPage, validSnapshot, validDocument, widgetSize } from "../web/js/onroad-layout.js"
// Use the server's real registry so a UI gate cannot silently reject a newly exposed widget.
const data = JSON.parse(execFileSync(process.env.PYTHON || "python3", ["-c", `
import json
from openpilot.starpilot.ui.onroad_customization import customization_metadata, default_document
print(json.dumps(dict(document=default_document(), defaults=default_document(), metadata=customization_metadata(),
revision="native", editable=True, valid=True, activeProfile="large", supportedProfiles=["large"])))
`], { encoding: "utf8" }))
assert.equal(validSnapshot(data), true)
const vm = { ...OnroadLayoutPage.setup({ mode: "local", unauthorized() {} }), $emit() {}, $refs: {} }
for (const [key, getter] of Object.entries(OnroadLayoutPage.computed)) Object.defineProperty(vm, key, { get: () => getter.call(vm) })
for (const [key, method] of Object.entries(OnroadLayoutPage.methods)) vm[key] = method.bind(vm)
for (const feed of [vm.artFeed, vm.backdropFeed]) {
feed.start = () => { feed.active = true }
feed.update = () => {}
feed.stop = () => { feed.active = false }
}
vm.state.data = data
vm.state.draft = structuredClone(data.document)
vm.state.status = "ready"
assert.equal(data.document.layouts.large.nav_card.enabled, true, "the native turn card stays on after an upgrade")
assert(!vm.inactiveWidgets.some(widget => widget.id === "nav_card"))
vm.changePosition("nav_card", 100, 300)
assert.equal(validDocument(vm.state.draft, data.metadata), true)
for (const id of ["nav_map", "stopped_timer", "jetlink_status"]) {
assert(vm.inactiveWidgets.some(widget => widget.id === id))
vm.add(id)
assert.equal(vm.layout[id].enabled, true)
vm.changePosition(id, 100, 300)
assert.equal(vm.layout[id].x, 100)
assert.equal(validDocument(vm.state.draft, data.metadata), true)
}
vm.state.selected = "nav_map"
vm.boxInput("width", { target: { value: "900" } })
vm.boxInput("height", { target: { value: "500" } })
vm.opacityInput({ target: { value: "35" } })
assert.deepEqual(widgetSize(vm.selectedWidget, vm.selectedPosition), [900, 500])
assert.equal(vm.layout.nav_map.opacity, 35)
assert.equal(validDocument(vm.state.draft, data.metadata), true)
vm.state.selected = "jetlink_status"
vm.opacityInput({ target: { value: "40" } })
assert.equal(vm.layout.jetlink_status.opacity, 40)
assert.equal(validDocument(vm.state.draft, data.metadata), true)
vm.state.selected = "nav_map"
vm.remove("nav_map")
assert.equal(vm.layout.nav_map.enabled, false)
vm.undo()
assert.equal(vm.layout.nav_map.enabled, true)
assert.equal(vm.layout.nav_map.opacity, 35)
assert.equal(validSnapshot({ ...data, document: vm.state.draft }), true)
console.log("Native Big widgets: real metadata, optional add/move/remove, map resize/opacity and undo passed")
@@ -48,6 +48,11 @@ export function snapshot() {
for (const [profile, data] of Object.entries(metadata.profiles))
for (const [id, widget] of Object.entries(data.widgets)) widget.colors = { ...colors[profile][id] }
metadata.profiles.large.widgets.steering_wheel.resizable = { min: 144, default: 192, max: 240 }
// As the server sends them: anchors for the sidebar-hidden road, and positions that may use its extra width.
const shifts = { steering_wheel: 1, current_speed: .5, torque_bar: .5 }
for (const [id, shift] of Object.entries(shifts)) metadata.profiles.large.widgets[id].anchorShift = shift
for (const id of ["current_speed", "cruise_limits", "speed_limit_actions", "steering_wheel", "driver_monitor"])
Object.assign(metadata.profiles.large.widgets[id], { expandable: true, bounds: { x: 30 - 300 * (shifts[id] || 0), y: 30, width: 2100, height: 1020 } })
metadata.profiles.compact.widgets.steering_wheel.resizable = { min: 40, default: 50, max: 70 }
metadata.roadColorFields = [{ id: 'path', label: 'Path', default: '#30FF9CFF' },
{ id: 'pathEdge', label: 'Path edges', default: '#00FF40FF' }, { id: 'laneLines', label: 'Lane lines', default: '#FFFFFFFF' }]
@@ -120,7 +125,9 @@ resizedWheel.layouts.compact.steering_wheel.y = 160
assert.equal(validDocument(resizedWheel, data.metadata), true)
assert.equal(clampPlacement(data.metadata.profiles.compact, "steering_wheel", 150, 170, resizedWheel.layouts.compact), null)
assert.deepEqual(clampPlacement(data.metadata.profiles.compact, "steering_wheel", 21, 176, resizedWheel.layouts.compact), { x: 21, y: 170 })
assert.deepEqual(clampPlacement(data.metadata.profiles.large, "current_speed", -99, -99), { x: 30, y: 30 })
assert.deepEqual(clampPlacement(data.metadata.profiles.large, "current_speed", -999, -99), { x: -120, y: 30 }, "the sidebar-hidden screen is reachable")
assert.deepEqual(clampPlacement(data.metadata.profiles.large, "cruise_limits", 9999, 30), { x: 2130 - 480, y: 30 })
assert.deepEqual(clampPlacement(data.metadata.profiles.large, "torque_bar", -99, 759), { x: 30, y: 759 }, "the torque bar keeps its road")
assert.equal(clampPlacement(data.metadata.profiles.large, "fake", 30, 30), null)
assert.equal(clampPlacement(data.metadata.profiles.large, "current_speed", NaN, 30), null)
assert.deepEqual(previewPoint({ clientX: 316, clientY: 200 }, { left: 100, top: 92, width: 432, height: 216 }, data.metadata.profiles.large), { x: 1080, y: 540 })
@@ -251,7 +258,7 @@ traySnap.cancelDrag()
assert.equal(traySnap.layout.current_speed.enabled, false)
const clampedSnap = editor().vm
clampedSnap.profile.widgets.driver_monitor.bounds = { x: 30, y: 30, width: 300, height: 1020 }
Object.assign(clampedSnap.profile.widgets.driver_monitor, { expandable: false, bounds: { x: 30, y: 30, width: 300, height: 1020 } })
clampedSnap.layout.current_speed.x = 184
clampedSnap.startDrag("driver_monitor", largePoint(88, 808))
clampedSnap.moveDrag(largePoint(187, 400)) // Current speed at 184 is beyond the mover's own bounds.
@@ -1051,6 +1058,23 @@ display.vm.moveDrag(wheelPoint(1920))
assert.equal(display.vm.layout.steering_wheel.x, 1608, "dragging saves the movement without adding the runtime anchor offset")
display.vm.endDrag(wheelPoint(1920))
display.vm.undo()
const wide = editor()
wide.vm.state.data = merged; wide.vm.state.draft = copy(merged.document)
wide.vm.setDisplay("DeveloperSidebar", "Off")
// With the sidebar hidden every widget reaches the whole screen; with it showing, only the road beside it.
wide.vm.changePosition("cruise_limits", 5000, 100)
assert.equal(wide.vm.layout.cruise_limits.x, 2130 - 480)
assert.equal(wide.vm.previewX("cruise_limits"), 2130 - 480)
wide.vm.changePosition("steering_wheel", -999, 75)
assert.equal(wide.vm.layout.steering_wheel.x, -270)
assert.equal(wide.vm.previewX("steering_wheel"), 30, "a right-anchored widget reaches the left edge")
assert.equal(validDocument(wide.vm.state.draft, wide.vm.state.data.metadata), true)
wide.vm.setDisplay("DeveloperSidebar", "On")
assert.equal(wide.vm.limitProfile.widgets.steering_wheel.bounds, undefined)
assert.equal(wide.vm.previewX("cruise_limits"), 1830 - 480, "a position from the wider screen is shown inside the road")
assert.equal(wide.vm.previewX("steering_wheel"), 30)
wide.vm.changePosition("cruise_limits", 5000, 100)
assert.equal(wide.vm.layout.cruise_limits.x, 1830 - 480)
assert.equal(display.vm.previewX("steering_wheel"), 1888)
display.vm.undo()
assert.equal(display.vm.displayValue("DeveloperSidebar"), "On")
@@ -43,6 +43,22 @@ class TestOnroadLayoutOwner(unittest.TestCase):
self.assertEqual(self.params.get(PARAM_KEY), compact['document'])
self.assertNotEqual(initial['revision'], result['revision'])
def test_disable_map_turns_only_the_big_screen_map_off_and_only_when_it_was_on(self):
self.assertFalse(self.owner.disable_map(lambda: True), 'nothing saved yet')
payload = self.changed()
payload['document']['layouts']['large']['nav_map']['enabled'] = True
payload['document']['layouts']['large']['nav_card']['x'] -= 5
self.owner.save(payload, session_valid=lambda: True)
self.assertFalse(self.owner.disable_map(lambda: False))
self.parked = False
self.assertFalse(self.owner.disable_map(lambda: True))
self.parked = True
self.assertTrue(self.owner.disable_map(lambda: True))
expected = copy.deepcopy(payload['document'])
expected['layouts']['large']['nav_map']['enabled'] = False
self.assertEqual(self.owner.snapshot()['document'], expected)
self.assertFalse(self.owner.disable_map(lambda: True))
def test_v1_read_is_non_mutating_and_widget_colors_save_with_source_revision(self):
old = default_document()
old['version'] = 1
@@ -7,6 +7,8 @@ import { OnroadLayoutFeed, OnroadLayoutPage, validSnapshot, validDocument } from
const copy = (v) => JSON.parse(JSON.stringify(v))
const native = snapshot()
const metadata = copy(native.metadata.profiles.large)
// The Android Auto metadata has its own canvas, so the Big screen's widened bounds and anchors do not apply.
for (const widget of Object.values(metadata.widgets)) for (const field of ["bounds", "anchorShift", "expandable"]) delete widget[field]
metadata.width = 2880
metadata.bounds.width = 2820
metadata.widgets.current_speed.default.x += 510
@@ -7,6 +7,8 @@ import { LayoutWidgetPreview, OnroadLayoutPage, clampBox, clampPlacement, ordere
const copy = (v) => JSON.parse(JSON.stringify(v))
const native = snapshot()
const metadata = copy(native.metadata.profiles.large)
// The Android Auto metadata has its own canvas, so the Big screen's widened bounds and anchors do not apply.
for (const widget of Object.values(metadata.widgets)) for (const field of ["bounds", "anchorShift", "expandable"]) delete widget[field]
metadata.width = 2880
metadata.bounds.width = 2820
metadata.widgets.current_speed.default.x += 510
@@ -73,7 +75,7 @@ const offscreen = copy(good)
Object.assign(offscreen.layouts.large.nav_map, { x: 2000, width: 900 })
assert.equal(validDocument(offscreen, data.metadata), false, "a wide map cannot hang off the right edge")
// Box metadata is Android Auto only, and only for the map.
// Box metadata is only for the map, on either screen.
const comma = snapshot()
comma.metadata.profiles.large.widgets.current_speed.box = { minWidth: 100, maxWidth: 600, minHeight: 100, maxHeight: 300 }
comma.metadata.profiles.large.widgets.current_speed.opacity = { min: 10, max: 100, default: 50 }
@@ -101,6 +103,7 @@ Object.defineProperties(vm, {
canUndo: { get: () => vm.state.history.undo.length > 0 },
canRedo: { get: () => vm.state.history.redo.length > 0 },
profile: { get: () => profile },
limitProfile: { get: () => profile },
layout: { get: () => vm.state.draft.layouts.large },
selectedWidget: { get: () => profile.widgets[vm.state.selected] },
selectedPosition: { get: () => vm.state.draft.layouts.large[vm.state.selected] },
@@ -201,18 +201,18 @@ export function validSnapshot(data) {
if (widget.visualInsetTop != null && (!Number.isFinite(widget.visualInsetTop) || widget.visualInsetTop < 0 || widget.visualInsetTop > area.height)) return false
if (!object(widget.colors) || !Object.entries(widget.colors).every(([key, value]) =>
COLORS.includes(key) && (value === null || (typeof value === "string" && HEX.test(value))))) return false
if (!Number.isFinite(area.x) || !Number.isFinite(area.y) || area.x < 0 || area.y < 0 ||
if (!Number.isFinite(area.x) || !Number.isFinite(area.y) || area.x < (widget.expandable ? -300 : 0) || area.y < 0 ||
!positive(area.width) || !positive(area.height) || area.x + area.width > profile.width ||
area.y + area.height > profile.height || typeof widget.label !== "string" || typeof widget.kind !== "string" ||
!positive(widget.width) || !positive(widget.height) || widget.width > area.width || widget.height > area.height) return false
if (widget.box && (data.metadata.projection !== true || widget.kind !== "nav_map" || !object(widget.opacity) ||
if (widget.box && (widget.kind !== "nav_map" || !object(widget.opacity) ||
![widget.box.minWidth, widget.box.maxWidth, widget.box.minHeight, widget.box.maxHeight].every(Number.isInteger) ||
widget.box.minWidth < 24 || widget.box.minHeight < 24 || widget.box.minWidth > widget.box.maxWidth ||
widget.box.minHeight > widget.box.maxHeight || widget.box.maxWidth > area.width || widget.box.maxHeight > area.height ||
![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
widget.opacity.default > widget.opacity.max)) return false
if (widget.opacity && (!projection || !["nav_map", "nav_home", "nav_work", "bookmark", "car_exit", "cruise_limits"].includes(widget.kind) ||
if (widget.opacity && (!(["nav_map", "jetlink_status"].includes(widget.kind) || projection && ["nav_home", "nav_work", "bookmark", "car_exit", "cruise_limits"].includes(widget.kind)) ||
!object(widget.opacity) || ![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
widget.opacity.default > widget.opacity.max)) return false
@@ -148,7 +148,7 @@ export const ONROAD_LAYOUT_TEMPLATE = `
<p class="gx-note">Widgets snap to the screen edges, the center and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.</p>
<p class="gx-note">General edit shows samples of all enabled widgets together. Driving scenes hide widgets that would be absent, and the eyes follow the scene. Eye changes stay with that scene; your General edit eyes are remembered.</p>
<p class="gx-note">The stopped timer appears in General edit and Stopped timer. Turn off editing aids to see the screen without outlines and guides.</p>
<p class="gx-note">The steering wheel and driver monitoring icon can be resized in each layout{{ projection ? ', and the map overlay takes any size and opacity' : '' }}. Existing display preferences and driving state still control when widgets appear.</p>
<p class="gx-note">The steering wheel and driver monitoring icon can be resized in each layout.{{ profile.widgets.nav_map ? ' The map overlay has adjustable size and opacity.' : '' }} Existing display preferences and driving state still control when widgets appear.</p>
<p v-if="!projection" class="gx-note">Small speed-limit signs can overlap confirmation actions. Live confirmations hide an overlapping sign until the decision ends. Select either widget in the list to edit it.</p>
<p v-if="!projection && state.showFavoriteZones" class="gx-note">Favorite tap areas apply to enabled slots. Speed-limit controls and the steering wheel take priority when the drawer is closed.</p>
</div>
@@ -220,7 +220,7 @@ export const ONROAD_LAYOUT_TEMPLATE = `
<input v-if="selectedWidget.resizable" class="gx-slider" type="range" step="1" aria-label="Size" :min="selectedWidget.resizable.min" :max="selectedWidget.resizable.max"
:value="selectedPosition.size" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="sizeSlider($event)" @change="finishColorEdit" @blur="finishColorEdit">
<template v-if="selectedWidget.opacity">
<label for="layout-widget-opacity">{{ selectedWidget.box ? 'Map opacity' : selectedWidget.kind === 'cruise_limits' ? 'Background opacity' : 'Opacity' }} · {{ selectedPosition.opacity }}%</label>
<label for="layout-widget-opacity">{{ selectedWidget.box ? 'Map opacity' : ['cruise_limits', 'jetlink_status'].includes(selectedWidget.kind) ? 'Background opacity' : 'Opacity' }} · {{ selectedPosition.opacity }}%</label>
<input id="layout-widget-opacity" class="gx-slider" type="range" step="1" :min="selectedWidget.opacity.min" :max="selectedWidget.opacity.max"
:value="selectedPosition.opacity" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @input="opacityInput($event)" @change="finishColorEdit" @blur="finishColorEdit">
</template>
@@ -118,6 +118,7 @@ export class OnroadLayoutFeed {
if (response.status === 409) throw new Error("Saved values or parked status changed. Your edits are kept here. Reload before saving again.")
if (!response.ok) throw new Error(saving ? "Colors and layout could not be saved. Your edits are kept here. Reload before trying again." :
"Colors and layout are unavailable. Try reloading.")
const mapMoved = saving && data.mapDisabledElsewhere === true
data = this.projection ? editorSnapshot(data) : withDisplay(data)
if (!validSnapshot(data)) throw new Error("The device returned an unsupported colors and layout document. Reload to try again.")
const initial = !this.data
@@ -128,7 +129,7 @@ export class OnroadLayoutFeed {
status: "ready", data, ...((saving || initial || !background) ? { draft: clone(data.document) } : {}), error: "", needsReload: false,
notice: saving ? (this.projection ? "Android Auto layout saved." : data.failed?.length ?
"Layout saved, but these display options did not change: " + data.failed.join(", ") + ". Reload and try again." :
"Colors and both layouts saved.") :
"Colors and both layouts saved.") + (mapMoved ? " The map overlay is now off in the " + (this.projection ? "Big screen" : "Android Auto") + " layout." : "") :
!background && !initial ? "Reloaded the saved layout." : ""
})
return true
@@ -231,6 +232,14 @@ export const LayoutWidgetPreview = {
<path d="M38 48 H78 M67 37 L78 48 L67 59" />
</g>
</g>
<g v-else-if="widget.kind === 'jetlink_status'" :opacity="(widget.opacity ?? 85) / 100">
<rect :width="widget.width" :height="widget.height" :rx="widget.height / 2" fill="#0f0d17eb" />
<circle :cx="widget.height / 2" :cy="widget.height / 2" r="9" fill="#46c86e" />
<text :x="widget.height - 6" y="40" font-size="28" font-weight="600" fill="#f5f2fc">Link up</text>
<text :x="widget.height - 6" y="68" font-size="22" fill="#b8b2c8">Jetlink • iPhone</text>
<text :x="widget.width - 70" y="52" text-anchor="end" font-size="34" font-weight="600" fill="#f5f2fc">24.6</text>
<text :x="widget.width - 30" y="52" text-anchor="end" font-size="22" fill="#b8b2c8">ms</text>
</g>
<g v-else-if="widget.kind === 'nav_card'">
<rect x="1" y="1" :width="widget.width - 2" :height="widget.height - 2" rx="39" fill="#0f0d17f5" stroke="#a07edcbe" stroke-width="2" />
<g transform="translate(24 47) scale(1.7)" fill="none" stroke="white" stroke-width="7" stroke-linecap="round" stroke-linejoin="round">
@@ -324,7 +333,7 @@ export const OnroadLayoutPage = {
for (const [key, value] of Object.entries(update)) {
if (JSON.stringify(state[key]) !== JSON.stringify(value)) state[key] = value
}
if (draftChanged || ["Colors and both layouts saved.", "Android Auto layout saved."].includes(update.notice)) state.history = { undo: [], redo: [], group: null }
if (draftChanged || ["Colors and both layouts saved.", "Android Auto layout saved."].some(saved => update.notice?.startsWith(saved))) state.history = { undo: [], redo: [], group: null }
}
const feed = new OnroadLayoutFeed({ projection: props.projection, unauthorized: props.unauthorized, publish: apply })
watch(() => !!state.drag || !!state.layerDrag, (dragging) => { if (!dragging && deferred) apply({}) }, { flush: "sync" })
@@ -391,6 +400,15 @@ export const OnroadLayoutPage = {
sceneBorder() { return previewScene(this.state.scene).border },
expandedRoad() { return !this.projection && this.state.profile === "large" && this.displayValue("DeveloperSidebar") !== "On" },
roadBounds() { return this.expandedRoad ? { ...this.profile.bounds, width: this.profile.bounds.width + 300 } : this.profile.bounds },
// Widgets reach the whole screen only while the developer sidebar is hidden; otherwise the road beside it.
limitProfile() {
if (this.expandedRoad || this.projection || !this.profile) return this.profile
return { ...this.profile, widgets: Object.fromEntries(Object.entries(this.profile.widgets).map(([id, widget]) => {
if (!widget.expandable) return [id, widget]
const { bounds, ...narrow } = widget
return [id, narrow]
})) }
},
// The last screen for this profile stays up while the next scene renders, instead of flashing the sketch.
nativeBackdropReady() { return !!this.state.backdrop.url && this.state.backdrop.profile === (this.projection ? "projection" : this.state.profile) },
layout() { return this.state.draft?.layouts[this.state.profile] },
@@ -465,7 +483,7 @@ export const OnroadLayoutPage = {
return [{ offset: 0, color: '#72ff5c', alpha: 0 }, { offset: .5, color: '#72ff5c', alpha: 89 / 255 },
{ offset: 1, color: '#0df87a', alpha: 102 / 255 }]
},
limits() { return this.selectedWidget ? placementLimits(this.profile, this.state.selected, this.layout) : null },
limits() { return this.selectedWidget ? placementLimits(this.limitProfile, this.state.selected, this.layout) : null },
displayGroups() {
const groups = []
for (const row of this.state.data?.display || []) {
@@ -491,11 +509,17 @@ export const OnroadLayoutPage = {
},
methods: {
previewShift(id) {
return this.expandedRoad ? ({ steering_wheel: 300, current_speed: 150, torque_bar: 150, driving_mode_descriptions: 150, clock: 150 }[id] || 0) : 0
return this.expandedRoad ? (this.profile.widgets[id]?.anchorShift || 0) * 300 : 0
},
// A position saved for the wider screen is shown pulled inside the road that is showing, as the device does.
previewX(id) {
const x = this.layout[id].x + this.previewShift(id), widget = this.profile.widgets[id]
if (!widget?.expandable) return x
const road = this.roadBounds
return Math.max(road.x, Math.min(x, road.x + road.width - widgetSize(widget, this.layout[id])[0]))
},
previewX(id) { return this.layout[id].x + this.previewShift(id) },
previewSnapTargets(id) {
if (!this.expandedRoad) return snapTargets(this.profile, this.layout, id, key => !this.previewHidden(key))
if (!this.expandedRoad) return snapTargets(this.limitProfile, this.layout, id, key => !this.previewHidden(key))
const widget = this.profile.widgets[id], bounds = widget.bounds || this.profile.bounds, shift = this.previewShift(id)
const profile = { ...this.profile, widgets: { ...this.profile.widgets, [id]: { ...widget, bounds: { ...bounds, x: bounds.x + shift } } } }
const layout = Object.fromEntries(Object.entries(this.layout).map(([key, at]) => [key, { ...at, x: this.previewX(key) }]))
@@ -767,7 +791,7 @@ export const OnroadLayoutPage = {
},
changePosition(id, x, y) {
if (!this.editable) return
const position = clampPlacement(this.profile, id, x, y, this.layout)
const position = clampPlacement(this.limitProfile, id, x, y, this.layout)
if (position) {
const before = this.state.drag ? null : clone(this.state.draft)
Object.assign(this.layout[id], position)
@@ -845,7 +869,7 @@ export const OnroadLayoutPage = {
// Actions that sat right under the card follow its new bottom; both stay on screen.
if (actions && actions.x === card.x && actions.y === card.y + oldHeight + 22) actions.y = card.y + height + 22
for (const id of ["cruise_limits", "speed_limit_actions"].filter(id => this.layout[id])) {
const limits = placementLimits(this.profile, id, this.layout)
const limits = placementLimits(this.limitProfile, id, this.layout)
this.layout[id].y = Math.max(limits.minY, Math.min(this.layout[id].y, limits.maxY))
}
this.recordChange(before)
@@ -890,7 +914,7 @@ export const OnroadLayoutPage = {
const [newWidth] = widgetSize(this.selectedWidget, this.selectedPosition)
this.selectedPosition.x += (oldWidth - newWidth) / 2
}
Object.assign(this.selectedPosition, clampPlacement(this.profile, this.state.selected,
Object.assign(this.selectedPosition, clampPlacement(this.limitProfile, this.state.selected,
this.selectedPosition.x, this.selectedPosition.y, this.layout))
this.recordChange(before)
this.state.notice = ""
@@ -910,7 +934,7 @@ export const OnroadLayoutPage = {
}
const before = clone(this.state.draft), position = this.selectedPosition
position.size = size
const next = clampPlacement(this.profile, this.state.selected, position.x, position.y, this.layout)
const next = clampPlacement(this.limitProfile, this.state.selected, position.x, position.y, this.layout)
if (next) {
Object.assign(position, next)
this.recordChange(before)
@@ -971,7 +995,7 @@ export const OnroadLayoutPage = {
},
resizeBox(id, width, height, group = null) {
if (!this.editable) return false
const position = this.layout[id], next = clampBox(this.profile, id, position, width, height)
const position = this.layout[id], next = clampBox(this.limitProfile, id, position, width, height)
if (!next) return false
const before = this.state.drag ? null : clone(this.state.draft)
Object.assign(position, next)
@@ -990,7 +1014,7 @@ export const OnroadLayoutPage = {
if (!this.editable || this.state.drag || this.state.layerDrag || !range || !Number.isInteger(size) || size < range.min || size > range.max) return
const before = clone(this.state.draft), position = this.selectedPosition, previous = position.size
position.size = size
const next = clampPlacement(this.profile, this.state.selected, position.x, position.y, this.layout)
const next = clampPlacement(this.limitProfile, this.state.selected, position.x, position.y, this.layout)
if (next) {
Object.assign(position, next)
this.recordChange(before, this.state.profile + ":" + this.state.selected + ":size")
@@ -1053,16 +1077,16 @@ export const OnroadLayoutPage = {
const [width, height] = widgetSize(this.profile.widgets[drag.id], this.layout[drag.id])
drag.targets ||= this.previewSnapTargets(drag.id)
// Pushing past an edge parks the widget on it, so snap from where the clamp will leave it.
const { minX, maxX, minY, maxY } = placementLimits(this.profile, drag.id, this.layout)
const { minX, maxX, minY, maxY } = placementLimits(this.limitProfile, drag.id, this.layout)
const inside = { x: Math.max(minX, Math.min(maxX, raw.x)), y: Math.max(minY, Math.min(maxY, raw.y)) }
const snapped = snapMove({ ...inside, width, height }, drag.targets, snapThreshold(this.profile, rect, event.pointerType))
const clamped = clampPlacement(this.profile, drag.id, snapped.x, snapped.y, this.layout)
const clamped = clampPlacement(this.limitProfile, drag.id, snapped.x, snapped.y, this.layout)
if (clamped) {
next = snapped
if (clamped.x === Math.round(snapped.x) && clamped.y === Math.round(snapped.y)) this.state.snapGuides = snapped.guides
}
}
if (drag.fromTray) drag.inside = clampPlacement(this.profile, drag.id, next.x, next.y, this.layout) !== null
if (drag.fromTray) drag.inside = clampPlacement(this.limitProfile, drag.id, next.x, next.y, this.layout) !== null
this.changePosition(drag.id, next.x, next.y)
}
if (drag.fromTray) this.layout[drag.id].enabled = drag.inside
@@ -1,4 +1,4 @@
"""Separate AA placement document; native large/compact schemas stay fixed."""
"""Separate AA placement document, with screen-specific geometry and actions."""
import copy
import json
import math
@@ -6,8 +6,9 @@ from pathlib import Path
from openpilot.starpilot.system.android_auto.identity import DATA_DIR
from openpilot.starpilot.system.android_auto.display_profile import screen_geometry
from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, MODE_WIDGET, STOPPED_TIMER_WIDGET,
CEM_GAUGE_FRAME, CEM_GAUGE_INSET, SPEED_ALIGNMENTS, customization_metadata, validate_widget_order)
from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, JETLINK_WIDGET, MODE_WIDGET,
STOPPED_TIMER_WIDGET, CEM_GAUGE_FRAME, CEM_GAUGE_INSET, MAP_MIN_SIZE, MAP_OPACITY, NAV_CARD_SIZE,
SPEED_ALIGNMENTS, customization_metadata, validate_map_box, validate_widget_order)
from openpilot.starpilot.ui.onroad_stopped_timer import timer_size
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
from openpilot.starpilot.ui.onroad_customization import SPEED_LAYOUT_V7, migrate_speed_layout
@@ -16,8 +17,7 @@ MAX_BYTES = 16384
DOCUMENT_KEY = 'projection'
DOCUMENT_PATH = DATA_DIR / 'layouts/document.json'
# Android Auto only: the comma layout keeps its fixed schema. Layouts saved
# before these (or the driving-mode widget) existed gain them with their defaults.
# Layouts saved before these widgets existed gain them with their defaults.
NAV_CARD, NAV_MAP = 'nav_card', 'nav_map'
NAV_HOME, NAV_WORK = 'nav_home', 'nav_work'
FAVORITE_WIDGETS = (NAV_HOME, NAV_WORK)
@@ -31,15 +31,11 @@ CAR_EXIT_SIZE = (96, 96)
CAR_EXIT_MIN_OPACITY = 25
NOW_PLAYING_WIDGET = 'now_playing'
NOW_PLAYING_SIZE = (520, 96)
NOW_PLAYING_OPACITY = (0, 100, 85) # percent: min, max, default
JETLINK_WIDGET = 'jetlink_status'
NOW_PLAYING_OPACITY = (0, 100, 85)
JETLINK_SIZE = (420, 84)
JETLINK_OPACITY = (0, 100, 85) # percent: min, max, default
CARD_WIDGETS = {NOW_PLAYING_WIDGET: NOW_PLAYING_OPACITY, JETLINK_WIDGET: JETLINK_OPACITY} # background opacity only
JETLINK_OPACITY = (0, 100, 85)
CARD_WIDGETS = {NOW_PLAYING_WIDGET: NOW_PLAYING_OPACITY, JETLINK_WIDGET: JETLINK_OPACITY}
PROJECTION_WIDGETS = (NAV_CARD, NAV_MAP, *DISPLAY_WIDGETS, STOPPED_TIMER_WIDGET, NOW_PLAYING_WIDGET, JETLINK_WIDGET, CAR_EXIT)
NAV_CARD_SIZE = (560, 195)
MAP_MIN_SIZE = (280, 200)
MAP_OPACITY = (15, 100, 50) # percent: min, max, default
class ProjectionLayoutSource:
@@ -71,6 +67,11 @@ def layout_metadata_for_viewport(viewport):
reservedZones=[], inputZones=[])
profile.pop('inputZonePriority', None)
widgets = profile['widgets']
widgets.pop(JETLINK_WIDGET) # Big-screen only for now
profile['widgetOrder'] = [key for key in profile['widgetOrder'] if key != JETLINK_WIDGET]
for widget in widgets.values():
for field in ('bounds', 'anchorShift', 'expandable'):
widget.pop(field, None)
widgets['current_speed']['default']['x'] += (width - 1860) / 2
widgets[MODE_WIDGET]['default']['x'] += (width - 1860) / 2
widgets[CLOCK_WIDGET]['default']['x'] += (width - 1860) / 2
@@ -79,28 +80,18 @@ def layout_metadata_for_viewport(viewport):
x, y, w, h = maximum_footprint(30, 30, width - 60, height - 60, width)
widgets['torque_bar'].update(width=w, height=h)
widgets['torque_bar']['default'].update(x=x, y=y)
timer_w, timer_h = timer_size('large')
widgets[STOPPED_TIMER_WIDGET] = {'label': 'Stopped timer', 'kind': STOPPED_TIMER_WIDGET,
'width': timer_w, 'height': timer_h, 'colors': {},
'note': 'Counts how long you have been stopped, after the first minute of the drive',
'default': {'x': (width - timer_w) / 2, 'y': 520, 'enabled': False}}
# The turn card keeps the comma's large-UI spot, following the right edge.
timer_w, _ = timer_size('large')
widgets[STOPPED_TIMER_WIDGET]['default'].update(x=(width - timer_w) / 2, y=520)
card_w, card_h = NAV_CARD_SIZE
widgets[NAV_CARD] = {'label': 'Turn-by-turn', 'kind': NAV_CARD, 'width': card_w, 'height': card_h, 'colors': {},
'note': 'The next maneuver while a route is active, in the comma style',
'default': {'x': 1230 + width - 1860, 'y': 415, 'enabled': True}}
card_x, card_bottom = 1230 + width - 1860, 415 + card_h + 15
widgets[NAV_CARD]['default'].update(x=card_x)
# The map scales with the screen: about 30% of its width in a 4:3 frame,
# right-aligned with the turn card and sitting just below it.
card_x, card_bottom = 1230 + width - 1860, 415 + card_h + 15
map_w = max(MAP_MIN_SIZE[0], round(width * 0.3 / 10) * 10)
map_h = max(MAP_MIN_SIZE[1], min(round(map_w * 0.75 / 10) * 10, (height - 30 - card_bottom) // 10 * 10))
widgets[NAV_MAP] = {'label': 'Map overlay', 'kind': NAV_MAP, 'width': map_w, 'height': map_h, 'colors': {},
'note': 'Road lines and your route over the camera. Drag the corner to resize; opacity sets how much shows.',
'box': {'minWidth': MAP_MIN_SIZE[0], 'maxWidth': width - 60,
'minHeight': MAP_MIN_SIZE[1], 'maxHeight': height - 60},
'opacity': {'min': MAP_OPACITY[0], 'max': MAP_OPACITY[1], 'default': MAP_OPACITY[2]},
'default': {'x': max(30, card_x + card_w - map_w), 'y': min(card_bottom, height - 30 - map_h),
'enabled': False, 'width': map_w, 'height': map_h, 'opacity': MAP_OPACITY[2]}}
widgets[NAV_MAP].update(width=map_w, height=map_h,
box={'minWidth': MAP_MIN_SIZE[0], 'maxWidth': width - 60, 'minHeight': MAP_MIN_SIZE[1], 'maxHeight': height - 60})
widgets[NAV_MAP]['default'].update(x=max(30, card_x + card_w - map_w), y=min(card_bottom, height - 30 - map_h), width=map_w, height=map_h)
for index, key in enumerate(FAVORITE_WIDGETS):
label = ('Home', 'Work')[index]
widgets[key] = {'label': label, 'kind': key, 'width': FAVORITE_SIZE[0], 'height': FAVORITE_SIZE[1], 'colors': {},
@@ -137,7 +128,8 @@ def layout_metadata_for_viewport(viewport):
# Background opacity for the cruise and speed-limit card; its text stays solid.
widgets['cruise_limits'].update(opacity={'min': 0, 'max': 100, 'default': 100})
widgets['cruise_limits']['default'].update(opacity=100)
profile["widgetOrder"] = [NAV_MAP, *profile["widgetOrder"], STOPPED_TIMER_WIDGET, NAV_CARD, *DISPLAY_WIDGETS, NOW_PLAYING_WIDGET, JETLINK_WIDGET, CAR_EXIT]
profile["widgetOrder"] = [NAV_MAP, *[key for key in profile["widgetOrder"] if key not in (NAV_MAP, NAV_CARD, STOPPED_TIMER_WIDGET)],
STOPPED_TIMER_WIDGET, NAV_CARD, *DISPLAY_WIDGETS, NOW_PLAYING_WIDGET, JETLINK_WIDGET, CAR_EXIT]
return profile
@@ -295,11 +287,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
if type(size) is not int or not limits['min'] <= size <= limits['max']:
raise ValueError('Invalid widget size')
if key == NAV_MAP:
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')
validate_map_box(placement, widget)
dimensions = placement_size(key, widget, placement)
if value['version'] < 4 and key in SPEED_LAYOUT_V7['large']:
dimensions = SPEED_LAYOUT_V7['large'][key][:2]
@@ -368,7 +356,8 @@ def projection_customization(document, base_customization):
shifts = {'current_speed': ((width - 1860) / 2, 0),
MODE_WIDGET: ((width - 1860) / 2, 0),
CLOCK_WIDGET: ((width - 1860) / 2, 0),
'steering_wheel': (width - 1860, 0), 'driver_monitor': (0, height - 1080)}
'steering_wheel': (width - 1860, 0), 'driver_monitor': (0, height - 1080),
NAV_CARD: (width - 1860, 0), NAV_MAP: (width - 1860, 0)}
native = PROFILES['large']['widgets']
assert isinstance(native, dict)
tx, ty, _, _ = maximum_footprint(30, 30, width - 60, height - 60, width)
@@ -0,0 +1,39 @@
"""Lets the native UI drop its map while Android Auto is drawing one; both need GPS and GPU tile work."""
import os
import time
DEFAULT_PATH = '/tmp/starpilot-projection-map.lease'
MAX_AGE_S = 3.0
TOUCH_EVERY_S = 1.0
class MapLeaseWriter:
def __init__(self, path=DEFAULT_PATH):
self.path = str(path)
self._touched = -TOUCH_EVERY_S
def touch(self, now=None):
now = time.monotonic() if now is None else now
if now - self._touched < TOUCH_EVERY_S:
return
self._touched = now
try:
with open(self.path, 'a'):
os.utime(self.path)
except OSError:
pass
def release(self):
self._touched = -TOUCH_EVERY_S
try:
os.unlink(self.path)
except OSError:
pass
def projection_map_active(path=DEFAULT_PATH, now=None):
try:
age = (time.time() if now is None else now) - os.stat(path).st_mtime
except OSError:
return False
return 0 <= age <= MAX_AGE_S
@@ -9,6 +9,7 @@ import time
from openpilot.starpilot.system.android_auto.identity import EXPIRY_WARNING_DAYS
from openpilot.starpilot.system.android_auto.projection_exit import ProjectionExit
from openpilot.starpilot.system.android_auto.projection_geometry import FALLBACK_VIEWPORT
from openpilot.starpilot.system.android_auto.projection_map_lease import MapLeaseWriter
CERTIFICATE_NOTICE_NS = 10_000_000_000 # how long the expiry heads-up stays at the start of a drive
BOOKMARK_NOTICE_NS = 2_000_000_000 # how long "Bookmark Saved" stays after the drive is bookmarked
@@ -207,6 +208,8 @@ class ProjectionOnroad:
self.onroad.pip_layer = self._pip_layer
self.map = None
self.map_feed = None
self._map_lease = MapLeaseWriter()
self._resources.callback(self._release_map)
self._ensure_map()
self.adapter = native.adapter(native.ui_state)
# Personality notices and the reverse camera are decided inside the adapter, so it needs AA's choices too.
@@ -290,10 +293,16 @@ class ProjectionOnroad:
native = self.native
if self.map is None and self._map_placement() is not None and getattr(native, 'map_overlay', None) is not None:
self.map = native.map_overlay(fonts=self.fonts)
self._resources.callback(self.map.close)
self.map_feed = native.map_feed()
self.onroad.map_layer = self._map_layer
def _release_map(self):
if self.map is not None:
self.map.close()
self.map = self.map_feed = None
self.onroad.map_layer = None
self._map_lease.release()
def apply_layout(self, customization):
"""Switch to a newly saved Android Auto layout without reconnecting."""
self.customization = customization
@@ -340,8 +349,11 @@ class ProjectionOnroad:
self.onroad.navigation_favorites.document = self.favorites.document
self.onroad.navigation_favorites.error = self.favorites.error
placed = self._map_placement()
if self.map is not None and placed is None:
self._release_map()
if self.map is None or placed is None or not self.native.ui_state.started:
return
self._map_lease.touch()
document = self.favorites.document if self.favorites is not None else None
requested = bool(document['destination']) if document is not None else None
from openpilot.starpilot.system.android_auto.projection_favorites import destination_label
@@ -34,7 +34,7 @@ class TestProjectionLayout(unittest.TestCase):
base = default_document()
converted = projection_customization(decoded, base)
self.assertEqual(converted['layouts']['large']['jetlink_status'], migrated['widgets']['jetlink_status'])
self.assertNotIn('jetlink_status', base['layouts']['large'])
self.assertFalse(base['layouts']['large']['jetlink_status']['enabled'])
self.assertNotIn('jetlink_status', converted['layouts']['compact'])
def test_exit_defaults_and_monitor_scaling_preserve_saved_appearance(self):
@@ -256,9 +256,11 @@ class TestProjectionLayout(unittest.TestCase):
before = copy.deepcopy(base)
document = default_layout(SCREEN)
converted = projection_customization(document, base)
native = {key: value for key, value in converted['layouts']['large'].items() if key not in PROJECTION_WIDGETS}
self.assertEqual(native, base['layouts']['large'])
self.assertEqual(converted['layouts']['large'][NAV_MAP], document['widgets'][NAV_MAP])
own = (*PROJECTION_WIDGETS, 'jetlink_status')
native = {key: value for key, value in converted['layouts']['large'].items() if key not in own}
self.assertEqual(native, {key: value for key, value in base['layouts']['large'].items() if key not in own})
shift = document['canvas']['width'] - 1860
self.assertEqual(converted['layouts']['large'][NAV_MAP], {**document['widgets'][NAV_MAP], 'x': document['widgets'][NAV_MAP]['x'] - shift})
document['widgets']['current_speed']['x'] += 100
converted = projection_customization(document, base)
self.assertEqual(converted['layouts']['large']['current_speed']['x'], 740)
@@ -1,6 +1,7 @@
"""The map overlay is created only when the AA layout enables it, prepared outside the frame, drawn at its placement."""
import copy
import unittest
from unittest.mock import patch
from openpilot.starpilot.system.android_auto import projection_onroad as projection
from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport
@@ -33,7 +34,24 @@ class FakeFeed:
return "map input"
class FakeLease:
def __init__(self):
self.touches, self.releases = 0, 0
def touch(self):
self.touches += 1
def release(self):
self.releases += 1
class TestProjectionMap(unittest.TestCase):
def setUp(self):
self.lease = FakeLease()
patcher = patch.object(projection, 'MapLeaseWriter', return_value=self.lease)
patcher.start()
self.addCleanup(patcher.stop)
def native(self):
native, events = boundary.TestProjectionOnroad().dependencies()
native.map_overlay, native.map_feed = FakeMap, FakeFeed
@@ -90,3 +108,24 @@ class TestProjectionMap(unittest.TestCase):
view.prepare()
view.close()
self.assertEqual(layout, before)
def test_turning_the_map_off_frees_it_and_the_lease_until_it_returns(self):
native, _ = self.native()
layout = self.layout(enabled=True)
view = projection.ProjectionOnroad(dependencies=native, viewport=(2880, 1080), customization=layout)
native.ui_state.started = True
view.prepare()
self.assertEqual(self.lease.touches, 1)
overlay = view.map
off = copy.deepcopy(layout)
off["widgets"]["nav_map"]["enabled"] = False
view.apply_layout(off)
view.prepare()
self.assertTrue(overlay.closed)
self.assertIsNone(view.map)
self.assertIsNone(view.onroad.map_layer)
self.assertGreaterEqual(self.lease.releases, 1)
view.apply_layout(layout)
self.assertIsInstance(view.map, FakeMap)
self.assertIsNot(view.map, overlay)
view.close()
@@ -0,0 +1,32 @@
import os
import tempfile
import unittest
from openpilot.starpilot.system.android_auto.projection_map_lease import MAX_AGE_S, MapLeaseWriter, projection_map_active
class MapLeaseTests(unittest.TestCase):
def test_lease_is_active_only_while_fresh_and_ends_on_release(self):
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, 'lease')
writer = MapLeaseWriter(path)
self.assertFalse(projection_map_active(path))
writer.touch(now=100.0)
self.assertTrue(projection_map_active(path))
mtime = os.stat(path).st_mtime
self.assertTrue(projection_map_active(path, now=mtime + MAX_AGE_S))
self.assertFalse(projection_map_active(path, now=mtime + MAX_AGE_S + 1))
writer.release()
self.assertFalse(projection_map_active(path))
writer.release()
def test_touch_is_throttled(self):
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, 'lease')
writer = MapLeaseWriter(path)
writer.touch(now=10.0)
os.utime(path, (1, 1))
writer.touch(now=10.5)
self.assertEqual(os.stat(path).st_mtime, 1)
writer.touch(now=11.5)
self.assertGreater(os.stat(path).st_mtime, 1)
@@ -15,7 +15,8 @@ import pyray as rl
from openpilot.starpilot.ui.layout_preview_transport import SCENES, validate_preview_document
from openpilot.starpilot.ui.developer_preview import OnroadVisualPreview
from openpilot.starpilot.ui.onroad_customization import CAMERA_WIDGETS, PROFILES, RUNTIME_PREVIEW_WIDGETS, placement, validate_document, widget_size
from openpilot.starpilot.ui.onroad_customization import (CAMERA_WIDGETS, PROFILES, ROAD_BASE_WIDTH, RUNTIME_PREVIEW_WIDGETS, fit_to_road, placement,
validate_document, widget_size)
from openpilot.starpilot.ui.onroad_widget_style import draw_widget_frame
from openpilot.starpilot.curve_speed.status import CurveObservation
from openpilot.starpilot.ui.onroad_state import ConditionalLead
@@ -219,11 +220,12 @@ class _Canvas:
self.view.projection_exit_layer = ProjectionExit(rl).render
self.view.now_playing.track = {'source': 'spotify', 'status': 'playing', 'title': 'Midnight City', 'artist': 'M83', 'album': '',
'durationMs': 243000, 'position': {'ms': 92000, 'at': 0, 'speed': 0.0}}
from openpilot.starpilot.models.jetlink_status import JetlinkStatus
self.view.jetlink_status.status = JetlinkStatus('iPhone', 'active', 24.6, 'connected')
if profile == Profile.LARGE:
from openpilot.starpilot.ui.onroad_map import MapOverlay, SampleTileReader
self.map = MapOverlay(reader=SampleTileReader(), fonts=self.fonts)
self.view.map_layer = self._render_map
from openpilot.starpilot.models.jetlink_status import JetlinkStatus
self.view.jetlink_status.status = JetlinkStatus('iPhone', 'active', 24.6, 'connected')
self.monitor = _DriverMonitorArt(profile)
self.view.driver_monitor_layer = self._render_driver_monitor
self.view.preview_pip_layer = self._render_preview_pip
@@ -247,7 +249,8 @@ class _Canvas:
def _render_map(self, _rect: rl.Rectangle, state: OnroadState) -> None:
placed = self._map_placement(state)
if placed is not None and self.map is not None:
self.map.draw(rl.Rectangle(placed["x"], placed["y"], placed["width"], placed["height"]), placed["opacity"] / 100.0)
shift = (self.viewport[0] if self.viewport else state.viewport_width) - 1860
self.map.draw(rl.Rectangle(placed["x"] + shift, placed["y"], placed["width"], placed["height"]), placed["opacity"] / 100.0)
def _render_preview_pip(self, rect, state, *, submit=None):
if submit is not None:
@@ -457,11 +460,14 @@ class LayoutPreviewRenderer:
profile = Profile.LARGE if viewport else Profile(payload["profile"])
scene = payload["scene"]
sidebar = document.pop('developerSidebar', False)
state = sample_state(scene, document)
if profile == Profile.LARGE and viewport is None:
width = profile.size[0] - (SIDEBAR_WIDTH if sidebar else 0)
# Widget art is cropped from the saved 1860px road, as the editor places each sprite itself.
document = fit_to_road(document, ROAD_BASE_WIDTH if payload.get('art') is True else width)
state = sample_state(scene, document)
if profile == Profile.LARGE and viewport is None:
state = replace(state, viewport_width=width, appearance=replace(state.appearance, show_developer_sidebar=sidebar))
if viewport:
if profile == Profile.LARGE:
from openpilot.starpilot.ui.navigation_state import NavigationDisplay
state = replace(state, navigation=NavigationDisplay(("preview", 1), "Turn left onto Desert Inn Road", "turn", "left",
152.0, 6800.0, 540.0))
+2
View File
@@ -144,6 +144,8 @@ class OnroadView:
self.current_speed = CurrentSpeedHud(fonts)
self.cem_gauge = AetherGauge(fonts)
self.steering_wheel = SteeringWheelWidget(asset_directory)
from openpilot.starpilot.ui.onroad_jetlink import JetlinkStatusWidget
self.jetlink_status = JetlinkStatusWidget(fonts)
else:
self.compact_hud = CompactHudRenderer(fonts, asset_directory)
self.compact_sidebar = MiciSidebarWidgets(fonts)
+93 -9
View File
@@ -9,6 +9,7 @@ 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.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)
@@ -36,6 +37,9 @@ class WidgetPlacement(TypedDict):
y: float
enabled: bool
size: NotRequired[int]
width: NotRequired[int]
height: NotRequired[int]
opacity: NotRequired[int]
class Widget(TypedDict):
@@ -53,6 +57,8 @@ class Widget(TypedDict):
colors: NotRequired[dict[str, str | None]]
resizable: NotRequired[dict[str, int]]
sourcesExtent: NotRequired[dict[str, float]]
box: NotRequired[dict[str, int]]
opacity: NotRequired[dict[str, int]]
class LayoutProfile(TypedDict):
@@ -154,12 +160,37 @@ for _profile, _geometry in {"large": (300, 72, 780, 430), "compact": (140, 44, 1
_clock_widget["default"]["enabled"] = False
PROFILES[_profile]["widgets"][CLOCK_WIDGET] = _clock_widget
# Small-UI only; the box matches the old fixed, centered position.
STOPPED_TIMER_WIDGET = "stopped_timer"
_timer_widget = _widget("Stopped timer", STOPPED_TIMER_WIDGET, 440, 112, 18, 48)
_timer_widget["default"]["enabled"] = False
_timer_widget["note"] = "Counts how long you have been stopped"
PROFILES["compact"]["widgets"][STOPPED_TIMER_WIDGET] = _timer_widget
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))
_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))
_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'
@@ -170,8 +201,21 @@ 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, 452)
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}
EXPANDABLE_WIDGETS = ("current_speed", "cruise_limits", "speed_limit_actions", "steering_wheel", "driver_monitor", MODE_WIDGET,
CLOCK_WIDGET, CEM_GAUGE_WIDGET, STOPPED_TIMER_WIDGET, NAV_CARD_WIDGET, NAV_MAP_WIDGET, JETLINK_WIDGET)
for _key, _widget_data in PROFILES["large"]["widgets"].items():
if _key in ANCHOR_SHIFT:
_widget_data["anchorShift"] = ANCHOR_SHIFT[_key]
if _key in EXPANDABLE_WIDGETS:
_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})
OPTIONAL_WIDGETS = frozenset({MODE_WIDGET, CLOCK_WIDGET, STOPPED_TIMER_WIDGET, CEM_GAUGE_WIDGET, NAV_CARD_WIDGET, NAV_MAP_WIDGET, JETLINK_WIDGET})
# These editor sprites use the runtime components and their canonical assets/fonts.
RUNTIME_PREVIEW_WIDGETS = {
@@ -208,8 +252,8 @@ WIDGET_COLORS = {
# Back-to-front order used when a layout first opts into custom layers.
DEFAULT_WIDGET_ORDER = {
"large": ("pip_left", "pip_right", "cruise_limits", "speed_limit_actions", CEM_GAUGE_WIDGET, "current_speed",
"steering_wheel", "torque_bar", "driver_monitor", MODE_WIDGET, CLOCK_WIDGET),
"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),
}
@@ -267,6 +311,14 @@ def default_document():
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):
if profile == 'large':
card, actions = layout['cruise_limits'], layout['speed_limit_actions']
@@ -453,8 +505,18 @@ def validate_document(value):
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"]
@@ -472,7 +534,7 @@ def validate_document(value):
if dimensions is None and value['version'] < 8:
dimensions = SPEED_LAYOUT_V7[profile].get(key)
dimension = (dimensions[0 if axis == 'x' else 1] if dimensions else
size if key in WIDGET_SIZES else widget[extent])
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:
@@ -540,11 +602,33 @@ 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]
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"])
+1 -1
View File
@@ -2,7 +2,7 @@
import pyray as rl
from openpilot.starpilot.models.jetlink_status import JetlinkStatus
from openpilot.starpilot.system.android_auto.projection_layout import JETLINK_SIZE, JETLINK_WIDGET
from openpilot.starpilot.ui.onroad_customization import JETLINK_SIZE, JETLINK_WIDGET
from openpilot.starpilot.ui.presentation import FontRole
LABELS = {'active': 'Link up', 'connected': 'Host connected', 'ready': 'Ready to switch',
@@ -0,0 +1,37 @@
"""Keeps Jetlink's status polled for the native large screen only while its widget is showing."""
from openpilot.starpilot.models.jetlink_status import JetlinkStatusFeed
from openpilot.starpilot.ui.onroad_customization import JETLINK_WIDGET
from openpilot.system.ui.lib.application import gui_app
class NativeJetlinkLayer:
def __init__(self, widget, ui_state):
self.widget, self.ui = widget, ui_state
self.feed = JetlinkStatusFeed()
self.pending = None
gui_app.add_render_prepare(self.prepare, self.close)
@staticmethod
def placement(state):
placed = state.customization["layouts"]["large"].get(JETLINK_WIDGET)
return placed if placed is not None and placed["enabled"] else None
def prepare(self):
# The preceding rendered frame asks for the next poll; hidden pages stop asking.
state, self.pending = self.pending, None
self.feed.set_enabled(state is not None and bool(self.ui.started) and self.placement(state) is not None)
def render(self, state):
if self.placement(state) is None:
self.pending = None
return
self.pending = state
self.widget.status = self.feed.snapshot()
self.widget.render(state)
def close(self):
gui_app.remove_render_prepare(self.prepare)
self.pending = None
self.feed.set_enabled(False)
self.feed.close()
@@ -0,0 +1,57 @@
"""Lazy map resources for the native large screen; GPU preparation precedes drawing."""
import pyray as rl
from openpilot.starpilot.system.android_auto.projection_map_lease import projection_map_active
from openpilot.system.ui.lib.application import gui_app
class NativeMapLayer:
def __init__(self, fonts, ui_state):
self.fonts, self.ui = fonts, ui_state
self.map = self.feed = None
self.pending = None
self.prepared_size = None
gui_app.add_render_prepare(self.prepare, self.close)
@staticmethod
def placement(state):
placed = state.customization["layouts"]["large"].get("nav_map")
return placed if placed is not None and placed["enabled"] else None
def prepare(self):
# The preceding rendered frame requests the next map. Hidden pages stop
# requesting it, so their worker, GPS subscription and GPU targets go away.
# Android Auto's own map takes precedence, so only one tile worker runs.
state, self.pending = self.pending, None
placed = self.placement(state) if state is not None and self.ui.started and not projection_map_active() else None
if placed is None:
self._release()
return
if self.map is None:
from openpilot.starpilot.ui.onroad_map import MapFeed, MapOverlay
self.map = MapOverlay(fonts=self.fonts)
self.feed = MapFeed()
self.map.prepare(self.feed.read(self.ui.sm), placed["width"], placed["height"])
self.prepared_size = (placed["width"], placed["height"])
def render(self, _rect, state):
placed = self.placement(state)
if placed is None or not self.ui.started or projection_map_active():
self.pending = None
return
self.pending = state
if self.map is not None and self.prepared_size == (placed["width"], placed["height"]):
self.map.draw(rl.Rectangle(placed["x"] + state.viewport_width - 1860, placed["y"], placed["width"], placed["height"]),
placed["opacity"] / 100)
def _release(self):
if self.map is not None:
self.map.close()
self.map = self.feed = None
self.prepared_size = None
def close(self):
gui_app.remove_render_prepare(self.prepare)
self.pending = None
self._release()
+5 -7
View File
@@ -49,14 +49,12 @@ class NavigationCard:
def _placed(self, state):
if self.fonts.profile == Profile.COMPACT:
return rl.Rectangle(244, 18, 72, 72) if self.collapsed else rl.Rectangle(96, 16, 368, 142)
# Android Auto layouts place the card; the comma keeps its fixed spot.
# The card follows the right edge; legacy callers without a placement keep its fixed spot.
placed = state.customization["layouts"]["large"].get("nav_card")
if placed is not None:
if not placed["enabled"]:
return None
x, y = placed["x"], placed["y"]
else:
x, y = 1230 + state.viewport_width - 1860, 415
if placed is not None and not placed["enabled"]:
return None
x, y = (placed["x"], placed["y"]) if placed is not None else (1230, 415)
x += state.viewport_width - 1860
return rl.Rectangle(x + 448, y, 112, 112) if self.collapsed else rl.Rectangle(x, y, 560, 195)
def press(self, x, y, state):
+19 -4
View File
@@ -43,7 +43,7 @@ from openpilot.starpilot.ui.pip_owner import FORMAT_PREFIX as PIP_FORMAT_PREFIX,
from openpilot.starpilot.ui.pip_preferences import read_pip
from openpilot.starpilot.ui.pip_render import PiPRenderer
from openpilot.starpilot.ui.pip_sidecam import Signals, Rect
from openpilot.starpilot.ui.onroad_customization import CAMERA_WIDGETS, placement, widget_size
from openpilot.starpilot.ui.onroad_customization import CAMERA_WIDGETS, fit_to_road, placement, widget_size
from openpilot.starpilot.ui.layout_preview_sidebar import SIDEBAR_WIDTH
from openpilot.starpilot.ui.pip_warning import PiPWarningSource
from openpilot.starpilot.spot_monitor.preferences import read_preferences as read_vasm_preferences
@@ -295,6 +295,14 @@ class StarShellSession:
self._projection_slc = SlcActionReceiver()
except OSError:
pass # Car-screen speed-limit taps stay unavailable without affecting the native UI.
self.onroad_map = self.onroad_jetlink = None
if profile == Profile.LARGE:
from openpilot.starpilot.ui.onroad_map_layer import NativeMapLayer
self.onroad_map = NativeMapLayer(self.fonts, ui_state)
self.view.onroad.map_layer = self.onroad_map.render
from openpilot.starpilot.ui.onroad_jetlink_layer import NativeJetlinkLayer
self.onroad_jetlink = NativeJetlinkLayer(self.view.onroad.jetlink_status, ui_state)
self.view.onroad.jetlink_status = self.onroad_jetlink
def _favorite_authority(self, *, allow_personality_notice: bool = False) -> bool:
if getattr(ui_state, "replay_clock", None) is not None:
@@ -1280,11 +1288,12 @@ class StarShellSession:
snapshot = self.adapter.build(mode, self.selected, compact_y=self.compact_y,
compact_scroll_x=self.compact_scroll_x, sidebar_expanded=self.sidebar_expanded,
menu_only=self.profile == Profile.COMPACT and mode == ShellMode.SETTINGS,
onroad_view_only=mode == ShellMode.ONROAD)
onroad_view_only=mode == ShellMode.ONROAD, stopped_timer_profile=str(self.profile))
if mode == ShellMode.ONROAD and self.profile == Profile.LARGE:
width = getattr(self, '_onroad_page_width', 2160.0) - (SIDEBAR_WIDTH if snapshot.onroad.appearance.show_developer_sidebar else 0)
if snapshot.onroad.viewport_width != width:
snapshot = replace(snapshot, onroad=replace(snapshot.onroad, viewport_width=width))
fitted = fit_to_road(snapshot.onroad.customization, width)
if snapshot.onroad.viewport_width != width or fitted is not snapshot.onroad.customization:
snapshot = replace(snapshot, onroad=replace(snapshot.onroad, viewport_width=width, customization=fitted))
if mode == ShellMode.HOME:
now = time.monotonic()
previous = getattr(self, "_home_model", None)
@@ -1689,6 +1698,12 @@ class StarShellSession:
self._favorite_claimed = False
def close(self) -> None:
if layer := getattr(self, 'onroad_jetlink', None):
layer.close()
self.onroad_jetlink = None
if layer := getattr(self, 'onroad_map', None):
layer.close()
self.onroad_map = None
if receiver := getattr(self, '_projection_bookmarks', None):
receiver.close()
self._projection_bookmarks = None
+2 -2
View File
@@ -380,7 +380,7 @@ class RuntimeSnapshotAdapter:
def build(self, mode: ShellMode, selected: Destination = Destination.STAR, *, compact_y: float = 0,
compact_scroll_x: float = 0, sidebar_expanded: bool = True, now_ns: int | None = None,
menu_only: bool = False, stopped_timer_enabled: bool | None = None,
onroad_view_only: bool = False) -> ShellSnapshot:
onroad_view_only: bool = False, stopped_timer_profile: str = "compact") -> ShellSnapshot:
replay = getattr(self.ui_state, "replay_sample", None)
if getattr(self.ui_state, "replay_clock", None) is not None:
if replay is not None and replay.epoch != self._replay_epoch:
@@ -492,7 +492,7 @@ class RuntimeSnapshotAdapter:
self._replay_stopped_history = False
elif not timer_standstill or timer_reverse:
self._replay_stopped_history = True
timer_enabled = (placement(self._customization_value, "compact", STOPPED_TIMER_WIDGET)["enabled"]
timer_enabled = (placement(self._customization_value, stopped_timer_profile, STOPPED_TIMER_WIDGET)["enabled"]
if stopped_timer_enabled is None else stopped_timer_enabled)
stopped_duration = self._stopped_timer.step(
now_ns=now_ns,
@@ -136,10 +136,12 @@ def test_background_tap_on_home_or_settings_does_not_toggle_onroad_sidebar(large
@pytest.mark.parametrize('width', [1560, 1860, 2160])
def test_wheel_and_navigation_hit_boxes_follow_resized_camera(width):
def test_wheel_and_legacy_navigation_hit_boxes_follow_resized_camera(width):
observed = OnroadState(False, True, 10, 50, SpeedLimitObservation(), viewport_width=width,
experimental_available=True, experimental_action_token='test',
navigation=NavigationDisplay(('route', 1), 'Turn left', 'turn', 'left', 120, 1000, 90))
# Legacy callers without an editable card retain their right-edge anchor.
observed.customization['layouts']['large'].pop('nav_card')
emitted = []
wheel = OnroadInput(emitted.append)
x = 1650 + width - 1860
@@ -87,6 +87,51 @@ class LayoutPreviewRendererTests(unittest.TestCase):
self.assertIsNone(state.stopped_duration_s)
renderer.close()
def test_big_and_projection_previews_supply_route_and_timer_samples_but_compact_does_not(self):
from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport
from openpilot.starpilot.ui.onroad_customization import STOPPED_TIMER_WIDGET
native = default_document()
native['layouts']['large'][STOPPED_TIMER_WIDGET]['enabled'] = True
native['layouts']['large']['nav_map'].update(enabled=True)
payloads = [('large', native, Profile.LARGE, None),
('projection', {'base': default_document(), 'layout': default_layout_for_viewport((2880, 1080))}, Profile.LARGE, (2880, 1080)),
('compact', default_document(), Profile.COMPACT, None)]
with patch('openpilot.starpilot.ui.layout_preview_renderer.rl.is_window_ready', return_value=True), \
patch('openpilot.starpilot.ui.layout_preview_renderer._Canvas') as canvas_type:
for profile, document, expected, viewport in payloads:
renderer = LayoutPreviewRenderer()
renderer({'profile': profile, 'document': document, 'scene': 'stopped'})
self.assertEqual(canvas_type.call_args.args, (expected, viewport))
state = canvas_type.return_value.render.call_args.args[0]
self.assertEqual(state.navigation is not None, expected == Profile.LARGE, profile)
if profile == 'large':
self.assertEqual(state.stopped_duration_s, 134)
self.assertTrue(state.customization['layouts']['large']['nav_card']['enabled'])
renderer.close()
def test_big_preview_fits_widgets_to_the_road_that_is_showing(self):
from openpilot.starpilot.ui.onroad_customization import widget_size
document = default_document()
card = widget_size(document, 'large', 'cruise_limits')[0]
document['layouts']['large']['cruise_limits']['x'] = 2130 - card
document['layouts']['large']['steering_wheel']['x'] = -270
with patch('openpilot.starpilot.ui.layout_preview_renderer.rl.is_window_ready', return_value=True), \
patch('openpilot.starpilot.ui.layout_preview_renderer._Canvas') as canvas_type:
renderer = LayoutPreviewRenderer()
for sidebar, art, width, viewport in ((False, None, 2160, 2160), (True, None, 1860, 1860), (False, True, 1860, 2160)):
payload = {'profile': 'large', 'scene': 'engaged', 'document': {**document, 'developerSidebar': sidebar}}
if art:
payload['art'] = True
canvas_type.return_value.render_widget_art.return_value = b'png'
renderer(payload)
call = canvas_type.return_value.render_widget_art if art else canvas_type.return_value.render
state = call.call_args.args[0]
large = state.customization['layouts']['large']
self.assertEqual(state.viewport_width, viewport)
self.assertEqual(large['cruise_limits']['x'], 2130 - card if width == 2160 else 1830 - card)
self.assertEqual(large['steering_wheel']['x'], -270 if width == 2160 else 30)
renderer.close()
def test_fixed_scenes_have_no_camera_or_live_input(self):
document = default_document()
expected = {
@@ -138,7 +183,7 @@ class LayoutPreviewRendererTests(unittest.TestCase):
def test_invalid_draft_rejected_before_gl_resources(self):
renderer = LayoutPreviewRenderer()
document = default_document()
document["layouts"]["large"]["current_speed"]["x"] = -1
document["layouts"]["large"]["current_speed"]["x"] = -200
with patch("openpilot.starpilot.ui.layout_preview_renderer._Canvas") as canvas:
with self.assertRaises(ValueError):
renderer({"document": document, "profile": "large", "scene": "engaged"})
@@ -791,7 +791,7 @@ def test_stopped_timer_widget_is_optional_off_and_protected_when_on():
upgraded = validate_document(older)
assert upgraded["layouts"]["compact"][STOPPED_TIMER_WIDGET] == {"x": 18, "y": 48, "enabled": False}
assert upgraded["widgetOrder"]["compact"][-1] == STOPPED_TIMER_WIDGET
assert STOPPED_TIMER_WIDGET not in upgraded["layouts"]["large"]
assert upgraded["layouts"]["large"][STOPPED_TIMER_WIDGET]["enabled"] is False
# Off, it may sit anywhere; on, it must clear the speed-limit buttons like other widgets.
parked = default_document()
parked["layouts"]["compact"][STOPPED_TIMER_WIDGET].update(y=120)
@@ -849,3 +849,91 @@ def test_projection_timer_renders_at_saved_coordinates_without_native_timer_enab
document['layouts']['large'][STOPPED_TIMER_WIDGET]['enabled'] = False
view._stopped_timer(road(document), 134)
view.fonts.draw.assert_not_called()
def test_large_navigation_widgets_migrate_without_changing_existing_layout_or_order():
import copy
keys = ("nav_card", "nav_map", STOPPED_TIMER_WIDGET)
older = default_document()
older["layouts"]["large"]["current_speed"].update(x=700, enabled=False)
older["widgetOrder"] = {"large": [key for key in reversed(DEFAULT_WIDGET_ORDER["large"]) if key not in keys]}
for key in keys:
del older["layouts"]["large"][key]
original = copy.deepcopy(older)
upgraded = validate_document(older)
assert older == original
assert upgraded["layouts"]["large"]["current_speed"] == older["layouts"]["large"]["current_speed"]
assert upgraded["widgetOrder"]["large"][:len(older["widgetOrder"]["large"])] == older["widgetOrder"]["large"]
assert set(upgraded["widgetOrder"]["large"]) == set(upgraded["layouts"]["large"])
for key in keys:
assert upgraded["layouts"]["large"][key] == default_document()["layouts"]["large"][key]
assert upgraded["layouts"]["large"][key]["enabled"] is (key == "nav_card")
upgraded["layouts"]["large"]["nav_map"].update(x=100, y=300, width=900, height=500, opacity=35, enabled=True)
upgraded["layouts"]["large"]["nav_card"].update(x=800, y=500, enabled=True)
assert decode_document(json.dumps(upgraded)) == upgraded
assert widget_size(upgraded, "large", "nav_map") == (900, 500)
@pytest.mark.parametrize("change", [{"width": 279}, {"height": 199}, {"opacity": 14}, {"opacity": 101},
{"opacity": True}, {"width": 560.5}, {"width": 900}, {"x": float("nan")}])
def test_native_map_rejects_invalid_size_opacity_and_offscreen_placements(change):
document = default_document()
document["layouts"]["large"]["nav_map"].update(change)
with pytest.raises(ValueError):
validate_document(document)
def test_big_widgets_can_use_the_space_a_hidden_sidebar_adds():
from openpilot.starpilot.ui.onroad_customization import ANCHOR_SHIFT, PROFILES, fit_to_road
widgets = PROFILES["large"]["widgets"]
document = default_document()
speed = document["layouts"]["large"]["current_speed"]
assert widgets["current_speed"]["bounds"]["x"] == 30 - 150 and widgets["steering_wheel"]["bounds"]["x"] == 30 - 300
assert "bounds" not in widgets["torque_bar"] and "bounds" not in widgets["pip_left"]
speed["x"] = 30 - 150
assert validate_document(document)["layouts"]["large"]["current_speed"]["x"] == -120
speed["x"] = 30 - 151
with pytest.raises(ValueError):
validate_document(document)
# Every widget reaches the right edge of the full screen, and not past it.
for key in ("cruise_limits", "stopped_timer", "nav_card", "nav_map"):
document = default_document()
placed, width = document["layouts"]["large"][key], widget_size(document, "large", key)[0]
placed["x"] = 2130 - width - ANCHOR_SHIFT.get(key, 0) * 300
validate_document(document)
placed["x"] += 1
with pytest.raises(ValueError):
validate_document(document)
def test_fit_to_road_pulls_widgets_in_only_where_the_road_is_narrower():
from openpilot.starpilot.ui.onroad_customization import fit_to_road
document = default_document()
large = document["layouts"]["large"]
large["stopped_timer"]["x"] = 2130 - 660
large["steering_wheel"]["x"] = -270
large["current_speed"]["x"] = -120
assert fit_to_road(document, 2160) is document
fitted = fit_to_road(document, 1860)["layouts"]["large"]
assert fitted["stopped_timer"]["x"] == 1830 - 660
assert fitted["steering_wheel"]["x"] == 30
assert fitted["current_speed"]["x"] == 30
assert fitted["cruise_limits"] is large["cruise_limits"]
assert large["stopped_timer"]["x"] == 2130 - 660
assert fit_to_road(default_document(), 1860)["layouts"]["large"] == default_document()["layouts"]["large"]
def test_jetlink_status_is_an_opt_in_big_widget_with_background_opacity():
document = default_document()
placed = document["layouts"]["large"]["jetlink_status"]
assert placed["enabled"] is False and placed["opacity"] == 85
assert "jetlink_status" not in document["layouts"]["compact"]
placed.update(enabled=True, x=100, y=300, opacity=40)
assert validate_document(document)["layouts"]["large"]["jetlink_status"]["opacity"] == 40
older = default_document()
del older["layouts"]["large"]["jetlink_status"]
assert validate_document(older)["layouts"]["large"]["jetlink_status"]["enabled"] is False
for bad in (101, -1, 50.5, True, None):
document["layouts"]["large"]["jetlink_status"]["opacity"] = bad
with pytest.raises(ValueError):
validate_document(document)
@@ -0,0 +1,33 @@
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from openpilot.starpilot.ui.onroad_customization import default_document
from openpilot.starpilot.ui.onroad_jetlink_layer import NativeJetlinkLayer
def test_native_jetlink_polls_only_while_its_widget_is_showing_onroad():
with patch('openpilot.starpilot.ui.onroad_jetlink_layer.gui_app') as app, \
patch('openpilot.starpilot.ui.onroad_jetlink_layer.JetlinkStatusFeed') as feed:
widget, ui = Mock(), NS(started=True)
layer = NativeJetlinkLayer(widget, ui)
state = NS(customization=default_document())
app.add_render_prepare.assert_called_once_with(layer.prepare, layer.close)
layer.render(state)
layer.prepare()
widget.render.assert_not_called() # Off by default.
feed.return_value.set_enabled.assert_called_with(False)
state.customization['layouts']['large']['jetlink_status']['enabled'] = True
layer.render(state)
assert widget.status is feed.return_value.snapshot.return_value
widget.render.assert_called_once_with(state)
layer.prepare()
feed.return_value.set_enabled.assert_called_with(True)
layer.prepare() # No render request: the page is hidden.
feed.return_value.set_enabled.assert_called_with(False)
layer.render(state)
ui.started = False
layer.prepare()
feed.return_value.set_enabled.assert_called_with(False)
layer.close()
app.remove_render_prepare.assert_called_once_with(layer.prepare)
feed.return_value.close.assert_called_once()
@@ -0,0 +1,70 @@
from types import SimpleNamespace as NS
from unittest.mock import Mock, patch
from openpilot.starpilot.ui.onroad_customization import default_document
from openpilot.starpilot.ui.onroad_map_layer import NativeMapLayer
def test_native_map_prepares_outside_render_and_releases_when_hidden_disabled_or_offroad():
with patch('openpilot.starpilot.ui.onroad_map_layer.gui_app') as app, \
patch('openpilot.starpilot.ui.onroad_map.MapOverlay') as overlay, \
patch('openpilot.starpilot.ui.onroad_map.MapFeed') as feed:
ui = NS(started=True, sm=Mock())
layer = NativeMapLayer(Mock(), ui)
state = NS(customization=default_document(), viewport_width=1860)
app.add_render_prepare.assert_called_once_with(layer.prepare, layer.close)
layer.render(None, state)
layer.prepare()
overlay.assert_not_called()
placed = state.customization['layouts']['large']['nav_map']
placed.update(enabled=True, x=100, y=200, width=600, height=400, opacity=35)
layer.render(None, state)
overlay.assert_not_called() # Rendering only queues offscreen work.
layer.prepare()
overlay.return_value.prepare.assert_called_once_with(feed.return_value.read.return_value, 600, 400)
feed.return_value.read.assert_called_once_with(ui.sm)
layer.render(None, state)
args = overlay.return_value.draw.call_args.args
assert (args[0].x, args[0].y, args[0].width, args[0].height, args[1]) == (100, 200, 600, 400, .35)
placed.update(width=700)
overlay.return_value.draw.reset_mock()
layer.render(None, state)
overlay.return_value.draw.assert_not_called() # Never stretch an old prepared map after a resize.
layer.prepare()
layer.prepare() # No render request: the page is hidden.
assert layer.map is None
overlay.return_value.close.assert_called_once()
layer.render(None, state)
layer.prepare()
placed['enabled'] = False
layer.render(None, state)
layer.prepare()
assert layer.map is None
placed['enabled'] = True
layer.render(None, state)
ui.started = False
layer.prepare()
assert layer.map is None
layer.close()
app.remove_render_prepare.assert_called_once_with(layer.prepare)
def test_native_map_yields_while_android_auto_draws_its_own():
with patch('openpilot.starpilot.ui.onroad_map_layer.gui_app'), \
patch('openpilot.starpilot.ui.onroad_map_layer.projection_map_active') as lease, \
patch('openpilot.starpilot.ui.onroad_map.MapOverlay') as overlay, \
patch('openpilot.starpilot.ui.onroad_map.MapFeed'):
ui = NS(started=True, sm=Mock())
layer = NativeMapLayer(Mock(), ui)
state = NS(customization=default_document(), viewport_width=1860)
state.customization['layouts']['large']['nav_map'].update(enabled=True)
lease.return_value = False
layer.render(None, state)
layer.prepare()
assert layer.map is not None
lease.return_value = True
layer.render(None, state)
assert layer.pending is None
layer.prepare()
assert layer.map is None
overlay.return_value.close.assert_called_once()
@@ -35,6 +35,19 @@ def state():
return OnroadState(False, False, 10., 50., SpeedLimitObservation(), navigation=navigation_display(message(), now_ns=NOW, drive_id=DRIVE))
def test_large_navigation_optional_widget_controls_draw_and_touch_bounds():
card, observed = NavigationCard(Mock(profile=Profile.LARGE)), replace(state(), viewport_width=1860)
placed = observed.customization['layouts']['large']['nav_card']
placed.update(x=900, y=650)
bounds = card.bounds(observed)
assert (bounds.x, bounds.y, bounds.width, bounds.height) == (900, 650, 560, 195)
assert card.bounds(replace(observed, viewport_width=2160)).x == 1200
assert card.press(920, 670, observed)
placed['enabled'] = False
assert card.bounds(observed) is None
assert not card.press(920, 670, observed)
def test_actual_instruction_does_not_require_control_authority():
nav = message()
nav.controlValid = False
@@ -17,6 +17,24 @@ SECOND = 1_000_000_000
class StoppedTimerTests(unittest.TestCase):
def test_native_timer_follows_its_screen_profile(self):
for profile in ("large", "compact"):
ui = ui_fake()
car = ui.sm.messages['carState']
car.canValid, car.canTimeout, car.standstill = True, False, True
car.gearShifter = car_schema.CarState.GearShifter.drive
document = default_document()
document['layouts'][profile][STOPPED_TIMER_WIDGET]['enabled'] = True
for screen in ("large", "compact"):
adapter = RuntimeSnapshotAdapter(ui)
with patch('openpilot.starpilot.ui.runtime_snapshot.read_customization', return_value=document):
for second in (0, 61):
now = NOW + second * SECOND
for name in ('deviceState', 'pandaStates', 'carState'):
ui.sm.logMonoTime[name] = now
state = adapter.build(ShellMode.ONROAD, now_ns=now, stopped_timer_profile=screen).onroad
self.assertEqual(state.stopped_duration_s, 61 if screen == profile else None)
def test_projection_timer_enablement_overrides_native_choice(self):
for native_enabled, projection_enabled in ((False, True), (True, False)):
with self.subTest(native_enabled=native_enabled, projection_enabled=projection_enabled):
@@ -58,11 +58,11 @@ def test_projection_order_is_independent_from_native_and_survives_decode():
def test_legacy_projection_order_adds_upstream_mode_widget():
projection = default_layout_for_viewport((2880, 1080))
projection['widgets'].pop(MODE_WIDGET)
projection['widgetOrder'] = [key for key in DEFAULT_WIDGET_ORDER['large'] if key != MODE_WIDGET]
projection['widgetOrder'] = [key for key in DEFAULT_WIDGET_ORDER['large'] if key not in (MODE_WIDGET, 'jetlink_status')]
migrated = validate_layout_for_viewport(projection, (2880, 1080))
assert migrated['widgets'][MODE_WIDGET]['enabled'] is False
assert migrated['widgetOrder'] == [*projection['widgetOrder'], MODE_WIDGET, 'nav_card', 'nav_map', 'nav_home', 'nav_work',
'bookmark', 'stopped_timer', 'now_playing', 'jetlink_status', CAR_EXIT]
assert migrated['widgetOrder'] == [*projection['widgetOrder'], MODE_WIDGET, 'nav_home', 'nav_work',
'bookmark', 'now_playing', 'jetlink_status', CAR_EXIT]
def test_old_documents_keep_their_original_shape():
@@ -138,6 +138,8 @@ def test_renderer_dispatches_each_widget_in_saved_order(profile, viewport):
order = [*reversed([key for key in default_order if key != CAR_EXIT]), CAR_EXIT]
projection['widgetOrder'] = order
document = projection_customization(projection, document)
view.projection_exit_layer = lambda state: seen.append(CAR_EXIT)
if profile == 'large':
view.map_layer = lambda *args: seen.append('nav_map')
view.navigation = NS(render=lambda *args: seen.append('nav_card'))
view.navigation_favorites = NS(render=lambda key, state: seen.append(key))