mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
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:
@@ -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 })
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user