Skip to content

Instantly share code, notes, and snippets.

@markusand
Last active January 31, 2025 16:53
Show Gist options
  • Save markusand/55f4cc650775f7bf1645abe3bf811d3e to your computer and use it in GitHub Desktop.
Save markusand/55f4cc650775f7bf1645abe3bf811d3e to your computer and use it in GitHub Desktop.
Autoparse NMEA messages
from typing import List, Type, TypeVar
T = TypeVar("T", bound="Autoparser")
class Autoparser:
"""Automatic parse into parseable classes."""
def __repr__(self):
exclude = (classmethod,)
attrs = [
f"{name}={getattr(self, name)}"
for name, field in self.__class__.__annotations__.items()
if field not in exclude
]
return f"{self.__class__.__name__}({', '.join(attrs)})"
@classmethod
def validate(cls: Type[T], data: str) -> None:
"""Validate Raises ValueError if invalid."""
raise NotImplementedError("Not implemented")
@classmethod
def split(cls: Type[T], data: str) -> List[str]:
"""Split sentence into parts"""
raise NotImplementedError("Not implemented")
@classmethod
def parse(cls: Type[T], data: str):
"""Parse sentence into a class instance"""
cls.validate(data)
values = cls.split(data)
exclude = (classmethod, property)
fields = {
name: type_hint
for name, type_hint in cls.__annotations__.items()
if type_hint not in exclude
}
if len(values) != len(fields):
raise ValueError(f"Expected {len(fields)} values, got {len(values)}")
parsed = {
name: field(value) if value else None
for (name, field), value in zip(fields.items(), values)
}
return cls(**parsed)
from enum import Enum
from dataclasses import dataclass
from .nmea import NMEA
class FixType(Enum):
"""RTK fix type"""
NOT_VALID = "0"
GPS_FIX = "1"
DIFF_GPS_FIX = "2"
NOT_APPLICABLE = "3"
RTK_FIX = "4"
RTK_FLOAT = "5"
INS = "6"
def ddmm_to_decimal(ddmm: float, direction: str) -> float:
"""Convert coordinates from ddmm to decimal degrees"""
degrees = int(ddmm // 100)
minutes = ddmm % 100
mult = -1 if direction in ("W", "S") else 1
return mult * (degrees + (minutes / 60))
@dataclass(frozen=True)
class GGA(NMEA):
"""NMEA GGA message"""
sentence: str
utc: float # UTC time seconds
_lat: float # Latitude in ddmm format
lat_hemisphere: str # N or S
_lon: float # Longitude in ddmm format
lon_hemisphere: str # E or W
fix: FixType # Fix type
satellites_in_use: int # Number of satellites in use
hdop: float # Horizontal dilution of precision
alt: float # Altitude relative to mean sea level
alt_unit: str # Altitude unit (meters)
geoid_separation: float # Geoid separation height
geoid_separation_unit: str # Geoid separation unit (meters)
age_differential: int # Approximate age of differential data (last GPS MSM message received)
reference_station: str # Reference station ID
@property
def lat(self) -> float:
"""Latitude in decimal degrees"""
return ddmm_to_decimal(self._lat, self.lat_hemisphere)
@property
def lon(self) -> float:
"""Longitude in decimal degrees"""
return ddmm_to_decimal(self._lon, self.lon_hemisphere)
def main():
"""Main function"""
gga = GGA.parse("$GPGGA,202530.00,5109.0262,N,11401.8407,W,5,40,0.5,1097.36,M,-17.00,M,18,NRTI*61")
print(gga)
print(f"Lat:{gga.lat:.6f} Lon:{gga.lon:.6f}, Alt:{gga.alt:.1f}m")
if __name__ == "__main__":
main()
from functools import reduce
from typing import List, Type, TypeVar
from .autoparser import Autoparser
T = TypeVar("T", bound="NMEA")
class NMEA(Autoparser):
"""NMEA class"""
@classmethod
def validate(cls: Type[T], data: str) -> None:
"""Validate NMEA message."""
try:
if not data or len(data) == 0:
raise ValueError("Empty data")
content, checksum = data.strip("\n").split("*", 1)
if len(checksum) != 2:
raise ValueError("Checksum length must be 2 digits")
if not content.startswith("$") or cls.__name__ not in (content[3:6], "NMEA"):
raise ValueError(f"{content[:6]} is an invalid NMEA identifier")
# Verify checksum
_checksum = reduce(lambda x, y: x ^ ord(y), content[1:], 0)
if _checksum != int(checksum, 16):
raise ValueError("Checksum verification failed")
except ValueError as error:
raise ValueError("Invalid or malformed NMEA message") from error
@classmethod
def split(cls: Type[T], data: str) -> List[str]:
"""Split sentence into NAME parts"""
content, _checksum = data[1:].split("*")
return content.split(",")
@property
def talker(self) -> str:
"""Get talker"""
sentence = getattr(self, "sentence", "")
return sentence[:2]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment