From d7e4c021b6653fb6d40196332a7a42b2bfc8fd1e Mon Sep 17 00:00:00 2001 From: Prabhaav Pillai Date: Thu, 8 Oct 2026 03:10:10 -0400 Subject: [PATCH] Updated RouteRuntime to read routing settings instead of general settings. Modified test cases to ensure proper handling of route changes and storage failures. Enhanced tests for temporary destination handling and suggestions. Improved scaling logic in Android Auto UI to account for letterbox offsets. Added CSS for offline road maps in the Android Auto overlay. Refined touch handling in projection favorites to prevent interaction issues. Ensured proper state management in map overlays and tile reading. --- .../tests/browser/navigation-compact.cjs | 8 +- .../galaxy/tests/test_navigation_http.py | 26 +-- .../galaxy/tests/test_offline_road_maps.mjs | 33 ++++ openpilot/starpilot/galaxy/web/css/galaxy.css | 27 +-- .../starpilot/galaxy/web/css/navigation.css | 6 +- .../galaxy/web/css/offline-road-maps.css | 24 +++ .../starpilot/galaxy/web/css/settings.css | 2 +- .../starpilot/galaxy/web/js/navigation-map.js | 5 + .../starpilot/galaxy/web/js/navigation.js | 27 +-- .../galaxy/web/js/offline-road-maps.js | 51 +++--- .../starpilot/navigation/mapbox_budget.py | 6 +- openpilot/starpilot/navigation/navtilesd.py | 76 ++++++--- .../starpilot/navigation/offline_roads.py | 41 +++-- openpilot/starpilot/navigation/owner.py | 160 ++++++++++++++---- openpilot/starpilot/navigation/runtime.py | 2 +- .../navigation/tests/test_navtilesd.py | 120 ++++++++++++- .../navigation/tests/test_search_places.py | 74 ++++---- .../system/android_auto/current_car_ui.py | 11 +- .../android_auto/projection_favorites.py | 12 ++ .../system/android_auto/projection_onroad.py | 1 + .../tests/test_current_display_source.py | 8 + .../tests/test_projection_favorites.py | 15 ++ openpilot/starpilot/ui/onroad.py | 1 + openpilot/starpilot/ui/onroad_map.py | 13 +- .../starpilot/ui/tests/test_onroad_map.py | 38 +++++ .../starpilot/ui/tests/test_widget_layers.py | 13 +- 26 files changed, 608 insertions(+), 192 deletions(-) create mode 100644 openpilot/starpilot/galaxy/web/css/offline-road-maps.css diff --git a/openpilot/starpilot/galaxy/tests/browser/navigation-compact.cjs b/openpilot/starpilot/galaxy/tests/browser/navigation-compact.cjs index 7d6c4c8fda..feeeb5a7bd 100644 --- a/openpilot/starpilot/galaxy/tests/browser/navigation-compact.cjs +++ b/openpilot/starpilot/galaxy/tests/browser/navigation-compact.cjs @@ -21,12 +21,12 @@ const status = { enabled: true, hasKey: true, isMetric: true, status: 'noDestina if (pathname === '/api/navigation/search') { const body = request.postDataJSON() searches.push(body) - return route.fulfill({ json: { results: [{ id: 'poi/coffee', name: place.name, description: place.address, searchId: body.searchId }] } }) + return route.fulfill({ json: { results: [{ ...place }] } }) } if (pathname === '/api/navigation/action') { const body = request.postDataJSON() actions.push(body) - if (body.action === 'favoritePlace') status.favorites = [{ ...place, ...(body.label ? { label: body.label } : {}) }] + if (body.action === 'favorite') status.favorites = [{ ...place, ...(body.label ? { label: body.label } : {}) }] if (body.action === 'labelFavorite') status.favorites = [{ ...place, ...(body.label ? { label: body.label } : {}) }] status.revision = String(Number(status.revision) + 1) return route.fulfill({ json: status }) @@ -44,7 +44,7 @@ const status = { enabled: true, hasKey: true, isMetric: true, status: 'noDestina contentType: pathname.endsWith('.js') ? 'text/javascript' : pathname.endsWith('.css') ? 'text/css' : pathname.endsWith('.json') ? 'application/json' : pathname.endsWith('.svg') ? 'image/svg+xml' : 'application/octet-stream' }) }) await page.goto('http://navigation.test/') - const search = page.getByRole('combobox', { name: 'Search destinations' }) + const search = page.getByRole('searchbox', { name: 'Search destinations' }) await search.waitFor() assert.equal(await page.getByText('Back to tools', { exact: true }).count(), 0) await search.fill('coffee') @@ -72,7 +72,7 @@ const status = { enabled: true, hasKey: true, isMetric: true, status: 'noDestina assert.equal(actions.at(-1).action, 'labelFavorite') assert.equal(actions.at(-1).label, null) await page.goto('http://navigation.test/?shell=1#/navigation') - await page.getByRole('combobox', { name: 'Search destinations' }).waitFor() + await page.getByRole('searchbox', { name: 'Search destinations' }).waitFor() for (const theme of ['dark', 'light']) { await page.locator('html').evaluate((el, theme) => el.dataset.theme = theme, theme) for (const width of [360, 390, 768, 1280]) { diff --git a/openpilot/starpilot/galaxy/tests/test_navigation_http.py b/openpilot/starpilot/galaxy/tests/test_navigation_http.py index 361384a3ae..a9fd032a82 100644 --- a/openpilot/starpilot/galaxy/tests/test_navigation_http.py +++ b/openpilot/starpilot/galaxy/tests/test_navigation_http.py @@ -188,11 +188,14 @@ class NavigationHttpTest(unittest.TestCase): self.assertEqual(result['destination']['name'],'Coffee Shop') self.assertEqual(provider.call_count,2) self.assertEqual(provider.call_args_list[0].args[2]['session_token'],provider.call_args_list[1].args[2]['session_token']) - self.assertEqual(self.navigation.read()['destination']['name'],'Coffee Shop') - self.assertEqual([row['name'] for row in result['recents']],['Coffee Shop']) + self.assertIsNone(self.navigation.read()['destination']) + self.assertEqual(result['destination']['temporary'],True) + self.assertEqual(result['recents'],[]) + self.assertNotIn('Coffee Shop',self.navigation.path.read_text()) self.assertNotIn('pk.synthetic',json.dumps(result)) self.assertEqual(self.action('selectPlace',id=identity,searchId=search['searchId'])[0],400) - status, saved, _ = self.action('favorite',destination=result['destination'],label='work') + self.assertEqual(self.action('favorite',destination=result['destination'],label='work')[0],400) + status, saved, _ = self.action('favorite',destination={'name':'Work address','latitude':41.,'longitude':-91.},label='work') self.assertEqual(status,200) self.assertEqual(saved['favorites'][0]['label'],'work') place = saved['favorites'][0]['id'] @@ -204,20 +207,17 @@ class NavigationHttpTest(unittest.TestCase): self.assertEqual(self.action('clearRecents')[1]['recents'],[]) self.assertEqual(self.action('removeFavorite',id=place)[1]['favorites'],[]) - def test_search_result_can_be_saved_without_navigating(self): + def test_temporary_search_result_cannot_be_saved(self): self.action('configure', patch={'token':'pk.synthetic','enabled':True}) search = {'query':'coffee', 'searchId':str(uuid.uuid4()), 'clientId':str(uuid.uuid4())} suggestion = {'suggestions':[{'mapbox_id':'poi/id','name':'Coffee Shop','feature_type':'poi'}]} - feature = {'features':[{'properties':{'mapbox_id':'poi/id','name':'Coffee Shop','full_address':'1 Main St'}, - 'geometry':{'coordinates':[-90.,40.]}}]} - with patch('openpilot.starpilot.navigation.owner.response_json',side_effect=[suggestion,feature,feature]): + with patch('openpilot.starpilot.navigation.owner.response_json',return_value=suggestion) as provider: self.assertEqual(self.request('/api/navigation/search',payload=search,cookie=self.local_cookie)[0],200) - status, saved, _ = self.action('favoritePlace',id='poi/id',searchId=search['searchId'],label='home') - self.assertEqual(status,200) - self.assertEqual(saved['favorites'][0]['label'],'home') - self.assertEqual((saved['favorites'][0]['name'],saved['favorites'][0]['address'],saved['destination']),('Coffee Shop','1 Main St',None)) - status, chosen, _ = self.action('selectPlace',id='poi/id',searchId=search['searchId']) - self.assertEqual((status,chosen['destination']['name']),(200,'Coffee Shop')) + status, body, _ = self.action('favoritePlace',id='poi/id',searchId=search['searchId'],label='home') + self.assertEqual(status,400) + self.assertIn('cannot be saved',body['error']) + self.assertEqual(provider.call_count,1) + self.assertEqual(self.navigation.read()['favorites'],[]) def test_status_poll_with_route_key_omits_unchanged_route(self): self.action('configure', patch={'token':'pk.synthetic','enabled':True}) diff --git a/openpilot/starpilot/galaxy/tests/test_offline_road_maps.mjs b/openpilot/starpilot/galaxy/tests/test_offline_road_maps.mjs index be92106e05..ab914bd900 100644 --- a/openpilot/starpilot/galaxy/tests/test_offline_road_maps.mjs +++ b/openpilot/starpilot/galaxy/tests/test_offline_road_maps.mjs @@ -73,6 +73,10 @@ globalThis.performance = realPerformance frames.length = 0 map.requestDraw(); map.requestDraw(); map.requestDraw() assert.equal(frames.length, 1, "pointer moves within a frame draw once") +let picked = null +map.onTap = point => { picked = point } +listeners.keydown({ key: "Enter", preventDefault() {} }) +assert.deepEqual(picked, map.center, "keyboard users can select the map center") map.close() // Client: load, act, and hand 401s to the sign-in flow. @@ -104,6 +108,35 @@ assert.equal(signedOut, true) assert.equal(guarded.active, false) client.stop() +// An older poll cannot undo a completed setting change, even if fetch ignores abort. +const pending = [] +const racing = new OfflineRoadsClient({ publish() {}, later: () => 1, cancel() {}, + fetcher: () => new Promise(resolve => pending.push(resolve)) }) +const oldPoll = racing.start() +const changed = racing.action({ action: "settings", patch: { saveDriven: false } }) +const response = body => ({ ok: true, status: 200, json: async () => body }) +pending[1](response(snapshot({ settings: { saveDriven: false } }))) +await changed +pending[0](response(snapshot())) +await oldPoll +assert.equal(racing.data.settings.saveDriven, false) +const stoppedPoll = racing.load() +racing.stop() +pending[2](response(snapshot())) +await stoppedPoll +assert.equal(racing.data.settings.saveDriven, false, "stopped requests cannot publish") + +let timeout +const stalled = new OfflineRoadsClient({ publish() {}, cancel() {}, later: (fn, ms) => { if (ms === 10000) timeout = fn; return 1 }, + fetcher: (_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new DOMException("Timed out", "AbortError")))) }) +stalled.active = true +const timedOut = stalled.action({ action: "settings", patch: { saveDriven: true } }) +timeout() +await timedOut +assert.equal(stalled.busy, false, "an unresponsive action releases the controls") +assert.ok(stalled.error) +stalled.stop() + // Templates compile with the real Vue compiler, and the Offline Maps tab shows both panels. const decodeEntities = (value) => value.replaceAll("&", "&") compile(OfflineRoadMapsPanel.template, { decodeEntities }) diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index add6229255..3b8ca49c12 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -877,7 +877,7 @@ button.gx-crash-row:hover { background: var(--glass-bg-hover); transform: transl .gx-searchbox.open .gx-search-toggle { flex: none; order: 2; } } -@container galaxy-header (max-width: 350px) { .gx-appbar__title { display: none; } } +@container galaxy-header (max-width: 380px) { .gx-appbar__title { display: none; } } @media (max-width: 767px) { .blur-nav { display: grid; } @@ -1488,28 +1488,3 @@ progress { max-width: 100%; accent-color: var(--primary); } /* Descriptions are ordinary page content; notices communicate changing state. */ .gx-settings-tabs > button[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); } - -/* Offline road maps for the Android Auto overlay (#/navigation, Offline Maps tab). */ -.gx-navigation__offline { display: grid; gap: 16px; padding: 0 0 96px; } -.gx-offline-roads { padding: 24px; display: grid; gap: 14px; } -.gx-offline-roads__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; } -.gx-offline-roads__stat { display: grid; gap: 2px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, currentColor 6%, transparent); } -.gx-offline-roads__stat span { font-size: .78rem; opacity: .7; } -.gx-offline-roads__stat strong { font-size: 1.05rem; } -.gx-offline-roads__stat small { opacity: .7; font-size: .76rem; display: flex; flex-wrap: wrap; gap: 4px 12px; } -.gx-offline-roads__stat--wide { grid-column: 1 / -1; } -.gx-offline-roads__bar { display: flex; height: 8px; border-radius: 6px; overflow: hidden; background: color-mix(in srgb, currentColor 12%, transparent); margin: 6px 0 2px; } -.gx-offline-roads__bar i { display: block; height: 100%; transition: width .4s ease; } -.gx-offline-roads__legend { display: inline-flex; align-items: center; gap: 6px; } -.gx-offline-roads__legend b { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } -.gx-offline-roads__picker { display: grid; grid-template-columns: minmax(0, 2fr) minmax(240px, 1fr); gap: 14px; align-items: start; } -@media (max-width: 760px) { .gx-offline-roads__picker { grid-template-columns: 1fr; } } -.gx-offline-roads__map { position: relative; border-radius: 16px; overflow: hidden; background: #0e0e1a; height: 340px; } -.gx-offline-roads__map canvas { width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; } -.gx-offline-roads__map .gx-navigation-map__controls { position: absolute; right: 10px; top: 10px; display: grid; gap: 6px; } -.gx-offline-roads__hint { position: absolute; left: 12px; bottom: 10px; padding: 6px 10px; border-radius: 10px; background: #0e0e1acc; color: #e8e6f5; font-size: .8rem; pointer-events: none; } -.gx-offline-roads__form { display: grid; gap: 8px; } -.gx-offline-roads__areas { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } -.gx-offline-roads__areas li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, currentColor 5%, transparent); } -.gx-offline-roads__area { display: grid; gap: 2px; min-width: 0; flex: 1; } -.gx-offline-roads__area small { opacity: .72; } diff --git a/openpilot/starpilot/galaxy/web/css/navigation.css b/openpilot/starpilot/galaxy/web/css/navigation.css index 6b868747a3..3eda7a363f 100644 --- a/openpilot/starpilot/galaxy/web/css/navigation.css +++ b/openpilot/starpilot/galaxy/web/css/navigation.css @@ -1,3 +1,5 @@ +@import "./offline-road-maps.css"; + /* A shared page shell keeps destination, maps, and setup in the same layout. */ .gx-navigation {--navigation-land:#191a1a;width:100%;max-width:1440px;margin:0 auto;min-width:0;padding-bottom:24px} [data-theme="light"] .gx-navigation {--navigation-land:#f5f5f3} @@ -41,7 +43,7 @@ .gx-navigation .gx-navigation__field input:focus-visible {outline:none;box-shadow:none} .gx-navigation__field:focus-within {border-color:var(--glass-text);box-shadow:0 0 0 1px var(--glass-border)} .gx-navigation__icon {display:inline-grid;place-items:center;flex:0 0 auto;width:44px;height:44px;border:0;border-radius:50%;background:none;color:inherit;font-size:1.05rem;cursor:pointer} -.gx-navigation__icon:hover:not(:disabled),.gx-navigation__icon:focus-visible {background:var(--glass-active-bg);outline:none} +.gx-navigation__icon:hover:not(:disabled),.gx-navigation__icon:focus-visible {background:var(--glass-active-bg)} .gx-navigation__icon:disabled {opacity:.45;cursor:default} .gx-navigation__icon[aria-pressed="true"] .bi-star-fill,.gx-navigation__save--on {color:#f5b400} .gx-navigation__suggestions {display:grid;gap:2px;padding:6px;border-radius:16px;max-height:55vh;overflow:auto} @@ -49,7 +51,7 @@ .gx-navigation__favorite-choices {display:flex;gap:6px;flex:1 1 100%;margin:4px 0} .gx-navigation__favorite-choices .gx-btn {flex:1;min-width:0;padding:6px 10px;display:flex;align-items:center;justify-content:center;gap:6px} .gx-navigation__suggestion {flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 12px;border:0;border-radius:12px;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit} -.gx-navigation__suggestion:hover:not(:disabled),.gx-navigation__suggestion:focus-visible {background:var(--glass-active-bg);outline:none} +.gx-navigation__suggestion:hover:not(:disabled),.gx-navigation__suggestion:focus-visible {background:var(--glass-active-bg)} .gx-navigation__suggestion i {color:var(--primary);font-size:1.05rem;flex:0 0 auto;width:20px;text-align:center} .gx-navigation__suggestion .bi-star-fill {color:#f5b400} .gx-navigation__suggestion span {display:grid;min-width:0} diff --git a/openpilot/starpilot/galaxy/web/css/offline-road-maps.css b/openpilot/starpilot/galaxy/web/css/offline-road-maps.css new file mode 100644 index 0000000000..a79f52da4a --- /dev/null +++ b/openpilot/starpilot/galaxy/web/css/offline-road-maps.css @@ -0,0 +1,24 @@ +/* Offline road maps for the Android Auto overlay (#/navigation, Offline Maps tab). */ +.gx-navigation__offline { display: grid; gap: 16px; padding: 0 0 96px; } +.gx-offline-roads { padding: 24px; display: grid; gap: 14px; } +.gx-offline-roads__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; } +.gx-offline-roads__stat { display: grid; gap: 2px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, currentColor 6%, transparent); } +.gx-offline-roads__stat span { font-size: .78rem; opacity: .7; } +.gx-offline-roads__stat strong { font-size: 1.05rem; } +.gx-offline-roads__stat small { opacity: .7; font-size: .76rem; display: flex; flex-wrap: wrap; gap: 4px 12px; } +.gx-offline-roads__stat--wide { grid-column: 1 / -1; } +.gx-offline-roads__bar { display: flex; height: 8px; border-radius: 6px; overflow: hidden; background: color-mix(in srgb, currentColor 12%, transparent); margin: 6px 0 2px; } +.gx-offline-roads__bar i { display: block; height: 100%; transition: width .4s ease; } +.gx-offline-roads__legend { display: inline-flex; align-items: center; gap: 6px; } +.gx-offline-roads__legend b { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.gx-offline-roads__picker { display: grid; grid-template-columns: minmax(0, 2fr) minmax(240px, 1fr); gap: 14px; align-items: start; } +@media (max-width: 760px) { .gx-offline-roads__picker { grid-template-columns: 1fr; } } +.gx-offline-roads__map { position: relative; border-radius: 16px; overflow: hidden; background: #0e0e1a; height: 340px; } +.gx-offline-roads__map canvas { width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; } +.gx-offline-roads__map .gx-navigation-map__controls { position: absolute; right: 10px; top: 10px; display: grid; gap: 6px; } +.gx-offline-roads__hint { position: absolute; left: 12px; bottom: 10px; padding: 6px 10px; border-radius: 10px; background: #0e0e1acc; color: #e8e6f5; font-size: .8rem; pointer-events: none; } +.gx-offline-roads__form { display: grid; gap: 8px; } +.gx-offline-roads__areas { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.gx-offline-roads__areas li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, currentColor 5%, transparent); } +.gx-offline-roads__area { display: grid; gap: 2px; min-width: 0; flex: 1; } +.gx-offline-roads__area small { opacity: .72; } diff --git a/openpilot/starpilot/galaxy/web/css/settings.css b/openpilot/starpilot/galaxy/web/css/settings.css index b944986019..ca66f299c6 100644 --- a/openpilot/starpilot/galaxy/web/css/settings.css +++ b/openpilot/starpilot/galaxy/web/css/settings.css @@ -402,7 +402,7 @@ input[type="range"].gx-slider:active::-moz-range-thumb { .gx-layout__scenes { flex-wrap: nowrap; overflow-x: auto; padding: 2px; } .gx-layout__scenes > button { flex-shrink: 0; } .gx-layout__inspector .gx-layout__palette { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; } -@media (max-width: 900px) { +@container (max-width: 1040px) { .gx-layout { max-width: 760px; } .gx-layout__workspace { grid-template-columns: minmax(0, 1fr); gap: 12px; } .gx-layout__preview-card { position: static; } diff --git a/openpilot/starpilot/galaxy/web/js/navigation-map.js b/openpilot/starpilot/galaxy/web/js/navigation-map.js index b0c3d5686e..da7db2a54d 100644 --- a/openpilot/starpilot/galaxy/web/js/navigation-map.js +++ b/openpilot/starpilot/galaxy/web/js/navigation-map.js @@ -101,6 +101,11 @@ export class RasterMap { if (this.onTap) this.onTap(this.pointAt(...this.local(event))) } this.key = (event) => { + if (this.onTap && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault() + this.onTap({ ...this.center }) + return + } const delta = { ArrowLeft: [-80, 0], ArrowRight: [80, 0], ArrowUp: [0, -80], ArrowDown: [0, 80] } diff --git a/openpilot/starpilot/galaxy/web/js/navigation.js b/openpilot/starpilot/galaxy/web/js/navigation.js index a178841303..6b3a321897 100644 --- a/openpilot/starpilot/galaxy/web/js/navigation.js +++ b/openpilot/starpilot/galaxy/web/js/navigation.js @@ -23,7 +23,7 @@ const coordinate = (value) => !!value && Number.isFinite(value.latitude) && Math const text = (value, limit) => typeof value === "string" && value.trim().length > 0 && value.length <= limit export const validDestination = (value) => coordinate(value) && text(value.id, 256) && text(value.name, 256) && (value.address === undefined || text(value.address, 512)) && (value.label === undefined || ["home", "work"].includes(value.label)) -// Suggestions carry the search they came from; the comma looks up their coordinates when one is chosen or saved. +// Suggestions carry the search they came from; their temporary coordinates are retrieved only for routing. export const validSuggestion = (value) => !!value && text(value.id, 256) && text(value.name, 256) && (value.description === undefined || typeof value.description === "string" && value.description.length <= 512) && typeof value.searchId === "string" && /^[0-9a-f-]{36}$/.test(value.searchId) @@ -111,7 +111,7 @@ export class NavigationClient { const text = query.trim() if (this.pendingSuggestion && this.suggestionQuery === text) await this.pendingSuggestion if (!this.active || this.busy) return null - if (this.suggestionResultsQuery === text && this.suggestions?.length && this.autocompleteId) { + if (this.suggestionResultsQuery === text && this.suggestions?.length && this.autocompleteId && !this.suggestions.some(place => place.temporary)) { this.searchId = this.autocompleteId this.results = this.suggestions.slice() this.searched = true @@ -131,6 +131,7 @@ export class NavigationClient { return pending } save(place, label) { + if (place.temporary) return Promise.resolve(null) const category = label === undefined ? {} : { label } return place.searchId ? this.action("favoritePlace", { id: place.id, searchId: place.searchId, ...category }) : this.action("favorite", { destination: placeOf(place), ...category }) @@ -431,10 +432,10 @@ export const NavigationPage = {

Mapbox

One public Mapbox access token (starting with pk.) handles the map, place and address search, and routes. A separate secret token is not needed.

-

Save your token while parked. Galaxy keeps it on your comma and never displays it after saving. Favorites and recent destinations are also kept on your comma; going to one skips search, so it uses none of your search allowance. URL-restricted keys may reject map requests.

-

Typing suggestions and offline road maps stop just short of Mapbox's free monthly allowance and resume on the 1st. Searches, address lookups, routes and map views are counted here but not limited.

-
- +

Save your token while parked. Galaxy keeps it on your comma and never displays it after saving. Permanent address results can be saved. Search suggestions are temporary and used only for the current route. Favorites and recent destinations are also kept on your comma; going to one skips search, so it uses none of your search allowance. URL-restricted keys may reject map requests.

+

Typing suggestions and offline road maps stop just short of Mapbox's free monthly allowance and resume on the 1st. Permanent address lookups are billed by Mapbox. Searches, address lookups, routes and map views are counted here but not limited.

+
+
Get a Mapbox access token

A Mapbox key is saved.

@@ -458,7 +459,7 @@ export const NavigationPage = { @@ -470,7 +471,7 @@ export const NavigationPage = { - +
Recent @@ -478,7 +479,7 @@ export const NavigationPage = {
- +
@@ -487,13 +488,13 @@ export const NavigationPage = {
- +
- +
@@ -506,7 +507,7 @@ export const NavigationPage = {

{{ data.destination.name }}

{{ data.destination.address }}

@@ -538,7 +539,7 @@ export const NavigationPage = { - + diff --git a/openpilot/starpilot/galaxy/web/js/offline-road-maps.js b/openpilot/starpilot/galaxy/web/js/offline-road-maps.js index 581a5b40e0..6169559fa1 100644 --- a/openpilot/starpilot/galaxy/web/js/offline-road-maps.js +++ b/openpilot/starpilot/galaxy/web/js/offline-road-maps.js @@ -56,28 +56,40 @@ export function areaLabel(area) { export class OfflineRoadsClient { constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) { - Object.assign(this, { publish, unauthorized, fetcher }) + Object.assign(this, { publish, unauthorized, fetcher, later, cancel }) this.data = null this.busy = false this.error = "" this.active = false + this.generation = 0 this.poller = new PollTimer({ read: () => this.load(), interval: 3000, later, cancel }) } emit() { this.publish({ data: this.data, busy: this.busy, error: this.error }) } - start() { this.active = true; this.poller.start(); return this.load() } - stop() { this.active = false; this.poller.stop() } + start() { this.stop(); this.active = true; this.poller.start(); return this.load() } + stop() { this.active = false; this.generation++; this.controller?.abort(); this.busy = false; this.poller.stop() } async request(body = null) { - const response = await this.fetcher("./api/navigation/offline", { credentials: "same-origin", cache: "no-store", - ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) }) - const payload = await response.json().catch(() => null) - if (response.status === 401 || ["access_unavailable", "setup_required"].includes(payload?.code)) { - this.stop(); this.unauthorized(); return null - } - if (!response.ok) throw new Error(payload?.error || "Offline maps are unavailable. Try again shortly.") - if (!validOffline(payload)) throw new Error("Offline maps returned an unexpected answer. Reload to try again.") - return payload + this.controller?.abort() + const generation = ++this.generation, controller = this.controller = new AbortController() + const deadline = this.later(() => controller.abort(), 10000) + try { + const response = await this.fetcher("./api/navigation/offline", { credentials: "same-origin", cache: "no-store", + signal: controller.signal, + ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) }) + const payload = await response.json().catch(() => null) + if (!this.active || generation !== this.generation) return null + if (controller.signal.aborted) throw new Error("Offline maps did not respond. Try again shortly.") + if (response.status === 401 || ["access_unavailable", "setup_required"].includes(payload?.code)) { + this.stop(); this.unauthorized(); return null + } + if (!response.ok) throw new Error(payload?.error || "Offline maps are unavailable. Try again shortly.") + if (!validOffline(payload)) throw new Error("Offline maps returned an unexpected answer. Reload to try again.") + return payload + } catch (error) { + if (this.active && generation === this.generation) throw error + return null + } finally { this.cancel(deadline) } } async load() { @@ -93,12 +105,13 @@ export class OfflineRoadsClient { this.busy = true this.error = "" this.emit() + const generation = this.generation + 1 try { const data = await this.request(body) if (data) this.data = data return data } catch (error) { this.error = connectionError(error); return null } - finally { this.busy = false; this.emit() } + finally { if (generation === this.generation) { this.busy = false; if (this.active) this.emit() } } } } @@ -155,7 +168,7 @@ export const OfflineRoadMapsPanel = { return { used, cap, parts: [["Areas", bytes.saved || 0, "#9d72ff"], ["Driven", bytes.driven || 0, "#34c778"], ["Recent", bytes.cache || 0, "#4096ff"]] } }, usage() { const usage = this.data?.usage || {}; return { tiles: usage.tiles || 0, free: usage.freeTiles || 200000 } }, - // The downloader stops at 99% of the free tiles, so nothing is billed. + // This counter covers this comma, not other uses of the same key. freeTilesLeft() { return Math.max(0, Math.floor(this.usage.free * 0.99) - this.usage.tiles) }, canSave() { return this.mode === "local" && !this.busy && !!this.point && !!this.data && this.data.areas.length < this.constants.maxAreas }, }, @@ -217,7 +230,7 @@ export const OfflineRoadMapsPanel = { {{ error }} Add your Mapbox key in Setup. Road maps download with the same key. Mapbox rejected the saved key for map tiles. Check that it is a public (pk.) key without URL restrictions. - This month's free Mapbox road tiles are used up, so downloads pause until the 1st. Nothing is billed. + This comma's Mapbox road tile allowance is used up, so downloads pause until the 1st. The comma's storage is nearly full, so new map areas are paused. Delete an area or free space.
Downloader{{ serviceLabel }}
@@ -227,10 +240,10 @@ export const OfflineRoadMapsPanel = {
{{ label }} {{ formatBytes(value) }}
- +
Save roads as you driveKeeps the roads around everywhere you drive, so familiar places work offline without planning ahead. +
-
+
{{ point ? 'Adjust the radius, name it, then save.' : 'Tap the map to center a new area.' }}
@@ -242,7 +255,7 @@ export const OfflineRoadMapsPanel = {

≈ {{ estimate.tiles.toLocaleString() }} tiles · {{ formatBytes(estimate.downloadBytes) }} to download · {{ formatBytes(estimate.storedBytes) }} on the comma

-

Only {{ freeTilesLeft.toLocaleString() }} free tiles are left this month. The area downloads that many now and finishes after the 1st, without being billed.

+

Only {{ freeTilesLeft.toLocaleString() }} tiles are left in this comma's allowance this month. The area resumes after the 1st.

diff --git a/openpilot/starpilot/navigation/mapbox_budget.py b/openpilot/starpilot/navigation/mapbox_budget.py index 685ded1689..d05b804ef4 100644 --- a/openpilot/starpilot/navigation/mapbox_budget.py +++ b/openpilot/starpilot/navigation/mapbox_budget.py @@ -1,6 +1,6 @@ -"""Keep every Mapbox service inside its free monthly allowance. +"""Track Mapbox usage and cap services with a free monthly allowance. -Each service is counted on the comma and refused once it reaches 99% of the +Requests are counted on the comma; capped flows stop at 99% of the free tier for the calendar month (UTC, as Mapbox bills). The last 1% absorbs small differences between this count and Mapbox's (sessions Mapbox splits on its own, counts not yet written when a process restarts). Requests made with the @@ -20,7 +20,7 @@ from pathlib import Path # Free monthly allowances (mapbox.com/pricing, 2026-10). FREE = { "searchSessions": 500, # Search Box API sessions (typing and searching) - "geocoding": 100_000, # Temporary Geocoding API (address search) + "geocoding": 0, # Permanent address results have no free tier; counted, not capped "staticTiles": 200_000, # Static Tiles API (Galaxy's map pictures) "directions": 100_000, # Directions API (routes) "vectorTiles": 200_000, # Vector Tiles API (offline road maps) diff --git a/openpilot/starpilot/navigation/navtilesd.py b/openpilot/starpilot/navigation/navtilesd.py index 22553cbc6b..a3fd137814 100644 --- a/openpilot/starpilot/navigation/navtilesd.py +++ b/openpilot/starpilot/navigation/navtilesd.py @@ -106,6 +106,8 @@ class Fetcher: if self.usage.snapshot()["tiles"] >= int(FREE["vectorTiles"] * STOP_FRACTION): self._block(FAILURE_BUDGET, BUDGET_RECHECK_S) # free tiles for the month are used up; resumes on the 1st return None + if self.usage is not None: + self.usage.add() try: response = self.session.get(MVT_URL.format(z=key.z, x=key.x, y=key.y), params={"access_token": token}, timeout=REQUEST_TIMEOUT, stream=True) @@ -128,8 +130,6 @@ class Fetcher: return None except TileFormatError: return None - if self.usage is not None: - self.usage.add() if status in (401, 403): self._block(FAILURE_KEY, KEY_BACKOFF_S) return None @@ -161,6 +161,8 @@ class AreaProgress: self.refresh = refresh self.store = store self.recorded = False + self.completed_at = area.get("refreshed", 0) + self.retry_after = 0.0 @property def finished(self) -> bool: @@ -175,10 +177,6 @@ class AreaProgress: self.index += 1 self.done += 1 continue - if age is None and not self.refresh and self.store.promote("saved", key): - self.index += 1 - self.done += 1 - continue yield key return @@ -192,7 +190,8 @@ def covered(key: TileKey, areas: list[dict]) -> bool: for area in areas: cx, cy = world_xy(area["latitude"], area["longitude"], key.z) reach = area["radiusKm"] * 1000.0 / max(1.0, meters_per_tile(area["latitude"], key.z)) - nx, ny = min(max(cx, key.x), key.x + 1), min(max(cy, key.y), key.y + 1) + tx = key.x + round((cx - key.x) / (1 << key.z)) * (1 << key.z) + nx, ny = min(max(cx, tx), tx + 1), min(max(cy, key.y), key.y + 1) if (nx - cx) ** 2 + (ny - cy) ** 2 <= reach * reach: return True return False @@ -219,6 +218,8 @@ class TileDaemon: self.last_status = -math.inf self.last_trim = -math.inf self.no_space = False + self.storage_retry_after = 0.0 + self.saved_full_at: int | None = None self.sizes = dict.fromkeys(("saved", "driven", "cache"), 0) # ---- planning @@ -236,7 +237,7 @@ class TileDaemon: self.urgent.clear() self.urgent_kind = "driven" if settings["saveDriven"] else "cache" - route_key = (len(env.route), env.route[0] if env.route else None, env.route[-1] if env.route else None) + route_key = tuple(env.route) if route_key != self.route_key: self.route_key = route_key self.route_plan = corridor_tiles(env.route) if len(env.route) >= 2 else [] @@ -251,7 +252,10 @@ class TileDaemon: for area_id, area in areas.items(): progress = self.progress.get(area_id) stale = bool(area.get("refreshed")) and self.wall() - area["refreshed"] > AREA_REFRESH_SECONDS - if progress is None or _shape(progress.area) != _shape(area) or (progress.finished and stale and not progress.refresh): + retry = progress is not None and progress.finished and ( + progress.failed and self.clock() >= progress.retry_after or + progress.recorded and stale and self.wall() - progress.completed_at > AREA_REFRESH_SECONDS) + if progress is None or _shape(progress.area) != _shape(area) or retry: self.progress[area_id] = AreaProgress(area, self.store, refresh=stale) self.known_areas = areas @@ -260,6 +264,8 @@ class TileDaemon: def _store(self, kind: str, key: TileKey, data: bytes) -> bool: ok = self.store.write(kind, key, data) self.no_space = not ok + if not ok: + self.storage_retry_after = self.clock() + TRIM_INTERVAL_S return ok def _urgent_job(self) -> TileKey | None: @@ -284,11 +290,18 @@ class TileDaemon: """Do at most one download. Returns seconds to wait before the next step, or None when idle.""" if env.network == "offline" or not env.token or self.fetcher.blocked: return None + if self.clock() < self.storage_retry_after: + return None + if not self.store.has_space(): + self.no_space = True + return None + self.no_space = False key = self._urgent_job() if key is not None: data = self.fetcher.fetch(key, env.token) if data is not None: - self._store(self.urgent_kind, key, data) + if not self._store(self.urgent_kind, key, data): + self.urgent.appendleft(key) elif self.fetcher.blocked: self.urgent.appendleft(key) return 0.0 @@ -296,20 +309,37 @@ class TileDaemon: if key is not None: data = self.fetcher.fetch(key, env.token) if data is not None: - self._store("cache", key, data) + if not self._store("cache", key, data): + self.route_index -= 1 elif self.fetcher.blocked: self.route_index -= 1 return 0.0 if env.network != "wifi": return None + if self.saved_full_at == self.sizes["saved"]: + self.no_space = True + return None for progress in self.progress.values(): key = None if progress.finished else next(progress.pending(), None) if key is None: if progress.finished and not progress.recorded and progress.failed == 0: progress.recorded = True - self.state.mark_refreshed(progress.area, self.wall()) + progress.completed_at = self.wall() + self.state.mark_refreshed(progress.area, progress.completed_at) continue - if self.sizes["saved"] >= SAVED_MAX_BYTES: + cached = self.store.find(key) if not progress.refresh else None + if cached is not None: + size = cached.stat().st_size + if self.sizes["saved"] + size > SAVED_MAX_BYTES: + self.no_space = True + self.saved_full_at = self.sizes["saved"] + return None + if self.store.promote("saved", key): + self.sizes["saved"] += size + progress.done += 1 + progress.index += 1 + return 0.0 + if self.sizes["saved"] >= SAVED_MAX_BYTES and not self.store.has("saved", key): self.no_space = True return None wait = AREA_INTERVAL_S - (self.clock() - self.last_area_fetch) @@ -317,13 +347,20 @@ class TileDaemon: return wait self.last_area_fetch = self.clock() data = self.fetcher.fetch(key, env.token) + saved = self.store.path("saved", key) + old_size = saved.stat().st_size if saved.is_file() else 0 + if data is not None and self.sizes["saved"] + len(data) - old_size > SAVED_MAX_BYTES: + self.no_space = True + self.saved_full_at = self.sizes["saved"] + return None if data is not None and self._store("saved", key, data): - self.sizes["saved"] += len(data) + self.sizes["saved"] += len(data) - old_size progress.done += 1 progress.index += 1 elif not self.fetcher.blocked and not self.no_space: progress.failed += 1 progress.index += 1 + progress.retry_after = self.clock() + SERVER_BACKOFF_S return 0.0 return None @@ -416,7 +453,8 @@ def _environment(sm, owner, map_enabled: bool, now_ns: int) -> Environment: try: from openpilot.starpilot.navigation.wire import navigation_state nav = navigation_state(sm["starpilotNavigation"]) if sm.valid["starpilotNavigation"] else None - if nav is not None and nav.status == "guiding": + if (nav is not None and nav.enabled and nav.status == "guiding" and + 0 < nav.frameMonoTime <= now_ns <= nav.frameMonoTime + 3_000_000_000): env.route = [(point.latitude, point.longitude) for point in nav.route] except Exception: env.route = [] @@ -435,19 +473,14 @@ def main() -> None: from openpilot.starpilot.gps.source import GPS_SOURCES sm = messaging.SubMaster(["deviceState", *GPS_SOURCES, "starpilotNavigation"]) map_enabled, layout_checked = False, -math.inf - route: list[tuple[float, float]] = [] while True: sm.update(0) now = time.monotonic() if now - layout_checked > LAYOUT_CHECK_S: layout_checked, map_enabled = now, _map_enabled() env = _environment(sm, owner, map_enabled, time.monotonic_ns()) - if env.route: - route = env.route - elif not env.onroad: - route = [] - env.route = route daemon.plan(env) + daemon.maintain() idle = False deadline = time.monotonic() + 0.9 while time.monotonic() < deadline: @@ -457,7 +490,6 @@ def main() -> None: break if wait > 0: time.sleep(min(wait, max(0.0, deadline - time.monotonic()))) - daemon.maintain() daemon.status(env) if idle: time.sleep(1.0) diff --git a/openpilot/starpilot/navigation/offline_roads.py b/openpilot/starpilot/navigation/offline_roads.py index eb94e00550..bfbaaf4835 100644 --- a/openpilot/starpilot/navigation/offline_roads.py +++ b/openpilot/starpilot/navigation/offline_roads.py @@ -5,14 +5,15 @@ Three tile directories, read in this order: driven/ tiles near the car while "save as you drive" is on; trimmed at their own cap cache/ everything else the map has needed; trimmed least recently used first -State files beside them, each with one writer: - areas/.json offline areas (Galaxy writes; navtilesd only reads) +State files beside them: + areas/.json Galaxy writes definitions; navtilesd updates refresh times under the shared lock settings.json save-as-you-drive (Galaxy writes) status.json download progress, sizes, network and usage (navtilesd writes) """ from __future__ import annotations +import fcntl import json import math import os @@ -21,6 +22,7 @@ import shutil import tempfile import time import uuid +from contextlib import contextmanager from pathlib import Path from typing import Any @@ -38,6 +40,7 @@ MAX_AREAS = 24 AREA_REFRESH_SECONDS = 120 * 24 * 3600 STATUS_STALE_SECONDS = 20.0 FREE_TILES_PER_MONTH = 200_000 # Mapbox Vector Tiles API free tier +MAX_AREA_TILES = int(FREE_TILES_PER_MONTH * 0.99) # bound planning memory as well as a month's downloads _ID = re.compile(r"[0-9a-f]{32}\Z") @@ -119,7 +122,7 @@ class TileStore: def write(self, kind: str, key: TileKey, data: bytes) -> bool: path = self.path(kind, key) try: - if shutil.disk_usage(_existing_ancestor(path)).free < self.min_free_bytes + len(data): + if not self.has_space(len(data)): return False path.parent.mkdir(parents=True, exist_ok=True) fd, temp = tempfile.mkstemp(dir=path.parent, prefix=".tile-") @@ -132,6 +135,9 @@ class TileStore: self.path("cache", key).unlink(missing_ok=True) return True + def has_space(self, size: int = 0) -> bool: + return shutil.disk_usage(_existing_ancestor(self.root)).free >= self.min_free_bytes + size + def promote(self, kind: str, key: TileKey) -> bool: """Move a tile already on disk into a longer-lived directory without downloading it.""" target = self.path(kind, key) @@ -221,6 +227,8 @@ def validate_area(value: Any) -> dict: not -180 <= longitude <= 180 or not math.isfinite(radius) or not AREA_MIN_RADIUS_KM <= radius <= AREA_MAX_RADIUS_KM or not isinstance(name, str) or len(name) > 80): raise ValueError("Choose a place and a radius between 2 and 200 km") + if estimate(latitude, longitude, radius)["tiles"] > MAX_AREA_TILES: + raise ValueError("Choose a smaller offline area") area = {"latitude": round(latitude, 6), "longitude": round(longitude, 6), "radiusKm": round(radius, 1), "name": " ".join(name.split()) or f"{latitude:.3f}, {longitude:.3f}"} for key in ("id", "created", "refreshed"): @@ -244,6 +252,13 @@ class OfflineState: def __init__(self, root: Path | None = None): self.root = Path(root) if root is not None else roads_root() + @contextmanager + def _exclusive(self): + self.root.mkdir(parents=True, exist_ok=True) + with (self.root / ".areas.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + yield + @property def areas_dir(self) -> Path: return self.root / "areas" @@ -267,21 +282,27 @@ class OfflineState: def add_area(self, value: Any) -> dict: area = validate_area(value) - if len(self.areas()) >= MAX_AREAS: - raise ValueError(f"Up to {MAX_AREAS} offline areas can be saved") - area.update(id=uuid.uuid4().hex, created=_wall(), refreshed=0) - write_json(self.areas_dir / f"{area['id']}.json", area) + with self._exclusive(): + areas = self.areas() + if len(areas) >= MAX_AREAS: + raise ValueError(f"Up to {MAX_AREAS} offline areas can be saved") + if sum(estimate(row["latitude"], row["longitude"], row["radiusKm"])["tiles"] for row in [*areas, area]) > MAX_AREA_TILES: + raise ValueError("Choose a smaller area or delete an existing area") + area.update(id=uuid.uuid4().hex, created=_wall(), refreshed=0) + write_json(self.areas_dir / f"{area['id']}.json", area) return area def delete_area(self, area_id: str) -> None: if not isinstance(area_id, str) or not _ID.fullmatch(area_id): raise ValueError("Unknown offline area") - (self.areas_dir / f"{area_id}.json").unlink(missing_ok=True) + with self._exclusive(): + (self.areas_dir / f"{area_id}.json").unlink(missing_ok=True) def mark_refreshed(self, area: dict, when: float | None = None) -> None: path = self.areas_dir / f"{area['id']}.json" - if path.is_file(): - write_json(path, {**{k: v for k, v in area.items() if k != "id"}, "refreshed": _wall() if when is None else when}) + with self._exclusive(): + if path.is_file(): + write_json(path, {**{k: v for k, v in area.items() if k != "id"}, "refreshed": _wall() if when is None else when}) def settings(self) -> dict: value = read_json(self.root / "settings.json", 4096) diff --git a/openpilot/starpilot/navigation/owner.py b/openpilot/starpilot/navigation/owner.py index 186d5a95aa..003a1e88e6 100644 --- a/openpilot/starpilot/navigation/owner.py +++ b/openpilot/starpilot/navigation/owner.py @@ -8,10 +8,12 @@ import hashlib import json import math import os +import stat import sys import subprocess from urllib.parse import quote from pathlib import Path +from itertools import islice import tempfile import threading import time @@ -181,6 +183,71 @@ class NavigationOwner: raise ValidationError('Navigation temporary storage is unavailable') return fd + def _sweep_active(self, current): + directory = self._temporary_directory() + try: + with os.scandir(directory) as entries: + for entry in islice(entries, MAX_SEARCHES + 1): + if ((entry.name.endswith('.json') and entry.name != current['revision'] + '.json') or + entry.name.startswith('.active-')): + os.unlink(entry.name, dir_fd=directory) + finally: + os.close(directory) + + def _read_active(self, current): + directory = self._temporary_directory() + try: + try: + fd = os.open(current['revision'] + '.json', os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory) + except FileNotFoundError: + return None + with os.fdopen(fd, 'rb') as source: + info = os.fstat(source.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077: + raise ValidationError('Navigation temporary storage is unavailable') + raw = source.read(16385) + value = json.loads(raw) + if (len(raw) > 16384 or set(value) != {'version', 'revision', 'expires', 'destination'} or type(value['version']) is not int or value['version'] != 1 or + not isinstance(value['revision'], str) or type(value['expires']) not in (float, int) or + not math.isfinite(value['expires'])): + raise ValueError + selected = destination(value['destination']) + if value['revision'] != current['revision'] or not time.monotonic() < value['expires'] <= time.monotonic() + ACTIVE_TTL: + os.unlink(current['revision'] + '.json', dir_fd=directory) + return None + return dict(value, destination=selected) + except (ValueError, TypeError, KeyError): + raise ValidationError('Navigation temporary destination could not be read') from None + finally: + os.close(directory) + + def _write_active(self, value): + directory = self._temporary_directory() + name = '.active-' + uuid.uuid4().hex + try: + fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=directory) + with os.fdopen(fd, 'w') as out: + json.dump(value, out, allow_nan=False, separators=(',', ':')) + out.flush() + os.fsync(out.fileno()) + os.replace(name, value['revision'] + '.json', src_dir_fd=directory, dst_dir_fd=directory) + os.fsync(directory) + finally: + try: + os.unlink(name, dir_fd=directory) + except FileNotFoundError: + pass + os.close(directory) + + def read_routing(self): + with self._exclusive(): + current = self.read() + self._sweep_active(current) + active = self._read_active(current) + if active is not None: + current['destination'] = dict(active['destination'], temporary=True) + return current + @contextmanager def _exclusive(self): with self._lock: @@ -189,15 +256,22 @@ class NavigationOwner: fcntl.flock(lock, fcntl.LOCK_EX) yield - def _change(self, transform, expected_revision: str, authorized) -> dict: + def _change(self, transform, expected_revision: str, authorized, *, active_destination=None, preserve_active=False) -> dict: with self._exclusive(): current = self.read() if not isinstance(expected_revision, str) or current['revision'] != expected_revision: raise ConflictError('Navigation changed; refresh and try again') + self._sweep_active(current) + active = self._read_active(current) if preserve_active else None transform(current) if not (authorized() if callable(authorized) else authorized is True): raise PermissionError('Navigation changes are not available right now') current['revision'] = uuid.uuid4().hex + if active_destination is not None: + active = {'version':1, 'destination':active_destination, 'expires':time.monotonic() + ACTIVE_TTL} + if active is not None: + self._write_active(dict(active, revision=current['revision'])) + committed = False fd, temporary = tempfile.mkstemp(dir=self.root, prefix='.settings-') try: with os.fdopen(fd, 'w') as out: @@ -207,6 +281,15 @@ class NavigationOwner: if not (authorized() if callable(authorized) else authorized is True): raise PermissionError('Navigation changes are not available right now') os.replace(temporary, self.path) + committed = True + temporary_directory = self._temporary_directory() + try: + with os.scandir(temporary_directory) as entries: + for stale in islice(entries, MAX_SEARCHES + 1): + if stale.name.endswith('.json') and stale.name != current['revision'] + '.json': + os.unlink(stale.name, dir_fd=temporary_directory) + finally: + os.close(temporary_directory) directory = os.open(self.root, os.O_RDONLY) try: os.fsync(directory) @@ -214,6 +297,14 @@ class NavigationOwner: os.close(directory) finally: Path(temporary).unlink(missing_ok=True) + if not committed and active is not None: + directory = self._temporary_directory() + try: + os.unlink(current['revision'] + '.json', dir_fd=directory) + except FileNotFoundError: + pass + finally: + os.close(directory) return self.snapshot() def snapshot(self, route_key: str | None = None) -> dict: @@ -228,7 +319,7 @@ class NavigationOwner: return self._params[1].get_bool('IsMetric') def _snapshot(self, route_key: str | None = None) -> dict: - document = self.read() + document = self.read_routing() status = ('disabled' if not document['enabled'] else 'needsKey' if not document['token'] else 'noDestination' if document['destination'] is None else 'waitingForLocation') result = {key: document[key] for key in ('enabled', 'destination', 'recents', 'revision')} @@ -297,7 +388,7 @@ class NavigationOwner: rows = self.route_options(expected_revision) if type(index) is not int or not 0 <= index < len(rows): raise ValidationError('This route is no longer available; refresh and try again') - result = self._change(lambda doc: doc.update(routeChoice=index), expected_revision, authorized) + result = self._change(lambda doc: doc.update(routeChoice=index), expected_revision, authorized, preserve_active=True) # Retain choices across the preference revision without requesting another route. with self._exclusive(): value = {'revision': result['revision'], 'expires': time.monotonic() + ACTIVE_TTL, 'routes': rows} @@ -396,10 +487,10 @@ class NavigationOwner: token = self.read()['token'] if not token: raise ValidationError('Add your Mapbox access token first') - # Temporary geocoding is inside Mapbox's free monthly allowance; results are kept only as this comma's own places. + # Address results are saved in favorites and recents, so request permanent geocoding. self.geocode_budget.spend(enforce=False) data = response_json(self.session, 'https://api.mapbox.com/search/geocode/v6/forward', - {'q': query.strip(), 'access_token': token, 'limit': 8, 'autocomplete': 'false', **self._search_context()}) + {'q': query.strip(), 'access_token': token, 'limit': 8, 'autocomplete': 'false', 'permanent': 'true', **self._search_context()}) results = [] features = data.get('features') if not isinstance(features, list): @@ -514,7 +605,7 @@ class NavigationOwner: isinstance(name, str) and 1 <= len(name.strip()) <= 256): description = item.get('full_address') or item.get('place_formatted') or '' description = description.strip()[:512] if isinstance(description, str) else '' - results.append({'id':identity, 'name':name.strip(), 'description':description, 'searchId':search_id}) + results.append({'id':identity, 'name':name.strip(), 'description':description, 'searchId':search_id, 'temporary':True}) with self._lock: if (self._searches.get(key) is not entry or entry['expires'] <= time.monotonic() or self.read()['token'] != current['token']): @@ -536,12 +627,12 @@ class NavigationOwner: self.cancel_search(caller, search_id) return self.search(query) - def _retrieve(self, identity, search_id, caller, expected_revision, authorized, *, keep: bool) -> dict: - """Coordinates for a search suggestion. Saving (keep) leaves the other results usable.""" + def _retrieve(self, identity, search_id, caller, expected_revision, authorized) -> dict: + """Temporary coordinates for the selected route.""" if not isinstance(identity, str) or not isinstance(search_id, str): raise ValidationError('Choose a place from search results') with self._lock: - entry = self._searches.get((caller, search_id)) if keep else self._searches.pop((caller, search_id), None) + entry = self._searches.pop((caller, search_id), None) current = self.read() if (entry is None or entry['expires'] <= time.monotonic() or identity not in entry['ids'] or current['revision'] != expected_revision or entry['revision'] != expected_revision or @@ -549,15 +640,8 @@ class NavigationOwner: raise ValidationError('Search again before choosing this place') if not (authorized() if callable(authorized) else authorized is True): raise PermissionError('Navigation changes are not available right now') - with self._lock: - session = entry['session'] - # A retrieve ends the Mapbox session; a later retrieve from the same results is billed as its own session. - if entry.get('retrieved'): - self.search_budget.spend(enforce=False) - entry['retrieved'] = True - entry['session'] = str(uuid.uuid4()) data = response_json(self.session, 'https://api.mapbox.com/search/searchbox/v1/retrieve/' + quote(identity, safe=''), - {'access_token':current['token'], 'session_token':session}) + {'access_token':current['token'], 'session_token':entry['session']}) try: feature = data['features'][0] properties = feature['properties'] @@ -570,22 +654,11 @@ class NavigationOwner: raise ValidationError('The map service returned an invalid place') from None def select_place(self, identity, search_id, caller, expected_revision, authorized): - selected = self._retrieve(identity, search_id, caller, expected_revision, authorized, keep=False) - return self._change(lambda doc: self._navigate(doc, selected), expected_revision, authorized) + selected = self._retrieve(identity, search_id, caller, expected_revision, authorized) + return self._change(lambda doc: doc.update(destination=None, routeChoice=0), expected_revision, authorized, active_destination=selected) def favorite_place(self, identity, search_id, caller, expected_revision, authorized, label=None): - self._validate_favorite_label(label) - selected = self._retrieve(identity, search_id, caller, expected_revision, authorized, keep=True) - def update(doc): - self._add_favorite(doc, selected) - if label is not None: - self._label_favorite(doc, selected['id'], label) - result = self._change(update, expected_revision, authorized) - with self._lock: - entry = self._searches.get((caller, search_id)) - if entry is not None and entry['revision'] == expected_revision: - entry['revision'] = result['revision'] # the other results stay choosable after saving one - return result + raise ValidationError('Search suggestions can be used for a route but cannot be saved; search for an address instead') @staticmethod def _navigate(doc, selected): @@ -600,9 +673,19 @@ class NavigationOwner: raise ValidationError('Remove a saved place before adding another') doc['favorites'] = doc['favorites'] + [selected] + def _reject_temporary_promotion(self, doc, selected): + active = self._read_active(doc) + if active is not None and active['destination']['id'] == selected['id']: + raise ValidationError('This place can be used for a route but cannot be saved') + def select(self, value: dict, expected_revision: str, authorized) -> dict: + if isinstance(value, dict) and value.get('temporary'): + raise ValidationError('This place can be used for a route but cannot be saved') selected = destination(value) - return self._change(lambda doc: self._navigate(doc, selected), expected_revision, authorized) + def update(doc): + self._reject_temporary_promotion(doc, selected) + self._navigate(doc, selected) + return self._change(update, expected_revision, authorized) def clear(self, expected_revision: str, authorized) -> dict: with self._lock: @@ -611,21 +694,24 @@ class NavigationOwner: def favorite(self, value: dict, expected_revision: str, authorized, label=None) -> dict: self._validate_favorite_label(label) + if isinstance(value, dict) and value.get('temporary'): + raise ValidationError('This place can be used for a route but cannot be saved') selected = destination(value) def update(doc): + self._reject_temporary_promotion(doc, selected) self._add_favorite(doc, selected) if label is not None: self._label_favorite(doc, selected['id'], label) - return self._change(update, expected_revision, authorized) + return self._change(update, expected_revision, authorized, preserve_active=True) def remove_favorite(self, identity: str, expected_revision: str, authorized) -> dict: return self._change(lambda doc: doc.update(favorites=[row for row in doc['favorites'] if row['id'] != identity]), - expected_revision, authorized) + expected_revision, authorized, preserve_active=True) def label_favorite(self, identity: str, label, expected_revision: str, authorized) -> dict: """Mark a saved place as Home or Work (one each), or clear its label with None.""" self._validate_favorite_label(label) - return self._change(lambda doc: self._label_favorite(doc, identity, label), expected_revision, authorized) + return self._change(lambda doc: self._label_favorite(doc, identity, label), expected_revision, authorized, preserve_active=True) @staticmethod def _validate_favorite_label(label): @@ -644,10 +730,10 @@ class NavigationOwner: def remove_recent(self, identity: str, expected_revision: str, authorized) -> dict: return self._change(lambda doc: doc.update(recents=[row for row in doc['recents'] if row['id'] != identity]), - expected_revision, authorized) + expected_revision, authorized, preserve_active=True) def clear_recents(self, expected_revision: str, authorized) -> dict: - return self._change(lambda doc: doc.update(recents=[]), expected_revision, authorized) + return self._change(lambda doc: doc.update(recents=[]), expected_revision, authorized, preserve_active=True) def close(self): with self._lock: diff --git a/openpilot/starpilot/navigation/runtime.py b/openpilot/starpilot/navigation/runtime.py index c8660e69e4..8b822feb30 100644 --- a/openpilot/starpilot/navigation/runtime.py +++ b/openpilot/starpilot/navigation/runtime.py @@ -48,7 +48,7 @@ class RouteRuntime: self.owner.position_store.record({'longitude': coordinates[0], 'latitude': coordinates[1], 'bearing': bearing}) if position is None: self.owner.position_store.flush() - settings = self.owner.read() + settings = self.owner.read_routing() selected = settings['destination'] destination_key = None if selected is None else (selected['id'], selected['longitude'], selected['latitude']) key = settings['enabled'], hashlib.sha256(settings['token'].encode()).digest(), destination_key, drive_id diff --git a/openpilot/starpilot/navigation/tests/test_navtilesd.py b/openpilot/starpilot/navigation/tests/test_navtilesd.py index 88b26fb754..b578e4d101 100644 --- a/openpilot/starpilot/navigation/tests/test_navtilesd.py +++ b/openpilot/starpilot/navigation/tests/test_navtilesd.py @@ -60,7 +60,7 @@ def run(d, env, limit=10_000): wait = d.step(env) if wait is None: return - d.clock.now += max(wait, 0.0) if hasattr(d.clock, "now") else 0 + d.clock.now += max(wait, 1e-6) if hasattr(d.clock, "now") else 0 raise AssertionError("daemon never went idle") @@ -160,6 +160,124 @@ def test_route_corridor_is_prefetched_after_near_tiles(tmp_path): assert all(d.store.find(key) is not None for key in corridor_tiles(route)) +def test_reroute_and_clear_replace_download_plan(tmp_path): + d = daemon(tmp_path) + route = [(36.1, -115.1), (36.15, -115.1), (36.2, -115.2)] + d.plan(Environment(route=route)) + old = list(d.route_plan) + route[1] = (36.15, -115.3) + d.plan(Environment(route=route)) + assert d.route_plan != old + d.plan(Environment()) + assert d.route_plan == [] + + +def test_storage_failure_pauses_until_retry(tmp_path, monkeypatch): + d = daemon(tmp_path) + d.state.add_area({"latitude": 36.1, "longitude": -115.1, "radiusKm": 2}) + env = Environment(token="pk.test", network="wifi") + d.plan(env) + with monkeypatch.context() as patch: + patch.setattr(d.store, "write", lambda *args: False) + d.step(env) + for _ in range(3): + d.clock.now += 1 + assert d.step(env) is None + assert len(d.fetcher.session.urls) == 1 + d.clock.now += navtilesd.TRIM_INTERVAL_S + assert d.step(env) == 0 + assert len(d.fetcher.session.urls) == 2 + + +def test_incomplete_area_retries_missing_tiles(tmp_path): + d = daemon(tmp_path, Session(body=b"invalid tile")) + d.state.add_area({"latitude": 36.1, "longitude": -115.1, "radiusKm": 2}) + env = Environment(token="pk.test", network="wifi") + run(d, env) + progress = next(iter(d.progress.values())) + assert progress.finished and progress.failed + d.fetcher.session.body = TILE + d.clock.now += navtilesd.SERVER_BACKOFF_S + 1 + run(d, env) + progress = next(iter(d.progress.values())) + assert progress.finished and progress.done == progress.total and not progress.failed + + +def test_repeated_refresh_does_not_double_count_saved_bytes(tmp_path, monkeypatch): + d = daemon(tmp_path) + area = d.state.add_area({"latitude": 36.1, "longitude": -115.1, "radiusKm": 2}) + d.state.mark_refreshed(area, d.wall() - navtilesd.AREA_REFRESH_SECONDS - 1) + env = Environment(token="pk.test", network="wifi") + run(d, env) + saved = d.sizes["saved"] + fetched = len(d.fetcher.session.urls) + now = d.wall() + navtilesd.AREA_REFRESH_SECONDS + 1 + monkeypatch.setattr(d, "wall", lambda: now) + monkeypatch.setattr(d.store, "age", lambda *args: navtilesd.AREA_REFRESH_SECONDS + 1) + run(d, env) + assert len(d.fetcher.session.urls) == fetched * 2 + assert d.sizes["saved"] == saved == d.store.size("saved") + + +def test_saved_cap_includes_promoted_tiles(tmp_path, monkeypatch): + d = daemon(tmp_path) + area = d.state.add_area({"latitude": 36.1, "longitude": -115.1, "radiusKm": 2}) + key = area_tiles(area)[0] + d.store.write("cache", key, b"cached") + monkeypatch.setattr(navtilesd, "SAVED_MAX_BYTES", 5) + env = Environment(token="pk.test", network="wifi") + d.plan(env) + for _ in range(3): + assert d.step(env) is None + assert d.no_space and not d.store.has("saved", key) + assert not d.fetcher.session.urls + + +def test_saved_coverage_wraps_at_date_line(): + area = {"latitude": 0., "longitude": 179.99, "radiusKm": 25} + assert all(navtilesd.covered(key, [area]) for key in area_tiles(area)) + + +def test_area_planning_is_bounded(tmp_path): + with pytest.raises(ValueError, match="smaller"): + validate_area({"latitude": 84, "longitude": 0, "radiusKm": 200}) + state = OfflineState(tmp_path) + for _ in range(2): + state.add_area({"latitude": 60, "longitude": 0, "radiusKm": 200}) + with pytest.raises(ValueError, match="smaller"): + state.add_area({"latitude": 60, "longitude": 0, "radiusKm": 200}) + + +def test_refresh_cannot_resurrect_concurrently_deleted_area(tmp_path, monkeypatch): + import threading + from openpilot.starpilot.navigation import offline_roads + state = OfflineState(tmp_path) + area = state.add_area({"latitude": 36.1, "longitude": -115.1, "radiusKm": 2}) + entered, release, deleted = threading.Event(), threading.Event(), threading.Event() + write = offline_roads.write_json + def blocked_write(*args): + entered.set() + assert release.wait(2) + write(*args) + monkeypatch.setattr(offline_roads, "write_json", blocked_write) + refresh = threading.Thread(target=state.mark_refreshed, args=(area, 1)) + def remove(): + state.delete_area(area["id"]) + deleted.set() + deletion = threading.Thread(target=remove) + refresh.start() + try: + assert entered.wait(1) + deletion.start() + assert not deleted.wait(.05), "delete must serialize with the refresh write" + finally: + release.set() + refresh.join(2) + if deletion.ident is not None: + deletion.join(2) + assert deleted.is_set() and state.areas() == [] + + def test_area_validation_and_estimate(tmp_path): with pytest.raises(ValueError): validate_area({"latitude": 36, "longitude": -115, "radiusKm": 500}) diff --git a/openpilot/starpilot/navigation/tests/test_search_places.py b/openpilot/starpilot/navigation/tests/test_search_places.py index f0b2cf11e3..5871bd7c7b 100644 --- a/openpilot/starpilot/navigation/tests/test_search_places.py +++ b/openpilot/starpilot/navigation/tests/test_search_places.py @@ -34,17 +34,17 @@ def choose(owner, result, caller='caller', authorized=True): return value, provider -def test_selected_place_is_kept_with_its_address_and_becomes_recent(owner): +def test_selected_place_routes_without_persisting_temporary_coordinates(owner): result, session, _ = suggest(owner) - assert 'latitude' not in result and 'temporary' not in result - assert result['description'] == '123 Main St, Springfield' + assert 'latitude' not in result and result['temporary'] is True status, provider = choose(owner, result) assert provider.call_args.args[2]['session_token'] == session assert status['destination']['name'] == 'Coffee Shop' assert status['destination']['address'] == '123 Main St, Springfield' - assert owner.read()['destination'] == status['destination'] - assert [row['name'] for row in status['recents']] == ['Coffee Shop', 'Old address'] - assert not list(owner.transient_root.glob('*.json')) + assert status['destination']['temporary'] is True + assert owner.read()['destination'] is None + assert [row['name'] for row in status['recents']] == ['Old address'] + assert 'Coffee Shop' not in owner.path.read_text() second = NavigationOwner(owner.root, runtime_source=lambda:None, transient_root=owner.transient_root) executor = Executor() runtime = RouteRuntime(second, engine=type('Engine',(),{'fetch':None})(), executor=executor) @@ -52,13 +52,18 @@ def test_selected_place_is_kept_with_its_address_and_becomes_recent(owner): assert len(executor.jobs) == 1 with pytest.raises(ValidationError): choose(owner, result) - saved = owner.favorite(status['destination'], status['revision'], True) - assert saved['favorites'] == [status['destination']] + with pytest.raises(ValidationError, match='cannot be saved'): + owner.favorite(status['destination'], status['revision'], True) + unmarked = {key:value for key,value in status['destination'].items() if key != 'temporary'} + with pytest.raises(ValidationError, match='cannot be saved'): + owner.select(unmarked, status['revision'], True) + with patch('openpilot.starpilot.navigation.owner.time.monotonic', return_value=time.monotonic() + 50000): + assert owner.read_routing()['destination'] is None + assert not list(owner.transient_root.glob('*.json')) def test_recents_move_to_top_dedupe_and_are_editable(owner): - result, _, _ = suggest(owner) - choose(owner, result) + owner.select({'name': 'Coffee Shop', 'latitude': 40., 'longitude': -90.}, owner.read()['revision'], True) cleared = owner.clear(owner.read()['revision'], True) assert cleared['destination'] is None and len(cleared['recents']) == 2 again = owner.select(ADDRESS, cleared['revision'], True) @@ -74,8 +79,7 @@ def test_recents_move_to_top_dedupe_and_are_editable(owner): def test_favorite_edits_keep_route_and_labels_are_unique(owner): - result, _, _ = suggest(owner) - status, _ = choose(owner,result) + status = owner.select({'name': 'Coffee Shop', 'latitude': 40., 'longitude': -90.}, owner.read()['revision'], True) saved = owner.favorite(ADDRESS,status['revision'],True) saved = owner.favorite(status['destination'],saved['revision'],True) assert saved['destination']['name'] == 'Coffee Shop' @@ -97,29 +101,40 @@ def test_favorite_edits_keep_route_and_labels_are_unique(owner): assert removed['destination']['name'] == 'Coffee Shop' and [row['name'] for row in removed['favorites']] == ['Coffee Shop'] -def test_saving_a_suggestion_keeps_the_other_results_choosable(owner): - result, session, _ = suggest(owner) - revision = owner.read()['revision'] - with patch('openpilot.starpilot.navigation.owner.response_json', return_value=FEATURE) as provider: - saved = owner.favorite_place(result['id'], result['searchId'], 'caller', revision, True) - assert provider.call_args.args[2]['session_token'] == session - assert saved['favorites'][0]['name'] == 'Coffee Shop' and saved['destination']['name'] == 'Old address' - used = owner.search_budget.used - status, provider = choose(owner, result) - assert provider.call_args.args[2]['session_token'] != session, "a retrieve ends a Mapbox session" - assert owner.search_budget.used == used + 1, "so choosing afterwards counts as its own session" - assert status['destination']['name'] == 'Coffee Shop' +def test_suggestions_cannot_be_saved_but_remain_choosable(owner): + result, _, _ = suggest(owner) + before = owner.read() + with patch('openpilot.starpilot.navigation.owner.response_json') as provider: + with pytest.raises(ValidationError, match='cannot be saved'): + owner.favorite_place(result['id'], result['searchId'], 'caller', before['revision'], True) + provider.assert_not_called() + assert owner.read() == before + assert choose(owner, result)[0]['destination']['temporary'] is True + + +def test_address_search_requests_permanent_results(owner): + with patch('openpilot.starpilot.navigation.owner.response_json', return_value={'features': []}) as provider: + assert owner.search('100 Main Street') == [] + assert provider.call_args.args[2]['permanent'] == 'true' + + +def test_temporary_destination_supports_full_unicode_address(owner): + result, _, _ = suggest(owner) + feature = {'features': [{'properties': {**POI, 'name': '🏠' * 256, 'full_address': '🏠' * 512}, + 'geometry': {'coordinates': [-90., 40.]}}]} + with patch('openpilot.starpilot.navigation.owner.response_json', return_value=feature): + status = owner.select_place(result['id'], result['searchId'], 'caller', owner.read()['revision'], True) + assert status['destination']['address'] == '🏠' * 512 + assert owner.read()['destination'] is None def test_save_home_and_work_in_one_change_without_changing_destination(owner): saved = owner.favorite(ADDRESS, owner.read()['revision'], True, label='home') - old_revision = saved['revision'] - result, _, _ = suggest(owner) - with patch('openpilot.starpilot.navigation.owner.response_json', return_value=FEATURE): - saved = owner.favorite_place(result['id'], result['searchId'], 'caller', old_revision, True, label='home') + before = owner.read() + coffee = {'name': 'Coffee Shop', 'latitude': 40., 'longitude': -90.} + saved = owner.favorite(coffee, before['revision'], True, label='home') assert [(row['name'], row.get('label')) for row in saved['favorites']] == [('Coffee Shop', 'home'), ('Old address', None)] assert saved['destination']['name'] == 'Old address' - assert owner._searches[('caller', result['searchId'])]['revision'] == saved['revision'] before = owner.read() with pytest.raises(ValidationError): owner.favorite(ADDRESS, before['revision'], True, label='invalid') @@ -193,6 +208,7 @@ def test_address_fallback_and_symlink_safe_ephemeral_state(owner,tmp_path): patch.object(owner,'search',return_value=[ADDRESS]) as fallback: assert owner.search_places('100 Main','caller',str(uuid.uuid4()),str(uuid.uuid4())) == [ADDRESS] fallback.assert_called_once_with('100 Main') + owner.transient_root.rmdir() owner.transient_root.symlink_to(tmp_path) with pytest.raises(OSError): owner.record_routes(owner.read()['revision'], []) diff --git a/openpilot/starpilot/system/android_auto/current_car_ui.py b/openpilot/starpilot/system/android_auto/current_car_ui.py index 0ddc7927d2..a0653cbac9 100644 --- a/openpilot/starpilot/system/android_auto/current_car_ui.py +++ b/openpilot/starpilot/system/android_auto/current_car_ui.py @@ -109,20 +109,20 @@ def projected_touches(events, geometry): return mapped -def scale_scissors(rl, scale: float) -> None: +def scale_scissors(rl, scale: float, left: float = 0, top: float = 0) -> None: """Scale scissor rectangles like the matrix that draws the UI into the visible-size target. The UI clips in logical coordinates, but raylib applies scissors in target pixels, unaffected by rl_scalef. This is gui_app._patch_scissor_mode for a renderer that never opens a window; it runs in its own process, so it touches only the car view. """ - if scale == 1.0: + if scale == 1.0 and left == top == 0: return if not hasattr(rl, "_orig_begin_scissor_mode"): rl._orig_begin_scissor_mode = rl.begin_scissor_mode def begin_scissor_mode_scaled(x, y, width, height): - return rl._orig_begin_scissor_mode(int(x * scale), int(y * scale), + return rl._orig_begin_scissor_mode(int(left + x * scale), int(top + y * scale), int(math.ceil(width * scale)), int(math.ceil(height * scale))) rl.begin_scissor_mode = begin_scissor_mode_scaled @@ -200,7 +200,9 @@ def run(frames_path: str, touch_path: str | None = None) -> int: if not content.id: raise RuntimeError('Projection content target unavailable') resources.callback(rl.unload_render_texture, content) - scale_scissors(rl, geometry.scale) + left = (geometry.width - geometry.logical_width * geometry.scale) / 2 + top = (geometry.height - geometry.logical_height * geometry.scale) / 2 + scale_scissors(rl, geometry.scale, left, top) converter = None if request.flags & FLAG_NV12: try: @@ -326,6 +328,7 @@ def run(frames_path: str, touch_path: str | None = None) -> int: rl.clear_background(rl.BLACK) rl.rl_push_matrix() try: + rl.rl_translatef(left, top, 0) rl.rl_scalef(geometry.scale, geometry.scale, 1.0) layout.render() finally: diff --git a/openpilot/starpilot/system/android_auto/projection_favorites.py b/openpilot/starpilot/system/android_auto/projection_favorites.py index 9d22f88eba..03e39b9953 100644 --- a/openpilot/starpilot/system/android_auto/projection_favorites.py +++ b/openpilot/starpilot/system/android_auto/projection_favorites.py @@ -12,6 +12,7 @@ class ProjectionFavorites: self.owner = owner if owner is not None else NavigationOwner() self.document = None self.error = '' + self.card_bounds = None self._read_at = float('-inf') self._press = None @@ -51,6 +52,17 @@ class ProjectionFavorites: if order is None: order = [*widget_order(state.customization, 'large'), *FAVORITE_WIDGETS] for key in reversed(order): + placed = state.customization['layouts']['large'].get(key) + if placed is not None and placed['enabled']: + px, py = placed['x'], placed['y'] + if key == 'nav_map': + width, height = placed['width'], placed['height'] + elif key == 'nav_card' and self.card_bounds is not None and (rect := self.card_bounds(state)) is not None: + px, py, width, height = rect.x, rect.y, rect.width, rect.height + else: + width = height = 0 + if px <= x < px + width and py <= y < py + height: + return None if key in FAVORITE_WIDGETS and (placed := self.bounds(key, state)) is not None: width, height = (FAVORITE_ICON_SIZE, FAVORITE_ICON_SIZE) if placed.get('display') == 'icons' else FAVORITE_SIZE if placed['x'] <= x < placed['x'] + width and placed['y'] <= y < placed['y'] + height: diff --git a/openpilot/starpilot/system/android_auto/projection_onroad.py b/openpilot/starpilot/system/android_auto/projection_onroad.py index e4454dc3f5..2a0258d0e9 100644 --- a/openpilot/starpilot/system/android_auto/projection_onroad.py +++ b/openpilot/starpilot/system/android_auto/projection_onroad.py @@ -126,6 +126,7 @@ class ProjectionOnroad: self.favorites = None if getattr(native, 'favorites', None) is not None: self.favorites = native.favorites(lambda: native.ui_state.started) + self.favorites.card_bounds = getattr(getattr(self.onroad, 'navigation', None), 'bounds', None) self._resources.callback(self.favorites.close) except BaseException: self._resources.close() diff --git a/openpilot/starpilot/system/android_auto/tests/test_current_display_source.py b/openpilot/starpilot/system/android_auto/tests/test_current_display_source.py index ab438c36ce..93048b952a 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_current_display_source.py +++ b/openpilot/starpilot/system/android_auto/tests/test_current_display_source.py @@ -63,6 +63,14 @@ class TestCurrentDisplaySource(unittest.TestCase): rl.begin_scissor_mode(30, 30, 1800, 1020) self.assertEqual(calls, [(30, 30, 1800, 1020), (12, 12, 775, 439)]) + def test_scissors_include_letterbox_offset(self): + calls = [] + rl = type("Rl", (), {})() + rl.begin_scissor_mode = lambda *args: calls.append(args) + scale_scissors(rl, .5, 20, 40) + rl.begin_scissor_mode(10, 30, 100, 50) + self.assertEqual(calls, [(25, 55, 50, 25)]) + def test_view_uses_current_renderer(self): source = (SOURCE / "view.py").read_text() self.assertIn('"openpilot.starpilot.system.android_auto.current_car_ui"', source) diff --git a/openpilot/starpilot/system/android_auto/tests/test_projection_favorites.py b/openpilot/starpilot/system/android_auto/tests/test_projection_favorites.py index 51a132d75d..4bd3c8511c 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_projection_favorites.py +++ b/openpilot/starpilot/system/android_auto/tests/test_projection_favorites.py @@ -56,6 +56,21 @@ def tap(control, state, key): control.touch('up', x, y, state, 1) +def test_map_above_home_blocks_touch(favorites): + view = state(ordered=True) + placed = view.customization['layouts']['large']['nav_home'] + view.customization['layouts']['large']['nav_map'].update(enabled=True, x=placed['x'], y=placed['y']) + order = view.customization['widgetOrder']['large'] + order.remove('nav_map') + order.append('nav_map') + assert favorites.hit(*point(view, 'nav_home'), view) is None + tap(favorites, view, 'nav_home') + assert favorites.owner.read()['destination'] is None + order.remove('nav_home') + order.append('nav_home') + assert favorites.hit(*point(view, 'nav_home'), view) == 'nav_home' + + @pytest.mark.parametrize('ordered', [True, False]) @pytest.mark.parametrize('display', ['words', 'icons']) def test_home_work_start_replace_and_end_navigation(favorites, ordered, display): diff --git a/openpilot/starpilot/ui/onroad.py b/openpilot/starpilot/ui/onroad.py index fdc22fbf24..5c6389066c 100644 --- a/openpilot/starpilot/ui/onroad.py +++ b/openpilot/starpilot/ui/onroad.py @@ -287,6 +287,7 @@ class OnroadView: if not driver_camera: if stock_layer is not None: submit("model_confidence", lambda: stock_layer(rl.Rectangle(0, 0, 536, 240), state)) + submit("following_distance", lambda: self.compact_sidebar.render_personality(rl.Rectangle(0, 0, 536, 240), state)) else: for key in RAIL_WIDGETS: submit(key, lambda key=key: self.compact_sidebar.render(rl.Rectangle(0, 0, 536, 240), state, widget=key)) diff --git a/openpilot/starpilot/ui/onroad_map.py b/openpilot/starpilot/ui/onroad_map.py index 8ddff89556..aac7f6d808 100644 --- a/openpilot/starpilot/ui/onroad_map.py +++ b/openpilot/starpilot/ui/onroad_map.py @@ -310,7 +310,7 @@ class TileReader: self._missing.pop(key, None) self._tiles[key] = tile self._tiles.move_to_end(key) - while len(self._tiles) > TILE_CACHE: + while len(self._tiles) > max(TILE_CACHE, len(self._wanted)): self._tiles.popitem(last=False) self.generation += 1 elif job is not None: @@ -411,7 +411,7 @@ class MapOverlay: dt = 0.0 if self._last_prepare is None else min(0.2, max(0.0, now - self._last_prepare)) self._last_prepare = now if fix is None: - self.status = "waiting" if self._world is None else self.status + self.status = "waiting" return target_world = world_xy(fix.latitude, fix.longitude) age = min(DEAD_RECKON_S, max(0.0, now - fix.monotonic)) @@ -591,7 +591,8 @@ class MapOverlay: rl.draw_triangle_strip(rl.ffi.from_buffer("Vector2 *", strip), len(strip), color) finally: rl.rl_pop_matrix() - self._puck(anchor.x, anchor.y) + if self.status == "live": + self._puck(anchor.x, anchor.y) self._compass(width, height) else: rl.draw_texture_pro(self._canvas.texture, rl.Rectangle(0, 0, side, -side), @@ -685,7 +686,8 @@ class MapFeed: from openpilot.starpilot.gps.source import bearing, select_location self.sm.update(0) now = time.monotonic() - selected = select_location(self.sm, time.monotonic_ns()) + now_ns = time.monotonic_ns() + selected = select_location(self.sm, now_ns) if selected is not None: stamp, gps = selected speed = float(getattr(gps, "speed", float("nan"))) @@ -699,7 +701,8 @@ class MapFeed: try: from openpilot.starpilot.navigation.wire import navigation_state nav = navigation_state(navigation_sm["starpilotNavigation"]) if navigation_sm.valid["starpilotNavigation"] else None - if nav is not None and nav.status in ("guiding", "arrived"): + if (nav is not None and nav.enabled and nav.status in ("guiding", "arrived") and + 0 < nav.frameMonoTime <= now_ns <= nav.frameMonoTime + 3_000_000_000): route = tuple((point.latitude, point.longitude) for point in nav.route) except Exception: route = () diff --git a/openpilot/starpilot/ui/tests/test_onroad_map.py b/openpilot/starpilot/ui/tests/test_onroad_map.py index 79b1ca5277..a486fdb43c 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_map.py +++ b/openpilot/starpilot/ui/tests/test_onroad_map.py @@ -3,6 +3,7 @@ import math import os import time +import threading import numpy as np @@ -136,6 +137,34 @@ def test_tile_reader_loads_from_disk_and_retries_missing(tmp_path): reader.close() +def test_tile_reader_finishes_canvas_larger_than_cache(tmp_path): + from openpilot.starpilot.ui.onroad_map import TILE_CACHE + store = TileStore(tmp_path, min_free_bytes=0) + keys = [TileKey(DATA_ZOOM, x, 10) for x in range(TILE_CACHE + 1)] + for key in keys: + store.write("saved", key, encode_road_tile({})) + reader = TileReader(tmp_path) + finished = threading.Event() + try: + reader.want(keys) + reader.submit(finished.set) + assert finished.wait(2), "requested tiles must not evict each other and starve the canvas" + assert len(reader.snapshot(keys)) == len(keys) + finally: + reader.close() + + +def test_lost_gps_marks_previous_position_stale(tmp_path): + overlay = MapOverlay(tmp_path) + try: + overlay._advance(MapInput(MapFix(*CENTER, 0., 10., 100.)), 100.) + assert overlay.status == "live" + overlay._advance(MapInput(None), 106.) + assert overlay.status == "waiting" + finally: + overlay.close() + + # ---------------------------------------------------------------- GL def render_overlay(rl, tmp_path, bearing, width=560, height=420, opacity=1.0, route=True, steps=40): @@ -266,6 +295,15 @@ def test_map_feed_uses_navigation_gps_choice_with_car_fallback(monkeypatch): sm.put("gpsLocationExternal", now - 100_000_000, latitude=36.3, bearingAccuracyDeg=200.0) fix = feed.read().fix assert fix.latitude == 36.3 and fix.bearing is None, "a receiver wins; an unusable heading is dropped" + nav = sm.ns(version=2, enabled=True, status="guiding", frameMonoTime=now, + route=[sm.ns(latitude=36.1, longitude=-115.1)]) + published = {"starpilotNavigation": sm.ns(navigation=nav)} + class NavigationMaster(dict): + valid = {"starpilotNavigation": True} + navigation = NavigationMaster(published) + assert feed.read(navigation).route == ((36.1, -115.1),) + nav.frameMonoTime = now - 3_000_000_001 + assert feed.read(navigation).route == (), "a stopped route publisher must not leave stale guidance" def test_route_stays_bright_at_minimum_map_opacity(gl, tmp_path): diff --git a/openpilot/starpilot/ui/tests/test_widget_layers.py b/openpilot/starpilot/ui/tests/test_widget_layers.py index d3b217b56f..1b244376a9 100644 --- a/openpilot/starpilot/ui/tests/test_widget_layers.py +++ b/openpilot/starpilot/ui/tests/test_widget_layers.py @@ -5,7 +5,7 @@ import json import pytest from openpilot.starpilot.ui.onroad_customization import ( - DEFAULT_WIDGET_ORDER, MODE_WIDGET, PROFILES, customization_metadata, decode_document, default_document, validate_document, + CLOCK_WIDGET, DEFAULT_WIDGET_ORDER, MODE_WIDGET, PROFILES, customization_metadata, decode_document, default_document, validate_document, ) from openpilot.starpilot.system.android_auto.projection_layout import ( default_layout_for_viewport, decode_layout_for_viewport, layout_metadata_for_viewport, projection_customization, validate_layout_for_viewport, @@ -79,7 +79,7 @@ def test_renderer_dispatches_each_widget_in_saved_order(profile, viewport): source = Path(__file__).parents[1] / 'onroad.py' tree = ast.parse(source.read_text()) method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == '_ordered_widgets') - namespace = {'MODE_WIDGET': MODE_WIDGET, 'placement': placement, 'widget_order': widget_order, 'RAIL_WIDGETS': RAIL_WIDGETS, + namespace = {'CLOCK_WIDGET': CLOCK_WIDGET, 'MODE_WIDGET': MODE_WIDGET, 'placement': placement, 'widget_order': widget_order, 'RAIL_WIDGETS': RAIL_WIDGETS, 'AlertSize': NS(NONE='none', FULL='full'), 'CameraViewChoice': NS(NONE='none'), 'rl': NS(Rectangle=lambda x, y, width, height: NS(x=x, y=y, width=width, height=height))} exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), 'exec'), namespace) @@ -89,6 +89,7 @@ def test_renderer_dispatches_each_widget_in_saved_order(profile, viewport): for key in ('pip_left', 'pip_right')]) view.driver_monitor_layer = lambda *args: seen.append('driver_monitor') view._driving_mode = lambda *args: seen.append(MODE_WIDGET) + view._clock = lambda *args: seen.append(CLOCK_WIDGET) view._slc_actions = lambda *args: seen.append('speed_limit_actions') for attr, key in [('unified_speed', 'cruise_limits'), ('current_speed', 'current_speed'), ('steering_wheel', 'steering_wheel'), ('torque_bar', 'torque_bar')]: @@ -119,3 +120,11 @@ def test_renderer_dispatches_each_widget_in_saved_order(profile, viewport): assert 'steering_wheel' not in seen assert 'speed_limit_actions' not in seen assert 'torque_bar' not in seen + if profile == 'compact': + seen.clear() + state.alert.size = 'none' + view.compact_sidebar.render_personality = lambda *args: seen.append('following_distance') + stock = Mock() + namespace['_ordered_widgets'](view, NS(x=0, y=0, width=476, height=240), state, stock_layer=stock) + stock.assert_called_once() + assert seen.count('following_distance') == 1