mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
305 lines
13 KiB
Python
305 lines
13 KiB
Python
"""Bounded local route inventory and read leases shared by Galaxy and replay; never decode or alter sources."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
|
|
from openpilot.common.hardware.hw import Paths
|
|
from openpilot.starpilot.connect.provider import PROVIDERS, ROUTE_MARKER
|
|
from openpilot.starpilot.galaxy.drive_history import (
|
|
DIR_FLAGS, FILE_KINDS, FILES, MAX_ROUTES, MAX_SEGMENT_ENTRIES, ROUTE, SEGMENT_NAME, DriveHistory, _same_directory,
|
|
)
|
|
from openpilot.starpilot.galaxy.recording_library import RecordingLibrary, display_name
|
|
|
|
MAX_ROOT_ENTRIES = 32768
|
|
MAX_ROUTE_SEGMENTS = 512
|
|
MAX_CATALOG_SEGMENTS = 512
|
|
_ROUTE_ENTRY = re.compile(rf'(?P<route>{ROUTE})--.*\Z')
|
|
|
|
|
|
class CatalogError(Exception):
|
|
def __init__(self, code: str, message: str):
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
def _route_id(value: object) -> str:
|
|
if type(value) is not str or SEGMENT_NAME.fullmatch(value + '--0') is None:
|
|
raise CatalogError('invalid_route', 'Invalid local route identity')
|
|
return value.replace('_', '|')
|
|
|
|
|
|
def _identity(info: os.stat_result) -> tuple[int, int, int, int]:
|
|
# Upload status xattrs change ctime without changing replay content.
|
|
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns
|
|
|
|
|
|
def _route_names(root_fd: int, selected: str | None = None) -> tuple[dict[str, list[str]], bool]:
|
|
routes: dict[str, list[str]] = {}
|
|
incomplete = False
|
|
with os.scandir(root_fd) as entries:
|
|
for count, entry in enumerate(entries, 1):
|
|
if count > MAX_ROOT_ENTRIES:
|
|
incomplete = True
|
|
break
|
|
matched = _ROUTE_ENTRY.fullmatch(entry.name)
|
|
if matched is None:
|
|
continue
|
|
route_id = matched.group('route').replace('_', '|')
|
|
if selected is not None and route_id != selected:
|
|
continue
|
|
if selected is None and route_id not in routes and len(routes) >= MAX_ROUTES:
|
|
incomplete = True
|
|
continue
|
|
names = routes.setdefault(route_id, [])
|
|
# One extra entry records that a selected route exceeds the admission bound.
|
|
if len(names) <= MAX_ROUTE_SEGMENTS:
|
|
names.append(entry.name)
|
|
return routes, incomplete
|
|
|
|
|
|
def _sources(fd: int) -> dict[str, tuple[int, int, int, int]]:
|
|
sources = {}
|
|
with os.scandir(fd) as entries:
|
|
for count, entry in enumerate(entries, 1):
|
|
if count > MAX_SEGMENT_ENTRIES:
|
|
raise CatalogError('scan_incomplete', 'A route segment has too many entries to verify')
|
|
if entry.name.endswith('.lock'):
|
|
raise CatalogError('in_progress', 'This route is still being recorded')
|
|
if entry.name not in FILES:
|
|
continue
|
|
info = entry.stat(follow_symlinks=False)
|
|
if not stat.S_ISREG(info.st_mode):
|
|
raise CatalogError('route_unavailable', 'A route source is not a regular file')
|
|
if _identity(info) != _identity(os.stat(entry.name, dir_fd=fd, follow_symlinks=False)):
|
|
raise CatalogError('route_changed', 'The route changed while it was being inspected')
|
|
sources[entry.name] = _identity(info)
|
|
if not sources:
|
|
raise CatalogError('route_unavailable', 'A route segment contains no saved driving logs or camera footage')
|
|
return sources
|
|
|
|
|
|
def _provider(segment_fd: int) -> str | None:
|
|
fd = -1
|
|
try:
|
|
fd = os.open(ROUTE_MARKER, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, dir_fd=segment_fd)
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_size > 32:
|
|
return None
|
|
value = os.read(fd, 32).decode('ascii')
|
|
return value if value in PROVIDERS else None
|
|
except FileNotFoundError:
|
|
return 'comma'
|
|
except (OSError, UnicodeError):
|
|
return None
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
|
|
|
|
def _segment(name: str) -> dict:
|
|
match = SEGMENT_NAME.fullmatch(name)
|
|
number = int(match.group('number')) if match is not None and len(match.group('number')) <= 6 else None
|
|
return {'segmentName': name, 'number': number, 'files': dict.fromkeys(FILE_KINDS, False), 'sourceFiles': [],
|
|
'fileTime': None, 'provider': None, 'logFiles': [], 'logBytes': {}, 'startTime': None, 'endTime': None}
|
|
|
|
|
|
class RouteInventory:
|
|
def __init__(self, root: Path | None = None):
|
|
self.root = Path(root) if root is not None else Path(Paths.log_root())
|
|
self.library = RecordingLibrary(DriveHistory(self.root))
|
|
|
|
def _open_root(self) -> int:
|
|
try:
|
|
fd = os.open(self.root, DIR_FLAGS)
|
|
except OSError as error:
|
|
raise CatalogError('route_unavailable', 'Local recordings are unavailable') from error
|
|
if not _same_directory(fd, None, self.root):
|
|
os.close(fd)
|
|
raise CatalogError('route_changed', 'The recording directory changed')
|
|
return fd
|
|
|
|
def _inspect(self, root_fd: int, route_id: str, names: list[str], *, hold: bool = False) -> tuple[dict, list]:
|
|
route = {'routeId': route_id, 'displayName': route_id, 'source': 'local', 'segmentCount': len(names),
|
|
'fileTime': None, 'provider': None, 'segments': [_segment(name) for name in names],
|
|
'startTime': None, 'endTime': None, 'availability': 'unavailable', 'playable': False,
|
|
'reason': None, 'warnings': [], 'videoBytes': dict.fromkeys(('fcamera', 'dcamera', 'ecamera', 'qcamera'), 0)}
|
|
opened = []
|
|
error = None
|
|
content_error = None
|
|
numbers = [segment['number'] for segment in route['segments']]
|
|
if len(names) > MAX_ROUTE_SEGMENTS:
|
|
error = CatalogError('scan_incomplete', 'This route exceeds the 512-segment replay limit')
|
|
elif None in numbers or len(set(numbers)) != len(numbers):
|
|
error = CatalogError('route_unavailable', 'This route has malformed or duplicate segment identities')
|
|
else:
|
|
for segment in route['segments']:
|
|
fd = -1
|
|
try:
|
|
fd = os.open(segment['segmentName'], DIR_FLAGS, dir_fd=root_fd)
|
|
if hold:
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
except BlockingIOError as busy:
|
|
raise CatalogError('source_busy', 'This route is being deleted') from busy
|
|
if not _same_directory(fd, root_fd, segment['segmentName']):
|
|
raise CatalogError('route_changed', 'A route segment changed')
|
|
sources = _sources(fd)
|
|
if any(identity[2] == 0 for identity in sources.values()):
|
|
content_error = CatalogError('route_unavailable', 'A route source is empty; replay is unavailable')
|
|
for name, identity in sources.items():
|
|
if identity[2] == 0:
|
|
continue
|
|
segment['files'][FILES[name]] = True
|
|
if FILES[name] in route['videoBytes']:
|
|
route['videoBytes'][FILES[name]] += identity[2]
|
|
if name.startswith(('rlog.', 'qlog.')):
|
|
segment['logBytes'][name] = identity[2]
|
|
segment['sourceFiles'] = sorted(sources)
|
|
segment['logFiles'] = sorted(segment['logBytes'])
|
|
segment['provider'] = _provider(fd)
|
|
segment['fileTime'] = min((identity[3] / 1e9 for identity in sources.values() if identity[3] > 946684800 * 1e9), default=None)
|
|
if hold:
|
|
opened.append((segment['segmentName'], fd, sources))
|
|
fd = -1
|
|
except CatalogError as problem:
|
|
error = error or problem
|
|
except OSError:
|
|
error = error or CatalogError('route_changed', 'A route source is missing or cannot be opened safely')
|
|
finally:
|
|
if fd >= 0:
|
|
os.close(fd)
|
|
route['segments'].sort(key=lambda segment: (segment['number'] is None, segment['number'] or 0, segment['segmentName']))
|
|
# Keep existing user labels and preservation metadata; neither affects admission.
|
|
self.library.describe({'routes': [route]})
|
|
try:
|
|
route['displayName'] = display_name(route['displayName'])
|
|
except ValueError:
|
|
route['displayName'] = route_id
|
|
route['inventoryComplete'] = error is None
|
|
error = error or content_error
|
|
route['fileTime'] = min((segment['fileTime'] for segment in route['segments'] if segment['fileTime'] is not None), default=None)
|
|
providers = {segment['provider'] for segment in route['segments']}
|
|
route['provider'] = next(iter(providers)) if len(providers) == 1 else None
|
|
if error is not None:
|
|
route.update(reason=str(error), reasonCode=error.code)
|
|
else:
|
|
files = [segment['files'] for segment in route['segments']]
|
|
if any(not item['rlog'] and item['qlog'] for item in files):
|
|
route['warnings'].append('Reduced log detail; model and lane overlays may be unavailable.')
|
|
if any(not item['fcamera'] and item['qcamera'] for item in files):
|
|
route['warnings'].append('Some road video uses lower resolution qcamera footage.')
|
|
if any(not (item['rlog'] or item['qlog']) for item in files):
|
|
route['warnings'].append('Some segments have no recorded driving state.')
|
|
if any(not (item['fcamera'] or item['qcamera']) for item in files):
|
|
route['warnings'].append('Some segments have no road camera footage.')
|
|
ordered = sorted(numbers)
|
|
if ordered and (ordered[0] != 0 or any(b != a + 1 for a, b in zip(ordered, ordered[1:], strict=False))):
|
|
route['warnings'].append('This route has missing segments; playback contains gaps.')
|
|
if any((item['rlog'] or item['qlog']) and (item['fcamera'] or item['qcamera']) for item in files):
|
|
route.update(playable=True, availability='limited' if route['warnings'] else 'ready')
|
|
else:
|
|
route.update(reason='No segment has both driving logs and road camera footage', reasonCode='route_unavailable')
|
|
return route, opened
|
|
|
|
def catalog(self) -> dict:
|
|
result = {'schemaVersion': 1, 'routes': [], 'scanIncomplete': False}
|
|
try:
|
|
self.root.lstat()
|
|
except FileNotFoundError:
|
|
return result
|
|
except OSError as error:
|
|
raise CatalogError('route_unavailable', 'Local recordings are unavailable') from error
|
|
root_fd = self._open_root()
|
|
try:
|
|
groups, result['scanIncomplete'] = _route_names(root_fd)
|
|
remaining = MAX_CATALOG_SEGMENTS
|
|
for route_id, names in sorted(groups.items()):
|
|
if len(names) <= remaining:
|
|
route, _ = self._inspect(root_fd, route_id, names)
|
|
remaining -= len(names)
|
|
else:
|
|
route, _ = self._inspect(root_fd, route_id, [])
|
|
route.update(segmentCount=len(names), reason='Open this route to inspect its replay sources', reasonCode='scan_incomplete')
|
|
result['routes'].append({key: value for key, value in route.items() if key != 'segments'})
|
|
result['scanIncomplete'] |= route.get('reasonCode') == 'scan_incomplete'
|
|
if not _same_directory(root_fd, None, self.root):
|
|
raise CatalogError('route_changed', 'The recording directory changed')
|
|
return result
|
|
except OSError as error:
|
|
raise CatalogError('scan_incomplete', 'Local recordings could not be completely inspected') from error
|
|
finally:
|
|
os.close(root_fd)
|
|
|
|
def detail(self, route_id: object) -> dict:
|
|
route_id = _route_id(route_id)
|
|
root_fd = self._open_root()
|
|
try:
|
|
groups, incomplete = _route_names(root_fd, route_id)
|
|
if incomplete:
|
|
raise CatalogError('scan_incomplete', 'The recording directory exceeds the replay scan limit')
|
|
if route_id not in groups:
|
|
raise CatalogError('route_unavailable', 'This local route is no longer available')
|
|
route, _ = self._inspect(root_fd, route_id, groups[route_id])
|
|
if not _same_directory(root_fd, None, self.root):
|
|
raise CatalogError('route_changed', 'The recording directory changed')
|
|
return route
|
|
except OSError as error:
|
|
raise CatalogError('route_changed', 'The recording directory changed') from error
|
|
finally:
|
|
os.close(root_fd)
|
|
|
|
def lease(self, route_id: object) -> SourceLease:
|
|
return SourceLease(self, _route_id(route_id))
|
|
|
|
|
|
class SourceLease:
|
|
"""Shared directory locks protect sources from cooperating deletion paths."""
|
|
def __init__(self, catalog: RouteInventory, route_id: str):
|
|
self.catalog, self.route_id = catalog, route_id
|
|
self.root_fd, self.opened = -1, []
|
|
try:
|
|
self.root_fd = catalog._open_root()
|
|
groups, incomplete = _route_names(self.root_fd, route_id)
|
|
if incomplete:
|
|
raise CatalogError('scan_incomplete', 'The recording directory exceeds the replay scan limit')
|
|
if route_id not in groups:
|
|
raise CatalogError('route_unavailable', 'This local route is no longer available')
|
|
self.names = set(groups[route_id])
|
|
self.route, self.opened = catalog._inspect(self.root_fd, route_id, groups[route_id], hold=True)
|
|
if not self.route['playable']:
|
|
raise CatalogError(self.route.get('reasonCode', 'route_unavailable'), self.route['reason'])
|
|
if not self.current():
|
|
raise CatalogError('route_changed', 'The route changed while acquiring replay sources')
|
|
except Exception:
|
|
self.close()
|
|
raise
|
|
|
|
def current(self) -> bool:
|
|
if self.root_fd < 0 or not _same_directory(self.root_fd, None, self.catalog.root):
|
|
return False
|
|
try:
|
|
groups, incomplete = _route_names(self.root_fd, self.route_id)
|
|
return not incomplete and set(groups.get(self.route_id, [])) == self.names and all(
|
|
_same_directory(fd, self.root_fd, name) and _sources(fd) == sources for name, fd, sources in self.opened)
|
|
except (OSError, CatalogError):
|
|
return False
|
|
|
|
def close(self) -> None:
|
|
for _, fd, _ in self.opened:
|
|
os.close(fd)
|
|
self.opened.clear()
|
|
if self.root_fd >= 0:
|
|
os.close(self.root_fd)
|
|
self.root_fd = -1
|
|
|
|
def __enter__(self) -> SourceLease:
|
|
return self
|
|
|
|
def __exit__(self, *_args) -> None:
|
|
self.close()
|