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