mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
ec8d7eb98b
Define persistent settings, typed schema fields and storage migrations before feature owners consume them. Keep calibration and parameter readers aligned with the shared data contracts. Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com> Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com> Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com> Co-authored-by: prabhaavp <prabhaav@msu.edu>
327 lines
11 KiB
Python
327 lines
11 KiB
Python
"""Cloud-provider identity and a reboot-bound switch with recoverable state."""
|
|
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
import base64
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import tempfile
|
|
|
|
from openpilot.common.hardware.hw import Paths
|
|
from openpilot.starpilot.storage import starpilot_storage_root
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Provider:
|
|
name: str
|
|
label: str
|
|
api: str
|
|
athena: str
|
|
web: str
|
|
upload_attribute: str
|
|
|
|
|
|
PROVIDERS = {
|
|
'comma': Provider('comma', 'comma connect', 'https://api.commadotai.com', 'wss://athena.comma.ai',
|
|
'https://connect.comma.ai', 'user.upload'),
|
|
'konik': Provider('konik', 'Konik', 'https://api.konik.ai', 'wss://athena.konik.ai',
|
|
'https://stable.konik.ai', 'user.upload.konik'),
|
|
}
|
|
STATE_KEYS = ('DongleId', 'PrimeType', 'PairingProvider', 'PairingEmail', 'AthenadUploadQueue', 'AthenadRecentlyViewedRoutes',
|
|
'ApiCache_Device', 'ApiCache_FirehoseStats')
|
|
TRANSIENT_KEYS = ('AccessToken', 'LastAthenaPingTime')
|
|
MAX_STATE_BYTES = 8 * 1024 * 1024
|
|
ROUTE_MARKER = '.cloud-provider'
|
|
OFFLINE = Provider('offline', 'Cloud Unavailable', '', '', '', 'user.upload.disabled')
|
|
|
|
|
|
def root_path():
|
|
return starpilot_storage_root() / 'connect'
|
|
|
|
|
|
def _read(path, default=None):
|
|
try:
|
|
with path.open('rb') as stream:
|
|
raw = stream.read(MAX_STATE_BYTES + 1)
|
|
if len(raw) > MAX_STATE_BYTES:
|
|
raise ValueError('Cloud state is too large')
|
|
return json.loads(raw)
|
|
except FileNotFoundError:
|
|
return default
|
|
|
|
|
|
def _write(path, value):
|
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(prefix='.connect-', dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, 'w') as stream:
|
|
json.dump(value, stream, separators=(',', ':'))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(parent)
|
|
finally:
|
|
os.close(parent)
|
|
finally:
|
|
Path(temporary).unlink(missing_ok=True)
|
|
|
|
|
|
def configuration(root=None):
|
|
value = _read((root or root_path()) / 'provider.json', {'active': 'comma', 'selected': 'comma', 'requestedBoot': ''})
|
|
if (type(value) is not dict or set(value) != {'active', 'selected', 'requestedBoot'} or
|
|
value['active'] not in PROVIDERS or value['selected'] not in PROVIDERS or type(value['requestedBoot']) is not str):
|
|
raise ValueError('Cloud provider configuration is invalid')
|
|
return value
|
|
|
|
|
|
def active_provider():
|
|
try:
|
|
return PROVIDERS[configuration()['active']]
|
|
except (OSError, ValueError, TypeError, KeyError):
|
|
return OFFLINE
|
|
|
|
|
|
def state_revision(value):
|
|
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
|
|
|
|
def boot_id():
|
|
try:
|
|
return Path('/proc/sys/kernel/random/boot_id').read_text().strip()
|
|
except FileNotFoundError:
|
|
import psutil
|
|
return f'host-{psutil.boot_time():.3f}'
|
|
|
|
|
|
@contextmanager
|
|
def _exclusive(root):
|
|
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
fd = os.open(root / '.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
yield
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def select_provider(name, revision, authorized, *, root=None, boot=boot_id):
|
|
root = root or root_path()
|
|
if name not in PROVIDERS:
|
|
raise ValueError('Unknown cloud provider')
|
|
with _exclusive(root):
|
|
value = configuration(root)
|
|
if not authorized() or state_revision(value) != revision:
|
|
raise ValueError('Cloud selection changed. Refresh and try again.')
|
|
value.update(selected=name, requestedBoot=boot())
|
|
if not authorized():
|
|
raise ValueError('Turn off the vehicle before changing cloud providers')
|
|
_write(root / 'provider.json', value)
|
|
return status(root)
|
|
|
|
|
|
def status(root=None):
|
|
value = configuration(root)
|
|
return {**value, 'revision': state_revision(value), 'restartRequired': value['active'] != value['selected'],
|
|
'providers': [{'id': p.name, 'label': p.label, 'url': p.web} for p in PROVIDERS.values()]}
|
|
|
|
|
|
def _capture(params):
|
|
values = {}
|
|
for key in STATE_KEYS:
|
|
path = Path(params.get_param_path(key))
|
|
try:
|
|
raw = path.read_bytes()
|
|
if len(raw) > MAX_STATE_BYTES // 2:
|
|
raise ValueError('Cloud preference is too large')
|
|
values[key] = base64.b64encode(raw).decode()
|
|
except FileNotFoundError:
|
|
values[key] = None
|
|
if len(json.dumps(values)) > MAX_STATE_BYTES:
|
|
raise ValueError('Cloud snapshot is too large')
|
|
return values
|
|
|
|
|
|
def _restore(params, values):
|
|
if type(values) is not dict or set(values) != set(STATE_KEYS):
|
|
raise ValueError('Cloud identity snapshot is invalid')
|
|
decoded = {key: None if value is None else base64.b64decode(value, validate=True) for key, value in values.items()}
|
|
for key, raw in decoded.items():
|
|
params.check_key(key)
|
|
path = Path(params.get_param_path(key))
|
|
if raw is None:
|
|
params.remove(key)
|
|
else:
|
|
fd, temporary = tempfile.mkstemp(prefix='.cloud-', dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, 'wb') as stream:
|
|
stream.write(raw)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
finally:
|
|
Path(temporary).unlink(missing_ok=True)
|
|
for key in TRANSIENT_KEYS:
|
|
params.remove(key)
|
|
fd = os.open(Path(params.get_param_path(STATE_KEYS[0])).parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def activate_at_boot(params, *, root=None, boot=boot_id):
|
|
"""Called before managed services start; never changes provider in a live session."""
|
|
root = root or root_path()
|
|
if not (root / 'provider.json').exists() and not (root / 'switch.json').exists():
|
|
params.put('ConnectProvider', 'comma', block=True)
|
|
return
|
|
with _exclusive(root):
|
|
value = configuration(root)
|
|
journal = _read(root / 'switch.json')
|
|
if journal is None:
|
|
if value['active'] == value['selected'] or value['requestedBoot'] == boot():
|
|
params.put('ConnectProvider', value['active'], block=True)
|
|
return
|
|
_write(root / value['active'] / 'params.json', _capture(params))
|
|
target = _read(root / value['selected'] / 'params.json', dict.fromkeys(STATE_KEYS))
|
|
journal = {'target': value['selected'], 'values': target}
|
|
_write(root / 'switch.json', journal)
|
|
if type(journal) is not dict or set(journal) != {'target', 'values'} or journal['target'] not in PROVIDERS:
|
|
raise ValueError('Cloud switch journal is invalid')
|
|
_restore(params, journal['values'])
|
|
params.put('ConnectProvider', journal['target'], block=True)
|
|
value['active'] = journal['target']
|
|
_write(root / 'provider.json', value)
|
|
(root / 'switch.json').unlink()
|
|
fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def konik_key_pair():
|
|
"""Konik never receives the factory comma private key."""
|
|
root = root_path()
|
|
with _exclusive(root):
|
|
path = root / 'konik' / 'identity.json'
|
|
identity = _read(path)
|
|
if identity is None:
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
identity = {
|
|
'private': key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL,
|
|
serialization.NoEncryption()).decode(),
|
|
'public': key.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode(),
|
|
}
|
|
_write(path, identity)
|
|
return 'RS256', identity['private'], identity['public']
|
|
|
|
|
|
def galaxy_device_id(params):
|
|
"""Galaxy pairing belongs to the physical device, independent of its cloud."""
|
|
if active_provider().name == 'comma':
|
|
return params.get('DongleId') or ''
|
|
try:
|
|
saved = _read(root_path() / 'comma' / 'params.json', {})
|
|
raw = saved.get('DongleId')
|
|
if raw is not None:
|
|
value = base64.b64decode(raw, validate=True).decode()
|
|
if re.fullmatch(r'[a-f0-9]{16}', value):
|
|
return value
|
|
return (Path(Paths.persist_root()) / 'comma/dongle_id').read_text().strip()
|
|
except (OSError, ValueError, KeyError, TypeError):
|
|
return (params.get('DongleId') or '') if params.get('ConnectProvider') == 'comma' else ''
|
|
|
|
|
|
def route_url(dongle, route, provider=None):
|
|
provider = provider or active_provider()
|
|
if not re.fullmatch(r'[a-f0-9]{16}', dongle or '') or not re.fullmatch(r'[a-f0-9]{8}--[a-f0-9]{10}|[0-9-]{20}', route or ''):
|
|
return None
|
|
return f'{provider.web}/{dongle}/{route}'
|
|
|
|
|
|
def mark_recording(directory, provider_name):
|
|
"""Commit ownership before creating derived recording data."""
|
|
if provider_name not in PROVIDERS:
|
|
raise ValueError('Cloud provider is unavailable')
|
|
directory = Path(directory)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
marker = directory / ROUTE_MARKER
|
|
if marker.exists():
|
|
if recording_provider(directory) != provider_name:
|
|
raise ValueError('Recording belongs to another cloud provider')
|
|
return
|
|
fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
with os.fdopen(fd, 'wb') as stream:
|
|
stream.write(provider_name.encode('ascii'))
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
parent = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(parent)
|
|
finally:
|
|
os.close(parent)
|
|
|
|
|
|
def recording_provider(path, *, dir_fd=None):
|
|
"""Untagged recordings predate provider selection and belong to comma."""
|
|
marker = ROUTE_MARKER if dir_fd is not None else Path(path) / ROUTE_MARKER
|
|
try:
|
|
fd = os.open(marker, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=dir_fd)
|
|
with os.fdopen(fd, 'rb') as stream:
|
|
value = stream.read(32).decode('ascii')
|
|
return value if value in PROVIDERS else None
|
|
except FileNotFoundError:
|
|
return 'comma'
|
|
except (OSError, UnicodeError):
|
|
return None
|
|
|
|
|
|
def owns_recording(path, log_root, provider=None):
|
|
provider = provider or active_provider().name
|
|
try:
|
|
relative = Path(path).resolve().relative_to(Path(log_root).resolve())
|
|
if not relative.parts:
|
|
return False
|
|
directory = Path(log_root) / relative.parts[0]
|
|
if len(relative.parts) > 1:
|
|
marker = Path(path).parent / ('.cloud-' + Path(path).name)
|
|
if marker.exists() or marker.is_symlink():
|
|
fd = os.open(marker, os.O_RDONLY | os.O_NOFOLLOW)
|
|
with os.fdopen(fd, 'rb') as stream:
|
|
return stream.read(32) == provider.encode('ascii')
|
|
return recording_provider(directory) == provider
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
|
|
def cloudlog_root():
|
|
base = Path(Paths.swaglog_root())
|
|
provider = active_provider().name
|
|
return str(base if provider == 'comma' else base / provider)
|
|
|
|
|
|
def recording_device_id(name, params):
|
|
if name not in PROVIDERS:
|
|
return None
|
|
try:
|
|
if active_provider().name == name:
|
|
identity = params.get('DongleId')
|
|
else:
|
|
saved = _read(root_path() / name / 'params.json', {})
|
|
raw = saved.get('DongleId')
|
|
identity = base64.b64decode(raw, validate=True).decode() if raw else None
|
|
if not identity and name == 'comma':
|
|
identity = (Path(Paths.persist_root()) / 'comma/dongle_id').read_text().strip()
|
|
return identity if re.fullmatch(r'[a-f0-9]{16}', identity or '') else None
|
|
except (OSError, ValueError, TypeError, KeyError):
|
|
return None
|