feat: add realtime GNSS monitor with telemetry websocket
All checks were successful
Deploy to Production / deploy (push) Successful in 1m10s

This commit is contained in:
魏风
2026-03-20 11:48:04 +08:00
parent e8846f4831
commit e81aadcd99
16 changed files with 1951 additions and 22 deletions

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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

View File

@@ -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}
)

View File

@@ -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)

View File

@@ -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}

View File

@@ -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

448
backend/tests/mqtt/nmea.txt Normal file
View File

@@ -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

View File

@@ -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,
}

View File

@@ -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