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:
@@ -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}
|
||||
Reference in New Issue
Block a user