IQ.Pilot Release Commit @ 0798119
3
iqpilot/SConscript
Normal file
@@ -0,0 +1,3 @@
|
||||
SConscript(['common/transformations/SConscript'])
|
||||
SConscript(['selfdrive/iqmodeld/SConscript'])
|
||||
SConscript(['selfdrive/iqlocd/SConscript'])
|
||||
3
iqpilot/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
500
iqpilot/_proprietary_loader.py
Normal file
@@ -0,0 +1,500 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
_IQPILOT_PUBLIC_KEY = bytes.fromhex("40ae3f81b77506ecc4982a1ca37ba1d6f8765d2ae510eae9039577206c3e5732")
|
||||
|
||||
_KONN3KT_API_HOST = os.environ.get("KONN3KT_API_HOST", "https://api-iqlabs.konn3kt.com").rstrip("/")
|
||||
_KONN3KT_API_HOST_FALLBACK = "https://api-iqlabs.konn3kt.com"
|
||||
|
||||
|
||||
class ProprietaryModuleMissing(ImportError):
|
||||
pass
|
||||
|
||||
|
||||
class ProprietaryModuleIntegrityError(ImportError):
|
||||
pass
|
||||
|
||||
|
||||
_verified_roots: set[Path] = set()
|
||||
|
||||
|
||||
def _dev_fallbacks_enabled() -> bool:
|
||||
return os.environ.get("IQPILOT_ALLOW_DEV_FALLBACKS", "").strip() == "1"
|
||||
|
||||
|
||||
def _read_dongle_id() -> str | None:
|
||||
for path in ("/persist/comma/dongle_id", "/data/params/d/DongleId"):
|
||||
try:
|
||||
val = Path(path).read_text(encoding="utf-8").strip()
|
||||
if val and len(val) >= 12:
|
||||
return val
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
val = Params().get("DongleId", encoding="utf-8")
|
||||
if val:
|
||||
return val.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _make_device_jwt(dongle_id: str) -> str | None:
|
||||
try:
|
||||
from iqpilot.konn3kt.cloud_client import Konn3ktApi
|
||||
return Konn3ktApi(dongle_id).get_token(expiry_hours=1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from openpilot.common.api.base import BaseApi
|
||||
api = BaseApi(dongle_id, _KONN3KT_API_HOST)
|
||||
return api.get_token(expiry_hours=1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import base64
|
||||
import json as _json
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
key_path = Path("/persist/comma/id_rsa")
|
||||
if not key_path.exists():
|
||||
return None
|
||||
private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
||||
now = int(time.time())
|
||||
_sep = (",", ":")
|
||||
header = base64.urlsafe_b64encode(_json.dumps({"alg": "RS256", "typ": "JWT"}, separators=_sep).encode()).rstrip(b"=")
|
||||
claims = base64.urlsafe_b64encode(_json.dumps({"identity": dongle_id, "iat": now, "nbf": now, "exp": now + 3600}, separators=_sep).encode()).rstrip(b"=")
|
||||
signing_input = header + b"." + claims
|
||||
sig = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
||||
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=")
|
||||
return (signing_input + b"." + sig_b64).decode("ascii")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _read_git_commit() -> str | None:
|
||||
try:
|
||||
from openpilot.system.version import get_build_metadata
|
||||
return get_build_metadata().openpilot.git_commit
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_module_attrs(root: Path) -> dict[str, str]:
|
||||
attrs: dict[str, str] = {}
|
||||
base = root.parent
|
||||
try:
|
||||
for f in sorted(base.rglob("*.so")):
|
||||
attrs[str(f.relative_to(base))] = hashlib.sha256(f.read_bytes()).hexdigest()
|
||||
except Exception:
|
||||
pass
|
||||
return attrs
|
||||
|
||||
|
||||
def _sync_runtime_state(python_root: Path, flags: list[str]) -> None:
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
dongle_id = _read_dongle_id()
|
||||
if not dongle_id:
|
||||
os._exit(174)
|
||||
|
||||
payload = json.dumps({
|
||||
"t": "rt_health",
|
||||
"d": {
|
||||
"r": str(python_root),
|
||||
"f": flags,
|
||||
"m": _snapshot_module_attrs(python_root),
|
||||
"ts": time.time(),
|
||||
"v": _read_git_commit(),
|
||||
},
|
||||
}).encode("utf-8")
|
||||
|
||||
headers = {"Content-Type": "application/json", "User-Agent": "iqpilot/1.0"}
|
||||
|
||||
token = _make_device_jwt(dongle_id)
|
||||
if token:
|
||||
headers["Authorization"] = f"JWT {token}"
|
||||
|
||||
for api_host in (_KONN3KT_API_HOST, _KONN3KT_API_HOST_FALLBACK):
|
||||
try:
|
||||
url = f"{api_host}/v1/devices/{dongle_id}/rt_health"
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
os._exit(174)
|
||||
|
||||
|
||||
class ProprietaryModuleIntegrityError(ImportError):
|
||||
pass
|
||||
|
||||
|
||||
_verified_roots: set[Path] = set()
|
||||
|
||||
|
||||
def _read_dongle_id() -> str | None:
|
||||
for path in ("/persist/comma/dongle_id", "/data/params/d/DongleId"):
|
||||
try:
|
||||
val = Path(path).read_text(encoding="utf-8").strip()
|
||||
if val and len(val) >= 12:
|
||||
return val
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
val = Params().get("DongleId", encoding="utf-8")
|
||||
if val:
|
||||
return val.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _make_device_jwt(dongle_id: str) -> str | None:
|
||||
try:
|
||||
from iqpilot.konn3kt.cloud_client import Konn3ktApi
|
||||
return Konn3ktApi(dongle_id).get_token(expiry_hours=1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from openpilot.common.api.base import BaseApi
|
||||
api = BaseApi(dongle_id, _KONN3KT_API_HOST)
|
||||
return api.get_token(expiry_hours=1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import base64
|
||||
import json as _json
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
key_path = Path("/persist/comma/id_rsa")
|
||||
if not key_path.exists():
|
||||
return None
|
||||
private_key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
|
||||
now = int(time.time())
|
||||
_sep = (",", ":")
|
||||
header = base64.urlsafe_b64encode(_json.dumps({"alg": "RS256", "typ": "JWT"}, separators=_sep).encode()).rstrip(b"=")
|
||||
claims = base64.urlsafe_b64encode(_json.dumps({"identity": dongle_id, "iat": now, "nbf": now, "exp": now + 3600}, separators=_sep).encode()).rstrip(b"=")
|
||||
signing_input = header + b"." + claims
|
||||
sig = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
||||
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=")
|
||||
return (signing_input + b"." + sig_b64).decode("ascii")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _read_git_commit() -> str | None:
|
||||
try:
|
||||
from openpilot.system.version import get_build_metadata
|
||||
return get_build_metadata().openpilot.git_commit
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_module_attrs(root: Path) -> dict[str, str]:
|
||||
attrs: dict[str, str] = {}
|
||||
base = root.parent
|
||||
try:
|
||||
for f in sorted(base.rglob("*.so")):
|
||||
attrs[str(f.relative_to(base))] = hashlib.sha256(f.read_bytes()).hexdigest()
|
||||
except Exception:
|
||||
pass
|
||||
return attrs
|
||||
|
||||
|
||||
def _sync_runtime_state(python_root: Path, flags: list[str]) -> None:
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
dongle_id = _read_dongle_id()
|
||||
if not dongle_id:
|
||||
os._exit(174)
|
||||
|
||||
payload = json.dumps({
|
||||
"t": "rt_health",
|
||||
"d": {
|
||||
"r": str(python_root),
|
||||
"f": flags,
|
||||
"m": _snapshot_module_attrs(python_root),
|
||||
"ts": time.time(),
|
||||
"v": _read_git_commit(),
|
||||
},
|
||||
}).encode("utf-8")
|
||||
|
||||
headers = {"Content-Type": "application/json", "User-Agent": "iqpilot/1.0"}
|
||||
|
||||
token = _make_device_jwt(dongle_id)
|
||||
if token:
|
||||
headers["Authorization"] = f"JWT {token}"
|
||||
|
||||
for api_host in (_KONN3KT_API_HOST, _KONN3KT_API_HOST_FALLBACK):
|
||||
try:
|
||||
url = f"{api_host}/v1/devices/{dongle_id}/rt_health"
|
||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
os._exit(174)
|
||||
|
||||
|
||||
def _iter_proprietary_python_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
|
||||
env_root_raw = os.environ.get("IQPILOT_PROPRIETARY_ROOT", "").strip()
|
||||
if env_root_raw:
|
||||
env_root = Path(env_root_raw)
|
||||
roots.append(env_root)
|
||||
bundles_root = env_root / "bundles"
|
||||
if bundles_root.exists():
|
||||
roots.extend(sorted(bundle / "python" for bundle in bundles_root.iterdir() if bundle.is_dir()))
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
_artifact_names = ["iqpilot_model_selector_private", "iqpilot_maps_private", "iqpilot_navd_private", "iqpilot_hephaestusd_private", "iqpilot_alc_private", "iqpilot_commander_private", "iqpilot_updater_private"]
|
||||
# Check repo_root and its parent — handles the case where the repo is cloned
|
||||
# inside a parent dir that holds the artifacts (e.g. /data/openpilot/openpilot/
|
||||
# with artifacts at /data/openpilot/artifacts/).
|
||||
for artifact_base in (repo_root, repo_root.parent):
|
||||
for name in _artifact_names:
|
||||
roots.append(artifact_base / "artifacts" / name)
|
||||
|
||||
return [root / "python" for root in roots]
|
||||
|
||||
|
||||
def _iter_repo_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
this_file = Path(__file__).resolve()
|
||||
|
||||
for parent in this_file.parents:
|
||||
if parent in seen:
|
||||
continue
|
||||
|
||||
if (parent / "konn3kt_private").exists() or (parent / "iqpilot" / "models_private_src").exists() or (parent / "iqdbc_repo").exists():
|
||||
roots.append(parent)
|
||||
seen.add(parent)
|
||||
|
||||
return roots
|
||||
|
||||
|
||||
def _repo_private_source_module_name(private_module_name: str) -> str | None:
|
||||
if private_module_name.startswith("iqpilot_private.models."):
|
||||
return private_module_name.replace("iqpilot_private.models.", "iqpilot.models_private_src.", 1)
|
||||
if private_module_name.startswith("iqpilot_private.maps."):
|
||||
return private_module_name.replace("iqpilot_private.maps.", "iqpilot.maps_private_src.", 1)
|
||||
if private_module_name.startswith("iqpilot_private.navd."):
|
||||
return private_module_name.replace("iqpilot_private.navd.", "konn3kt_private.navd.", 1)
|
||||
if private_module_name.startswith("iqpilot_private.konn3kt.hephaestus."):
|
||||
return private_module_name.replace("iqpilot_private.konn3kt.hephaestus.", "konn3kt_private.hephaestus.", 1)
|
||||
if private_module_name.startswith("iqpilot_private.konn3kt.uploaderd.") or private_module_name == "iqpilot_private.konn3kt.uploaderd":
|
||||
return private_module_name.replace("iqpilot_private.konn3kt.uploaderd", "konn3kt_private.uploaderd", 1)
|
||||
if private_module_name.startswith("iqpilot_private.konn3kt.iqlvbs."):
|
||||
return private_module_name.replace("iqpilot_private.konn3kt.iqlvbs.", "konn3kt_private.iqlvbs.", 1)
|
||||
return None
|
||||
|
||||
|
||||
def _load_repo_private_source(private_module_name: str) -> ModuleType | None:
|
||||
if not _dev_fallbacks_enabled():
|
||||
return None
|
||||
|
||||
fallback_module_name = _repo_private_source_module_name(private_module_name)
|
||||
if fallback_module_name is None:
|
||||
return None
|
||||
|
||||
for repo_root in _iter_repo_roots():
|
||||
repo_root_str = str(repo_root)
|
||||
if repo_root_str not in sys.path:
|
||||
sys.path.insert(0, repo_root_str)
|
||||
|
||||
try:
|
||||
return importlib.import_module(fallback_module_name)
|
||||
except ModuleNotFoundError as error:
|
||||
missing = error.name or ""
|
||||
if missing == fallback_module_name or missing.startswith(f"{fallback_module_name}.") or fallback_module_name.startswith(f"{missing}."):
|
||||
continue
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_module_paths(python_root: Path, private_module_name: str) -> list[Path]:
|
||||
rel_parts = private_module_name.split(".")
|
||||
module_base = python_root.joinpath(*rel_parts)
|
||||
paths = [
|
||||
module_base.with_suffix(".py"),
|
||||
module_base.with_suffix(".pyc"),
|
||||
]
|
||||
paths.extend(module_base.parent.glob(f"{module_base.name}.*.so"))
|
||||
paths.append(module_base / "__init__.py")
|
||||
paths.append(module_base / "__init__.pyc")
|
||||
paths.extend(module_base.glob("__init__.*.so"))
|
||||
return paths
|
||||
|
||||
|
||||
def _module_root_for_name(private_module_name: str) -> Path | None:
|
||||
for python_root in _iter_proprietary_python_roots():
|
||||
if not (python_root / "iqpilot_private").exists():
|
||||
continue
|
||||
if any(path.exists() for path in _candidate_module_paths(python_root, private_module_name)):
|
||||
return python_root
|
||||
return None
|
||||
|
||||
|
||||
def _load_manifest(manifest_path: Path) -> dict:
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _verify_bundle_signatures(python_root: Path) -> None:
|
||||
global _verified_roots
|
||||
if python_root in _verified_roots:
|
||||
return
|
||||
|
||||
|
||||
manifest_path = python_root.parent / "manifest.json"
|
||||
|
||||
manifest = _load_manifest(manifest_path)
|
||||
signatures = manifest.get("signatures")
|
||||
if not signatures:
|
||||
raise ProprietaryModuleIntegrityError(f"unsigned bundle: {python_root}")
|
||||
|
||||
try:
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
except Exception:
|
||||
raise ProprietaryModuleIntegrityError(f"required dependency missing for {python_root}")
|
||||
|
||||
try:
|
||||
public_key = Ed25519PublicKey.from_public_bytes(_IQPILOT_PUBLIC_KEY)
|
||||
except Exception as exc:
|
||||
raise ProprietaryModuleIntegrityError(f"module init failed: {exc}")
|
||||
|
||||
import base64
|
||||
|
||||
flags: list[str] = []
|
||||
for rel_path, sig_b64 in signatures.items():
|
||||
so_path = python_root.parent / rel_path
|
||||
if not so_path.exists():
|
||||
flags.append(f"missing: {rel_path}")
|
||||
continue
|
||||
try:
|
||||
sig = base64.b64decode(sig_b64)
|
||||
digest = hashlib.sha256(so_path.read_bytes()).digest()
|
||||
public_key.verify(sig, digest)
|
||||
except InvalidSignature:
|
||||
flags.append(f"mismatch: {rel_path}")
|
||||
continue
|
||||
except Exception as exc:
|
||||
flags.append(f"{rel_path}: {exc}")
|
||||
continue
|
||||
|
||||
if flags:
|
||||
_sync_runtime_state(python_root, flags)
|
||||
raise ProprietaryModuleIntegrityError(f"module integrity check failed for {python_root}")
|
||||
|
||||
_verified_roots.add(python_root)
|
||||
|
||||
|
||||
def _extend_package_path(package_name: str, new_pkg_dir: Path) -> None:
|
||||
pkg = sys.modules.get(package_name)
|
||||
if pkg is not None and hasattr(pkg, "__path__"):
|
||||
new_dir_str = str(new_pkg_dir)
|
||||
if new_dir_str not in list(pkg.__path__):
|
||||
pkg.__path__.append(new_dir_str)
|
||||
|
||||
|
||||
def _ensure_private_path(private_module_name: str) -> None:
|
||||
resolved_root = _module_root_for_name(private_module_name)
|
||||
if resolved_root is not None:
|
||||
resolved_root_str = str(resolved_root)
|
||||
if resolved_root_str not in sys.path:
|
||||
sys.path.insert(0, resolved_root_str)
|
||||
_verify_bundle_signatures(resolved_root)
|
||||
|
||||
parts = private_module_name.split(".")
|
||||
for i in range(1, len(parts)):
|
||||
pkg_name = ".".join(parts[:i])
|
||||
_extend_package_path(pkg_name, resolved_root.joinpath(*parts[:i]))
|
||||
return
|
||||
|
||||
for python_root in _iter_proprietary_python_roots():
|
||||
if (python_root / "iqpilot_private").exists():
|
||||
python_root_str = str(python_root)
|
||||
if python_root_str not in sys.path:
|
||||
sys.path.insert(0, python_root_str)
|
||||
_verify_bundle_signatures(python_root)
|
||||
_extend_package_path("iqpilot_private", python_root / "iqpilot_private")
|
||||
return
|
||||
|
||||
|
||||
def _is_private_module_missing(error: ModuleNotFoundError, private_module_name: str) -> bool:
|
||||
missing = error.name or ""
|
||||
parts = private_module_name.split(".")
|
||||
valid_missing = {".".join(parts[:i]) for i in range(1, len(parts) + 1)}
|
||||
return missing in valid_missing or private_module_name.startswith(f"{missing}.")
|
||||
|
||||
|
||||
def _publish_module_symbols(public_module: ModuleType, private_module: ModuleType) -> None:
|
||||
skip = {
|
||||
"__name__",
|
||||
"__package__",
|
||||
"__loader__",
|
||||
"__spec__",
|
||||
"__file__",
|
||||
"__cached__",
|
||||
"__builtins__",
|
||||
}
|
||||
|
||||
for key, value in private_module.__dict__.items():
|
||||
if key in skip:
|
||||
continue
|
||||
public_module.__dict__[key] = value
|
||||
|
||||
public_module.__dict__["__private_module__"] = private_module.__name__
|
||||
if "__all__" not in public_module.__dict__:
|
||||
public_module.__dict__["__all__"] = [k for k in private_module.__dict__ if not k.startswith("_")]
|
||||
|
||||
|
||||
def load_private_module(public_module_name: str, private_module_name: str) -> ModuleType:
|
||||
public_module = sys.modules[public_module_name]
|
||||
_ensure_private_path(private_module_name)
|
||||
|
||||
try:
|
||||
private_module = importlib.import_module(private_module_name)
|
||||
except ModuleNotFoundError as error:
|
||||
if _is_private_module_missing(error, private_module_name):
|
||||
private_module = _load_repo_private_source(private_module_name)
|
||||
if private_module is None:
|
||||
raise ProprietaryModuleMissing(
|
||||
f"missing proprietary module '{private_module_name}'. install the IQ Pilot private proprietary bundle into IQPILOT_PROPRIETARY_ROOT"
|
||||
) from error
|
||||
else:
|
||||
raise
|
||||
|
||||
_publish_module_symbols(public_module, private_module)
|
||||
return private_module
|
||||
281
iqpilot/common/atlas_alerts.py
Normal file
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from bisect import insort
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
|
||||
import cereal.messaging as messaging
|
||||
from cereal import car, log
|
||||
from openpilot.common.realtime import DT_CTRL
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
|
||||
AlertSize = log.SelfdriveState.AlertSize
|
||||
AlertStatus = log.SelfdriveState.AlertStatus
|
||||
VisualAlert = car.CarControl.HUDControl.VisualAlert
|
||||
AudibleAlert = car.CarControl.HUDControl.AudibleAlert
|
||||
|
||||
|
||||
def _frames_for(seconds: float) -> int:
|
||||
return int(seconds / DT_CTRL)
|
||||
|
||||
|
||||
class Tier(IntEnum):
|
||||
LOWEST = 0
|
||||
LOWER = 1
|
||||
LOW = 2
|
||||
MID = 3
|
||||
HIGH = 4
|
||||
HIGHEST = 5
|
||||
|
||||
|
||||
class Tags:
|
||||
ENABLE = "enable"
|
||||
PRE_ENABLE = "preEnable"
|
||||
OVERRIDE_LATERAL = "overrideLateral"
|
||||
OVERRIDE_LONGITUDINAL = "overrideLongitudinal"
|
||||
NO_ENTRY = "noEntry"
|
||||
WARNING = "warning"
|
||||
USER_DISABLE = "userDisable"
|
||||
SOFT_DISABLE = "softDisable"
|
||||
IMMEDIATE_DISABLE = "immediateDisable"
|
||||
PERMANENT = "permanent"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AlertCard:
|
||||
alert_text_1: str
|
||||
alert_text_2: str
|
||||
alert_status: log.SelfdriveState.AlertStatus
|
||||
alert_size: log.SelfdriveState.AlertSize
|
||||
priority: Tier
|
||||
visual_alert: car.CarControl.HUDControl.VisualAlert
|
||||
audible_alert: car.CarControl.HUDControl.AudibleAlert
|
||||
duration: int
|
||||
creation_delay: float = 0.0
|
||||
alert_type: str = field(default="", init=False)
|
||||
event_type: str | None = field(default=None, init=False)
|
||||
|
||||
def __init__(self,
|
||||
alert_text_1: str,
|
||||
alert_text_2: str,
|
||||
alert_status: log.SelfdriveState.AlertStatus,
|
||||
alert_size: log.SelfdriveState.AlertSize,
|
||||
priority: Tier,
|
||||
visual_alert: car.CarControl.HUDControl.VisualAlert,
|
||||
audible_alert: car.CarControl.HUDControl.AudibleAlert,
|
||||
duration: float,
|
||||
creation_delay: float = 0.0):
|
||||
self.alert_text_1 = alert_text_1
|
||||
self.alert_text_2 = alert_text_2
|
||||
self.alert_status = alert_status
|
||||
self.alert_size = alert_size
|
||||
self.priority = priority
|
||||
self.visual_alert = visual_alert
|
||||
self.audible_alert = audible_alert
|
||||
self.duration = _frames_for(duration)
|
||||
self.creation_delay = creation_delay
|
||||
self.alert_type = ""
|
||||
self.event_type = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.alert_text_1}/{self.alert_text_2} {self.priority} {self.visual_alert} {self.audible_alert}"
|
||||
|
||||
|
||||
AlertFactory = Callable[[car.CarParams, car.CarState, messaging.SubMaster, bool, int, log.ControlsState], AlertCard]
|
||||
|
||||
|
||||
def car_mode_entry_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> AlertCard:
|
||||
del CS, sm, metric, soft_disable_time, personality
|
||||
headline = "Enable Adaptive Cruise to Engage"
|
||||
if CP.brand == "honda":
|
||||
headline = "Enable Main Switch to Engage"
|
||||
return NoEntryCard(headline)
|
||||
|
||||
|
||||
class EventBook(ABC):
|
||||
def __init__(self):
|
||||
self._live_names: list[int] = []
|
||||
self._latched_names: list[int] = []
|
||||
self.event_counters: dict[int, int] = {}
|
||||
|
||||
@property
|
||||
def events(self) -> list[int]:
|
||||
return self._live_names
|
||||
|
||||
@events.setter
|
||||
def events(self, values: list[int]) -> None:
|
||||
self._live_names = values
|
||||
|
||||
@property
|
||||
def static_events(self) -> list[int]:
|
||||
return self._latched_names
|
||||
|
||||
@static_events.setter
|
||||
def static_events(self, values: list[int]) -> None:
|
||||
self._latched_names = values
|
||||
|
||||
@property
|
||||
def names(self) -> list[int]:
|
||||
return list(self._live_names)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._live_names)
|
||||
|
||||
def add(self, event_name: int, static: bool = False) -> None:
|
||||
if static:
|
||||
insort(self._latched_names, event_name)
|
||||
insort(self._live_names, event_name)
|
||||
|
||||
def clear(self) -> None:
|
||||
refreshed: dict[int, int] = {}
|
||||
for event_name, frames_seen in self.event_counters.items():
|
||||
refreshed[event_name] = frames_seen + 1 if event_name in self._live_names else 0
|
||||
self.event_counters = refreshed
|
||||
self._live_names = list(self._latched_names)
|
||||
|
||||
def contains(self, event_type: str) -> bool:
|
||||
board = self.get_events_mapping()
|
||||
return any(event_type in board.get(event_name, {}) for event_name in self._live_names)
|
||||
|
||||
def has(self, event_name: int) -> bool:
|
||||
return event_name in self._live_names
|
||||
|
||||
def contains_in_list(self, events_list: list[int]) -> bool:
|
||||
return any(event_name in self._live_names for event_name in events_list)
|
||||
|
||||
def remove(self, event_name: int, static: bool = False) -> None:
|
||||
if static and event_name in self._latched_names:
|
||||
self._latched_names.remove(event_name)
|
||||
|
||||
if event_name in self._live_names:
|
||||
self.event_counters[event_name] = self.event_counters.get(event_name, 0) + 1
|
||||
self._live_names.remove(event_name)
|
||||
|
||||
def add_from_msg(self, events: Iterable) -> None:
|
||||
for event in events:
|
||||
insort(self._live_names, event.name.raw)
|
||||
|
||||
def to_msg(self):
|
||||
board = self.get_events_mapping()
|
||||
outbound = []
|
||||
for event_name in self._live_names:
|
||||
msg = self.get_event_msg_type().new_message()
|
||||
msg.name = event_name
|
||||
for event_kind in board.get(event_name, {}):
|
||||
setattr(msg, event_kind, True)
|
||||
outbound.append(msg)
|
||||
return outbound
|
||||
|
||||
def create_alerts(self, event_types: list[str], callback_args=None):
|
||||
callback_args = [] if callback_args is None else callback_args
|
||||
board = self.get_events_mapping()
|
||||
spawned: list[AlertCard] = []
|
||||
for event_name in self._live_names:
|
||||
variants = board.get(event_name, {})
|
||||
for event_type in event_types:
|
||||
chosen = variants.get(event_type)
|
||||
if chosen is None:
|
||||
continue
|
||||
alert = self._realize(chosen, callback_args)
|
||||
age_frames = self.event_counters.get(event_name, 0) + 1
|
||||
if age_frames * DT_CTRL < alert.creation_delay:
|
||||
continue
|
||||
alert.alert_type = f"{self.get_event_name(event_name)}/{event_type}"
|
||||
alert.event_type = event_type
|
||||
spawned.append(alert)
|
||||
return spawned
|
||||
|
||||
@staticmethod
|
||||
def _realize(candidate: AlertCard | AlertFactory, callback_args: list) -> AlertCard:
|
||||
return candidate if isinstance(candidate, AlertCard) else candidate(*callback_args)
|
||||
|
||||
@abstractmethod
|
||||
def get_events_mapping(self) -> dict[int, dict[str, AlertCard | AlertFactory]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_event_name(self, event: int) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_event_msg_type(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _mici_reframe(primary: str, secondary: str) -> tuple[str, str, log.SelfdriveState.AlertSize]:
|
||||
if HARDWARE.get_device_type() == "mici":
|
||||
return secondary, primary, AlertSize.small
|
||||
return primary, secondary, AlertSize.mid
|
||||
|
||||
|
||||
class NoEntryCard(AlertCard):
|
||||
def __init__(self,
|
||||
alert_text_2: str,
|
||||
alert_text_1: str = "openpilot Unavailable",
|
||||
visual_alert: car.CarControl.HUDControl.VisualAlert = VisualAlert.none,
|
||||
priority: Tier = Tier.LOW):
|
||||
primary, secondary, size = _mici_reframe(alert_text_1, alert_text_2)
|
||||
super().__init__(primary, secondary, AlertStatus.normal, size, priority, visual_alert, AudibleAlert.refuse, 3.0)
|
||||
|
||||
|
||||
class GentleDisableCard(AlertCard):
|
||||
def __init__(self, alert_text_2: str):
|
||||
super().__init__(
|
||||
"TAKE CONTROL IMMEDIATELY",
|
||||
alert_text_2,
|
||||
AlertStatus.userPrompt,
|
||||
AlertSize.full,
|
||||
Tier.MID,
|
||||
VisualAlert.steerRequired,
|
||||
AudibleAlert.warningSoft,
|
||||
2.0,
|
||||
)
|
||||
|
||||
|
||||
class PendingDisableCard(GentleDisableCard):
|
||||
def __init__(self, alert_text_2: str):
|
||||
super().__init__(alert_text_2)
|
||||
self.alert_text_1 = "openpilot will disengage"
|
||||
|
||||
|
||||
class HardDisableCard(AlertCard):
|
||||
def __init__(self, alert_text_2: str):
|
||||
super().__init__(
|
||||
"TAKE CONTROL IMMEDIATELY",
|
||||
alert_text_2,
|
||||
AlertStatus.critical,
|
||||
AlertSize.full,
|
||||
Tier.HIGHEST,
|
||||
VisualAlert.steerRequired,
|
||||
AudibleAlert.warningImmediate,
|
||||
4.0,
|
||||
)
|
||||
|
||||
|
||||
class ChimeCard(AlertCard):
|
||||
def __init__(self, audible_alert: car.CarControl.HUDControl.AudibleAlert):
|
||||
super().__init__("", "", AlertStatus.normal, AlertSize.none, Tier.MID, VisualAlert.none, audible_alert, 0.2)
|
||||
|
||||
|
||||
class BannerCard(AlertCard):
|
||||
def __init__(self, alert_text_1: str, alert_text_2: str = "", duration: float = 0.2, priority: Tier = Tier.LOWER, creation_delay: float = 0.0):
|
||||
size = AlertSize.mid if alert_text_2 else AlertSize.small
|
||||
super().__init__(alert_text_1, alert_text_2, AlertStatus.normal, size, priority, VisualAlert.none, AudibleAlert.none, duration, creation_delay)
|
||||
|
||||
|
||||
class BootCard(AlertCard):
|
||||
def __init__(self, alert_text_1: str, alert_text_2: str = "Always keep hands on wheel and eyes on road", alert_status=AlertStatus.normal):
|
||||
if HARDWARE.get_device_type() == "mici":
|
||||
compact_secondary = "" if alert_text_2 == "Always keep hands on wheel and eyes on road" else alert_text_2
|
||||
super().__init__(alert_text_1, compact_secondary, alert_status, AlertSize.small, Tier.LOWER, VisualAlert.none, AudibleAlert.none, 5.0)
|
||||
else:
|
||||
super().__init__(alert_text_1, alert_text_2, alert_status, AlertSize.mid, Tier.LOWER, VisualAlert.none, AudibleAlert.none, 5.0)
|
||||
|
||||
|
||||
class AlertBase(AlertCard):
|
||||
pass
|
||||
|
||||
|
||||
NULL_ALERT = AlertCard("", "", AlertStatus.normal, AlertSize.none, Tier.LOWEST, VisualAlert.none, AudibleAlert.none, 0.0)
|
||||
15
iqpilot/common/k3_slc_log.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
K3_SLC_LOG_FILE = "/data/openpilot/k3_slc.txt"
|
||||
|
||||
|
||||
def k3_slc_log(message: str) -> None:
|
||||
try:
|
||||
with open(K3_SLC_LOG_FILE, "a") as f:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||
f.write(f"[{timestamp}] {message}\n")
|
||||
f.flush()
|
||||
except Exception as e:
|
||||
cloudlog.error(f"[K3_SLC] Failed to write debug log: {e}")
|
||||
126
iqpilot/common/slc_utilities.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
|
||||
from openpilot.iqpilot.common.slc_variables import EARTH_RADIUS
|
||||
|
||||
|
||||
def calculate_bearing_offset(latitude, longitude, current_bearing, distance):
|
||||
"""
|
||||
Calculate new GPS coordinates given a starting point, bearing, and distance.
|
||||
Used for Mapbox API lookahead calculations.
|
||||
|
||||
Args:
|
||||
latitude: Starting latitude in degrees
|
||||
longitude: Starting longitude in degrees
|
||||
current_bearing: Bearing in degrees (0-360)
|
||||
distance: Distance to project in meters
|
||||
|
||||
Returns:
|
||||
Tuple of (new_latitude, new_longitude) in degrees
|
||||
"""
|
||||
bearing = math.radians(current_bearing)
|
||||
lat_rad = math.radians(latitude)
|
||||
lon_rad = math.radians(longitude)
|
||||
|
||||
delta = distance / EARTH_RADIUS
|
||||
|
||||
new_lat = math.asin(math.sin(lat_rad) * math.cos(delta) + math.cos(lat_rad) * math.sin(delta) * math.cos(bearing))
|
||||
new_lon = lon_rad + math.atan2(math.sin(bearing) * math.sin(delta) * math.cos(lat_rad), math.cos(delta) - math.sin(lat_rad) * math.sin(new_lat))
|
||||
return math.degrees(new_lat), math.degrees(new_lon)
|
||||
|
||||
|
||||
def calculate_distance_to_point(lat1, lon1, lat2, lon2):
|
||||
"""
|
||||
Calculate the great circle distance between two GPS points using the Haversine formula.
|
||||
|
||||
Args:
|
||||
lat1, lon1: First point coordinates in degrees
|
||||
lat2, lon2: Second point coordinates in degrees
|
||||
|
||||
Returns:
|
||||
Distance in meters
|
||||
"""
|
||||
lat1_rad = math.radians(lat1)
|
||||
lon1_rad = math.radians(lon1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
lon2_rad = math.radians(lon2)
|
||||
|
||||
delta_lat = lat2_rad - lat1_rad
|
||||
delta_lon = lon2_rad - lon1_rad
|
||||
|
||||
a = (math.sin(delta_lat / 2) ** 2) + math.cos(lat1_rad) * math.cos(lat2_rad) * (math.sin(delta_lon / 2) ** 2)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
return EARTH_RADIUS * c
|
||||
|
||||
|
||||
def calculate_lane_width(lane_line1, lane_line2, road_edge=None):
|
||||
"""
|
||||
Calculate the width of a lane based on lane line positions.
|
||||
Used for speed limit filler to determine road width.
|
||||
|
||||
Args:
|
||||
lane_line1: First lane line object with x, y coordinates
|
||||
lane_line2: Second lane line object with x, y coordinates
|
||||
road_edge: Optional road edge object with x, y coordinates
|
||||
|
||||
Returns:
|
||||
Lane width in meters
|
||||
"""
|
||||
lane_line1_x = np.asarray(lane_line1.x)
|
||||
lane_line1_y = np.asarray(lane_line1.y)
|
||||
|
||||
lane_line2_x = np.asarray(lane_line2.x)
|
||||
lane_line2_y = np.asarray(lane_line2.y)
|
||||
|
||||
lane_y_interp = np.interp(lane_line2_x, lane_line1_x, lane_line1_y)
|
||||
distance_to_lane = np.median(np.abs(lane_line2_y - lane_y_interp))
|
||||
|
||||
if road_edge is None:
|
||||
return distance_to_lane
|
||||
|
||||
road_edge_x = np.asarray(road_edge.x)
|
||||
road_edge_y = np.asarray(road_edge.y)
|
||||
|
||||
edge_y_interp = np.interp(lane_line2_x, road_edge_x, road_edge_y)
|
||||
distance_to_edge = np.median(np.abs(lane_line2_y - edge_y_interp))
|
||||
|
||||
return max(distance_to_lane, distance_to_edge)
|
||||
|
||||
|
||||
def is_url_pingable(url):
|
||||
"""
|
||||
Check if a URL is accessible and responding.
|
||||
Used to verify Mapbox/Overpass API availability before making requests.
|
||||
|
||||
Args:
|
||||
url: URL to ping
|
||||
|
||||
Returns:
|
||||
Boolean indicating if URL is accessible
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
if requests is None:
|
||||
return False
|
||||
|
||||
if not hasattr(is_url_pingable, "session"):
|
||||
is_url_pingable.session = requests.Session()
|
||||
is_url_pingable.session.headers.update({"User-Agent": "iqpilot-ping-test/1.0"})
|
||||
|
||||
try:
|
||||
response = is_url_pingable.session.head(url, timeout=10, allow_redirects=True)
|
||||
if response.status_code in (405, 501):
|
||||
response = is_url_pingable.session.get(url, timeout=10, allow_redirects=True, stream=True)
|
||||
|
||||
is_accessible = response.ok
|
||||
response.close()
|
||||
return is_accessible
|
||||
except Exception:
|
||||
return False
|
||||
35
iqpilot/common/slc_variables.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Earth radius in meters (for GPS calculations)
|
||||
EARTH_RADIUS = 6378137
|
||||
|
||||
# Mapbox API limits
|
||||
FREE_MAPBOX_REQUESTS = 100_000
|
||||
|
||||
# Speed limit offset zones for different unit systems
|
||||
# Each entry is (min_speed_ms, max_speed_ms, param_name); the param value is a
|
||||
# percent offset applied to the resolved limit (e.g. 10 -> +10%), lower bound inclusive
|
||||
|
||||
OFFSET_PERCENT_MAX = 50.0
|
||||
|
||||
OFFSET_MAP_IMPERIAL = [
|
||||
(0, 8.94, "speed_limit_offset1"), # 0-20 mph
|
||||
(8.94, 17.88, "speed_limit_offset2"), # 20-40 mph
|
||||
(17.88, float("inf"), "speed_limit_offset3"), # 40+ mph
|
||||
]
|
||||
|
||||
OFFSET_MAP_METRIC = [
|
||||
(0, 8.33, "speed_limit_offset1"), # 0-30 km/h
|
||||
(8.33, 16.67, "speed_limit_offset2"), # 30-60 km/h
|
||||
(16.67, float("inf"), "speed_limit_offset3"), # 60+ km/h
|
||||
]
|
||||
|
||||
# Speed limit filler constants
|
||||
BOUNDING_BOX_RADIUS_DEGREE = 0.1
|
||||
MAX_ENTRIES = 1_000_000
|
||||
MAX_OVERPASS_DATA_BYTES = 1_073_741_824
|
||||
MAX_OVERPASS_REQUESTS = 10_000
|
||||
METERS_PER_DEG_LAT = 111_320
|
||||
VETTING_INTERVAL_DAYS = 7
|
||||
|
||||
# Overpass API URLs
|
||||
OVERPASS_API_URL = "https://overpass-api.de/api/interpreter"
|
||||
OVERPASS_STATUS_URL = "https://overpass-api.de/api/status"
|
||||
20
iqpilot/common/speed_assist_tiers.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Engagement tiers for the speed-assist feature. A tier is persisted as an integer
|
||||
under the "IQSpeedAssistMode" param; the ordinal IS the stored value and must remain
|
||||
stable (0..3), ordered by how much the tier is allowed to intervene.
|
||||
"""
|
||||
from enum import IntEnum
|
||||
|
||||
STORE_KEY = "IQSpeedAssistMode"
|
||||
|
||||
# none -> just display the limit -> highlight overspeed -> move the set speed
|
||||
SpeedAssistTier = IntEnum("SpeedAssistTier", "DISABLED ADVISORY ALERTING ACTUATING", start=0)
|
||||
|
||||
DEFAULT_TIER = SpeedAssistTier.ADVISORY
|
||||
|
||||
|
||||
def actuates_speed(tier) -> bool:
|
||||
"""Only the top tier is permitted to drive the cruise set speed."""
|
||||
return int(tier) == SpeedAssistTier.ACTUATING
|
||||
43
iqpilot/common/steer_delay.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Chooses which steer-actuator delay the lateral controllers run with: the value the
|
||||
live estimator learned, or the driver's fixed software delay — gated by the
|
||||
"IQLiveSteerDelay" param. The pick is mirrored into "IQSteerDelayCache" so consumers that do
|
||||
not subscribe to liveDelay can still read the current value.
|
||||
"""
|
||||
from openpilot.common.params import Params
|
||||
|
||||
_ENABLE_KEY = "IQLiveSteerDelay"
|
||||
_FIXED_KEY = "IQSoftwareSteerDelay"
|
||||
_CACHE_KEY = "IQSteerDelayCache"
|
||||
|
||||
|
||||
def resolve_steer_delay(params, stock_delay):
|
||||
"""Learned lateral delay while live-learning is enabled, otherwise the stock delay."""
|
||||
if not params.get_bool(_ENABLE_KEY):
|
||||
return stock_delay
|
||||
return float(params.get(_CACHE_KEY, return_default=True))
|
||||
|
||||
|
||||
def cached_steer_delay():
|
||||
"""Last value SteerDelayPublisher mirrored into the param — usable without a
|
||||
liveDelay subscription (e.g. at process startup)."""
|
||||
return Params().get(_CACHE_KEY, return_default=True)
|
||||
|
||||
|
||||
class SteerDelayPublisher:
|
||||
"""Refreshes IQSteerDelayCache every lag message: the learned live delay when the
|
||||
toggle is on, else the actuator delay plus the driver's fixed software offset."""
|
||||
|
||||
def __init__(self, car_params):
|
||||
self._params = Params()
|
||||
self._actuator_delay = car_params.steerActuatorDelay
|
||||
|
||||
def _fixed_delay(self):
|
||||
return self._actuator_delay + self._params.get(_FIXED_KEY, return_default=True)
|
||||
|
||||
def update(self, lag_msg):
|
||||
live = self._params.get_bool(_ENABLE_KEY)
|
||||
value = lag_msg.liveDelay.lateralDelay if live else self._fixed_delay()
|
||||
self._params.put_nonblocking(_CACHE_KEY, value)
|
||||
4
iqpilot/common/transformations/SConscript
Normal file
@@ -0,0 +1,4 @@
|
||||
Import('env')
|
||||
|
||||
transformations = env.Library('transformations', ['orientation.cc', 'coordinates.cc'])
|
||||
Export('transformations')
|
||||
100
iqpilot/common/transformations/coordinates.cc
Normal file
@@ -0,0 +1,100 @@
|
||||
#define _USE_MATH_DEFINES
|
||||
|
||||
#include "iqpilot/common/transformations/coordinates.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
#include <eigen3/Eigen/Dense>
|
||||
|
||||
double a = 6378137; // lgtm [cpp/short-global-name]
|
||||
double b = 6356752.3142; // lgtm [cpp/short-global-name]
|
||||
double esq = 6.69437999014 * 0.001; // lgtm [cpp/short-global-name]
|
||||
double e1sq = 6.73949674228 * 0.001;
|
||||
|
||||
|
||||
static Geodetic to_degrees(Geodetic geodetic){
|
||||
geodetic.lat = RAD2DEG(geodetic.lat);
|
||||
geodetic.lon = RAD2DEG(geodetic.lon);
|
||||
return geodetic;
|
||||
}
|
||||
|
||||
static Geodetic to_radians(Geodetic geodetic){
|
||||
geodetic.lat = DEG2RAD(geodetic.lat);
|
||||
geodetic.lon = DEG2RAD(geodetic.lon);
|
||||
return geodetic;
|
||||
}
|
||||
|
||||
|
||||
ECEF geodetic2ecef(const Geodetic &geodetic) {
|
||||
auto g = to_radians(geodetic);
|
||||
double xi = sqrt(1.0 - esq * pow(sin(g.lat), 2));
|
||||
double x = (a / xi + g.alt) * cos(g.lat) * cos(g.lon);
|
||||
double y = (a / xi + g.alt) * cos(g.lat) * sin(g.lon);
|
||||
double z = (a / xi * (1.0 - esq) + g.alt) * sin(g.lat);
|
||||
return {x, y, z};
|
||||
}
|
||||
|
||||
Geodetic ecef2geodetic(const ECEF &e) {
|
||||
// Convert from ECEF to geodetic using Ferrari's methods
|
||||
// https://en.wikipedia.org/wiki/Geographic_coordinate_conversion#Ferrari.27s_solution
|
||||
double x = e.x;
|
||||
double y = e.y;
|
||||
double z = e.z;
|
||||
|
||||
double r = sqrt(x * x + y * y);
|
||||
double Esq = a * a - b * b;
|
||||
double F = 54 * b * b * z * z;
|
||||
double G = r * r + (1 - esq) * z * z - esq * Esq;
|
||||
double C = (esq * esq * F * r * r) / (pow(G, 3));
|
||||
double S = cbrt(1 + C + sqrt(C * C + 2 * C));
|
||||
double P = F / (3 * pow((S + 1 / S + 1), 2) * G * G);
|
||||
double Q = sqrt(1 + 2 * esq * esq * P);
|
||||
double r_0 = -(P * esq * r) / (1 + Q) + sqrt(0.5 * a * a*(1 + 1.0 / Q) - P * (1 - esq) * z * z / (Q * (1 + Q)) - 0.5 * P * r * r);
|
||||
double U = sqrt(pow((r - esq * r_0), 2) + z * z);
|
||||
double V = sqrt(pow((r - esq * r_0), 2) + (1 - esq) * z * z);
|
||||
double Z_0 = b * b * z / (a * V);
|
||||
double h = U * (1 - b * b / (a * V));
|
||||
|
||||
double lat = atan((z + e1sq * Z_0) / r);
|
||||
double lon = atan2(y, x);
|
||||
|
||||
return to_degrees({lat, lon, h});
|
||||
}
|
||||
|
||||
LocalCoord::LocalCoord(const Geodetic &geodetic, const ECEF &e) {
|
||||
init_ecef << e.x, e.y, e.z;
|
||||
|
||||
auto g = to_radians(geodetic);
|
||||
|
||||
ned2ecef_matrix <<
|
||||
-sin(g.lat)*cos(g.lon), -sin(g.lon), -cos(g.lat)*cos(g.lon),
|
||||
-sin(g.lat)*sin(g.lon), cos(g.lon), -cos(g.lat)*sin(g.lon),
|
||||
cos(g.lat), 0, -sin(g.lat);
|
||||
ecef2ned_matrix = ned2ecef_matrix.transpose();
|
||||
}
|
||||
|
||||
NED LocalCoord::ecef2ned(const ECEF &e) {
|
||||
Eigen::Vector3d ecef;
|
||||
ecef << e.x, e.y, e.z;
|
||||
|
||||
Eigen::Vector3d ned = (ecef2ned_matrix * (ecef - init_ecef));
|
||||
return {ned[0], ned[1], ned[2]};
|
||||
}
|
||||
|
||||
ECEF LocalCoord::ned2ecef(const NED &n) {
|
||||
Eigen::Vector3d ned;
|
||||
ned << n.n, n.e, n.d;
|
||||
|
||||
Eigen::Vector3d ecef = (ned2ecef_matrix * ned) + init_ecef;
|
||||
return {ecef[0], ecef[1], ecef[2]};
|
||||
}
|
||||
|
||||
NED LocalCoord::geodetic2ned(const Geodetic &g) {
|
||||
ECEF e = ::geodetic2ecef(g);
|
||||
return ecef2ned(e);
|
||||
}
|
||||
|
||||
Geodetic LocalCoord::ned2geodetic(const NED &n) {
|
||||
ECEF e = ned2ecef(n);
|
||||
return ::ecef2geodetic(e);
|
||||
}
|
||||
43
iqpilot/common/transformations/coordinates.hpp
Normal file
@@ -0,0 +1,43 @@
|
||||
#pragma once
|
||||
|
||||
#include <eigen3/Eigen/Dense>
|
||||
|
||||
#define DEG2RAD(x) ((x) * M_PI / 180.0)
|
||||
#define RAD2DEG(x) ((x) * 180.0 / M_PI)
|
||||
|
||||
struct ECEF {
|
||||
double x, y, z;
|
||||
Eigen::Vector3d to_vector() const {
|
||||
return Eigen::Vector3d(x, y, z);
|
||||
}
|
||||
};
|
||||
|
||||
struct NED {
|
||||
double n, e, d;
|
||||
Eigen::Vector3d to_vector() const {
|
||||
return Eigen::Vector3d(n, e, d);
|
||||
}
|
||||
};
|
||||
|
||||
struct Geodetic {
|
||||
double lat, lon, alt;
|
||||
bool radians=false;
|
||||
};
|
||||
|
||||
ECEF geodetic2ecef(const Geodetic &g);
|
||||
Geodetic ecef2geodetic(const ECEF &e);
|
||||
|
||||
class LocalCoord {
|
||||
public:
|
||||
Eigen::Matrix3d ned2ecef_matrix;
|
||||
Eigen::Matrix3d ecef2ned_matrix;
|
||||
Eigen::Vector3d init_ecef;
|
||||
LocalCoord(const Geodetic &g, const ECEF &e);
|
||||
LocalCoord(const Geodetic &g) : LocalCoord(g, ::geodetic2ecef(g)) {}
|
||||
LocalCoord(const ECEF &e) : LocalCoord(::ecef2geodetic(e), e) {}
|
||||
|
||||
NED ecef2ned(const ECEF &e);
|
||||
ECEF ned2ecef(const NED &n);
|
||||
NED geodetic2ned(const Geodetic &g);
|
||||
Geodetic ned2geodetic(const NED &n);
|
||||
};
|
||||
143
iqpilot/common/transformations/orientation.cc
Normal file
@@ -0,0 +1,143 @@
|
||||
#define _USE_MATH_DEFINES
|
||||
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
#include <eigen3/Eigen/Dense>
|
||||
|
||||
#include "iqpilot/common/transformations/orientation.hpp"
|
||||
#include "iqpilot/common/transformations/coordinates.hpp"
|
||||
|
||||
Eigen::Quaterniond ensure_unique(const Eigen::Quaterniond &quat) {
|
||||
if (quat.w() > 0){
|
||||
return quat;
|
||||
} else {
|
||||
return Eigen::Quaterniond(-quat.w(), -quat.x(), -quat.y(), -quat.z());
|
||||
}
|
||||
}
|
||||
|
||||
Eigen::Quaterniond euler2quat(const Eigen::Vector3d &euler) {
|
||||
Eigen::Quaterniond q;
|
||||
|
||||
q = Eigen::AngleAxisd(euler(2), Eigen::Vector3d::UnitZ())
|
||||
* Eigen::AngleAxisd(euler(1), Eigen::Vector3d::UnitY())
|
||||
* Eigen::AngleAxisd(euler(0), Eigen::Vector3d::UnitX());
|
||||
return ensure_unique(q);
|
||||
}
|
||||
|
||||
|
||||
Eigen::Vector3d quat2euler(const Eigen::Quaterniond &quat) {
|
||||
// TODO: switch to eigen implementation if the range of the Euler angles doesn't matter anymore
|
||||
// Eigen::Vector3d euler = quat.toRotationMatrix().eulerAngles(2, 1, 0);
|
||||
// return {euler(2), euler(1), euler(0)};
|
||||
double gamma = atan2(2 * (quat.w() * quat.x() + quat.y() * quat.z()), 1 - 2 * (quat.x()*quat.x() + quat.y()*quat.y()));
|
||||
double asin_arg_clipped = std::clamp(2 * (quat.w() * quat.y() - quat.z() * quat.x()), -1.0, 1.0);
|
||||
double theta = asin(asin_arg_clipped);
|
||||
double psi = atan2(2 * (quat.w() * quat.z() + quat.x() * quat.y()), 1 - 2 * (quat.y()*quat.y() + quat.z()*quat.z()));
|
||||
return {gamma, theta, psi};
|
||||
}
|
||||
|
||||
Eigen::Matrix3d quat2rot(const Eigen::Quaterniond &quat) {
|
||||
return quat.toRotationMatrix();
|
||||
}
|
||||
|
||||
Eigen::Quaterniond rot2quat(const Eigen::Matrix3d &rot) {
|
||||
return ensure_unique(Eigen::Quaterniond(rot));
|
||||
}
|
||||
|
||||
Eigen::Matrix3d euler2rot(const Eigen::Vector3d &euler) {
|
||||
return quat2rot(euler2quat(euler));
|
||||
}
|
||||
|
||||
Eigen::Vector3d rot2euler(const Eigen::Matrix3d &rot) {
|
||||
return quat2euler(rot2quat(rot));
|
||||
}
|
||||
|
||||
Eigen::Matrix3d rot_matrix(double roll, double pitch, double yaw) {
|
||||
return euler2rot({roll, pitch, yaw});
|
||||
}
|
||||
|
||||
Eigen::Matrix3d rot(const Eigen::Vector3d &axis, double angle) {
|
||||
Eigen::Quaterniond q;
|
||||
q = Eigen::AngleAxisd(angle, axis);
|
||||
return q.toRotationMatrix();
|
||||
}
|
||||
|
||||
|
||||
Eigen::Vector3d ecef_euler_from_ned(const ECEF &ecef_init, const Eigen::Vector3d &ned_pose) {
|
||||
/*
|
||||
Using Rotations to Build Aerospace Coordinate Systems
|
||||
Don Koks
|
||||
https://apps.dtic.mil/dtic/tr/fulltext/u2/a484864.pdf
|
||||
*/
|
||||
LocalCoord converter = LocalCoord(ecef_init);
|
||||
Eigen::Vector3d zero = ecef_init.to_vector();
|
||||
|
||||
Eigen::Vector3d x0 = converter.ned2ecef({1, 0, 0}).to_vector() - zero;
|
||||
Eigen::Vector3d y0 = converter.ned2ecef({0, 1, 0}).to_vector() - zero;
|
||||
Eigen::Vector3d z0 = converter.ned2ecef({0, 0, 1}).to_vector() - zero;
|
||||
|
||||
Eigen::Vector3d x1 = rot(z0, ned_pose(2)) * x0;
|
||||
Eigen::Vector3d y1 = rot(z0, ned_pose(2)) * y0;
|
||||
Eigen::Vector3d z1 = rot(z0, ned_pose(2)) * z0;
|
||||
|
||||
Eigen::Vector3d x2 = rot(y1, ned_pose(1)) * x1;
|
||||
Eigen::Vector3d y2 = rot(y1, ned_pose(1)) * y1;
|
||||
Eigen::Vector3d z2 = rot(y1, ned_pose(1)) * z1;
|
||||
|
||||
Eigen::Vector3d x3 = rot(x2, ned_pose(0)) * x2;
|
||||
Eigen::Vector3d y3 = rot(x2, ned_pose(0)) * y2;
|
||||
|
||||
|
||||
x0 = Eigen::Vector3d(1, 0, 0);
|
||||
y0 = Eigen::Vector3d(0, 1, 0);
|
||||
z0 = Eigen::Vector3d(0, 0, 1);
|
||||
|
||||
double psi = atan2(x3.dot(y0), x3.dot(x0));
|
||||
double theta = atan2(-x3.dot(z0), sqrt(pow(x3.dot(x0), 2) + pow(x3.dot(y0), 2)));
|
||||
|
||||
y2 = rot(z0, psi) * y0;
|
||||
z2 = rot(y2, theta) * z0;
|
||||
|
||||
double phi = atan2(y3.dot(z2), y3.dot(y2));
|
||||
|
||||
return {phi, theta, psi};
|
||||
}
|
||||
|
||||
Eigen::Vector3d ned_euler_from_ecef(const ECEF &ecef_init, const Eigen::Vector3d &ecef_pose) {
|
||||
/*
|
||||
Using Rotations to Build Aerospace Coordinate Systems
|
||||
Don Koks
|
||||
https://apps.dtic.mil/dtic/tr/fulltext/u2/a484864.pdf
|
||||
*/
|
||||
LocalCoord converter = LocalCoord(ecef_init);
|
||||
|
||||
Eigen::Vector3d x0 = Eigen::Vector3d(1, 0, 0);
|
||||
Eigen::Vector3d y0 = Eigen::Vector3d(0, 1, 0);
|
||||
Eigen::Vector3d z0 = Eigen::Vector3d(0, 0, 1);
|
||||
|
||||
Eigen::Vector3d x1 = rot(z0, ecef_pose(2)) * x0;
|
||||
Eigen::Vector3d y1 = rot(z0, ecef_pose(2)) * y0;
|
||||
Eigen::Vector3d z1 = rot(z0, ecef_pose(2)) * z0;
|
||||
|
||||
Eigen::Vector3d x2 = rot(y1, ecef_pose(1)) * x1;
|
||||
Eigen::Vector3d y2 = rot(y1, ecef_pose(1)) * y1;
|
||||
Eigen::Vector3d z2 = rot(y1, ecef_pose(1)) * z1;
|
||||
|
||||
Eigen::Vector3d x3 = rot(x2, ecef_pose(0)) * x2;
|
||||
Eigen::Vector3d y3 = rot(x2, ecef_pose(0)) * y2;
|
||||
|
||||
Eigen::Vector3d zero = ecef_init.to_vector();
|
||||
x0 = converter.ned2ecef({1, 0, 0}).to_vector() - zero;
|
||||
y0 = converter.ned2ecef({0, 1, 0}).to_vector() - zero;
|
||||
z0 = converter.ned2ecef({0, 0, 1}).to_vector() - zero;
|
||||
|
||||
double psi = atan2(x3.dot(y0), x3.dot(x0));
|
||||
double theta = atan2(-x3.dot(z0), sqrt(pow(x3.dot(x0), 2) + pow(x3.dot(y0), 2)));
|
||||
|
||||
y2 = rot(z0, psi) * y0;
|
||||
z2 = rot(y2, theta) * z0;
|
||||
|
||||
double phi = atan2(y3.dot(z2), y3.dot(y2));
|
||||
|
||||
return {phi, theta, psi};
|
||||
}
|
||||
17
iqpilot/common/transformations/orientation.hpp
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <eigen3/Eigen/Dense>
|
||||
#include "iqpilot/common/transformations/coordinates.hpp"
|
||||
|
||||
|
||||
Eigen::Quaterniond ensure_unique(const Eigen::Quaterniond &quat);
|
||||
|
||||
Eigen::Quaterniond euler2quat(const Eigen::Vector3d &euler);
|
||||
Eigen::Vector3d quat2euler(const Eigen::Quaterniond &quat);
|
||||
Eigen::Matrix3d quat2rot(const Eigen::Quaterniond &quat);
|
||||
Eigen::Quaterniond rot2quat(const Eigen::Matrix3d &rot);
|
||||
Eigen::Matrix3d euler2rot(const Eigen::Vector3d &euler);
|
||||
Eigen::Vector3d rot2euler(const Eigen::Matrix3d &rot);
|
||||
Eigen::Matrix3d rot_matrix(double roll, double pitch, double yaw);
|
||||
Eigen::Matrix3d rot(const Eigen::Vector3d &axis, double angle);
|
||||
Eigen::Vector3d ecef_euler_from_ned(const ECEF &ecef_init, const Eigen::Vector3d &ned_pose);
|
||||
Eigen::Vector3d ned_euler_from_ecef(const ECEF &ecef_init, const Eigen::Vector3d &ecef_pose);
|
||||
1
iqpilot/common/version.h
Normal file
@@ -0,0 +1 @@
|
||||
#define IQPILOT_VERSION "IQ.Pilot 1.0c"
|
||||
5
iqpilot/iq_maps/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
|
||||
VENDOR_MAPD_BIN_DIR = os.path.join(BASEDIR, "third_party/mapd_pfeiferj")
|
||||
VENDOR_MAPD_PATH = os.path.join(VENDOR_MAPD_BIN_DIR, "mapd")
|
||||
469
iqpilot/iq_maps/orchestrator.py
Executable file
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
import platform
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import cereal.messaging as messaging
|
||||
from cereal import custom
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import Ratekeeper, config_realtime_process
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
from openpilot.iqpilot.iq_maps import VENDOR_MAPD_BIN_DIR, VENDOR_MAPD_PATH
|
||||
from openpilot.iqpilot.iq_maps.tile_bundle_downloader import TileBundleDownloader, region_bundle_installed
|
||||
from openpilot.iqpilot.iq_maps.vendor_mapd_installer import VendorMapdInstaller
|
||||
|
||||
OfflineMapAction = custom.MapdInputType
|
||||
_region_sync_worker: threading.Thread | None = None
|
||||
|
||||
# mapd_manager only runs offroad (process_config.only_offroad) and the onroad
|
||||
# NativeProcess("mapd", ...) is started the instant `started` flips True. If a
|
||||
# vendor-map download is in flight at that exact moment, the two `mapd`
|
||||
# binaries end up pointed at the same Paths.mapd_root() tile directory at the
|
||||
# same time: this one still downloading/writing, the onroad one already
|
||||
# mmap-reading. Manager only sends SIGINT/SIGTERM to stop mapd_manager, which
|
||||
# by default only interrupts the main thread — the background download thread
|
||||
# and the vendor `mapd` subprocess it spawned are otherwise orphaned and keep
|
||||
# writing into the tile directory the onroad reader just opened, which is what
|
||||
# was segfaulting (-12) the onroad process in a tight restart loop. The lock +
|
||||
# pidfile below make sure that subprocess is always killed (on clean shutdown
|
||||
# via the signal handlers, and on the next boot if this process itself got
|
||||
# SIGKILLed) before anything else is allowed to read the tile directory.
|
||||
_active_proc_lock = threading.Lock()
|
||||
_active_proc: subprocess.Popen | None = None
|
||||
_shutdown = threading.Event()
|
||||
# Display-tile bundles for the offline on-screen map (separate asset from mapd's routing
|
||||
# data). Downloaded after the mapd fetch in the same worker so a region selection installs
|
||||
# both, and independently restorable when only the tile bundle is missing.
|
||||
_tile_downloader: TileBundleDownloader | None = None
|
||||
|
||||
|
||||
def _vendor_fetch_pidfile() -> str:
|
||||
return os.path.join(Paths.mapd_root(), ".vendor_fetch.pid")
|
||||
|
||||
|
||||
def _pid_is_vendor_fetch(pid: int) -> bool:
|
||||
try:
|
||||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||
cmdline = f.read()
|
||||
except OSError:
|
||||
return False
|
||||
return VENDOR_MAPD_PATH.encode() in cmdline
|
||||
|
||||
|
||||
def _reap_orphaned_vendor_fetch() -> None:
|
||||
"""Kill any vendor-fetch mapd subprocess left running from a prior, uncleanly-terminated run."""
|
||||
pidfile = _vendor_fetch_pidfile()
|
||||
try:
|
||||
with open(pidfile) as f:
|
||||
pid = int(f.read().strip())
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
try:
|
||||
if _pid_is_vendor_fetch(pid):
|
||||
cloudlog.warning(f"iq_maps: reaping orphaned vendor-fetch mapd pid={pid} from a prior run")
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
for _ in range(20):
|
||||
time.sleep(0.1)
|
||||
if not _pid_is_vendor_fetch(pid):
|
||||
break
|
||||
else:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.remove(pidfile)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _kill_active_proc() -> None:
|
||||
with _active_proc_lock:
|
||||
proc = _active_proc
|
||||
if proc is None or proc.poll() is not None:
|
||||
return
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _handle_shutdown_signal(signum, _frame) -> None:
|
||||
cloudlog.warning(f"iq_maps: mapd_manager received signal {signum}, cleaning up vendor-fetch subprocess")
|
||||
_shutdown.set()
|
||||
_kill_active_proc()
|
||||
if _tile_downloader is not None:
|
||||
_tile_downloader.cancel()
|
||||
worker = _region_sync_worker
|
||||
if worker is not None and worker.is_alive():
|
||||
worker.join(timeout=3)
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def _install_signal_handlers() -> None:
|
||||
signal.signal(signal.SIGINT, _handle_shutdown_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_shutdown_signal)
|
||||
|
||||
|
||||
class _QuietSpinner:
|
||||
def update(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
def close(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_vendor_runtime() -> None:
|
||||
try:
|
||||
VendorMapdInstaller(_QuietSpinner()).check_and_download()
|
||||
except Exception:
|
||||
cloudlog.exception("iq_maps: vendor runtime install/download failed")
|
||||
|
||||
params = Params()
|
||||
mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else params
|
||||
|
||||
|
||||
def stale_region_artifacts() -> list[str]:
|
||||
patterns = [
|
||||
f"{Paths.mapd_root()}/db",
|
||||
f"{Paths.mapd_root()}/v*"
|
||||
]
|
||||
stale_paths: list[str] = []
|
||||
for pattern in patterns:
|
||||
for match in glob.glob(pattern):
|
||||
stale_paths.append(match)
|
||||
if os.path.isdir(match):
|
||||
stale_paths.extend(glob.glob(match + '/**', recursive=True))
|
||||
if not os.path.isfile(VENDOR_MAPD_PATH):
|
||||
stale_paths.append(VENDOR_MAPD_PATH)
|
||||
return stale_paths
|
||||
|
||||
|
||||
def purge_stale_region_artifacts(stale_paths: list[str]) -> None:
|
||||
for candidate in stale_paths:
|
||||
if candidate.endswith('/') and os.path.isfile(candidate[:-1]):
|
||||
candidate = candidate[:-1]
|
||||
if os.path.islink(candidate) or os.path.isfile(candidate):
|
||||
os.remove(candidate)
|
||||
elif os.path.isdir(candidate):
|
||||
shutil.rmtree(candidate, ignore_errors=False)
|
||||
|
||||
|
||||
def _compose_region_selector(nations: list[str], states: list[str] | None = None) -> str:
|
||||
requested_paths: list[str] = []
|
||||
for state_code in (states or []):
|
||||
code = str(state_code).strip().upper()
|
||||
if code and code != "ALL":
|
||||
requested_paths.append(f"us_state.{code}")
|
||||
for nation_code in (nations or []):
|
||||
code = str(nation_code).strip().upper()
|
||||
if code:
|
||||
requested_paths.append(f"nation.{code}")
|
||||
return ",".join(requested_paths)
|
||||
|
||||
|
||||
def _fetch_tile_bundles(region_selector: str, abort_check=None) -> None:
|
||||
"""Download the offline on-screen map display tiles for the selected regions.
|
||||
|
||||
Separate asset from mapd's routing data: the on-screen map's OsmOfflineProvider reads
|
||||
raster .mbtiles bundles, so a region selection installs both when OfflineOSMaps is on."""
|
||||
global _tile_downloader
|
||||
if not params.get_bool("OfflineOSMaps"):
|
||||
return
|
||||
selectors = [part for part in region_selector.split(",") if part]
|
||||
if not selectors:
|
||||
return
|
||||
try:
|
||||
_tile_downloader = TileBundleDownloader(params=params, mem_params=mem_params, abort_check=abort_check)
|
||||
_tile_downloader.download_regions(selectors)
|
||||
except Exception:
|
||||
cloudlog.exception("iq_maps: tile bundle download failed")
|
||||
finally:
|
||||
_tile_downloader = None
|
||||
|
||||
|
||||
def _drive_vendor_fetch(region_selector: str, requested_regions: dict) -> None:
|
||||
global _active_proc
|
||||
proc = None
|
||||
cancelled = False
|
||||
try:
|
||||
mem_params.put("OSMDownloadLocations", requested_regions)
|
||||
proc = subprocess.Popen([VENDOR_MAPD_PATH], cwd=VENDOR_MAPD_BIN_DIR,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
with _active_proc_lock:
|
||||
_active_proc = proc
|
||||
with open(_vendor_fetch_pidfile(), "w") as f:
|
||||
f.write(str(proc.pid))
|
||||
|
||||
pm = messaging.PubMaster(["mapdIn"])
|
||||
sm = messaging.SubMaster(["mapdExtendedOut"])
|
||||
time.sleep(4.0)
|
||||
|
||||
for _ in range(10):
|
||||
msg = messaging.new_message("mapdIn")
|
||||
msg.mapdIn.type = OfflineMapAction.download
|
||||
msg.mapdIn.str = region_selector
|
||||
pm.send("mapdIn", msg)
|
||||
time.sleep(0.2)
|
||||
|
||||
started = False
|
||||
deadline = time.monotonic() + 3600.0
|
||||
while time.monotonic() < deadline and not _shutdown.is_set():
|
||||
sm.update(500)
|
||||
dp = sm["mapdExtendedOut"].downloadProgress
|
||||
mem_params.put("OSMDownloadProgress", {
|
||||
"active": bool(dp.active),
|
||||
"total_files": int(dp.totalFiles),
|
||||
"downloaded_files": int(dp.downloadedFiles),
|
||||
})
|
||||
if dp.active:
|
||||
started = True
|
||||
elif started:
|
||||
break
|
||||
if not mem_params.get("OSMDownloadLocations"):
|
||||
cancelled = True
|
||||
cancel = messaging.new_message("mapdIn")
|
||||
cancel.mapdIn.type = OfflineMapAction.cancelDownload
|
||||
pm.send("mapdIn", cancel)
|
||||
break
|
||||
cloudlog.info(f"iq_maps: vendor map download finished for {region_selector}")
|
||||
if not cancelled and not _shutdown.is_set():
|
||||
# OSMDownloadLocations stays set until the finally below, so the konn3kt cancel RPC
|
||||
# (which removes it) aborts the tile phase exactly like it cancels the mapd phase.
|
||||
_fetch_tile_bundles(region_selector, abort_check=lambda: _shutdown.is_set() or not mem_params.get("OSMDownloadLocations"))
|
||||
except Exception:
|
||||
cloudlog.exception("iq_maps: vendor map download failed")
|
||||
finally:
|
||||
try:
|
||||
mem_params.remove("OSMDownloadLocations")
|
||||
except Exception:
|
||||
pass
|
||||
if proc is not None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
with _active_proc_lock:
|
||||
_active_proc = None
|
||||
try:
|
||||
os.remove(_vendor_fetch_pidfile())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def queue_region_refresh(nations: list[str], states: list[str] | None = None) -> None:
|
||||
global _region_sync_worker
|
||||
params.put("OsmDownloadedDate", str(datetime.now().timestamp()))
|
||||
params.put_bool("OsmDbUpdatesCheck", False)
|
||||
|
||||
region_selector = _compose_region_selector(nations, states)
|
||||
if not region_selector:
|
||||
cloudlog.warning("iq_maps: no region selected for offline map download")
|
||||
return
|
||||
if _region_sync_worker is not None and _region_sync_worker.is_alive():
|
||||
cloudlog.warning("iq_maps: vendor map download already in progress")
|
||||
return
|
||||
|
||||
requested_regions = {"nations": nations, "states": states or [], "paths": region_selector}
|
||||
cloudlog.info(f"iq_maps: starting vendor map download for {region_selector}")
|
||||
_region_sync_worker = threading.Thread(
|
||||
target=_drive_vendor_fetch,
|
||||
args=(region_selector, requested_regions),
|
||||
daemon=True,
|
||||
)
|
||||
_region_sync_worker.start()
|
||||
|
||||
|
||||
def normalize_region_selection(nations: list[str], states: list[str] | None = None) -> tuple[list[str], list[str]]:
|
||||
normalized_nations = list(nations)
|
||||
normalized_states = list(states or [])
|
||||
lowered_states = {entry.lower() for entry in normalized_states}
|
||||
|
||||
if "US" in normalized_nations and normalized_states and "all" not in lowered_states:
|
||||
normalized_nations = [entry for entry in normalized_nations if entry != "US"]
|
||||
elif normalized_states:
|
||||
normalized_states = [entry for entry in normalized_states if entry.lower() != "all"]
|
||||
|
||||
return normalized_nations, normalized_states
|
||||
|
||||
|
||||
_AUTO_RESTORE_INTERVAL_S = 1800.0
|
||||
_last_auto_restore_t = 0.0
|
||||
|
||||
|
||||
def region_data_missing() -> bool:
|
||||
# a media wipe (reflash/format) can delete the downloaded region while the params
|
||||
# that configure offline maps survive; mapd then retries the missing files forever
|
||||
# and nothing re-downloads (stale_region_artifacts only sees leftover files)
|
||||
if not params.get_bool("OsmLocal"):
|
||||
return False
|
||||
if not params.get("OsmDownloadedDate"):
|
||||
return False
|
||||
if glob.glob(f"{Paths.mapd_root()}/db") or glob.glob(f"{Paths.mapd_root()}/v*"):
|
||||
return False
|
||||
# mapd v2 stores region tiles under offline/<evenLat>/<evenLon>.tar.gz — without this
|
||||
# check a v2 install looks perpetually wiped and re-downloads every backoff interval
|
||||
if glob.glob(f"{Paths.mapd_root()}/offline/*/*"):
|
||||
return False
|
||||
country = params.get("OsmLocationName", return_default=True)
|
||||
return bool(country)
|
||||
|
||||
|
||||
def configured_states() -> list[str]:
|
||||
"""Selected US states: OsmStateNames (JSON list, multi-state) wins; the legacy
|
||||
single OsmStateName remains the fallback for pre-list configs."""
|
||||
try:
|
||||
states = params.get("OsmStateNames")
|
||||
if isinstance(states, bytes):
|
||||
import json as _json
|
||||
states = _json.loads(states.decode("utf-8"))
|
||||
if isinstance(states, str):
|
||||
import json as _json
|
||||
states = _json.loads(states)
|
||||
if isinstance(states, list) and states:
|
||||
return [str(s).strip().upper() for s in states if str(s).strip()]
|
||||
except Exception:
|
||||
pass
|
||||
state = params.get("OsmStateName", return_default=True)
|
||||
return [state] if state else []
|
||||
|
||||
|
||||
def maybe_auto_restore_region() -> None:
|
||||
global _last_auto_restore_t
|
||||
if not region_data_missing():
|
||||
return
|
||||
if _region_sync_worker is not None and _region_sync_worker.is_alive():
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - _last_auto_restore_t < _AUTO_RESTORE_INTERVAL_S:
|
||||
return
|
||||
_last_auto_restore_t = now
|
||||
country = params.get("OsmLocationName", return_default=True)
|
||||
states = configured_states()
|
||||
nations, states_filtered = normalize_region_selection([country], states)
|
||||
cloudlog.warning(f"iq_maps: configured offline region {country}/{states} has no data on disk; auto-restoring")
|
||||
queue_region_refresh(nations, states_filtered)
|
||||
|
||||
|
||||
_TILE_RESTORE_INTERVAL_S = 1800.0
|
||||
_last_tile_restore_t = 0.0
|
||||
_tile_only_worker: threading.Thread | None = None
|
||||
|
||||
|
||||
def _configured_region_selector() -> str:
|
||||
country = params.get("OsmLocationName", return_default=True)
|
||||
states = configured_states()
|
||||
nations, states_filtered = normalize_region_selection([country] if country else [], states)
|
||||
return _compose_region_selector(nations, states_filtered)
|
||||
|
||||
|
||||
def tile_bundles_missing() -> bool:
|
||||
# covers a media wipe AND the user enabling OfflineOSMaps after the region download
|
||||
# already ran (the vendor fetch only pulls tile bundles when the toggle is on)
|
||||
if not params.get_bool("OfflineOSMaps"):
|
||||
return False
|
||||
selector = _configured_region_selector()
|
||||
if not selector:
|
||||
return False
|
||||
return any(not region_bundle_installed(part) for part in selector.split(",") if part)
|
||||
|
||||
|
||||
def maybe_restore_tile_bundles() -> None:
|
||||
"""Tile-only download: don't re-run the whole mapd vendor fetch when only the display
|
||||
tiles are missing."""
|
||||
global _last_tile_restore_t, _tile_only_worker
|
||||
if not tile_bundles_missing():
|
||||
return
|
||||
if _region_sync_worker is not None and _region_sync_worker.is_alive():
|
||||
return
|
||||
if _tile_only_worker is not None and _tile_only_worker.is_alive():
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - _last_tile_restore_t < _TILE_RESTORE_INTERVAL_S:
|
||||
return
|
||||
_last_tile_restore_t = now
|
||||
selector = _configured_region_selector()
|
||||
cloudlog.warning(f"iq_maps: offline map tile bundles missing for {selector}; downloading")
|
||||
_tile_only_worker = threading.Thread(
|
||||
target=_fetch_tile_bundles,
|
||||
args=(selector,),
|
||||
kwargs={"abort_check": _shutdown.is_set},
|
||||
daemon=True,
|
||||
)
|
||||
_tile_only_worker.start()
|
||||
|
||||
|
||||
def sync_osm_request_flags() -> None:
|
||||
maybe_auto_restore_region()
|
||||
maybe_restore_tile_bundles()
|
||||
if params.get_bool("OsmDbUpdatesCheck"):
|
||||
if _region_sync_worker is not None and _region_sync_worker.is_alive():
|
||||
# A download is already writing into Paths.mapd_root() - deleting/rewriting
|
||||
# files under it right now would race the writer (and any onroad mapd
|
||||
# reader) the same way the orphaned-subprocess bug did. Wait for it to finish.
|
||||
return
|
||||
purge_stale_region_artifacts(stale_region_artifacts())
|
||||
country = params.get("OsmLocationName", return_default=True)
|
||||
states = configured_states()
|
||||
filtered_nations, filtered_states = normalize_region_selection([country], states)
|
||||
queue_region_refresh(filtered_nations, filtered_states)
|
||||
|
||||
if not mem_params.get("OSMDownloadBounds"):
|
||||
mem_params.put("OSMDownloadBounds", "")
|
||||
|
||||
if not mem_params.get("LastGPSPosition"):
|
||||
mem_params.put("LastGPSPosition", "{}")
|
||||
|
||||
|
||||
def run_loop():
|
||||
ensure_vendor_runtime()
|
||||
config_realtime_process([0, 1, 2, 3], 5)
|
||||
|
||||
rk = Ratekeeper(1, print_delay_threshold=None)
|
||||
|
||||
try:
|
||||
os.mkdir(Paths.mapd_root())
|
||||
except FileExistsError:
|
||||
pass
|
||||
except PermissionError:
|
||||
cloudlog.exception(f"iq_maps: failed to make {Paths.mapd_root()}")
|
||||
|
||||
# A prior run that got SIGKILLed (or crashed) may have left its vendor-fetch
|
||||
# mapd subprocess running and still writing into Paths.mapd_root(); clear it
|
||||
# before anything (including the onroad mapd, once `started` flips) reads
|
||||
# from that directory. Signal handlers cover the graceful-shutdown path.
|
||||
_reap_orphaned_vendor_fetch()
|
||||
_install_signal_handlers()
|
||||
|
||||
while not _shutdown.is_set():
|
||||
show_alert = stale_region_artifacts() and params.get_bool("OsmLocal")
|
||||
set_offroad_alert("Offroad_OSMUpdateRequired", show_alert, "This alert will be cleared when new maps are downloaded.")
|
||||
|
||||
sync_osm_request_flags()
|
||||
rk.keep_time()
|
||||
|
||||
|
||||
def main():
|
||||
run_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
25
iqpilot/iq_maps/road_data/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Shared tunables and a small debug logger for the offline road-name / turn-speed path.
|
||||
"""
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
# seconds of road ahead we scan for upcoming turn-speed zones published on iqLiveData
|
||||
LOOK_AHEAD_HORIZON_TIME = 15.0
|
||||
# clear the on-screen road name once it has gone this long without a refresh (s)
|
||||
ROAD_NAME_TIMEOUT = 30
|
||||
|
||||
R = 6373000.0 # mean Earth radius in metres (great-circle distance math)
|
||||
QUERY_RADIUS = 3000 # online OSM query reach, metres
|
||||
QUERY_RADIUS_OFFLINE = 2250 # offline-tile OSM query reach, metres
|
||||
|
||||
_DEBUG = False
|
||||
_CLOUDLOG_DEBUG = False
|
||||
|
||||
|
||||
def debug_road_data(msg, log_to_cloud=True):
|
||||
if _CLOUDLOG_DEBUG and log_to_cloud:
|
||||
cloudlog.debug(msg)
|
||||
if _DEBUG:
|
||||
print(msg)
|
||||
67
iqpilot/iq_maps/road_data/iq_road_layer.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import platform
|
||||
|
||||
from cereal import custom
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.iqpilot.iq_maps.road_data.signal_bridge import RoadSignalBridge
|
||||
from openpilot.iqpilot.navd.helpers import Coordinate
|
||||
|
||||
|
||||
class IQRoadLayer(RoadSignalBridge):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params
|
||||
|
||||
def refresh_position(self) -> None:
|
||||
location = self.location_sub['iqLiveLocation']
|
||||
self.fix_ready = (
|
||||
location.solutionState == custom.IQLiveLocation.SolutionState.ready
|
||||
and location.geodeticPosition.isValid
|
||||
)
|
||||
|
||||
if self.fix_ready:
|
||||
self.heading_deg = math.degrees(location.alignedOrientationNed.values[2])
|
||||
self.last_coordinate = Coordinate(location.geodeticPosition.values[0], location.geodeticPosition.values[1])
|
||||
|
||||
if self.last_coordinate is None:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"latitude": self.last_coordinate.latitude,
|
||||
"longitude": self.last_coordinate.longitude,
|
||||
}
|
||||
|
||||
if self.heading_deg is not None:
|
||||
payload["bearing"] = self.heading_deg
|
||||
|
||||
self.mem_params.put("LastGPSPosition", json.dumps(payload))
|
||||
|
||||
def read_current_limit(self) -> float:
|
||||
return float(self.mem_params.get("MapSpeedLimit") or 0.0)
|
||||
|
||||
def read_current_road(self) -> str:
|
||||
return str(self.mem_params.get("RoadName") or "")
|
||||
|
||||
def read_upcoming_limit(self) -> tuple[float, float]:
|
||||
raw_segment = self.mem_params.get("NextMapSpeedLimit")
|
||||
if isinstance(raw_segment, bytes):
|
||||
raw_segment = raw_segment.decode("utf-8")
|
||||
try:
|
||||
upcoming_segment = json.loads(raw_segment) if isinstance(raw_segment, str) and raw_segment else (raw_segment or {})
|
||||
except json.JSONDecodeError:
|
||||
upcoming_segment = {}
|
||||
|
||||
next_limit = float(upcoming_segment.get("speedlimit", 0.0) or 0.0)
|
||||
target_lat = upcoming_segment.get("latitude")
|
||||
target_lon = upcoming_segment.get("longitude")
|
||||
distance_to_limit = 0.0
|
||||
|
||||
if target_lat is not None and target_lon is not None:
|
||||
limit_coordinate = Coordinate(float(target_lat), float(target_lon))
|
||||
distance_to_limit = (self.last_coordinate or Coordinate(0, 0)).distance_to(limit_coordinate)
|
||||
|
||||
return next_limit, distance_to_limit
|
||||
35
iqpilot/iq_maps/road_data/road_daemon.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
from openpilot.common.realtime import Ratekeeper, config_realtime_process
|
||||
from openpilot.iqpilot.iq_maps.road_data import debug_road_data
|
||||
from openpilot.iqpilot.iq_maps.road_data.iq_road_layer import IQRoadLayer
|
||||
|
||||
ROAD_LAYER_HZ = 1
|
||||
ROAD_LAYER_CORES = [0, 1, 2, 3]
|
||||
|
||||
|
||||
def _log_thread_exception(args) -> None:
|
||||
debug_road_data(f"IQ maps threading exception:\n{args}")
|
||||
traceback.print_exception(args.exc_type, args.exc_value, args.exc_traceback)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
config_realtime_process(ROAD_LAYER_CORES, 5)
|
||||
layer = IQRoadLayer()
|
||||
rk = Ratekeeper(ROAD_LAYER_HZ, print_delay_threshold=None)
|
||||
while True:
|
||||
layer.step()
|
||||
rk.keep_time()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
threading.excepthook = _log_thread_exception
|
||||
run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
62
iqpilot/iq_maps/road_data/signal_bridge.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from abc import abstractmethod, ABC
|
||||
|
||||
import cereal.messaging as messaging
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
|
||||
from openpilot.iqpilot.navd.helpers import coordinate_from_param
|
||||
|
||||
ROAD_SPEED_CEILING = V_CRUISE_UNSET * CV.KPH_TO_MS
|
||||
|
||||
|
||||
class RoadSignalBridge(ABC):
|
||||
def __init__(self):
|
||||
self.params = Params()
|
||||
|
||||
self.location_sub = messaging.SubMaster(['iqLiveLocation'])
|
||||
self.output_pub = messaging.PubMaster(['iqLiveData'])
|
||||
|
||||
self.fix_ready = False
|
||||
self.heading_deg = None
|
||||
self.last_coordinate = coordinate_from_param("LastGPSPositionIQLoc", self.params)
|
||||
|
||||
@abstractmethod
|
||||
def refresh_position(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def read_current_limit(self) -> float:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def read_upcoming_limit(self) -> tuple[float, float]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def read_current_road(self) -> str:
|
||||
pass
|
||||
|
||||
def publish_snapshot(self) -> None:
|
||||
active_limit = self.read_current_limit()
|
||||
next_limit, next_limit_distance = self.read_upcoming_limit()
|
||||
|
||||
outbound = messaging.new_message('iqLiveData')
|
||||
outbound.valid = self.location_sub['iqLiveLocation'].gpsHealthy
|
||||
live_data = outbound.iqLiveData
|
||||
|
||||
live_data.speedLimitValid = bool(ROAD_SPEED_CEILING > active_limit > 0)
|
||||
live_data.speedLimit = active_limit
|
||||
live_data.speedLimitAheadValid = bool(ROAD_SPEED_CEILING > next_limit > 0)
|
||||
live_data.speedLimitAhead = next_limit
|
||||
live_data.speedLimitAheadDistance = next_limit_distance
|
||||
live_data.roadName = self.read_current_road()
|
||||
|
||||
self.output_pub.send('iqLiveData', outbound)
|
||||
|
||||
def step(self) -> None:
|
||||
self.location_sub.update(0)
|
||||
self.refresh_position()
|
||||
self.publish_snapshot()
|
||||
355
iqpilot/iq_maps/tile_bundle_downloader.py
Normal file
@@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Downloads per-region raster display-tile bundles (.mbtiles) for the offline on-screen map.
|
||||
|
||||
These are a separate asset from mapd's routing/speed-limit data: mapd pulls OSM way tiles
|
||||
into Paths.mapd_root(), while the on-screen map (OsmOfflineProvider) reads raster .mbtiles
|
||||
from offline_map_root()/regions/<selector>/tiles/offline.mbtiles. Bundles are built per
|
||||
state/nation by scripts/iqpilot/build_state_tile_bundles.py and hosted behind a static base
|
||||
URL that serves:
|
||||
|
||||
<base>/index.json {"version": 1, "regions": {<selector>: entry}}
|
||||
<base>/<entry["path"]> the raster .mbtiles for that region
|
||||
|
||||
Entry fields: path, bytes, sha256, bounds ("minLon,minLat,maxLon,maxLat"), minzoom, maxzoom.
|
||||
Selectors match the mapd region menu naming: us_state.CA, nation.US.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.iqpilot.ui.onroad.offline_tiles import offline_map_root
|
||||
|
||||
# Proprietary auth + hosted endpoints (gitea raw with an embedded read-only PAT, same
|
||||
# pattern as the model selector). Optional: without the private bundle the downloader
|
||||
# still works anonymously against OfflineTilesBaseUrl (e.g. a public R2 bucket).
|
||||
try:
|
||||
from openpilot.iqpilot.iq_maps.tiles_auth import get_base_urls as _private_base_urls, get_requests_auth as _private_auth
|
||||
except Exception: # ProprietaryModuleMissing or import errors in stripped builds
|
||||
_private_base_urls = None
|
||||
_private_auth = None
|
||||
|
||||
# R2 bucket iqnav behind the public custom domain (see scripts/iqpilot/tile_factory/r2_sync_watch.py)
|
||||
DEFAULT_TILE_BUNDLE_BASE_URL = "https://maps.konn3kt.com/iqosmd/v1"
|
||||
BASE_URL_PARAM = "OfflineTilesBaseUrl"
|
||||
PROGRESS_PARAM = "OfflineTilesDownloadProgress"
|
||||
REQUEST_PARAM = "OfflineTilesDownloadRequest"
|
||||
CHUNK_BYTES = 1 << 20
|
||||
HTTP_TIMEOUT_S = 30.0
|
||||
STREAM_RETRIES = 8
|
||||
|
||||
|
||||
def candidate_base_urls(params: Params) -> list[str]:
|
||||
"""Hosts to try in order: user/param override first, then the embedded private
|
||||
endpoints (gitea raw), then the public default."""
|
||||
override = params.get(BASE_URL_PARAM)
|
||||
if isinstance(override, bytes):
|
||||
override = override.decode("utf-8", errors="ignore")
|
||||
override = (override or "").strip()
|
||||
if override:
|
||||
return [override.rstrip("/")]
|
||||
urls: list[str] = []
|
||||
if _private_base_urls is not None:
|
||||
try:
|
||||
urls.extend(url.rstrip("/") for url in _private_base_urls())
|
||||
except Exception:
|
||||
pass
|
||||
urls.append(DEFAULT_TILE_BUNDLE_BASE_URL)
|
||||
return urls
|
||||
|
||||
|
||||
def request_auth() -> tuple[str, str] | None:
|
||||
if _private_auth is None:
|
||||
return None
|
||||
try:
|
||||
return _private_auth()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_index(base_url: str, session: requests.Session) -> dict:
|
||||
response = session.get(f"{base_url}/index.json", timeout=HTTP_TIMEOUT_S, auth=request_auth())
|
||||
response.raise_for_status()
|
||||
index = response.json()
|
||||
regions = index.get("regions")
|
||||
if not isinstance(regions, dict):
|
||||
raise ValueError("tile bundle index has no regions")
|
||||
return regions
|
||||
|
||||
|
||||
def region_bundle_dir(selector: str) -> Path:
|
||||
return offline_map_root() / "regions" / selector
|
||||
|
||||
|
||||
def region_bundle_path(selector: str) -> Path:
|
||||
return region_bundle_dir(selector) / "tiles" / "offline.mbtiles"
|
||||
|
||||
|
||||
def region_bundle_installed(selector: str) -> bool:
|
||||
return region_bundle_path(selector).exists()
|
||||
|
||||
|
||||
def installed_region_selectors() -> list[str]:
|
||||
regions_root = offline_map_root() / "regions"
|
||||
if not regions_root.exists():
|
||||
return []
|
||||
return sorted(
|
||||
child.name for child in regions_root.iterdir()
|
||||
if child.is_dir() and (child / "tiles" / "offline.mbtiles").exists()
|
||||
)
|
||||
|
||||
|
||||
def _hash_existing(path: Path) -> tuple["hashlib._Hash", int]:
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
digest.update(chunk)
|
||||
size += len(chunk)
|
||||
return digest, size
|
||||
|
||||
|
||||
def _write_manifest(selector: str, entry: dict) -> None:
|
||||
manifest = {
|
||||
"region": selector,
|
||||
"version": entry.get("version", ""),
|
||||
"mbtiles": {
|
||||
"bounds": entry.get("bounds", ""),
|
||||
"minzoom": entry.get("minzoom"),
|
||||
"maxzoom": entry.get("maxzoom"),
|
||||
"bytes": entry.get("bytes"),
|
||||
"sha256": entry.get("sha256", ""),
|
||||
},
|
||||
}
|
||||
if entry.get("day_path"):
|
||||
manifest["mbtiles_day"] = {
|
||||
"bytes": entry.get("day_bytes"),
|
||||
"sha256": entry.get("day_sha256", ""),
|
||||
}
|
||||
manifest_path = region_bundle_dir(selector) / "manifest.json"
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||
|
||||
|
||||
class TileBundleDownloader:
|
||||
"""Streams region bundles to disk with resume + sha256 verify + atomic install.
|
||||
|
||||
Cancellation matches the mapd flow: the caller sets REQUEST_PARAM in mem params while a
|
||||
download runs; removing it (konn3kt cancel RPC or settings) aborts between chunks. The
|
||||
partial .part file is kept so a retry resumes instead of restarting.
|
||||
"""
|
||||
|
||||
def __init__(self, params: Params | None = None, mem_params: Params | None = None,
|
||||
abort_check=None):
|
||||
self.params = params if params is not None else Params()
|
||||
if mem_params is not None:
|
||||
self.mem_params = mem_params
|
||||
else:
|
||||
self.mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else self.params
|
||||
self.session = requests.Session()
|
||||
self._cancelled = threading.Event()
|
||||
# optional external cancel signal, e.g. the orchestrator's OSMDownloadLocations removal
|
||||
self._abort_check = abort_check
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled.set()
|
||||
|
||||
def _should_abort(self) -> bool:
|
||||
if self._cancelled.is_set():
|
||||
return True
|
||||
if not self.mem_params.get(REQUEST_PARAM):
|
||||
# request flag was removed out from under us -> user cancelled
|
||||
self._cancelled.set()
|
||||
return True
|
||||
if self._abort_check is not None and self._abort_check():
|
||||
self._cancelled.set()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _publish_progress(self, region: str, downloaded: int, total: int, active: bool) -> None:
|
||||
self.mem_params.put(PROGRESS_PARAM, {
|
||||
"active": active,
|
||||
"region": region,
|
||||
"downloaded_bytes": int(downloaded),
|
||||
"total_bytes": int(total),
|
||||
})
|
||||
|
||||
def _download_one(self, selector: str, entry: dict, base_url: str,
|
||||
progress_offset: int, progress_total: int) -> bool:
|
||||
"""Download a region: the night bundle, plus the optional day-style variant."""
|
||||
night_path = region_bundle_path(selector)
|
||||
ok = self._download_file(
|
||||
selector, base_url, entry["path"], int(entry.get("bytes", 0)),
|
||||
str(entry.get("sha256", "")).strip().lower(), night_path,
|
||||
progress_offset, progress_total,
|
||||
)
|
||||
if not ok:
|
||||
return False
|
||||
if entry.get("day_path"):
|
||||
day_ok = self._download_file(
|
||||
selector, base_url, entry["day_path"], int(entry.get("day_bytes", 0)),
|
||||
str(entry.get("day_sha256", "")).strip().lower(),
|
||||
night_path.with_name("offline_day.mbtiles"),
|
||||
progress_offset + int(entry.get("bytes", 0)), progress_total,
|
||||
)
|
||||
if not day_ok:
|
||||
# the night set is complete and usable; a failed day variant retries next pass
|
||||
cloudlog.warning(f"iq_maps: day-style bundle failed for {selector}; night set installed")
|
||||
# manifest last: bounds drive region matching, so it must describe installed files
|
||||
_write_manifest(selector, entry)
|
||||
cloudlog.info(f"iq_maps: installed tile bundle {selector}")
|
||||
return True
|
||||
|
||||
def _download_file(self, selector: str, base_url: str, remote_path: str, expected_bytes: int,
|
||||
expected_sha: str, final_path: Path,
|
||||
progress_offset: int, progress_total: int) -> bool:
|
||||
url = f"{base_url}/{remote_path.lstrip('/')}"
|
||||
part_path = final_path.with_name(final_path.name + ".part")
|
||||
part_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# A cellular/hotspot link routinely kills a multi-hundred-MB stream mid-flight; retry
|
||||
# each interruption from the current .part offset instead of failing the whole region.
|
||||
downloaded = 0
|
||||
digest = hashlib.sha256()
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(STREAM_RETRIES):
|
||||
if self._should_abort():
|
||||
cloudlog.warning(f"iq_maps: tile bundle download cancelled for {selector}")
|
||||
return False
|
||||
if attempt:
|
||||
time.sleep(min(30.0, 2.0 * attempt))
|
||||
try:
|
||||
digest = hashlib.sha256()
|
||||
resume_from = 0
|
||||
if part_path.exists():
|
||||
digest, resume_from = _hash_existing(part_path)
|
||||
if expected_bytes and resume_from > expected_bytes:
|
||||
part_path.unlink()
|
||||
digest = hashlib.sha256()
|
||||
resume_from = 0
|
||||
|
||||
headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
|
||||
auth = request_auth()
|
||||
response = self.session.get(url, headers=headers, stream=True, timeout=HTTP_TIMEOUT_S, auth=auth)
|
||||
if resume_from and response.status_code != 206:
|
||||
# server ignored the Range request -> restart from scratch
|
||||
digest = hashlib.sha256()
|
||||
resume_from = 0
|
||||
part_path.unlink(missing_ok=True)
|
||||
if response.status_code == 416:
|
||||
response = self.session.get(url, stream=True, timeout=HTTP_TIMEOUT_S, auth=auth)
|
||||
response.raise_for_status()
|
||||
|
||||
downloaded = resume_from
|
||||
mode = "ab" if resume_from else "wb"
|
||||
with open(part_path, mode) as f:
|
||||
for chunk in response.iter_content(chunk_size=CHUNK_BYTES):
|
||||
if self._should_abort():
|
||||
cloudlog.warning(f"iq_maps: tile bundle download cancelled for {selector}")
|
||||
return False
|
||||
f.write(chunk)
|
||||
digest.update(chunk)
|
||||
downloaded += len(chunk)
|
||||
self._publish_progress(selector, progress_offset + downloaded, progress_total, active=True)
|
||||
break
|
||||
except requests.RequestException as exc:
|
||||
last_error = exc
|
||||
cloudlog.warning(f"iq_maps: tile bundle stream interrupted for {selector} "
|
||||
+ f"(attempt {attempt + 1}/{STREAM_RETRIES}): {exc}")
|
||||
else:
|
||||
raise requests.RequestException(f"stream failed after {STREAM_RETRIES} attempts") from last_error
|
||||
|
||||
if expected_bytes and downloaded != expected_bytes:
|
||||
cloudlog.error(f"iq_maps: tile bundle size mismatch for {selector}: {downloaded} != {expected_bytes}")
|
||||
part_path.unlink(missing_ok=True)
|
||||
return False
|
||||
if expected_sha and digest.hexdigest() != expected_sha:
|
||||
cloudlog.error(f"iq_maps: tile bundle sha256 mismatch for {selector}")
|
||||
part_path.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
part_path.replace(final_path)
|
||||
return True
|
||||
|
||||
def download_regions(self, selectors: list[str]) -> bool:
|
||||
"""Download the display-tile bundles for the given region selectors. Returns True if all
|
||||
requested bundles are installed and current when done."""
|
||||
self._cancelled.clear()
|
||||
ok = True
|
||||
try:
|
||||
self.mem_params.put(REQUEST_PARAM, {"regions": list(selectors)})
|
||||
regions = None
|
||||
base_url = ""
|
||||
for candidate in candidate_base_urls(self.params):
|
||||
try:
|
||||
regions = fetch_index(candidate, self.session)
|
||||
base_url = candidate
|
||||
break
|
||||
except (requests.RequestException, ValueError, json.JSONDecodeError):
|
||||
cloudlog.warning(f"iq_maps: tile bundle index unavailable at {candidate}")
|
||||
if regions is None:
|
||||
cloudlog.error("iq_maps: no tile bundle host reachable")
|
||||
return False
|
||||
|
||||
wanted: list[tuple[str, dict]] = []
|
||||
for selector in selectors:
|
||||
entry = regions.get(selector)
|
||||
if entry is None:
|
||||
cloudlog.warning(f"iq_maps: no tile bundle published for {selector}")
|
||||
ok = False
|
||||
continue
|
||||
if region_bundle_installed(selector) and self._installed_matches(selector, entry):
|
||||
continue
|
||||
wanted.append((selector, entry))
|
||||
|
||||
progress_total = sum(int(entry.get("bytes", 0)) + int(entry.get("day_bytes", 0)) for _, entry in wanted)
|
||||
progress_offset = 0
|
||||
for selector, entry in wanted:
|
||||
if self._should_abort():
|
||||
return False
|
||||
try:
|
||||
if not self._download_one(selector, entry, base_url, progress_offset, progress_total):
|
||||
ok = False
|
||||
except (requests.RequestException, OSError):
|
||||
cloudlog.exception(f"iq_maps: tile bundle download failed for {selector}")
|
||||
ok = False
|
||||
progress_offset += int(entry.get("bytes", 0)) + int(entry.get("day_bytes", 0))
|
||||
return ok
|
||||
finally:
|
||||
self._publish_progress("", 0, 0, active=False)
|
||||
try:
|
||||
self.mem_params.remove(REQUEST_PARAM)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _installed_matches(selector: str, entry: dict) -> bool:
|
||||
manifest_path = region_bundle_dir(selector) / "manifest.json"
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
installed_sha = str(manifest.get("mbtiles", {}).get("sha256", "")).strip().lower()
|
||||
expected_sha = str(entry.get("sha256", "")).strip().lower()
|
||||
if not expected_sha or installed_sha != expected_sha:
|
||||
return False
|
||||
if entry.get("day_path"):
|
||||
# a published day variant must be installed and current too
|
||||
day_file = region_bundle_dir(selector) / "tiles" / "offline_day.mbtiles"
|
||||
installed_day = str(manifest.get("mbtiles_day", {}).get("sha256", "")).strip().lower()
|
||||
expected_day = str(entry.get("day_sha256", "")).strip().lower()
|
||||
if not day_file.exists() or installed_day != expected_day:
|
||||
return False
|
||||
return True
|
||||
10
iqpilot/iq_maps/tiles_auth.py
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import ProprietaryModuleMissing, load_private_module
|
||||
|
||||
try:
|
||||
load_private_module(__name__, "iqpilot_private.maps.git_auth")
|
||||
except ProprietaryModuleMissing:
|
||||
from iqpilot.maps_private_src.git_auth import * # noqa: F403
|
||||
74
iqpilot/iq_maps/update_vendor_version.py
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Maintainer utility: pin a new pfeiferj/mapd release tag and refresh the checked-in
|
||||
binary hash. Not used at runtime.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.iqpilot.iq_maps import VENDOR_MAPD_PATH
|
||||
from openpilot.iqpilot.iq_maps.vendor_mapd_installer import (
|
||||
VENDOR_RELEASE_TAG,
|
||||
sha256_of_file,
|
||||
)
|
||||
|
||||
_RELEASE_SYMBOL = "VENDOR_RELEASE_TAG"
|
||||
_INSTALLER_SRC = os.path.join(BASEDIR, "iqpilot", "iq_maps", "vendor_mapd_installer.py")
|
||||
# public: the checked-in hash the version test compares the installed binary against
|
||||
HASH_FILE = os.path.join(BASEDIR, "iqpilot", "iq_maps", "tests", "mapd_hash")
|
||||
_HASH_FILE = HASH_FILE
|
||||
_TAG_ASSIGN = re.compile(rf'^{_RELEASE_SYMBOL}\s*=\s*["\'][^"\']*["\']', re.MULTILINE)
|
||||
|
||||
|
||||
def rewrite_pinned_tag(new_tag: str) -> bool:
|
||||
with open(_INSTALLER_SRC) as f:
|
||||
src = f.read()
|
||||
|
||||
patched, count = _TAG_ASSIGN.subn(f'{_RELEASE_SYMBOL} = "{new_tag}"', src, count=1)
|
||||
if count != 1:
|
||||
print(f"could not locate the {_RELEASE_SYMBOL} assignment in {_INSTALLER_SRC}; nothing written")
|
||||
return False
|
||||
|
||||
with open(_INSTALLER_SRC, "w") as f:
|
||||
f.write(patched)
|
||||
print(f"pinned {_RELEASE_SYMBOL} -> {new_tag}")
|
||||
return True
|
||||
|
||||
|
||||
def refresh_hash_file() -> None:
|
||||
digest = sha256_of_file(VENDOR_MAPD_PATH)
|
||||
with open(_HASH_FILE, "w") as f:
|
||||
f.write(digest)
|
||||
print(f"wrote binary hash {digest} -> {_HASH_FILE}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Pin a new mapd release tag and refresh its hash")
|
||||
parser.add_argument("--new_ver", type=str, help='e.g. --new_ver "v2.1.0"')
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.new_ver:
|
||||
parser.print_help()
|
||||
print(f'\ncurrently pinned: {VENDOR_RELEASE_TAG} (unchanged)')
|
||||
return 0
|
||||
|
||||
target = args.new_ver.strip()
|
||||
if target == VENDOR_RELEASE_TAG:
|
||||
reply = input(f"{target} is already the pinned tag — re-run anyway? (y/N): ").strip().lower()
|
||||
if reply != "y":
|
||||
print("aborted; nothing changed")
|
||||
return 0
|
||||
|
||||
if not rewrite_pinned_tag(target):
|
||||
return 1
|
||||
refresh_hash_file()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
181
iqpilot/iq_maps/vendor_mapd_installer.py
Executable file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Provisions the `mapd` routing binary authored by Jacob Pfeifer (github.com/pfeiferj/mapd).
|
||||
The binary itself is his work; this module only fetches, verifies and stages it on-device.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from cereal import messaging
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.spinner import Spinner
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
from openpilot.system.version import is_prebuilt
|
||||
from openpilot.iqpilot.iq_maps import VENDOR_MAPD_BIN_DIR, VENDOR_MAPD_PATH
|
||||
import openpilot.system.sentry as sentry
|
||||
|
||||
VENDOR_RELEASE_TAG = "v2.0.6"
|
||||
VENDOR_RELEASE_URL = f"https://github.com/pfeiferj/mapd/releases/download/{VENDOR_RELEASE_TAG}/mapd"
|
||||
|
||||
_VERSION_PARAM = "MapdVersion"
|
||||
_HASH_FILE = os.path.join(BASEDIR, "iqpilot", "iq_maps", "tests", "mapd_hash")
|
||||
_HTTP_TIMEOUT_S = 60
|
||||
_FETCH_ATTEMPTS = 5
|
||||
_NET_PROBE_ATTEMPTS = 10
|
||||
_NET_PROBE_INTERVAL_S = 2
|
||||
|
||||
|
||||
def sha256_of_file(path: str) -> str:
|
||||
"""Hex SHA-256 digest of a file on disk."""
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for block in iter(lambda: handle.read(1 << 20), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def stamp_vendor_version(version: str, params: Params | None = None) -> None:
|
||||
(params or Params()).put(_VERSION_PARAM, version)
|
||||
|
||||
|
||||
class VendorMapdInstaller:
|
||||
def __init__(self, spinner_ref: Spinner):
|
||||
self._spinner = spinner_ref
|
||||
self._params = Params()
|
||||
|
||||
# --- externally consumed surface -----------------------------------------
|
||||
def get_installed_version(self) -> str:
|
||||
return str(self._params.get(_VERSION_PARAM) or "")
|
||||
|
||||
@staticmethod
|
||||
def ensure_directories_exist() -> None:
|
||||
for directory in (Paths.mapd_root(), VENDOR_MAPD_BIN_DIR):
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
def check_and_download(self) -> None:
|
||||
if not self._binary_up_to_date():
|
||||
self._provision()
|
||||
|
||||
def non_prebuilt_install(self) -> None:
|
||||
if self._on_metered_link():
|
||||
self._say("Metered connection detected — offline maps engine will not download here.")
|
||||
time.sleep(5)
|
||||
return
|
||||
|
||||
try:
|
||||
self.ensure_directories_exist()
|
||||
if self._binary_up_to_date():
|
||||
self._say("Offline maps engine already present and current.")
|
||||
time.sleep(0.1)
|
||||
return
|
||||
|
||||
if self._block_until_online():
|
||||
self._say(f"Retrieving offline maps engine [{self.get_installed_version() or 'none'}] -> [{VENDOR_RELEASE_TAG}]")
|
||||
time.sleep(0.1)
|
||||
self._provision()
|
||||
self._spinner.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._announce_failure(exc)
|
||||
|
||||
# --- internal ------------------------------------------------------------
|
||||
def _expected_hash(self) -> str:
|
||||
try:
|
||||
with open(_HASH_FILE) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def _binary_up_to_date(self) -> bool:
|
||||
if not os.path.exists(VENDOR_MAPD_PATH):
|
||||
return False
|
||||
if self.get_installed_version() != VENDOR_RELEASE_TAG:
|
||||
return False
|
||||
reference = self._expected_hash()
|
||||
if not reference:
|
||||
return True
|
||||
try:
|
||||
return sha256_of_file(VENDOR_MAPD_PATH) == reference
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _provision(self) -> None:
|
||||
self.ensure_directories_exist()
|
||||
if self._retrieve_binary():
|
||||
stamp_vendor_version(VENDOR_RELEASE_TAG, self._params)
|
||||
|
||||
def _retrieve_binary(self) -> bool:
|
||||
staging = Path(f"{VENDOR_MAPD_PATH}.part")
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, _FETCH_ATTEMPTS + 1):
|
||||
try:
|
||||
with requests.get(VENDOR_RELEASE_URL, stream=True, timeout=_HTTP_TIMEOUT_S) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(staging, "wb") as out:
|
||||
for chunk in resp.iter_content(chunk_size=1 << 16):
|
||||
out.write(chunk)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.chmod(staging, os.lstat(staging).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
staging.replace(VENDOR_MAPD_PATH)
|
||||
return True
|
||||
except requests.exceptions.RequestException as exc:
|
||||
last_error = exc
|
||||
self._say(f"offline maps fetch attempt {attempt}/{_FETCH_ATTEMPTS} did not complete ({exc})")
|
||||
time.sleep(0.5)
|
||||
staging.unlink(missing_ok=True)
|
||||
logging.error("offline maps engine could not be fetched after %d attempts: %s", _FETCH_ATTEMPTS, last_error)
|
||||
return False
|
||||
|
||||
def _on_metered_link(self) -> bool:
|
||||
sm = messaging.SubMaster(["deviceState"])
|
||||
return bool(sm["deviceState"].networkMetered)
|
||||
|
||||
def _block_until_online(self) -> bool:
|
||||
for i in range(1, _NET_PROBE_ATTEMPTS + 1):
|
||||
self._say(f"Waiting for a usable network connection... [{i}/{_NET_PROBE_ATTEMPTS}]")
|
||||
if self._link_reachable():
|
||||
return True
|
||||
time.sleep(_NET_PROBE_INTERVAL_S)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _link_reachable() -> bool:
|
||||
try:
|
||||
requests.head(VENDOR_RELEASE_URL, timeout=10, allow_redirects=True)
|
||||
return True
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logging.debug("network probe failed: %s", exc)
|
||||
return False
|
||||
|
||||
def _announce_failure(self, exc: Exception) -> None:
|
||||
for remaining in range(5, 0, -1):
|
||||
self._say(f"Offline maps engine unavailable; navigation stays online-only. Boot continues in {remaining}s...")
|
||||
time.sleep(1)
|
||||
logging.exception("vendor mapd install failed")
|
||||
sentry.init(sentry.SentryProject.SELFDRIVE)
|
||||
sentry.capture_exception(exc)
|
||||
|
||||
def _say(self, text: str) -> None:
|
||||
self._spinner.update(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
spinner = Spinner()
|
||||
installer = VendorMapdInstaller(spinner)
|
||||
installer.ensure_directories_exist()
|
||||
if is_prebuilt():
|
||||
spinner.update(f"[DEBUG] Prebuilt build; vendor mapd install skipped. "
|
||||
f"target [{VENDOR_RELEASE_TAG}], param [{installer.get_installed_version()}]")
|
||||
stamp_vendor_version(VENDOR_RELEASE_TAG)
|
||||
else:
|
||||
spinner.update(f"Verifying vendor mapd install. prebuilt [{is_prebuilt()}]")
|
||||
installer.non_prebuilt_install()
|
||||
0
iqpilot/iq_maps/version.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# NNFF models removed pending retraining on user data.
|
||||
# latcontrol_torque skips NNFF entirely when this dir has no matching model.
|
||||
4
iqpilot/konn3kt/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright ©️ IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
17
iqpilot/konn3kt/cloud_client.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Copyright ©️ IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
from openpilot.common.api.base import BaseApi
|
||||
API_HOST = os.getenv('KONN3KT_API_HOST', 'https://api-iqlabs.konn3kt.com')
|
||||
|
||||
class Konn3ktApi(BaseApi):
|
||||
|
||||
def __init__(self, dongle_id):
|
||||
super().__init__(dongle_id, API_HOST)
|
||||
self.user_agent = "konn3kt-device-"
|
||||
|
||||
def get_token(self, expiry_hours=1):
|
||||
return super()._get_token(expiry_hours=expiry_hours)
|
||||
3
iqpilot/konn3kt/common/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright ©️ IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
53
iqpilot/konn3kt/common/param_codec.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Serialises openpilot params to/from their on-wire byte form for backup transport.
|
||||
The byte encoding is unchanged so archives round-trip: BYTES pass through, JSON is
|
||||
json-encoded, everything else is str()'d; decoding is typed per the param's key.
|
||||
"""
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
|
||||
from openpilot.common.params import Params, ParamKeyType
|
||||
|
||||
|
||||
def encode_param(name: str, params=None, use_default: bool = False) -> bytes | None:
|
||||
params = params or Params()
|
||||
raw = params.get_default_value(name) if use_default else params.get(name)
|
||||
if raw is None:
|
||||
return None
|
||||
|
||||
ktype = params.get_type(name)
|
||||
if ktype == ParamKeyType.BYTES:
|
||||
return bytes(raw)
|
||||
if ktype == ParamKeyType.JSON:
|
||||
return json.dumps(raw).encode("utf-8")
|
||||
return str(raw).encode("utf-8")
|
||||
|
||||
|
||||
# text-form decoders keyed by param type; anything unlisted is left as the raw string
|
||||
_FROM_TEXT = {
|
||||
ParamKeyType.STRING: lambda s: s,
|
||||
ParamKeyType.BOOL: lambda s: s.lower() in ("true", "1", "yes"),
|
||||
ParamKeyType.INT: int,
|
||||
ParamKeyType.FLOAT: float,
|
||||
ParamKeyType.TIME: str,
|
||||
ParamKeyType.JSON: json.loads,
|
||||
}
|
||||
|
||||
|
||||
def restore_param_from_base64(name: str, b64_data: str, compressed: bool = False) -> None:
|
||||
params = Params()
|
||||
ktype = params.get_type(name)
|
||||
|
||||
blob = base64.b64decode(b64_data)
|
||||
if compressed:
|
||||
blob = gzip.decompress(blob)
|
||||
|
||||
if ktype == ParamKeyType.BYTES:
|
||||
value = blob
|
||||
else:
|
||||
value = _FROM_TEXT.get(ktype, lambda s: s)(blob.decode("utf-8"))
|
||||
|
||||
params.put(name, value)
|
||||
4
iqpilot/konn3kt/hephaestus/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright ©️ IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
29
iqpilot/konn3kt/hephaestus/ble_transportd.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
import logging
|
||||
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
|
||||
_ORIGINAL_LOGGER_LOG = logging.Logger._log
|
||||
|
||||
|
||||
def _ble_transport_logger_shim(self, level, msg, args,
|
||||
exc_info=None, extra=None, stack_info=False, stacklevel=1, **kwargs):
|
||||
if kwargs:
|
||||
suffix = " ".join(f"{key}={value!r}" for key, value in sorted(kwargs.items()))
|
||||
msg = f"{msg} {suffix}".strip() if msg is not None else suffix
|
||||
return _ORIGINAL_LOGGER_LOG(
|
||||
self, level, msg, args,
|
||||
exc_info=exc_info, extra=extra, stack_info=stack_info, stacklevel=stacklevel,
|
||||
)
|
||||
|
||||
|
||||
logging.Logger._log = _ble_transport_logger_shim
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.konn3kt.hephaestus.ble_transportd")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
iqpilot/konn3kt/hephaestus/hephaestusd.py
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.konn3kt.hephaestus.hephaestusd")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
122
iqpilot/konn3kt/hephaestus/manage_hephaestusd.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Copyright ©️ IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
import time
|
||||
from multiprocessing import Process
|
||||
|
||||
HEPHAESTUS_MGR_PID_PARAM = "HephaestusdPid"
|
||||
|
||||
|
||||
def _cloudlog():
|
||||
try:
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
return cloudlog
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _log(level: str, msg: str) -> None:
|
||||
cl = _cloudlog()
|
||||
if cl is not None:
|
||||
try:
|
||||
getattr(cl, level)(msg)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
print(f"manage_hephaestusd[{level}]: {msg}", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _lightweight_launcher(proc: str, name: str) -> None:
|
||||
try:
|
||||
mod = importlib.import_module(proc)
|
||||
try:
|
||||
from setproctitle import setproctitle
|
||||
setproctitle(proc)
|
||||
except Exception:
|
||||
pass
|
||||
cl = _cloudlog()
|
||||
if cl is not None:
|
||||
try:
|
||||
cl.bind(daemon=name)
|
||||
except Exception:
|
||||
pass
|
||||
mod.main()
|
||||
except KeyboardInterrupt:
|
||||
_log("warning", f"child {proc} got SIGINT")
|
||||
except Exception:
|
||||
_log("exception", f"child {proc} exception")
|
||||
raise
|
||||
|
||||
|
||||
def _bind_global_best_effort(dongle_id_param: str) -> None:
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
dongle_id = Params().get(dongle_id_param)
|
||||
try:
|
||||
from openpilot.system.version import get_build_metadata
|
||||
build_metadata = get_build_metadata()
|
||||
cloudlog.bind_global(dongle_id=dongle_id,
|
||||
version=build_metadata.openpilot.version,
|
||||
origin=build_metadata.openpilot.git_normalized_origin,
|
||||
branch=build_metadata.channel,
|
||||
commit=build_metadata.openpilot.git_commit,
|
||||
dirty=build_metadata.openpilot.is_dirty,
|
||||
device=HARDWARE.get_device_type())
|
||||
except Exception:
|
||||
cloudlog.bind_global(dongle_id=dongle_id, device=HARDWARE.get_device_type())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _remove_pid_param(pid_param: str) -> None:
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
Params().remove(pid_param)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def manage_hephaestusd(dongle_id_param: str, pid_param: str, process_name: str, target: str) -> None:
|
||||
_bind_global_best_effort(dongle_id_param)
|
||||
|
||||
try:
|
||||
while 1:
|
||||
_log("info", f"starting {process_name} daemon")
|
||||
proc = Process(name=process_name, target=_lightweight_launcher, args=(target, process_name))
|
||||
proc.start()
|
||||
# Lower priority so BLE stack doesn't compete with OP's Python processes
|
||||
# on an already heavily loaded system (RT processes like pandad/modeld are unaffected)
|
||||
if proc.pid is not None:
|
||||
try:
|
||||
os.setpriority(os.PRIO_PROCESS, proc.pid, 10)
|
||||
except OSError:
|
||||
pass
|
||||
proc.join()
|
||||
_log("info", f"{process_name} exited (exitcode={proc.exitcode})")
|
||||
if proc.exitcode == 174:
|
||||
time.sleep(30)
|
||||
else:
|
||||
time.sleep(5)
|
||||
except Exception:
|
||||
_log("exception", f"manage_{process_name}.exception")
|
||||
finally:
|
||||
_remove_pid_param(pid_param)
|
||||
|
||||
|
||||
def main():
|
||||
manage_hephaestusd(dongle_id_param="DongleId", pid_param=HEPHAESTUS_MGR_PID_PARAM, process_name="hephaestusd",
|
||||
target="iqpilot.konn3kt.hephaestus.hephaestusd")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
4
iqpilot/konn3kt/iqlvbs/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
7
iqpilot/konn3kt/iqlvbs/alc.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.konn3kt.iqlvbs.alc")
|
||||
19
iqpilot/konn3kt/iqlvbs/git_remote.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Public shim for the proprietary IQ.Lvbs git read-auth helper.
|
||||
|
||||
The token + git config logic live in the standalone, signed bundle
|
||||
``iqpilot_private.updater.git_remote`` (artifact iqpilot_updater_private) -- a
|
||||
dedicated bundle so the read-only PAT can be rotated by rebuilding only that tiny
|
||||
bundle, never touching ALC. Never in the open tree.
|
||||
|
||||
The private module exports:
|
||||
configure(repo_dir: str) -> None
|
||||
Install the read-only token as an ephemeral http.<host>.extraHeader on
|
||||
repo_dir (the only auth method that survives the WAF's 403-to-anonymous).
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.updater.git_remote")
|
||||
236
iqpilot/konn3kt/registration.py
Executable file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import jwt
|
||||
import re
|
||||
import secrets
|
||||
from typing import cast
|
||||
from pathlib import Path
|
||||
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from openpilot.common.api import api_get, get_key_pair
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.spinner import Spinner
|
||||
from openpilot.system.hardware import HARDWARE, PC
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
|
||||
UNREGISTERED_DONGLE_ID = "UnregisteredDevice"
|
||||
|
||||
_DONGLE_ID_RE = re.compile(r"^[a-fA-F0-9]{16}$")
|
||||
IMEI_WAIT_TIMEOUT = 15.0
|
||||
|
||||
|
||||
def _read_persist_dongle_id() -> str | None:
|
||||
p = Path(Paths.persist_root()) / "comma" / "dongle_id"
|
||||
try:
|
||||
if not p.is_file():
|
||||
return None
|
||||
s = p.read_text().strip()
|
||||
return s or None
|
||||
except Exception:
|
||||
cloudlog.exception("failed to read persist dongle_id")
|
||||
return None
|
||||
|
||||
|
||||
def get_cached_dongle_id(params: Params | None = None, prefer_readonly: bool = True) -> str | None:
|
||||
ro = _read_persist_dongle_id()
|
||||
if is_valid_dongle_id(ro):
|
||||
ro = ro.lower()
|
||||
if prefer_readonly and ro:
|
||||
return ro
|
||||
p = Params() if params is None else params
|
||||
v = p.get("DongleId")
|
||||
if v and v != UNREGISTERED_DONGLE_ID:
|
||||
return v.lower() if is_valid_dongle_id(v) else v
|
||||
return ro or None
|
||||
def is_valid_dongle_id(dongle_id: str | None) -> bool:
|
||||
return bool(dongle_id and _DONGLE_ID_RE.fullmatch(dongle_id))
|
||||
def get_or_create_dongle_id(params: Params | None = None, prefer_readonly: bool = True) -> str:
|
||||
p = Params() if params is None else params
|
||||
dongle_id = get_cached_dongle_id(p, prefer_readonly=prefer_readonly)
|
||||
if dongle_id and dongle_id != UNREGISTERED_DONGLE_ID:
|
||||
return dongle_id
|
||||
dongle_id = secrets.token_hex(8)
|
||||
p.put("DongleId", dongle_id)
|
||||
cloudlog.warning(f"generated new DongleId={dongle_id} (no readonly dongle_id found)")
|
||||
return dongle_id
|
||||
def ensure_dev_pairing_identity(params: Params | None = None, force_reset: bool = False) -> dict[str, str]:
|
||||
p = Params() if params is None else params
|
||||
|
||||
persist_dir = Path(Paths.persist_root()) / "comma"
|
||||
persist_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dongle_path = persist_dir / "dongle_id"
|
||||
priv_path = persist_dir / "id_rsa"
|
||||
pub_path = persist_dir / "id_rsa.pub"
|
||||
|
||||
if force_reset:
|
||||
for fp in (dongle_path, priv_path, pub_path):
|
||||
try:
|
||||
fp.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
cloudlog.exception(f"failed to remove {fp}")
|
||||
try:
|
||||
(persist_dir / "konn3kt_prime_type").unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
p.remove("PrimeType")
|
||||
except Exception:
|
||||
pass
|
||||
forced_dongle = os.getenv("KONN3KT_DEV_DONGLE_ID")
|
||||
dongle_id = forced_dongle.strip().lower() if forced_dongle else None
|
||||
if dongle_id and not is_valid_dongle_id(dongle_id):
|
||||
cloudlog.error("KONN3KT_DEV_DONGLE_ID must be 16 hex chars")
|
||||
dongle_id = None
|
||||
if dongle_id is None:
|
||||
existing = None
|
||||
try:
|
||||
existing = dongle_path.read_text().strip().lower() if dongle_path.is_file() else None
|
||||
except Exception:
|
||||
cloudlog.exception("failed reading existing dev dongle_id")
|
||||
dongle_id = existing if is_valid_dongle_id(existing) else secrets.token_hex(8)
|
||||
try:
|
||||
dongle_path.write_text(dongle_id)
|
||||
except Exception:
|
||||
cloudlog.exception("failed writing dev dongle_id")
|
||||
p.put("DongleId", dongle_id)
|
||||
p.put("HardwareSerial", p.get("HardwareSerial") or f"DEV-{dongle_id}")
|
||||
if force_reset or (not priv_path.is_file()) or (not pub_path.is_file()):
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
priv_bytes = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
pub_bytes = key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
priv_path.write_bytes(priv_bytes)
|
||||
pub_path.write_bytes(pub_bytes)
|
||||
except Exception:
|
||||
cloudlog.exception("failed generating dev RSA keys")
|
||||
raise
|
||||
return {
|
||||
"dongle_id": dongle_id,
|
||||
"serial": p.get("HardwareSerial") or f"DEV-{dongle_id}",
|
||||
"persist_dir": str(persist_dir),
|
||||
}
|
||||
def is_registered_device() -> bool:
|
||||
dongle = Params().get("DongleId")
|
||||
return dongle not in (None, UNREGISTERED_DONGLE_ID)
|
||||
|
||||
|
||||
def _normalize_imei(value: str | None) -> str:
|
||||
return value or ""
|
||||
|
||||
|
||||
def get_registration_identifiers(wait_timeout: float = IMEI_WAIT_TIMEOUT, show_spinner: bool = False) -> tuple[str, str, str]:
|
||||
serial = HARDWARE.get_serial()
|
||||
spinner = Spinner() if show_spinner else None
|
||||
start_time = time.monotonic()
|
||||
imei1: str | None = None
|
||||
imei2: str | None = None
|
||||
|
||||
while time.monotonic() - start_time < wait_timeout:
|
||||
try:
|
||||
imei1, imei2 = HARDWARE.get_imei(0), HARDWARE.get_imei(1)
|
||||
if imei1 or imei2:
|
||||
break
|
||||
except RuntimeError as e:
|
||||
if "no modems" in str(e).lower():
|
||||
cloudlog.warning("No cellular modem available, proceeding without IMEI")
|
||||
break
|
||||
cloudlog.exception("Error getting imei, trying again...")
|
||||
except Exception:
|
||||
cloudlog.exception("Error getting imei, trying again...")
|
||||
time.sleep(1)
|
||||
|
||||
imei1 = _normalize_imei(imei1)
|
||||
imei2 = _normalize_imei(imei2)
|
||||
|
||||
if not imei1 and not imei2:
|
||||
cloudlog.warning(f"proceeding with serial-only registration for serial={serial}")
|
||||
if spinner is not None:
|
||||
spinner.update(f"registering device - serial: {serial}, IMEI: ({imei1 or None}, {imei2 or None})")
|
||||
spinner.close()
|
||||
|
||||
return serial, imei1, imei2
|
||||
|
||||
|
||||
def register(show_spinner=False) -> str | None:
|
||||
"""
|
||||
All devices built since March 2024 come with all
|
||||
info stored in /persist/. This is kept around
|
||||
only for devices built before then.
|
||||
|
||||
With a backend update to take serial number instead
|
||||
of dongle ID to some endpoints, this can be removed
|
||||
entirely.
|
||||
"""
|
||||
params = Params()
|
||||
|
||||
dongle_id: str | None = get_cached_dongle_id(params, prefer_readonly=True)
|
||||
if dongle_id in ("", UNREGISTERED_DONGLE_ID):
|
||||
dongle_id = None
|
||||
|
||||
# Create registration token, in the future, this key will make JWTs directly
|
||||
jwt_algo, private_key, public_key = get_key_pair()
|
||||
|
||||
if not public_key:
|
||||
dongle_id = UNREGISTERED_DONGLE_ID
|
||||
cloudlog.warning("missing public key")
|
||||
elif dongle_id is None:
|
||||
if show_spinner:
|
||||
spinner = Spinner()
|
||||
spinner.update("registering device")
|
||||
|
||||
serial, imei1, imei2 = get_registration_identifiers(wait_timeout=IMEI_WAIT_TIMEOUT, show_spinner=False)
|
||||
|
||||
backoff = 0
|
||||
start_time = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
register_token = jwt.encode({'register': True, 'exp': datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1)},
|
||||
cast(str, private_key), algorithm=jwt_algo)
|
||||
cloudlog.info("getting pilotauth")
|
||||
cloudlog.info("getting pilotauth")
|
||||
resp = api_get("v2/pilotauth/", method='POST', timeout=15,
|
||||
imei=imei1, imei2=imei2, serial=serial, public_key=public_key, register_token=register_token)
|
||||
|
||||
if resp.status_code in (400, 402, 403):
|
||||
cloudlog.info(f"Unable to register device, got {resp.status_code}")
|
||||
dongle_id = UNREGISTERED_DONGLE_ID
|
||||
else:
|
||||
dongleauth = json.loads(resp.text)
|
||||
dongle_id = dongleauth["dongle_id"]
|
||||
break
|
||||
except Exception:
|
||||
cloudlog.exception("failed to authenticate")
|
||||
backoff = min(backoff + 1, 15)
|
||||
time.sleep(backoff)
|
||||
|
||||
if time.monotonic() - start_time > 60 and show_spinner:
|
||||
spinner.update(f"registering device - serial: {serial}, IMEI: ({imei1}, {imei2})")
|
||||
return UNREGISTERED_DONGLE_ID # hotfix to prevent an infinite wait for registration
|
||||
|
||||
if show_spinner:
|
||||
spinner.update(f"registering device - serial: {serial}, IMEI: ({imei1 or None}, {imei2 or None})")
|
||||
spinner.close()
|
||||
|
||||
if dongle_id:
|
||||
params.put("DongleId", dongle_id)
|
||||
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert # lazy: keeps registration import light for the setup zipapp
|
||||
set_offroad_alert("Offroad_UnregisteredHardware", False)
|
||||
return dongle_id
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(register())
|
||||
11
iqpilot/konn3kt/service_health.py
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright ©️ IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
|
||||
def hephaestus_ready(params=None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def hephaestus_ready_shim():
|
||||
return hephaestus_ready()
|
||||
41
iqpilot/konn3kt/tests/test_registration.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from openpilot.iqpilot.konn3kt import registration
|
||||
|
||||
|
||||
def test_get_registration_identifiers_uses_serial_without_imei():
|
||||
imei_calls = {"count": 0}
|
||||
|
||||
def fake_get_imei(slot: int) -> str | None:
|
||||
imei_calls["count"] += 1
|
||||
return None
|
||||
|
||||
monotonic_values = iter([0.0, 0.0, 1.0, 2.0])
|
||||
with patch.object(registration.HARDWARE, "get_serial", return_value="lite123"), \
|
||||
patch.object(registration.HARDWARE, "get_imei", side_effect=fake_get_imei), \
|
||||
patch.object(registration.time, "monotonic", side_effect=lambda: next(monotonic_values)), \
|
||||
patch.object(registration.time, "sleep", return_value=None):
|
||||
serial, imei1, imei2 = registration.get_registration_identifiers(wait_timeout=1.5, show_spinner=False)
|
||||
|
||||
assert serial == "lite123"
|
||||
assert imei1 == ""
|
||||
assert imei2 == ""
|
||||
assert imei_calls["count"] >= 2
|
||||
|
||||
|
||||
def test_get_registration_identifiers_returns_first_available_imei():
|
||||
imeis = [None, "123456789012345"]
|
||||
monotonic_values = iter([0.0, 0.0, 0.5, 0.5])
|
||||
|
||||
def fake_get_imei(slot: int) -> str | None:
|
||||
return imeis.pop(0) if slot == 0 else None
|
||||
|
||||
with patch.object(registration.HARDWARE, "get_serial", return_value="lite123"), \
|
||||
patch.object(registration.HARDWARE, "get_imei", side_effect=fake_get_imei), \
|
||||
patch.object(registration.time, "monotonic", side_effect=lambda: next(monotonic_values)), \
|
||||
patch.object(registration.time, "sleep", return_value=None):
|
||||
serial, imei1, imei2 = registration.get_registration_identifiers(wait_timeout=2.0, show_spinner=False)
|
||||
|
||||
assert serial == "lite123"
|
||||
assert imei1 == "123456789012345"
|
||||
assert imei2 == ""
|
||||
1
iqpilot/konn3kt/uploaderd/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Public wrappers for proprietary uploader modules."""
|
||||
10
iqpilot/konn3kt/uploaderd/iquploaderd.py
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.konn3kt.uploaderd.iquploaderd")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
iqpilot/modeld/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.config import * # noqa: F401,F403
|
||||
2
iqpilot/modeld/constants.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.config import * # noqa: F401,F403
|
||||
2
iqpilot/modeld/runners/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.model_runner import * # noqa: F401,F403
|
||||
2
iqpilot/modeld/runners/constants.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.model_runner import * # noqa: F401,F403
|
||||
2
iqpilot/modeld/runners/helpers.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.model_runner import * # noqa: F401,F403
|
||||
2
iqpilot/modeld/runners/model_runner.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.model_runner import * # noqa: F401,F403
|
||||
1
iqpilot/modeld/runners/onnx/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
#!/usr/bin/env python3
|
||||
2
iqpilot/modeld/runners/onnx/onnx_runner.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.onnx.onnx_runner import * # noqa: F401,F403
|
||||
1
iqpilot/modeld/runners/tinygrad/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
#!/usr/bin/env python3
|
||||
2
iqpilot/modeld/runners/tinygrad/fused_runner.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.tinygrad.fused_runner import * # noqa: F401,F403
|
||||
2
iqpilot/modeld/runners/tinygrad/model_types.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.tinygrad.model_types import * # noqa: F401,F403
|
||||
2
iqpilot/modeld/runners/tinygrad/supercombo_runner.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.tinygrad.supercombo_runner import * # noqa: F401,F403
|
||||
2
iqpilot/modeld/runners/tinygrad/tinygrad_runner.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.iqpilot.selfdrive.iqmodeld.models.runners.tinygrad.tinygrad_runner import * # noqa: F401,F403
|
||||
3
iqpilot/navd/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
7
iqpilot/navd/event_builder.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.event_builder")
|
||||
7
iqpilot/navd/helpers.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.helpers")
|
||||
10
iqpilot/navd/iq_maps_bridge.py
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.iqmapd")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
iqpilot/navd/long_decel.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.long_decel")
|
||||
7
iqpilot/navd/mapbox_client.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.mapbox_client")
|
||||
7
iqpilot/navd/nav_cameras.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.nav_cameras")
|
||||
10
iqpilot/navd/navd.py
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.navd")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
10
iqpilot/navd/navrenderd.py
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.navrenderd")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
iqpilot/navd/osm_cameras.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.osm_cameras")
|
||||
7
iqpilot/navd/reroute.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.reroute")
|
||||
7
iqpilot/navd/route_manager.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.route_manager")
|
||||
7
iqpilot/navd/runtime_common.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.runtime_common")
|
||||
7
iqpilot/navd/turn_desire.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.turn_desire")
|
||||
7
iqpilot/navd/web_server.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from openpilot.iqpilot._proprietary_loader import load_private_module
|
||||
|
||||
load_private_module(__name__, "iqpilot_private.navd.web_server")
|
||||
22
iqpilot/sab/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
from .behavior import (
|
||||
SteeringAssistanceBehavior,
|
||||
GuidanceStateMachine,
|
||||
DriverInterventionMode,
|
||||
BRANDS_WITHOUT_MAIN_CRUISE_TOGGLE,
|
||||
apply_aol_brand_overrides,
|
||||
apply_aol_experience_flags,
|
||||
read_aol_enabled_pref,
|
||||
read_joint_engagement_pref,
|
||||
read_main_cruise_pref,
|
||||
resolve_brake_intervention_mode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SteeringAssistanceBehavior", "GuidanceStateMachine", "DriverInterventionMode",
|
||||
"BRANDS_WITHOUT_MAIN_CRUISE_TOGGLE", "apply_aol_brand_overrides", "apply_aol_experience_flags",
|
||||
"read_aol_enabled_pref", "read_joint_engagement_pref", "read_main_cruise_pref",
|
||||
"resolve_brake_intervention_mode",
|
||||
]
|
||||
523
iqpilot/sab/behavior.py
Normal file
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Steering Assistance Behavior (SAB): brand preference resolution, the guidance
|
||||
state machine and the per-frame event/behaviour engine, together in one module.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from openpilot.common.params import Params, UnknownKeyName
|
||||
from iqdbc.car import structs
|
||||
from openpilot.common.realtime import DT_CTRL
|
||||
from iqdbc.safety import ALTERNATIVE_EXPERIENCE
|
||||
from openpilot.selfdrive.selfdrived.events import ET
|
||||
from iqdbc.car.hyundai.values import HyundaiFlags, HyundaiFlagsIQ, HyundaiSafetyFlagsIQ
|
||||
from openpilot.selfdrive.selfdrived.state import SOFT_DISABLE_TIME
|
||||
from cereal import log, custom
|
||||
|
||||
State = custom.AlwaysOnLateral.AlwaysOnLateralState
|
||||
|
||||
|
||||
# ===== preferences =====
|
||||
|
||||
class DriverInterventionMode:
|
||||
"""What a brake press does to steering guidance (AolSteeringMode param values)."""
|
||||
CONTINUE = 0
|
||||
SUSPEND = 1
|
||||
CANCEL = 2
|
||||
|
||||
|
||||
# Per-brand quirks. A brand absent from a set behaves normally.
|
||||
_FORCED_BRAKE_CANCEL = frozenset({"rivian"})
|
||||
BRANDS_WITHOUT_MAIN_CRUISE_TOGGLE = ("rivian", "tesla")
|
||||
_HYUNDAI_MAIN_CRUISE_FLAG_BRANDS = frozenset({"hyundai"})
|
||||
|
||||
_EXPERIENCE_BY_BRAKE_MODE = {
|
||||
DriverInterventionMode.CANCEL: ALTERNATIVE_EXPERIENCE.AOL_DISENGAGE_LATERAL_ON_BRAKE,
|
||||
DriverInterventionMode.SUSPEND: ALTERNATIVE_EXPERIENCE.AOL_PAUSE_LATERAL_ON_BRAKE,
|
||||
}
|
||||
|
||||
|
||||
def uses_forced_brake_cancel(CP: structs.CarParams, CP_IQ: structs.IQCarParams):
|
||||
del CP_IQ
|
||||
return CP.brand in _FORCED_BRAKE_CANCEL
|
||||
|
||||
|
||||
def read_aol_enabled_pref(params: Params):
|
||||
return params.get_bool("AolEnabled")
|
||||
|
||||
|
||||
def read_main_cruise_pref(params: Params):
|
||||
return params.get_bool("AolMainCruiseAllowed")
|
||||
|
||||
|
||||
def read_joint_engagement_pref(params: Params):
|
||||
return params.get_bool("AolUnifiedEngagementMode")
|
||||
|
||||
|
||||
def resolve_brake_intervention_mode(CP: structs.CarParams, CP_IQ: structs.IQCarParams, params: Params):
|
||||
if uses_forced_brake_cancel(CP, CP_IQ):
|
||||
return DriverInterventionMode.CANCEL
|
||||
return params.get("AolSteeringMode", return_default=True)
|
||||
|
||||
|
||||
def apply_aol_experience_flags(CP: structs.CarParams, CP_IQ: structs.IQCarParams, params: Params):
|
||||
if not read_aol_enabled_pref(params):
|
||||
return
|
||||
CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.ENABLE_AOL
|
||||
mode = resolve_brake_intervention_mode(CP, CP_IQ, params)
|
||||
CP.alternativeExperience |= _EXPERIENCE_BY_BRAKE_MODE.get(mode, 0)
|
||||
|
||||
|
||||
def apply_aol_brand_overrides(CP: structs.CarParams, CP_IQ: structs.IQCarParams, params: Params):
|
||||
if CP.brand in _HYUNDAI_MAIN_CRUISE_FLAG_BRANDS:
|
||||
CP_IQ.flags |= HyundaiFlagsIQ.MAIN_BTN_LONG_TOGGLE.value
|
||||
CP_IQ.iqSafetyFlags |= HyundaiSafetyFlagsIQ.MAIN_BTN_LONG_TOGGLE
|
||||
|
||||
if uses_forced_brake_cancel(CP, CP_IQ):
|
||||
# the brand can only cancel on brake; pin the params so the UI reflects reality
|
||||
params.put("AolSteeringMode", DriverInterventionMode.CANCEL)
|
||||
params.put_bool("AolUnifiedEngagementMode", True)
|
||||
|
||||
if CP.brand in BRANDS_WITHOUT_MAIN_CRUISE_TOGGLE:
|
||||
params.remove("AolMainCruiseAllowed")
|
||||
|
||||
# ===== state_machine =====
|
||||
|
||||
EventName = log.OnroadEvent.EventName
|
||||
EventNameIQ = custom.IQOnroadEvent.EventName
|
||||
TORQUE_DELIVERING_STATES = (State.overriding, State.enabled, State.softDisabling)
|
||||
LATERAL_CONTROLLED_STATES = (State.paused, *TORQUE_DELIVERING_STATES)
|
||||
GUIDANCE_AVAILABLE_SIGNAL = ET.ENABLE
|
||||
GUIDANCE_GATE_BLOCK_SIGNAL = ET.NO_ENTRY
|
||||
GUIDANCE_SUPPRESSION_SIGNAL = ET.SOFT_DISABLE
|
||||
GUIDANCE_OPERATOR_OFF_SIGNAL = ET.USER_DISABLE
|
||||
GUIDANCE_HARD_CUT_SIGNAL = ET.IMMEDIATE_DISABLE
|
||||
GUIDANCE_DRIVER_OVERRIDE_SIGNAL = ET.OVERRIDE_LATERAL
|
||||
GUIDANCE_ACTIVE_ALERT = ET.WARNING
|
||||
|
||||
PAUSE_WITH_IQ_EVENTS = (
|
||||
EventNameIQ.parkBrakeSilent,
|
||||
EventNameIQ.seatbeltUnbuckledSilent,
|
||||
EventNameIQ.doorAjarSilent,
|
||||
EventNameIQ.brakeHoldSilent,
|
||||
EventNameIQ.reverseSilent,
|
||||
EventNameIQ.gearNotDriveSilent,
|
||||
)
|
||||
PAUSE_WITH_STOCK_EVENTS = (
|
||||
EventName.parkBrake,
|
||||
EventName.seatbeltNotLatched,
|
||||
EventName.doorOpen,
|
||||
EventName.brakeHold,
|
||||
EventName.reverseGear,
|
||||
EventName.wrongGear,
|
||||
)
|
||||
GEARS_ALLOW_PAUSED_SILENT = PAUSE_WITH_IQ_EVENTS
|
||||
GEARS_ALLOW_PAUSED = PAUSE_WITH_STOCK_EVENTS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuidancePulse:
|
||||
wake_ping: bool
|
||||
gate_closed: bool
|
||||
cooldown_call: bool
|
||||
driver_kill: bool
|
||||
hard_cut: bool
|
||||
hands_on_wheel: bool
|
||||
hush_cut: bool
|
||||
pit_stop_ready: bool
|
||||
|
||||
|
||||
class GuidanceStateMachine:
|
||||
def __init__(self, sab):
|
||||
self.selfdrive = sab.selfdrive
|
||||
self._sm_core = sab.selfdrive.state_machine
|
||||
self._events = sab.selfdrive.events
|
||||
self._events_iq = sab.selfdrive.events_iq
|
||||
self.state = State.disabled
|
||||
|
||||
def _queue_alert_if_solo(self, alert_type: str):
|
||||
if not self.selfdrive.enabled:
|
||||
self._sm_core.current_alert_types.append(alert_type)
|
||||
|
||||
def _sees_event(self, event_type: str):
|
||||
return self._events.contains(event_type) or self._events_iq.contains(event_type)
|
||||
|
||||
def _can_take_pit_stop(self):
|
||||
return self._events.contains_in_list(PAUSE_WITH_STOCK_EVENTS) or self._events_iq.contains_in_list(PAUSE_WITH_IQ_EVENTS)
|
||||
|
||||
def _capture_pulse(self) -> GuidancePulse:
|
||||
return GuidancePulse(
|
||||
wake_ping=self._sees_event(GUIDANCE_AVAILABLE_SIGNAL),
|
||||
gate_closed=self._sees_event(GUIDANCE_GATE_BLOCK_SIGNAL),
|
||||
cooldown_call=self._sees_event(GUIDANCE_SUPPRESSION_SIGNAL),
|
||||
driver_kill=self._sees_event(GUIDANCE_OPERATOR_OFF_SIGNAL),
|
||||
hard_cut=self._sees_event(GUIDANCE_HARD_CUT_SIGNAL),
|
||||
hands_on_wheel=self._sees_event(GUIDANCE_DRIVER_OVERRIDE_SIGNAL),
|
||||
hush_cut=self._events_iq.has(EventNameIQ.alcDisengagedSilent),
|
||||
pit_stop_ready=self._can_take_pit_stop(),
|
||||
)
|
||||
|
||||
def _start_grace_period(self):
|
||||
if not self.selfdrive.enabled:
|
||||
self._sm_core.soft_disable_timer = int(SOFT_DISABLE_TIME / DT_CTRL)
|
||||
self._sm_core.current_alert_types.append(GUIDANCE_SUPPRESSION_SIGNAL)
|
||||
|
||||
def _run_global_cutoffs(self, pulse: GuidancePulse) -> Optional[object]:
|
||||
if pulse.driver_kill:
|
||||
self._sm_core.current_alert_types.append(GUIDANCE_OPERATOR_OFF_SIGNAL)
|
||||
return State.paused if pulse.hush_cut else State.disabled
|
||||
if pulse.hard_cut:
|
||||
self._queue_alert_if_solo(GUIDANCE_HARD_CUT_SIGNAL)
|
||||
return State.disabled
|
||||
return None
|
||||
|
||||
def _handle_disabled(self, pulse: GuidancePulse) -> State:
|
||||
if not pulse.wake_ping:
|
||||
return State.disabled
|
||||
if pulse.gate_closed:
|
||||
self._queue_alert_if_solo(GUIDANCE_GATE_BLOCK_SIGNAL)
|
||||
return State.paused if pulse.pit_stop_ready else State.disabled
|
||||
self._queue_alert_if_solo(GUIDANCE_AVAILABLE_SIGNAL)
|
||||
return State.overriding if pulse.hands_on_wheel else State.enabled
|
||||
|
||||
def _handle_enabled(self, pulse: GuidancePulse) -> State:
|
||||
forced_state = self._run_global_cutoffs(pulse)
|
||||
if forced_state is not None:
|
||||
return forced_state
|
||||
if pulse.cooldown_call:
|
||||
self._start_grace_period()
|
||||
return State.softDisabling
|
||||
if pulse.hands_on_wheel:
|
||||
self._queue_alert_if_solo(GUIDANCE_DRIVER_OVERRIDE_SIGNAL)
|
||||
return State.overriding
|
||||
return State.enabled
|
||||
|
||||
def _handle_soft_disabling(self, pulse: GuidancePulse) -> State:
|
||||
forced_state = self._run_global_cutoffs(pulse)
|
||||
if forced_state is not None:
|
||||
return forced_state
|
||||
if not pulse.cooldown_call:
|
||||
return State.enabled
|
||||
if self._sm_core.soft_disable_timer > 0:
|
||||
self._queue_alert_if_solo(GUIDANCE_SUPPRESSION_SIGNAL)
|
||||
return State.softDisabling
|
||||
return State.disabled
|
||||
|
||||
def _handle_paused(self, pulse: GuidancePulse) -> State:
|
||||
forced_state = self._run_global_cutoffs(pulse)
|
||||
if forced_state is not None:
|
||||
return forced_state
|
||||
if not pulse.wake_ping:
|
||||
return State.paused
|
||||
if pulse.gate_closed:
|
||||
self._queue_alert_if_solo(GUIDANCE_GATE_BLOCK_SIGNAL)
|
||||
return State.paused
|
||||
self._queue_alert_if_solo(GUIDANCE_AVAILABLE_SIGNAL)
|
||||
return State.overriding if pulse.hands_on_wheel else State.enabled
|
||||
|
||||
def _handle_overriding(self, pulse: GuidancePulse) -> State:
|
||||
forced_state = self._run_global_cutoffs(pulse)
|
||||
if forced_state is not None:
|
||||
return forced_state
|
||||
if pulse.cooldown_call:
|
||||
self._start_grace_period()
|
||||
return State.softDisabling
|
||||
if pulse.hands_on_wheel:
|
||||
self._sm_core.current_alert_types.append(GUIDANCE_DRIVER_OVERRIDE_SIGNAL)
|
||||
return State.overriding
|
||||
return State.enabled
|
||||
|
||||
def update(self):
|
||||
pulse = self._capture_pulse()
|
||||
handler = {
|
||||
State.disabled: self._handle_disabled,
|
||||
State.enabled: self._handle_enabled,
|
||||
State.softDisabling: self._handle_soft_disabling,
|
||||
State.paused: self._handle_paused,
|
||||
State.overriding: self._handle_overriding,
|
||||
}[self.state]
|
||||
|
||||
self.state = handler(pulse)
|
||||
enabled = self.state in LATERAL_CONTROLLED_STATES
|
||||
active = self.state in TORQUE_DELIVERING_STATES
|
||||
if active:
|
||||
self._queue_alert_if_solo(GUIDANCE_ACTIVE_ALERT)
|
||||
return enabled, active
|
||||
|
||||
# ===== behavior =====
|
||||
|
||||
_E = log.OnroadEvent.EventName
|
||||
_Q = custom.IQOnroadEvent.EventName
|
||||
_BTN = structs.CarState.ButtonEvent.Type
|
||||
_GEAR = structs.CarState.GearShifter
|
||||
|
||||
_CRUISE_SET_TAPS = frozenset((_BTN.accelCruise, _BTN.resumeCruise, _BTN.decelCruise, _BTN.setCruise))
|
||||
_LATERAL_TOGGLE_BUTTONS = (_BTN.lkas, _BTN.lfaButton)
|
||||
_HYUNDAI_LDA_MASK = HyundaiFlags.CANFD
|
||||
|
||||
# While a lateral-only session rides through a pause, these stock blockers are
|
||||
# swapped for their silent IQ twins. Order here is not load-bearing (each row
|
||||
# guards a distinct event), so it is grouped standstill-first for readability.
|
||||
# (silent replacement, stock trigger, only-when-stopped, extra predicate)
|
||||
_QUIET_SWAPS = (
|
||||
(_Q.seatbeltUnbuckledSilent, _E.seatbeltNotLatched, True, None),
|
||||
(_Q.doorAjarSilent, _E.doorOpen, True, None),
|
||||
(_Q.reverseSilent, _E.reverseGear, False, None),
|
||||
(_Q.parkBrakeSilent, _E.parkBrake, False, None),
|
||||
(_Q.brakeHoldSilent, _E.brakeHold, False, None),
|
||||
(_Q.gearNotDriveSilent, _E.wrongGear, False,
|
||||
lambda cs: cs.vEgo < 2.5 or cs.gearShifter == _GEAR.reverse),
|
||||
)
|
||||
|
||||
# Longitudinal-only chatter that must not gate a lateral-only session.
|
||||
_DROP_ON_ENTRY = (_E.speedTooLow, _E.belowEngageSpeed, _E.preEnableStandstill,
|
||||
_E.manualRestart, _E.cruiseDisabled)
|
||||
_DROP_ON_EXIT = (_E.wrongCruiseMode, _E.pedalPressed, _E.buttonCancel, _E.pcmDisable)
|
||||
|
||||
|
||||
class SteeringAssistanceBehavior:
|
||||
def __init__(self, selfdrive):
|
||||
sd = selfdrive
|
||||
self.selfdrive = sd
|
||||
self.CP, self.CP_IQ, self.params = sd.CP, sd.CP_IQ, sd.params
|
||||
self.events, self.events_iq = sd.events, sd.events_iq
|
||||
|
||||
self.enabled = self.active = self.available = False
|
||||
sd.enabled_prev = False
|
||||
self.state_machine = GuidanceStateMachine(self)
|
||||
|
||||
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
|
||||
self._apply_brand_capabilities()
|
||||
self._reload_preferences(full=True)
|
||||
|
||||
def _apply_brand_capabilities(self):
|
||||
brand = self.CP.brand
|
||||
self.no_main_cruise = brand in BRANDS_WITHOUT_MAIN_CRUISE_TOGGLE
|
||||
lda_capable = bool(self.CP.flags & _HYUNDAI_LDA_MASK) or bool(self.CP_IQ.flags & HyundaiFlagsIQ.HAS_LFA_BUTTON)
|
||||
self.hkg_allow = brand == "hyundai" and lda_capable
|
||||
|
||||
def _reload_preferences(self, full: bool = False):
|
||||
self.main_enabled_toggle = read_main_cruise_pref(self.params)
|
||||
self.unified_engagement_mode = read_joint_engagement_pref(self.params)
|
||||
if full:
|
||||
self.enabled_toggle = read_aol_enabled_pref(self.params)
|
||||
self.steering_mode_on_brake = resolve_brake_intervention_mode(self.CP, self.CP_IQ, self.params)
|
||||
|
||||
def read_params(self):
|
||||
self._reload_preferences()
|
||||
|
||||
# -- event plumbing (thin wrappers over the stock/IQ event queues) -----------
|
||||
def _has(self, ev):
|
||||
return self.events.has(ev)
|
||||
|
||||
def _drop(self, ev):
|
||||
self.events.remove(ev)
|
||||
|
||||
def _raise(self, ev):
|
||||
self.events.add(ev)
|
||||
|
||||
def _emit(self, ev):
|
||||
self.events_iq.add(ev)
|
||||
|
||||
def _retract(self, ev):
|
||||
self.events_iq.remove(ev)
|
||||
|
||||
def _emitted(self, ev):
|
||||
return self.events_iq.contains(ev)
|
||||
|
||||
def _emitted_any(self, evs):
|
||||
return self.events_iq.contains_in_list(evs)
|
||||
|
||||
def _iq_has(self, ev):
|
||||
return self.events_iq.has(ev)
|
||||
|
||||
# -- predicates --------------------------------------------------------------
|
||||
def _brake_without_gas(self, cs):
|
||||
prev_gas = self.selfdrive.CS_prev.gasPressed
|
||||
gas_rising_edge = cs.gasPressed and not prev_gas
|
||||
override_via_gas = gas_rising_edge and self.disengage_on_accelerator
|
||||
return self._has(_E.pedalPressed) and not override_via_gas
|
||||
|
||||
def _may_silently_resume(self, cs):
|
||||
suspend_on_brake = self.steering_mode_on_brake == DriverInterventionMode.SUSPEND
|
||||
if suspend_on_brake and self._brake_without_gas(cs):
|
||||
return False
|
||||
return not self._emitted_any(GEARS_ALLOW_PAUSED_SILENT)
|
||||
|
||||
@property
|
||||
def _long_held_two_cycles(self):
|
||||
sd = self.selfdrive
|
||||
return bool(sd.enabled_prev and sd.enabled)
|
||||
|
||||
def _uem_blocks_engage(self):
|
||||
if not self.unified_engagement_mode or self.enabled:
|
||||
return True
|
||||
return self._long_held_two_cycles
|
||||
|
||||
def _lateral_offered(self, cs):
|
||||
return bool(cs.lateralAvailable or cs.cruiseState.available or self.hkg_allow or self.CP.brand == "tesla")
|
||||
|
||||
@staticmethod
|
||||
def _main_cruise_live(cs):
|
||||
cruise = getattr(cs, 'cruiseState', None)
|
||||
if getattr(cruise, 'available', False):
|
||||
return True
|
||||
return bool(getattr(cs, 'cruiseFaultLateralMode', False))
|
||||
|
||||
# -- event surgery -----------------------------------------------------------
|
||||
def _swap_event(self, stock: int, silent: int):
|
||||
self._drop(stock)
|
||||
self._emit(silent)
|
||||
|
||||
def _flag_pause(self):
|
||||
already_held = self.state_machine.state is State.paused
|
||||
if not already_held:
|
||||
self._emit(_Q.alcDisengagedSilent)
|
||||
|
||||
def _resolve_wrong_mode(self, alert_only: bool):
|
||||
if not alert_only:
|
||||
self._drop(_E.wrongCarMode)
|
||||
elif self._has(_E.wrongCarMode):
|
||||
self._swap_event(_E.wrongCarMode, _Q.carModeMismatchNotice)
|
||||
|
||||
# -- joystick/debug hook -----------------------------------------------------
|
||||
def _consume_joystick_aol_request(self, cs) -> str | None:
|
||||
if not self.params.get_bool("JoystickDebugMode"):
|
||||
return None
|
||||
try:
|
||||
raw = self.params.get("JoystickAolRequest")
|
||||
except UnknownKeyName:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
request = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw)
|
||||
except Exception:
|
||||
request = ""
|
||||
try:
|
||||
self.params.remove("JoystickAolRequest")
|
||||
except UnknownKeyName:
|
||||
return None
|
||||
|
||||
verb = request.strip().lower()
|
||||
if verb not in ("enable", "disable"):
|
||||
return None
|
||||
if not getattr(cs, "started", False):
|
||||
return None
|
||||
if getattr(cs, "doorOpen", False) or getattr(cs, "seatbeltUnlatched", False):
|
||||
return None
|
||||
parked_or_reverse = getattr(cs, "gearShifter", _GEAR.unknown) in (_GEAR.park, _GEAR.reverse)
|
||||
return None if parked_or_reverse else verb
|
||||
|
||||
# -- pipeline stages ---------------------------------------------------------
|
||||
def _phase_joystick(self, cs):
|
||||
verb = self._consume_joystick_aol_request(cs)
|
||||
if verb is not None:
|
||||
self._emit(_Q.alcEngaged if verb == "enable" else _Q.alcDisengaged)
|
||||
|
||||
def _phase_soften_for_lateral_session(self, cs):
|
||||
if self.selfdrive.enabled or not self.enabled:
|
||||
return
|
||||
|
||||
for silent, stock, standstill_only, extra in _QUIET_SWAPS:
|
||||
if standstill_only and not cs.standstill:
|
||||
continue
|
||||
if not self._has(stock):
|
||||
continue
|
||||
if extra is not None and not extra(cs):
|
||||
continue
|
||||
self._swap_event(stock, silent)
|
||||
self._flag_pause()
|
||||
|
||||
if self.steering_mode_on_brake == DriverInterventionMode.SUSPEND and self._brake_without_gas(cs):
|
||||
self._flag_pause()
|
||||
|
||||
for chatter in _DROP_ON_ENTRY:
|
||||
self._drop(chatter)
|
||||
|
||||
_ENGAGE_TRIGGERS = (_E.pcmEnable, _E.buttonEnable)
|
||||
|
||||
def _phase_engagement(self, cs):
|
||||
long_engage = any(self._has(trig) for trig in self._ENGAGE_TRIGGERS)
|
||||
tapped_set = any(be.type in _CRUISE_SET_TAPS for be in cs.buttonEvents)
|
||||
self._resolve_wrong_mode(long_engage or tapped_set)
|
||||
|
||||
if long_engage:
|
||||
if self._brake_without_gas(cs):
|
||||
self._emit(_Q.pedalHeldNotice)
|
||||
if self._uem_blocks_engage():
|
||||
self._drop(_E.pcmEnable)
|
||||
self._drop(_E.buttonEnable)
|
||||
return
|
||||
|
||||
if self.main_enabled_toggle and self._main_cruise_live(cs) and not self._main_cruise_live(self.selfdrive.CS_prev):
|
||||
self._emit(_Q.alcEngaged)
|
||||
|
||||
def _phase_buttons(self, cs):
|
||||
kill_all = False
|
||||
long_dropped_out = self.selfdrive.enabled_prev and not self.selfdrive.enabled
|
||||
for be in cs.buttonEvents:
|
||||
if be.type == _BTN.cancel and long_dropped_out:
|
||||
self._emit(_Q.speedManually)
|
||||
if not (be.type in _LATERAL_TOGGLE_BUTTONS and be.pressed and self._lateral_offered(cs)):
|
||||
continue
|
||||
if not self.enabled:
|
||||
self._emit(_Q.alcEngaged)
|
||||
continue
|
||||
self._emit(_Q.alcDisengaged)
|
||||
if self.selfdrive.enabled:
|
||||
kill_all = True
|
||||
return kill_all
|
||||
|
||||
def _phase_availability(self, cs):
|
||||
main_off = self.main_enabled_toggle and not self._main_cruise_live(cs)
|
||||
if self.no_main_cruise or (self._lateral_offered(cs) and not main_off):
|
||||
return
|
||||
self._drop(_E.buttonEnable)
|
||||
if self.enabled:
|
||||
self._emit(_Q.alcDisengaged)
|
||||
|
||||
def _phase_brake_policy(self, cs):
|
||||
if self.steering_mode_on_brake != DriverInterventionMode.CANCEL or not self._brake_without_gas(cs):
|
||||
return
|
||||
if self.enabled:
|
||||
self._emit(_Q.alcDisengaged)
|
||||
elif self._emitted(_Q.alcEngaged):
|
||||
self._retract(_Q.alcEngaged)
|
||||
self._emit(_Q.pedalHeldNotice)
|
||||
|
||||
def _phase_resume_from_pause(self, cs):
|
||||
held = self.state_machine.state is State.paused
|
||||
if held and self._may_silently_resume(cs):
|
||||
self._emit(_Q.alcEngagedSilent)
|
||||
|
||||
def update_events(self, cs):
|
||||
self._phase_joystick(cs)
|
||||
self._phase_soften_for_lateral_session(cs)
|
||||
self._phase_engagement(cs)
|
||||
kill_all = self._phase_buttons(cs)
|
||||
self._phase_availability(cs)
|
||||
self._phase_brake_policy(cs)
|
||||
self._phase_resume_from_pause(cs)
|
||||
|
||||
for chatter in _DROP_ON_EXIT:
|
||||
self._drop(chatter)
|
||||
|
||||
if kill_all:
|
||||
self._raise(_E.buttonCancel)
|
||||
|
||||
def update(self, cs):
|
||||
if not self.enabled_toggle and not self.params.get_bool("JoystickDebugMode"):
|
||||
return
|
||||
self.update_events(cs)
|
||||
self.update_state()
|
||||
|
||||
def update_state(self):
|
||||
sd = self.selfdrive
|
||||
sd.enabled_prev = sd.enabled
|
||||
runnable = sd.initialized and not self.CP.passive
|
||||
if runnable:
|
||||
verdict = self.state_machine.update()
|
||||
self.enabled, self.active = verdict
|
||||
175
iqpilot/sab/tests/test_sab.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cereal import custom
|
||||
from iqdbc.car import structs
|
||||
from iqdbc.car.hyundai.values import HyundaiFlags, HyundaiFlagsIQ
|
||||
from openpilot.iqpilot.sab.behavior import SteeringAssistanceBehavior
|
||||
from openpilot.iqpilot.selfdrive.selfdrived.events import IQEvents
|
||||
from openpilot.selfdrive.selfdrived.events import Events
|
||||
|
||||
|
||||
ButtonType = structs.CarState.ButtonEvent.Type
|
||||
EventNameIQ = custom.IQOnroadEvent.EventName
|
||||
|
||||
|
||||
class MockParams:
|
||||
def __init__(self, main_cruise_allowed: bool = False, aol_enabled: bool = True):
|
||||
self.main_cruise_allowed = main_cruise_allowed
|
||||
self.aol_enabled = aol_enabled
|
||||
|
||||
def get_bool(self, key: str) -> bool:
|
||||
return {
|
||||
"AolEnabled": self.aol_enabled,
|
||||
"AolMainCruiseAllowed": self.main_cruise_allowed,
|
||||
"AolUnifiedEngagementMode": False,
|
||||
"JoystickDebugMode": False,
|
||||
}.get(key, False)
|
||||
|
||||
def get(self, key: str, return_default: bool = False):
|
||||
if key == "AolSteeringMode":
|
||||
return 0 if return_default else b"0"
|
||||
return None
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def make_selfdrive(cp_flags: int, brand: str = "hyundai", main_cruise_allowed: bool = False,
|
||||
aol_enabled: bool = True, cp_iq_flags: int = 0):
|
||||
cp = SimpleNamespace(
|
||||
brand=brand,
|
||||
flags=cp_flags,
|
||||
passive=False,
|
||||
notCar=False,
|
||||
safetyModel=structs.CarParams.SafetyModel.noOutput,
|
||||
)
|
||||
cp_iq = SimpleNamespace(flags=cp_iq_flags)
|
||||
return SimpleNamespace(
|
||||
CP=cp,
|
||||
CP_IQ=cp_iq,
|
||||
params=MockParams(main_cruise_allowed, aol_enabled),
|
||||
state_machine=SimpleNamespace(soft_disable_timer=0, current_alert_types=[]),
|
||||
events=Events(),
|
||||
events_iq=IQEvents(),
|
||||
CS_prev=SimpleNamespace(
|
||||
gasPressed=False,
|
||||
cruiseState=SimpleNamespace(available=False),
|
||||
lateralAvailable=False,
|
||||
),
|
||||
enabled=False,
|
||||
enabled_prev=False,
|
||||
initialized=True,
|
||||
)
|
||||
|
||||
|
||||
def make_car_state():
|
||||
return SimpleNamespace(
|
||||
started=True,
|
||||
standstill=False,
|
||||
doorOpen=False,
|
||||
seatbeltUnlatched=False,
|
||||
gearShifter=structs.CarState.GearShifter.drive,
|
||||
vEgo=0.0,
|
||||
gasPressed=False,
|
||||
brakePressed=False,
|
||||
cruiseState=SimpleNamespace(available=False),
|
||||
lateralAvailable=False,
|
||||
buttonEvents=[structs.CarState.ButtonEvent(pressed=True, type=ButtonType.lkas)],
|
||||
)
|
||||
|
||||
|
||||
def make_vw_car_state(cruise_available: bool, cruise_fault_lateral: bool = False):
|
||||
return SimpleNamespace(
|
||||
started=True,
|
||||
standstill=False,
|
||||
doorOpen=False,
|
||||
seatbeltUnlatched=False,
|
||||
gearShifter=structs.CarState.GearShifter.drive,
|
||||
vEgo=0.0,
|
||||
gasPressed=False,
|
||||
brakePressed=False,
|
||||
cruiseState=SimpleNamespace(available=cruise_available),
|
||||
lateralAvailable=cruise_available or cruise_fault_lateral,
|
||||
cruiseFaultLateralMode=cruise_fault_lateral,
|
||||
buttonEvents=[],
|
||||
)
|
||||
|
||||
|
||||
def test_hyundai_lkas_button_can_arm_guidance_before_lateral_available():
|
||||
selfdrive = make_selfdrive(0, cp_iq_flags=HyundaiFlagsIQ.HAS_LFA_BUTTON)
|
||||
guidance = SteeringAssistanceBehavior(selfdrive)
|
||||
car_state = make_car_state()
|
||||
car_state.buttonEvents = [structs.CarState.ButtonEvent(pressed=True, type=ButtonType.lfaButton)]
|
||||
|
||||
guidance.update_events(car_state)
|
||||
|
||||
assert selfdrive.events_iq.has(EventNameIQ.alcEngaged)
|
||||
|
||||
|
||||
def test_hyundai_lkas_button_stays_inactive_without_platform_support():
|
||||
selfdrive = make_selfdrive(0)
|
||||
guidance = SteeringAssistanceBehavior(selfdrive)
|
||||
|
||||
guidance.update_events(make_car_state())
|
||||
|
||||
assert not selfdrive.events_iq.has(EventNameIQ.alcEngaged)
|
||||
|
||||
|
||||
def test_main_cruise_drop_cuts_guidance_even_if_lateral_signal_stays_true():
|
||||
selfdrive = make_selfdrive(0, brand="volkswagen", main_cruise_allowed=True)
|
||||
selfdrive.CS_prev = make_vw_car_state(cruise_available=True)
|
||||
guidance = SteeringAssistanceBehavior(selfdrive)
|
||||
guidance.enabled = True
|
||||
|
||||
guidance.update_events(make_vw_car_state(cruise_available=False))
|
||||
|
||||
assert selfdrive.events_iq.has(EventNameIQ.alcDisengaged)
|
||||
|
||||
|
||||
def test_faulted_lateral_mode_does_not_force_disable_guidance():
|
||||
selfdrive = make_selfdrive(0, brand="volkswagen", main_cruise_allowed=True)
|
||||
selfdrive.CS_prev = make_vw_car_state(cruise_available=True)
|
||||
guidance = SteeringAssistanceBehavior(selfdrive)
|
||||
guidance.enabled = True
|
||||
|
||||
guidance.update_events(make_vw_car_state(cruise_available=False, cruise_fault_lateral=True))
|
||||
|
||||
assert not selfdrive.events_iq.has(EventNameIQ.alcDisengaged)
|
||||
|
||||
|
||||
def test_main_switch_rising_edge_arms_guidance_during_faulted_cruise():
|
||||
selfdrive = make_selfdrive(0, brand="volkswagen", main_cruise_allowed=True)
|
||||
selfdrive.CS_prev = make_vw_car_state(cruise_available=False, cruise_fault_lateral=False)
|
||||
guidance = SteeringAssistanceBehavior(selfdrive)
|
||||
|
||||
guidance.update_events(make_vw_car_state(cruise_available=False, cruise_fault_lateral=True))
|
||||
|
||||
assert selfdrive.events_iq.has(EventNameIQ.alcEngaged)
|
||||
|
||||
|
||||
def test_main_cruise_rising_edge_does_not_engage_when_toggle_is_off():
|
||||
selfdrive = make_selfdrive(0, brand="volkswagen", main_cruise_allowed=True, aol_enabled=False)
|
||||
selfdrive.CS_prev = make_vw_car_state(cruise_available=False)
|
||||
guidance = SteeringAssistanceBehavior(selfdrive)
|
||||
|
||||
guidance.update(make_vw_car_state(cruise_available=True))
|
||||
|
||||
assert not selfdrive.events_iq.has(EventNameIQ.alcEngaged)
|
||||
assert not guidance.active
|
||||
assert not guidance.enabled
|
||||
assert guidance.state_machine.state == custom.AlwaysOnLateral.AlwaysOnLateralState.disabled
|
||||
|
||||
|
||||
def test_main_cruise_rising_edge_engages_when_toggle_is_on():
|
||||
selfdrive = make_selfdrive(0, brand="volkswagen", main_cruise_allowed=True, aol_enabled=True)
|
||||
selfdrive.CS_prev = make_vw_car_state(cruise_available=False)
|
||||
guidance = SteeringAssistanceBehavior(selfdrive)
|
||||
|
||||
guidance.update(make_vw_car_state(cruise_available=True))
|
||||
|
||||
assert selfdrive.events_iq.has(EventNameIQ.alcEngaged)
|
||||
assert guidance.active
|
||||
221
iqpilot/sab/tests/test_sab_state_machine.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Table-driven checks for GuidanceStateMachine: every transition is one row of
|
||||
(start state, signals present, expected state), and the side effects (queued
|
||||
alert types, soft-disable timer arming) are asserted separately.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from cereal import custom
|
||||
from openpilot.common.realtime import DT_CTRL
|
||||
from openpilot.selfdrive.selfdrived.events import ET
|
||||
from openpilot.selfdrive.selfdrived.state import SOFT_DISABLE_TIME
|
||||
from openpilot.iqpilot.sab.behavior import (GuidanceStateMachine, PAUSE_WITH_IQ_EVENTS,
|
||||
PAUSE_WITH_STOCK_EVENTS)
|
||||
|
||||
State = custom.AlwaysOnLateral.AlwaysOnLateralState
|
||||
EventNameIQ = custom.IQOnroadEvent.EventName
|
||||
|
||||
SOFT_DISABLE_FRAMES = int(SOFT_DISABLE_TIME / DT_CTRL)
|
||||
|
||||
# signal aliases used in the table rows
|
||||
ENABLE = ET.ENABLE
|
||||
NO_ENTRY = ET.NO_ENTRY
|
||||
SOFT = ET.SOFT_DISABLE
|
||||
USER = ET.USER_DISABLE
|
||||
IMMEDIATE = ET.IMMEDIATE_DISABLE
|
||||
OVERRIDE = ET.OVERRIDE_LATERAL
|
||||
SILENT = "silent-disable" # silentLkasDisable present in the IQ event bag
|
||||
PAUSE_OK = "pause-eligible" # a gear/door/belt event from the pause lists is present
|
||||
|
||||
|
||||
class SignalBag:
|
||||
"""Stands in for both event buckets; the machine only probes membership."""
|
||||
|
||||
def __init__(self, types=(), names=()):
|
||||
self._types = set(types)
|
||||
self._names = set(names)
|
||||
|
||||
def contains(self, event_type):
|
||||
return event_type in self._types
|
||||
|
||||
def has(self, name):
|
||||
return name in self._names
|
||||
|
||||
def contains_in_list(self, names):
|
||||
return any(n in self._names for n in names)
|
||||
|
||||
|
||||
class Host:
|
||||
"""Minimal stand-in for the sab/selfdrive plumbing the machine touches."""
|
||||
|
||||
class _SSM:
|
||||
def __init__(self):
|
||||
self.current_alert_types = []
|
||||
self.soft_disable_timer = 0
|
||||
|
||||
def __init__(self, signals, selfdrive_enabled=False):
|
||||
types = {s for s in signals if s in (ENABLE, NO_ENTRY, SOFT, USER, IMMEDIATE, OVERRIDE)}
|
||||
names = set()
|
||||
if SILENT in signals:
|
||||
names.add(EventNameIQ.alcDisengagedSilent)
|
||||
if PAUSE_OK in signals:
|
||||
names.add(PAUSE_WITH_IQ_EVENTS[0])
|
||||
|
||||
self.enabled = selfdrive_enabled
|
||||
self.state_machine = self._SSM()
|
||||
self.events = SignalBag(types)
|
||||
self.events_iq = SignalBag((), names)
|
||||
|
||||
|
||||
class Sab:
|
||||
def __init__(self, host):
|
||||
self.selfdrive = host
|
||||
|
||||
|
||||
def machine_at(state, signals, selfdrive_enabled=False):
|
||||
host = Host(signals, selfdrive_enabled)
|
||||
m = GuidanceStateMachine(Sab(host))
|
||||
m.state = state
|
||||
return m, host
|
||||
|
||||
|
||||
# (id, start state, signals, expected state)
|
||||
TRANSITIONS = [
|
||||
# from disabled
|
||||
("idle stays idle", State.disabled, (), State.disabled),
|
||||
("engage", State.disabled, (ENABLE,), State.enabled),
|
||||
("engage while overriding", State.disabled, (ENABLE, OVERRIDE), State.overriding),
|
||||
("blocked entry", State.disabled, (ENABLE, NO_ENTRY), State.disabled),
|
||||
("blocked entry parks when pause-eligible", State.disabled, (ENABLE, NO_ENTRY, PAUSE_OK), State.paused),
|
||||
|
||||
# from enabled
|
||||
("cruise steady", State.enabled, (), State.enabled),
|
||||
("driver off switch", State.enabled, (USER,), State.disabled),
|
||||
("driver off switch, silent -> pause", State.enabled, (USER, SILENT), State.paused),
|
||||
("hard fault", State.enabled, (IMMEDIATE,), State.disabled),
|
||||
("grace period entry", State.enabled, (SOFT,), State.softDisabling),
|
||||
("hands on wheel", State.enabled, (OVERRIDE,), State.overriding),
|
||||
("user beats soft", State.enabled, (USER, SOFT), State.disabled),
|
||||
("hard beats soft", State.enabled, (IMMEDIATE, SOFT), State.disabled),
|
||||
|
||||
# from softDisabling (timer still armed -> stays; see timer tests for expiry)
|
||||
("condition cleared", State.softDisabling, (), State.enabled),
|
||||
("user during grace", State.softDisabling, (USER,), State.disabled),
|
||||
("hard during grace", State.softDisabling, (IMMEDIATE,), State.disabled),
|
||||
|
||||
# from paused
|
||||
("stays parked", State.paused, (), State.paused),
|
||||
("blocked resume", State.paused, (ENABLE, NO_ENTRY), State.paused),
|
||||
("resume", State.paused, (ENABLE,), State.enabled),
|
||||
("resume into override", State.paused, (ENABLE, OVERRIDE), State.overriding),
|
||||
("user kill while parked", State.paused, (USER,), State.disabled),
|
||||
("silent user kill re-parks", State.paused, (USER, SILENT), State.paused),
|
||||
("hard fault while parked", State.paused, (IMMEDIATE,), State.disabled),
|
||||
|
||||
# from overriding
|
||||
("override released", State.overriding, (), State.enabled),
|
||||
("override held", State.overriding, (OVERRIDE,), State.overriding),
|
||||
("override to grace", State.overriding, (SOFT,), State.softDisabling),
|
||||
("override user kill", State.overriding, (USER,), State.disabled),
|
||||
("override hard fault", State.overriding, (IMMEDIATE,), State.disabled),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label,start,signals,expected", TRANSITIONS, ids=[t[0] for t in TRANSITIONS])
|
||||
def test_transition(label, start, signals, expected):
|
||||
m, _ = machine_at(start, signals)
|
||||
m.update()
|
||||
assert m.state == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start,signals,expected_enabled,expected_active", [
|
||||
(State.disabled, (), False, False),
|
||||
(State.disabled, (ENABLE,), True, True),
|
||||
(State.disabled, (ENABLE, NO_ENTRY, PAUSE_OK), True, False), # paused: guidance armed, torque off
|
||||
(State.enabled, (), True, True),
|
||||
(State.enabled, (SOFT,), True, True),
|
||||
(State.enabled, (USER,), False, False),
|
||||
(State.overriding, (OVERRIDE,), True, True),
|
||||
])
|
||||
def test_update_outputs(start, signals, expected_enabled, expected_active):
|
||||
m, _ = machine_at(start, signals)
|
||||
enabled, active = m.update()
|
||||
assert (enabled, active) == (expected_enabled, expected_active)
|
||||
|
||||
|
||||
class TestSoftDisableTimer:
|
||||
def test_grace_period_arms_timer_when_solo(self):
|
||||
m, host = machine_at(State.enabled, (SOFT,))
|
||||
m.update()
|
||||
assert m.state == State.softDisabling
|
||||
assert host.state_machine.soft_disable_timer == SOFT_DISABLE_FRAMES
|
||||
assert ET.SOFT_DISABLE in host.state_machine.current_alert_types
|
||||
|
||||
def test_grace_period_skips_timer_when_selfdrive_owns_it(self):
|
||||
m, host = machine_at(State.enabled, (SOFT,), selfdrive_enabled=True)
|
||||
m.update()
|
||||
assert m.state == State.softDisabling
|
||||
assert host.state_machine.soft_disable_timer == 0
|
||||
|
||||
def test_expiry_disables(self):
|
||||
m, host = machine_at(State.softDisabling, (SOFT,))
|
||||
host.state_machine.soft_disable_timer = 0
|
||||
m.update()
|
||||
assert m.state == State.disabled
|
||||
|
||||
def test_countdown_keeps_grace(self):
|
||||
m, host = machine_at(State.softDisabling, (SOFT,))
|
||||
host.state_machine.soft_disable_timer = 5
|
||||
m.update()
|
||||
assert m.state == State.softDisabling
|
||||
|
||||
|
||||
class TestAlertQueueing:
|
||||
def test_alerts_only_queued_when_solo(self):
|
||||
m, host = machine_at(State.disabled, (ENABLE,), selfdrive_enabled=True)
|
||||
m.update()
|
||||
assert host.state_machine.current_alert_types == []
|
||||
|
||||
def test_engage_alert_queued(self):
|
||||
m, host = machine_at(State.disabled, (ENABLE,))
|
||||
m.update()
|
||||
assert ET.ENABLE in host.state_machine.current_alert_types
|
||||
assert ET.WARNING in host.state_machine.current_alert_types # active -> warning channel open
|
||||
|
||||
def test_no_entry_alert_queued(self):
|
||||
m, host = machine_at(State.disabled, (ENABLE, NO_ENTRY))
|
||||
m.update()
|
||||
assert ET.NO_ENTRY in host.state_machine.current_alert_types
|
||||
|
||||
def test_user_disable_alert_always_queued(self):
|
||||
# user disable bypasses the solo gate — the driver asked, the driver hears back
|
||||
m, host = machine_at(State.enabled, (USER,), selfdrive_enabled=True)
|
||||
m.update()
|
||||
assert ET.USER_DISABLE in host.state_machine.current_alert_types
|
||||
|
||||
def test_override_alert_repeats_while_held(self):
|
||||
m, host = machine_at(State.overriding, (OVERRIDE,), selfdrive_enabled=True)
|
||||
m.update()
|
||||
assert ET.OVERRIDE_LATERAL in host.state_machine.current_alert_types
|
||||
|
||||
|
||||
class TestPauseEligibility:
|
||||
@pytest.mark.parametrize("event_name", PAUSE_WITH_IQ_EVENTS)
|
||||
def test_each_iq_pause_event_parks(self, event_name):
|
||||
host = Host((ENABLE, NO_ENTRY))
|
||||
host.events_iq = SignalBag((), {event_name})
|
||||
m = GuidanceStateMachine(Sab(host))
|
||||
m.state = State.disabled
|
||||
m.update()
|
||||
assert m.state == State.paused
|
||||
|
||||
@pytest.mark.parametrize("event_name", PAUSE_WITH_STOCK_EVENTS)
|
||||
def test_each_stock_pause_event_parks(self, event_name):
|
||||
host = Host((ENABLE, NO_ENTRY))
|
||||
host.events = SignalBag({ENABLE, NO_ENTRY}, {event_name})
|
||||
m = GuidanceStateMachine(Sab(host))
|
||||
m.state = State.disabled
|
||||
m.update()
|
||||
assert m.state == State.paused
|
||||
0
iqpilot/selfdrive/__init__.py
Normal file
BIN
iqpilot/selfdrive/assets/icons/clock.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
iqpilot/selfdrive/assets/img_minus_arrow_down.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
iqpilot/selfdrive/assets/img_plus_arrow_up.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_arrive.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_continue_left.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_continue_right.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_flag.png
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
iqpilot/selfdrive/assets/navigation/direction_fork_left.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_fork_right.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_merge_left.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_merge_right.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_off_ramp_left.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_off_ramp_right.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_turn_left.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
iqpilot/selfdrive/assets/navigation/direction_turn_right.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
iqpilot/selfdrive/assets/offroad/icon_home.png
Normal file
|
After Width: | Height: | Size: 537 KiB |
12
iqpilot/selfdrive/assets/offroad/icon_home.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="#ffffff"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M 4 2 h 16 a 2 2 0 0 1 2 2 v 6 a 2 2 0 0 1 -2 2 h -4.5 c -1.3 0 -1.9 3.2 -3.5 3.2 s -2.2 -3.2 -3.5 -3.2 H 4 a 2 2 0 0 1 -2 -2 V 4 a 2 2 0 0 1 2 -2 z M 4 3.2 h 16 a 0.8 0.8 0 0 1 0.8 0.8 v 6.2 a 0.8 0.8 0 0 1 -0.8 0.8 H 4 a 0.8 0.8 0 0 1 -0.8 -0.8 V 4 a 0.8 0.8 0 0 1 0.8 -0.8 z M 12 12.95 a 0.65 0.65 0 1 0 0 1.3 a 0.65 0.65 0 0 0 0 -1.3 z M 10.5 12.6 a 0.3 0.3 0 1 0 0 0.6 a 0.3 0.3 0 0 0 0 -0.6 z M 13.5 12.6 a 0.3 0.3 0 1 0 0 0.6 a 0.3 0.3 0 0 0 0 -0.6 z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 620 B |
BIN
iqpilot/selfdrive/assets/offroad/icon_longitudinal.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
1
iqpilot/selfdrive/assets/offroad/icon_longitudinal.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m12 14l4-4M3.34 19a10 10 0 1 1 17.32 0"/></svg>
|
||||
|
After Width: | Height: | Size: 233 B |
BIN
iqpilot/selfdrive/assets/offroad/icon_models.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
1
iqpilot/selfdrive/assets/offroad/icon_models.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 5a3 3 0 1 0-5.997.125a4 4 0 0 0-2.526 5.77a4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M9 13a4.5 4.5 0 0 0 3-4M6.003 5.125A3 3 0 0 0 6.401 6.5m-2.924 4.396a4 4 0 0 1 .585-.396M6 18a4 4 0 0 1-1.967-.516M12 13h4m-4 5h6a2 2 0 0 1 2 2v1M12 8h8m-4 0V5a2 2 0 0 1 2-2"/><circle cx="16" cy="13" r=".5"/><circle cx="18" cy="3" r=".5"/><circle cx="20" cy="21" r=".5"/><circle cx="20" cy="8" r=".5"/></g></svg>
|
||||
|
After Width: | Height: | Size: 597 B |
BIN
iqpilot/selfdrive/assets/offroad/icon_software.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
1
iqpilot/selfdrive/assets/offroad/icon_software.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><g fill="none" stroke="white" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 15V3m9 12v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10l5 5l5-5"/></g></svg>
|
||||
|
After Width: | Height: | Size: 275 B |
BIN
iqpilot/selfdrive/assets/offroad/icon_toggle.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |