Last active
January 31, 2025 16:53
-
-
Save markusand/55f4cc650775f7bf1645abe3bf811d3e to your computer and use it in GitHub Desktop.
Autoparse NMEA messages
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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