IQ.Pilot Release Commit @ b6534c0
This commit is contained in:
18
iqpilot/selfdrive/ui/lib/api_helpers.py
Normal file
18
iqpilot/selfdrive/ui/lib/api_helpers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from iqpilot.common.api import Api
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
|
||||
TOKEN_EXPIRY_HOURS = 2
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_token(dongle_id: str, t: int):
|
||||
if not system_time_valid():
|
||||
raise RuntimeError("System time is not valid, cannot generate token")
|
||||
|
||||
return Api(dongle_id).get_token(expiry_hours=TOKEN_EXPIRY_HOURS)
|
||||
|
||||
|
||||
def get_token(dongle_id: str):
|
||||
return _get_token(dongle_id, int(time.monotonic() / (TOKEN_EXPIRY_HOURS / 2 * 60 * 60)))
|
||||
101
iqpilot/selfdrive/ui/lib/cloud_routes_shim.py
Normal file
101
iqpilot/selfdrive/ui/lib/cloud_routes_shim.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Public, konn3kt-agnostic shim to the private cloud route client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cache
|
||||
|
||||
UPLOAD_NONE = "none"
|
||||
UPLOAD_UPLOADING = "uploading"
|
||||
UPLOAD_UPLOADED = "uploaded"
|
||||
|
||||
@cache
|
||||
def _load_cloud():
|
||||
try:
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
return import_verified_module("iqpilot_hephaestusd_private",
|
||||
"iqpilot_private.konn3kt.hephaestus.cloud_routes")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def cloud_available() -> bool:
|
||||
return _load_cloud() is not None
|
||||
|
||||
|
||||
def get_dongle_id() -> str | None:
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return None
|
||||
try:
|
||||
return cloud.get_dongle_id()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def list_cloud_routes(dongle_id: str) -> list:
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return []
|
||||
try:
|
||||
return cloud.list_cloud_routes(dongle_id)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def cloud_route_road_segments(dongle_id: str, fullname: str) -> list:
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return []
|
||||
try:
|
||||
return cloud.cloud_route_road_segments(dongle_id, fullname)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def cloud_route_camera_urls(dongle_id: str, fullname: str, camera: str = "road") -> list:
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return []
|
||||
try:
|
||||
return cloud.cloud_route_camera_urls(dongle_id, fullname, camera)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def request_mp4_conversion(dongle_id: str, segment_canonical_name: str, camera: str):
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return None
|
||||
try:
|
||||
return cloud.request_mp4_conversion(dongle_id, segment_canonical_name, camera)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_mp4_conversion(dongle_id: str, segment_canonical_name: str, camera: str):
|
||||
cloud = _load_cloud()
|
||||
if cloud is None:
|
||||
return None
|
||||
try:
|
||||
return cloud.get_mp4_conversion(dongle_id, segment_canonical_name, camera)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def merge_routes(local_routes: list, cloud_routes: list) -> list:
|
||||
cloud = _load_cloud()
|
||||
if cloud is not None:
|
||||
try:
|
||||
return cloud.merge_routes(local_routes, cloud_routes)
|
||||
except Exception:
|
||||
pass
|
||||
return [_LocalOnly(local) for local in local_routes]
|
||||
|
||||
|
||||
class _LocalOnly:
|
||||
def __init__(self, local):
|
||||
self.name = local.name
|
||||
self.local = local
|
||||
self.cloud = None
|
||||
self.is_local = True
|
||||
self.is_cloud = False
|
||||
self.upload_state = UPLOAD_NONE
|
||||
243
iqpilot/selfdrive/ui/lib/local_routes.py
Normal file
243
iqpilot/selfdrive/ui/lib/local_routes.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.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 iqpilot.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 iqpilot.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]
|
||||
35
iqpilot/selfdrive/ui/lib/motd.py
Normal file
35
iqpilot/selfdrive/ui/lib/motd.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
FALLBACK_MOTDS = ("Drive safely. Stay focused.",)
|
||||
_BUNDLE_NAME = "iqpilot_hephaestusd_private"
|
||||
_MODULE_NAME = "iqpilot_private.konn3kt.hephaestus.motd"
|
||||
|
||||
|
||||
def _dongle_id() -> str | None:
|
||||
try:
|
||||
from iqpilot.common.params import Params
|
||||
return Params().get("DongleId", encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _clean_messages(messages: object) -> list[str]:
|
||||
if not isinstance(messages, Iterable) or isinstance(messages, (str, bytes)):
|
||||
return []
|
||||
return [message.strip() for message in messages if isinstance(message, str) and message.strip()]
|
||||
|
||||
|
||||
def load_motds(dongle_id: str | None = None) -> list[str]:
|
||||
try:
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
module = import_verified_module(_BUNDLE_NAME, _MODULE_NAME)
|
||||
messages = module.messages_for_dongle(_dongle_id() if dongle_id is None else dongle_id)
|
||||
cleaned = _clean_messages(messages)
|
||||
if cleaned:
|
||||
return cleaned
|
||||
except Exception:
|
||||
pass
|
||||
return list(FALLBACK_MOTDS)
|
||||
151
iqpilot/selfdrive/ui/lib/nav_helpers.py
Normal file
151
iqpilot/selfdrive/ui/lib/nav_helpers.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import json
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
|
||||
_MAPBOX_DEFAULT_HELPER_UNAVAILABLE = False
|
||||
_GPS_SERVICES = ("gpsLocationExternal", "gpsLocation")
|
||||
_POSITION_PARAM_KEYS = ("LastGPSPosition", "LastGPSPositionIQLoc")
|
||||
|
||||
|
||||
def _decode_param(value) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="ignore").strip()
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_mapbox_token(params: Params | None = None) -> str:
|
||||
global _MAPBOX_DEFAULT_HELPER_UNAVAILABLE
|
||||
|
||||
params = params or Params()
|
||||
token = _decode_param(params.get("MapboxToken"))
|
||||
if token:
|
||||
return token
|
||||
|
||||
if _MAPBOX_DEFAULT_HELPER_UNAVAILABLE:
|
||||
return ""
|
||||
|
||||
try:
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
runtime_common = import_verified_module("iqpilot_navd_private", "iqpilot_private.navd.runtime_common")
|
||||
except Exception:
|
||||
_MAPBOX_DEFAULT_HELPER_UNAVAILABLE = True
|
||||
return ""
|
||||
|
||||
for args in ((params,), ()):
|
||||
try:
|
||||
token = _decode_param(runtime_common.ensure_default_mapbox_token(*args))
|
||||
except TypeError:
|
||||
continue
|
||||
except Exception:
|
||||
token = ""
|
||||
|
||||
if not token:
|
||||
token = _decode_param(params.get("MapboxToken"))
|
||||
if token:
|
||||
return token
|
||||
|
||||
return _decode_param(params.get("MapboxToken"))
|
||||
|
||||
|
||||
def has_mapbox_token(params: Params | None = None) -> bool:
|
||||
return bool(resolve_mapbox_token(params))
|
||||
|
||||
|
||||
def _valid_lat_lon(lat: float, lon: float) -> bool:
|
||||
return abs(lat) <= 90.0 and abs(lon) <= 180.0 and (abs(lat) > 1e-4 or abs(lon) > 1e-4)
|
||||
|
||||
|
||||
def _float_field(data: dict, *names: str) -> float:
|
||||
for name in names:
|
||||
if name in data:
|
||||
return float(data.get(name) or 0.0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _position_from_json(raw) -> tuple[float, float, float, bool]:
|
||||
text = _decode_param(raw)
|
||||
if not text:
|
||||
return 0.0, 0.0, 0.0, False
|
||||
try:
|
||||
data = json.loads(text)
|
||||
if not isinstance(data, dict):
|
||||
return 0.0, 0.0, 0.0, False
|
||||
lat = _float_field(data, "latitude", "lat")
|
||||
lon = _float_field(data, "longitude", "lon", "lng")
|
||||
if _valid_lat_lon(lat, lon):
|
||||
return lat, lon, _float_field(data, "bearing", "bearingDeg"), True
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
return 0.0, 0.0, 0.0, False
|
||||
|
||||
|
||||
def _position_from_msg(msg, lat_name: str = "latitude", lon_name: str = "longitude",
|
||||
bearing_name: str = "bearingDeg") -> tuple[float, float, float, bool]:
|
||||
try:
|
||||
lat = float(getattr(msg, lat_name, 0.0))
|
||||
lon = float(getattr(msg, lon_name, 0.0))
|
||||
if _valid_lat_lon(lat, lon):
|
||||
return lat, lon, float(getattr(msg, bearing_name, 0.0)), True
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0, 0.0, 0.0, False
|
||||
|
||||
|
||||
def _position_from_params(params: Params) -> tuple[float, float, float, bool]:
|
||||
for key in _POSITION_PARAM_KEYS:
|
||||
lat, lon, bearing, valid = _position_from_json(params.get(key))
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
return 0.0, 0.0, 0.0, False
|
||||
|
||||
|
||||
def current_or_last_gps_position(params: Params | None = None) -> tuple[float, float, float, bool]:
|
||||
# ui_state is imported lazily AND guarded: night-mode init constructs the ui_state singleton,
|
||||
# which calls in here before the module finishes importing. In that window the import raises
|
||||
# (partially initialized module) — fall back to the params path (Night Mode passes self.params),
|
||||
# since there's no live GPS during boot anyway.
|
||||
try:
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state
|
||||
except ImportError:
|
||||
ui_state = None
|
||||
|
||||
if ui_state is not None:
|
||||
for service in _GPS_SERVICES:
|
||||
try:
|
||||
lat, lon, bearing, valid = _position_from_msg(ui_state.sm[service])
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
lat, lon, bearing, valid = _position_from_msg(
|
||||
ui_state.sm["iqNavRenderState"],
|
||||
lat_name="currentLatitude",
|
||||
lon_name="currentLongitude",
|
||||
bearing_name="bearingDeg",
|
||||
)
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
explicit_params = params is not None
|
||||
params = params or (ui_state.params if ui_state is not None else Params())
|
||||
lat, lon, bearing, valid = _position_from_params(params)
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
|
||||
if not explicit_params and platform.system() != "Darwin" and Path("/dev/shm/params/d").exists():
|
||||
try:
|
||||
lat, lon, bearing, valid = _position_from_params(Params("/dev/shm/params"))
|
||||
if valid:
|
||||
return lat, lon, bearing, True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0.0, 0.0, 0.0, False
|
||||
319
iqpilot/selfdrive/ui/lib/nav_search.py
Normal file
319
iqpilot/selfdrive/ui/lib/nav_search.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Destination search + persistence for the offroad Navigate screen.
|
||||
|
||||
Uses Mapbox's Search Box API (the same public Mapbox service the nav map preview already calls) for
|
||||
POI-and-address autocomplete biased to the device's location — so "Walmart" returns the nearest
|
||||
Walmart store, not a street named Walmart, and partial addresses complete as you type. Selecting a
|
||||
result writes NavigationDestination, which navd picks up to build the route.
|
||||
|
||||
Home/Work/Recents live in a small JSON file on /data (persistent, no new param key needed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
import requests
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.selfdrive.ui.lib.nav_helpers import resolve_mapbox_token, current_or_last_gps_position
|
||||
|
||||
SEARCHBOX = "https://api.mapbox.com/search/searchbox/v1"
|
||||
FAVORITES_PATH = "/data/nav_favorites.json"
|
||||
MAX_RESULTS = 6
|
||||
MAX_RECENTS = 8
|
||||
|
||||
|
||||
def _load_amap_client():
|
||||
try:
|
||||
from iqpilot.system.proprietary_runtime._verified_import import import_verified_module
|
||||
return import_verified_module("iqpilot_navd_private", "iqpilot_private.navd.amap_client")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_amap_client = _load_amap_client()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
name: str
|
||||
address: str
|
||||
mapbox_id: str = ""
|
||||
distance_m: float | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
provider: str = "mapbox"
|
||||
|
||||
@property
|
||||
def has_coords(self) -> bool:
|
||||
return self.lat is not None and self.lon is not None
|
||||
|
||||
|
||||
class NavSearch:
|
||||
"""Threaded, debounced Search Box autocomplete. The UI calls search() as the user types and reads
|
||||
results()/searching each frame; a stale query's results are dropped so only the latest shows."""
|
||||
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self._session = str(uuid.uuid4())
|
||||
self._lock = threading.Lock()
|
||||
self._results: list[SearchResult] = []
|
||||
self._seq = 0
|
||||
self._last_query = ""
|
||||
self._searching = False
|
||||
self._amap_adcode = ""
|
||||
|
||||
def new_session(self) -> None:
|
||||
# A Search Box "session" groups suggest+retrieve for billing; start one per search visit.
|
||||
self._session = str(uuid.uuid4())
|
||||
with self._lock:
|
||||
self._results = []
|
||||
self._last_query = ""
|
||||
self._amap_adcode = ""
|
||||
|
||||
def results(self) -> list[SearchResult]:
|
||||
with self._lock:
|
||||
return list(self._results)
|
||||
|
||||
@property
|
||||
def searching(self) -> bool:
|
||||
return self._searching
|
||||
|
||||
def search(self, query: str) -> None:
|
||||
query = query.strip()
|
||||
if query == self._last_query:
|
||||
return
|
||||
self._last_query = query
|
||||
self._seq += 1
|
||||
seq = self._seq
|
||||
if len(query) < 2:
|
||||
with self._lock:
|
||||
self._results = []
|
||||
self._searching = False
|
||||
return
|
||||
self._searching = True
|
||||
threading.Thread(target=self._do_search, args=(query, seq), daemon=True).start()
|
||||
|
||||
def _do_search(self, query: str, seq: int) -> None:
|
||||
try:
|
||||
lat, lon, _, fix = current_or_last_gps_position(self._params)
|
||||
position = SimpleNamespace(latitude=lat, longitude=lon) if fix else None
|
||||
use_amap = _amap_client is not None and _amap_client.is_mainland_china_configured(self._params, position)
|
||||
if use_amap:
|
||||
key = _amap_client.get_key(self._params)
|
||||
if not self._amap_adcode and position is not None:
|
||||
self._amap_adcode = _amap_client.reverse_adcode(position, key)
|
||||
amap_results = _amap_client.autocomplete(
|
||||
query,
|
||||
key,
|
||||
city=self._amap_adcode,
|
||||
position=position,
|
||||
)
|
||||
try:
|
||||
self._params.put("AmapStatus", _amap_client.status())
|
||||
except Exception:
|
||||
pass
|
||||
results = [
|
||||
SearchResult(
|
||||
name=item.name,
|
||||
address=item.address,
|
||||
mapbox_id=item.provider_id,
|
||||
lat=item.latitude,
|
||||
lon=item.longitude,
|
||||
provider="amap",
|
||||
)
|
||||
for item in amap_results[:MAX_RESULTS]
|
||||
]
|
||||
if seq == self._seq:
|
||||
with self._lock:
|
||||
self._results = results
|
||||
return
|
||||
|
||||
token = resolve_mapbox_token(self._params)
|
||||
params = {"q": query, "access_token": token, "session_token": self._session,
|
||||
"limit": MAX_RESULTS, "language": "en"}
|
||||
if fix:
|
||||
params["proximity"] = f"{lon},{lat}"
|
||||
resp = requests.get(f"{SEARCHBOX}/suggest", params=params, timeout=8)
|
||||
resp.raise_for_status()
|
||||
results = []
|
||||
for s in resp.json().get("suggestions", []):
|
||||
mid = s.get("mapbox_id")
|
||||
addr = s.get("full_address") or s.get("place_formatted") or ""
|
||||
# Skip brand/category refinement rows (e.g. "Walmart · Brand") — not a single routable place.
|
||||
if not mid or not addr or s.get("feature_type") in ("category", "brand"):
|
||||
continue
|
||||
results.append(SearchResult(name=s.get("name", ""), address=addr, mapbox_id=mid,
|
||||
distance_m=s.get("distance")))
|
||||
if seq == self._seq:
|
||||
with self._lock:
|
||||
self._results = results
|
||||
except Exception as e:
|
||||
cloudlog.event("nav_search.suggest_failed", error=str(e))
|
||||
if seq == self._seq:
|
||||
with self._lock:
|
||||
self._results = []
|
||||
finally:
|
||||
if seq == self._seq:
|
||||
self._searching = False
|
||||
|
||||
def retrieve(self, result: SearchResult) -> SearchResult | None:
|
||||
"""Resolve a suggestion's coordinates (Search Box suggest omits them by design)."""
|
||||
if result.has_coords:
|
||||
return result
|
||||
try:
|
||||
if result.provider == "amap":
|
||||
if _amap_client is None:
|
||||
return None
|
||||
item = _amap_client.place_detail(result.mapbox_id, _amap_client.get_key(self._params))
|
||||
try:
|
||||
self._params.put("AmapStatus", _amap_client.status())
|
||||
except Exception:
|
||||
pass
|
||||
if item is None or item.latitude is None or item.longitude is None:
|
||||
return None
|
||||
result.lat, result.lon = item.latitude, item.longitude
|
||||
result.name = item.name or result.name
|
||||
result.address = item.address or result.address
|
||||
return result
|
||||
|
||||
token = resolve_mapbox_token(self._params)
|
||||
resp = requests.get(f"{SEARCHBOX}/retrieve/{result.mapbox_id}",
|
||||
params={"access_token": token, "session_token": self._session}, timeout=8)
|
||||
resp.raise_for_status()
|
||||
feats = resp.json().get("features", [])
|
||||
if not feats:
|
||||
return None
|
||||
coords = feats[0]["geometry"]["coordinates"]
|
||||
props = feats[0].get("properties", {})
|
||||
result.lon, result.lat = float(coords[0]), float(coords[1])
|
||||
result.name = props.get("name") or result.name
|
||||
result.address = props.get("full_address") or result.address
|
||||
return result
|
||||
except Exception as e:
|
||||
cloudlog.event("nav_search.retrieve_failed", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
# --- destination + favorites persistence -------------------------------------------------------
|
||||
|
||||
def set_destination(lat: float, lon: float, name: str) -> None:
|
||||
"""Hand a destination to navd (it routes off NavigationDestination). Mirrors hephaestusd's
|
||||
setNavDestination so a fresh route is always recomputed."""
|
||||
params = Params()
|
||||
params.remove("AthenaNavigationRoute")
|
||||
params.put_bool("NavigationActive", False)
|
||||
# NavigationDestination is a JSON-typed param: pass the object, not a pre-serialized string.
|
||||
params.put("NavigationDestination", {"latitude": float(lat), "longitude": float(lon), "name": name or ""})
|
||||
|
||||
|
||||
def cancel_navigation() -> None:
|
||||
"""Clear the active route/destination so navd stops navigating."""
|
||||
params = Params()
|
||||
params.remove("NavigationDestination")
|
||||
params.remove("AthenaNavigationRoute")
|
||||
params.put_bool("NavigationActive", False)
|
||||
|
||||
|
||||
def has_active_destination() -> bool:
|
||||
try:
|
||||
return bool(Params().get("NavigationDestination"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _load_favorites() -> dict:
|
||||
try:
|
||||
with open(FAVORITES_PATH) as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_favorites(data: dict) -> None:
|
||||
try:
|
||||
tmp = FAVORITES_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(data, f)
|
||||
import os
|
||||
os.replace(tmp, FAVORITES_PATH)
|
||||
except Exception as e:
|
||||
cloudlog.event("nav_search.save_favorites_failed", error=str(e))
|
||||
|
||||
|
||||
def _place_to_result(place: dict | None) -> SearchResult | None:
|
||||
if not place:
|
||||
return None
|
||||
try:
|
||||
return SearchResult(name=place.get("name", ""), address=place.get("address", ""),
|
||||
lat=float(place["lat"]), lon=float(place["lon"]))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_home() -> SearchResult | None:
|
||||
return _place_to_result(_load_favorites().get("home"))
|
||||
|
||||
|
||||
def get_work() -> SearchResult | None:
|
||||
return _place_to_result(_load_favorites().get("work"))
|
||||
|
||||
|
||||
def _place_dict(r: SearchResult) -> dict:
|
||||
return {"name": r.name, "address": r.address, "lat": r.lat, "lon": r.lon}
|
||||
|
||||
|
||||
def save_home(r: SearchResult) -> None:
|
||||
data = _load_favorites()
|
||||
data["home"] = _place_dict(r)
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def save_work(r: SearchResult) -> None:
|
||||
data = _load_favorites()
|
||||
data["work"] = _place_dict(r)
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def remove_home() -> None:
|
||||
data = _load_favorites()
|
||||
data.pop("home", None)
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def remove_work() -> None:
|
||||
data = _load_favorites()
|
||||
data.pop("work", None)
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def remove_recent(r: SearchResult) -> None:
|
||||
data = _load_favorites()
|
||||
data["recents"] = [p for p in data.get("recents", [])
|
||||
if not (abs(p.get("lat", 0) - (r.lat or 0)) < 1e-5 and abs(p.get("lon", 0) - (r.lon or 0)) < 1e-5)]
|
||||
_save_favorites(data)
|
||||
|
||||
|
||||
def get_recents() -> list[SearchResult]:
|
||||
out = []
|
||||
for p in _load_favorites().get("recents", []):
|
||||
r = _place_to_result(p)
|
||||
if r is not None:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def add_recent(r: SearchResult) -> None:
|
||||
if not r.has_coords:
|
||||
return
|
||||
data = _load_favorites()
|
||||
recents = [p for p in data.get("recents", [])
|
||||
if not (abs(p.get("lat", 0) - r.lat) < 1e-5 and abs(p.get("lon", 0) - r.lon) < 1e-5)]
|
||||
recents.insert(0, _place_dict(r))
|
||||
data["recents"] = recents[:MAX_RECENTS]
|
||||
_save_favorites(data)
|
||||
144
iqpilot/selfdrive/ui/lib/prime_state.py
Normal file
144
iqpilot/selfdrive/ui/lib/prime_state.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from enum import IntEnum
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
from iqpilot.common.swaglog import cloudlog
|
||||
from iqpilot.common.time_helpers import system_time_valid
|
||||
from iqpilot.konn3kt.cloud_client import Konn3ktApi
|
||||
from iqpilot.konn3kt.registration import UNREGISTERED_DONGLE_ID, get_cached_dongle_id, ensure_dev_pairing_identity
|
||||
from iqpilot.system.hardware import PC
|
||||
from iqpilot.system.hardware.hw import Paths
|
||||
|
||||
|
||||
class PairState(IntEnum):
|
||||
UNKNOWN = -2
|
||||
UNPAIRED = -1
|
||||
PAIRED = 0
|
||||
|
||||
|
||||
class PrimeState:
|
||||
FETCH_INTERVAL = 5.0 # seconds between konn3kt pairing checks
|
||||
API_TIMEOUT = 10.0 # seconds for konn3kt API requests
|
||||
SLEEP_INTERVAL = 0.5 # seconds to sleep between checks in the worker thread
|
||||
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
self._lock = threading.Lock()
|
||||
# Must be computed at runtime (OPENPILOT_PREFIX can change paths).
|
||||
# Keep a writable fallback in /tmp in case /persist becomes read-only.
|
||||
self._konn3kt_state_paths = [
|
||||
Path(Paths.persist_root()) / "comma" / "konn3kt_prime_type",
|
||||
Path(Paths.config_root()) / "konn3kt_prime_type",
|
||||
]
|
||||
|
||||
if PC and os.getenv("KONN3KT_DEV_PAIRING") == "1":
|
||||
try:
|
||||
ensure_dev_pairing_identity(self._params, force_reset=os.getenv("KONN3KT_DEV_PAIRING_RESET") == "1")
|
||||
self._write_cached_state(PairState.UNPAIRED)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"dev pairing identity setup failed: {e}")
|
||||
|
||||
self.pair_state: PairState = self._load_initial_state()
|
||||
|
||||
self._running = False
|
||||
self._thread = None
|
||||
|
||||
def _write_cached_state(self, pair_state: PairState) -> None:
|
||||
payload = str(int(pair_state))
|
||||
for path in self._konn3kt_state_paths:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(payload)
|
||||
return
|
||||
except OSError:
|
||||
continue
|
||||
except Exception:
|
||||
cloudlog.exception("failed to write konn3kt pairing cache")
|
||||
return
|
||||
cloudlog.warning("failed to write konn3kt pairing cache to any path")
|
||||
|
||||
def _coerce(self, value: int | None) -> PairState:
|
||||
if value is None:
|
||||
return PairState.UNKNOWN
|
||||
if value >= 0:
|
||||
return PairState.PAIRED
|
||||
if value == PairState.UNPAIRED:
|
||||
return PairState.UNPAIRED
|
||||
return PairState.UNKNOWN
|
||||
|
||||
def _load_initial_state(self) -> PairState:
|
||||
env_val = os.getenv("PRIME_TYPE")
|
||||
if env_val is not None:
|
||||
try:
|
||||
return self._coerce(int(env_val))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
for path in self._konn3kt_state_paths:
|
||||
try:
|
||||
if path.is_file():
|
||||
return self._coerce(int(path.read_text().strip()))
|
||||
except Exception:
|
||||
cloudlog.exception("failed to read konn3kt pairing cache")
|
||||
return PairState.UNKNOWN
|
||||
|
||||
def _refresh_pair_status(self) -> None:
|
||||
dongle_id = get_cached_dongle_id(self._params, prefer_readonly=True)
|
||||
if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
|
||||
return
|
||||
|
||||
# the JWT can't be minted until the clock is NTP-synced; at boot skip
|
||||
# quietly instead of error-spamming every retry
|
||||
if not system_time_valid():
|
||||
return
|
||||
|
||||
try:
|
||||
api = Konn3ktApi(dongle_id)
|
||||
resp = api.get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=api.get_token())
|
||||
if resp.status_code == 200:
|
||||
paired = bool(resp.json().get("is_paired", False))
|
||||
self.set_paired(paired)
|
||||
elif resp.status_code == 404:
|
||||
self.set_paired(False)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"failed to fetch konn3kt pairing status: {e}")
|
||||
|
||||
def set_paired(self, paired: bool) -> None:
|
||||
new_state = PairState.PAIRED if paired else PairState.UNPAIRED
|
||||
with self._lock:
|
||||
if new_state != self.pair_state:
|
||||
self.pair_state = new_state
|
||||
self._write_cached_state(new_state)
|
||||
cloudlog.info(f"konn3kt pairing updated to {new_state}")
|
||||
|
||||
def _worker_thread(self) -> None:
|
||||
from iqpilot.selfdrive.ui.ui_state import ui_state, device
|
||||
while self._running:
|
||||
if not ui_state.started and device._awake:
|
||||
self._refresh_pair_status()
|
||||
|
||||
for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)):
|
||||
if not self._running:
|
||||
break
|
||||
time.sleep(self.SLEEP_INTERVAL)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._worker_thread, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
def is_paired(self) -> bool:
|
||||
with self._lock:
|
||||
return self.pair_state > PairState.UNPAIRED
|
||||
|
||||
def __del__(self):
|
||||
self.stop()
|
||||
36
iqpilot/selfdrive/ui/lib/solar.py
Normal file
36
iqpilot/selfdrive/ui/lib/solar.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
|
||||
_J2000 = datetime(2000, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
SUNSET_ELEVATION_DEG = -0.833 # standard sunset/sunrise threshold, corrected for atmospheric refraction
|
||||
|
||||
|
||||
def sun_elevation_deg(lat: float, lon: float, dt_utc: datetime | None = None) -> float:
|
||||
"""Low-precision solar elevation angle (accurate to well under a degree), per the
|
||||
standard Meeus-derived approximation. lat/lon in degrees (lon positive east)."""
|
||||
dt_utc = dt_utc or datetime.now(UTC)
|
||||
n = (dt_utc - _J2000).total_seconds() / 86400.0
|
||||
|
||||
mean_lon = math.radians((280.460 + 0.9856474 * n) % 360)
|
||||
mean_anomaly = math.radians((357.528 + 0.9856003 * n) % 360)
|
||||
ecliptic_lon = (mean_lon + math.radians(1.915) * math.sin(mean_anomaly)
|
||||
+ math.radians(0.020) * math.sin(2 * mean_anomaly))
|
||||
obliquity = math.radians(23.439 - 0.0000004 * n)
|
||||
|
||||
declination = math.asin(math.sin(obliquity) * math.sin(ecliptic_lon))
|
||||
right_ascension = math.atan2(math.cos(obliquity) * math.sin(ecliptic_lon), math.cos(ecliptic_lon))
|
||||
|
||||
equation_of_time_deg = math.degrees(mean_lon - right_ascension)
|
||||
equation_of_time_deg = (equation_of_time_deg + 180) % 360 - 180
|
||||
|
||||
utc_hours = dt_utc.hour + dt_utc.minute / 60 + dt_utc.second / 3600
|
||||
hour_angle = math.radians(15 * (utc_hours - 12) + lon + equation_of_time_deg)
|
||||
|
||||
lat_rad = math.radians(lat)
|
||||
elevation = math.asin(math.sin(lat_rad) * math.sin(declination)
|
||||
+ math.cos(lat_rad) * math.cos(declination) * math.cos(hour_angle))
|
||||
return math.degrees(elevation)
|
||||
|
||||
|
||||
def is_after_sunset(lat: float, lon: float, dt_utc: datetime | None = None) -> bool:
|
||||
return sun_elevation_deg(lat, lon, dt_utc) < SUNSET_ELEVATION_DEG
|
||||
55
iqpilot/selfdrive/ui/lib/wifi_ssid.py
Normal file
55
iqpilot/selfdrive/ui/lib/wifi_ssid.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import time
|
||||
import threading
|
||||
import subprocess
|
||||
|
||||
# Shared, throttled current-Wi-Fi-SSID lookup for the status bars (home pill + onroad sidebar).
|
||||
# The SSID isn't in deviceState, so we shell out to NetworkManager on a background thread and cache
|
||||
# the result; the render thread only ever reads the cached string.
|
||||
|
||||
_ssid = ""
|
||||
_last_fetch = 0.0
|
||||
_fetching = False
|
||||
_lock = threading.Lock()
|
||||
REFRESH_SECONDS = 10.0
|
||||
|
||||
|
||||
def _read_ssid() -> str:
|
||||
try:
|
||||
out = subprocess.run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"],
|
||||
capture_output=True, text=True, timeout=4).stdout
|
||||
for line in out.splitlines():
|
||||
if line.startswith("yes:"):
|
||||
name = line.split(":", 1)[1].strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
name = subprocess.run(["iwgetid", "-r"], capture_output=True, text=True, timeout=4).stdout.strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _fetch() -> None:
|
||||
global _ssid, _last_fetch, _fetching
|
||||
name = _read_ssid()
|
||||
with _lock:
|
||||
_ssid = name
|
||||
_last_fetch = time.monotonic()
|
||||
_fetching = False
|
||||
|
||||
|
||||
def current_ssid(on_wifi: bool) -> str:
|
||||
"""Return the connected SSID (or "" if unknown / not on Wi-Fi). Kicks a throttled background
|
||||
refresh; never blocks the caller."""
|
||||
global _fetching
|
||||
if not on_wifi:
|
||||
return ""
|
||||
with _lock:
|
||||
if not _fetching and time.monotonic() - _last_fetch > REFRESH_SECONDS:
|
||||
_fetching = True
|
||||
threading.Thread(target=_fetch, daemon=True).start()
|
||||
return _ssid
|
||||
Reference in New Issue
Block a user