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.
This commit is contained in:
Prabhaav Pillai
2026-10-08 03:10:10 -04:00
parent d5f7852c0d
commit d7e4c021b6
26 changed files with 608 additions and 192 deletions
@@ -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]) {
@@ -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})
@@ -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 })
+1 -26
View File
@@ -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; }
@@ -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}
@@ -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; }
@@ -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; }
@@ -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]
}
+14 -13
View File
@@ -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 = {
<section class="gx-card gx-navigation__section">
<h3>Mapbox</h3>
<p>One public Mapbox access token (starting with pk.) handles the map, place and address search, and routes. A separate secret token is not needed.</p>
<p>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.</p>
<p class="gx-note">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.</p>
<dl v-if="usageRows.length" class="gx-navigation__usage" aria-label="Free Mapbox use this month">
<template v-for="row in usageRows" :key="row.key"><dt>{{ row.label }}</dt><dd>{{ row.used.toLocaleString() }} of {{ row.limit.toLocaleString() }} free this month</dd></template>
<p>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.</p>
<p class="gx-note">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.</p>
<dl v-if="usageRows.length" class="gx-navigation__usage" aria-label="Mapbox use this month">
<template v-for="row in usageRows" :key="row.key"><dt>{{ row.label }}</dt><dd>{{ row.used.toLocaleString() }}<template v-if="row.key !== 'geocoding'"> of {{ row.limit.toLocaleString() }} free</template> this month</dd></template>
</dl>
<a class="gx-navigation__token-link" href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noopener noreferrer">Get a Mapbox access token</a>
<p v-if="data?.hasKey" class="gx-note">A Mapbox key is saved.</p>
@@ -458,7 +459,7 @@ export const NavigationPage = {
<i class="bi bi-search" aria-hidden="true"></i>
<label for="navigation-search" class="gx-sr-only">Search destinations</label>
<input id="navigation-search" ref="search" v-model="query" placeholder="Address or place name" minlength="2" maxlength="200" required
autocomplete="off" enterkeyhint="search" :disabled="!available" role="combobox" :aria-expanded="showQuick || showSuggestions" aria-controls="navigation-suggestions"
autocomplete="off" enterkeyhint="search" :disabled="!available" role="searchbox"
@input="typed" @focus="suggestOpen = true" @keydown.escape="closeSuggestions">
<button v-if="query || results.length" type="button" class="gx-navigation__icon" aria-label="Clear search" @mousedown.prevent @click="clearQuery"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</div>
@@ -470,7 +471,7 @@ export const NavigationPage = {
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong>{{ placeName(place) }}</strong><small v-if="placeDetail(place)">{{ placeDetail(place) }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Remove ' + place.name + ' from saved places'" :disabled="!available" @click="client.action('removeFavorite',{id:place.id})"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name + ' as Home, Work, or Other'" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available" @click="openFavorite(place)"><i class="bi bi-star-fill" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name + ' as Home, Work, or Other'" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi bi-star-fill" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="available" @choose="saveAs(place, $event)" />
</div>
<div v-if="recents.length" class="gx-navigation__group"><span>Recent</span>
@@ -478,7 +479,7 @@ export const NavigationPage = {
<div v-for="place in recents" :key="'recent-' + place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
<i class="bi bi-clock-history" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.address">{{ place.address }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available" @click="openFavorite(place)"><i class="bi bi-star" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi bi-star" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Remove ' + place.name + ' from recent places'" :disabled="!available" @click="client.action('removeRecent',{id:place.id})"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="available" @choose="saveAs(place, $event)" />
</div>
@@ -487,13 +488,13 @@ export const NavigationPage = {
<div v-for="place in localMatches" :key="'local-' + place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong>{{ placeName(place) }}</strong><small>{{ placeDetail(place) || (isFavorite(place) ? 'Saved place' : 'Recent') }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="available" @choose="saveAs(place, $event)" />
</div>
<div v-for="place in suggestions" :key="place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)">
<i class="bi bi-geo-alt-fill" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.description">{{ place.description }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-pressed="isFavorite(place)" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-pressed="isFavorite(place)" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="available" @choose="saveAs(place, $event)" />
</div>
</template>
@@ -506,7 +507,7 @@ export const NavigationPage = {
<div class="gx-navigation__heading">
<div><h3 class="gx-navigation__summary-title">{{ data.destination.name }}</h3><p v-if="data.destination.address" class="gx-note">{{ data.destination.address }}</p></div>
<button type="button" class="gx-navigation__icon gx-navigation__save" :class="{'gx-navigation__save--on':destinationSaved}" :aria-pressed="!!destinationSaved"
:aria-label="destinationSaved ? 'Change saved place category' : 'Save this place'" :aria-expanded="favoritePicker === favoriteKey(data.destination)" :disabled="!available" @click="openFavorite(data.destination)">
:aria-label="destinationSaved ? 'Change saved place category' : 'Save this place'" :aria-expanded="favoritePicker === favoriteKey(data.destination)" :disabled="!available || data.destination.temporary" :title="data.destination.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(data.destination)">
<i class="bi" :class="destinationSaved ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
</div>
<FavoriteChoices v-if="favoritePicker === favoriteKey(data.destination)" :available="available" @choose="saveAs(data.destination, $event)" />
@@ -538,7 +539,7 @@ export const NavigationPage = {
<button type="button" class="gx-navigation__suggestion" :disabled="!available" @click="pick(place)" :aria-label="'Start navigation to ' + place.name">
<i class="bi bi-arrow-up-right" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.address || place.description">{{ place.address || place.description }}</small><small class="gx-navigation__start">Start navigation</small></span>
</button>
<button type="button" class="gx-navigation__icon" :aria-label="(isFavorite(place) ? 'Saved: ' : 'Save ') + place.name" :aria-pressed="isFavorite(place)" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="(isFavorite(place) ? 'Saved: ' : 'Save ') + place.name" :aria-pressed="isFavorite(place)" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!available || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="available" @choose="saveAs(place, $event)" />
</li>
</ul>
@@ -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 = {
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<GxNotice tone="warn" v-if="!hasKey">Add your Mapbox key in Setup. Road maps download with the same key.</GxNotice>
<GxNotice tone="warn" v-else-if="service.failure === 'key'">Mapbox rejected the saved key for map tiles. Check that it is a public (pk.) key without URL restrictions.</GxNotice>
<GxNotice tone="warn" v-if="service.failure === 'budget'">This month's free Mapbox road tiles are used up, so downloads pause until the 1st. Nothing is billed.</GxNotice>
<GxNotice tone="warn" v-if="service.failure === 'budget'">This comma's Mapbox road tile allowance is used up, so downloads pause until the 1st.</GxNotice>
<GxNotice tone="warn" v-if="service.noSpace">The comma's storage is nearly full, so new map areas are paused. Delete an area or free space.</GxNotice>
<div class="gx-offline-roads__stats">
<div class="gx-offline-roads__stat"><span>Downloader</span><strong>{{ serviceLabel }}</strong></div>
@@ -227,10 +240,10 @@ export const OfflineRoadMapsPanel = {
<i v-for="[label, value, color] in storage.parts" :key="label" :style="{ width: (storage.cap ? Math.max(value ? 1.5 : 0, value / storage.cap * 100) : 0) + '%', background: color }"></i></div>
<small><span v-for="[label, value, color] in storage.parts" :key="label" class="gx-offline-roads__legend"><b :style="{ background: color }"></b>{{ label }} {{ formatBytes(value) }}</span></small></div>
</div>
<label class="gx-toggle-row"><input type="checkbox" :checked="data?.settings.saveDriven" :disabled="!data || busy" @change="setSaveDriven($event.target.checked)">
<span>Save roads as you drive<small class="gx-note">Keeps the roads around everywhere you drive, so familiar places work offline without planning ahead.</small></span></label>
<div class="gx-row"><span>Save roads as you drive<small class="gx-note">Keeps the roads around everywhere you drive, so familiar places work offline without planning ahead.</small></span>
<label class="gx-switch"><input type="checkbox" role="switch" aria-label="Save roads as you drive" :checked="data?.settings.saveDriven" :disabled="!data || busy" @change="setSaveDriven($event.target.checked)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<div class="gx-offline-roads__picker">
<div v-if="hasKey" class="gx-offline-roads__map"><canvas ref="canvas" tabindex="0" aria-label="Map. Tap to center a new offline area; drag to pan."></canvas>
<div v-if="hasKey" class="gx-offline-roads__map"><canvas ref="canvas" tabindex="0" aria-label="Map. Tap or press Enter to center a new offline area; drag or use arrow keys to pan."></canvas>
<div class="gx-navigation-map__controls"><button class="gx-btn" type="button" @click="zoom(1)" aria-label="Zoom in">+</button>
<button class="gx-btn" type="button" @click="zoom(-1)" aria-label="Zoom out">−</button></div>
<span class="gx-offline-roads__hint">{{ point ? 'Adjust the radius, name it, then save.' : 'Tap the map to center a new area.' }}</span></div>
@@ -242,7 +255,7 @@ export const OfflineRoadMapsPanel = {
<label for="offline-area-radius">Radius · {{ radiusLabel(radiusKm, metric) }}</label>
<input id="offline-area-radius" class="gx-slider" type="range" :min="range.min" :max="range.max" step="1" v-model.number="radius" @change="focus(point, radiusKm)">
<p class="gx-note" role="status">≈ {{ estimate.tiles.toLocaleString() }} tiles · {{ formatBytes(estimate.downloadBytes) }} to download · {{ formatBytes(estimate.storedBytes) }} on the comma</p>
<p v-if="estimate.tiles > freeTilesLeft" class="gx-note">Only {{ freeTilesLeft.toLocaleString() }} free tiles are left this month. The area downloads that many now and finishes after the 1st, without being billed.</p>
<p v-if="estimate.tiles > freeTilesLeft" class="gx-note">Only {{ freeTilesLeft.toLocaleString() }} tiles are left in this comma's allowance this month. The area resumes after the 1st.</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="point = null">Cancel</button>
<button type="button" class="gx-btn" :disabled="!canSave" @click="save">Save area</button></div>
</template>
@@ -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)
+54 -22
View File
@@ -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)
+31 -10
View File
@@ -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/<id>.json offline areas (Galaxy writes; navtilesd only reads)
State files beside them:
areas/<id>.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)
+123 -37
View File
@@ -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:
+1 -1
View File
@@ -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
@@ -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})
@@ -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'], [])
@@ -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:
@@ -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:
@@ -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()
@@ -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)
@@ -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):
+1
View File
@@ -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))
+8 -5
View File
@@ -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 = ()
@@ -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):
@@ -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