mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-09 22:13:43 +08:00
7a38780739
Preserve usable phone GPS fixes and enforce measurement freshness
774 lines
38 KiB
Python
774 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import OrderedDict
|
|
from contextlib import contextmanager
|
|
import copy
|
|
import fcntl
|
|
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
|
|
import uuid
|
|
|
|
import requests
|
|
|
|
from openpilot.starpilot.storage import starpilot_storage_root
|
|
from openpilot.starpilot.navigation.position import LastPositionStore
|
|
from openpilot.starpilot.gps.source import GPS_MODES
|
|
|
|
MAX_DOCUMENT = 256 * 1024
|
|
MAX_RESPONSE = 8 * 1024 * 1024
|
|
SEARCH_TTL = 180
|
|
ACTIVE_TTL = 12 * 60 * 60
|
|
MAX_SEARCHES = 32
|
|
TILE_CACHE_TTL = 15 * 60
|
|
TILE_CACHE_ENTRIES = 256
|
|
TILE_CACHE_BYTES = 32 * 1024 * 1024
|
|
TILE_SLOTS = 6
|
|
TILE_SLOT_WAIT_S = 3.0 # a panned-away request still holds its slot until Mapbox answers; wait instead of failing
|
|
AUTOCOMPLETE_TYPES = {'poi', 'address', 'street', 'place', 'neighborhood', 'locality'}
|
|
SUGGESTS_PER_SESSION = 50 # Mapbox starts a new billable session after this many suggestions
|
|
MAX_FAVORITES = 100
|
|
MAX_RECENTS = 10
|
|
FAVORITE_LABELS = ('home', 'work')
|
|
ROUTE_AVOIDANCES = {'avoidTolls': 'toll', 'avoidHighways': 'motorway', 'avoidFerries': 'ferry'}
|
|
# Fixed provider styles matching Galaxy; cache entries are separate for each theme.
|
|
MAP_STYLES = {'light': 'mapbox/light-v11', 'dark': 'mapbox/dark-v11'}
|
|
|
|
|
|
class ValidationError(ValueError):
|
|
pass
|
|
|
|
|
|
class ConflictError(ValueError):
|
|
pass
|
|
|
|
|
|
def destination(value: dict) -> dict:
|
|
if not isinstance(value, dict):
|
|
raise ValidationError('Choose a destination')
|
|
name = value.get('name', '')
|
|
latitude, longitude = value.get('latitude'), value.get('longitude')
|
|
if (not isinstance(name, str) or not 1 <= len(name.strip()) <= 256 or
|
|
(not isinstance(latitude, (int, float)) or isinstance(latitude, bool) or not math.isfinite(latitude) or
|
|
not isinstance(longitude, (int, float)) or isinstance(longitude, bool) or not math.isfinite(longitude)) or
|
|
not -90 <= latitude <= 90 or not -180 <= longitude <= 180):
|
|
raise ValidationError('Destination name and coordinates are invalid')
|
|
identity = hashlib.sha256(f'{latitude:.6f},{longitude:.6f}'.encode()).hexdigest()[:20]
|
|
result = {'id': identity, 'name': name.strip(), 'latitude': float(latitude), 'longitude': float(longitude)}
|
|
address = value.get('address')
|
|
if isinstance(address, str) and address.strip() and address.strip() != result['name']:
|
|
result['address'] = address.strip()[:512]
|
|
return result
|
|
|
|
|
|
def favorite_place(value: dict) -> dict:
|
|
result = destination(value)
|
|
if value.get('label') in FAVORITE_LABELS:
|
|
result['label'] = value['label']
|
|
return result
|
|
|
|
|
|
def remember(recents: list[dict], selected: dict) -> list[dict]:
|
|
"""Most recent first; choosing a place again moves it to the top."""
|
|
return ([{key: selected[key] for key in ('id', 'name', 'latitude', 'longitude', 'address') if key in selected}] +
|
|
[row for row in recents if row['id'] != selected['id']])[:MAX_RECENTS]
|
|
|
|
|
|
def rounded(points):
|
|
# About 10 cm: plenty for drawing, and a third of the bytes of full float precision.
|
|
return [dict(point, latitude=round(point['latitude'], 6), longitude=round(point['longitude'], 6)) for point in points]
|
|
|
|
|
|
def response_json(session, url: str, params: dict) -> dict:
|
|
started = time.monotonic()
|
|
try:
|
|
with session.get(url, params=params, timeout=(3, 2), stream=True) as response:
|
|
if response.status_code != 200:
|
|
raise ValidationError('The map service could not complete this request')
|
|
raw = bytearray()
|
|
for chunk in response.iter_content(64 * 1024):
|
|
raw.extend(chunk)
|
|
if time.monotonic() - started > 10:
|
|
raise ValidationError('The map service took too long to respond')
|
|
if len(raw) > MAX_RESPONSE:
|
|
raise ValidationError('The map service response is too large')
|
|
result = json.loads(raw)
|
|
if not isinstance(result, dict):
|
|
raise ValueError
|
|
return result
|
|
except (requests.RequestException, ValueError) as exc:
|
|
if isinstance(exc, ValidationError):
|
|
raise
|
|
raise ValidationError('The map service could not complete this request') from None
|
|
|
|
|
|
class NavigationOwner:
|
|
def __init__(self, root: Path | None = None, runtime_source=None, session=requests, transient_root: Path | None = None):
|
|
self.root = Path(root) if root is not None else starpilot_storage_root() / 'navigation'
|
|
self.path = self.root / 'settings.json'
|
|
self.position_store = LastPositionStore(self.root)
|
|
self.runtime_source, self.session = runtime_source, session
|
|
self._tile_slots = threading.BoundedSemaphore(TILE_SLOTS)
|
|
from openpilot.starpilot.navigation.mapbox_budget import MonthlyBudget
|
|
self.search_budget = MonthlyBudget('searchSessions', self.root / 'mapbox-usage')
|
|
self.tile_budget = MonthlyBudget('staticTiles', self.root / 'mapbox-usage', flush_every=25)
|
|
self.geocode_budget = MonthlyBudget('geocoding', self.root / 'mapbox-usage')
|
|
self._tile_lock = threading.Lock()
|
|
self._tiles = OrderedDict()
|
|
self._tile_key = None
|
|
self._tile_bytes = 0
|
|
self._lock = threading.RLock()
|
|
self._searches = {}
|
|
self._read_cache = None
|
|
self._params = None
|
|
identity = hashlib.sha256(str(self.root.resolve()).encode()).hexdigest()[:24]
|
|
if transient_root is not None:
|
|
self.transient_root = Path(transient_root)
|
|
elif sys.platform == "linux":
|
|
self.transient_root = Path("/dev/shm") / ("starpilot-navigation-" + identity)
|
|
else:
|
|
boot = subprocess.check_output(["/usr/sbin/sysctl", "-n", "kern.boottime"]).strip()
|
|
boot_id = hashlib.sha256(boot).hexdigest()[:16]
|
|
self.transient_root = Path("/tmp") / ("starpilot-navigation-" + boot_id + "-" + identity)
|
|
|
|
def read(self) -> dict:
|
|
# Galaxy polls and every map tile read settings; reparse only after a write replaced the file.
|
|
try:
|
|
info = os.stat(self.path)
|
|
except FileNotFoundError:
|
|
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': [], 'recents': [], 'gpsSource': 'backup',
|
|
**dict.fromkeys(ROUTE_AVOIDANCES, False)}
|
|
key = (info.st_ino, info.st_mtime_ns, info.st_size)
|
|
cached = getattr(self, '_read_cache', None)
|
|
if cached is not None and cached[0] == key:
|
|
return copy.deepcopy(cached[1])
|
|
try:
|
|
with self.path.open('rb') as source:
|
|
raw = source.read(MAX_DOCUMENT + 1)
|
|
except FileNotFoundError:
|
|
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': [], 'recents': [], 'gpsSource': 'backup',
|
|
**dict.fromkeys(ROUTE_AVOIDANCES, False)}
|
|
try:
|
|
value = json.loads(raw)
|
|
if (len(raw) > MAX_DOCUMENT or not isinstance(value, dict) or value.get('version') != 1 or
|
|
type(value.get('enabled')) is not bool or not isinstance(value.get('token'), str) or
|
|
not isinstance(value.get('revision'), str) or not 1 <= len(value['revision']) <= 64 or
|
|
any(ch not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-' for ch in value['revision']) or
|
|
len(value['token']) > 2048 or 'destination' not in value or
|
|
type(value.get('routeChoice', 0)) is not int or not 0 <= value.get('routeChoice', 0) <= 2 or
|
|
not isinstance(value.get('favorites'), list) or len(value['favorites']) > MAX_FAVORITES or
|
|
not isinstance(value.get('recents', []), list)):
|
|
raise ValueError
|
|
if value.get('destination') is not None:
|
|
value['destination'] = destination(value['destination'])
|
|
value['favorites'] = [favorite_place(item) for item in value['favorites']]
|
|
value['recents'] = [destination(item) for item in value.get('recents', [])][:MAX_RECENTS]
|
|
for setting in ROUTE_AVOIDANCES:
|
|
if type(value.setdefault(setting, False)) is not bool:
|
|
raise ValueError
|
|
if value.setdefault('gpsSource', 'backup') not in GPS_MODES:
|
|
raise ValueError
|
|
except (ValueError, TypeError, KeyError):
|
|
raise ValidationError('Saved navigation settings could not be read') from None
|
|
self._read_cache = (key, copy.deepcopy(value))
|
|
return value
|
|
|
|
def _temporary_directory(self):
|
|
self.transient_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
fd = os.open(self.transient_root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
info = os.fstat(fd)
|
|
if info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
|
os.close(fd)
|
|
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:
|
|
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
with (self.root / '.lock').open('a') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
yield
|
|
|
|
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:
|
|
json.dump(current, out, allow_nan=False, separators=(',', ':'))
|
|
out.flush()
|
|
os.fsync(out.fileno())
|
|
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)
|
|
finally:
|
|
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:
|
|
with self._lock:
|
|
return self._snapshot(route_key)
|
|
|
|
def _is_metric(self) -> bool:
|
|
# One Params handle for the 1 Hz status polls instead of a new one per request.
|
|
from openpilot.common.params import Params
|
|
if self._params is None or self._params[0] is not Params:
|
|
self._params = (Params, Params())
|
|
return self._params[1].get_bool('IsMetric')
|
|
|
|
def _snapshot(self, route_key: str | None = None) -> dict:
|
|
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')}
|
|
result.update({key: document[key] for key in ROUTE_AVOIDANCES})
|
|
result['gpsSource'] = document['gpsSource']
|
|
from openpilot.starpilot.gps.phone import phone_gps_status
|
|
result['phoneGps'] = phone_gps_status()
|
|
# Home and Work lead, then places in the order they were saved.
|
|
result['favorites'] = sorted(document['favorites'], key=lambda row: FAVORITE_LABELS.index(row['label']) if 'label' in row else 2)
|
|
from openpilot.starpilot.navigation.mapbox_budget import read_usage
|
|
result['mapboxUsage'] = read_usage(self.root / 'mapbox-usage')
|
|
result['alternatives'] = [dict(row, geometry=rounded(row['geometry'])) for row in self.route_options(document['revision'])]
|
|
result['selectedRoute'] = document.get('routeChoice', 0)
|
|
result.update(hasKey=bool(document['token']), status=status, instruction=None, route=[], isMetric=self._is_metric(), location=None,
|
|
network='unknown')
|
|
if document['enabled'] and document['token']:
|
|
if self.runtime_source is None:
|
|
from openpilot.starpilot.navigation.status import NavigationStatusSource
|
|
self.runtime_source = NavigationStatusSource()
|
|
if hasattr(self.runtime_source, 'network_status'):
|
|
try:
|
|
network = self.runtime_source.network_status()
|
|
result['network'] = network if network in ('online', 'offline', 'unknown') else 'unknown'
|
|
except (OSError, ValueError, RuntimeError):
|
|
pass
|
|
if hasattr(self.runtime_source, 'map_position'):
|
|
try:
|
|
result['location'] = self.runtime_source.map_position()
|
|
except (OSError, ValueError, RuntimeError):
|
|
result['location'] = None
|
|
if result['location'] is not None and result['location'].get('validForMs', 0) > 0:
|
|
# navigationd is the sole durable writer; HTTP reads must not checkpoint older samples.
|
|
saved = self.position_store.read()
|
|
bearing = result['location'].get('bearing')
|
|
if saved and 'bearing' in saved and (not isinstance(bearing, (int, float)) or isinstance(bearing, bool) or not math.isfinite(bearing)):
|
|
result['location'] = dict(result['location'], bearing=saved['bearing'])
|
|
if result['location'] is None:
|
|
result['location'] = self.position_store.read()
|
|
state = (self.runtime_source() if callable(self.runtime_source) else self.runtime_source.snapshot()) if document['destination'] else None
|
|
if document['destination'] and isinstance(state, dict) and state.get('revision') == document['revision']:
|
|
for key in ('status', 'instruction', 'route'):
|
|
result[key] = state[key]
|
|
result['route'] = rounded(result['route'])
|
|
# Route lines are most of each status poll; a client that already holds them gets only the key.
|
|
result['routeKey'] = hashlib.sha256(json.dumps([result['route'], result['alternatives']], separators=(',', ':')).encode()).hexdigest()[:16]
|
|
if route_key is not None and route_key == result['routeKey']:
|
|
del result['route'], result['alternatives']
|
|
result['routeUnchanged'] = True
|
|
return result
|
|
|
|
def route_options(self, revision):
|
|
try:
|
|
value = json.loads((self.transient_root / 'routes.cache').read_text())
|
|
return value['routes'] if value['revision'] == revision and time.monotonic() < value['expires'] else []
|
|
except (OSError, ValueError, KeyError):
|
|
return []
|
|
|
|
def record_routes(self, revision, routes):
|
|
rows = [{'index': index, 'durationSeconds': route.total_duration, 'distanceMeters': route.total_distance,
|
|
'geometry': route.preview()} for index, route in enumerate(routes)]
|
|
with self._exclusive():
|
|
if self.read()['revision'] != revision:
|
|
return
|
|
directory = self._temporary_directory()
|
|
os.close(directory)
|
|
fd, name = tempfile.mkstemp(dir=self.transient_root, prefix='.routes-')
|
|
try:
|
|
with os.fdopen(fd, 'w') as out:
|
|
json.dump({'revision': revision, 'expires': time.monotonic() + ACTIVE_TTL, 'routes': rows}, out, allow_nan=False)
|
|
os.replace(name, self.transient_root / 'routes.cache')
|
|
finally:
|
|
Path(name).unlink(missing_ok=True)
|
|
|
|
def select_route(self, index, expected_revision, authorized):
|
|
if self.read()['revision'] != expected_revision:
|
|
raise ConflictError('Navigation changed; refresh and try again')
|
|
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, 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}
|
|
(self.transient_root / 'routes.cache').write_text(json.dumps(value, allow_nan=False))
|
|
return self.snapshot()
|
|
|
|
def map_tile(self, z: int, x: int, y: int, theme: str = 'light') -> bytes:
|
|
if theme not in MAP_STYLES:
|
|
raise ValidationError('Invalid map theme')
|
|
if any(type(v) is not int for v in (z, x, y)) or not 0 <= z <= 18 or not 0 <= x < 2 ** z or not 0 <= y < 2 ** z:
|
|
raise ValidationError('Invalid map tile')
|
|
token = self.read()['token']
|
|
if not token:
|
|
raise ValidationError('Save a Mapbox key to view the map')
|
|
key = hashlib.sha256(token.encode()).digest()
|
|
coordinates = (theme, z, x, y)
|
|
with self._tile_lock:
|
|
self._expire_tiles(key)
|
|
cached = self._tiles.get(coordinates)
|
|
if cached is not None:
|
|
self._tiles.move_to_end(coordinates)
|
|
if cached is not None:
|
|
if self.read()['token'] != token:
|
|
raise ValidationError('Map key changed; try again')
|
|
return cached[1]
|
|
self.tile_budget.spend(enforce=False) # upstream's map: counted for Setup, never refused
|
|
if not self._tile_slots.acquire(timeout=TILE_SLOT_WAIT_S):
|
|
raise ValidationError('Map is busy; try again')
|
|
try:
|
|
started = time.monotonic()
|
|
with self.session.get(f'https://api.mapbox.com/styles/v1/{MAP_STYLES[theme]}/tiles/512/{z}/{x}/{y}.png',
|
|
params={'access_token': token}, timeout=(2, 2), stream=True, allow_redirects=False) as response:
|
|
if response.status_code != 200 or response.headers.get('Content-Type', '').split(';')[0] != 'image/png':
|
|
raise ValidationError('Map tiles are unavailable')
|
|
raw = bytearray()
|
|
for chunk in response.iter_content(64 * 1024):
|
|
raw.extend(chunk)
|
|
if len(raw) > 2 * 1024 * 1024 or time.monotonic() - started > 5:
|
|
raise ValidationError('Map tile exceeded the request limit')
|
|
if bytes(raw[:8]) != b'\x89PNG\r\n\x1a\n' or bytes(raw[12:16]) != b'IHDR' or bytes(raw[16:24]) != b'\x00\x00\x02\x00' * 2:
|
|
raise ValidationError('Invalid map tile response')
|
|
if self.read()['token'] != token:
|
|
raise ValidationError('Map key changed; try again')
|
|
tile = bytes(raw)
|
|
with self._tile_lock:
|
|
if self.read()['token'] != token:
|
|
raise ValidationError('Map key changed; try again')
|
|
self._expire_tiles(key)
|
|
previous = self._tiles.pop(coordinates, None)
|
|
if previous is not None:
|
|
self._tile_bytes -= len(previous[1])
|
|
self._tiles[coordinates] = (time.monotonic() + TILE_CACHE_TTL, tile)
|
|
self._tile_bytes += len(tile)
|
|
while len(self._tiles) > TILE_CACHE_ENTRIES or self._tile_bytes > TILE_CACHE_BYTES:
|
|
_, removed = self._tiles.popitem(last=False)
|
|
self._tile_bytes -= len(removed[1])
|
|
return tile
|
|
except requests.RequestException:
|
|
raise ValidationError('Map tiles are unavailable') from None
|
|
finally:
|
|
self._tile_slots.release()
|
|
|
|
def _expire_tiles(self, key):
|
|
# Called with the metadata lock held; provider requests never hold this lock.
|
|
if self._tile_key != key:
|
|
self._tiles.clear()
|
|
self._tile_bytes = 0
|
|
self._tile_key = key
|
|
now = time.monotonic()
|
|
for coordinates, (expires, tile) in list(self._tiles.items()):
|
|
if expires <= now:
|
|
del self._tiles[coordinates]
|
|
self._tile_bytes -= len(tile)
|
|
|
|
def configure(self, patch: dict, expected_revision: str, authorized) -> dict:
|
|
if not isinstance(patch, dict) or not patch or set(patch) - ({'enabled', 'token', 'gpsSource'} | ROUTE_AVOIDANCES.keys()):
|
|
raise ValidationError('Unknown navigation setting')
|
|
if 'gpsSource' in patch and patch['gpsSource'] not in GPS_MODES:
|
|
raise ValidationError('Choose a GPS source')
|
|
if 'enabled' in patch and type(patch['enabled']) is not bool:
|
|
raise ValidationError('Navigation enabled must be on or off')
|
|
if any(type(patch[key]) is not bool for key in ROUTE_AVOIDANCES if key in patch):
|
|
raise ValidationError('Route avoidance settings must be on or off')
|
|
if 'token' in patch and (not isinstance(patch['token'], str) or len(patch['token']) > 2048 or
|
|
any(ch.isspace() for ch in patch['token'])):
|
|
raise ValidationError('Enter a valid Mapbox access token')
|
|
with self._lock:
|
|
self._searches.clear()
|
|
def update(value):
|
|
if any(key in patch and patch[key] != value[key] for key in ROUTE_AVOIDANCES):
|
|
value['routeChoice'] = 0
|
|
value.update(patch)
|
|
result = self._change(update, expected_revision, authorized, preserve_active=set(patch) <= ROUTE_AVOIDANCES.keys() | {'gpsSource'})
|
|
if 'token' in patch:
|
|
with self._tile_lock:
|
|
self._tiles.clear()
|
|
self._tile_bytes = 0
|
|
self._tile_key = None
|
|
return result
|
|
|
|
def search(self, query: str) -> list[dict]:
|
|
if not isinstance(query, str) or not 2 <= len(query.strip()) <= 256:
|
|
raise ValidationError('Enter at least two characters to search')
|
|
token = self.read()['token']
|
|
if not token:
|
|
raise ValidationError('Add your Mapbox access token first')
|
|
from openpilot.starpilot.navigation.mapbox_budget import BudgetExhausted
|
|
try:
|
|
self.geocode_budget.spend()
|
|
except BudgetExhausted as error:
|
|
raise ValidationError(str(error)) from None
|
|
data = response_json(self.session, 'https://api.mapbox.com/search/geocode/v6/forward',
|
|
{'q': query.strip(), 'access_token': token, 'limit': 8, 'autocomplete': 'false', 'permanent': 'false', **self._search_context()})
|
|
results = []
|
|
features = data.get('features')
|
|
if not isinstance(features, list):
|
|
raise ValidationError('The map service returned invalid search results')
|
|
for feature in features[:8]:
|
|
try:
|
|
properties = feature['properties']
|
|
coordinates = feature['geometry']['coordinates']
|
|
address = properties.get('full_address') or properties.get('place_formatted')
|
|
name = properties.get('name_preferred') or properties.get('name') or address
|
|
results.append(destination({'name': name, 'address': address, 'longitude': coordinates[0], 'latitude': coordinates[1]}))
|
|
except (ValueError, TypeError, KeyError, IndexError, AttributeError):
|
|
continue
|
|
return results
|
|
|
|
def cancel_search(self, caller, search_id):
|
|
if not isinstance(search_id, str):
|
|
raise ValidationError('Start a new destination search')
|
|
with self._lock:
|
|
self._searches.pop((caller, search_id), None)
|
|
|
|
def _search_context(self) -> dict:
|
|
with self._lock:
|
|
try:
|
|
if self.runtime_source is None:
|
|
from openpilot.starpilot.navigation.status import NavigationStatusSource
|
|
self.runtime_source = NavigationStatusSource()
|
|
position = getattr(self.runtime_source, 'search_position', None)
|
|
if position is None:
|
|
return {}
|
|
coordinates = position()
|
|
if coordinates is None:
|
|
saved = self.position_store.read()
|
|
if saved is not None:
|
|
coordinates = (saved['longitude'], saved['latitude'])
|
|
if (type(coordinates) is not tuple or len(coordinates) != 2 or
|
|
any(type(v) not in (int, float) or not math.isfinite(v) for v in coordinates) or
|
|
not -180 <= coordinates[0] <= 180 or not -90 <= coordinates[1] <= 90):
|
|
return {}
|
|
return {'proximity': f'{coordinates[0]:.6f},{coordinates[1]:.6f}'}
|
|
except (AttributeError, ImportError, OSError, ValueError, TypeError, OverflowError, RuntimeError):
|
|
return {}
|
|
|
|
def search_places(self, query, caller, search_id, client_id, autocomplete=False):
|
|
if not isinstance(client_id, str) or len(client_id) != 36:
|
|
raise ValidationError('Start a new destination search')
|
|
if not isinstance(search_id, str) or len(search_id) != 36:
|
|
raise ValidationError('Start a new destination search')
|
|
try:
|
|
if str(uuid.UUID(search_id, version=4)) != search_id or str(uuid.UUID(client_id, version=4)) != client_id:
|
|
raise ValueError
|
|
except ValueError:
|
|
raise ValidationError('Start a new destination search') from None
|
|
if not isinstance(query, str) or not 2 <= len(query.strip()) <= 256:
|
|
raise ValidationError('Enter at least two characters to search')
|
|
current = self.read()
|
|
if not current['token']:
|
|
raise ValidationError('Add your Mapbox access token first')
|
|
key = (caller, search_id)
|
|
from openpilot.starpilot.navigation.mapbox_budget import BudgetExhausted
|
|
with self._lock:
|
|
now = time.monotonic()
|
|
previous = self._searches.get(key)
|
|
# Typing continues one Mapbox search session: keystrokes share a session token until a place is chosen.
|
|
continuing = (autocomplete and previous is not None and previous['expires'] > now and previous['client_id'] == client_id and
|
|
previous.get('autocomplete') and previous['revision'] == current['revision'] and
|
|
previous['token_digest'] == hashlib.sha256(current['token'].encode()).digest())
|
|
self._searches = {other:value for other,value in self._searches.items()
|
|
if value['expires'] > now and (other == key and continuing or
|
|
not (other[0] == caller and value['client_id'] == client_id))}
|
|
if key in self._searches and not continuing:
|
|
raise ValidationError('Start a new destination search')
|
|
if continuing:
|
|
entry = previous
|
|
entry['expires'] = now + SEARCH_TTL
|
|
entry['suggests'] = entry.get('suggests', 0) + 1
|
|
if entry['suggests'] % SUGGESTS_PER_SESSION == 0:
|
|
try:
|
|
self.search_budget.spend()
|
|
except BudgetExhausted as error:
|
|
raise ValidationError(str(error)) from None
|
|
else:
|
|
if len(self._searches) >= MAX_SEARCHES or sum(key[0] == caller for key in self._searches) >= 8:
|
|
raise ValidationError('Too many destination searches; try again shortly')
|
|
# The Search button keeps upstream's behavior (counted, never refused); typing suggestions stop at the cap.
|
|
try:
|
|
self.search_budget.spend(enforce=autocomplete)
|
|
except BudgetExhausted as error:
|
|
raise ValidationError(str(error)) from None
|
|
entry = {'client_id':client_id, 'session':str(uuid.uuid4()), 'expires':now + SEARCH_TTL, 'revision':current['revision'],
|
|
'token_digest':hashlib.sha256(current['token'].encode()).digest(), 'ids':set(), 'autocomplete':autocomplete}
|
|
self._searches[key] = entry
|
|
try:
|
|
context = self._search_context()
|
|
with self._lock:
|
|
if (self._searches.get(key) is not entry or entry['expires'] <= time.monotonic() or
|
|
self.read()['token'] != current['token']):
|
|
raise ValidationError('Start a new destination search')
|
|
data = response_json(self.session, 'https://api.mapbox.com/search/searchbox/v1/suggest',
|
|
{'q':query.strip(), 'access_token':current['token'], 'session_token':entry['session'],
|
|
'types':','.join(sorted(AUTOCOMPLETE_TYPES)) if autocomplete else 'poi', 'limit':8, **context})
|
|
suggestions = data.get('suggestions')
|
|
if not isinstance(suggestions, list):
|
|
raise ValidationError('The map service returned invalid search results')
|
|
results = []
|
|
for item in suggestions[:8]:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
identity, name = item.get('mapbox_id'), item.get('name')
|
|
kinds = AUTOCOMPLETE_TYPES if autocomplete else {'poi'}
|
|
if (item.get('feature_type') in kinds and isinstance(identity, str) and 1 <= len(identity) <= 256 and
|
|
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, 'temporary':True})
|
|
with self._lock:
|
|
if (self._searches.get(key) is not entry or entry['expires'] <= time.monotonic() or
|
|
self.read()['token'] != current['token']):
|
|
raise ValidationError('Start a new destination search')
|
|
# Any suggestion shown during this typing session can still be chosen.
|
|
entry['ids'] = (entry['ids'] if autocomplete else set()) | {item['id'] for item in results}
|
|
if results or autocomplete:
|
|
return results
|
|
except ValidationError:
|
|
with self._lock:
|
|
if (self._searches.get(key) is not entry or entry['expires'] <= time.monotonic() or
|
|
self.read()['token'] != current['token']):
|
|
raise ValidationError('Start a new destination search') from None
|
|
if autocomplete:
|
|
return [] # suggestions are best effort; typing never spends address lookups
|
|
self.cancel_search(caller, search_id)
|
|
# Address search remains available if place search is unavailable.
|
|
return self.search(query)
|
|
self.cancel_search(caller, search_id)
|
|
return self.search(query)
|
|
|
|
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.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
|
|
entry['token_digest'] != hashlib.sha256(current['token'].encode()).digest()):
|
|
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')
|
|
data = response_json(self.session, 'https://api.mapbox.com/search/searchbox/v1/retrieve/' + quote(identity, safe=''),
|
|
{'access_token':current['token'], 'session_token':entry['session']})
|
|
try:
|
|
feature = data['features'][0]
|
|
properties = feature['properties']
|
|
if properties.get('mapbox_id') != identity:
|
|
raise ValueError
|
|
coordinates = feature['geometry']['coordinates']
|
|
return destination({'name':properties.get('name'), 'address':properties.get('full_address') or properties.get('place_formatted'),
|
|
'longitude':coordinates[0], 'latitude':coordinates[1]})
|
|
except (ValueError, TypeError, KeyError, IndexError):
|
|
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)
|
|
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):
|
|
raise ValidationError('Search suggestions can be used for a route but cannot be saved; search for an address instead')
|
|
|
|
@staticmethod
|
|
def _navigate(doc, selected):
|
|
doc.update(destination=selected, routeChoice=0, recents=remember(doc.get('recents', []), selected))
|
|
|
|
@staticmethod
|
|
def _add_favorite(doc, selected):
|
|
existing = next((row for row in doc['favorites'] if row['id'] == selected['id']), None)
|
|
if existing is not None:
|
|
return
|
|
if len(doc['favorites']) >= MAX_FAVORITES:
|
|
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)
|
|
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:
|
|
self._searches.clear()
|
|
return self._change(lambda doc: doc.update(destination=None, routeChoice=0), expected_revision, authorized)
|
|
|
|
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, 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, 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, preserve_active=True)
|
|
|
|
@staticmethod
|
|
def _validate_favorite_label(label):
|
|
if label is not None and label not in FAVORITE_LABELS:
|
|
raise ValidationError('Choose Home or Work')
|
|
|
|
@staticmethod
|
|
def _label_favorite(doc, identity, label):
|
|
if not any(row['id'] == identity for row in doc['favorites']):
|
|
raise ValidationError('Choose a saved place')
|
|
for row in doc['favorites']:
|
|
if row['id'] == identity or label is not None and row.get('label') == label:
|
|
row.pop('label', None)
|
|
if row['id'] == identity and label is not None:
|
|
row['label'] = label
|
|
|
|
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, preserve_active=True)
|
|
|
|
def clear_recents(self, expected_revision: str, authorized) -> dict:
|
|
return self._change(lambda doc: doc.update(recents=[]), expected_revision, authorized, preserve_active=True)
|
|
|
|
def close(self):
|
|
with self._lock:
|
|
if self.runtime_source is not None and hasattr(self.runtime_source, 'close'):
|
|
self.runtime_source.close()
|
|
self.runtime_source = None
|