Welcome back to my blog! Today, we’re diving into Part 2 of our GNSS evaluation board series. This time, we’ll focus on the firmware side of things. I’ll walk you through how to interface an ESP32-S3 with the module and gather essential information like location, local time, and satellite data. Whether you’re a seasoned coder or just getting started, this guide will help you get the most out of your GNSS module. If you haven’t already, check out Part 1. Let’s get started!
The MAX-M10S module has two interfaces for receiving position information: UART and I2C. The UART interface is the most popular, and almost all GNSS modules have it. As soon as the device is powered on, it starts sending information via the serial port using the NMEA protocol. As satellites are connected and positioning data is calculated, this information becomes available on the UART at a default frequency of 1 Hz.
To start, I connected a USB to serial converter to the module, and this is what I see on my serial terminal.
$GNGSA,A,3,21,10,,,,,,,,,,,8.66,4.98,7.08,1*01
$GNGSA,A,3,26,,,,,,,,,,,,8.66,4.98,7.08,3*05
$GNGSA,A,3,20,,,,,,,,,,,,8.66,4.98,7.08,4*04
$GNGSA,A,3,,,,,,,,,,,,,8.66,4.98,7.08,5*07
$GPGSV,1,1,03,10,48,053,17,21,57,293,10,27,51,141,05,1*52
$GPGSV,2,1,07,02,40,283,,03,04,218,,08,83,227,,14,11,323,,0*61
$GPGSV,2,2,07,14,11,323,,16,10,185,,23,13,046,,0*5E
$GAGSV,1,1,02,14,26,134,13,26,37,071,08,7*7A
$GAGSV,2,1,08,07,27,066,,08,02,022,,10,08,269,,12,30,275,,0*73
$GAGSV,2,2,08,12,30,275,,19,05,164,,24,30,303,,31,40,241,,0*76
$GBGSV,1,1,01,20,28,047,17,1*4A
$GQGSV,1,1,00,0*64
$GNGLL,####.####,N,####.#####,E,131737.00,A,A*7C
$GNRMC,131738.00,A,####.####,N,####.####,E,1.399,,200524,,,A,V*1E
$GNVTG,,T,,M,1.399,N,2.591,K,A*30
$GNGGA,131738.00,####.####,N,####.####,E,1,05,2.29,251.1,M,47.2,M,,*41
The way the data is presented is described by a standard called NMEA. Here’s a link for reference. The encoding is in ASCII, making it clearly readable by humans. Each row begins with a code that identifies the information contained in the line and ends with a checksum. All fields are comma-separated.
Let’s look at this screenshot from a random NMEA reference manual from google:
The GGA — Global Positioning System Fixed Data line contains information about time, position, and fix for the receiver. It also tells us which constellation is being used for positioning. This module supports GPS, GLONASS, Galileo, and BeiDou. Based on the constellation, we will see prefixes like GNGGA for multi-constellation, GPGGA for GPS, GLGGA for GLONASS, BDGGA for BeiDou, and GAGGA for Galileo. Besides location and time, the HDOP field indicates positioning accuracy: less than 2 is excellent, up to 5 is good, but it depends on the application.
Reading information with i2c and an ESP32-S3
With the I2C protocol, you can receive information from the uBlox module in two ways: using the NMEA standard or the proprietary UBX standard. Here, I’ll show the NMEA method, which also simplifies porting from I2C to UART since the data packets are identical.
For development with the ESP32-S3, I use ESP-IDF combined with Eclipse (Link here).
First, I created a new ESP-IDF blank project. Since it runs on FreeRTOS, one option is to create a configuration function to initialize the I2C peripheral and a task to periodically query the GNSS module, read information, and send it to the terminal.
let’s start with the i2c configuration:
void i2c_config(void){
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = SDA_PIN_W,
.scl_io_num = SCL_PIN_W,
.sda_pullup_en = GPIO_PULLUP_DISABLE,
.scl_pullup_en = GPIO_PULLUP_DISABLE,
.master.clk_speed = 400000,
};
i2c_param_config(I2C_NUM_0, &conf);
ESP_ERROR_CHECK(i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0));
}
Now that we have I2C configured to work with the MAX-M10S module, we want to read data from it. To do this, we need to understand how data transmission works via the I2C interface, which is clearly explained in the IntegrationManual. In short, there are three registers available for reading: two that contain the number of bytes to read (0xFD and 0xFE) and one that buffers the message stream (0xFF). A simple way to read info from the module is to periodically query the number of bytes available and read until the buffer is exhausted.
In the code below, I request 2 bytes starting at address 0xFD of the internal register, then store the number of available bytes to read in a variable.
tx_data[0] = 0xFD;
i2c_master_write_read_device(I2C_NUM_0, I2C-ADD, tx_data, 1, rx_data, 2, 500/portTICK_PERIOD_MS);
num_to_read = (uint32_t)rx_data[1] | ((uint32_t)rx_data[0] << 8);
What I want to do at this point is read the data stream from the module and analyze it line by line to obtain the information that interests me.
I propose an example to read GGA packet and extract the UTC time from the GNSS module. In the video I show you the evaluation board connected with a board with an LED matrix that displays the time.
Below is the code I used inside the while(1) structure of my task:
As long as num_to_read
is greater than 0, I read from the I2C device one byte at a time and append it to my gps_string_in[]
string. If I receive a new line character, it means I have a line available to analyze. Using the strstr()
function, I check whether the GGA identifier is present. If it is, I use the parseNMEA_GGA() parsing function to update my database. I then display the time on the LED matrix with specific functions for my device. Below in the post, you’ll find a version that prints the parsed data to the terminal instead.
...
while(num_to_read > 0){
i2c_master_write_read_device(I2C_NUM_0, I2C_ADD, tx_data, 1, rx_data, 3,
500/portTICK_PERIOD_MS);
gps_string_in[string_idx] = (char)rx_data[2];
if(rx_data[2] == '\n'){
gps_string_in[string_idx++] = '\0';
if(strstr(gps_string_in, "GNGGA" ) != NULL){
parseNMEA_GGA(gps_string_in, &gnss_data);
VirtualMatrix_Clear();
VirtualMatrix_WriteInt(gnss_data.utc_hour, Font_6x8,rgb_c(0,6,4));
VirtualMatrix_WriteChar(':', Font_6x8,rgb_c(0,6,4));
VirtualMatrix_WriteInt(gnss_data.utc_minute, Font_6x8,rgb_c(0,6,4));
}
string_idx = 0;
}else{
string_idx ++;
}
num_to_read = (uint32_t)rx_data[1] | ((uint32_t)rx_data[0] << 8);
}
vTaskDelay(1000/portTICK_PERIOD_MS);
...
Parsing NMEA data format into variables
There are many libraries available for NMEA parsing, but here I’ll show you a simple version from scratch to understand how it works. This method can be extended to all the information provided by the uBlox module (more generally from all GPS modules with NMEA data format output), allowing us to extract, display, and save the data, for example, on a memory card.
Suppose we want to parse information from the GGA data frame. We know from the previous table that the data is packed into the frame, so first, I create a data structure:
typedef struct {
int utc_hour;
int utc_minute;
float utc_second;
float latitude;
char lat_N_S;
float longitude;
char lon_E_W;
int fix_quality;
int num_satellites;
float horizontal_dilution;
float altitude;
char alt_unit;
float height_geoid;
char height_geoid_unit;
} NMEAGGA_Data_s;
Since we know the structure of the string from the GNSS module, a simple way to extract the data fields from the string is using sscanf() function.
void parseNMEA_GGA (const char* nmeaGgaString, NMEAData* GGA_data){
sscanf(nmeaGgaString,"$GNGGA,%2d%2d%f,%f,%c,%f,%c,%d,%d,%f,%f,%c,%f,%c",
&GGA_data->utc_hour, &GGA_data->utc_minute, &GGA_data->utc_second,
&GGA_data->latitude, &GGA_data->lat_N_S,
&GGA_data->longitude, &GGA_data->lon_E_W,
&GGA_data->fix_quality, &GGA_data->num_satellites, &GGA_data->horizontal_dilution,
&GGA_data->altitude, &GGA_data->alt_unit, &GGA_data->height_geoid,
&GGA_data->height_geoid_unit);
//Adjust the latitude and longitude to decimal representation
GGA_data->latitude = (int)(GGA_data->latitude / 100) +fmod(GGA_data->latitude, 100) /60.0;
if(GGA_data->lat_N_S == 'S') GGA_data->latitude = -GGA_data->latitude;
GGA_data->longitude = (int)(GGA_data->longitude / 100) +fmod(GGA_data->longitude, 100) /60.0;
if(GGA_data->lon_E_W== 'W') GGA_data->longitude = -GGA_data->longitude;
}
Checksum validation
To make it more robust, we can add a checksum control function to validate the received data and ensure it is not corrupted.
To do this, I implemented two functions: one to calculate the checksum value and another to compare and validate it.
// Function to calculate checksum
char calculateChecksum(const char* inString) {
char checksumchecksum = 0;
//Discard the first character '$' - Counts up to the character '*'
for (const char* p = inString+ 1; *p != '*' && *p != '\0'; p++) {
checksum ^= *p; //NMEA use XOR for checksum calculation
}
return checksum;
}
// Function to validate the checksum
bool validateChecksum(const char* nmeaString) {
// Find the position of the '*' character
const char* checksumPos= strchr(nmeaString, '*');
if (checksumPos == NULL || checksumPos- nmeaString< 3) {
return false; // No '*' found or invalid string length
}
// Calculate the checksum
char calculatedChecksum= calculateChecksum(nmeaString);
// Extract the checksum from the string
unsigned int receivedChecksum;
sscanf(checksumPos + 1, "%2x", &receivedChecksum);
// Compare the calculated checksum with the received checksum
return calculatedChecksum == receivedChecksum;
}
Final Code
Below is the output of the test code I wrote and analyzed in this article. The information contained in the GNGGA data frames from the GNSS module is displayed on the terminal after being parsed.
UTC: 13:7:6
Latitude: 45.##### Longitude: 7.##### Altitude: 202.60 m
Fix: 1 N of satellites: 3
Horizontal dilution: 6.75 Height geoid: 47.20 M
Of course, this is just a starting point. You can add more features, checks, and error controls. It will be easy to add further parsing functions to store all the information you need from the navigation module.
Here, I leave the complete main.c
file, and for the entire firmware, you can check out my dedicated GitHub repository.
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "driver/i2c.h"
#include "esp_log.h"
//Define GPIO for i2c peripheral
#define SDA_PIN_W GPIO_NUM_16
#define SCL_PIN_W GPIO_NUM_13
//max_m10s i2c device address
#define I2C_ADD 0x42
typedef struct {
int utc_hour;
int utc_minute;
float utc_second;
float latitude;
char lat_N_S;
float longitude;
char lon_E_W;
int fix_quality;
int num_satellites;
float horizontal_dilution;
float altitude;
char alt_unit;
float height_geoid;
char height_geoid_unit;
} NMEAGGA_Data_s;
//Define NMEA GGA data structure
NMEAGGA_Data_s GGAdata;
void parseNMEA_GGA (const char* nmeaGgaString, NMEAGGA_Data_s* GGA_data){
sscanf(nmeaGgaString,"$GNGGA,%2d%2d%f,%f,%c,%f,%c,%d,%d,%f,%f,%c,%f,%c",
&GGA_data->utc_hour, &GGA_data->utc_minute, &GGA_data->utc_second,
&GGA_data->latitude, &GGA_data->lat_N_S,
&GGA_data->longitude, &GGA_data->lon_E_W,
&GGA_data->fix_quality, &GGA_data->num_satellites, &GGA_data->horizontal_dilution,
&GGA_data->altitude, &GGA_data->alt_unit, &GGA_data->height_geoid,
&GGA_data->height_geoid_unit);
//Adjust the latitude and longitude to decimal representation
GGA_data->latitude = (int)(GGA_data->latitude / 100) +fmod(GGA_data->latitude, 100) /60.0;
if(GGA_data->lat_N_S == 'S') GGA_data->latitude = -GGA_data->latitude;
GGA_data->longitude = (int)(GGA_data->longitude / 100) +fmod(GGA_data->longitude, 100) /60.0;
if(GGA_data->lon_E_W== 'W') GGA_data->longitude = -GGA_data->longitude;
}
//Print on terminal the GGA data
void printGGA_data(NMEAGGA_Data_s GGA_data){
printf("UTC: %d:%d:%.0f\n",GGA_data.utc_hour, GGA_data.utc_minute, GGA_data.utc_second);
printf("Latitude: %f Longitude: %f Altitude: %.2f m\n",GGA_data.latitude, GGA_data.longitude, GGA_data.altitude);
printf("Fix: %d N of satellites: %d\n",GGA_data.fix_quality, GGA_data.num_satellites);
printf("Horizontal dilution: %.2f Height geoid: %.2f %c\n\n", GGA_data.horizontal_dilution, GGA_data.height_geoid, GGA_data.height_geoid_unit);
}
char calculateChecksum(const char* inString) {
char checksumchecksum = 0;
//Discard the first character '$' - Counts up to the character '*'
for (const char* p = inString+ 1; *p != '*' && *p != '\0'; p++) {
checksum ^= *p; //NMEA use XOR for checksum calculation
}
return checksum;
}
// Function to validate the checksum
bool validateChecksum(const char* nmeaString) {
// Find the position of the '*' character
const char* checksumPos= strchr(nmeaString, '*');
if (checksumPos == NULL || checksumPos- nmeaString< 3) {
return false; // No '*' found or invalid string length
}
// Calculate the checksum
char calculatedChecksum= calculateChecksum(nmeaString);
// Extract the checksum from the string
unsigned int receivedChecksum;
sscanf(checksumPos + 1, "%2x", &receivedChecksum);
// Compare the calculated checksum with the received checksum
return calculatedChecksum == receivedChecksum;
}
void i2c_config(void){
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = SDA_PIN_W,
.scl_io_num = SCL_PIN_W,
.sda_pullup_en = GPIO_PULLUP_DISABLE,
.scl_pullup_en = GPIO_PULLUP_DISABLE,
.master.clk_speed = 400000,
};
i2c_param_config(I2C_NUM_0, &conf);
ESP_ERROR_CHECK(i2c_driver_install(I2C_NUM_0, I2C_MODE_MASTER, 0, 0, 0));
}
static void GNSS_manager_task(void *arg){
uint8_t rx_data[16];
uint8_t tx_data[16];
char gps_string_in[250]; // 250 is just a reasonably big number for a NMEA sentence
int string_idx = 0;
uint32_t num_to_read = 0;
tx_data[0] = 0xFD;
while(1){
i2c_master_write_read_device(I2C_NUM_0, I2C_ADD, tx_data, 1, rx_data, 3,
500/portTICK_PERIOD_MS);
num_to_read = (uint32_t)rx_data[1] | ((uint32_t)rx_data[0] << 8);
while(num_to_read > 0){
i2c_master_write_read_device(I2C_NUM_0, I2C_ADD , tx_data, 1, rx_data, 3,
500/portTICK_PERIOD_MS);
gps_string_in[string_idx] = (char)rx_data[2];
if(rx_data[2] == '\n'){
gps_string_in[string_idx++] = '\0';
if(strstr(gps_string_in, "GNGGA" ) != NULL){
if(validateChecksum(gps_string_in) == true){
parseNMEA_GGA(gps_string_in, &GGAdata);
printGGA_data(GGAdata);
}else{
ESP_LOGI("NMEA", "Input string Checksum error!");
}
}
string_idx = 0;
}else{
if(string_idx >= 250){ //To be sure we don't overflow the array
string_idx = 0;
}else{
string_idx = ++;
}
}
num_to_read = (uint32_t)rx_data[1] | ((uint32_t)rx_data[0] << 8);
}
vTaskDelay(1000/portTICK_PERIOD_MS);
}
}
void app_main (void)
{
i2c_config();
xTaskCreatePinnedToCore(GNSS_manager_task, "GNSS_manager", 4*1024, NULL, 5, NULL,1);
while(1) {
vTaskDelay(1000/portTICK_PERIOD_MS);
}
}
Thank you for reading and supporting my blog! If you enjoyed this content and would like to help me continue creating more, please consider leaving a donation. Your generosity will help me cover the costs of materials and tools for future projects. Any amount is greatly appreciated! And remember to follow me on Instagram for more updates and behind-the-scenes content.