#!/usr/bin/env python3 """ Copyright (c) IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos """ import asyncio import hashlib import os import time from pathlib import Path import aiohttp from cereal import custom from openpilot.common.realtime import Ratekeeper from openpilot.common.time_helpers import system_time_valid from openpilot.iqpilot._proprietary_loader import ProprietaryModuleMissing, load_private_module from openpilot.common.swaglog import cloudlog from openpilot.system.hardware.hw import Paths _TIME_SYNC_WAIT_TIMEOUT_S = 30.0 _TIME_SYNC_POLL_S = 0.5 def _wait_for_valid_clock(timeout: float = _TIME_SYNC_WAIT_TIMEOUT_S) -> None: if system_time_valid(): return cloudlog.warning("models_manager: system clock not yet valid, waiting for NTP before fetching") deadline = time.monotonic() + timeout while time.monotonic() < deadline: if system_time_valid(): cloudlog.warning("models_manager: system clock is now valid, resuming") return time.sleep(_TIME_SYNC_POLL_S) cloudlog.warning("models_manager: gave up waiting for a valid clock, proceeding anyway") try: load_private_module(__name__, "iqpilot_private.models.manager") _BaseIQModelManager = IQModelManager # noqa: F821 except ProprietaryModuleMissing: from iqpilot.models_private_src.manager import IQModelManager as _BaseIQModelManager from openpilot.iqpilot.selfdrive.iqmodeld.models.git_auth import get_aiohttp_auth from openpilot.iqpilot.selfdrive.iqmodeld.models.helpers import ( bundle_files_ready, get_active_bundle, get_runtime_bundle_upgrade, is_default_bundle, persist_active_bundle, ) _ACTIVE_BUNDLE_KEY = "ModelManager_ActiveBundle" _DOWNLOAD_INDEX_KEY = "ModelManager_DownloadIndex" _RUNNER_CACHE_KEY = "ModelRunnerTypeCache" class IQModelManager(_BaseIQModelManager): def __init__(self): super().__init__() self._validated_active_key: tuple[tuple[str, str], ...] | None = None self._manifest_refresh_key: tuple[tuple[str, str], ...] | None = None @staticmethod def _bundle_index(bundle) -> int | None: try: return int(getattr(bundle, "index", -1)) except (TypeError, ValueError): return None @staticmethod def _bundle_files(bundle) -> list[tuple[str, str]]: files = [] for model in getattr(bundle, "models", []) or []: for artifact in (getattr(model, "metadata", None), getattr(model, "artifact", None)): filename = getattr(artifact, "fileName", "") if artifact is not None else "" if not filename: continue download_uri = getattr(artifact, "downloadUri", None) sha256 = getattr(download_uri, "sha256", "") if download_uri is not None else "" files.append((filename, sha256 or "")) return files @staticmethod def _safe_model_path(filename: str) -> Path | None: if not filename or os.path.basename(filename) != filename: cloudlog.warning(f"Ignoring unsafe model filename {filename!r}") return None root = Path(Paths.model_root()).resolve() path = (root / filename).resolve() try: path.relative_to(root) except ValueError: cloudlog.warning(f"Ignoring model path outside model root {path}") return None return path @staticmethod def _verify_file_sync(path: Path, expected_hash: str) -> bool: if not path.is_file(): return False if not expected_hash: return True sha256_hash = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): sha256_hash.update(chunk) return sha256_hash.hexdigest().lower() == expected_hash.lower() def _bundle_validation_key(self, bundle) -> tuple[tuple[str, str], ...]: return tuple(self._bundle_files(bundle)) def _bundle_files_valid(self, bundle) -> bool: for filename, expected_hash in self._bundle_files(bundle): path = self._safe_model_path(filename) if path is None or not self._verify_file_sync(path, expected_hash): return False return True def _remove_bundle_files(self, bundle) -> None: for filename, _expected_hash in self._bundle_files(bundle): path = self._safe_model_path(filename) if path is None: continue for candidate in (path, Path(f"{path}.download")): try: if candidate.is_file(): candidate.unlink() except OSError as e: cloudlog.exception(f"Failed to remove model artifact {candidate}: {e}") def _find_available_bundle(self, target): target_index = self._bundle_index(target) target_ref = getattr(target, "ref", None) target_internal = getattr(target, "internalName", None) target_display = getattr(target, "displayName", None) for bundle in self.available_models: if target_index is not None and self._bundle_index(bundle) == target_index: return bundle if target_ref and getattr(bundle, "ref", None) == target_ref: return bundle if target_internal and getattr(bundle, "internalName", None) == target_internal: return bundle if target_display and getattr(bundle, "displayName", None) == target_display: return bundle return None def _bundle_matches(self, left, right) -> bool: if left is None or right is None: return False left_index = self._bundle_index(left) right_index = self._bundle_index(right) if left_index is not None and right_index is not None and left_index == right_index: return True for attr in ("ref", "internalName", "displayName"): left_value = getattr(left, attr, None) if left_value and left_value == getattr(right, attr, None): return True return False def _clear_active_bundle(self) -> None: self.params.remove(_ACTIVE_BUNDLE_KEY) self.params.remove(_RUNNER_CACHE_KEY) self.active_bundle = None self._validated_active_key = None def _download_request_matches(self, bundle) -> bool: bundle_index = self._bundle_index(bundle) return bundle_index is not None and self._download_index() == bundle_index def _queue_active_redownload_if_invalid(self) -> None: if self.active_bundle is None: self._validated_active_key = None return validation_key = self._bundle_validation_key(self.active_bundle) if validation_key == self._validated_active_key: return if self._bundle_files_valid(self.active_bundle): self._validated_active_key = validation_key return bundle = self._find_available_bundle(self.active_bundle) or self.active_bundle bundle_index = self._bundle_index(bundle) cloudlog.warning(f"Active model {_display_bundle_name(self.active_bundle)} is missing or corrupt; queueing redownload") self._remove_bundle_files(bundle) self._clear_active_bundle() if bundle_index is not None and self._download_index() is None: self.params.put(_DOWNLOAD_INDEX_KEY, bundle_index) def _find_manifest_counterpart(self, target): # never match by index: indexes shift between manifest generations, and a # positional match could redownload a different model than the user selected for attr in ("ref", "internalName", "displayName"): value = getattr(target, attr, None) if not value: continue for bundle in self.available_models: if getattr(bundle, attr, None) == value: return bundle return None def _queue_active_manifest_refresh(self) -> None: active = self.active_bundle if active is None or is_default_bundle(active): return if self._download_index() is not None: return counterpart = self._find_manifest_counterpart(active) if counterpart is None: return counterpart_index = self._bundle_index(counterpart) if counterpart_index is None: return active_files = dict(self._bundle_files(active)) stale = False for filename, sha in self._bundle_files(counterpart): if not sha: continue active_sha = active_files.get(filename) # an empty recorded hash can't prove a mismatch, so it never triggers a redownload if active_sha is None or (active_sha and active_sha.lower() != sha.lower()): stale = True break if not stale: self._manifest_refresh_key = None return # the manifest may be an expired offline cache, so keep the active bundle and its # files in place: the download flow replaces artifacts atomically and only persists # the counterpart as active once everything landed. One attempt per bundle per run # so a dead network doesn't turn the 1Hz loop into a download-retry storm. key = self._bundle_validation_key(active) if key == self._manifest_refresh_key: return self._manifest_refresh_key = key cloudlog.warning(f"Active model {_display_bundle_name(active)} artifacts are stale vs current manifest; queueing redownload") self.params.put(_DOWNLOAD_INDEX_KEY, counterpart_index) async def _download_file(self, url: str, path: str, model) -> None: temp_path = f"{path}.download" self._download_start_times[model.fileName] = time.monotonic() try: if os.path.exists(temp_path): os.remove(temp_path) async with aiohttp.ClientSession(auth=get_aiohttp_auth()) as session: async with session.get(url) as response: response.raise_for_status() total_size = int(response.headers.get("content-length", 0)) bytes_downloaded = 0 with open(temp_path, "wb") as f: async for chunk in response.content.iter_chunked(self._chunk_size): f.write(chunk) bytes_downloaded += len(chunk) if self._download_index() is None: raise Exception("Download cancelled") if total_size > 0: progress = (bytes_downloaded / total_size) * 100 model.downloadProgress.status = custom.IQModelManager.DownloadStatus.downloading model.downloadProgress.progress = progress model.downloadProgress.eta = self._calculate_eta(model.fileName, progress) self._report_status() f.flush() os.fsync(f.fileno()) os.replace(temp_path, path) except Exception: if os.path.exists(temp_path): os.remove(temp_path) raise finally: self._download_start_times.pop(model.fileName, None) async def _download_bundle(self, model_bundle: custom.IQModelManager.ModelBundle, destination_path: str) -> None: self.selected_bundle = model_bundle self.selected_bundle.status = custom.IQModelManager.DownloadStatus.downloading os.makedirs(destination_path, exist_ok=True) try: if not self._download_request_matches(model_bundle): raise RuntimeError("Download cancelled") tasks = [self._process_model(model, destination_path) for model in self.selected_bundle.models] await asyncio.gather(*tasks) if not self._download_request_matches(model_bundle): raise RuntimeError("Download cancelled") self.active_bundle = self.selected_bundle self.active_bundle.status = custom.IQModelManager.DownloadStatus.downloaded self.params.put(_ACTIVE_BUNDLE_KEY, self.active_bundle.to_dict()) self.params.remove(_RUNNER_CACHE_KEY) self.selected_bundle = None except Exception: if self._download_request_matches(model_bundle) and self.selected_bundle is not None: self.selected_bundle.status = custom.IQModelManager.DownloadStatus.failed else: self.selected_bundle = None raise finally: self._report_status() def download(self, model_bundle: custom.IQModelManager.ModelBundle, destination_path: str) -> None: asyncio.run(self._download_bundle(model_bundle, destination_path)) def _queue_tinygrad_upgrade(self) -> None: if self.active_bundle is None: return replacement = get_runtime_bundle_upgrade(self.active_bundle, self.params, self.available_models) if replacement is None or replacement is self.active_bundle: return if bundle_files_ready(replacement): persist_active_bundle(self.params, replacement) self.active_bundle = replacement return if self._download_index() is None and getattr(replacement, "index", None) is not None: self.params.put("ModelManager_DownloadIndex", int(replacement.index)) cloudlog.warning(f"Queued tinygrad upgrade for retired bundle {getattr(self.active_bundle, 'internalName', '')}") def main_thread(self) -> None: _wait_for_valid_clock() rk = Ratekeeper(1, print_delay_threshold=None) while True: try: # before NTP the TLS cert reads "not yet valid" and every fetch SSL-fails; one line, not spam if not system_time_valid(): if not getattr(self, "_ntp_wait_logged", False): cloudlog.warning("models_manager: waiting for NTP before fetching (system clock not valid)") self._ntp_wait_logged = True rk.keep_time() continue self._ntp_wait_logged = False self.available_models = self.model_fetcher.get_available_bundles() self.active_bundle = get_active_bundle(self.params) self._queue_active_redownload_if_invalid() self._queue_tinygrad_upgrade() self._queue_active_manifest_refresh() if (index_to_download := self._download_index()) is not None: if model_to_download := next((model for model in self.available_models if model.index == index_to_download), None): try: self.download(model_to_download, Paths.model_root()) except Exception as e: cloudlog.exception(e) finally: self.params.remove("ModelManager_DownloadIndex") self.selected_bundle = None if self.params.get("ModelManager_ClearCache"): self.clear_model_cache() self.params.remove("ModelManager_ClearCache") self._report_status() rk.keep_time() except Exception as e: cloudlog.exception(f"Error in main thread: {str(e)}") rk.keep_time() def _display_bundle_name(bundle) -> str: return getattr(bundle, "internalName", None) or getattr(bundle, "displayName", None) or "" def main(): IQModelManager().main_thread() if __name__ == "__main__": main()