""" lidar_api.py — Livox Mid-360 LiDAR interface via SlamEngineClient ================================================================== Background thread polls SLAM data_q at 20Hz. Exposes obstacle_ahead(), get_slam_pose(), get_nav_cmd(), get_loc_state(). The SLAM subprocess handles all point cloud processing, costmap building, and safety evaluation internally. We just read the results. """ import math import os import sys import threading import time import numpy as np from Core.config_loader import load_config, config_path from Core.logger import log _cfg = load_config("LiDAR") LIDAR_AVAILABLE = False # ── Shared state (thread-safe) ─────────────────────────────────────────────── _lock = threading.Lock() _latest_safety = [{}] _latest_pose = [None] # 4x4 numpy or None _latest_nav = [{}] _latest_loc_state = ["IDLE"] _latest_mode = ["IDLE"] _latest_perf = [{}] _lidar_alive = [False] _client = [None] _last_frame_time = [0.0] # ══════════════════════════════════════════════════════════════════════════════ # INIT # ══════════════════════════════════════════════════════════════════════════════ def init_lidar(): """ Start SLAM subprocess + poll thread. Returns True if LiDAR connected successfully. """ global LIDAR_AVAILABLE if not _cfg.get("enabled", True): print(" [LiDAR] Disabled in config") return False slam_dir = config_path(_cfg.get("slam_source_dir", "Lidar")) if slam_dir not in sys.path: sys.path.insert(0, slam_dir) try: from SLAM_engine import SlamEngineClient except ImportError as e: print(f" [LiDAR] SlamEngineClient not found: {e}") log(f"LiDAR import failed: {e}", "error", "lidar") return False try: client = SlamEngineClient() _client[0] = client print(" [LiDAR] SLAM engine created") log("SLAM engine created", "info", "lidar") except Exception as e: print(f" [LiDAR] SLAM init error: {e}") log(f"SLAM init error: {e}", "error", "lidar") return False # Start subprocess + connect to hardware if _cfg.get("auto_connect", True): try: client.start_process() client.connect() print(" [LiDAR] Connected to Livox Mid-360") log("Connected to LiDAR", "info", "lidar") if _cfg.get("start_localize_only", False): client.start_localize_only() print(" [LiDAR] Localize-only mode") elif _cfg.get("auto_start_mapping", False): client.start_mapping() print(" [LiDAR] Mapping started") except Exception as e: print(f" [LiDAR] Connect error: {e}") log(f"LiDAR connect error: {e}", "error", "lidar") return False # Start poll thread _lidar_alive[0] = True threading.Thread(target=_poll_loop, daemon=True, name="lidar-poll").start() # Wait for first FRAME (up to 5s) deadline = time.time() + 5.0 while time.time() < deadline: if _last_frame_time[0] > 0: break time.sleep(0.1) if _last_frame_time[0] > 0: LIDAR_AVAILABLE = True print(" [LiDAR] Receiving data") log("LiDAR available — receiving frames", "info", "lidar") else: print(" [LiDAR] No data yet (will keep trying)") LIDAR_AVAILABLE = True # still mark available — data may arrive later return True # ══════════════════════════════════════════════════════════════════════════════ # POLL LOOP # ══════════════════════════════════════════════════════════════════════════════ def _poll_loop(): """Drain data_q, keep latest FRAME payload, update shared state.""" client = _client[0] if client is None: return poll_interval = 1.0 / max(1, _cfg.get("poll_hz", 20)) while _lidar_alive[0]: try: # Drain data_q — keep only the latest FRAME payload = None while True: try: item = client.data_q.get_nowait() if isinstance(item, tuple) and len(item) >= 2 and item[0] == "FRAME": payload = item[1] except Exception: break if payload is not None: _update_state(payload) # Drain status_q (just log, don't block) while True: try: msg = client.status_q.get_nowait() if isinstance(msg, tuple) and len(msg) >= 2: level, text = str(msg[0]), str(msg[1]) if level == "ERROR": log(f"SLAM: {text}", "error", "lidar") elif level == "WARN": log(f"SLAM: {text}", "warning", "lidar") except Exception: break except Exception as e: log(f"LiDAR poll error: {e}", "error", "lidar") time.sleep(poll_interval) def _update_state(payload): """Extract safety/pose/nav from FRAME payload into shared state.""" with _lock: _latest_safety[0] = payload.get("safety", {}) _latest_pose[0] = payload.get("pose") _latest_nav[0] = payload.get("nav", {}) _latest_loc_state[0] = str(payload.get("loc_state", "IDLE")) _latest_mode[0] = str(payload.get("mode", "IDLE")) _latest_perf[0] = payload.get("perf", {}) _last_frame_time[0] = time.time() # ══════════════════════════════════════════════════════════════════════════════ # PUBLIC API # ══════════════════════════════════════════════════════════════════════════════ def obstacle_ahead(radius=None): """ Returns True if the SLAM safety system detected an obstacle. Checks safety.emergency OR safety.hold from the latest FRAME. This uses the SLAM's own costmap + SafetySupervisor — 0.50m collision zone, localization loss detection, and 0.8s emergency hold. Safe to call even when LiDAR is not available (returns False). """ if not LIDAR_AVAILABLE or _last_frame_time[0] == 0: return False with _lock: safety = _latest_safety[0] if not safety: return False return bool(safety.get("emergency", False)) or bool(safety.get("hold", False)) def get_slam_pose(): """ Returns (x, y, heading_deg) from the SLAM 4x4 pose matrix. Returns None if no pose available. More accurate than dead reckoning (±2cm vs ±10cm). """ with _lock: pose = _latest_pose[0] if pose is None: return None pose = np.asarray(pose, dtype=np.float64) if pose.shape != (4, 4): return None x = float(pose[0, 3]) y = float(pose[1, 3]) yaw = math.degrees(math.atan2(float(pose[1, 0]), float(pose[0, 0]))) return {"x": round(x, 4), "y": round(y, 4), "heading": round(yaw, 2), "source": "slam"} def get_nav_cmd(): """ Returns the SLAM path planner's recommended velocity command. {"linear_mps": float, "angular_rps": float, "blocked": bool, "goal_reached": bool} Returns None if no nav data. """ with _lock: nav = _latest_nav[0] cmd = nav.get("cmd") if nav else None if not cmd or not isinstance(cmd, dict): return None return cmd def get_loc_state(): """Returns 'TRACKING', 'DEGRADED', 'LOST', or 'RECOVERY'.""" with _lock: return _latest_loc_state[0] def get_safety_reasons(): """Returns list of current safety trigger reasons, or empty list.""" with _lock: safety = _latest_safety[0] return safety.get("reasons", []) if safety else [] def get_lidar_status(): """Full status dict for diagnostics.""" with _lock: return { "available": LIDAR_AVAILABLE, "mode": _latest_mode[0], "loc_state": _latest_loc_state[0], "safety": dict(_latest_safety[0]) if _latest_safety[0] else {}, "perf": dict(_latest_perf[0]) if _latest_perf[0] else {}, "last_frame_age": round(time.time() - _last_frame_time[0], 1) if _last_frame_time[0] > 0 else -1, "pose": get_slam_pose(), } def get_client(): """Return the SlamEngineClient instance (for direct commands).""" return _client[0] def stop_lidar(): """Clean shutdown.""" global LIDAR_AVAILABLE _lidar_alive[0] = False LIDAR_AVAILABLE = False client = _client[0] if client is not None: try: client.stop_process() except Exception: pass log("LiDAR stopped", "info", "lidar") print(" [LiDAR] Stopped")