| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480 |
- from __future__ import annotations
- import asyncio
- import json
- import time
- import uuid
- from dataclasses import dataclass
- from typing import Any, Dict, Optional, Tuple
- from config.logger import setup_logging
- from core.providers.tools.device_mcp.mcp_handler import call_mcp_tool
- from core.providers.tts.dto.dto import ContentType, SentenceType, TTSMessageDTO
- from .geo import (
- convert_from_wgs84,
- convert_to_wgs84,
- crosses_alert_line,
- haversine_m,
- normalize_coord_type,
- )
- from .storage import LineAlert, NavDB
- TAG = __name__
- logger = setup_logging()
- MONITOR_TICK_HZ = 5
- # Only ignore effectively identical fixes. A larger threshold misses
- # slow crossings that are visually obvious on the app map.
- MIN_MOVEMENT_M = 0.05
- LINE_BUFFER_M = 2.5
- TRIGGER_COOLDOWN_S = 15.0
- NOTE_MAX_LEN = 120
- ALERT_ECHO_TTL_S = 12.0
- @dataclass
- class GnssFix:
- ok: bool
- lat: float
- lon: float
- speed_mps: float
- heading_deg: Optional[float] = None
- t: Optional[int] = None
- def _parse_jsonish(raw: Any) -> Optional[dict]:
- if raw is None:
- return None
- if isinstance(raw, str):
- raw = raw.strip()
- if not raw:
- return None
- try:
- raw = json.loads(raw)
- except Exception:
- return None
- if not isinstance(raw, dict):
- return None
- return raw
- def _parse_fix(raw: Any) -> GnssFix:
- payload = _parse_jsonish(raw)
- if payload is None:
- return GnssFix(False, 0.0, 0.0, 0.0)
- if "gnss" in payload and isinstance(payload.get("gnss"), dict):
- payload = payload["gnss"]
- try:
- coord_type = normalize_coord_type(payload.get("coord_type"), default="WGS84")
- except ValueError:
- return GnssFix(False, 0.0, 0.0, 0.0)
- ok = bool(payload.get("ok", True))
- has_fix = payload.get("has_fix")
- if has_fix is not None:
- ok = ok and bool(has_fix)
- lat = payload.get("lat", payload.get("latitude"))
- lon = payload.get("lon", payload.get("longitude"))
- try:
- lat_f = float(lat)
- lon_f = float(lon)
- lat_f, lon_f = convert_to_wgs84(lat_f, lon_f, coord_type)
- except Exception:
- return GnssFix(False, 0.0, 0.0, 0.0)
- speed_mps = payload.get("speed_mps")
- if speed_mps is None:
- speed_knots = payload.get("speed_knots")
- if speed_knots is not None:
- try:
- speed_mps = float(speed_knots) * 0.514444
- except Exception:
- speed_mps = 0.0
- else:
- speed_kmh = payload.get("speed_kmh")
- if speed_kmh is not None:
- try:
- speed_mps = float(speed_kmh) / 3.6
- except Exception:
- speed_mps = 0.0
- else:
- speed_mps = payload.get("speed") or 0.0
- try:
- speed_mps_f = float(speed_mps)
- except Exception:
- speed_mps_f = 0.0
- heading = payload.get("heading_deg", payload.get("course_deg"))
- heading_f = None
- if heading is not None:
- try:
- heading_f = float(heading)
- except Exception:
- heading_f = None
- t = payload.get("t") or payload.get("timestamp") or payload.get("time")
- t_i = None
- if t is not None:
- try:
- t_i = int(float(t))
- except Exception:
- t_i = None
- fix = payload.get("fix") or payload.get("fix_quality") or payload.get("quality")
- if fix is not None:
- try:
- ok = ok and int(fix) > 0
- except Exception:
- pass
- return GnssFix(ok, lat_f, lon_f, max(0.0, speed_mps_f), heading_f, t_i)
- def _pick_tool_name(conn, preferred: str, keywords: Tuple[str, ...]) -> Optional[str]:
- if not hasattr(conn, "mcp_client") or not conn.mcp_client:
- return None
- if conn.mcp_client.has_tool(preferred):
- return preferred
- for name in conn.mcp_client.tools.keys():
- lowered = name.lower()
- if all(keyword in lowered for keyword in keywords):
- return name
- return None
- def _pick_gnss_tool_name(conn) -> Optional[str]:
- return _pick_tool_name(conn, "self_gnss_get_fix", ("gnss", "fix"))
- def normalize_note(note: str) -> str:
- normalized = " ".join((note or "").split()).strip()
- if not normalized:
- raise ValueError("note_required")
- if len(normalized) > NOTE_MAX_LEN:
- raise ValueError("note_too_long")
- return normalized
- def normalize_latlon(lat: Any, lon: Any) -> Tuple[float, float]:
- lat_f = float(lat)
- lon_f = float(lon)
- if not (-90.0 <= lat_f <= 90.0 and -180.0 <= lon_f <= 180.0):
- raise ValueError("invalid_coordinate")
- return lat_f, lon_f
- class NavCopilotService:
- def __init__(self, conn, db: NavDB):
- self.conn = conn
- self.db = db
- self.monitor_task: Optional[asyncio.Task] = None
- self._shared_gps_cache: Optional[Dict[str, Any]] = None
- self._last_fix_wgs84: Optional[Tuple[float, float]] = None
- self._last_trigger_at: Dict[int, float] = {}
- def device_id(self) -> str:
- return str(getattr(self.conn, "device_id", "") or "")
- async def recover_monitoring_if_needed(self) -> Optional[int]:
- device_id = self.device_id()
- if not device_id:
- return None
- count = self.db.count_line_alerts(device_id)
- if count <= 0:
- return None
- await self.ensure_monitoring()
- return count
- def _remember_spoken_alert(self, text: str) -> None:
- if not text:
- return
- now = time.monotonic()
- recent = getattr(self.conn, "_recent_nav_alert_notes", None)
- if not isinstance(recent, dict):
- recent = {}
- recent[text] = now
- expired = [
- note
- for note, spoken_at in recent.items()
- if now - float(spoken_at) > ALERT_ECHO_TTL_S
- ]
- for note in expired:
- recent.pop(note, None)
- self.conn._recent_nav_alert_notes = recent
- def _speak(self, text: str) -> None:
- text = str(text or "").strip()
- if not text:
- return
- try:
- self.conn.client_abort = True
- self.conn.clear_queues()
- self.conn.clearSpeakStatus()
- self.conn.client_abort = False
- self.conn.sentence_id = uuid.uuid4().hex
- self.conn.tts.store_tts_text(self.conn.sentence_id, text)
- self.conn.tts.tts_text_queue.put(
- TTSMessageDTO(
- sentence_id=self.conn.sentence_id,
- sentence_type=SentenceType.FIRST,
- content_type=ContentType.ACTION,
- )
- )
- self.conn.tts.tts_one_sentence(
- self.conn,
- ContentType.TEXT,
- content_detail=text,
- )
- self.conn.tts.tts_text_queue.put(
- TTSMessageDTO(
- sentence_id=self.conn.sentence_id,
- sentence_type=SentenceType.LAST,
- content_type=ContentType.ACTION,
- )
- )
- self._remember_spoken_alert(text)
- except Exception as exc:
- logger.bind(tag=TAG).warning(f"TTS speak failed: {exc}")
- async def _push_app_alert(self, *, alert_id: int, note: str) -> None:
- server = getattr(self.conn, "server", None)
- device_id = self.device_id()
- if server is None or not device_id:
- return
- triggered_at_epoch_s = time.time()
- payload = {
- "type": "risk_line_triggered",
- "event_id": uuid.uuid4().hex,
- "alert_id": int(alert_id),
- "note": str(note or "").strip(),
- "triggered_at": float(triggered_at_epoch_s),
- "triggered_at_ms": int(triggered_at_epoch_s * 1000),
- }
- try:
- await server.push_device_alert(device_id, payload)
- except Exception as exc:
- logger.bind(tag=TAG).warning(f"push app alert failed: {exc}")
- async def _get_fix(self) -> GnssFix:
- server = getattr(self.conn, "server", None)
- device_id = getattr(self.conn, "device_id", None)
- if server is not None and device_id:
- try:
- payload, _ = await server.get_latest_gps(
- str(device_id), max_age_s=3.0, allow_stale=False
- )
- if payload is not None:
- return _parse_fix(payload)
- except Exception as exc:
- logger.bind(tag=TAG).debug(f"read live telemetry gps failed: {exc}")
- tool_name = _pick_gnss_tool_name(self.conn)
- if not tool_name:
- return GnssFix(False, 0.0, 0.0, 0.0)
- try:
- raw = await call_mcp_tool(
- self.conn,
- self.conn.mcp_client,
- tool_name,
- "{}",
- timeout=5,
- )
- return _parse_fix(raw)
- except Exception as exc:
- logger.bind(tag=TAG).warning(f"GNSS fix failed: {exc}")
- return GnssFix(False, 0.0, 0.0, 0.0)
- def update_shared_gps(self, fix: GnssFix) -> None:
- if not fix.ok:
- return
- self._shared_gps_cache = {
- "ok": True,
- "device_id": getattr(self.conn, "device_id", None),
- "lat": fix.lat,
- "lon": fix.lon,
- "speed_mps": fix.speed_mps,
- "heading_deg": fix.heading_deg,
- "timestamp": fix.t,
- "coord_type": "WGS84",
- "source": "nav_copilot_shared",
- }
- def get_cached_gps(self) -> Optional[Dict[str, Any]]:
- return self._shared_gps_cache
- def is_monitoring_active(self) -> bool:
- return self.monitor_task is not None and not self.monitor_task.done()
- def list_line_alerts(self, device_id: str) -> list[LineAlert]:
- return self.db.list_line_alerts(device_id)
- async def create_line_alert(
- self,
- *,
- device_id: str,
- start_lat: float,
- start_lon: float,
- end_lat: float,
- end_lon: float,
- coord_type: str = "BD09",
- note: str,
- ) -> LineAlert:
- start_lat, start_lon = normalize_latlon(start_lat, start_lon)
- end_lat, end_lon = normalize_latlon(end_lat, end_lon)
- coord_type = normalize_coord_type(coord_type, default="BD09")
- note = normalize_note(note)
- if haversine_m(start_lat, start_lon, end_lat, end_lon) < 1.0:
- raise ValueError("line_too_short")
- alert_id = self.db.create_line_alert(
- device_id=device_id,
- start_lat=start_lat,
- start_lon=start_lon,
- end_lat=end_lat,
- end_lon=end_lon,
- coord_type=coord_type,
- note=note,
- )
- alert = self.db.get_line_alert(alert_id, device_id=device_id)
- assert alert is not None
- if device_id == self.device_id():
- await self.ensure_monitoring()
- return alert
- async def delete_line_alert(self, device_id: str, alert_id: int) -> bool:
- deleted = self.db.delete_line_alert(alert_id, device_id=device_id)
- if deleted and device_id == self.device_id() and self.db.count_line_alerts(device_id) <= 0:
- await self.stop_monitoring()
- return deleted
- async def ensure_monitoring(self) -> bool:
- device_id = self.device_id()
- if not device_id:
- return False
- if self.db.count_line_alerts(device_id) <= 0:
- return False
- if self.is_monitoring_active():
- return True
- self.monitor_task = asyncio.create_task(
- self._monitor_loop(),
- name=f"LineAlertMonitor:{device_id}",
- )
- return True
- async def stop_monitoring(self) -> None:
- task = self.monitor_task
- if task is not None and not task.done():
- task.cancel()
- try:
- await task
- except BaseException:
- pass
- self.monitor_task = None
- self._last_fix_wgs84 = None
- self._last_trigger_at = {}
- async def _monitor_loop(self) -> None:
- device_id = self.device_id()
- logger.bind(tag=TAG).info(f"Line alert monitor started: device_id={device_id}")
- try:
- while not self.conn.stop_event.is_set():
- if self.db.count_line_alerts(device_id) <= 0:
- break
- fix = await self._get_fix()
- if not fix.ok:
- await asyncio.sleep(1.0 / MONITOR_TICK_HZ)
- continue
- self.update_shared_gps(fix)
- current_fix_wgs84 = (fix.lat, fix.lon)
- last_fix_wgs84 = self._last_fix_wgs84
- self._last_fix_wgs84 = current_fix_wgs84
- if last_fix_wgs84 is None:
- await asyncio.sleep(1.0 / MONITOR_TICK_HZ)
- continue
- movement_m = haversine_m(
- last_fix_wgs84[0],
- last_fix_wgs84[1],
- current_fix_wgs84[0],
- current_fix_wgs84[1],
- )
- if movement_m < MIN_MOVEMENT_M:
- await asyncio.sleep(1.0 / MONITOR_TICK_HZ)
- continue
- for alert in self.db.list_line_alerts(device_id):
- prev_alert_lat, prev_alert_lon = convert_from_wgs84(
- last_fix_wgs84[0],
- last_fix_wgs84[1],
- alert.coord_type,
- )
- curr_alert_lat, curr_alert_lon = convert_from_wgs84(
- current_fix_wgs84[0],
- current_fix_wgs84[1],
- alert.coord_type,
- )
- if not crosses_alert_line(
- prev_alert_lat,
- prev_alert_lon,
- curr_alert_lat,
- curr_alert_lon,
- alert.start_lat,
- alert.start_lon,
- alert.end_lat,
- alert.end_lon,
- buffer_m=LINE_BUFFER_M,
- ):
- continue
- now = time.monotonic()
- last_trigger_at = self._last_trigger_at.get(alert.id, 0.0)
- if now - last_trigger_at < TRIGGER_COOLDOWN_S:
- continue
- logger.bind(tag=TAG).info(
- "Line alert triggered: "
- f"device_id={device_id}, alert_id={alert.id}, note={alert.note!r}"
- )
- self._speak(alert.note)
- await self._push_app_alert(
- alert_id=alert.id,
- note=alert.note,
- )
- self._last_trigger_at[alert.id] = now
- await asyncio.sleep(1.0 / MONITOR_TICK_HZ)
- except asyncio.CancelledError:
- raise
- finally:
- logger.bind(tag=TAG).info(f"Line alert monitor stopped: device_id={device_id}")
- self.monitor_task = None
- self._last_fix_wgs84 = None
- self._last_trigger_at = {}
- def status_text(self) -> str:
- device_id = self.device_id()
- if not device_id:
- return "当前没有可用设备连接。"
- count = self.db.count_line_alerts(device_id)
- if count <= 0:
- return "当前没有通过手机 App 配置的风险线。"
- if self.is_monitoring_active():
- return f"当前设备已启用风险线预警,数量 {count}。"
- return f"当前设备已有风险线 {count} 条,等待开始监控。"
- def get_service(conn) -> NavCopilotService:
- if not hasattr(conn, "nav_copilot_service") or conn.nav_copilot_service is None:
- conn.nav_copilot_service = NavCopilotService(conn, NavDB.default())
- return conn.nav_copilot_service
|