diff --git a/openpilot/starpilot/galaxy/onroad_layout.py b/openpilot/starpilot/galaxy/onroad_layout.py
index 98b1646b9a..cd19502bc9 100644
--- a/openpilot/starpilot/galaxy/onroad_layout.py
+++ b/openpilot/starpilot/galaxy/onroad_layout.py
@@ -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.
diff --git a/openpilot/starpilot/galaxy/projection_layout.py b/openpilot/starpilot/galaxy/projection_layout.py
index d99b658053..bc39547b76 100644
--- a/openpilot/starpilot/galaxy/projection_layout.py
+++ b/openpilot/starpilot/galaxy/projection_layout.py
@@ -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}
diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py
index 393905947f..e1e95ff019 100644
--- a/openpilot/starpilot/galaxy/server.py
+++ b/openpilot/starpilot/galaxy/server.py
@@ -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:
diff --git a/openpilot/starpilot/galaxy/test_projection_layout.py b/openpilot/starpilot/galaxy/test_projection_layout.py
index 514278278d..19fbf3d115 100644
--- a/openpilot/starpilot/galaxy/test_projection_layout.py
+++ b/openpilot/starpilot/galaxy/test_projection_layout.py
@@ -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)
diff --git a/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs b/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs
new file mode 100644
index 0000000000..514bd91654
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs
@@ -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")
diff --git a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs
index ca9f3cd4f7..b688362dd0 100644
--- a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs
@@ -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")
diff --git a/openpilot/starpilot/galaxy/tests/test_onroad_layout.py b/openpilot/starpilot/galaxy/tests/test_onroad_layout.py
index ca52a9df1b..361257c99a 100644
--- a/openpilot/starpilot/galaxy/tests/test_onroad_layout.py
+++ b/openpilot/starpilot/galaxy/tests/test_onroad_layout.py
@@ -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
diff --git a/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs b/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs
index 1899f3d4f4..8f6441ae67 100644
--- a/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs
@@ -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
diff --git a/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs b/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs
index df636fea2e..314f090be3 100644
--- a/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs
@@ -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] },
diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js
index 1efa4f8bd3..63dc3ff12b 100644
--- a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js
+++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js
@@ -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
diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-template.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-template.js
index 2b05e70a44..146c64587e 100644
--- a/openpilot/starpilot/galaxy/web/js/onroad-layout-template.js
+++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-template.js
@@ -148,7 +148,7 @@ export const ONROAD_LAYOUT_TEMPLATE = `
Widgets snap to the screen edges, the center and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.
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.
The stopped timer appears in General edit and Stopped timer. Turn off editing aids to see the screen without outlines and guides.
-
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.
+
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.
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.
Favorite tap areas apply to enabled slots. Speed-limit controls and the steering wheel take priority when the drawer is closed.
@@ -220,7 +220,7 @@ export const ONROAD_LAYOUT_TEMPLATE = `
-
+
diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js
index e17bb87f4d..0b5b54e22b 100644
--- a/openpilot/starpilot/galaxy/web/js/onroad-layout.js
+++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js
@@ -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 = {
+
+
+
+ Link up
+ Jetlink • iPhone
+ 24.6
+ ms
+
@@ -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
diff --git a/openpilot/starpilot/system/android_auto/projection_layout.py b/openpilot/starpilot/system/android_auto/projection_layout.py
index 5f6989c2d0..75b065dc6d 100644
--- a/openpilot/starpilot/system/android_auto/projection_layout.py
+++ b/openpilot/starpilot/system/android_auto/projection_layout.py
@@ -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)
diff --git a/openpilot/starpilot/system/android_auto/projection_map_lease.py b/openpilot/starpilot/system/android_auto/projection_map_lease.py
new file mode 100644
index 0000000000..5c2bbfc8c2
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/projection_map_lease.py
@@ -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
diff --git a/openpilot/starpilot/system/android_auto/projection_onroad.py b/openpilot/starpilot/system/android_auto/projection_onroad.py
index c8e59f394e..9e43faff74 100644
--- a/openpilot/starpilot/system/android_auto/projection_onroad.py
+++ b/openpilot/starpilot/system/android_auto/projection_onroad.py
@@ -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
diff --git a/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py b/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py
index d1c44a5024..11af632d57 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py
@@ -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)
diff --git a/openpilot/starpilot/system/android_auto/tests/test_projection_map.py b/openpilot/starpilot/system/android_auto/tests/test_projection_map.py
index bae6e0ef3e..64d7f9d219 100644
--- a/openpilot/starpilot/system/android_auto/tests/test_projection_map.py
+++ b/openpilot/starpilot/system/android_auto/tests/test_projection_map.py
@@ -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()
diff --git a/openpilot/starpilot/system/android_auto/tests/test_projection_map_lease.py b/openpilot/starpilot/system/android_auto/tests/test_projection_map_lease.py
new file mode 100644
index 0000000000..2359394f93
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/tests/test_projection_map_lease.py
@@ -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)
diff --git a/openpilot/starpilot/ui/layout_preview_renderer.py b/openpilot/starpilot/ui/layout_preview_renderer.py
index 43b8656e45..2424ec29c8 100644
--- a/openpilot/starpilot/ui/layout_preview_renderer.py
+++ b/openpilot/starpilot/ui/layout_preview_renderer.py
@@ -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))
diff --git a/openpilot/starpilot/ui/onroad.py b/openpilot/starpilot/ui/onroad.py
index 33fdbbb845..20fccdb5a2 100644
--- a/openpilot/starpilot/ui/onroad.py
+++ b/openpilot/starpilot/ui/onroad.py
@@ -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)
diff --git a/openpilot/starpilot/ui/onroad_customization.py b/openpilot/starpilot/ui/onroad_customization.py
index b34f4b85da..eda9af62e9 100644
--- a/openpilot/starpilot/ui/onroad_customization.py
+++ b/openpilot/starpilot/ui/onroad_customization.py
@@ -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"])
diff --git a/openpilot/starpilot/ui/onroad_jetlink.py b/openpilot/starpilot/ui/onroad_jetlink.py
index 6bddf6f91c..4091002a4d 100644
--- a/openpilot/starpilot/ui/onroad_jetlink.py
+++ b/openpilot/starpilot/ui/onroad_jetlink.py
@@ -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',
diff --git a/openpilot/starpilot/ui/onroad_jetlink_layer.py b/openpilot/starpilot/ui/onroad_jetlink_layer.py
new file mode 100644
index 0000000000..1f05950089
--- /dev/null
+++ b/openpilot/starpilot/ui/onroad_jetlink_layer.py
@@ -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()
diff --git a/openpilot/starpilot/ui/onroad_map_layer.py b/openpilot/starpilot/ui/onroad_map_layer.py
new file mode 100644
index 0000000000..d181a20ac3
--- /dev/null
+++ b/openpilot/starpilot/ui/onroad_map_layer.py
@@ -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()
diff --git a/openpilot/starpilot/ui/onroad_navigation.py b/openpilot/starpilot/ui/onroad_navigation.py
index 787a9136c4..ffde3222aa 100644
--- a/openpilot/starpilot/ui/onroad_navigation.py
+++ b/openpilot/starpilot/ui/onroad_navigation.py
@@ -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):
diff --git a/openpilot/starpilot/ui/runtime_app.py b/openpilot/starpilot/ui/runtime_app.py
index 064680d381..757f438a0b 100644
--- a/openpilot/starpilot/ui/runtime_app.py
+++ b/openpilot/starpilot/ui/runtime_app.py
@@ -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
diff --git a/openpilot/starpilot/ui/runtime_snapshot.py b/openpilot/starpilot/ui/runtime_snapshot.py
index 665a109367..1b92f26d92 100644
--- a/openpilot/starpilot/ui/runtime_snapshot.py
+++ b/openpilot/starpilot/ui/runtime_snapshot.py
@@ -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,
diff --git a/openpilot/starpilot/ui/tests/test_large_onroad_navigation.py b/openpilot/starpilot/ui/tests/test_large_onroad_navigation.py
index eb6c9bf22f..62f435d47a 100644
--- a/openpilot/starpilot/ui/tests/test_large_onroad_navigation.py
+++ b/openpilot/starpilot/ui/tests/test_large_onroad_navigation.py
@@ -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
diff --git a/openpilot/starpilot/ui/tests/test_layout_preview_renderer.py b/openpilot/starpilot/ui/tests/test_layout_preview_renderer.py
index 6a005dfb8d..8472cde2fa 100644
--- a/openpilot/starpilot/ui/tests/test_layout_preview_renderer.py
+++ b/openpilot/starpilot/ui/tests/test_layout_preview_renderer.py
@@ -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"})
diff --git a/openpilot/starpilot/ui/tests/test_onroad_customization.py b/openpilot/starpilot/ui/tests/test_onroad_customization.py
index bd0a3d09df..3723b944c2 100644
--- a/openpilot/starpilot/ui/tests/test_onroad_customization.py
+++ b/openpilot/starpilot/ui/tests/test_onroad_customization.py
@@ -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)
diff --git a/openpilot/starpilot/ui/tests/test_onroad_jetlink_layer.py b/openpilot/starpilot/ui/tests/test_onroad_jetlink_layer.py
new file mode 100644
index 0000000000..8e183ba18b
--- /dev/null
+++ b/openpilot/starpilot/ui/tests/test_onroad_jetlink_layer.py
@@ -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()
diff --git a/openpilot/starpilot/ui/tests/test_onroad_map_layer.py b/openpilot/starpilot/ui/tests/test_onroad_map_layer.py
new file mode 100644
index 0000000000..13c5aa6c47
--- /dev/null
+++ b/openpilot/starpilot/ui/tests/test_onroad_map_layer.py
@@ -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()
diff --git a/openpilot/starpilot/ui/tests/test_onroad_navigation.py b/openpilot/starpilot/ui/tests/test_onroad_navigation.py
index 1e85105f27..bb922f891a 100644
--- a/openpilot/starpilot/ui/tests/test_onroad_navigation.py
+++ b/openpilot/starpilot/ui/tests/test_onroad_navigation.py
@@ -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
diff --git a/openpilot/starpilot/ui/tests/test_onroad_stopped_timer.py b/openpilot/starpilot/ui/tests/test_onroad_stopped_timer.py
index 91fe30c90d..c16d9f289e 100644
--- a/openpilot/starpilot/ui/tests/test_onroad_stopped_timer.py
+++ b/openpilot/starpilot/ui/tests/test_onroad_stopped_timer.py
@@ -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):
diff --git a/openpilot/starpilot/ui/tests/test_widget_layers.py b/openpilot/starpilot/ui/tests/test_widget_layers.py
index b71165afdd..c717dfaacb 100644
--- a/openpilot/starpilot/ui/tests/test_widget_layers.py
+++ b/openpilot/starpilot/ui/tests/test_widget_layers.py
@@ -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))