|
|
@@ -69,6 +69,8 @@ class HazardMarkResult:
|
|
|
mode: str
|
|
|
counter_value: Optional[int] = None
|
|
|
counter_absolute: Optional[int] = None
|
|
|
+ lat: Optional[float] = None
|
|
|
+ lon: Optional[float] = None
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
@@ -230,6 +232,10 @@ def _pick_counter_tool_name(conn) -> Optional[str]:
|
|
|
return _pick_tool_name(conn, "self_nav_counter_get_state", ("counter", "state"))
|
|
|
|
|
|
|
|
|
+def _pick_nav_alert_tool_name(conn) -> Optional[str]:
|
|
|
+ return _pick_tool_name(conn, "self_nav_alert_play", ("nav", "alert", "play"))
|
|
|
+
|
|
|
+
|
|
|
class NavCopilotService:
|
|
|
def __init__(self, conn, db: NavDB):
|
|
|
self.conn = conn
|
|
|
@@ -257,15 +263,57 @@ class NavCopilotService:
|
|
|
self._run_hazards: List[Hazard] = []
|
|
|
self._run_last_idx: Optional[int] = None
|
|
|
self._run_announced: Set[int] = set()
|
|
|
+ self._run_gps_last_announce_at: Dict[int, float] = {}
|
|
|
self._run_counter_announced: Set[Tuple[int, int, str]] = set()
|
|
|
self._run_last_announce_at = 0.0
|
|
|
|
|
|
+ self.gps_alert_radius_m = 3.0
|
|
|
+ self.gps_alert_repeat_cooldown_s = 2.0
|
|
|
self.announce_at_m = 50.0
|
|
|
self.announce_window_m = 18.0
|
|
|
self.announce_cooldown_s = 5.0
|
|
|
self.counter_announce_ahead = 6
|
|
|
self.counter_announce_cooldown_s = 1.5
|
|
|
|
|
|
+ async def recover_monitoring_if_needed(self) -> Optional[int]:
|
|
|
+ if self.run_route_id is not None:
|
|
|
+ return self.run_route_id
|
|
|
+ if not _pick_gnss_tool_name(self.conn):
|
|
|
+ return None
|
|
|
+
|
|
|
+ route_id = self.db.get_monitoring_route_id()
|
|
|
+ if not route_id:
|
|
|
+ return None
|
|
|
+
|
|
|
+ hazards = self.db.load_hazards(route_id)
|
|
|
+ if not hazards:
|
|
|
+ logger.bind(tag=TAG).warning(
|
|
|
+ f"Route {route_id} was marked as monitoring but has no hazards; clearing monitoring state"
|
|
|
+ )
|
|
|
+ self.db.set_monitoring_route(None)
|
|
|
+ return None
|
|
|
+
|
|
|
+ route_mode = self.db.get_route_mode(route_id)
|
|
|
+ if route_mode != "gps":
|
|
|
+ logger.bind(tag=TAG).warning(
|
|
|
+ f"Route {route_id} monitoring recovery only supports gps mode, current mode={route_mode}; clearing monitoring state"
|
|
|
+ )
|
|
|
+ self.db.set_monitoring_route(None)
|
|
|
+ return None
|
|
|
+
|
|
|
+ try:
|
|
|
+ await self.start_run(route_id=route_id, tick_hz=10, mode=route_mode)
|
|
|
+ except RuntimeError as exc:
|
|
|
+ logger.bind(tag=TAG).warning(
|
|
|
+ f"Failed to recover monitoring route {route_id}: {exc}"
|
|
|
+ )
|
|
|
+ return None
|
|
|
+
|
|
|
+ logger.bind(tag=TAG).info(
|
|
|
+ f"Recovered GPS monitoring after reconnect, route_id={route_id}, hazards={len(hazards)}"
|
|
|
+ )
|
|
|
+ return route_id
|
|
|
+
|
|
|
def _counter_input_hint(self) -> str:
|
|
|
return (
|
|
|
"请确认 STM32 已接到 RXD1/GND,波特率 115200,"
|
|
|
@@ -282,10 +330,42 @@ class NavCopilotService:
|
|
|
except Exception as exc:
|
|
|
logger.bind(tag=TAG).warning(f"TTS speak failed: {exc}")
|
|
|
|
|
|
+ async def _play_local_hazard_alert(self, hazard: Hazard) -> bool:
|
|
|
+ tool_name = _pick_nav_alert_tool_name(self.conn)
|
|
|
+ if not tool_name:
|
|
|
+ return False
|
|
|
+ try:
|
|
|
+ await call_mcp_tool(
|
|
|
+ self.conn,
|
|
|
+ self.conn.mcp_client,
|
|
|
+ tool_name,
|
|
|
+ json.dumps(
|
|
|
+ {
|
|
|
+ "hazard_type": hazard.type,
|
|
|
+ "note": hazard.note or "",
|
|
|
+ },
|
|
|
+ ensure_ascii=False,
|
|
|
+ ),
|
|
|
+ timeout=5,
|
|
|
+ )
|
|
|
+ return True
|
|
|
+ except Exception as exc:
|
|
|
+ logger.bind(tag=TAG).warning(f"Local hazard alert failed: {exc}")
|
|
|
+ return False
|
|
|
+
|
|
|
+ async def _announce_gps_hazard(self, hazard: Hazard) -> None:
|
|
|
+ if await self._play_local_hazard_alert(hazard):
|
|
|
+ return
|
|
|
+ label = _HAZARD_LABELS.get(hazard.type, _HAZARD_LABELS["other"])
|
|
|
+ text = f"注意{label}"
|
|
|
+ if hazard.note:
|
|
|
+ text = f"{text},{hazard.note}"
|
|
|
+ self._speak(text)
|
|
|
+
|
|
|
def _normalize_mode(self, mode: str | None) -> str:
|
|
|
- normalized = (mode or "auto").strip().lower()
|
|
|
+ normalized = (mode or "gps").strip().lower()
|
|
|
if normalized not in {"auto", "gps", "counter"}:
|
|
|
- return "auto"
|
|
|
+ return "gps"
|
|
|
return normalized
|
|
|
|
|
|
def _resolve_mode(self, requested_mode: str | None, route_id: int | None = None) -> str:
|
|
|
@@ -298,15 +378,9 @@ class NavCopilotService:
|
|
|
if not _pick_gnss_tool_name(self.conn):
|
|
|
raise RuntimeError("设备端没有可用的 GNSS 定位工具")
|
|
|
return "gps"
|
|
|
- if mode == "counter":
|
|
|
- if not _pick_counter_tool_name(self.conn):
|
|
|
- raise RuntimeError("设备端没有可用的串口计数工具")
|
|
|
- return "counter"
|
|
|
- if _pick_counter_tool_name(self.conn):
|
|
|
- return "counter"
|
|
|
if _pick_gnss_tool_name(self.conn):
|
|
|
return "gps"
|
|
|
- raise RuntimeError("设备端既没有 GNSS 工具,也没有串口计数工具")
|
|
|
+ raise RuntimeError("设备端没有可用的 GNSS 定位工具")
|
|
|
|
|
|
async def _get_fix(self) -> GnssFix:
|
|
|
tool_name = _pick_gnss_tool_name(self.conn)
|
|
|
@@ -493,8 +567,11 @@ class NavCopilotService:
|
|
|
|
|
|
hazard_count = len(self.db.load_hazards(route_id))
|
|
|
monitor_started = False
|
|
|
- if route_mode == "counter" and hazard_count > 0:
|
|
|
- await self.start_run(route_id=route_id, tick_hz=10, mode="counter")
|
|
|
+ logger.bind(tag=TAG).info(
|
|
|
+ f"Stop recording route_id={route_id}, mode={route_mode}, hazards={hazard_count}"
|
|
|
+ )
|
|
|
+ if hazard_count > 0:
|
|
|
+ await self.start_run(route_id=route_id, tick_hz=10, mode=route_mode)
|
|
|
monitor_started = True
|
|
|
|
|
|
return StopRecordingResult(
|
|
|
@@ -627,50 +704,69 @@ class NavCopilotService:
|
|
|
route_id = self.record_route_id or self.run_route_id or self.db.get_active_route_id()
|
|
|
if not route_id:
|
|
|
raise RuntimeError("没有可用路线,请先开始记录")
|
|
|
- route_mode = self.db.get_route_mode(route_id)
|
|
|
+ route_mode = "gps"
|
|
|
now_t = int(time.time())
|
|
|
|
|
|
- if route_mode == "counter":
|
|
|
+ if False and route_mode == "counter":
|
|
|
sample = await self._get_counter_state()
|
|
|
if not sample.ok:
|
|
|
+ fix = await self._get_fix()
|
|
|
+ if fix.ok:
|
|
|
+ logger.bind(tag=TAG).warning(
|
|
|
+ f"Counter route {route_id} has no live counter sample but GPS is available; switching route to gps mode"
|
|
|
+ )
|
|
|
+ self.db.update_route_mode(route_id, "gps")
|
|
|
+ route_mode = "gps"
|
|
|
+ if self.record_route_id == route_id:
|
|
|
+ self.record_mode = "gps"
|
|
|
+ if self.record_task is None or self.record_task.done():
|
|
|
+ self._record_interval_s = 0.1
|
|
|
+ self.record_task = asyncio.create_task(
|
|
|
+ self._record_gps_loop(),
|
|
|
+ name=f"NavRecordGps:{route_id}",
|
|
|
+ )
|
|
|
+ if self.run_route_id == route_id:
|
|
|
+ self.run_mode = "gps"
|
|
|
+ if route_mode == "counter" and not sample.ok:
|
|
|
raise RuntimeError(
|
|
|
"当前没有可用的串口计数值,无法标记风险点。"
|
|
|
+ self._counter_input_hint()
|
|
|
)
|
|
|
|
|
|
- points = self.db.load_route_points(route_id)
|
|
|
- if points:
|
|
|
- point = self._pick_counter_point(points, sample)
|
|
|
- seq = point.seq
|
|
|
- cum = point.cum_dist_m
|
|
|
- else:
|
|
|
- seq = len(self.db.load_hazards(route_id)) + 1
|
|
|
- cum = float(max(0, seq - 1))
|
|
|
-
|
|
|
- hazard_id = self.db.add_hazard(
|
|
|
- route_id=route_id,
|
|
|
- seq=seq,
|
|
|
- t=int(sample.t or now_t),
|
|
|
- lat=0.0,
|
|
|
- lon=0.0,
|
|
|
- cum_dist_m=cum,
|
|
|
- hazard_type=hazard_type,
|
|
|
- note=note or "",
|
|
|
- counter_value=sample.value,
|
|
|
- counter_absolute=sample.absolute,
|
|
|
- )
|
|
|
+ if route_mode == "counter":
|
|
|
+ points = self.db.load_route_points(route_id)
|
|
|
+ if points:
|
|
|
+ point = self._pick_counter_point(points, sample)
|
|
|
+ seq = point.seq
|
|
|
+ cum = point.cum_dist_m
|
|
|
+ else:
|
|
|
+ seq = len(self.db.load_hazards(route_id)) + 1
|
|
|
+ cum = float(max(0, seq - 1))
|
|
|
+
|
|
|
+ hazard_id = self.db.add_hazard(
|
|
|
+ route_id=route_id,
|
|
|
+ seq=seq,
|
|
|
+ t=int(sample.t or now_t),
|
|
|
+ lat=0.0,
|
|
|
+ lon=0.0,
|
|
|
+ cum_dist_m=cum,
|
|
|
+ hazard_type=hazard_type,
|
|
|
+ note=note or "",
|
|
|
+ counter_value=sample.value,
|
|
|
+ counter_absolute=sample.absolute,
|
|
|
+ )
|
|
|
|
|
|
- if self.record_route_id == route_id and self.record_mode == "counter":
|
|
|
- self._record_counter_hazards = self.db.load_hazards(route_id)
|
|
|
- if self.run_route_id == route_id and self.run_mode == "counter":
|
|
|
- self._run_hazards = self.db.load_hazards(route_id)
|
|
|
- return HazardMarkResult(
|
|
|
- hazard_id=hazard_id,
|
|
|
- route_id=route_id,
|
|
|
- mode="counter",
|
|
|
- counter_value=sample.value,
|
|
|
- counter_absolute=sample.absolute,
|
|
|
- )
|
|
|
+ if self.record_route_id == route_id and self.record_mode == "counter":
|
|
|
+ self._record_counter_hazards = self.db.load_hazards(route_id)
|
|
|
+ if self.run_route_id == route_id and self.run_mode == "counter":
|
|
|
+ self._run_hazards = self.db.load_hazards(route_id)
|
|
|
+ return HazardMarkResult(
|
|
|
+ hazard_id=hazard_id,
|
|
|
+ route_id=route_id,
|
|
|
+ mode="counter",
|
|
|
+ counter_value=sample.value,
|
|
|
+ counter_absolute=sample.absolute,
|
|
|
+ )
|
|
|
|
|
|
fix = await self._get_fix()
|
|
|
if not fix.ok:
|
|
|
@@ -681,11 +777,13 @@ class NavCopilotService:
|
|
|
cum = self._record_cum_m
|
|
|
else:
|
|
|
points = self.db.load_route_points(route_id)
|
|
|
- if not points:
|
|
|
- raise RuntimeError("路线里还没有轨迹点")
|
|
|
- idx, _ = nearest_point_index([(point.lat, point.lon) for point in points], fix.lat, fix.lon)
|
|
|
- seq = points[idx].seq
|
|
|
- cum = points[idx].cum_dist_m
|
|
|
+ if points:
|
|
|
+ idx, _ = nearest_point_index([(point.lat, point.lon) for point in points], fix.lat, fix.lon)
|
|
|
+ seq = points[idx].seq
|
|
|
+ cum = points[idx].cum_dist_m
|
|
|
+ else:
|
|
|
+ seq = self._record_seq if self._record_seq > 0 else len(self.db.load_hazards(route_id)) + 1
|
|
|
+ cum = self._record_cum_m
|
|
|
|
|
|
hazard_id = self.db.add_hazard(
|
|
|
route_id=route_id,
|
|
|
@@ -704,6 +802,8 @@ class NavCopilotService:
|
|
|
hazard_id=hazard_id,
|
|
|
route_id=route_id,
|
|
|
mode="gps",
|
|
|
+ lat=fix.lat,
|
|
|
+ lon=fix.lon,
|
|
|
)
|
|
|
|
|
|
async def start_run(
|
|
|
@@ -724,17 +824,23 @@ class NavCopilotService:
|
|
|
route_mode = self._resolve_mode(mode, route_id=route_id)
|
|
|
points = self.db.load_route_points(route_id)
|
|
|
hazards = self.db.load_hazards(route_id)
|
|
|
- if route_mode == "gps" and len(points) < 2:
|
|
|
- raise RuntimeError("GPS 路线点太少,无法开始预警")
|
|
|
+ if route_mode == "gps" and not hazards:
|
|
|
+ raise RuntimeError("这条 GPS 路线还没有任何标记点")
|
|
|
if route_mode == "counter" and not hazards:
|
|
|
raise RuntimeError("这条计数路线还没有任何标记点")
|
|
|
|
|
|
+ logger.bind(tag=TAG).info(
|
|
|
+ f"Start run requested, route_id={route_id}, mode={route_mode}, hazards={len(hazards)}, points={len(points)}"
|
|
|
+ )
|
|
|
self.run_route_id = route_id
|
|
|
self.run_mode = route_mode
|
|
|
+ self.db.set_active_route(route_id)
|
|
|
+ self.db.set_monitoring_route(route_id)
|
|
|
self._run_points = points
|
|
|
self._run_hazards = hazards
|
|
|
self._run_last_idx = None
|
|
|
self._run_announced = set()
|
|
|
+ self._run_gps_last_announce_at = {}
|
|
|
self._run_counter_announced = set()
|
|
|
self._run_last_announce_at = 0.0
|
|
|
hz = max(1, int(tick_hz or 10))
|
|
|
@@ -765,20 +871,22 @@ class NavCopilotService:
|
|
|
self.run_task = None
|
|
|
self.run_route_id = None
|
|
|
self.run_mode = None
|
|
|
+ self.db.set_monitoring_route(None)
|
|
|
self._run_points = []
|
|
|
self._run_hazards = []
|
|
|
self._run_last_idx = None
|
|
|
self._run_announced = set()
|
|
|
+ self._run_gps_last_announce_at = {}
|
|
|
self._run_counter_announced = set()
|
|
|
return route_id
|
|
|
|
|
|
async def _run_gps_loop(self) -> None:
|
|
|
assert self.run_route_id is not None
|
|
|
route_id = self.run_route_id
|
|
|
- logger.bind(tag=TAG).info(f"GPS run loop started, route_id={route_id}")
|
|
|
+ logger.bind(tag=TAG).info(
|
|
|
+ f"GPS run loop started, route_id={route_id}, hazards={len(self._run_hazards)}, radius_m={self.gps_alert_radius_m}, repeat_cooldown_s={self.gps_alert_repeat_cooldown_s}"
|
|
|
+ )
|
|
|
|
|
|
- points = self._run_points
|
|
|
- latlon_points = [(point.lat, point.lon) for point in points]
|
|
|
try:
|
|
|
while not self.conn.stop_event.is_set():
|
|
|
fix = await self._get_fix()
|
|
|
@@ -786,52 +894,20 @@ class NavCopilotService:
|
|
|
await asyncio.sleep(1.0)
|
|
|
continue
|
|
|
|
|
|
- idx, _ = nearest_point_index(
|
|
|
- latlon_points,
|
|
|
- fix.lat,
|
|
|
- fix.lon,
|
|
|
- start_idx=self._run_last_idx,
|
|
|
- window=120,
|
|
|
- )
|
|
|
- self._run_last_idx = idx
|
|
|
- cur_cum = points[idx].cum_dist_m
|
|
|
-
|
|
|
- next_hazard: Optional[Hazard] = None
|
|
|
- next_distance: Optional[float] = None
|
|
|
for hazard in self._run_hazards:
|
|
|
- if hazard.id in self._run_announced:
|
|
|
- continue
|
|
|
- distance = hazard.cum_dist_m - cur_cum
|
|
|
- if distance < -15:
|
|
|
- self._run_announced.add(hazard.id)
|
|
|
+ if hazard.lat == 0.0 and hazard.lon == 0.0:
|
|
|
continue
|
|
|
- if distance < 0:
|
|
|
- continue
|
|
|
- if next_distance is None or distance < next_distance:
|
|
|
- next_hazard = hazard
|
|
|
- next_distance = distance
|
|
|
-
|
|
|
- if next_hazard and next_distance is not None:
|
|
|
- now = time.time()
|
|
|
- if now - self._run_last_announce_at >= self.announce_cooldown_s:
|
|
|
- if (
|
|
|
- self.announce_at_m - self.announce_window_m
|
|
|
- <= next_distance
|
|
|
- <= self.announce_at_m
|
|
|
- ):
|
|
|
- speed_mps = max(0.5, float(fix.speed_mps or 0.0))
|
|
|
- label = _HAZARD_LABELS.get(
|
|
|
- next_hazard.type,
|
|
|
- _HAZARD_LABELS["other"],
|
|
|
- )
|
|
|
- eta_s = int(round(next_distance / speed_mps))
|
|
|
- dist_i = int(round(next_distance))
|
|
|
- text = f"注意前方 {dist_i} 米{label},预计 {eta_s} 秒到达"
|
|
|
- if next_hazard.note:
|
|
|
- text = f"{text},{next_hazard.note}"
|
|
|
- self._speak(text)
|
|
|
- self._run_last_announce_at = now
|
|
|
- self._run_announced.add(next_hazard.id)
|
|
|
+ distance = haversine_m(fix.lat, fix.lon, hazard.lat, hazard.lon)
|
|
|
+ if distance <= self.gps_alert_radius_m:
|
|
|
+ now = time.monotonic()
|
|
|
+ last_announce_at = self._run_gps_last_announce_at.get(hazard.id, 0.0)
|
|
|
+ if now - last_announce_at < self.gps_alert_repeat_cooldown_s:
|
|
|
+ continue
|
|
|
+ logger.bind(tag=TAG).info(
|
|
|
+ f"GPS hazard triggered: route_id={route_id}, hazard_id={hazard.id}, distance_m={distance:.3f}, fix=({fix.lat:.6f},{fix.lon:.6f}), hazard=({hazard.lat:.6f},{hazard.lon:.6f})"
|
|
|
+ )
|
|
|
+ await self._announce_gps_hazard(hazard)
|
|
|
+ self._run_gps_last_announce_at[hazard.id] = now
|
|
|
|
|
|
await asyncio.sleep(self._run_interval_s)
|
|
|
finally:
|
|
|
@@ -843,6 +919,7 @@ class NavCopilotService:
|
|
|
self._run_hazards = []
|
|
|
self._run_last_idx = None
|
|
|
self._run_announced = set()
|
|
|
+ self._run_gps_last_announce_at = {}
|
|
|
self._run_counter_announced = set()
|
|
|
|
|
|
async def _run_counter_loop(self) -> None:
|
|
|
@@ -869,6 +946,7 @@ class NavCopilotService:
|
|
|
self._run_hazards = []
|
|
|
self._run_last_idx = None
|
|
|
self._run_announced = set()
|
|
|
+ self._run_gps_last_announce_at = {}
|
|
|
self._run_counter_announced = set()
|
|
|
|
|
|
def status_text(self) -> str:
|
|
|
@@ -892,6 +970,11 @@ class NavCopilotService:
|
|
|
f"正在预警路线 ID={self.run_route_id},模式={self.run_mode or 'unknown'},风险点={len(self._run_hazards)}"
|
|
|
)
|
|
|
if not parts:
|
|
|
+ route_id = self.db.get_monitoring_route_id()
|
|
|
+ if route_id:
|
|
|
+ parts.append(
|
|
|
+ f"数据库显示正在预警路线 ID={route_id},模式={self.db.get_route_mode(route_id)},等待本次连接恢复执行"
|
|
|
+ )
|
|
|
route_id = self.db.get_active_route_id()
|
|
|
if route_id:
|
|
|
parts.append(
|