https://github.com/octaprog7/light-nmea-micropython
Fast, NMEA 0183 parser for Python and MicroPython.
https://github.com/octaprog7/light-nmea-micropython
glonass gnss gps micropython nmea nmea0183 python3 uart
Last synced: 26 days ago
JSON representation
Fast, NMEA 0183 parser for Python and MicroPython.
- Host: GitHub
- URL: https://github.com/octaprog7/light-nmea-micropython
- Owner: octaprog7
- License: gpl-3.0
- Created: 2026-07-01T20:14:49.000Z (28 days ago)
- Default Branch: master
- Last Pushed: 2026-07-02T21:20:28.000Z (27 days ago)
- Last Synced: 2026-07-02T22:26:08.668Z (26 days ago)
- Topics: glonass, gnss, gps, micropython, nmea, nmea0183, python3, uart
- Language: Python
- Homepage:
- Size: 82 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# LightNMEA
📖 [По-русски](README_RU.md)
Extremely fast, zero-dependency, single-pass NMEA 0183 parser with minimal memory allocation, written specifically for MicroPython and Python (CPython).
Designed for 32-bit microcontrollers (ESP32, STM32, RP2040) working with high-rate GNSS receivers (10-50 Hz multi-constellation GPS/GLONASS/BeiDou/Galileo modules).
Why light_nmea?
At the time of creation (2026), there was only one NMEA parser available for MicroPython -- micropyGPS (2018). It works, but is slow (25 packets/sec) and does not support modern GNSS constellations.
light_nmea was built as an alternative:
* 15x(!) faster than micropyGPS
* Support for seven constellations (GPS, GLONASS, Galileo, BeiDou, QZSS, NavIC, Multi-GNSS)
* Zero memory allocation in the parsing loop
* MCU RTC integration (MicroPython)
* Non-blocking UART stream reading
Other popular parsers (pynmea2, pynmeagps) do not work on MicroPython due to dependencies on CPython-specific modules (dataclasses, typing).
## Benchmark
### Benchmark Methodology
To ensure a fair and comprehensive comparison, the benchmark script implements the following conditions:
* **Real-World Payload**: Tests are executed using a representative stream of 300,000 to 1,000,000 NMEA 0183 packets (including standard `RMC` and `GGA` sentences).
* **Warm-up Phase**: Every parser undergoes a warm-up phase (2 complete runs) before the actual measurement to eliminate interpreter initialization overhead.
* **Property Access Validation**: The benchmark does not just call the parser function; it executes a strict property access simulation (`_ = gps.valid` and `_ = gps.latitude`) to force the code to perform complete data decoding and validation.
* **Best of 3**: The final results are based on the minimum elapsed time across 3 independent runs, preventing operating system background task spikes from affecting the data.
* **Garbage Collection**: Explicit `gc.collect()` is invoked before each run to ensure precise memory state baseline.
### Benchmark Results (300,000 Packets)
| Platform / Environment | `light_nmea` | Competitor | Advantage |
|:----------------------------------|:---------------:|:---------------------------|:---------------------:|
| **CPython** (Ryzen 7 2700) | **122,861 pps** | `pynmea2`: 81,976 pps | **1.5× faster** |
| **CPython** (Ryzen 7 2700) | **124,991 pps** | `pynmeagps`: 48,845 pps | **2.6× faster** |
| **CPython** (Ryzen 7 2700) | **125,000 pps** | `adafruit_gps`: 76,277 pps | **1.6× faster** |
| **CPython** (Ryzen 7 2700) | **127,143 pps** | `micropyGPS`: 35,322 pps | **3.6× faster** |
| **MicroPython** (RP2040 @ 133MHz) | **375 pps** | `micropyGPS`: 25 pps | **12.0-15.0× faster** |
> **Note:** `pynmea2`, `pynmeagps`, and `adafruit_gps` do not work on MicroPython due to
> dependencies on CPython modules (`dataclasses`, extended `typing`, `circuitpython_typing`).
> `micropyGPS` is the only alternative NMEA parser for MicroPython, but it has not been updated
> since 2018 and is significantly slower. On CPython, the gap is smaller (×3.6), but on
> RP2040 microcontrollers, it reaches ×15 due to the overhead of object creation.
## Zero RAM Allocation on the Hot Path
The main parsing loop (parse_line) does not create new objects in memory:
* Comma positions are stored in a pre-allocated bytearray.
* Message type comparisons (RMC, GGA) and statuses (A, S, W) are performed via integer ASCII codes, not bytes slicing.
* CRC checking is built into the single-pass scanner without separate calls.
* GNSS constellation determination uses a table lookup by ASCII codes.
Allocations only happen to save final values (latitude, longitude, speed, course, altitude, time, date) -- this is inevitable, as the user must receive them as independent objects. However, the parsing process itself does not pollute the heap, which is critical for MicroPython with its small RAM and expensive garbage collector.
## Speed Margin for GPS Applications
| GPS Rate | Packets/sec | Margin on MicroPython |
|-------------------|-------------|-----------------------|
| 1 Гц (standard) | 1 | ×375 |
| 5 Гц (fast) | 5 | ×75 |
| 10 Гц (high rate) | 10 | ×37 |
| 20 Гц (extreme) | 20 | ×18 |
Even at a rate of 20 Hz, the microcontroller has an x18 CPU margin for other application logic (display rendering, communication, logging).
## Key Architectural Optimizations
1. Single-pass scanner. In a single pass over the string bytes, it calculates the checksum (XOR), finds the payload boundaries (*), and records the indices of all comma separators (,) into a pre-allocated bytearray.
2. Inline data parsing. The field extraction logic is fully unrolled inside the main loop. All field boundaries are calculated on the fly using the ready indices, which eliminates the overhead of creating stack frames and unnecessary function calls.
3. Protection against GC freezes. Extracting and converting coordinates, speed, and course into numbers happens "on the fly" without generating intermediate string garbage in the heap.
## Supported Messages
### NMEA 0183 v3.x (Standard)
| Message | Description | Support |
|---------------------|--------------------------------------------|--------------------------|
| `$GPRMC` / `$GNRMC` | Recommended Minimum Navigation Information | Full parsing |
| `$GPGGA` / `$GNGGA` | Global Positioning System Fix Data | Full parsing |
| `$GPGSV` | Satellites in View | Accepted, but not parsed |
### NMEA 0183 v4.10+ (новые Talker ID)
| Talker ID | Constellation | Example | Support |
|-----------|------------------------|----------|---------|
| `GP` | GPS (USA) | `$GPRMC` | + |
| `GL` | GLONASS (Russia) | `$GLRMC` | + |
| `GA` | Galileo (EU) | `$GARMC` | + |
| `GB` | BeiDou (China, legacy) | `$GBRMC` | + |
| `BD` | BeiDou (China, new) | `$BDRMC` | + |
| `QZ` | QZSS (Japan) | `$QZRMC` | + |
| `IR` | NavIC (Bharat -India) | `$IRRMC` | + |
| `GN` | Multi-GNSS (mixed) | `$GNRMC` | + |
### Constellation Filtering
```python
from light_nmea import LightNMEA, CST_MASK_GPS, CST_MASK_GALILEO
parser = LightNMEA()
# Accept only GPS and Galileo
parser.set_cst_filter(CST_MASK_GPS | CST_MASK_GALILEO)
# Accept all constellations (default)
parser.set_cst_filter(CST_MASK_ALL)
```
## Installation
### Installing Competitors for Benchmarks
CPython (PC, virtual environment recommended)
```console
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# .venv\Scripts\activate # Windows
```
#### Install all competitors with a single command:
```console
pip install pynmea2 pynmeagps
```
#### micropyGPS is not on PyPI -- install from GitHub
```console
pip install git+https://github.com/Knio/micropyGPS.git
```
#### Alternative (if git is not installed):
```console
wget https://raw.githubusercontent.com/Knio/micropyGPS/master/micropyGPS.py
```
### MicroPython
Simply copy the light_nmea folder with the files nmea0183_parser.py, nmea0183_stats.py, nmea0183_stream.py to the root of your microcontroller's filesystem.
There are no dependencies - the library uses only standard modules (gc, time, sys).
## Quick Start
### Basic Usage (Single Packet)
```python
from light_nmea import LightNMEA
# Initialize the parser
parser = LightNMEA(trust_gga_fix=True)
# Raw byte string from UART or log file
raw_sentence = b"$GNRMC,091530,A,5575.2057,N,03762.6130,E,15.0,120.5,220626,,,A*7F"
# Parse the string
if parser.parse_line(raw_sentence):
if parser.valid:
print("Latitude: ", parser.latitude)
print("Longitude:", parser.longitude)
print("Speed: ", parser.speed, "km/h")
print("Time: ", parser.time.decode('ascii'))
```
### Packet Stream Usage
```python
from light_nmea import LightNMEA
parser = LightNMEA(trust_gga_fix=True)
# Simulate a data stream from a log or UART
stream_packets = (
b"$GNRMC,091530,A,5575.2057,N,03762.6130,E,15.0,120.5,220626,,,A*7F",
b"$GNGGA,091530,5575.2057,N,03762.6130,E,1,12,1.0,156.3,M,0.0,M,,*42"
)
for raw_sentence in stream_packets:
if parser.parse_line(raw_sentence):
print("--- Packet processed successfully ---")
print("Fix status: ", parser.valid)
print("UTC Time: ", parser.time.decode('ascii') if parser.time else "None")
if parser.has_coordinates():
print("Coordinates:", parser.latitude, ",", parser.longitude)
if parser.has_navigation():
print("2D Nav: ", parser.speed, "km/h,", parser.course, "deg.")
if parser.has_3d_fix():
print("3D Fix: ", parser.altitude, "meters")
print("Satellites: ", parser.satellites)
```
### MicroPython Example (RP2040 + UART)
```python
import gc
import time
from machine import UART, Pin, RTC
from light_nmea import LightNMEA
# Configure UART
uart = UART(1, baudrate=9600, rx=Pin(5), rxbuf=512)
parser = LightNMEA(trust_gga_fix=True)
rtc = RTC()
# Critical for stable MCU operation:
gc.threshold(8192) # Auto GC when memory is low
gc_counter = 0
rtc_synced = False
while True:
if uart.any():
raw_line = uart.readline()
if raw_line and len(raw_line) > 10:
if parser.parse_line(raw_line):
if parser.has_coordinates() and not rtc_synced:
# Sync RTC on first fix
t = parser.time.decode()
d = parser.date.decode()
rtc.datetime((
# Date format is DDMMYY, hence + 2000!
2000 + int(d[4:6]), int(d[2:4]), int(d[0:2]), 0,
int(t[0:2]), int(t[2:4]), int(t[4:6]), 0
))
rtc_synced = True
# Periodic GC every 500 packets
gc_counter += 1
if gc_counter >= 500:
gc.collect()
gc_counter = 0
time.sleep_ms(10)
```
## API Reference
### Properties
* parser.valid (bool): Fix validity flag (True = fix acquired, False = no fix).
* parser.latitude (float | None): Latitude in degrees (WGS84, range -90.0 to +90.0).
* parser.longitude (float | None): Longitude in degrees (WGS84, range -180.0 to +180.0).
* parser.speed (float | None): Movement speed. Automatically converted to km/h.
* parser.course (float | None): True course (track angle) in degrees relative to geographic north (0.0 - 359.9).
* parser.altitude (float | None): Altitude above mean sea level (geoid) in meters.
* parser.satellites (int): Number of satellites used in the solution.
* parser.time (bytes): UTC time bytes (format HHMMSS).
* parser.date (bytes): UTC date bytes (format DDMMYY).
### State Check Methods
* parser.has_coordinates() -> bool: True if both latitude and longitude are parsed and not None.
* parser.has_navigation() -> bool: True if valid coordinates are available, along with speed and course parameters (full 2D fix).
* parser.has_3d_fix() -> bool: True if altitude above sea level is available in addition to 2D coordinates (full 3D fix from GGA).
### Memory Monitoring (GPSStats Class)
To monitor memory usage, the stats.get_memory_usage() -> float method is used, which returns the amount of occupied kilobytes.
* Under MicroPython, it automatically calls gc.collect() to get the actual heap size.
* Under Windows and Linux, it queries operating system interfaces without using external libraries.
## Project Directory Structure
When transferring the library to a microcontroller, only the light_nmea folder is required. The other scripts are used for testing and benchmarking on a PC.
```console
light-nmea-mp/
|-- LICENSE # GNU GPL v3.0 License
|-- README_RU.md # Project documentation (Russian)
|-- README.md # Project documentation (English)
|
|-- light_nmea/ # Isolated parser package (copy to MCU)
| |-- __init__.py # Python package marker
| |-- nmea0183_parser.py # Parser core
| |-- nmea0183_stats.py # Statistics and RAM measurement module
| +-- nmea0183_stream.py # UART reader with ring buffer
|
|-- Entry points
| |-- main.py # Stress test for 1,000,000 packets (CPython)
| +-- mpy_main.py # Main script for RP2040
|
|-- Tests
| |-- test_leak.py # Memory leak check script
| |-- uart_test.py # UART test with a real GPS module
| +-- run_tests.sh # Automatic bash script for running tests
|
|-- Benchmarks (comparison with competitors)
| |-- benchmark/ # Benchmark folder
| | |-- benchmark_pynmea2.py # Comparison with pynmea2 (CPython)
| | |-- benchmark_pynmeagps.py # Comparison with pynmeagps (CPython)
| | |-- benchmark_micropygps.py # Comparison with micropyGPS (MicroPython/CPython)
| | |-- benchmark_adafruitgps.py # Comparison with adafruit_gps (CPython)
| | |-- bench_utils.py
| | |-- busio.py # Mock module for CPython
| | |-- micropython.py # Mock module for CPython
| | +-- readme.txt # Benchmark description
| +-- nav_gen.py # Test NMEA packet generator
|
+-- Test dependencies
+-- micropyGPS.py # Local copy of micropyGPS for testing
+-- adafruit_gps.py # Local copy of adafruit_gps for testing
```
## NMEAStream Module (nmea0183_stream.py)
The nmea0183_stream module implements non-blocking UART reading with a ring buffer and automatic extraction of complete NMEA packets. It solves the main problem when working with GPS modules on microcontrollers: packet loss due to UART buffer overflow.
### Purpose
* Reading bytes from UART without blocking the main application loop
* Accumulating data in a fixed-size ring buffer
* Extracting complete packets (from $ to \r\n) with built-in CRC validation
* Passing ready packets to the LightNMEA parser
* Cooperative operation with time.sleep_ms() and gc.collect()
## NMEAStream Class (UART Streaming)
NMEAStream(uart, buffer_size=512): Constructor. Accepts a machine.UART object and the ring buffer size in bytes (default is 512).
### Methods
* read_available() -> int: Reads all available bytes from the hardware UART buffer into the ring buffer. Does not block execution. Returns the number of bytes read.
* get_packet() -> bytes | None: Extracts the next complete and valid NMEA packet (with CRC check) from the buffer. Returns bytes or None if the packet is incomplete or corrupted.
* buffer_usage() -> int: Returns the current number of bytes occupied in the ring buffer (from 0 to buffer_size). Useful for overflow monitoring.
* reset() -> None: Force clears the ring buffer and resets the frame search state. Used in case of critical overflow.
## Authorship and License
Copyright 2026 Roman Shevchik
### License
GNU General Public License v3.0 (GPL-3.0).
Free use, modification, and distribution of the code are permitted provided that the source code remains open (copyleft). The license protects users from patent trolls, prohibits the use of the code in closed hardware, and requires preserving notices of original authorship in all modified versions of the files. Any derivative product using this code must be distributed under the same GPL-3.0 license.