Files
StarPilot/openpilot/starpilot/replay/timeline.py
T

141 lines
6.1 KiB
Python

"""Replay-local history pages and palette; never used for driving decisions."""
from bisect import bisect_right
import math
import os
from pathlib import Path
import stat
from openpilot.starpilot.replay.client import parse
LEGEND = {
'chill': {'label': 'Chill', 'rgb': [22, 127, 64]},
'experimental': {'label': 'Experimental', 'rgb': [218, 111, 37]},
'aol': {'label': 'AOL', 'rgb': [10, 186, 181]},
'error': {'label': 'Alert', 'rgb': [201, 34, 49]},
'bookmark': {'label': 'Bookmark', 'rgb': [255, 214, 0]},
}
KINDS = tuple(LEGEND)
MAX_ROWS, PAGE_ROWS, MAX_PAGE, MAX_FILE = 65536, 256, 65536, 8 * 1024 * 1024
def _identity(info):
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600 or
info.st_nlink != 1 or not 0 < info.st_size <= MAX_FILE):
raise ValueError('Timeline history is unavailable')
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns
def file_identity(path: Path):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
try:
return _identity(os.fstat(fd))
finally:
os.close(fd)
def row_key(row):
return row[0], row[1], KINDS.index(row[2])
def validate_rows(rows, ranges, previous=None, ends=None):
if type(rows) is not list or len(rows) > PAGE_ROWS:
raise ValueError('Invalid timeline page')
ends = {} if ends is None else ends
for row in rows:
if (type(row) is not list or len(row) != 3 or type(row[2]) is not str or row[2] not in LEGEND or
any(type(t) not in (int, float) or not math.isfinite(t) or not 0 <= t <= 604800 for t in row[:2]) or
(row[0] != row[1] if row[2] == 'bookmark' else row[0] >= row[1]) or
not any(lo <= row[0] <= row[1] <= hi for lo, hi in ranges)):
raise ValueError('Invalid timeline interval')
if previous is not None and row_key(row) <= row_key(previous):
raise ValueError('Unordered timeline history')
layer = row[2] if row[2] in ('error', 'bookmark') else 'mode'
if row[0] < ends.get(layer, -1) or layer == 'bookmark' and row[0] == ends.get(layer):
raise ValueError('Overlapping timeline history')
ends[layer] = row[1]
previous = row
return previous, ends
def read_page(path: Path, cursor, identity, ranges):
if type(cursor) is not int or not 0 <= cursor < identity[2]:
raise ValueError('Invalid timeline cursor')
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
with os.fdopen(fd, 'rb') as source:
if _identity(os.fstat(source.fileno())) != identity:
raise ValueError('Timeline history changed')
if cursor and os.pread(source.fileno(), 1, cursor - 1) != b'\n':
raise ValueError('Invalid timeline cursor')
source.seek(cursor)
raw = source.readline(MAX_PAGE + 1)
if len(raw) > MAX_PAGE or not raw.endswith(b'\n'):
raise ValueError('Incomplete timeline page')
value = parse(raw, MAX_PAGE)
if (set(value) != {'schemaVersion', 'rowOffset', 'totalRows', 'source', 'rows'} or value['schemaVersion'] != 1 or
type(value['source']) is not str or value['source'] not in ('qlog', 'rlog', 'mixed') or
any(type(value[key]) is not int for key in ('rowOffset', 'totalRows')) or
not 0 <= value['rowOffset'] <= value['totalRows'] <= MAX_ROWS):
raise ValueError('Invalid timeline page')
validate_rows(value['rows'], ranges)
count = value['rowOffset'] + len(value['rows'])
next_cursor = source.tell() if source.tell() < identity[2] else None
if (count > value['totalRows'] or (next_cursor is None) != (count == value['totalRows']) or
not value['rows'] and (value['totalRows'] != 0 or cursor != 0) or
_identity(os.fstat(source.fileno())) != identity or file_identity(path) != identity):
raise ValueError('Incomplete timeline history')
return {**value, 'nextCursor': next_cursor, 'legend': LEGEND}
class TimelineHistory:
"""Assemble pages atomically; seeks reuse the same session history."""
def __init__(self, owner, session):
self.owner, self.session = owner, session
self.cursor = 0
self.pending = []
self.rows = None
self.total = self.source = None
self.previous, self.ends = None, {}
self.layers = {}
def append(self, page, ranges):
if (page.get('ownerId') != self.owner or page.get('sessionId') != self.session or page.get('schemaVersion') != 1 or
type(page.get('rowOffset')) is not int or page['rowOffset'] != len(self.pending) or
type(page.get('totalRows')) is not int or not 0 <= page['totalRows'] <= MAX_ROWS or
page.get('source') not in ('qlog', 'rlog', 'mixed') or page.get('legend') != LEGEND or
self.total is not None and (page['totalRows'] != self.total or page['source'] != self.source)):
raise ValueError('Invalid timeline history')
self.previous, self.ends = validate_rows(page.get('rows'), ranges, self.previous, self.ends)
next_cursor = page.get('nextCursor')
if (next_cursor is not None and (type(next_cursor) is not int or not self.cursor < next_cursor < MAX_FILE) or
len(self.pending) + len(page['rows']) > page['totalRows'] or
(next_cursor is None) != (len(self.pending) + len(page['rows']) == page['totalRows']) or
not page['rows'] and page['totalRows'] != 0):
raise ValueError('Incomplete timeline history')
self.total, self.source = page['totalRows'], page['source']
self.pending.extend(page['rows'])
self.cursor = next_cursor
if next_cursor is None:
self.rows, self.pending = self.pending, []
for kind in ('mode', 'error'):
rows = [row for row in self.rows if (row[2] == 'error') == (kind == 'error') and row[2] != 'bookmark']
self.layers[kind] = ([row[0] for row in rows], rows)
def entry(self, position):
if self.rows is None:
return None
for kind in ('error', 'mode'):
times, rows = self.layers[kind]
i = bisect_right(times, position) - 1
if i >= 0 and rows[i][0] <= position < rows[i][1]:
return LEGEND[rows[i][2]]
return None
def label(self, position):
if self.rows is None:
return 'Loading history'
entry = self.entry(position)
return entry['label'] if entry else 'Neutral'