Files
StarPilot/openpilot/starpilot/connect/provider.py
T
firestar5683 ec8d7eb98b Contracts
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>
2026-10-14 19:56:17 -05:00

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