mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
a82935f326
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>
181 lines
6.7 KiB
Python
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"])},
|
|
}
|