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)] 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: try:
payload = jwt.decode( payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
@@ -46,6 +46,10 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User:
return 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)] CurrentUser = Annotated[User, Depends(get_current_user)]

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter 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 from app.core.config import settings
api_router = APIRouter() api_router = APIRouter()
@@ -8,8 +8,8 @@ api_router.include_router(login.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(utils.router) api_router.include_router(utils.router)
api_router.include_router(locations.router) api_router.include_router(locations.router)
api_router.include_router(telemetry.router)
if settings.ENVIRONMENT == "local": if settings.ENVIRONMENT == "local":
api_router.include_router(private.router) 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 from typing import Any
SUPPORTED_NMEA_TYPES = {"GGA", "RMC", "GSV", "GSA", "GST"} 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: def _to_float(value: str) -> float | None:
@@ -40,19 +50,46 @@ def _parse_lat_lon(value: str, hemisphere: str) -> float | None:
return decimal return decimal
def _extract_sentence_type(sentence: str) -> str | None: def _extract_sentence_meta(sentence: str) -> tuple[str, str] | None:
if not sentence.startswith("$"): if not sentence.startswith("$"):
return None return None
if len(sentence) < 6: if len(sentence) < 6:
return None return None
talker = sentence[1:3].upper()
nmea_type = sentence[3:6].upper() nmea_type = sentence[3:6].upper()
if nmea_type not in SUPPORTED_NMEA_TYPES: if nmea_type not in SUPPORTED_NMEA_TYPES:
return None return None
return nmea_type return talker, nmea_type
def _parse_nmea_fields(nmea_type: str, fields: list[str]) -> dict[str, Any]: def _parse_satellite_blocks(fields: list[str]) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"sentence_type": nmea_type, "raw_fields": fields} 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": if nmea_type == "GGA":
payload.update( payload.update(
@@ -91,23 +128,27 @@ def _parse_nmea_fields(nmea_type: str, fields: list[str]) -> dict[str, Any]:
} }
) )
elif nmea_type == "GSV": elif nmea_type == "GSV":
satellites = _parse_satellite_blocks(fields)
payload.update( payload.update(
{ {
"total_messages": _to_int(fields[1] if len(fields) > 1 else ""), "total_messages": _to_int(fields[1] if len(fields) > 1 else ""),
"message_index": _to_int(fields[2] if len(fields) > 2 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_in_view": _to_int(fields[3] if len(fields) > 3 else ""),
"satellites": satellites,
"snr_values": [ "snr_values": [
_to_int(fields[i]) satellite["snr_dbhz"]
for i in range(7, len(fields), 4) for satellite in satellites
if i < len(fields) if satellite["snr_dbhz"] is not None
], ],
} }
) )
elif nmea_type == "GSA": elif nmea_type == "GSA":
used_satellite_ids = [sat_id for sat_id in fields[3:15] if sat_id]
payload.update( payload.update(
{ {
"mode": fields[1] if len(fields) > 1 else None, "mode": fields[1] if len(fields) > 1 else None,
"fix_type": _to_int(fields[2] if len(fields) > 2 else ""), "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 ""), "pdop": _to_float(fields[15] if len(fields) > 15 else ""),
"hdop": _to_float(fields[16] if len(fields) > 16 else ""), "hdop": _to_float(fields[16] if len(fields) > 16 else ""),
"vdop": _to_float(fields[17] if len(fields) > 17 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 continue
sentence = raw_line.decode("ascii", errors="ignore").strip() sentence = raw_line.decode("ascii", errors="ignore").strip()
nmea_type = _extract_sentence_type(sentence) sentence_meta = _extract_sentence_meta(sentence)
if nmea_type is None: if sentence_meta is None:
continue continue
talker, nmea_type = sentence_meta
main_part = sentence.split("*", 1)[0] main_part = sentence.split("*", 1)[0]
fields = main_part.split(",") 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( events.append(
{"event_type": f"nmea_{nmea_type.lower()}", "payload": parsed_payload} {"event_type": f"nmea_{nmea_type.lower()}", "payload": parsed_payload}
) )

View File

@@ -77,6 +77,74 @@ def mark_failed(
session.add(row) 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( def update_device_state_from_events(
*, *,
session: Session, session: Session,
@@ -96,6 +164,10 @@ def update_device_state_from_events(
if state is None: if state is None:
state = TerminalDeviceState(device_id=row.device_id) 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: for event in events:
payload = event.get("payload", {}) payload = event.get("payload", {})
event_type = str(event.get("event_type", "unknown")) 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: if payload.get("lon") is not None:
state.lon = payload.get("lon") state.lon = payload.get("lon")
status = payload.get("status") 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) state.fix_status = str(status)
elif event_type == "nmea_gsv": elif event_type == "nmea_gsv":
state.satellite_signal_summary = { payload_satellites = payload.get("satellites", [])
"snr_values": payload.get("snr_values", []), if isinstance(payload_satellites, list):
"satellites_in_view": payload.get("satellites_in_view"), 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": elif event_type == "rtcm_summary":
state.rtcm_type_summary = payload.get("rtcm_types", {}) 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.last_event_time = row.received_at
state.updated_at = get_datetime_utc() state.updated_at = get_datetime_utc()
session.add(state) 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} event_types = {event["event_type"] for event in events}
assert "rtcm_summary" in event_types 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: def test_schema_excludes_terminal_observation_event_table() -> None:
assert "terminal_observation_event" not in SQLModel.metadata.tables 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

View File

@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; 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 { 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<TelemetryReadTelemetryLatestResponse> {
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 { export class UsersService {
/** /**
* Read Users * Read Users

View File

@@ -177,6 +177,15 @@ export type PrivateCreateUserData = {
export type PrivateCreateUserResponse = (UserPublic); export type PrivateCreateUserResponse = (UserPublic);
export type TelemetryReadTelemetryLatestData = {
deviceId?: (string | null);
limit?: number;
};
export type TelemetryReadTelemetryLatestResponse = ({
[key: string]: unknown;
});
export type UsersReadUsersData = { export type UsersReadUsersData = {
limit?: number; limit?: number;
skip?: number; skip?: number;

View File

@@ -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 { SidebarAppearance } from "@/components/Common/Appearance"
import { Logo } from "@/components/Common/Logo" import { Logo } from "@/components/Common/Logo"
@@ -15,6 +15,7 @@ import { User } from "./User"
const baseItems: Item[] = [ const baseItems: Item[] = [
{ icon: Home, title: "Dashboard", path: "/" }, { icon: Home, title: "Dashboard", path: "/" },
{ icon: MapPin, title: "Locations", path: "/locations" }, { icon: MapPin, title: "Locations", path: "/locations" },
{ icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" },
] ]
export function AppSidebar() { export function AppSidebar() {

View File

@@ -17,6 +17,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as LayoutIndexRouteImport } from './routes/_layout/index' import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations' 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' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
const SignupRoute = SignupRouteImport.update({ const SignupRoute = SignupRouteImport.update({
@@ -58,6 +59,11 @@ const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
path: '/locations', path: '/locations',
getParentRoute: () => LayoutRoute, getParentRoute: () => LayoutRoute,
} as any) } as any)
const LayoutGnssMonitorRoute = LayoutGnssMonitorRouteImport.update({
id: '/gnss-monitor',
path: '/gnss-monitor',
getParentRoute: () => LayoutRoute,
} as any)
const LayoutAdminRoute = LayoutAdminRouteImport.update({ const LayoutAdminRoute = LayoutAdminRouteImport.update({
id: '/admin', id: '/admin',
path: '/admin', path: '/admin',
@@ -71,6 +77,7 @@ export interface FileRoutesByFullPath {
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute '/admin': typeof LayoutAdminRoute
'/gnss-monitor': typeof LayoutGnssMonitorRoute
'/locations': typeof LayoutLocationsRoute '/locations': typeof LayoutLocationsRoute
'/settings': typeof LayoutSettingsRoute '/settings': typeof LayoutSettingsRoute
} }
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/admin': typeof LayoutAdminRoute '/admin': typeof LayoutAdminRoute
'/gnss-monitor': typeof LayoutGnssMonitorRoute
'/locations': typeof LayoutLocationsRoute '/locations': typeof LayoutLocationsRoute
'/settings': typeof LayoutSettingsRoute '/settings': typeof LayoutSettingsRoute
'/': typeof LayoutIndexRoute '/': typeof LayoutIndexRoute
@@ -92,6 +100,7 @@ export interface FileRoutesById {
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/signup': typeof SignupRoute '/signup': typeof SignupRoute
'/_layout/admin': typeof LayoutAdminRoute '/_layout/admin': typeof LayoutAdminRoute
'/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute
'/_layout/locations': typeof LayoutLocationsRoute '/_layout/locations': typeof LayoutLocationsRoute
'/_layout/settings': typeof LayoutSettingsRoute '/_layout/settings': typeof LayoutSettingsRoute
'/_layout/': typeof LayoutIndexRoute '/_layout/': typeof LayoutIndexRoute
@@ -105,6 +114,7 @@ export interface FileRouteTypes {
| '/reset-password' | '/reset-password'
| '/signup' | '/signup'
| '/admin' | '/admin'
| '/gnss-monitor'
| '/locations' | '/locations'
| '/settings' | '/settings'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
| '/reset-password' | '/reset-password'
| '/signup' | '/signup'
| '/admin' | '/admin'
| '/gnss-monitor'
| '/locations' | '/locations'
| '/settings' | '/settings'
| '/' | '/'
@@ -125,6 +136,7 @@ export interface FileRouteTypes {
| '/reset-password' | '/reset-password'
| '/signup' | '/signup'
| '/_layout/admin' | '/_layout/admin'
| '/_layout/gnss-monitor'
| '/_layout/locations' | '/_layout/locations'
| '/_layout/settings' | '/_layout/settings'
| '/_layout/' | '/_layout/'
@@ -196,6 +208,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LayoutLocationsRouteImport preLoaderRoute: typeof LayoutLocationsRouteImport
parentRoute: typeof LayoutRoute parentRoute: typeof LayoutRoute
} }
'/_layout/gnss-monitor': {
id: '/_layout/gnss-monitor'
path: '/gnss-monitor'
fullPath: '/gnss-monitor'
preLoaderRoute: typeof LayoutGnssMonitorRouteImport
parentRoute: typeof LayoutRoute
}
'/_layout/admin': { '/_layout/admin': {
id: '/_layout/admin' id: '/_layout/admin'
path: '/admin' path: '/admin'
@@ -208,6 +227,7 @@ declare module '@tanstack/react-router' {
interface LayoutRouteChildren { interface LayoutRouteChildren {
LayoutAdminRoute: typeof LayoutAdminRoute LayoutAdminRoute: typeof LayoutAdminRoute
LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute
LayoutLocationsRoute: typeof LayoutLocationsRoute LayoutLocationsRoute: typeof LayoutLocationsRoute
LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute
LayoutIndexRoute: typeof LayoutIndexRoute LayoutIndexRoute: typeof LayoutIndexRoute
@@ -215,6 +235,7 @@ interface LayoutRouteChildren {
const LayoutRouteChildren: LayoutRouteChildren = { const LayoutRouteChildren: LayoutRouteChildren = {
LayoutAdminRoute: LayoutAdminRoute, LayoutAdminRoute: LayoutAdminRoute,
LayoutGnssMonitorRoute: LayoutGnssMonitorRoute,
LayoutLocationsRoute: LayoutLocationsRoute, LayoutLocationsRoute: LayoutLocationsRoute,
LayoutSettingsRoute: LayoutSettingsRoute, LayoutSettingsRoute: LayoutSettingsRoute,
LayoutIndexRoute: LayoutIndexRoute, LayoutIndexRoute: LayoutIndexRoute,

View File

@@ -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 { Footer } from "@/components/Common/Footer"
import AppSidebar from "@/components/Sidebar/AppSidebar" import AppSidebar from "@/components/Sidebar/AppSidebar"
@@ -21,6 +26,11 @@ export const Route = createFileRoute("/_layout")({
}) })
function Layout() { function Layout() {
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const isGnssMonitorPage = pathname === "/gnss-monitor"
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
@@ -29,7 +39,9 @@ function Layout() {
<SidebarTrigger className="-ml-1 text-muted-foreground" /> <SidebarTrigger className="-ml-1 text-muted-foreground" />
</header> </header>
<main className="flex-1 p-6 md:p-8"> <main className="flex-1 p-6 md:p-8">
<div className="mx-auto max-w-7xl"> <div
className={`mx-auto ${isGnssMonitorPage ? "max-w-none" : "max-w-7xl"}`}
>
<Outlet /> <Outlet />
</div> </div>
</main> </main>

View File

@@ -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<string, string> = {
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<string, string> = {
GPS: "G",
SBAS: "S",
Galileo: "E",
BeiDou: "B",
GLONASS: "R",
QZSS: "Q",
NavIC: "I",
Mixed: "M",
Unknown: "U",
}
const SIGNAL_STATUS_CLASS: Record<SignalHealthLevel, string> = {
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 (
<div className="h-40 flex items-center justify-center text-sm text-muted-foreground border rounded-lg">
No SNR data yet
</div>
)
}
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 (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
{grouped.map((group) => (
<div
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5"
key={group.name}
>
<span
className="size-2 rounded-full"
style={{ backgroundColor: satColor(group.name) }}
/>
<span>
{group.name} ({satPrefix(group.name)})
</span>
</div>
))}
<div className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<span className="size-2 rounded-full border-2 border-blue-500" />
<span>Not used in navigation</span>
</div>
</div>
<div className="flex gap-2 min-w-0">
<div className="w-10 h-[200px] text-[11px] text-muted-foreground shrink-0 flex flex-col justify-between">
{[50, 40, 30, 20, 10, 0].map((tick) => (
<span className="leading-none text-right" key={tick}>
{tick}
</span>
))}
</div>
<div className="relative h-[250px] flex-1 min-w-0">
<div className="absolute inset-0 pointer-events-none">
{[0, 10, 20, 30, 40, 50].map((tick) => (
<div
className="absolute left-0 right-0 border-t border-dashed border-border/70"
key={tick}
style={{ bottom: `${(tick / 50) * 200}px` }}
/>
))}
</div>
<div
className="relative h-full grid gap-x-1 min-w-0"
style={columnStyle}
>
{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 (
<div
className={`min-w-0 ${entry.groupStart ? "border-l border-border/80 pl-1" : ""}`}
key={`${entry.groupName}-${entry.satellite.satellite_id}`}
>
<div className="h-[200px] flex items-end justify-center">
<div
className="w-full max-w-4 rounded-sm border"
style={{
height: `${height}px`,
backgroundColor: entry.satellite.used_in_navigation
? color
: `${color}33`,
borderColor: entry.satellite.used_in_navigation
? color
: "#2563eb",
borderWidth: entry.satellite.used_in_navigation
? "1px"
: "2px",
}}
title={`${satelliteName(entry.satellite)}${entry.groupName}${snr} dB-Hz`}
/>
</div>
<div className="mt-1 flex flex-col items-center leading-none gap-1">
<span className="text-[10px] text-center whitespace-nowrap">
{satelliteName(entry.satellite)}
</span>
<span className="text-[10px] text-muted-foreground text-center">
{snr}
</span>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
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 (
<div className="relative w-full max-w-[420px] mx-auto aspect-square rounded-full border bg-muted/20">
<div className="absolute inset-[16%] rounded-full border border-dashed border-border" />
<div className="absolute inset-[33%] rounded-full border border-dashed border-border" />
<div className="absolute inset-[50%] rounded-full border border-dashed border-border" />
<div className="absolute left-1/2 top-0 -translate-x-1/2 text-xs text-muted-foreground">
N
</div>
<div className="absolute left-1/2 bottom-0 -translate-x-1/2 text-xs text-muted-foreground">
S
</div>
<div className="absolute left-1 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
W
</div>
<div className="absolute right-1 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
E
</div>
{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 (
<div
className="absolute size-4 rounded-full border-2"
key={`${satellite.constellation}-${satellite.satellite_id}-sky`}
style={{
left: `${50 + x}%`,
top: `${50 + y}%`,
transform: "translate(-50%, -50%)",
backgroundColor: color,
borderColor: satellite.used_in_navigation
? color
: "var(--border)",
opacity: satellite.used_in_navigation ? 1 : 0.5,
}}
title={`${satellite.constellation}-${satellite.satellite_id} (${satellite.snr_dbhz ?? "-"} dB-Hz)`}
/>
)
})}
</div>
)
}
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 (
<Badge variant="outline" className={statusClass}>
<Radio className="size-3" />
{statusText}
</Badge>
)
}
function GnssMonitor() {
const [snapshot, setSnapshot] = useState<TelemetrySnapshot | null>(null)
const [status, setStatus] = useState<"connecting" | "live" | "retrying">(
"connecting",
)
const [selectedDeviceId, setSelectedDeviceId] = useState<string>("")
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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">GNSS Monitor</h1>
<p className="text-muted-foreground">
Real-time positioning and satellite signal watch
</p>
</div>
<div className="flex items-center gap-3">
<ConnectionBadge status={status} />
<Badge variant="outline">
{snapshot?.generated_at
? `Updated ${formatTime(snapshot.generated_at)}`
: "Waiting for stream"}
</Badge>
</div>
</div>
<Card>
<CardHeader className="gap-3">
<CardTitle>Device</CardTitle>
<CardDescription>
Choose a device to inspect live fix and satellite conditions
</CardDescription>
</CardHeader>
<CardContent>
<select
className="h-10 rounded-md border bg-background px-3 text-sm min-w-[280px]"
onChange={(event) => setSelectedDeviceId(event.target.value)}
value={selectedDeviceId}
>
{snapshot?.devices.map((device) => (
<option key={device.device_id} value={device.device_id}>
{device.device_id}
</option>
))}
</select>
</CardContent>
</Card>
{!currentDevice ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
Waiting for MQTT parsed telemetry...
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Gauge className="size-4" />
Fix Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Badge variant="outline" className={fixMeta.className}>
{fixMeta.label}
</Badge>
<p className="text-sm text-muted-foreground">
{fixMeta.description}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Signal className="size-4" />
Signal Health
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Badge
variant="outline"
className={
SIGNAL_STATUS_CLASS[signalHealth?.level ?? "unknown"]
}
>
{(signalHealth?.level ?? "unknown").toUpperCase()}
</Badge>
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>Score</span>
<span className="font-medium">
{signalHealth?.score ?? 0}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${signalHealth?.score ?? 0}%` }}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{signalHealth?.message ?? "No signal data"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Satellite className="size-4" />
Satellites
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Used in fix</span>
<span>{currentDevice.satellites_used ?? "-"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">In view</span>
<span>
{currentDevice.satellite_signal_summary
.satellites_in_view ?? "-"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Best SNR</span>
<span>{signalHealth?.best_snr_dbhz ?? "-"} dB-Hz</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-4" />
Position
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Latitude</span>
<span>{formatCoord(currentDevice.lat)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Longitude</span>
<span>{formatCoord(currentDevice.lon)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Altitude</span>
<span>
{currentDevice.alt_m === null
? "-"
: `${currentDevice.alt_m.toFixed(2)} m`}
</span>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Signal className="size-4" />
Satellite Signal View
</CardTitle>
<CardDescription>
Grouped by constellation, with per-satellite CN0 bars and clear
non-navigation marking.
</CardDescription>
</CardHeader>
<CardContent>
<SignalBars satellites={satellites} />
</CardContent>
</Card>
<div className="grid gap-6 xl:grid-cols-[460px_1fr]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Compass className="size-4" />
Satellite Position View
</CardTitle>
<CardDescription>
Sky plot by azimuth/elevation for quick distribution checks.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<SkyPlot satellites={satellites} />
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(constellations).map(([name, detail]) => (
<div
className="rounded-md border px-2 py-1 flex items-center justify-between"
key={name}
>
<span className="flex items-center gap-1">
<span
className="size-2 rounded-full"
style={{ backgroundColor: satColor(name) }}
/>
{name}
</span>
<span className="text-muted-foreground">
{detail.used_in_navigation ?? 0}/{detail.count ?? 0}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data View</CardTitle>
<CardDescription>
Lightweight runtime details for monitoring, without offline
analysis.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-3">
<div>
<p className="text-muted-foreground">Device ID</p>
<p className="font-medium break-all">
{currentDevice.device_id}
</p>
</div>
<div>
<p className="text-muted-foreground">Last Event Time</p>
<p className="font-medium">
{formatTime(currentDevice.last_event_time)}
</p>
</div>
<div>
<p className="text-muted-foreground">Data Age</p>
<p className="font-medium">
{formatAge(currentDevice.updated_at)}
</p>
</div>
<div>
<p className="text-muted-foreground">Average SNR</p>
<p className="font-medium">
{signalHealth?.average_snr_dbhz ?? "-"} dB-Hz
</p>
</div>
<div>
<p className="text-muted-foreground">{"Strong CN0 (>=40)"}</p>
<p className="font-medium">
{signalHealth?.strong_signal_count ?? 0}
</p>
</div>
<div className="md:col-span-2 xl:col-span-3">
<p className="text-muted-foreground mb-2">Map Jump</p>
{currentDevice.lat === null || currentDevice.lon === null ? (
<p className="text-sm text-muted-foreground">
Waiting for valid coordinates
</p>
) : (
<Button asChild size="sm" variant="outline">
<a
href={`https://www.openstreetmap.org/?mlat=${currentDevice.lat}&mlon=${currentDevice.lon}&zoom=16`}
rel="noreferrer"
target="_blank"
>
Open in OpenStreetMap
</a>
</Button>
)}
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
)
}