mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
141 lines
6.1 KiB
Python
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'
|