mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-11 15:03:44 +08:00
203 lines
9.1 KiB
Python
203 lines
9.1 KiB
Python
"""Phone identity and configuration locations, and identity validation.
|
|
|
|
The identity (phone certificate, its key, and the Google Automotive Link root used
|
|
to verify the head unit) is extracted from the user's own Android Auto app by
|
|
``apk_identity`` into ``IDENTITY_DIR``. It is never
|
|
committed, never logged, and the key must be readable only by its owner.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import ssl
|
|
import stat
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
DATA_DIR = Path(os.environ.get("ANDROID_AUTO_DIR", "/data/android_auto"))
|
|
IDENTITY_DIR = DATA_DIR / "identity"
|
|
CONFIG_PATH = DATA_DIR / "config.json"
|
|
LOG_DIR = DATA_DIR / "logs"
|
|
CERT_NAME, KEY_NAME, ROOT_NAME = "phone-cert.pem", "phone-key.pem", "root-cert.pem"
|
|
EXPIRY_WARNING_DAYS = 14
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Identity:
|
|
cert: str
|
|
key: str
|
|
root: str | None
|
|
expires: str
|
|
days_left: int
|
|
|
|
|
|
class IdentityError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _not_after(cert_path: Path) -> datetime | None:
|
|
try:
|
|
from cryptography import x509
|
|
certificate = x509.load_pem_x509_certificate(cert_path.read_bytes())
|
|
if hasattr(certificate, "not_valid_after_utc"):
|
|
return certificate.not_valid_after_utc
|
|
return certificate.not_valid_after.replace(tzinfo=UTC) # cryptography < 42, as on device
|
|
except ImportError:
|
|
pass
|
|
try:
|
|
decoded = ssl._ssl._test_decode_cert(str(cert_path)) # type: ignore[attr-defined]
|
|
return datetime.fromtimestamp(ssl.cert_time_to_seconds(decoded["notAfter"]), UTC)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def load_identity(directory: Path | None = None, now: datetime | None = None) -> Identity:
|
|
directory = directory or IDENTITY_DIR
|
|
cert, key, root = directory / CERT_NAME, directory / KEY_NAME, directory / ROOT_NAME
|
|
missing = [path.name for path in (cert, key) if not path.is_file()]
|
|
if missing:
|
|
raise IdentityError(f"Android Auto identity missing ({', '.join(missing)} in {directory}); " +
|
|
"add it in The Galaxy: Toggles → Android Auto → Android Auto Certificate")
|
|
if stat.S_IMODE(key.stat().st_mode) & 0o077:
|
|
raise IdentityError(f"{key} must not be readable by other users (chmod 600)")
|
|
try:
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.load_cert_chain(str(cert), str(key))
|
|
if root.is_file():
|
|
context.load_verify_locations(cafile=str(root))
|
|
except (ssl.SSLError, OSError) as error:
|
|
raise IdentityError(f"Android Auto identity is unusable: {error}") from error
|
|
expires = _not_after(cert)
|
|
now = now or datetime.now(UTC)
|
|
if expires is not None and expires <= now:
|
|
raise IdentityError(f"Android Auto phone certificate expired on {expires.date()}; " +
|
|
"renew it in The Galaxy: Toggles → Android Auto → Android Auto Certificate")
|
|
days_left = (expires - now).days if expires is not None else -1
|
|
return Identity(str(cert), str(key), str(root) if root.is_file() else None,
|
|
expires.isoformat() if expires is not None else "unknown", days_left)
|
|
|
|
|
|
CONFIG_VERSION = 4
|
|
# Before config versions, every save wrote these defaults, which pinned projection
|
|
# at 12 fps / 4000 kbps. Unversioned files holding exactly them get today's defaults.
|
|
LEGACY_DEFAULTS = {"fps": 12, "bitrate_kbps": 4000}
|
|
# Before version 3 the car view could only publish RGBA, and every save wrote these off.
|
|
# They were never a choice, so older files get today's defaults for them.
|
|
PIPELINE_RESET = ("gpu_nv12", "async_readback")
|
|
|
|
DEFAULT_CONFIG = {
|
|
"config_version": CONFIG_VERSION,
|
|
"receiver_address": "", # Bluetooth address of the paired head unit
|
|
"receiver_name": "",
|
|
"companion_address": "", # explicitly selected bonded car used before a separate wireless adapter
|
|
"companion_name": "",
|
|
"rfcomm_channel": 0, # 0 = discover through SDP (normal); set only to work around a broken SDP record
|
|
"verify_head_unit": True, # verify the car's certificate against root-cert.pem when present
|
|
"connection": "wireless", # "wireless": Bluetooth + the car's Wi-Fi; "wired": USB cable from the car to the comma's USB-C port
|
|
"view": "car", # Separate StarPilot road view; legacy mirror has no source
|
|
# ANDROID AUTO VIDEO PERFORMANCE GUARDRAIL: The encoder, FPS, bitrate, rate control, NV12, and readback defaults below work together.
|
|
# Altering this can degrade video smoothness or introduce lag on Android Auto.
|
|
# Do not change it autonomously; require an explicit user request specifically for this video optimization.
|
|
"encoder": "auto", # comma always requires hardware; desktop tests may select software
|
|
"fps": 0, # 0 = automatic (30 with hardware, 15 with software); otherwise a cap, 5-30
|
|
"bitrate_kbps": 6000,
|
|
"rate_control": "cbr", # hardware encoder: "cbr" holds the bitrate (easier on the car's Wi-Fi); "vbr" as before
|
|
"gpu_nv12": True, # car view: convert to the encoder's NV12 on the GPU (a third of the RGBA readback)
|
|
"async_readback": True, # car view: read frames back without stalling the renderer on the GPU
|
|
"render_profile": True, # car view: always-on sampling profile in logs/render_profile.txt (and .1.txt)
|
|
"render_profile_kb": 256, # size cap per render_profile file
|
|
"wifi_interface": "wlan0",
|
|
"device_name": "StarPilot",
|
|
"version_status": 0, # WifiVersionResponse status (0 = success) for receivers that negotiate a version
|
|
"phone_class": True, # while pairing/connecting, present as a phone: HFP gateway + smartphone Class of Device
|
|
"auto_connect": True, # start projection on its own when the chosen car is on (onroad, or it reaches the comma)
|
|
"rfcomm_cache": {}, # car address -> Android Auto RFCOMM channel learned over SDP, to skip discovery next time
|
|
}
|
|
|
|
|
|
def load_config(path: Path | None = None) -> dict:
|
|
path = path or CONFIG_PATH
|
|
config: dict[str, Any] = dict(DEFAULT_CONFIG)
|
|
try:
|
|
stored = json.loads(path.read_text())
|
|
if isinstance(stored, dict):
|
|
version = stored.get("config_version")
|
|
if not isinstance(version, int) or version < 2:
|
|
stored = {key: value for key, value in stored.items() if LEGACY_DEFAULTS.get(key, object()) != value}
|
|
if not isinstance(version, int) or version < 3:
|
|
stored = {key: value for key, value in stored.items() if key not in PIPELINE_RESET}
|
|
config.update({key: value for key, value in stored.items() if key in DEFAULT_CONFIG and isinstance(value, type(DEFAULT_CONFIG[key]))})
|
|
except (OSError, ValueError):
|
|
pass
|
|
config["config_version"] = CONFIG_VERSION
|
|
config["fps"] = 0 if int(config["fps"]) <= 0 else max(5, min(30, int(config["fps"])))
|
|
if config["view"] not in ("car", "mirror"):
|
|
config["view"] = "car"
|
|
if config["connection"] not in ("wireless", "wired"):
|
|
config["connection"] = "wireless"
|
|
config["bitrate_kbps"] = max(1000, min(12000, int(config["bitrate_kbps"])))
|
|
if config["rate_control"] not in ("cbr", "vbr"):
|
|
config["rate_control"] = "cbr"
|
|
config["render_profile_kb"] = max(16, min(4096, int(config["render_profile_kb"])))
|
|
config["rfcomm_cache"] = {str(address).upper(): channel for address, channel in config["rfcomm_cache"].items()
|
|
if type(channel) is int and 1 <= channel <= 30}
|
|
return config
|
|
|
|
|
|
def save_config(config: dict, path: Path | None = None) -> None:
|
|
path = path or CONFIG_PATH
|
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
data = json.dumps({key: config[key] for key in DEFAULT_CONFIG if key in config}, indent=2) + "\n"
|
|
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".config-")
|
|
try:
|
|
with os.fdopen(fd, "w") as handle:
|
|
handle.write(data)
|
|
os.chmod(temporary, 0o600)
|
|
os.replace(temporary, path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(temporary)
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def expiry_warning(identity: Identity) -> str:
|
|
if 0 <= identity.days_left <= EXPIRY_WARNING_DAYS:
|
|
return f"Android Auto identity expires in {identity.days_left} days ({identity.expires[:10]}); renew it in The Galaxy"
|
|
return ""
|
|
|
|
|
|
def certificate_days_left(directory: Path | None = None, now: datetime | None = None) -> int | None:
|
|
"""Whole days until the installed certificate expires (as ``Identity.days_left``), or None if unreadable.
|
|
|
|
Reads only the certificate, never the key, so the read-only car view can call it.
|
|
"""
|
|
cert = (directory or IDENTITY_DIR) / CERT_NAME
|
|
try:
|
|
expires = _not_after(cert) if cert.is_file() else None
|
|
except (OSError, ValueError):
|
|
return None
|
|
return None if expires is None else (expires - (now or datetime.now(UTC))).days
|
|
|
|
|
|
def session_log_order(path: Path) -> tuple[int, str]:
|
|
"""Sort key for session logs, oldest first.
|
|
|
|
Files are numbered, because the clock can read a date from months ago until it syncs;
|
|
ordering by the timestamp in the name put the newest session first. Unnumbered files
|
|
are from before numbering, so they are the oldest.
|
|
"""
|
|
match = re.fullmatch(r"session-(\d{6})-.*\.jsonl", path.name)
|
|
return (int(match[1]), path.name) if match else (-1, path.name)
|
|
|
|
|
|
def timestamp() -> str:
|
|
return time.strftime("%Y%m%d-%H%M%S")
|