Files
StarPilot/openpilot/starpilot/analytics/settings.py
T
firestar5683 a82935f326 User interfaces and Android Auto
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: Shane Smiskol <shane@smiskol.com>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: pharmacomaniac <blittle65@gmail.com>

Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

133 lines
5.9 KiB
Python

"""Read-only scalar settings, resolved by the same owners as the settings UI."""
from __future__ import annotations
import json
import math
import re
from collections.abc import Iterable
MAX_SETTINGS = 512
MAX_BYTES = 128 * 1024
MAX_FIELDS = 2048
MAX_ENUM = 80
KEY = re.compile(r"[A-Za-z][A-Za-z0-9_:/.-]{0,95}\Z")
NUMBER = re.compile(r"-?(?:\d+(?:\.\d*)?|\.\d+)\Z")
EXCLUDED_KEYS = frozenset(("SoundPack", "torque_prepare_firestar"))
EXCLUDED_PREFIXES = ("pip:mask:", "pip:format:")
COMMAND = re.compile(r"(?:^|[:_])(?:reset|repair|adopt|editor|annotation|auto)(?:_|:|$)")
Scalar = bool | float | str
class _ReadOnlyParams:
def __init__(self, params):
self._params = params
def __getattr__(self, name):
if name not in ("get", "get_bool", "get_int", "get_float", "get_type", "get_default_value", "get_param_path", "get_params_path"):
raise AttributeError(f"Analytics cannot use Params.{name}")
return getattr(self._params, name)
def _number(value, low, high):
if not isinstance(value, str) or NUMBER.fullmatch(value) is None:
return None
number = float(value)
return number if math.isfinite(number) and low <= number <= high else None
def _value(row, value):
if not isinstance(value, str) or len(value) > MAX_ENUM:
return None
if row.choices == ("Off", "On") or not row.choices and row.default_value in ("Off", "On"):
return {"Off": False, "On": True}.get(value)
if (math.isfinite(row.minimum) and math.isfinite(row.maximum) and row.maximum > row.minimum and
(row.step > 0 or row.default_value is not None)):
numeric = _number(value, row.minimum, row.maximum)
if numeric is not None:
return numeric
if isinstance(value, str) and value in row.choices and len(value) <= MAX_ENUM:
return value
return None
def _eligible(row):
return (isinstance(row.key, str) and KEY.fullmatch(row.key) is not None and
not row.page and row.key not in EXCLUDED_KEYS and COMMAND.search(row.key) is None and
not row.key.startswith(EXCLUDED_PREFIXES) and
not row.actions and bool(row.choices or row.default_value in ("Off", "On") or row.step > 0 or
row.default_value is not None and row.maximum > row.minimum))
def _fields(rows: Iterable[tuple[str, object]]) -> dict[str, Scalar]:
records = {}
identities = {}
collisions = set()
for page, row in rows:
if not _eligible(row):
continue
identity = (page, row.key)
suffix = re.sub(r"[^A-Za-z0-9_]", "_", f"{page}_{row.key}")
if suffix in identities and identities[suffix] != identity:
collisions.add(suffix)
continue
identities[suffix] = identity
value, default = _value(row, row.value), _value(row, row.default_value)
fields = {f"valid_{suffix}": value is not None, f"default_known_{suffix}": default is not None}
if value is not None:
fields[f"setting_{suffix}"] = value
if default is not None:
fields[f"default_{suffix}"] = default
records[suffix] = fields
result: dict[str, Scalar] = {}
truncated = False
count = 0
for suffix in sorted(records):
fields = {f"valid_{suffix}": False, f"default_known_{suffix}": False} if suffix in collisions else records[suffix]
candidate = {**result, **fields}
if (count >= MAX_SETTINGS or len(candidate) > MAX_FIELDS or
len(json.dumps(candidate, allow_nan=False).encode()) > MAX_BYTES - 128):
truncated = True
break
result = candidate
count += 1
return {**result, "settings_schema_version": 1.0, "settings_count": float(count), "settings_truncated": truncated}
def _owner_rows(params, CP, profile):
from openpilot.common.hardware import HARDWARE
from openpilot.starpilot.galaxy.settings import PAGES, AuthorityContext, _qualified
from openpilot.starpilot.ui.appearance_owner import AppearanceOwner
from openpilot.starpilot.ui.display_owner import DisplayOwner
from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner
from openpilot.starpilot.ui.feature_settings_state import FeaturePage
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.pip_owner import PiPOwner
from openpilot.starpilot.ui.sentry_owner import SentryOwner
from openpilot.starpilot.ui.sounds_owner import SoundsOwner
from openpilot.starpilot.ui.vasm_owner import VASMOwner
params = _ReadOnlyParams(params)
parked = params.get("IsOffroad") is True
profile = Profile(profile) if profile is not None else Profile.COMPACT if HARDWARE.get_device_type() == "mici" else Profile.LARGE
context = AuthorityContext(parked, CP, None)
owner = FeatureSettingsOwner(params, lambda group: _qualified(context, group),
vehicle_fingerprint=lambda: str(CP.carFingerprint) if CP is not None else None,
vehicle_params=lambda: CP, show_cruise_intervals=True)
system_long = bool(CP is not None and CP.openpilotLongitudinalControl)
lateral_context = _qualified(context, "lane")
separate = {"hub", "appearance", "display", "sounds", "pip", "sentry", "vasm"}
pages = sorted(({str(page) for page in FeaturePage} | PAGES) - separate)
for page in pages:
state = owner.snapshot(page, parked=parked, system_long=system_long, lateral_context=lateral_context, metric=False)
yield from ((page, row) for row in state.rows)
for state in (AppearanceOwner(params, lambda: parked).snapshot(profile),
DisplayOwner(params, lambda: parked).snapshot(profile), SoundsOwner(params, lambda: parked).snapshot(),
PiPOwner(params, lambda: parked, lambda: False).snapshot(), SentryOwner(params, lambda: parked).snapshot(),
VASMOwner(params, lambda: parked).snapshot()):
yield from ((state.page, row) for row in state.rows)
def collect_settings(params, CP, *, profile=None) -> dict[str, Scalar]:
"""Saved/default choices only; no live actuation, source blobs, or mutations."""
return _fields(_owner_rows(params, CP, profile))