feat: add realtime GNSS monitor with telemetry websocket
All checks were successful
Deploy to Production / deploy (push) Successful in 1m10s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m10s
This commit is contained in:
@@ -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)]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
63
backend/app/api/routes/telemetry.py
Normal file
63
backend/app/api/routes/telemetry.py
Normal 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
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
158
backend/app/mqtt/telemetry.py
Normal file
158
backend/app/mqtt/telemetry.py
Normal 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}
|
||||
127
backend/tests/api/routes/test_telemetry.py
Normal file
127
backend/tests/api/routes/test_telemetry.py
Normal 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
448
backend/tests/mqtt/nmea.txt
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
import type { LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, TelemetryReadTelemetryLatestData, TelemetryReadTelemetryLatestResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
|
||||
export class LocationsService {
|
||||
/**
|
||||
@@ -235,6 +235,31 @@ export class PrivateService {
|
||||
}
|
||||
}
|
||||
|
||||
export class TelemetryService {
|
||||
/**
|
||||
* Read Telemetry Latest
|
||||
* Return the latest parsed GNSS telemetry snapshot.
|
||||
* @param data The data for the request.
|
||||
* @param data.deviceId
|
||||
* @param data.limit
|
||||
* @returns unknown Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readTelemetryLatest(data: TelemetryReadTelemetryLatestData = {}): CancelablePromise<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 {
|
||||
/**
|
||||
* Read Users
|
||||
|
||||
@@ -177,6 +177,15 @@ export type PrivateCreateUserData = {
|
||||
|
||||
export type PrivateCreateUserResponse = (UserPublic);
|
||||
|
||||
export type TelemetryReadTelemetryLatestData = {
|
||||
deviceId?: (string | null);
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type TelemetryReadTelemetryLatestResponse = ({
|
||||
[key: string]: unknown;
|
||||
});
|
||||
|
||||
export type UsersReadUsersData = {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, MapPin, Users } from "lucide-react"
|
||||
import { Home, MapPin, Satellite, Users } from "lucide-react"
|
||||
|
||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||
import { Logo } from "@/components/Common/Logo"
|
||||
@@ -15,6 +15,7 @@ import { User } from "./User"
|
||||
const baseItems: Item[] = [
|
||||
{ icon: Home, title: "Dashboard", path: "/" },
|
||||
{ icon: MapPin, title: "Locations", path: "/locations" },
|
||||
{ icon: Satellite, title: "GNSS Monitor", path: "/gnss-monitor" },
|
||||
]
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
|
||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
|
||||
import { Route as LayoutGnssMonitorRouteImport } from './routes/_layout/gnss-monitor'
|
||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
@@ -58,6 +59,11 @@ const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
|
||||
path: '/locations',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutGnssMonitorRoute = LayoutGnssMonitorRouteImport.update({
|
||||
id: '/gnss-monitor',
|
||||
path: '/gnss-monitor',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
@@ -71,6 +77,7 @@ export interface FileRoutesByFullPath {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||
'/locations': typeof LayoutLocationsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
}
|
||||
@@ -80,6 +87,7 @@ export interface FileRoutesByTo {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||
'/locations': typeof LayoutLocationsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/': typeof LayoutIndexRoute
|
||||
@@ -92,6 +100,7 @@ export interface FileRoutesById {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/_layout/admin': typeof LayoutAdminRoute
|
||||
'/_layout/gnss-monitor': typeof LayoutGnssMonitorRoute
|
||||
'/_layout/locations': typeof LayoutLocationsRoute
|
||||
'/_layout/settings': typeof LayoutSettingsRoute
|
||||
'/_layout/': typeof LayoutIndexRoute
|
||||
@@ -105,6 +114,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/gnss-monitor'
|
||||
| '/locations'
|
||||
| '/settings'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -114,6 +124,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/gnss-monitor'
|
||||
| '/locations'
|
||||
| '/settings'
|
||||
| '/'
|
||||
@@ -125,6 +136,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/signup'
|
||||
| '/_layout/admin'
|
||||
| '/_layout/gnss-monitor'
|
||||
| '/_layout/locations'
|
||||
| '/_layout/settings'
|
||||
| '/_layout/'
|
||||
@@ -196,6 +208,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutLocationsRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/gnss-monitor': {
|
||||
id: '/_layout/gnss-monitor'
|
||||
path: '/gnss-monitor'
|
||||
fullPath: '/gnss-monitor'
|
||||
preLoaderRoute: typeof LayoutGnssMonitorRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/admin': {
|
||||
id: '/_layout/admin'
|
||||
path: '/admin'
|
||||
@@ -208,6 +227,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
interface LayoutRouteChildren {
|
||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||
LayoutGnssMonitorRoute: typeof LayoutGnssMonitorRoute
|
||||
LayoutLocationsRoute: typeof LayoutLocationsRoute
|
||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||
@@ -215,6 +235,7 @@ interface LayoutRouteChildren {
|
||||
|
||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||
LayoutAdminRoute: LayoutAdminRoute,
|
||||
LayoutGnssMonitorRoute: LayoutGnssMonitorRoute,
|
||||
LayoutLocationsRoute: LayoutLocationsRoute,
|
||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||
LayoutIndexRoute: LayoutIndexRoute,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
redirect,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router"
|
||||
|
||||
import { Footer } from "@/components/Common/Footer"
|
||||
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
||||
@@ -21,6 +26,11 @@ export const Route = createFileRoute("/_layout")({
|
||||
})
|
||||
|
||||
function Layout() {
|
||||
const pathname = useRouterState({
|
||||
select: (state) => state.location.pathname,
|
||||
})
|
||||
const isGnssMonitorPage = pathname === "/gnss-monitor"
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
@@ -29,7 +39,9 @@ function Layout() {
|
||||
<SidebarTrigger className="-ml-1 text-muted-foreground" />
|
||||
</header>
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
826
frontend/src/routes/_layout/gnss-monitor.tsx
Normal file
826
frontend/src/routes/_layout/gnss-monitor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user