Files
StarPilot/openpilot/starpilot/galaxy/software_status.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

181 lines
6.7 KiB
Python

"""Bounded read-only snapshot of manager and updater status Params."""
from __future__ import annotations
import json
from datetime import UTC, datetime
import os
from pathlib import Path
import re
import stat
from openpilot.common.hardware import PC
from openpilot.common.hardware.hw import Paths
from openpilot.common.params import Params
from openpilot.starpilot.ui.brand import DISPLAY_VERSION
MAX_FIELD_BYTES = 256
PARAMS_TARGET = re.compile(r"(?:\.tmp_[A-Za-z0-9]{1,128}|[A-Za-z0-9][A-Za-z0-9._-]{0,127})\Z")
FIELDS = ("Version", "GitBranch", "GitCommit", "UpdaterState", "UpdaterTargetBranch",
"LastUpdateTime", "UpdaterLastCheckTime", "UpdaterLastFetchTime", "UpdaterFetchAvailable", "UpdateAvailable",
"UpdateFailedCount", "UpdaterFastState", "UpdaterOSUpdate")
def _fast_state(raw):
try:
value = json.loads(raw) if raw is not None else None
if not isinstance(value, dict) or value.get("version") != 1 or value.get("stage") not in (
"preparing", "fetching", "validating", "applying", "submodules", "rebooting", "complete", "error"):
return None
detail = value.get("detail")
if not isinstance(detail, str) or len(detail) > 512:
return None
return {"stage": value["stage"], "detail": detail}
except (TypeError, ValueError):
return None
class SoftwareUnavailable(Exception):
pass
def _identity(info: os.stat_result) -> tuple[int, int, int, int, int]:
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns
def _text(raw: bytes | None) -> str | None:
if raw is None:
return None
try:
value = raw.decode("utf-8")
except UnicodeDecodeError:
return None
return value if value and value.isprintable() else None
def _flag(raw: bytes | None) -> bool | None:
return None if raw not in (b"0", b"1") else raw == b"1"
def _count(raw: bytes | None) -> int | None:
if raw is None or len(raw) > 6 or not raw.isdigit():
return None
value = int(raw)
return value if value <= 999999 and str(value).encode() == raw else None
def _time(raw: bytes | None) -> str | None:
text = _text(raw)
if text is None:
return None
try:
parsed = datetime.fromisoformat(text)
if parsed.isoformat() != text:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC).isoformat().replace("+00:00", "Z")
except (ValueError, OverflowError):
return None
class SoftwareStatus:
def __init__(self, params: Params | None = None):
if params is not None:
self.directory = Path(params.get_param_path("Version")).parent
else:
# Params::Params uses OPENPILOT_PREFIX as the active link name. Resolve
# it without constructing Params, which may create a missing store.
prefix = os.environ.get("OPENPILOT_PREFIX", "d")
if not prefix or prefix in (".", "..") or any(character in prefix for character in ("/", "\\", "\0")):
raise SoftwareUnavailable
parent = Path(os.environ.get("PARAMS_ROOT", str(Path(Paths.comma_home()) / "params") if PC else "/data/params"))
self.directory = parent / prefix
def _root(self) -> Path:
return self.directory
@staticmethod
def _read(root_fd: int, key: str) -> tuple[bytes | None, tuple[int, int, int, int, int] | None]:
try:
fd = os.open(key, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=root_fd)
except FileNotFoundError:
return None, None
except OSError:
raise SoftwareUnavailable from None
try:
before = os.fstat(fd)
if not stat.S_ISREG(before.st_mode):
raise SoftwareUnavailable
limit = 8192 if key == "UpdaterFastState" else MAX_FIELD_BYTES
content = bytearray()
while len(content) <= limit:
chunk = os.read(fd, limit + 1 - len(content))
if not chunk:
break
content.extend(chunk)
after = os.fstat(fd)
try:
named = os.stat(key, dir_fd=root_fd, follow_symlinks=False)
except OSError:
raise SoftwareUnavailable from None
if _identity(before) != _identity(after) or _identity(before) != _identity(named) or len(content) > limit:
raise SoftwareUnavailable
return bytes(content), _identity(before)
except OSError:
raise SoftwareUnavailable from None
finally:
os.close(fd)
def snapshot(self) -> dict:
root = self._root()
try:
# Params stores its active directory behind a symlink. A retained device
# namespace may use a named sibling instead of Params' .tmp_ directory.
target = Path(os.readlink(root))
if not target.is_absolute() or target.parent != root.parent or not PARAMS_TARGET.fullmatch(target.name):
raise SoftwareUnavailable
fd = os.open(target, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
except OSError:
raise SoftwareUnavailable from None
try:
opened = os.fstat(fd)
captured = {key: self._read(fd, key) for key in FIELDS}
for key, (_, selected) in captured.items():
try:
named = os.stat(key, dir_fd=fd, follow_symlinks=False)
except FileNotFoundError:
if selected is None:
continue
raise SoftwareUnavailable from None
if selected is None or _identity(named) != selected or not stat.S_ISREG(named.st_mode):
raise SoftwareUnavailable
current = os.stat(root)
if Path(os.readlink(root)) != target or (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
raise SoftwareUnavailable
except OSError:
raise SoftwareUnavailable from None
finally:
os.close(fd)
data = {key: raw for key, (raw, _) in captured.items()}
try:
os_update = json.loads(data["UpdaterOSUpdate"] or b"{}")
if not isinstance(os_update, dict) or type(os_update.get("required")) is not bool or not isinstance(os_update.get("branch"), str):
os_update = {}
except (ValueError, TypeError):
os_update = {}
return {
"schemaVersion": 1,
"installed": {"version": _text(data["Version"]), "displayVersion": DISPLAY_VERSION, "branch": _text(data["GitBranch"]),
"commit": _text(data["GitCommit"])},
"updater": {"state": _text(data["UpdaterState"]), "targetBranch": _text(data["UpdaterTargetBranch"]),
"lastCheckedAt": _time(data["UpdaterLastCheckTime"]), "lastSuccessAt": _time(data["LastUpdateTime"]),
"lastFetchAt": _time(data["UpdaterLastFetchTime"]),
"targetChangeFound": _flag(data["UpdaterFetchAvailable"]),
"finalizedUpdateReady": _flag(data["UpdateAvailable"]),
"osUpdate": os_update,
"failedCount": _count(data["UpdateFailedCount"]), "fast": _fast_state(data["UpdaterFastState"])},
}