mirror of
https://gitlvb.teallvbs.xyz/IQ.Lvbs/IQ.Pilot.git
synced 2026-07-25 13:32:04 +08:00
244 lines
8.0 KiB
Python
244 lines
8.0 KiB
Python
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
from openpilot.system.hardware.hw import Paths
|
||
|
||
_utc_offset_cache: int | None = None
|
||
|
||
|
||
def utc_offset_hours() -> int:
|
||
"""Approximate local UTC offset from the device's last GPS longitude (comma devices run in UTC
|
||
with no timezone configured). ~1h imprecise (ignores DST/political borders) but auto and close;
|
||
cached for the session."""
|
||
global _utc_offset_cache
|
||
if _utc_offset_cache is not None:
|
||
return _utc_offset_cache
|
||
offset = 0
|
||
try:
|
||
from openpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
|
||
lat, lon, _, have_fix = current_or_last_gps_position()
|
||
if have_fix:
|
||
offset = int(round(lon / 15.0)) # solar offset ≈ standard-time zone
|
||
# Crude DST: add an hour in the local warm season (northern spring–autumn, southern inverse).
|
||
month = datetime.now(timezone.utc).month
|
||
northern_dst = 3 <= month <= 10
|
||
if (lat >= 0 and northern_dst) or (lat < 0 and not northern_dst):
|
||
offset += 1
|
||
offset = max(-12, min(14, offset))
|
||
except Exception:
|
||
offset = 0
|
||
_utc_offset_cache = offset
|
||
return offset
|
||
|
||
|
||
def format_local_time(epoch_seconds: float) -> str:
|
||
if epoch_seconds <= 0:
|
||
return "Recorded route"
|
||
dt = datetime.fromtimestamp(epoch_seconds, timezone(timedelta(hours=utc_offset_hours())))
|
||
return f"{dt.strftime('%b')} {dt.day} {dt.strftime('%I:%M %p').lstrip('0').lower()}"
|
||
|
||
# Dongleless on-device segment directory, e.g. "00000051--3141cf1d76--6".
|
||
# The stock tools/lib Route + RE parsers require a 16-hex dongle id + '|' delimiter (cloud naming)
|
||
# and reject these, which is why the Routes page was empty. We parse them directly instead.
|
||
SEGMENT_DIR_RE = re.compile(r"^(?P<route>[0-9a-f]{8}--[0-9a-z]{10})--(?P<seg>\d+)$")
|
||
|
||
# Logical camera -> on-disk VIDEO file. Order defines the player's selector order.
|
||
# Road uses qcamera.ts (H.264, ~1052x660): small enough to software-decode at hundreds of fps and
|
||
# it carries the audio track. Wide/Driver are full-res HEVC (hardware-decoded offroad). The
|
||
# streaming decoder handles both containers via ffmpeg's concat demuxer.
|
||
CAMERA_FILES: dict[str, str] = {
|
||
"road": "qcamera.ts",
|
||
"wide": "ecamera.hevc",
|
||
"driver": "dcamera.hevc",
|
||
}
|
||
CAMERA_LABELS: dict[str, str] = {
|
||
"road": "Road Cam",
|
||
"wide": "Wide Cam",
|
||
"driver": "Driver Cam",
|
||
}
|
||
# The road preview (qcamera.ts) is the only file with an audio track, and the smaller
|
||
# cloud-streamable road video. It is not a FrameReader source (TS container).
|
||
AUDIO_CAMERA_FILE = "qcamera.ts"
|
||
NOMINAL_SEGMENT_SECONDS = 60.0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LocalRouteInfo:
|
||
name: str
|
||
label: str
|
||
subtitle: str
|
||
segment_count: int
|
||
cameras: tuple[str, ...]
|
||
modified_at: float
|
||
duration_s: float
|
||
distance_miles: float | None = None
|
||
|
||
|
||
def _scan_segments(root: Path) -> dict[str, dict[int, Path]]:
|
||
"""Group dongleless segment dirs under `root` by route id -> {segment_num: dir}."""
|
||
routes: dict[str, dict[int, Path]] = {}
|
||
if not root.exists():
|
||
return routes
|
||
for child in root.iterdir():
|
||
try:
|
||
if not child.is_dir():
|
||
continue
|
||
except OSError:
|
||
continue
|
||
m = SEGMENT_DIR_RE.match(child.name)
|
||
if m is None:
|
||
continue
|
||
routes.setdefault(m.group("route"), {})[int(m.group("seg"))] = child
|
||
return routes
|
||
|
||
|
||
def _cameras_present(seg_dir: Path) -> tuple[str, ...]:
|
||
present = []
|
||
for cam, filename in CAMERA_FILES.items():
|
||
try:
|
||
if (seg_dir / filename).exists():
|
||
present.append(cam)
|
||
except OSError:
|
||
continue
|
||
return tuple(present)
|
||
|
||
|
||
def _format_route_time(ts: float) -> str:
|
||
return format_local_time(ts)
|
||
|
||
|
||
def _format_duration(seconds: float) -> str:
|
||
s = max(0, int(round(seconds)))
|
||
h, rem = divmod(s, 3600)
|
||
m, sec = divmod(rem, 60)
|
||
if h:
|
||
return f"{h}h {m:02d}m"
|
||
return f"{m}:{sec:02d}"
|
||
|
||
|
||
def local_route_camera_paths(route_name: str, camera: str = "road", log_root: str | Path | None = None) -> list[str]:
|
||
"""Ordered per-segment file paths for one camera of a local route (for FrameReader)."""
|
||
root = Path(log_root or Paths.log_root())
|
||
segments = _scan_segments(root).get(route_name, {})
|
||
filename = CAMERA_FILES.get(camera, CAMERA_FILES["road"])
|
||
paths: list[str] = []
|
||
for seg_num in sorted(segments):
|
||
path = segments[seg_num] / filename
|
||
try:
|
||
if path.exists():
|
||
paths.append(path.as_posix())
|
||
except OSError:
|
||
continue
|
||
return paths
|
||
|
||
|
||
def local_route_audio_paths(route_name: str, log_root: str | Path | None = None) -> list[str]:
|
||
"""Ordered per-segment qcamera.ts paths (the only files with an audio track)."""
|
||
root = Path(log_root or Paths.log_root())
|
||
segments = _scan_segments(root).get(route_name, {})
|
||
paths: list[str] = []
|
||
for seg_num in sorted(segments):
|
||
path = segments[seg_num] / AUDIO_CAMERA_FILE
|
||
try:
|
||
if path.exists():
|
||
paths.append(path.as_posix())
|
||
except OSError:
|
||
continue
|
||
return paths
|
||
|
||
|
||
def local_route_qlog_paths(route_name: str, log_root: str | Path | None = None) -> list[str]:
|
||
"""Ordered per-segment qlog paths for a local route."""
|
||
root = Path(log_root or Paths.log_root())
|
||
segments = _scan_segments(root).get(route_name, {})
|
||
paths: list[str] = []
|
||
for seg_num in sorted(segments):
|
||
for name in ("qlog.zst", "qlog"):
|
||
path = segments[seg_num] / name
|
||
try:
|
||
if path.exists():
|
||
paths.append(path.as_posix())
|
||
break
|
||
except OSError:
|
||
continue
|
||
return paths
|
||
|
||
|
||
def compute_route_distance_miles(route_name: str, log_root: str | Path | None = None) -> float:
|
||
"""Total driven distance (miles) by integrating carState.vEgo over the route's qlogs.
|
||
|
||
Uses vEgo (not GPS) so it still works on cars with broken GPS. Decimated qlog cadence is
|
||
plenty for a distance total. This reads every segment's qlog, so callers should run it off the
|
||
UI thread."""
|
||
from openpilot.tools.lib.logreader import LogReader
|
||
|
||
total_m = 0.0
|
||
for qlog_path in local_route_qlog_paths(route_name, log_root):
|
||
last_t: float | None = None
|
||
try:
|
||
for msg in LogReader(qlog_path):
|
||
if msg.which() != "carState":
|
||
continue
|
||
t = msg.logMonoTime * 1e-9
|
||
v = float(msg.carState.vEgo)
|
||
# Guard against segment boundaries / gaps: only integrate contiguous samples.
|
||
if last_t is not None and 0.0 < t - last_t < 1.0:
|
||
total_m += v * (t - last_t)
|
||
last_t = t
|
||
except Exception:
|
||
continue
|
||
return total_m * 0.000621371
|
||
|
||
|
||
def get_local_route(route_name: str, log_root: str | Path | None = None) -> LocalRouteInfo | None:
|
||
root = Path(log_root or Paths.log_root())
|
||
segments = _scan_segments(root).get(route_name)
|
||
if not segments:
|
||
return None
|
||
return _build_info(route_name, segments)
|
||
|
||
|
||
def _build_info(route_name: str, segments: dict[int, Path]) -> LocalRouteInfo:
|
||
seg_nums = sorted(segments)
|
||
mtimes = []
|
||
for seg_num in seg_nums:
|
||
try:
|
||
mtimes.append(segments[seg_num].stat().st_mtime)
|
||
except OSError:
|
||
pass
|
||
modified_at = max(mtimes) if mtimes else 0.0
|
||
started_at = min(mtimes) if mtimes else 0.0
|
||
|
||
# Cameras available anywhere in the route (union across segments).
|
||
cameras: list[str] = []
|
||
for cam in CAMERA_FILES:
|
||
if any((segments[s] / CAMERA_FILES[cam]).exists() for s in seg_nums):
|
||
cameras.append(cam)
|
||
|
||
segment_count = len(seg_nums)
|
||
duration_s = segment_count * NOMINAL_SEGMENT_SECONDS
|
||
cam_names = ", ".join(CAMERA_LABELS[c] for c in cameras) if cameras else "no cameras"
|
||
subtitle = f"{_format_duration(duration_s)} · {cam_names}"
|
||
|
||
return LocalRouteInfo(
|
||
name=route_name,
|
||
label=_format_route_time(started_at),
|
||
subtitle=subtitle,
|
||
segment_count=segment_count,
|
||
cameras=tuple(cameras),
|
||
modified_at=modified_at,
|
||
duration_s=duration_s,
|
||
)
|
||
|
||
|
||
def list_local_routes(log_root: str | Path | None = None, limit: int = 100) -> list[LocalRouteInfo]:
|
||
root = Path(log_root or Paths.log_root())
|
||
routes = _scan_segments(root)
|
||
infos = [_build_info(name, segs) for name, segs in routes.items()]
|
||
infos.sort(key=lambda info: info.modified_at, reverse=True)
|
||
return infos[:limit]
|