diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c8..adccc4a 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -27,7 +27,7 @@ SessionDep = Annotated[Session, Depends(get_db)] TokenDep = Annotated[str, Depends(reusable_oauth2)] -def get_current_user(session: SessionDep, token: TokenDep) -> User: +def resolve_user_from_token(*, session: Session, token: str) -> User: try: payload = jwt.decode( token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] @@ -46,6 +46,10 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: return user +def get_current_user(session: SessionDep, token: TokenDep) -> User: + return resolve_user_from_token(session=session, token=token) + + CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 4ea6cac..ff1e1cb 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import locations, login, private, users, utils +from app.api.routes import locations, login, private, telemetry, users, utils from app.core.config import settings api_router = APIRouter() @@ -8,8 +8,8 @@ api_router.include_router(login.router) api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(locations.router) +api_router.include_router(telemetry.router) if settings.ENVIRONMENT == "local": api_router.include_router(private.router) - diff --git a/backend/app/api/routes/telemetry.py b/backend/app/api/routes/telemetry.py new file mode 100644 index 0000000..9342db4 --- /dev/null +++ b/backend/app/api/routes/telemetry.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, WebSocket, status +from fastapi.encoders import jsonable_encoder +from fastapi.websockets import WebSocketDisconnect +from sqlmodel import Session + +from app.api.deps import CurrentUser, SessionDep, resolve_user_from_token +from app.core.db import engine +from app.mqtt.telemetry import build_telemetry_snapshot + +router = APIRouter(prefix="/telemetry", tags=["telemetry"]) +STREAM_INTERVAL_SECONDS = 1.0 + + +@router.get("/latest") +def read_telemetry_latest( + session: SessionDep, + _current_user: CurrentUser, + device_id: str | None = None, + limit: int = Query(default=50, ge=1, le=200), +) -> dict[str, Any]: + """ + Return the latest parsed GNSS telemetry snapshot. + """ + return build_telemetry_snapshot(session=session, device_id=device_id, limit=limit) + + +@router.websocket("/ws") +async def telemetry_websocket( + websocket: WebSocket, + token: str | None = Query(default=None), + device_id: str | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), +) -> None: + if not token: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return + + with Session(engine) as session: + try: + resolve_user_from_token(session=session, token=token) + except HTTPException: + await websocket.close(code=status.WS_1008_POLICY_VIOLATION) + return + + await websocket.accept() + + try: + while True: + with Session(engine) as session: + payload = build_telemetry_snapshot( + session=session, + device_id=device_id, + limit=limit, + ) + await websocket.send_json(jsonable_encoder(payload)) + await asyncio.sleep(STREAM_INTERVAL_SECONDS) + except (WebSocketDisconnect, RuntimeError): + return diff --git a/backend/app/mqtt/parsers/nmea.py b/backend/app/mqtt/parsers/nmea.py index 218f70d..9e16f3b 100644 --- a/backend/app/mqtt/parsers/nmea.py +++ b/backend/app/mqtt/parsers/nmea.py @@ -3,6 +3,16 @@ from __future__ import annotations from typing import Any SUPPORTED_NMEA_TYPES = {"GGA", "RMC", "GSV", "GSA", "GST"} +TALKER_TO_CONSTELLATION = { + "GP": "GPS", + "GL": "GLONASS", + "GA": "Galileo", + "GB": "BeiDou", + "BD": "BeiDou", + "GQ": "QZSS", + "GI": "NavIC", + "GN": "Mixed", +} def _to_float(value: str) -> float | None: @@ -40,19 +50,46 @@ def _parse_lat_lon(value: str, hemisphere: str) -> float | None: return decimal -def _extract_sentence_type(sentence: str) -> str | None: +def _extract_sentence_meta(sentence: str) -> tuple[str, str] | None: if not sentence.startswith("$"): return None if len(sentence) < 6: return None + talker = sentence[1:3].upper() nmea_type = sentence[3:6].upper() if nmea_type not in SUPPORTED_NMEA_TYPES: return None - return nmea_type + return talker, nmea_type -def _parse_nmea_fields(nmea_type: str, fields: list[str]) -> dict[str, Any]: - payload: dict[str, Any] = {"sentence_type": nmea_type, "raw_fields": fields} +def _parse_satellite_blocks(fields: list[str]) -> list[dict[str, Any]]: + satellites: list[dict[str, Any]] = [] + for i in range(4, len(fields), 4): + if i + 3 >= len(fields): + continue + satellite_id = fields[i] + if not satellite_id: + continue + satellites.append( + { + "satellite_id": satellite_id, + "elevation_deg": _to_int(fields[i + 1]), + "azimuth_deg": _to_int(fields[i + 2]), + "snr_dbhz": _to_int(fields[i + 3]), + } + ) + return satellites + + +def _parse_nmea_fields( + *, talker: str, nmea_type: str, fields: list[str] +) -> dict[str, Any]: + payload: dict[str, Any] = { + "sentence_type": nmea_type, + "talker": talker, + "constellation": TALKER_TO_CONSTELLATION.get(talker, "Unknown"), + "raw_fields": fields, + } if nmea_type == "GGA": payload.update( @@ -91,23 +128,27 @@ def _parse_nmea_fields(nmea_type: str, fields: list[str]) -> dict[str, Any]: } ) elif nmea_type == "GSV": + satellites = _parse_satellite_blocks(fields) payload.update( { "total_messages": _to_int(fields[1] if len(fields) > 1 else ""), "message_index": _to_int(fields[2] if len(fields) > 2 else ""), "satellites_in_view": _to_int(fields[3] if len(fields) > 3 else ""), + "satellites": satellites, "snr_values": [ - _to_int(fields[i]) - for i in range(7, len(fields), 4) - if i < len(fields) + satellite["snr_dbhz"] + for satellite in satellites + if satellite["snr_dbhz"] is not None ], } ) elif nmea_type == "GSA": + used_satellite_ids = [sat_id for sat_id in fields[3:15] if sat_id] payload.update( { "mode": fields[1] if len(fields) > 1 else None, "fix_type": _to_int(fields[2] if len(fields) > 2 else ""), + "used_satellite_ids": used_satellite_ids, "pdop": _to_float(fields[15] if len(fields) > 15 else ""), "hdop": _to_float(fields[16] if len(fields) > 16 else ""), "vdop": _to_float(fields[17] if len(fields) > 17 else ""), @@ -134,13 +175,16 @@ def parse_nmea_sentences(payload: bytes) -> list[dict[str, Any]]: continue sentence = raw_line.decode("ascii", errors="ignore").strip() - nmea_type = _extract_sentence_type(sentence) - if nmea_type is None: + sentence_meta = _extract_sentence_meta(sentence) + if sentence_meta is None: continue + talker, nmea_type = sentence_meta main_part = sentence.split("*", 1)[0] fields = main_part.split(",") - parsed_payload = _parse_nmea_fields(nmea_type, fields) + parsed_payload = _parse_nmea_fields( + talker=talker, nmea_type=nmea_type, fields=fields + ) events.append( {"event_type": f"nmea_{nmea_type.lower()}", "payload": parsed_payload} ) diff --git a/backend/app/mqtt/repository.py b/backend/app/mqtt/repository.py index 9bd1f31..42a5c16 100644 --- a/backend/app/mqtt/repository.py +++ b/backend/app/mqtt/repository.py @@ -77,6 +77,74 @@ def mark_failed( session.add(row) +def _build_satellite_signal_summary( + *, + gsv_satellites: list[dict[str, Any]], + satellites_in_view: int | None, + used_satellite_ids: set[str], +) -> dict[str, Any]: + deduped: dict[tuple[str, str], dict[str, Any]] = {} + for satellite in gsv_satellites: + constellation = str(satellite.get("constellation", "Unknown")) + satellite_id = str(satellite.get("satellite_id", "")).strip() + if not satellite_id: + continue + key = (constellation, satellite_id) + existing = deduped.get(key) + if existing is None: + deduped[key] = satellite + continue + + incoming_snr = satellite.get("snr_dbhz") + existing_snr = existing.get("snr_dbhz") + if isinstance(incoming_snr, int) and ( + not isinstance(existing_snr, int) or incoming_snr > existing_snr + ): + deduped[key] = satellite + + satellites = list(deduped.values()) + satellites.sort( + key=lambda sat: (str(sat["constellation"]), str(sat["satellite_id"])) + ) + + snr_values: list[int] = [] + cn0_values: list[int] = [] + constellation_summary: dict[str, dict[str, int]] = {} + + for satellite in gsv_satellites: + cn0_value = satellite.get("snr_dbhz") + if isinstance(cn0_value, int): + cn0_values.append(cn0_value) + + for satellite in satellites: + constellation = str(satellite["constellation"]) + sat_id = str(satellite["satellite_id"]) + used_in_navigation = sat_id in used_satellite_ids + satellite["used_in_navigation"] = used_in_navigation + + constellation_item = constellation_summary.setdefault( + constellation, + {"count": 0, "with_signal": 0, "used_in_navigation": 0}, + ) + constellation_item["count"] += 1 + if used_in_navigation: + constellation_item["used_in_navigation"] += 1 + + snr_dbhz = satellite.get("snr_dbhz") + if isinstance(snr_dbhz, int): + snr_values.append(snr_dbhz) + constellation_item["with_signal"] += 1 + + return { + "satellites_in_view": satellites_in_view or len(satellites), + "snr_values": snr_values, + "cn0_values": cn0_values, + "used_satellite_ids": sorted(used_satellite_ids), + "constellations": constellation_summary, + "satellites": satellites, + } + + def update_device_state_from_events( *, session: Session, @@ -96,6 +164,10 @@ def update_device_state_from_events( if state is None: state = TerminalDeviceState(device_id=row.device_id) + gsv_satellites: list[dict[str, Any]] = [] + satellites_in_view: int | None = None + used_satellite_ids: set[str] = set() + for event in events: payload = event.get("payload", {}) event_type = str(event.get("event_type", "unknown")) @@ -114,16 +186,52 @@ def update_device_state_from_events( if payload.get("lon") is not None: state.lon = payload.get("lon") status = payload.get("status") - if status is not None: + if status is not None and state.fix_status is None: state.fix_status = str(status) elif event_type == "nmea_gsv": - state.satellite_signal_summary = { - "snr_values": payload.get("snr_values", []), - "satellites_in_view": payload.get("satellites_in_view"), - } + payload_satellites = payload.get("satellites", []) + if isinstance(payload_satellites, list): + for sat in payload_satellites: + if not isinstance(sat, dict): + continue + gsv_satellites.append( + { + "satellite_id": str(sat.get("satellite_id", "")), + "elevation_deg": sat.get("elevation_deg"), + "azimuth_deg": sat.get("azimuth_deg"), + "snr_dbhz": sat.get("snr_dbhz"), + "constellation": payload.get("constellation", "Unknown"), + } + ) + satellites_count = payload.get("satellites_in_view") + if isinstance(satellites_count, int): + satellites_in_view = max(satellites_in_view or 0, satellites_count) + elif event_type == "nmea_gsa": + for satellite_id in payload.get("used_satellite_ids", []): + if satellite_id is None: + continue + used_satellite_ids.add(str(satellite_id)) elif event_type == "rtcm_summary": state.rtcm_type_summary = payload.get("rtcm_types", {}) + if gsv_satellites: + state.satellite_signal_summary = _build_satellite_signal_summary( + gsv_satellites=gsv_satellites, + satellites_in_view=satellites_in_view, + used_satellite_ids=used_satellite_ids, + ) + elif used_satellite_ids and state.satellite_signal_summary: + current_summary = dict(state.satellite_signal_summary) + current_summary["used_satellite_ids"] = sorted(used_satellite_ids) + satellites = current_summary.get("satellites", []) + if isinstance(satellites, list): + for satellite in satellites: + if not isinstance(satellite, dict): + continue + sat_id = str(satellite.get("satellite_id", "")) + satellite["used_in_navigation"] = sat_id in used_satellite_ids + state.satellite_signal_summary = current_summary + state.last_event_time = row.received_at state.updated_at = get_datetime_utc() session.add(state) diff --git a/backend/app/mqtt/telemetry.py b/backend/app/mqtt/telemetry.py new file mode 100644 index 0000000..c4b5543 --- /dev/null +++ b/backend/app/mqtt/telemetry.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from typing import Any + +from sqlmodel import Session, col, select + +from app.models import TerminalDeviceState, get_datetime_utc + + +def _to_int(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + stripped = value.strip() + if stripped.isdigit(): + return int(stripped) + return None + + +def _normalize_satellite_summary(summary: Any) -> dict[str, Any]: + if not isinstance(summary, dict): + return { + "satellites_in_view": None, + "snr_values": [], + "cn0_values": [], + "constellations": {}, + "used_satellite_ids": [], + "satellites": [], + } + + raw_satellites = summary.get("satellites", []) + satellites: list[dict[str, Any]] = [] + if isinstance(raw_satellites, list): + for satellite in raw_satellites: + if not isinstance(satellite, dict): + continue + satellite_id = str(satellite.get("satellite_id", "")).strip() + if not satellite_id: + continue + satellites.append( + { + "satellite_id": satellite_id, + "constellation": str(satellite.get("constellation", "Unknown")), + "azimuth_deg": _to_int(satellite.get("azimuth_deg")), + "elevation_deg": _to_int(satellite.get("elevation_deg")), + "snr_dbhz": _to_int(satellite.get("snr_dbhz")), + "used_in_navigation": bool( + satellite.get("used_in_navigation", False) + ), + } + ) + + snr_values: list[int] = [] + raw_snr_values = summary.get("snr_values", []) + if isinstance(raw_snr_values, list): + for value in raw_snr_values: + converted = _to_int(value) + if converted is not None: + snr_values.append(converted) + + cn0_values: list[int] = [] + raw_cn0_values = summary.get("cn0_values", []) + if isinstance(raw_cn0_values, list): + for value in raw_cn0_values: + converted = _to_int(value) + if converted is not None: + cn0_values.append(converted) + if not cn0_values: + cn0_values = list(snr_values) + + satellites_in_view = _to_int(summary.get("satellites_in_view")) + if satellites_in_view is None: + satellites_in_view = len(satellites) + + constellations = summary.get("constellations", {}) + if not isinstance(constellations, dict): + constellations = {} + + used_satellite_ids = summary.get("used_satellite_ids", []) + if not isinstance(used_satellite_ids, list): + used_satellite_ids = [] + + return { + "satellites_in_view": satellites_in_view, + "snr_values": snr_values, + "cn0_values": cn0_values, + "constellations": constellations, + "used_satellite_ids": used_satellite_ids, + "satellites": satellites, + } + + +def _build_signal_health(snr_values: list[int]) -> dict[str, Any]: + strong_signal_count = sum(1 for value in snr_values if value >= 40) + average_snr = (sum(snr_values) / len(snr_values)) if snr_values else None + best_snr = max(snr_values) if snr_values else None + + if strong_signal_count >= 20: + level = "good" + message = "At least 20 satellites have CN0 >= 40" + elif strong_signal_count >= 10: + level = "fair" + message = "At least 10 satellites have CN0 >= 40" + else: + level = "poor" + message = "Less than 10 satellites have CN0 >= 40" + + score = min(strong_signal_count * 5, 100) + + return { + "level": level, + "score": score, + "strong_signal_count": strong_signal_count, + "average_snr_dbhz": round(average_snr, 1) if average_snr is not None else None, + "best_snr_dbhz": best_snr, + "message": message, + } + + +def _serialize_device_state(state: TerminalDeviceState) -> dict[str, Any]: + signal_summary = _normalize_satellite_summary(state.satellite_signal_summary) + signal_health = _build_signal_health(signal_summary["snr_values"]) + + return { + "device_id": state.device_id, + "last_event_time": state.last_event_time, + "updated_at": state.updated_at, + "fix_status": state.fix_status, + "lat": state.lat, + "lon": state.lon, + "alt_m": state.alt_m, + "satellites_used": state.satellites_used, + "satellite_signal_summary": signal_summary, + "signal_health": signal_health, + } + + +def build_telemetry_snapshot( + *, + session: Session, + device_id: str | None = None, + limit: int = 50, +) -> dict[str, Any]: + statement = select(TerminalDeviceState).order_by( + col(TerminalDeviceState.updated_at).desc() + ) + if device_id: + statement = statement.where(TerminalDeviceState.device_id == device_id) + statement = statement.limit(limit) + + states = session.exec(statement).all() + devices = [_serialize_device_state(state) for state in states] + + return {"generated_at": get_datetime_utc(), "devices": devices} diff --git a/backend/tests/api/routes/test_telemetry.py b/backend/tests/api/routes/test_telemetry.py new file mode 100644 index 0000000..4d0f2d9 --- /dev/null +++ b/backend/tests/api/routes/test_telemetry.py @@ -0,0 +1,127 @@ +from datetime import datetime, timezone + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.models import TerminalDeviceState + + +def _seed_device_state( + db: Session, device_id: str, snr_values: list[int] | None = None +) -> None: + state = db.get(TerminalDeviceState, device_id) + if state is None: + state = TerminalDeviceState(device_id=device_id) + + signal_values = snr_values if snr_values is not None else [28, 31, 36, 41, 44] + + state.fix_status = "5" + state.lat = 31.359591 + state.lon = 120.714489 + state.alt_m = 52.1 + state.satellites_used = 17 + state.satellite_signal_summary = { + "satellites_in_view": 22, + "snr_values": signal_values, + "constellations": { + "GPS": {"count": 8, "used_in_navigation": 6}, + "BeiDou": {"count": 10, "used_in_navigation": 8}, + "Galileo": {"count": 4, "used_in_navigation": 3}, + }, + "satellites": [ + { + "satellite_id": "G05", + "constellation": "GPS", + "azimuth_deg": 121, + "elevation_deg": 29, + "snr_dbhz": 37, + "used_in_navigation": True, + } + ], + } + state.last_event_time = datetime.now(timezone.utc) + db.add(state) + db.commit() + + +def test_read_telemetry_latest( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + device_id = "device-api-001" + _seed_device_state(db, device_id) + + response = client.get( + f"{settings.API_V1_STR}/telemetry/latest", + headers=superuser_token_headers, + ) + + assert response.status_code == 200 + payload = response.json() + assert "generated_at" in payload + assert len(payload["devices"]) >= 1 + device = next(item for item in payload["devices"] if item["device_id"] == device_id) + assert device["fix_status"] == "5" + assert device["signal_health"]["level"] == "poor" + + +def test_signal_health_levels_based_on_count_of_40_plus_signals( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + _seed_device_state(db, "device-health-good", [40] * 20 + [15] * 3) + _seed_device_state(db, "device-health-fair", [40] * 10 + [20] * 3) + _seed_device_state(db, "device-health-poor", [40] * 9 + [39]) + + response = client.get( + f"{settings.API_V1_STR}/telemetry/latest", + headers=superuser_token_headers, + params={"limit": 200}, + ) + assert response.status_code == 200 + devices = {item["device_id"]: item for item in response.json()["devices"]} + + assert devices["device-health-good"]["signal_health"]["level"] == "good" + assert devices["device-health-fair"]["signal_health"]["level"] == "fair" + assert devices["device-health-poor"]["signal_health"]["level"] == "poor" + + +def test_signal_health_uses_satellite_level_values_for_ucenter_view( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + state = db.get(TerminalDeviceState, "device-health-ucenter") + if state is None: + state = TerminalDeviceState(device_id="device-health-ucenter") + state.fix_status = "5" + state.satellite_signal_summary = { + # u-center signal bars are per-satellite bars, not per-frequency points + "snr_values": [41] * 9 + [30] * 4, + # keep raw CN0 points for diagnostics, but health should follow bar view + "cn0_values": [41] * 20 + [25] * 5, + } + db.add(state) + db.commit() + + response = client.get( + f"{settings.API_V1_STR}/telemetry/latest", + headers=superuser_token_headers, + params={"device_id": "device-health-ucenter"}, + ) + assert response.status_code == 200 + device = response.json()["devices"][0] + assert device["signal_health"]["strong_signal_count"] == 9 + assert device["signal_health"]["level"] == "poor" + + +def test_telemetry_websocket_stream( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + _seed_device_state(db, "device-api-002") + token = superuser_token_headers["Authorization"].split(" ", 1)[1] + + with client.websocket_connect( + f"{settings.API_V1_STR}/telemetry/ws?token={token}" + ) as websocket: + payload = websocket.receive_json() + + assert "generated_at" in payload + assert len(payload["devices"]) >= 1 diff --git a/backend/tests/mqtt/nmea.txt b/backend/tests/mqtt/nmea.txt new file mode 100644 index 0000000..0d19fc8 --- /dev/null +++ b/backend/tests/mqtt/nmea.txt @@ -0,0 +1,448 @@ +$GNRMC,015903.000,A,3121.575445,N,12042.869341,E,0.015,51.12,080126,,E,A,V*48 +$GNGGA,015903.000,3121.575445,N,12042.869341,E,1,17,1.09,51.800,M,0.000,M,,*79 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55 +$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A +$GPGSV,3,3,9,32,34,146,20,1*6A +$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77 +$GBGSV,8,5,29,33,58,251,00,34,12,157,00,35,11,059,40,38,17,196,00,1*71 +$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,251,00,4*7B +$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,36,75,13,044,43,1*47 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015903.000,0.86,8.38,1.39,81.42,32.311,7.315,13.683*43 +$GNSVD,0,0,0,118,22,22*40 +$GNRMC,015904.000,A,3121.575704,N,12042.869303,E,0.050,334.82,080126,,E,A,V*77 +$GNGGA,015904.000,3121.575704,N,12042.869303,E,1,17,1.09,51.838,M,0.000,M,,*75 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55 +$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A +$GPGSV,3,3,9,32,34,146,19,1*60 +$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,30,8*5A +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,41,1*76 +$GBGSV,8,5,29,33,58,251,00,34,12,157,00,35,11,059,40,38,17,196,00,1*71 +$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,251,00,4*7B +$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44 +$GLGSV,2,2,8,76,53,352,42,77,39,277,00,85,09,055,41,86,08,112,00,1*48 +$GNGST,015904.000,0.93,8.38,1.38,81.41,32.302,7.294,13.653*45 +$GNSVD,-1,2,0,350,68,71*68 +$GNRMC,015905.000,A,3121.575859,N,12042.869263,E,0.039,345.46,080126,,E,A,V*77 +$GNGGA,015905.000,3121.575859,N,12042.869263,E,1,17,1.09,51.796,M,0.000,M,,*7F +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55 +$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,44,31,59,357,39,1*5A +$GPGSV,3,3,9,32,34,146,19,1*60 +$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70 +$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B +$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,37,19,13,135,00,7*4B +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015905.000,0.68,8.41,1.37,81.41,32.384,7.264,13.612*45 +$GNSVD,0,1,0,409,76,76*44 +$GNRMC,015906.000,A,3121.576069,N,12042.869216,E,0.042,336.34,080126,,E,A,V*73 +$GNGGA,015906.000,3121.576069,N,12042.869216,E,1,17,1.09,51.776,M,0.000,M,,*78 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,310,29,1*55 +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,357,39,1*5A +$GPGSV,3,3,9,32,34,146,20,1*6A +$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,310,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,357,00,8*5F +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,41,1*76 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70 +$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B +$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015906.000,0.55,8.42,1.36,81.36,32.417,7.258,13.592*43 +$GNSVD,0,2,0,407,84,79*4B +$GNRMC,015907.000,A,3121.576167,N,12042.869218,E,0.058,344.47,080126,,E,A,V*79 +$GNGGA,015907.000,3121.576167,N,12042.869218,E,1,17,1.09,51.827,M,0.000,M,,*73 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54 +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,39,1*55 +$GPGSV,3,3,9,32,34,146,20,1*6A +$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70 +$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B +$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015907.000,0.74,8.46,1.36,81.39,32.556,7.267,13.652*4D +$GNSVD,0,2,1,351,70,80*43 +$GNRMC,015908.000,A,3121.576274,N,12042.869205,E,0.037,270.24,080126,,E,A,V*71 +$GNGGA,015908.000,3121.576274,N,12042.869205,E,1,17,1.09,51.877,M,0.000,M,,*74 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54 +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54 +$GPGSV,3,3,9,32,34,146,20,1*6A +$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70 +$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,058,34,33,58,250,00,4*7A +$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43 +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015908.000,0.73,8.42,1.35,81.41,32.460,7.222,13.595*40 +$GNSVD,-1,0,8,345,70,74*6A +$GNRMC,015909.000,A,3121.576447,N,12042.869168,E,0.038,223.29,080126,,E,A,V*7A +$GNGGA,015909.000,3121.576447,N,12042.869168,E,1,17,1.09,51.981,M,0.000,M,,*73 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54 +$GPGSV,3,3,9,32,34,146,20,1*6A +$GPGSV,3,1,9,03,24,284,00,04,16,319,36,16,47,239,00,26,74,311,31,8*5A +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,39,25,37,278,00,26,22,058,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70 +$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,058,34,33,58,250,00,4*7B +$GBGSV,4,2,13,34,12,157,00,35,11,059,36,38,17,196,00,39,62,002,37,4*74 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015909.000,0.79,8.42,1.35,81.34,32.445,7.223,13.571*45 +$GNSVD,-1,-1,10,379,80,76*7D +$GNRMC,015910.000,A,3121.576443,N,12042.869180,E,0.053,156.87,080126,,E,A,V*78 +$GNGGA,015910.000,3121.576443,N,12042.869180,E,1,17,1.09,52.067,M,0.000,M,,*7B +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,39,1*54 +$GPGSV,3,3,9,32,34,146,19,1*60 +$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70 +$GBGSV,8,6,29,39,62,002,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7F +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A +$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,002,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,44,1*43 +$GLGSV,2,2,8,76,53,352,42,77,39,277,00,85,09,055,41,86,08,112,00,1*48 +$GNGST,015910.000,0.96,8.50,1.36,81.36,32.672,7.253,13.641*4F +$GNSVD,1,-2,8,400,77,78*65 +$GNRMC,015911.000,A,3121.576500,N,12042.869206,E,0.064,148.17,080126,,E,A,V*70 +$GNGGA,015911.000,3121.576500,N,12042.869206,E,1,17,1.09,52.146,M,0.000,M,,*73 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B +$GPGSV,3,3,9,32,34,146,19,1*60 +$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,059,40,38,17,196,00,1*70 +$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A +$GBGSV,4,2,13,34,12,157,00,35,11,059,37,38,17,196,00,39,62,003,37,4*74 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015911.000,0.75,8.51,1.34,81.40,32.724,7.216,13.607*40 +$GNSVD,1,-2,6,394,71,77*68 +$GNRMC,015912.000,A,3121.576672,N,12042.869211,E,0.050,221.67,080126,,E,A,V*7F +$GNGGA,015912.000,3121.576672,N,12042.869211,E,1,17,1.09,52.182,M,0.000,M,,*78 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,44,31,59,358,40,1*5A +$GPGSV,3,3,9,32,34,146,20,1*6A +$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71 +$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,059,34,33,58,250,00,4*7B +$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015912.000,0.81,8.56,1.33,81.41,32.863,7.193,13.598*4E +$GNSVD,-1,-1,2,463,81,80*4A +$GNRMC,015913.000,A,3121.576893,N,12042.869268,E,0.038,116.71,080126,,E,A,V*7F +$GNGGA,015913.000,3121.576893,N,12042.869268,E,1,17,1.09,52.385,M,0.000,M,,*73 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,39,1*55 +$GPGSV,3,3,9,32,34,146,21,1*6B +$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71 +$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A +$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,40,19,13,135,00,1*4D +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,34,75,13,044,43,1*45 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015913.000,0.85,8.57,1.32,81.45,32.915,7.164,13.564*44 +$GNSVD,1,0,3,396,70,75*43 +$GNRMC,015914.000,A,3121.576888,N,12042.869261,E,0.042,124.95,080126,,E,A,V*7D +$GNGGA,015914.000,3121.576888,N,12042.869261,E,1,17,1.09,52.400,M,0.000,M,,*7D +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,29,1*55 +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B +$GPGSV,3,3,9,32,34,146,22,1*68 +$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71 +$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A +$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43 +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015914.000,1.04,8.64,1.32,81.48,33.129,7.180,13.627*4E +$GNSVD,1,-1,2,115,20,22*60 +$GNRMC,015915.000,A,3121.576930,N,12042.869269,E,0.082,170.97,080126,,E,A,V*79 +$GNGGA,015915.000,3121.576930,N,12042.869269,E,1,17,1.09,52.478,M,0.000,M,,*79 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,39,16,47,239,00,26,74,311,30,1*5D +$GPGSV,3,2,9,27,11,189,00,28,50,056,43,29,23,053,45,31,59,358,40,1*5B +$GPGSV,3,3,9,32,34,146,23,1*69 +$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71 +$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E +$GBGSV,8,7,29,44,23,105,00,50,09,164,00,59,48,145,00,60,33,239,00,1*76 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,38,25,37,278,00,26,22,059,34,33,58,250,00,4*7B +$GBGSV,4,2,13,34,12,157,00,35,11,058,37,38,17,196,00,39,62,003,37,4*75 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,105,00,4*7A +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43 +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,275,00,67,21,329,35,75,13,044,43,1*44 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015915.000,1.04,8.66,1.32,81.53,33.219,7.158,13.611*47 +$GNSVD,0,-4,0,372,67,69*69 +$GNRMC,015916.000,A,3121.576981,N,12042.869285,E,0.093,164.37,080126,,E,A,V*7D +$GNGGA,015916.000,3121.576981,N,12042.869285,E,1,17,1.09,52.469,M,0.000,M,,*72 +$GPGSA,A,3,04,26,28,29,31,32,,,,,,,1.54,1.09,1.08,1*1C +$GBGSA,A,3,06,24,26,35,39,41,,,,,,,1.54,1.09,1.08,4*04 +$GAGSA,A,3,09,,,,,,,,,,,,1.54,1.09,1.08,3*04 +$GLGSA,A,3,67,75,76,85,,,,,,,,,1.54,1.09,1.08,2*0E +$GPGSV,3,1,9,03,24,284,00,04,16,319,38,16,47,239,00,26,74,311,29,1*54 +$GPGSV,3,2,9,27,11,189,00,28,50,056,42,29,23,053,45,31,59,358,39,1*54 +$GPGSV,3,3,9,32,34,146,23,1*69 +$GPGSV,3,1,9,03,24,284,00,04,16,319,37,16,47,239,00,26,74,311,31,8*5B +$GPGSV,3,2,9,27,11,189,00,28,50,056,00,29,23,053,00,31,59,358,00,8*50 +$GPGSV,3,3,9,32,34,146,00,8*61 +$GBGSV,8,1,29,01,44,140,00,02,38,237,00,03,52,199,00,04,32,124,00,1*78 +$GBGSV,8,2,29,05,16,257,00,06,59,332,33,07,31,184,00,08,25,209,00,1*71 +$GBGSV,8,3,29,09,50,309,00,10,20,194,00,12,17,131,00,13,30,222,00,1*7A +$GBGSV,8,4,29,14,50,202,00,24,59,014,38,25,37,278,00,26,22,059,40,1*77 +$GBGSV,8,5,29,33,58,250,00,34,12,157,00,35,11,058,40,38,17,196,00,1*71 +$GBGSV,8,6,29,39,62,003,35,40,29,169,00,41,28,312,37,42,29,177,00,1*7E +$GBGSV,8,7,29,44,23,104,00,50,09,164,00,59,48,145,00,60,33,239,00,1*77 +$GBGSV,8,8,29,61,51,199,00,1*4F +$GBGSV,4,1,13,24,59,014,39,25,37,278,00,26,22,059,34,33,58,250,00,4*7A +$GBGSV,4,2,13,34,12,157,00,35,11,058,36,38,17,196,00,39,62,003,37,4*74 +$GBGSV,4,3,13,40,29,169,00,41,28,312,29,42,29,177,00,44,23,104,00,4*7B +$GBGSV,4,4,13,50,09,164,00,4*4E +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,38,19,13,135,00,7*44 +$GAGSV,2,2,5,31,78,255,00,7*79 +$GAGSV,2,1,5,04,69,130,00,06,81,329,00,09,54,320,39,19,13,135,00,1*43 +$GAGSV,2,2,5,31,78,255,00,1*7F +$GLGSV,2,1,8,65,39,176,00,66,72,274,00,67,21,329,35,75,13,044,43,1*45 +$GLGSV,2,2,8,76,53,352,43,77,39,277,00,85,09,055,41,86,08,112,00,1*49 +$GNGST,015916.000,1.05,8.75,1.31,81.60,33.492,7.151,13.627*4D +$GNSVD,1,-4,-1,478,76,80*4E diff --git a/backend/tests/mqtt/test_mixed_parser.py b/backend/tests/mqtt/test_mixed_parser.py index 34567cf..0ee0cb1 100644 --- a/backend/tests/mqtt/test_mixed_parser.py +++ b/backend/tests/mqtt/test_mixed_parser.py @@ -25,3 +25,24 @@ def test_parse_mixed_payload_returns_rtcm_summary() -> None: event_types = {event["event_type"] for event in events} assert "rtcm_summary" in event_types + + +def test_parse_nmea_sentences_extracts_satellite_details_and_used_ids() -> None: + payload = ( + b"$GPGSA,A,3,02,05,12,,,,,,,,,,1.50,0.90,1.20*00\r\n" + b"$GPGSV,1,1,04,02,17,213,40,05,29,121,37,12,66,314,44,25,14,048,39*71\r\n" + ) + + events = parse_nmea_sentences(payload) + gsa_event = next(event for event in events if event["event_type"] == "nmea_gsa") + gsv_event = next(event for event in events if event["event_type"] == "nmea_gsv") + + assert gsa_event["payload"]["used_satellite_ids"] == ["02", "05", "12"] + assert gsv_event["payload"]["constellation"] == "GPS" + assert len(gsv_event["payload"]["satellites"]) == 4 + assert gsv_event["payload"]["satellites"][0] == { + "satellite_id": "02", + "elevation_deg": 17, + "azimuth_deg": 213, + "snr_dbhz": 40, + } diff --git a/backend/tests/mqtt/test_parser_worker.py b/backend/tests/mqtt/test_parser_worker.py index 9852ed0..263f1c0 100644 --- a/backend/tests/mqtt/test_parser_worker.py +++ b/backend/tests/mqtt/test_parser_worker.py @@ -85,3 +85,65 @@ def test_worker_marks_failed_without_retry_schedule(monkeypatch) -> None: def test_schema_excludes_terminal_observation_event_table() -> None: assert "terminal_observation_event" not in SQLModel.metadata.tables + + +def test_ingest_and_parse_real_nmea_sample() -> None: + from pathlib import Path + + sample_path = Path(__file__).parent / "nmea.txt" + payload = sample_path.read_bytes() + + with _session() as session: + raw = insert_raw_message( + session=session, + device_id="device-real-sample", + topic="terminal/device-real-sample/raw", + payload=payload, + qos=1, + retain=False, + ) + + processed = process_pending_batch(session=session, batch_size=10) + assert processed == 1 + + refreshed = session.get(TerminalMessageRaw, raw.id) + assert refreshed is not None + assert refreshed.parsed_at is not None + assert refreshed.parse_error is None + + state = session.get(TerminalDeviceState, "device-real-sample") + assert state is not None + assert state.lat is not None + assert state.lon is not None + + +def test_worker_builds_satellite_distribution_summary() -> None: + payload = ( + b"$GNGGA,123519,3723.2475,N,12158.3416,W,4,12,0.8,10.0,M,-25.0,M,,*65\r\n" + b"$GPGSA,A,3,02,05,12,,,,,,,,,,1.50,0.90,1.20*00\r\n" + b"$GPGSV,1,1,04,02,17,213,40,05,29,121,37,12,66,314,44,25,14,048,39*71\r\n" + ) + + with _session() as session: + insert_raw_message( + session=session, + device_id="device-gsv", + topic="terminal/device-gsv/raw", + payload=payload, + qos=1, + retain=False, + ) + + processed = process_pending_batch(session=session, batch_size=10) + + assert processed == 1 + state = session.get(TerminalDeviceState, "device-gsv") + assert state is not None + assert state.fix_status == "4" + summary = state.satellite_signal_summary + assert summary is not None + assert summary["satellites_in_view"] == 4 + assert summary["snr_values"] == [40, 37, 44, 39] + assert summary["used_satellite_ids"] == ["02", "05", "12"] + assert summary["constellations"]["GPS"]["count"] == 4 + assert summary["constellations"]["GPS"]["used_in_navigation"] == 3 diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 53139ea..6b09ee9 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, TelemetryReadTelemetryLatestData, TelemetryReadTelemetryLatestResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; export class LocationsService { /** @@ -235,6 +235,31 @@ export class PrivateService { } } +export class TelemetryService { + /** + * Read Telemetry Latest + * Return the latest parsed GNSS telemetry snapshot. + * @param data The data for the request. + * @param data.deviceId + * @param data.limit + * @returns unknown Successful Response + * @throws ApiError + */ + public static readTelemetryLatest(data: TelemetryReadTelemetryLatestData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/telemetry/latest', + query: { + device_id: data.deviceId, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class UsersService { /** * Read Users diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index cb94f7d..856cc86 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -177,6 +177,15 @@ export type PrivateCreateUserData = { export type PrivateCreateUserResponse = (UserPublic); +export type TelemetryReadTelemetryLatestData = { + deviceId?: (string | null); + limit?: number; +}; + +export type TelemetryReadTelemetryLatestResponse = ({ + [key: string]: unknown; +}); + export type UsersReadUsersData = { limit?: number; skip?: number; diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index deace7d..6014804 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Home, MapPin, Users } from "lucide-react" +import { Home, MapPin, Satellite, Users } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" @@ -15,6 +15,7 @@ import { User } from "./User" const baseItems: Item[] = [ { icon: Home, title: "Dashboard", path: "/" }, { icon: MapPin, title: "Locations", path: "/locations" }, + { icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" }, ] export function AppSidebar() { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 54d2a18..048ba49 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations' +import { Route as LayoutGnssMonitorRouteImport } from './routes/_layout/gnss-monitor' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' const SignupRoute = SignupRouteImport.update({ @@ -58,6 +59,11 @@ const LayoutLocationsRoute = LayoutLocationsRouteImport.update({ path: '/locations', getParentRoute: () => LayoutRoute, } as any) +const LayoutGnssMonitorRoute = LayoutGnssMonitorRouteImport.update({ + id: '/gnss-monitor', + path: '/gnss-monitor', + getParentRoute: () => LayoutRoute, +} as any) const LayoutAdminRoute = LayoutAdminRouteImport.update({ id: '/admin', path: '/admin', @@ -71,6 +77,7 @@ export interface FileRoutesByFullPath { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/gnss-monitor': typeof LayoutGnssMonitorRoute '/locations': typeof LayoutLocationsRoute '/settings': typeof LayoutSettingsRoute } @@ -80,6 +87,7 @@ export interface FileRoutesByTo { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/gnss-monitor': typeof LayoutGnssMonitorRoute '/locations': typeof LayoutLocationsRoute '/settings': typeof LayoutSettingsRoute '/': typeof LayoutIndexRoute @@ -92,6 +100,7 @@ export interface FileRoutesById { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/_layout/admin': typeof LayoutAdminRoute + '/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute '/_layout/locations': typeof LayoutLocationsRoute '/_layout/settings': typeof LayoutSettingsRoute '/_layout/': typeof LayoutIndexRoute @@ -105,6 +114,7 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/gnss-monitor' | '/locations' | '/settings' fileRoutesByTo: FileRoutesByTo @@ -114,6 +124,7 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/gnss-monitor' | '/locations' | '/settings' | '/' @@ -125,6 +136,7 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/_layout/admin' + | '/_layout/gnss-monitor' | '/_layout/locations' | '/_layout/settings' | '/_layout/' @@ -196,6 +208,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutLocationsRouteImport parentRoute: typeof LayoutRoute } + '/_layout/gnss-monitor': { + id: '/_layout/gnss-monitor' + path: '/gnss-monitor' + fullPath: '/gnss-monitor' + preLoaderRoute: typeof LayoutGnssMonitorRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/admin': { id: '/_layout/admin' path: '/admin' @@ -208,6 +227,7 @@ declare module '@tanstack/react-router' { interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute + LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute LayoutLocationsRoute: typeof LayoutLocationsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutIndexRoute: typeof LayoutIndexRoute @@ -215,6 +235,7 @@ interface LayoutRouteChildren { const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, + LayoutGnssMonitorRoute: LayoutGnssMonitorRoute, LayoutLocationsRoute: LayoutLocationsRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutIndexRoute: LayoutIndexRoute, diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 1697305..3871ed5 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,4 +1,9 @@ -import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" +import { + createFileRoute, + Outlet, + redirect, + useRouterState, +} from "@tanstack/react-router" import { Footer } from "@/components/Common/Footer" import AppSidebar from "@/components/Sidebar/AppSidebar" @@ -21,6 +26,11 @@ export const Route = createFileRoute("/_layout")({ }) function Layout() { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }) + const isGnssMonitorPage = pathname === "/gnss-monitor" + return ( @@ -29,7 +39,9 @@ function Layout() {
-
+
diff --git a/frontend/src/routes/_layout/gnss-monitor.tsx b/frontend/src/routes/_layout/gnss-monitor.tsx new file mode 100644 index 0000000..9587722 --- /dev/null +++ b/frontend/src/routes/_layout/gnss-monitor.tsx @@ -0,0 +1,826 @@ +import { createFileRoute } from "@tanstack/react-router" +import { Compass, Gauge, MapPin, Radio, Satellite, Signal } from "lucide-react" +import { useEffect, useMemo, useState } from "react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +type SignalHealthLevel = "good" | "fair" | "poor" | "unknown" + +type SignalHealth = { + level: SignalHealthLevel + score: number + strong_signal_count?: number + average_snr_dbhz: number | null + best_snr_dbhz: number | null + message: string +} + +type SatellitePoint = { + satellite_id: string + constellation: string + azimuth_deg: number | null + elevation_deg: number | null + snr_dbhz: number | null + used_in_navigation: boolean +} + +type SatelliteSummary = { + satellites_in_view: number | null + snr_values: number[] + constellations: Record< + string, + { count?: number; with_signal?: number; used_in_navigation?: number } + > + used_satellite_ids: string[] + satellites: SatellitePoint[] +} + +type TelemetryDevice = { + device_id: string + last_event_time: string | null + updated_at: string + fix_status: string | null + lat: number | null + lon: number | null + alt_m: number | null + satellites_used: number | null + satellite_signal_summary: SatelliteSummary + signal_health: SignalHealth +} + +type TelemetrySnapshot = { + generated_at: string + devices: TelemetryDevice[] +} + +const CONSTELLATION_COLORS: Record = { + GPS: "#f59e0b", + SBAS: "#9ca3af", + BeiDou: "#8b5cf6", + Galileo: "#84cc16", + GLONASS: "#06b6d4", + QZSS: "#f97316", + NavIC: "#ec4899", + Mixed: "#64748b", + Unknown: "#94a3b8", +} + +const CONSTELLATION_ORDER = [ + "GPS", + "SBAS", + "Galileo", + "BeiDou", + "GLONASS", + "QZSS", + "NavIC", + "Mixed", + "Unknown", +] as const + +const CONSTELLATION_PREFIX: Record = { + GPS: "G", + SBAS: "S", + Galileo: "E", + BeiDou: "B", + GLONASS: "R", + QZSS: "Q", + NavIC: "I", + Mixed: "M", + Unknown: "U", +} + +const SIGNAL_STATUS_CLASS: Record = { + good: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20", + fair: "text-amber-500 bg-amber-500/10 border-amber-500/20", + poor: "text-red-500 bg-red-500/10 border-red-500/20", + unknown: "text-muted-foreground bg-muted border-border", +} + +const FIX_STATUS_META: Record< + string, + { label: string; className: string; description: string } +> = { + "0": { + label: "No Fix", + className: "text-red-500 bg-red-500/10 border-red-500/20", + description: "No position solution", + }, + "1": { + label: "Single", + className: "text-amber-500 bg-amber-500/10 border-amber-500/20", + description: "Standalone GNSS fix", + }, + "2": { + label: "DGPS", + className: "text-sky-500 bg-sky-500/10 border-sky-500/20", + description: "Differential correction active", + }, + "4": { + label: "RTK Fixed", + className: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20", + description: "Carrier fixed solution", + }, + "5": { + label: "RTK Float", + className: "text-orange-500 bg-orange-500/10 border-orange-500/20", + description: "Carrier float solution", + }, + A: { + label: "Valid", + className: "text-sky-500 bg-sky-500/10 border-sky-500/20", + description: "Valid navigation status", + }, + V: { + label: "Invalid", + className: "text-red-500 bg-red-500/10 border-red-500/20", + description: "Invalid navigation status", + }, +} + +export const Route = createFileRoute("/_layout/gnss-monitor")({ + component: GnssMonitor, + head: () => ({ + meta: [ + { + title: "GNSS Monitor - FastAPI Template", + }, + ], + }), +}) + +function buildTelemetryWsUrl(token: string): string { + const apiBase = ( + import.meta.env.VITE_API_URL || window.location.origin + ).replace(/\/$/, "") + const wsBase = apiBase.startsWith("https://") + ? `wss://${apiBase.slice("https://".length)}` + : apiBase.startsWith("http://") + ? `ws://${apiBase.slice("http://".length)}` + : apiBase + return `${wsBase}/api/v1/telemetry/ws?token=${encodeURIComponent(token)}` +} + +function formatCoord(value: number | null): string { + if (value === null) { + return "-" + } + return value.toFixed(6) +} + +function formatTime(value: string | null): string { + if (!value) { + return "-" + } + return new Date(value).toLocaleTimeString() +} + +function formatAge(value: string | null): string { + if (!value) { + return "-" + } + const diffSeconds = Math.max( + 0, + Math.floor((Date.now() - new Date(value).getTime()) / 1000), + ) + if (diffSeconds < 60) { + return `${diffSeconds}s` + } + const minutes = Math.floor(diffSeconds / 60) + const seconds = diffSeconds % 60 + return `${minutes}m ${seconds}s` +} + +function getFixMeta(fixStatus: string | null) { + if (!fixStatus) { + return { + label: "Unknown", + className: "text-muted-foreground bg-muted border-border", + description: "No fix state received yet", + } + } + return ( + FIX_STATUS_META[fixStatus] ?? { + label: `Fix ${fixStatus}`, + className: "text-muted-foreground bg-muted border-border", + description: "Unrecognized fix code", + } + ) +} + +function satColor(constellation: string): string { + return CONSTELLATION_COLORS[constellation] ?? CONSTELLATION_COLORS.Unknown +} + +function satPrefix(constellation: string): string { + return CONSTELLATION_PREFIX[constellation] ?? CONSTELLATION_PREFIX.Unknown +} + +function satelliteSortValue(satelliteId: string): number { + const digits = satelliteId.replace(/\D+/g, "") + return digits ? Number(digits) : Number.POSITIVE_INFINITY +} + +function satelliteName(satellite: SatellitePoint): string { + const satId = satellite.satellite_id.trim() + if (/^[A-Za-z]/.test(satId)) { + return satId + } + return `${satPrefix(satellite.constellation)}${satId}` +} + +function SignalBars({ satellites }: { satellites: SatellitePoint[] }) { + const withSnr = satellites.filter( + (sat) => sat.snr_dbhz !== null && sat.snr_dbhz > 0, + ) + + if (withSnr.length === 0) { + return ( +
+ No SNR data yet +
+ ) + } + + const grouped = CONSTELLATION_ORDER.map((name) => ({ + name, + satellites: withSnr + .filter((sat) => sat.constellation === name) + .sort( + (a, b) => + satelliteSortValue(a.satellite_id) - + satelliteSortValue(b.satellite_id), + ), + })).filter((group) => group.satellites.length > 0) + + const unknownGroup = withSnr + .filter( + (sat) => + !CONSTELLATION_ORDER.includes( + sat.constellation as (typeof CONSTELLATION_ORDER)[number], + ), + ) + .sort( + (a, b) => + satelliteSortValue(a.satellite_id) - satelliteSortValue(b.satellite_id), + ) + if (unknownGroup.length > 0) { + grouped.push({ name: "Unknown", satellites: unknownGroup }) + } + + const bars = grouped.flatMap((group, groupIndex) => + group.satellites.map((satellite, index) => ({ + groupName: group.name, + groupStart: groupIndex > 0 && index === 0, + satellite, + })), + ) + + const columnStyle = { + gridTemplateColumns: `repeat(${bars.length}, minmax(0, 1fr))`, + } as const + + return ( +
+
+ {grouped.map((group) => ( +
+ + + {group.name} ({satPrefix(group.name)}) + +
+ ))} +
+ + Not used in navigation +
+
+ +
+
+ {[50, 40, 30, 20, 10, 0].map((tick) => ( + + {tick} + + ))} +
+ +
+
+ {[0, 10, 20, 30, 40, 50].map((tick) => ( +
+ ))} +
+ +
+ {bars.map((entry) => { + const snr = entry.satellite.snr_dbhz ?? 0 + const color = satColor(entry.groupName) + const height = Math.max(Math.round((snr / 50) * 200), 4) + + return ( +
+
+
+
+
+ + {satelliteName(entry.satellite)} + + + {snr} + +
+
+ ) + })} +
+
+
+
+ ) +} + +function SkyPlot({ satellites }: { satellites: SatellitePoint[] }) { + const points = satellites.filter( + (sat) => + sat.azimuth_deg !== null && + sat.elevation_deg !== null && + sat.elevation_deg >= 0 && + sat.elevation_deg <= 90, + ) + + return ( +
+
+
+
+
+ N +
+
+ S +
+
+ W +
+
+ E +
+ {points.map((satellite) => { + const azimuth = satellite.azimuth_deg ?? 0 + const elevation = satellite.elevation_deg ?? 0 + const radialPercent = ((90 - elevation) / 90) * 45 + const angle = ((azimuth - 90) * Math.PI) / 180 + const x = Math.cos(angle) * radialPercent + const y = Math.sin(angle) * radialPercent + const color = satColor(satellite.constellation) + + return ( +
+ ) + })} +
+ ) +} + +function ConnectionBadge({ + status, +}: { + status: "connecting" | "live" | "retrying" +}) { + const statusText = + status === "live" + ? "Live" + : status === "retrying" + ? "Reconnecting" + : "Connecting" + const statusClass = + status === "live" + ? "text-emerald-500 bg-emerald-500/10 border-emerald-500/20" + : "text-amber-500 bg-amber-500/10 border-amber-500/20" + + return ( + + + {statusText} + + ) +} + +function GnssMonitor() { + const [snapshot, setSnapshot] = useState(null) + const [status, setStatus] = useState<"connecting" | "live" | "retrying">( + "connecting", + ) + const [selectedDeviceId, setSelectedDeviceId] = useState("") + + useEffect(() => { + const token = localStorage.getItem("access_token") + if (!token) { + return + } + + let reconnectTimer: number | undefined + let socket: WebSocket | undefined + let shouldReconnect = true + + const connect = () => { + setStatus((prev) => (prev === "live" ? prev : "connecting")) + socket = new WebSocket(buildTelemetryWsUrl(token)) + + socket.onopen = () => { + setStatus("live") + } + + socket.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) as TelemetrySnapshot + setSnapshot(payload) + setStatus("live") + } catch { + setStatus("retrying") + } + } + + socket.onclose = () => { + if (!shouldReconnect) { + return + } + setStatus("retrying") + reconnectTimer = window.setTimeout(connect, 2000) + } + + socket.onerror = () => { + socket?.close() + } + } + + connect() + + return () => { + shouldReconnect = false + if (reconnectTimer) { + window.clearTimeout(reconnectTimer) + } + socket?.close() + } + }, []) + + useEffect(() => { + if (!snapshot?.devices.length) { + setSelectedDeviceId("") + return + } + + const exists = snapshot.devices.some( + (device) => device.device_id === selectedDeviceId, + ) + if (!exists) { + setSelectedDeviceId(snapshot.devices[0].device_id) + } + }, [snapshot, selectedDeviceId]) + + const currentDevice = useMemo(() => { + if (!snapshot?.devices.length) { + return null + } + return ( + snapshot.devices.find( + (device) => device.device_id === selectedDeviceId, + ) ?? snapshot.devices[0] + ) + }, [selectedDeviceId, snapshot]) + + const fixMeta = getFixMeta(currentDevice?.fix_status ?? null) + const satellites = currentDevice?.satellite_signal_summary.satellites ?? [] + const constellations = + currentDevice?.satellite_signal_summary.constellations ?? {} + const signalHealth = currentDevice?.signal_health + + return ( +
+
+
+

GNSS Monitor

+

+ Real-time positioning and satellite signal watch +

+
+
+ + + {snapshot?.generated_at + ? `Updated ${formatTime(snapshot.generated_at)}` + : "Waiting for stream"} + +
+
+ + + + Device + + Choose a device to inspect live fix and satellite conditions + + + + + + + + {!currentDevice ? ( + + + Waiting for MQTT parsed telemetry... + + + ) : ( + <> +
+ + + + + Fix Status + + + + + {fixMeta.label} + +

+ {fixMeta.description} +

+
+
+ + + + + + Signal Health + + + + + {(signalHealth?.level ?? "unknown").toUpperCase()} + +
+
+ Score + + {signalHealth?.score ?? 0} + +
+
+
+
+
+

+ {signalHealth?.message ?? "No signal data"} +

+ + + + + + + + Satellites + + + +
+ Used in fix + {currentDevice.satellites_used ?? "-"} +
+
+ In view + + {currentDevice.satellite_signal_summary + .satellites_in_view ?? "-"} + +
+
+ Best SNR + {signalHealth?.best_snr_dbhz ?? "-"} dB-Hz +
+
+
+ + + + + + Position + + + +
+ Latitude + {formatCoord(currentDevice.lat)} +
+
+ Longitude + {formatCoord(currentDevice.lon)} +
+
+ Altitude + + {currentDevice.alt_m === null + ? "-" + : `${currentDevice.alt_m.toFixed(2)} m`} + +
+
+
+
+ + + + + + Satellite Signal View + + + Grouped by constellation, with per-satellite CN0 bars and clear + non-navigation marking. + + + + + + + +
+ + + + + Satellite Position View + + + Sky plot by azimuth/elevation for quick distribution checks. + + + + +
+ {Object.entries(constellations).map(([name, detail]) => ( +
+ + + {name} + + + {detail.used_in_navigation ?? 0}/{detail.count ?? 0} + +
+ ))} +
+
+
+ + + + Data View + + Lightweight runtime details for monitoring, without offline + analysis. + + + +
+

Device ID

+

+ {currentDevice.device_id} +

+
+
+

Last Event Time

+

+ {formatTime(currentDevice.last_event_time)} +

+
+
+

Data Age

+

+ {formatAge(currentDevice.updated_at)} +

+
+
+

Average SNR

+

+ {signalHealth?.average_snr_dbhz ?? "-"} dB-Hz +

+
+
+

{"Strong CN0 (>=40)"}

+

+ {signalHealth?.strong_signal_count ?? 0} +

+
+
+

Map Jump

+ {currentDevice.lat === null || currentDevice.lon === null ? ( +

+ Waiting for valid coordinates +

+ ) : ( + + )} +
+
+
+
+ + )} +
+ ) +}