diff --git a/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/main.cpp b/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/main.cpp index b8c9392..31057c5 100644 --- a/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/main.cpp +++ b/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/main.cpp @@ -46,7 +46,9 @@ extern "C" { #include #include "stm32_spi.h" #include "stm32_delay.h" -#include "TinyGPSPlus.h" +extern "C" { +#include "um982_gps.h" +} extern "C" { #include "GY_85_HAL.h" } @@ -121,8 +123,8 @@ UART_HandleTypeDef huart5; UART_HandleTypeDef huart3; /* USER CODE BEGIN PV */ -// The TinyGPSPlus object -TinyGPSPlus gps; +// UM982 dual-antenna GPS receiver +UM982_GPS_t um982; // Global data structures GPS_Data_t current_gps_data = {0}; @@ -700,16 +702,13 @@ SystemError_t checkSystemHealth(void) { last_bmp_check = HAL_GetTick(); } - // 6. Check GPS Communication - static uint32_t last_gps_fix = 0; - if (gps.location.isUpdated()) { - last_gps_fix = HAL_GetTick(); - } - if (HAL_GetTick() - last_gps_fix > 30000) { - current_error = ERROR_GPS_COMM; - DIAG_WARN("SYS", "Health check: GPS no fix for >30s"); - return current_error; - } + // 6. Check GPS Communication (30s grace period from boot / last valid fix) + uint32_t gps_fix_age = um982_position_age(&um982); + if (gps_fix_age > 30000) { + current_error = ERROR_GPS_COMM; + DIAG_WARN("SYS", "Health check: GPS no fix for >30s (age=%lu ms)", (unsigned long)gps_fix_age); + return current_error; + } // 7. Check RF Power Amplifier Current if (PowerAmplifier) { @@ -1034,20 +1033,7 @@ static inline void delay_ms(uint32_t ms) { HAL_Delay(ms); } -// This custom version of delay() ensures that the gps object -// is being "fed". -static void smartDelay(unsigned long ms) -{ - uint32_t start = HAL_GetTick(); - uint8_t ch; - - do { - // While there is new data available in UART (non-blocking) - if (HAL_UART_Receive(&huart5, &ch, 1, 0) == HAL_OK) { - gps.encode(ch); // Pass received byte to TinyGPS++ equivalent parser - } - } while (HAL_GetTick() - start < ms); -} +// smartDelay removed -- replaced by non-blocking um982_process() in main loop // Small helper to enable DWT cycle counter for microdelay static void DWT_Init(void) @@ -1580,6 +1566,12 @@ int main(void) Yaw_Sensor = (180*atan2(magRawY,magRawX)/PI) - Mag_Declination; if(Yaw_Sensor<0)Yaw_Sensor+=360; + + // Override magnetometer heading with UM982 dual-antenna heading when available + if (um982_is_heading_valid(&um982)) { + Yaw_Sensor = um982_get_heading(&um982); + } + RxEst_0 = RxEst_1; RyEst_0 = RyEst_1; RzEst_0 = RzEst_1; @@ -1755,14 +1747,38 @@ int main(void) ////////////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////GPS///////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////// - for(int i=0; i<10;i++){ - smartDelay(1000); - RADAR_Longitude = gps.location.lng(); - RADAR_Latitude = gps.location.lat(); + DIAG_SECTION("GPS INIT (UM982)"); + DIAG("GPS", "Initializing UM982 on UART5 @ 115200 (baseline=50cm, tol=3cm)"); + if (!um982_init(&um982, &huart5, 50.0f, 3.0f)) { + DIAG_WARN("GPS", "UM982 init: no VERSIONA response -- module may need more time"); + // Not fatal: module may still start sending NMEA data after boot + } else { + DIAG("GPS", "UM982 init OK -- VERSIONA received"); } - //move Stepper to position 1 = 0° - HAL_GPIO_WritePin(STEPPER_CW_P_GPIO_Port, STEPPER_CW_P_Pin, GPIO_PIN_RESET);//Set stepper motor spinning direction to CCW + // Collect GPS data for a few seconds (non-blocking pump) + DIAG("GPS", "Pumping GPS for 5 seconds to acquire initial fix..."); + { + uint32_t gps_start = HAL_GetTick(); + while (HAL_GetTick() - gps_start < 5000) { + um982_process(&um982); + HAL_Delay(10); + } + } + RADAR_Longitude = um982_get_longitude(&um982); + RADAR_Latitude = um982_get_latitude(&um982); + DIAG("GPS", "Initial position: lat=%.6f lon=%.6f fix=%d sats=%d", + RADAR_Latitude, RADAR_Longitude, + um982_get_fix_quality(&um982), um982_get_num_sats(&um982)); + + // Re-apply heading after GPS init so the north-alignment stepper move uses + // UM982 dual-antenna heading when available. + if (um982_is_heading_valid(&um982)) { + Yaw_Sensor = um982_get_heading(&um982); + } + + //move Stepper to position 1 = 0° + HAL_GPIO_WritePin(STEPPER_CW_P_GPIO_Port, STEPPER_CW_P_Pin, GPIO_PIN_RESET);//Set stepper motor spinning direction to CCW //Point Stepper to North for(int i= 0;i<(int)(Yaw_Sensor*Stepper_steps/360);i++){ HAL_GPIO_WritePin(STEPPER_CLK_P_GPIO_Port, STEPPER_CLK_P_Pin, GPIO_PIN_SET); @@ -2031,6 +2047,18 @@ int main(void) } DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared"); } + + ////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////// GPS: Non-blocking NMEA processing //////////////////////// + ////////////////////////////////////////////////////////////////////////////////////// + um982_process(&um982); + + // Update position globals continuously + if (um982_is_position_valid(&um982)) { + RADAR_Latitude = um982_get_latitude(&um982); + RADAR_Longitude = um982_get_longitude(&um982); + } + ////////////////////////////////////////////////////////////////////////////////////// ////////////////////////// Monitor ADF4382A lock status periodically////////////////// ////////////////////////////////////////////////////////////////////////////////////// @@ -2581,7 +2609,7 @@ static void MX_UART5_Init(void) /* USER CODE END UART5_Init 1 */ huart5.Instance = UART5; - huart5.Init.BaudRate = 9600; + huart5.Init.BaudRate = 115200; huart5.Init.WordLength = UART_WORDLENGTH_8B; huart5.Init.StopBits = UART_STOPBITS_1; huart5.Init.Parity = UART_PARITY_NONE; diff --git a/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/um982_gps.c b/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/um982_gps.c new file mode 100644 index 0000000..fd93027 --- /dev/null +++ b/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/um982_gps.c @@ -0,0 +1,586 @@ +/******************************************************************************* + * um982_gps.c -- UM982 dual-antenna GNSS receiver driver implementation + * + * See um982_gps.h for API documentation. + * Command syntax per Unicore N4 Command Reference EN R1.14. + ******************************************************************************/ +#include "um982_gps.h" +#include +#include +#include + +/* ========================= Internal helpers ========================== */ + +/** + * Advance to the next comma-delimited field in an NMEA sentence. + * Returns pointer to the start of the next field (after the comma), + * or NULL if no more commas found before end-of-string or '*'. + * + * Handles empty fields (consecutive commas) correctly by returning + * a pointer to the character after the comma (which may be another comma). + */ +static const char *next_field(const char *p) +{ + if (p == NULL) return NULL; + while (*p != '\0' && *p != ',' && *p != '*') { + p++; + } + if (*p == ',') return p + 1; + return NULL; /* End of sentence or checksum marker */ +} + +/** + * Get the length of the current field (up to next comma, '*', or '\0'). + */ +static int field_len(const char *p) +{ + int len = 0; + if (p == NULL) return 0; + while (p[len] != '\0' && p[len] != ',' && p[len] != '*') { + len++; + } + return len; +} + +/** + * Check if a field is non-empty (has at least one character before delimiter). + */ +static bool field_valid(const char *p) +{ + return p != NULL && field_len(p) > 0; +} + +/** + * Parse a floating-point value from a field, returning 0.0 if empty. + */ +static double field_to_double(const char *p) +{ + if (!field_valid(p)) return 0.0; + return strtod(p, NULL); +} + +static float field_to_float(const char *p) +{ + return (float)field_to_double(p); +} + +static int field_to_int(const char *p) +{ + if (!field_valid(p)) return 0; + return (int)strtol(p, NULL, 10); +} + +/* ========================= Checksum ================================== */ + +bool um982_verify_checksum(const char *sentence) +{ + if (sentence == NULL || sentence[0] != '$') return false; + + const char *p = sentence + 1; /* Skip '$' */ + uint8_t computed = 0; + + while (*p != '\0' && *p != '*') { + computed ^= (uint8_t)*p; + p++; + } + + if (*p != '*') return false; /* No checksum marker found */ + p++; /* Skip '*' */ + + /* Parse 2-char hex checksum */ + if (p[0] == '\0' || p[1] == '\0') return false; + + char hex_str[3] = { p[0], p[1], '\0' }; + unsigned long expected = strtoul(hex_str, NULL, 16); + + return computed == (uint8_t)expected; +} + +/* ========================= Coordinate parsing ======================== */ + +double um982_parse_coord(const char *field, char hemisphere) +{ + if (field == NULL || field[0] == '\0') return NAN; + + /* Find the decimal point to determine degree digit count. + * Latitude: ddmm.mmmm (dot at index 4, degrees = 2) + * Longitude: dddmm.mmmm (dot at index 5, degrees = 3) + * General: degree_digits = dot_position - 2 + */ + const char *dot = strchr(field, '.'); + if (dot == NULL) return NAN; + + int dot_pos = (int)(dot - field); + int deg_digits = dot_pos - 2; + + if (deg_digits < 1 || deg_digits > 3) return NAN; + + /* Extract degree portion */ + double degrees = 0.0; + for (int i = 0; i < deg_digits; i++) { + if (field[i] < '0' || field[i] > '9') return NAN; + degrees = degrees * 10.0 + (field[i] - '0'); + } + + /* Extract minutes portion (everything from deg_digits onward) */ + double minutes = strtod(field + deg_digits, NULL); + if (minutes < 0.0 || minutes >= 60.0) return NAN; + + double result = degrees + minutes / 60.0; + + /* Apply hemisphere sign */ + if (hemisphere == 'S' || hemisphere == 'W') { + result = -result; + } + + return result; +} + +/* ========================= Sentence parsers ========================== */ + +/** + * Identify the NMEA sentence type by skipping the 2-char talker ID + * and comparing the 3-letter formatter. + * + * "$GNGGA,..." -> talker="GN", formatter="GGA" + * "$GPTHS,..." -> talker="GP", formatter="THS" + * + * Returns pointer to the formatter (3 chars at sentence+3), or NULL + * if sentence is too short. + */ +static const char *get_formatter(const char *sentence) +{ + /* sentence starts with '$', followed by 2-char talker + 3-char formatter */ + if (sentence == NULL || strlen(sentence) < 6) return NULL; + return sentence + 3; /* Skip "$XX" -> points to formatter */ +} + +/** + * Parse GGA sentence — position and fix quality. + * + * Format: $--GGA,time,lat,N/S,lon,E/W,quality,numSat,hdop,alt,M,geoidSep,M,dgpsAge,refID*XX + * field: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 + */ +static void parse_gga(UM982_GPS_t *gps, const char *sentence) +{ + /* Skip to first field (after "$XXGGA,") */ + const char *f = strchr(sentence, ','); + if (f == NULL) return; + f++; /* f -> field 1 (time) */ + + /* Field 1: UTC time — skip for now */ + const char *f2 = next_field(f); /* lat */ + const char *f3 = next_field(f2); /* N/S */ + const char *f4 = next_field(f3); /* lon */ + const char *f5 = next_field(f4); /* E/W */ + const char *f6 = next_field(f5); /* quality */ + const char *f7 = next_field(f6); /* numSat */ + const char *f8 = next_field(f7); /* hdop */ + const char *f9 = next_field(f8); /* altitude */ + const char *f10 = next_field(f9); /* M */ + const char *f11 = next_field(f10); /* geoid sep */ + + uint32_t now = HAL_GetTick(); + + /* Parse fix quality first — if 0, position is meaningless */ + gps->fix_quality = (uint8_t)field_to_int(f6); + + /* Parse coordinates */ + if (field_valid(f2) && field_valid(f3)) { + char hem = field_valid(f3) ? *f3 : 'N'; + double lat = um982_parse_coord(f2, hem); + if (!isnan(lat)) gps->latitude = lat; + } + + if (field_valid(f4) && field_valid(f5)) { + char hem = field_valid(f5) ? *f5 : 'E'; + double lon = um982_parse_coord(f4, hem); + if (!isnan(lon)) gps->longitude = lon; + } + + /* Number of satellites */ + gps->num_satellites = (uint8_t)field_to_int(f7); + + /* HDOP */ + if (field_valid(f8)) { + gps->hdop = field_to_float(f8); + } + + /* Altitude */ + if (field_valid(f9)) { + gps->altitude = field_to_float(f9); + } + + /* Geoid separation */ + if (field_valid(f11)) { + gps->geoid_sep = field_to_float(f11); + } + + gps->last_gga_tick = now; + if (gps->fix_quality != UM982_FIX_NONE) { + gps->last_fix_tick = now; + } +} + +/** + * Parse RMC sentence — recommended minimum (position, speed, date). + * + * Format: $--RMC,time,status,lat,N/S,lon,E/W,speed,course,date,magVar,E/W,mode*XX + * field: 1 2 3 4 5 6 7 8 9 10 11 12 + */ +static void parse_rmc(UM982_GPS_t *gps, const char *sentence) +{ + const char *f = strchr(sentence, ','); + if (f == NULL) return; + f++; /* f -> field 1 (time) */ + + const char *f2 = next_field(f); /* status */ + const char *f3 = next_field(f2); /* lat */ + const char *f4 = next_field(f3); /* N/S */ + const char *f5 = next_field(f4); /* lon */ + const char *f6 = next_field(f5); /* E/W */ + const char *f7 = next_field(f6); /* speed knots */ + const char *f8 = next_field(f7); /* course true */ + + /* Status */ + if (field_valid(f2)) { + gps->rmc_status = *f2; + } + + /* Position (only if status = A for valid) */ + if (field_valid(f2) && *f2 == 'A') { + if (field_valid(f3) && field_valid(f4)) { + double lat = um982_parse_coord(f3, *f4); + if (!isnan(lat)) gps->latitude = lat; + } + if (field_valid(f5) && field_valid(f6)) { + double lon = um982_parse_coord(f5, *f6); + if (!isnan(lon)) gps->longitude = lon; + } + } + + /* Speed (knots) */ + if (field_valid(f7)) { + gps->speed_knots = field_to_float(f7); + } + + /* Course */ + if (field_valid(f8)) { + gps->course_true = field_to_float(f8); + } + + gps->last_rmc_tick = HAL_GetTick(); +} + +/** + * Parse THS sentence — true heading and status (UM982-specific). + * + * Format: $--THS,heading,mode*XX + * field: 1 2 + */ +static void parse_ths(UM982_GPS_t *gps, const char *sentence) +{ + const char *f = strchr(sentence, ','); + if (f == NULL) return; + f++; /* f -> field 1 (heading) */ + + const char *f2 = next_field(f); /* mode */ + + /* Heading */ + if (field_valid(f)) { + gps->heading = field_to_float(f); + } else { + gps->heading = NAN; + } + + /* Mode */ + if (field_valid(f2)) { + gps->heading_mode = *f2; + } else { + gps->heading_mode = 'V'; /* Not valid if missing */ + } + + gps->last_ths_tick = HAL_GetTick(); +} + +/** + * Parse VTG sentence — course and speed over ground. + * + * Format: $--VTG,courseTrue,T,courseMag,M,speedKnots,N,speedKmh,K,mode*XX + * field: 1 2 3 4 5 6 7 8 9 + */ +static void parse_vtg(UM982_GPS_t *gps, const char *sentence) +{ + const char *f = strchr(sentence, ','); + if (f == NULL) return; + f++; /* f -> field 1 (course true) */ + + const char *f2 = next_field(f); /* T */ + const char *f3 = next_field(f2); /* course mag */ + const char *f4 = next_field(f3); /* M */ + const char *f5 = next_field(f4); /* speed knots */ + const char *f6 = next_field(f5); /* N */ + const char *f7 = next_field(f6); /* speed km/h */ + + /* Course true */ + if (field_valid(f)) { + gps->course_true = field_to_float(f); + } + + /* Speed knots */ + if (field_valid(f5)) { + gps->speed_knots = field_to_float(f5); + } + + /* Speed km/h */ + if (field_valid(f7)) { + gps->speed_kmh = field_to_float(f7); + } + + gps->last_vtg_tick = HAL_GetTick(); +} + +/* ========================= Sentence dispatch ========================= */ + +void um982_parse_sentence(UM982_GPS_t *gps, const char *sentence) +{ + if (sentence == NULL || sentence[0] != '$') return; + + /* Verify checksum before parsing */ + if (!um982_verify_checksum(sentence)) return; + + /* Check for VERSIONA response (starts with '#', not '$') -- handled separately */ + /* Actually VERSIONA starts with '#', so it won't enter here. We check in feed(). */ + + /* Identify sentence type */ + const char *fmt = get_formatter(sentence); + if (fmt == NULL) return; + + if (strncmp(fmt, "GGA", 3) == 0) { + gps->initialized = true; + parse_gga(gps, sentence); + } else if (strncmp(fmt, "RMC", 3) == 0) { + gps->initialized = true; + parse_rmc(gps, sentence); + } else if (strncmp(fmt, "THS", 3) == 0) { + gps->initialized = true; + parse_ths(gps, sentence); + } else if (strncmp(fmt, "VTG", 3) == 0) { + gps->initialized = true; + parse_vtg(gps, sentence); + } + /* Other sentences silently ignored */ +} + +/* ========================= Command interface ========================= */ + +bool um982_send_command(UM982_GPS_t *gps, const char *cmd) +{ + if (gps == NULL || gps->huart == NULL || cmd == NULL) return false; + + /* Build command with \r\n termination */ + char buf[UM982_CMD_BUF_SIZE]; + int len = snprintf(buf, sizeof(buf), "%s\r\n", cmd); + if (len <= 0 || (size_t)len >= sizeof(buf)) return false; + + HAL_StatusTypeDef status = HAL_UART_Transmit( + gps->huart, (const uint8_t *)buf, (uint16_t)len, 100); + + return status == HAL_OK; +} + +/* ========================= Line assembly + feed ====================== */ + +/** + * Process a completed line from the line buffer. + */ +static void process_line(UM982_GPS_t *gps, const char *line) +{ + if (line == NULL || line[0] == '\0') return; + + /* NMEA sentence starts with '$' */ + if (line[0] == '$') { + um982_parse_sentence(gps, line); + return; + } + + /* Unicore proprietary response starts with '#' (e.g. #VERSIONA) */ + if (line[0] == '#') { + if (strncmp(line + 1, "VERSIONA", 8) == 0) { + gps->version_received = true; + gps->initialized = true; + } + return; + } +} + +void um982_feed(UM982_GPS_t *gps, const uint8_t *data, uint16_t len) +{ + if (gps == NULL || data == NULL || len == 0) return; + + for (uint16_t i = 0; i < len; i++) { + uint8_t ch = data[i]; + + /* End of line: process if we have content */ + if (ch == '\n' || ch == '\r') { + if (gps->line_len > 0 && !gps->line_overflow) { + gps->line_buf[gps->line_len] = '\0'; + process_line(gps, gps->line_buf); + } + gps->line_len = 0; + gps->line_overflow = false; + continue; + } + + /* Accumulate into line buffer */ + if (gps->line_len < UM982_LINE_BUF_SIZE - 1) { + gps->line_buf[gps->line_len++] = (char)ch; + } else { + gps->line_overflow = true; + } + } +} + +/* ========================= UART process (production) ================= */ + +void um982_process(UM982_GPS_t *gps) +{ + if (gps == NULL || gps->huart == NULL) return; + + /* Read all available bytes from the UART one at a time. + * At 115200 baud (~11.5 KB/s) and a typical main-loop period of ~10 ms, + * we expect ~115 bytes per call — negligible overhead on a 168 MHz STM32. + * + * Note: batch reads (HAL_UART_Receive with Size > 1 and Timeout = 0) are + * NOT safe here because the HAL consumes bytes from the data register as + * it reads them. If fewer than Size bytes are available, the consumed + * bytes are lost (HAL_TIMEOUT is returned and the caller has no way to + * know how many bytes were actually placed into the buffer). */ + uint8_t ch; + while (HAL_UART_Receive(gps->huart, &ch, 1, 0) == HAL_OK) { + um982_feed(gps, &ch, 1); + } +} + +/* ========================= Validity checks =========================== */ + +bool um982_is_heading_valid(const UM982_GPS_t *gps) +{ + if (gps == NULL) return false; + if (isnan(gps->heading)) return false; + + /* Mode must be Autonomous or Differential */ + if (gps->heading_mode != 'A' && gps->heading_mode != 'D') return false; + + /* Check age */ + uint32_t age = HAL_GetTick() - gps->last_ths_tick; + return age < UM982_HEADING_TIMEOUT_MS; +} + +bool um982_is_position_valid(const UM982_GPS_t *gps) +{ + if (gps == NULL) return false; + if (gps->fix_quality == UM982_FIX_NONE) return false; + + /* Check age of the last valid fix */ + uint32_t age = HAL_GetTick() - gps->last_fix_tick; + return age < UM982_POSITION_TIMEOUT_MS; +} + +uint32_t um982_heading_age(const UM982_GPS_t *gps) +{ + if (gps == NULL) return UINT32_MAX; + return HAL_GetTick() - gps->last_ths_tick; +} + +uint32_t um982_position_age(const UM982_GPS_t *gps) +{ + if (gps == NULL) return UINT32_MAX; + return HAL_GetTick() - gps->last_fix_tick; +} + +/* ========================= Initialization ============================ */ + +bool um982_init(UM982_GPS_t *gps, UART_HandleTypeDef *huart, + float baseline_cm, float tolerance_cm) +{ + if (gps == NULL || huart == NULL) return false; + + /* Zero-init entire structure */ + memset(gps, 0, sizeof(UM982_GPS_t)); + + gps->huart = huart; + gps->heading = NAN; + gps->heading_mode = 'V'; + gps->rmc_status = 'V'; + gps->speed_knots = 0.0f; + + /* Seed fix timestamp so position_age() returns ~0 instead of uptime. + * Gives the module a full 30s grace window from init to acquire a fix + * before the health check fires ERROR_GPS_COMM. */ + gps->last_fix_tick = HAL_GetTick(); + gps->speed_kmh = 0.0f; + gps->course_true = 0.0f; + + /* Step 1: Stop all current output to get a clean slate */ + um982_send_command(gps, "UNLOG"); + HAL_Delay(100); + + /* Step 2: Configure heading mode + * Per N4 Reference 4.18: CONFIG HEADING FIXLENGTH (default mode) + * "The distance between ANT1 and ANT2 is fixed. They move synchronously." */ + um982_send_command(gps, "CONFIG HEADING FIXLENGTH"); + HAL_Delay(50); + + /* Step 3: Set baseline length if specified + * Per N4 Reference: CONFIG HEADING LENGTH + * "parameter1: Fixed baseline length (cm), valid range >= 0" + * "parameter2: Tolerable error margin (cm), valid range > 0" */ + if (baseline_cm > 0.0f) { + char cmd[64]; + if (tolerance_cm > 0.0f) { + snprintf(cmd, sizeof(cmd), "CONFIG HEADING LENGTH %.0f %.0f", + baseline_cm, tolerance_cm); + } else { + snprintf(cmd, sizeof(cmd), "CONFIG HEADING LENGTH %.0f", + baseline_cm); + } + um982_send_command(gps, cmd); + HAL_Delay(50); + } + + /* Step 4: Enable NMEA output sentences on COM2. + * Per N4 Reference: "When requesting NMEA messages, users should add GP + * before each command name" + * + * We target COM2 because the ELT0213 board (GNSS.STORE) exposes COM2 + * (RXD2/TXD2) on its 12-pin JST connector (pins 5 & 6). The STM32 + * UART5 (PC12-TX, PD2-RX) connects to these pins via JP8. + * COM2 defaults to 115200 baud — matching our UART5 config. */ + um982_send_command(gps, "GPGGA COM2 1"); /* GGA at 1 Hz */ + HAL_Delay(50); + um982_send_command(gps, "GPRMC COM2 1"); /* RMC at 1 Hz */ + HAL_Delay(50); + um982_send_command(gps, "GPTHS COM2 0.2"); /* THS at 5 Hz (heading primary) */ + HAL_Delay(50); + + /* Step 5: Skip SAVECONFIG -- NMEA config is re-sent every boot anyway. + * Saving to NVM on every power cycle would wear flash. If persistent + * config is needed, call um982_send_command(gps, "SAVECONFIG") once + * during commissioning. */ + + /* Step 6: Query version to verify communication */ + gps->version_received = false; + um982_send_command(gps, "VERSIONA"); + + /* Wait for VERSIONA response (non-blocking poll) */ + uint32_t start = HAL_GetTick(); + while (!gps->version_received && + (HAL_GetTick() - start) < UM982_INIT_TIMEOUT_MS) { + um982_process(gps); + HAL_Delay(10); + } + + gps->initialized = gps->version_received; + return gps->initialized; +} diff --git a/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/um982_gps.h b/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/um982_gps.h new file mode 100644 index 0000000..ad94ac0 --- /dev/null +++ b/9_Firmware/9_1_Microcontroller/9_1_3_C_Cpp_Code/um982_gps.h @@ -0,0 +1,213 @@ +/******************************************************************************* + * um982_gps.h -- UM982 dual-antenna GNSS receiver driver + * + * Parses NMEA sentences (GGA, RMC, THS, VTG) from the Unicore UM982 module + * and provides position, heading, and velocity data. + * + * Design principles: + * - Non-blocking: process() reads available UART bytes without waiting + * - Correct NMEA parsing: proper tokenizer handles empty fields + * - Longitude handles 3-digit degrees (dddmm.mmmm) via decimal-point detection + * - Checksum verified on every sentence + * - Command syntax verified against Unicore N4 Command Reference EN R1.14 + * + * Hardware: UM982 on UART5 @ 115200 baud, dual-antenna heading mode + ******************************************************************************/ +#ifndef UM982_GPS_H +#define UM982_GPS_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Forward-declare the HAL UART handle type. The real definition comes from + * stm32f7xx_hal.h (production) or stm32_hal_mock.h (tests). */ +#ifndef STM32_HAL_MOCK_H +#include "stm32f7xx_hal.h" +#else +/* Already included via mock -- nothing to do */ +#endif + +/* ========================= Constants ================================= */ + +#define UM982_RX_BUF_SIZE 512 /* Ring buffer for incoming UART bytes */ +#define UM982_LINE_BUF_SIZE 96 /* Max NMEA sentence (82 chars + margin) */ +#define UM982_CMD_BUF_SIZE 128 /* Outgoing command buffer */ +#define UM982_INIT_TIMEOUT_MS 3000 /* Timeout waiting for VERSIONA response */ + +/* Fix quality values (from GGA field 6) */ +#define UM982_FIX_NONE 0 +#define UM982_FIX_GPS 1 +#define UM982_FIX_DGPS 2 +#define UM982_FIX_RTK_FIXED 4 +#define UM982_FIX_RTK_FLOAT 5 + +/* Validity timeout defaults (ms) */ +#define UM982_HEADING_TIMEOUT_MS 2000 +#define UM982_POSITION_TIMEOUT_MS 5000 + +/* ========================= Data Types ================================ */ + +typedef struct { + /* Position */ + double latitude; /* Decimal degrees, positive = North */ + double longitude; /* Decimal degrees, positive = East */ + float altitude; /* Meters above MSL */ + float geoid_sep; /* Geoid separation (meters) */ + + /* Heading (from dual-antenna THS) */ + float heading; /* True heading 0-360 degrees, NAN if invalid */ + char heading_mode; /* A=autonomous, D=diff, E=est, M=manual, S=sim, V=invalid */ + + /* Velocity */ + float speed_knots; /* Speed over ground (knots) */ + float speed_kmh; /* Speed over ground (km/h) */ + float course_true; /* Course over ground (degrees true) */ + + /* Quality */ + uint8_t fix_quality; /* 0=none, 1=GPS, 2=DGPS, 4=RTK fixed, 5=RTK float */ + uint8_t num_satellites; /* Satellites used in fix */ + float hdop; /* Horizontal dilution of precision */ + + /* RMC status */ + char rmc_status; /* A=valid, V=warning */ + + /* Timestamps (HAL_GetTick() at last update) */ + uint32_t last_fix_tick; /* Last valid GGA fix (fix_quality > 0) */ + uint32_t last_gga_tick; + uint32_t last_rmc_tick; + uint32_t last_ths_tick; + uint32_t last_vtg_tick; + + /* Communication state */ + bool initialized; /* VERSIONA or supported NMEA traffic seen */ + bool version_received; /* VERSIONA response seen */ + + /* ---- Internal parser state (not for external use) ---- */ + + /* Ring buffer */ + uint8_t rx_buf[UM982_RX_BUF_SIZE]; + uint16_t rx_head; /* Write index */ + uint16_t rx_tail; /* Read index */ + + /* Line assembler */ + char line_buf[UM982_LINE_BUF_SIZE]; + uint8_t line_len; + bool line_overflow; /* Current line exceeded buffer */ + + /* UART handle */ + UART_HandleTypeDef *huart; + +} UM982_GPS_t; + +/* ========================= Public API ================================ */ + +/** + * Initialize the UM982_GPS_t structure and configure the module. + * + * Sends: UNLOG, CONFIG HEADING, optional CONFIG HEADING LENGTH, + * GPGGA, GPRMC, GPTHS + * Queries VERSIONA to verify communication. + * + * @param gps Pointer to UM982_GPS_t instance + * @param huart UART handle (e.g. &huart5) + * @param baseline_cm Distance between antennas in cm (0 = use module default) + * @param tolerance_cm Baseline tolerance in cm (0 = use module default) + * @return true if VERSIONA response received within timeout + */ +bool um982_init(UM982_GPS_t *gps, UART_HandleTypeDef *huart, + float baseline_cm, float tolerance_cm); + +/** + * Process available UART data. Call from main loop — non-blocking. + * + * Reads all available bytes from UART, assembles lines, and dispatches + * complete NMEA sentences to the appropriate parser. + * + * @param gps Pointer to UM982_GPS_t instance + */ +void um982_process(UM982_GPS_t *gps); + +/** + * Feed raw bytes directly into the parser (useful for testing). + * In production, um982_process() calls this internally after UART read. + * + * @param gps Pointer to UM982_GPS_t instance + * @param data Pointer to byte array + * @param len Number of bytes + */ +void um982_feed(UM982_GPS_t *gps, const uint8_t *data, uint16_t len); + +/* ---- Getters ---- */ + +static inline float um982_get_heading(const UM982_GPS_t *gps) { return gps->heading; } +static inline double um982_get_latitude(const UM982_GPS_t *gps) { return gps->latitude; } +static inline double um982_get_longitude(const UM982_GPS_t *gps) { return gps->longitude; } +static inline float um982_get_altitude(const UM982_GPS_t *gps) { return gps->altitude; } +static inline uint8_t um982_get_fix_quality(const UM982_GPS_t *gps) { return gps->fix_quality; } +static inline uint8_t um982_get_num_sats(const UM982_GPS_t *gps) { return gps->num_satellites; } +static inline float um982_get_hdop(const UM982_GPS_t *gps) { return gps->hdop; } +static inline float um982_get_speed_knots(const UM982_GPS_t *gps) { return gps->speed_knots; } +static inline float um982_get_speed_kmh(const UM982_GPS_t *gps) { return gps->speed_kmh; } +static inline float um982_get_course(const UM982_GPS_t *gps) { return gps->course_true; } + +/** + * Check if heading is valid (mode A or D, and within timeout). + */ +bool um982_is_heading_valid(const UM982_GPS_t *gps); + +/** + * Check if position is valid (fix_quality > 0, and within timeout). + */ +bool um982_is_position_valid(const UM982_GPS_t *gps); + +/** + * Get age of last heading update in milliseconds. + */ +uint32_t um982_heading_age(const UM982_GPS_t *gps); + +/** + * Get age of the last valid position fix in milliseconds. + */ +uint32_t um982_position_age(const UM982_GPS_t *gps); + +/* ========================= Internal (exposed for testing) ============ */ + +/** + * Verify NMEA checksum. Returns true if valid. + * Sentence must start with '$' and contain '*XX' before termination. + */ +bool um982_verify_checksum(const char *sentence); + +/** + * Parse a complete NMEA line (with $ prefix and *XX checksum). + * Dispatches to GGA/RMC/THS/VTG parsers as appropriate. + */ +void um982_parse_sentence(UM982_GPS_t *gps, const char *sentence); + +/** + * Parse NMEA coordinate string to decimal degrees. + * Works for both latitude (ddmm.mmmm) and longitude (dddmm.mmmm) + * by detecting the decimal point position. + * + * @param field NMEA coordinate field (e.g. "4404.14036" or "12118.85961") + * @param hemisphere 'N', 'S', 'E', or 'W' + * @return Decimal degrees (negative for S/W), or NAN on parse error + */ +double um982_parse_coord(const char *field, char hemisphere); + +/** + * Send a command to the UM982. Appends \r\n automatically. + * @return true if UART transmit succeeded + */ +bool um982_send_command(UM982_GPS_t *gps, const char *cmd); + +#ifdef __cplusplus +} +#endif + +#endif /* UM982_GPS_H */ diff --git a/9_Firmware/9_1_Microcontroller/tests/Makefile b/9_Firmware/9_1_Microcontroller/tests/Makefile index 75b7548..732b00d 100644 --- a/9_Firmware/9_1_Microcontroller/tests/Makefile +++ b/9_Firmware/9_1_Microcontroller/tests/Makefile @@ -27,6 +27,10 @@ CXX_LIB_DIR := ../9_1_1_C_Cpp_Libraries CXX_SRCS := $(CXX_LIB_DIR)/ADAR1000_AGC.cpp $(CXX_LIB_DIR)/ADAR1000_Manager.cpp CXX_OBJS := ADAR1000_AGC.o ADAR1000_Manager.o +# GPS driver source +GPS_SRC := ../9_1_3_C_Cpp_Code/um982_gps.c +GPS_OBJ := um982_gps.o + # Real source files compiled against mock headers REAL_SRC := ../9_1_1_C_Cpp_Libraries/adf4382a_manager.c @@ -73,7 +77,10 @@ TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only # C++ tests (AGC outer loop) TESTS_WITH_CXX := test_agc_outer_loop -ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM) $(TESTS_WITH_CXX) +# GPS driver tests (need mocks + GPS source + -lm) +TESTS_GPS := test_um982_gps + +ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM) $(TESTS_WITH_CXX) $(TESTS_GPS) .PHONY: all build test clean \ $(addprefix test_,bug1 bug2 bug3 bug4 bug5 bug6 bug7 bug8 bug9 bug10 bug11 bug12 bug13 bug14 bug15) \ @@ -189,6 +196,20 @@ test_agc_outer_loop: test_agc_outer_loop.cpp $(CXX_OBJS) $(MOCK_OBJS) test_agc: test_agc_outer_loop ./test_agc_outer_loop +# --- GPS driver rules --- + +$(GPS_OBJ): $(GPS_SRC) + $(CC) $(CFLAGS) $(INCLUDES) -I../9_1_3_C_Cpp_Code -c $< -o $@ + +# Note: test includes um982_gps.c directly for white-box testing (static fn access) +test_um982_gps: test_um982_gps.c $(MOCK_OBJS) + $(CC) $(CFLAGS) $(INCLUDES) -I../9_1_3_C_Cpp_Code $< $(MOCK_OBJS) -lm -o $@ + +# Convenience target +.PHONY: test_gps +test_gps: test_um982_gps + ./test_um982_gps + # --- Individual test targets --- test_bug1: test_bug1_timed_sync_init_ordering diff --git a/9_Firmware/9_1_Microcontroller/tests/stm32_hal_mock.c b/9_Firmware/9_1_Microcontroller/tests/stm32_hal_mock.c index 2b33b4f..230efb9 100644 --- a/9_Firmware/9_1_Microcontroller/tests/stm32_hal_mock.c +++ b/9_Firmware/9_1_Microcontroller/tests/stm32_hal_mock.c @@ -21,6 +21,7 @@ SPI_HandleTypeDef hspi4 = { .id = 4 }; I2C_HandleTypeDef hi2c1 = { .id = 1 }; I2C_HandleTypeDef hi2c2 = { .id = 2 }; UART_HandleTypeDef huart3 = { .id = 3 }; +UART_HandleTypeDef huart5 = { .id = 5 }; /* GPS UART */ ADC_HandleTypeDef hadc3 = { .id = 3 }; TIM_HandleTypeDef htim3 = { .id = 3 }; @@ -34,6 +35,26 @@ uint32_t mock_tick = 0; /* ========================= Printf control ========================= */ int mock_printf_enabled = 0; +/* ========================= Mock UART TX capture =================== */ +uint8_t mock_uart_tx_buf[MOCK_UART_TX_BUF_SIZE]; +uint16_t mock_uart_tx_len = 0; + +/* ========================= Mock UART RX buffer ==================== */ +#define MOCK_UART_RX_SLOTS 8 + +static struct { + uint32_t uart_id; + uint8_t buf[MOCK_UART_RX_BUF_SIZE]; + uint16_t head; + uint16_t tail; +} mock_uart_rx[MOCK_UART_RX_SLOTS]; + +void mock_uart_tx_clear(void) +{ + mock_uart_tx_len = 0; + memset(mock_uart_tx_buf, 0, sizeof(mock_uart_tx_buf)); +} + /* ========================= Mock GPIO read ========================= */ #define GPIO_READ_TABLE_SIZE 32 static struct { @@ -49,6 +70,9 @@ void spy_reset(void) mock_tick = 0; mock_printf_enabled = 0; memset(gpio_read_table, 0, sizeof(gpio_read_table)); + memset(mock_uart_rx, 0, sizeof(mock_uart_rx)); + mock_uart_tx_len = 0; + memset(mock_uart_tx_buf, 0, sizeof(mock_uart_tx_buf)); } const SpyRecord *spy_get(int index) @@ -185,6 +209,83 @@ HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pD .value = Timeout, .extra = huart }); + /* Capture TX data for test inspection */ + for (uint16_t i = 0; i < Size && mock_uart_tx_len < MOCK_UART_TX_BUF_SIZE; i++) { + mock_uart_tx_buf[mock_uart_tx_len++] = pData[i]; + } + return HAL_OK; +} + +/* ========================= Mock UART RX helpers ====================== */ + +/* find_rx_slot, mock_uart_rx_load, etc. use the mock_uart_rx declared above */ + +static int find_rx_slot(UART_HandleTypeDef *huart) +{ + if (huart == NULL) return -1; + /* Find existing slot */ + for (int i = 0; i < MOCK_UART_RX_SLOTS; i++) { + if (mock_uart_rx[i].uart_id == huart->id && mock_uart_rx[i].head != mock_uart_rx[i].tail) { + return i; + } + if (mock_uart_rx[i].uart_id == huart->id) { + return i; + } + } + /* Find empty slot */ + for (int i = 0; i < MOCK_UART_RX_SLOTS; i++) { + if (mock_uart_rx[i].uart_id == 0) { + mock_uart_rx[i].uart_id = huart->id; + return i; + } + } + return -1; +} + +void mock_uart_rx_load(UART_HandleTypeDef *huart, const uint8_t *data, uint16_t len) +{ + int slot = find_rx_slot(huart); + if (slot < 0) return; + mock_uart_rx[slot].uart_id = huart->id; + for (uint16_t i = 0; i < len; i++) { + uint16_t next = (mock_uart_rx[slot].head + 1) % MOCK_UART_RX_BUF_SIZE; + if (next == mock_uart_rx[slot].tail) break; /* Buffer full */ + mock_uart_rx[slot].buf[mock_uart_rx[slot].head] = data[i]; + mock_uart_rx[slot].head = next; + } +} + +void mock_uart_rx_clear(UART_HandleTypeDef *huart) +{ + int slot = find_rx_slot(huart); + if (slot < 0) return; + mock_uart_rx[slot].head = 0; + mock_uart_rx[slot].tail = 0; +} + +HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, + uint16_t Size, uint32_t Timeout) +{ + (void)Timeout; + int slot = find_rx_slot(huart); + if (slot < 0) return HAL_TIMEOUT; + + for (uint16_t i = 0; i < Size; i++) { + if (mock_uart_rx[slot].head == mock_uart_rx[slot].tail) { + return HAL_TIMEOUT; /* No more data */ + } + pData[i] = mock_uart_rx[slot].buf[mock_uart_rx[slot].tail]; + mock_uart_rx[slot].tail = (mock_uart_rx[slot].tail + 1) % MOCK_UART_RX_BUF_SIZE; + } + + spy_push((SpyRecord){ + .type = SPY_UART_RX, + .port = NULL, + .pin = Size, + .value = Timeout, + .extra = huart + }); + return HAL_OK; } diff --git a/9_Firmware/9_1_Microcontroller/tests/stm32_hal_mock.h b/9_Firmware/9_1_Microcontroller/tests/stm32_hal_mock.h index ac41470..9153011 100644 --- a/9_Firmware/9_1_Microcontroller/tests/stm32_hal_mock.h +++ b/9_Firmware/9_1_Microcontroller/tests/stm32_hal_mock.h @@ -105,6 +105,7 @@ typedef struct { extern SPI_HandleTypeDef hspi1, hspi4; extern I2C_HandleTypeDef hi2c1, hi2c2; extern UART_HandleTypeDef huart3; +extern UART_HandleTypeDef huart5; /* GPS UART */ extern ADC_HandleTypeDef hadc3; extern TIM_HandleTypeDef htim3; /* Timer for DELADJ PWM */ @@ -139,6 +140,7 @@ typedef enum { SPY_TIM_SET_COMPARE, SPY_SPI_TRANSMIT_RECEIVE, SPY_SPI_TRANSMIT, + SPY_UART_RX, } SpyCallType; typedef struct { @@ -187,6 +189,23 @@ void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); uint32_t HAL_GetTick(void); void HAL_Delay(uint32_t Delay); HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout); +HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout); + +/* ========================= Mock UART RX buffer ======================= */ + +/* Inject bytes into the mock UART RX buffer for a specific UART handle. + * HAL_UART_Receive will return these bytes one at a time. */ +#define MOCK_UART_RX_BUF_SIZE 2048 + +void mock_uart_rx_load(UART_HandleTypeDef *huart, const uint8_t *data, uint16_t len); +void mock_uart_rx_clear(UART_HandleTypeDef *huart); + +/* Capture buffer for UART TX data (to verify commands sent to GPS module) */ +#define MOCK_UART_TX_BUF_SIZE 2048 + +extern uint8_t mock_uart_tx_buf[MOCK_UART_TX_BUF_SIZE]; +extern uint16_t mock_uart_tx_len; +void mock_uart_tx_clear(void); /* ========================= SPI stubs ============================== */ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_agc_outer_loop b/9_Firmware/9_1_Microcontroller/tests/test_agc_outer_loop new file mode 100755 index 0000000..3cfb6ef Binary files /dev/null and b/9_Firmware/9_1_Microcontroller/tests/test_agc_outer_loop differ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_gap3_emergency_state_ordering b/9_Firmware/9_1_Microcontroller/tests/test_gap3_emergency_state_ordering new file mode 100755 index 0000000..5deabe3 Binary files /dev/null and b/9_Firmware/9_1_Microcontroller/tests/test_gap3_emergency_state_ordering differ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_gap3_emergency_stop_rails b/9_Firmware/9_1_Microcontroller/tests/test_gap3_emergency_stop_rails new file mode 100755 index 0000000..e806865 Binary files /dev/null and b/9_Firmware/9_1_Microcontroller/tests/test_gap3_emergency_stop_rails differ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_gap3_idq_periodic_reread b/9_Firmware/9_1_Microcontroller/tests/test_gap3_idq_periodic_reread new file mode 100755 index 0000000..17c9ba1 Binary files /dev/null and b/9_Firmware/9_1_Microcontroller/tests/test_gap3_idq_periodic_reread differ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_gap3_iwdg_config b/9_Firmware/9_1_Microcontroller/tests/test_gap3_iwdg_config new file mode 100755 index 0000000..c684fbf Binary files /dev/null and b/9_Firmware/9_1_Microcontroller/tests/test_gap3_iwdg_config differ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_gap3_overtemp_emergency_stop b/9_Firmware/9_1_Microcontroller/tests/test_gap3_overtemp_emergency_stop new file mode 100755 index 0000000..b32cd47 Binary files /dev/null and b/9_Firmware/9_1_Microcontroller/tests/test_gap3_overtemp_emergency_stop differ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_gap3_temperature_max b/9_Firmware/9_1_Microcontroller/tests/test_gap3_temperature_max new file mode 100755 index 0000000..d073d68 Binary files /dev/null and b/9_Firmware/9_1_Microcontroller/tests/test_gap3_temperature_max differ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_um982_gps b/9_Firmware/9_1_Microcontroller/tests/test_um982_gps new file mode 100755 index 0000000..8f96f18 Binary files /dev/null and b/9_Firmware/9_1_Microcontroller/tests/test_um982_gps differ diff --git a/9_Firmware/9_1_Microcontroller/tests/test_um982_gps.c b/9_Firmware/9_1_Microcontroller/tests/test_um982_gps.c new file mode 100644 index 0000000..bab930e --- /dev/null +++ b/9_Firmware/9_1_Microcontroller/tests/test_um982_gps.c @@ -0,0 +1,853 @@ +/******************************************************************************* + * test_um982_gps.c -- Unit tests for UM982 GPS driver + * + * Tests NMEA parsing, checksum validation, coordinate parsing, init sequence, + * and validity tracking. Uses the mock HAL infrastructure for UART. + * + * Build: see Makefile target test_um982_gps + * Run: ./test_um982_gps + ******************************************************************************/ +#include "stm32_hal_mock.h" +#include "../9_1_3_C_Cpp_Code/um982_gps.h" +#include "../9_1_3_C_Cpp_Code/um982_gps.c" /* Include .c directly for white-box testing */ + +#include +#include +#include +#include + +/* ========================= Test helpers ============================== */ + +static int tests_passed = 0; +static int tests_failed = 0; + +#define TEST(name) \ + do { printf(" [TEST] %-55s ", name); } while(0) + +#define PASS() \ + do { printf("PASS\n"); tests_passed++; } while(0) + +#define FAIL(msg) \ + do { printf("FAIL: %s\n", msg); tests_failed++; } while(0) + +#define ASSERT_TRUE(expr, msg) \ + do { if (!(expr)) { FAIL(msg); return; } } while(0) + +#define ASSERT_FALSE(expr, msg) \ + do { if (expr) { FAIL(msg); return; } } while(0) + +#define ASSERT_EQ_INT(a, b, msg) \ + do { if ((a) != (b)) { \ + char _buf[256]; \ + snprintf(_buf, sizeof(_buf), "%s (got %d, expected %d)", msg, (int)(a), (int)(b)); \ + FAIL(_buf); return; \ + } } while(0) + +#define ASSERT_NEAR(a, b, tol, msg) \ + do { if (fabs((double)(a) - (double)(b)) > (tol)) { \ + char _buf[256]; \ + snprintf(_buf, sizeof(_buf), "%s (got %.8f, expected %.8f)", msg, (double)(a), (double)(b)); \ + FAIL(_buf); return; \ + } } while(0) + +#define ASSERT_NAN(val, msg) \ + do { if (!isnan(val)) { FAIL(msg); return; } } while(0) + +static UM982_GPS_t gps; + +static void reset_gps(void) +{ + spy_reset(); + memset(&gps, 0, sizeof(gps)); + gps.huart = &huart5; + gps.heading = NAN; + gps.heading_mode = 'V'; + gps.rmc_status = 'V'; +} + +/* ========================= Checksum tests ============================ */ + +static void test_checksum_valid(void) +{ + TEST("checksum: valid GGA"); + ASSERT_TRUE(um982_verify_checksum( + "$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47"), + "should be valid"); + PASS(); +} + +static void test_checksum_valid_ths(void) +{ + TEST("checksum: valid THS"); + ASSERT_TRUE(um982_verify_checksum("$GNTHS,341.3344,A*1F"), + "should be valid"); + PASS(); +} + +static void test_checksum_invalid(void) +{ + TEST("checksum: invalid (wrong value)"); + ASSERT_FALSE(um982_verify_checksum("$GNTHS,341.3344,A*FF"), + "should be invalid"); + PASS(); +} + +static void test_checksum_missing_star(void) +{ + TEST("checksum: missing * marker"); + ASSERT_FALSE(um982_verify_checksum("$GNTHS,341.3344,A"), + "should be invalid"); + PASS(); +} + +static void test_checksum_null(void) +{ + TEST("checksum: NULL input"); + ASSERT_FALSE(um982_verify_checksum(NULL), "should be false"); + PASS(); +} + +static void test_checksum_no_dollar(void) +{ + TEST("checksum: missing $ prefix"); + ASSERT_FALSE(um982_verify_checksum("GNTHS,341.3344,A*1F"), + "should be invalid without $"); + PASS(); +} + +/* ========================= Coordinate parsing tests ================== */ + +static void test_coord_latitude_north(void) +{ + TEST("coord: latitude 4404.14036 N"); + double lat = um982_parse_coord("4404.14036", 'N'); + /* 44 + 04.14036/60 = 44.069006 */ + ASSERT_NEAR(lat, 44.069006, 0.000001, "latitude"); + PASS(); +} + +static void test_coord_latitude_south(void) +{ + TEST("coord: latitude 3358.92500 S (negative)"); + double lat = um982_parse_coord("3358.92500", 'S'); + ASSERT_TRUE(lat < 0.0, "should be negative for S"); + ASSERT_NEAR(lat, -(33.0 + 58.925/60.0), 0.000001, "latitude"); + PASS(); +} + +static void test_coord_longitude_3digit(void) +{ + TEST("coord: longitude 12118.85961 W (3-digit degrees)"); + double lon = um982_parse_coord("12118.85961", 'W'); + /* 121 + 18.85961/60 = 121.314327 */ + ASSERT_TRUE(lon < 0.0, "should be negative for W"); + ASSERT_NEAR(lon, -(121.0 + 18.85961/60.0), 0.000001, "longitude"); + PASS(); +} + +static void test_coord_longitude_east(void) +{ + TEST("coord: longitude 11614.19729 E"); + double lon = um982_parse_coord("11614.19729", 'E'); + ASSERT_TRUE(lon > 0.0, "should be positive for E"); + ASSERT_NEAR(lon, 116.0 + 14.19729/60.0, 0.000001, "longitude"); + PASS(); +} + +static void test_coord_empty(void) +{ + TEST("coord: empty string returns NAN"); + ASSERT_NAN(um982_parse_coord("", 'N'), "should be NAN"); + PASS(); +} + +static void test_coord_null(void) +{ + TEST("coord: NULL returns NAN"); + ASSERT_NAN(um982_parse_coord(NULL, 'N'), "should be NAN"); + PASS(); +} + +static void test_coord_no_dot(void) +{ + TEST("coord: no decimal point returns NAN"); + ASSERT_NAN(um982_parse_coord("440414036", 'N'), "should be NAN"); + PASS(); +} + +/* ========================= GGA parsing tests ========================= */ + +static void test_parse_gga_full(void) +{ + TEST("GGA: full sentence with all fields"); + reset_gps(); + mock_set_tick(1000); + + um982_parse_sentence(&gps, + "$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47"); + + ASSERT_NEAR(gps.latitude, 44.069006, 0.0001, "latitude"); + ASSERT_NEAR(gps.longitude, -(121.0 + 18.85961/60.0), 0.0001, "longitude"); + ASSERT_EQ_INT(gps.fix_quality, 1, "fix quality"); + ASSERT_EQ_INT(gps.num_satellites, 12, "num sats"); + ASSERT_NEAR(gps.hdop, 0.98, 0.01, "hdop"); + ASSERT_NEAR(gps.altitude, 1113.0, 0.1, "altitude"); + ASSERT_NEAR(gps.geoid_sep, -21.3, 0.1, "geoid sep"); + PASS(); +} + +static void test_parse_gga_rtk_fixed(void) +{ + TEST("GGA: RTK fixed (quality=4)"); + reset_gps(); + + um982_parse_sentence(&gps, + "$GNGGA,023634.00,4004.73871635,N,11614.19729418,E,4,28,0.7,61.0988,M,-8.4923,M,,*5D"); + + ASSERT_EQ_INT(gps.fix_quality, 4, "RTK fixed"); + ASSERT_EQ_INT(gps.num_satellites, 28, "num sats"); + ASSERT_NEAR(gps.latitude, 40.0 + 4.73871635/60.0, 0.0000001, "latitude"); + ASSERT_NEAR(gps.longitude, 116.0 + 14.19729418/60.0, 0.0000001, "longitude"); + PASS(); +} + +static void test_parse_gga_no_fix(void) +{ + TEST("GGA: no fix (quality=0)"); + reset_gps(); + + /* Compute checksum for this sentence */ + um982_parse_sentence(&gps, + "$GNGGA,235959.00,,,,,0,00,99.99,,,,,,*79"); + + ASSERT_EQ_INT(gps.fix_quality, 0, "no fix"); + PASS(); +} + +/* ========================= RMC parsing tests ========================= */ + +static void test_parse_rmc_valid(void) +{ + TEST("RMC: valid position and speed"); + reset_gps(); + mock_set_tick(2000); + + um982_parse_sentence(&gps, + "$GNRMC,001031.00,A,4404.13993,N,12118.86023,W,0.146,,100117,,,A*7B"); + + ASSERT_EQ_INT(gps.rmc_status, 'A', "status"); + ASSERT_NEAR(gps.latitude, 44.0 + 4.13993/60.0, 0.0001, "latitude"); + ASSERT_NEAR(gps.longitude, -(121.0 + 18.86023/60.0), 0.0001, "longitude"); + ASSERT_NEAR(gps.speed_knots, 0.146, 0.001, "speed"); + PASS(); +} + +static void test_parse_rmc_void(void) +{ + TEST("RMC: void status (no valid fix)"); + reset_gps(); + gps.latitude = 12.34; /* Pre-set to check it doesn't get overwritten */ + + um982_parse_sentence(&gps, + "$GNRMC,235959.00,V,,,,,,,100117,,,N*64"); + + ASSERT_EQ_INT(gps.rmc_status, 'V', "void status"); + ASSERT_NEAR(gps.latitude, 12.34, 0.001, "lat should not change on void"); + PASS(); +} + +/* ========================= THS parsing tests ========================= */ + +static void test_parse_ths_autonomous(void) +{ + TEST("THS: autonomous heading 341.3344"); + reset_gps(); + mock_set_tick(3000); + + um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F"); + + ASSERT_NEAR(gps.heading, 341.3344, 0.001, "heading"); + ASSERT_EQ_INT(gps.heading_mode, 'A', "mode"); + PASS(); +} + +static void test_parse_ths_not_valid(void) +{ + TEST("THS: not valid mode"); + reset_gps(); + + um982_parse_sentence(&gps, "$GNTHS,,V*10"); + + ASSERT_NAN(gps.heading, "heading should be NAN when empty"); + ASSERT_EQ_INT(gps.heading_mode, 'V', "mode V"); + PASS(); +} + +static void test_parse_ths_zero(void) +{ + TEST("THS: heading exactly 0.0000"); + reset_gps(); + + um982_parse_sentence(&gps, "$GNTHS,0.0000,A*19"); + + ASSERT_NEAR(gps.heading, 0.0, 0.001, "heading zero"); + ASSERT_EQ_INT(gps.heading_mode, 'A', "mode A"); + PASS(); +} + +static void test_parse_ths_360_boundary(void) +{ + TEST("THS: heading near 360"); + reset_gps(); + + um982_parse_sentence(&gps, "$GNTHS,359.9999,D*13"); + + ASSERT_NEAR(gps.heading, 359.9999, 0.001, "heading near 360"); + ASSERT_EQ_INT(gps.heading_mode, 'D', "mode D"); + PASS(); +} + +/* ========================= VTG parsing tests ========================= */ + +static void test_parse_vtg(void) +{ + TEST("VTG: course and speed"); + reset_gps(); + + um982_parse_sentence(&gps, + "$GPVTG,220.86,T,,M,2.550,N,4.724,K,A*34"); + + ASSERT_NEAR(gps.course_true, 220.86, 0.01, "course"); + ASSERT_NEAR(gps.speed_knots, 2.550, 0.001, "speed knots"); + ASSERT_NEAR(gps.speed_kmh, 4.724, 0.001, "speed kmh"); + PASS(); +} + +/* ========================= Talker ID tests =========================== */ + +static void test_talker_gp(void) +{ + TEST("talker: GP prefix parses correctly"); + reset_gps(); + + um982_parse_sentence(&gps, "$GPTHS,123.4567,A*07"); + + ASSERT_NEAR(gps.heading, 123.4567, 0.001, "heading with GP"); + PASS(); +} + +static void test_talker_gl(void) +{ + TEST("talker: GL prefix parses correctly"); + reset_gps(); + + um982_parse_sentence(&gps, "$GLTHS,123.4567,A*1B"); + + ASSERT_NEAR(gps.heading, 123.4567, 0.001, "heading with GL"); + PASS(); +} + +/* ========================= Feed / line assembly tests ================ */ + +static void test_feed_single_sentence(void) +{ + TEST("feed: single complete sentence with CRLF"); + reset_gps(); + mock_set_tick(5000); + + const char *data = "$GNTHS,341.3344,A*1F\r\n"; + um982_feed(&gps, (const uint8_t *)data, (uint16_t)strlen(data)); + + ASSERT_NEAR(gps.heading, 341.3344, 0.001, "heading"); + PASS(); +} + +static void test_feed_multiple_sentences(void) +{ + TEST("feed: multiple sentences in one chunk"); + reset_gps(); + mock_set_tick(5000); + + const char *data = + "$GNTHS,100.0000,A*18\r\n" + "$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47\r\n"; + um982_feed(&gps, (const uint8_t *)data, (uint16_t)strlen(data)); + + ASSERT_NEAR(gps.heading, 100.0, 0.01, "heading from THS"); + ASSERT_EQ_INT(gps.fix_quality, 1, "fix from GGA"); + PASS(); +} + +static void test_feed_partial_then_complete(void) +{ + TEST("feed: partial bytes then complete"); + reset_gps(); + mock_set_tick(5000); + + const char *part1 = "$GNTHS,200."; + const char *part2 = "5000,A*1E\r\n"; + um982_feed(&gps, (const uint8_t *)part1, (uint16_t)strlen(part1)); + /* Heading should not be set yet */ + ASSERT_NAN(gps.heading, "should be NAN before complete"); + + um982_feed(&gps, (const uint8_t *)part2, (uint16_t)strlen(part2)); + ASSERT_NEAR(gps.heading, 200.5, 0.01, "heading after complete"); + PASS(); +} + +static void test_feed_bad_checksum_rejected(void) +{ + TEST("feed: bad checksum sentence is rejected"); + reset_gps(); + mock_set_tick(5000); + + const char *data = "$GNTHS,999.0000,A*FF\r\n"; + um982_feed(&gps, (const uint8_t *)data, (uint16_t)strlen(data)); + + ASSERT_NAN(gps.heading, "heading should remain NAN"); + PASS(); +} + +static void test_feed_versiona_response(void) +{ + TEST("feed: VERSIONA response sets flag"); + reset_gps(); + + const char *data = "#VERSIONA,79,GPS,FINE,2326,378237000,15434,0,18,889;\"UM982\"\r\n"; + um982_feed(&gps, (const uint8_t *)data, (uint16_t)strlen(data)); + + ASSERT_TRUE(gps.version_received, "version_received should be true"); + ASSERT_TRUE(gps.initialized, "VERSIONA should mark communication alive"); + PASS(); +} + +/* ========================= Validity / age tests ====================== */ + +static void test_heading_valid_within_timeout(void) +{ + TEST("validity: heading valid within timeout"); + reset_gps(); + mock_set_tick(10000); + + um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F"); + + /* Still at tick 10000 */ + ASSERT_TRUE(um982_is_heading_valid(&gps), "should be valid"); + PASS(); +} + +static void test_heading_invalid_after_timeout(void) +{ + TEST("validity: heading invalid after 2s timeout"); + reset_gps(); + mock_set_tick(10000); + + um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F"); + + /* Advance past timeout */ + mock_set_tick(12500); + ASSERT_FALSE(um982_is_heading_valid(&gps), "should be invalid after 2.5s"); + PASS(); +} + +static void test_heading_invalid_mode_v(void) +{ + TEST("validity: heading invalid with mode V"); + reset_gps(); + mock_set_tick(10000); + + um982_parse_sentence(&gps, "$GNTHS,,V*10"); + + ASSERT_FALSE(um982_is_heading_valid(&gps), "mode V is invalid"); + PASS(); +} + +static void test_position_valid(void) +{ + TEST("validity: position valid with fix quality 1"); + reset_gps(); + mock_set_tick(10000); + + um982_parse_sentence(&gps, + "$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47"); + + ASSERT_TRUE(um982_is_position_valid(&gps), "should be valid"); + PASS(); +} + +static void test_position_invalid_no_fix(void) +{ + TEST("validity: position invalid with no fix"); + reset_gps(); + mock_set_tick(10000); + + um982_parse_sentence(&gps, + "$GNGGA,235959.00,,,,,0,00,99.99,,,,,,*79"); + + ASSERT_FALSE(um982_is_position_valid(&gps), "no fix = invalid"); + PASS(); +} + +static void test_position_age_uses_last_valid_fix(void) +{ + TEST("age: position age uses last valid fix, not no-fix GGA"); + reset_gps(); + + mock_set_tick(10000); + um982_parse_sentence(&gps, + "$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47"); + + mock_set_tick(12000); + um982_parse_sentence(&gps, + "$GNGGA,235959.00,,,,,0,00,99.99,,,,,,*79"); + + mock_set_tick(12500); + ASSERT_EQ_INT(um982_position_age(&gps), 2500, "age should still be from last valid fix"); + ASSERT_FALSE(um982_is_position_valid(&gps), "latest no-fix GGA should invalidate position"); + PASS(); +} + +static void test_heading_age(void) +{ + TEST("age: heading age computed correctly"); + reset_gps(); + mock_set_tick(10000); + + um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F"); + + mock_set_tick(10500); + uint32_t age = um982_heading_age(&gps); + ASSERT_EQ_INT(age, 500, "age should be 500ms"); + PASS(); +} + +/* ========================= Send command tests ======================== */ + +static void test_send_command_appends_crlf(void) +{ + TEST("send_command: appends \\r\\n"); + reset_gps(); + + um982_send_command(&gps, "GPGGA COM2 1"); + + /* Check that TX buffer contains "GPGGA COM2 1\r\n" */ + const char *expected = "GPGGA COM2 1\r\n"; + ASSERT_TRUE(mock_uart_tx_len == strlen(expected), "TX length"); + ASSERT_TRUE(memcmp(mock_uart_tx_buf, expected, strlen(expected)) == 0, + "TX content should be 'GPGGA COM2 1\\r\\n'"); + PASS(); +} + +static void test_send_command_null_safety(void) +{ + TEST("send_command: NULL gps returns false"); + ASSERT_FALSE(um982_send_command(NULL, "RESET"), "should return false"); + PASS(); +} + +/* ========================= Init sequence tests ======================= */ + +static void test_init_sends_correct_commands(void) +{ + TEST("init: sends correct command sequence"); + spy_reset(); + mock_uart_tx_clear(); + + /* Pre-load VERSIONA response so init succeeds */ + const char *ver_resp = "#VERSIONA,79,GPS,FINE,2326,378237000,15434,0,18,889;\"UM982\"\r\n"; + mock_uart_rx_load(&huart5, (const uint8_t *)ver_resp, (uint16_t)strlen(ver_resp)); + + UM982_GPS_t init_gps; + bool ok = um982_init(&init_gps, &huart5, 50.0f, 3.0f); + + ASSERT_TRUE(ok, "init should succeed"); + ASSERT_TRUE(init_gps.initialized, "should be initialized"); + + /* Verify TX buffer contains expected commands */ + const char *tx = (const char *)mock_uart_tx_buf; + ASSERT_TRUE(strstr(tx, "UNLOG\r\n") != NULL, "should send UNLOG"); + ASSERT_TRUE(strstr(tx, "CONFIG HEADING FIXLENGTH\r\n") != NULL, "should send CONFIG HEADING"); + ASSERT_TRUE(strstr(tx, "CONFIG HEADING LENGTH 50 3\r\n") != NULL, "should send LENGTH"); + ASSERT_TRUE(strstr(tx, "GPGGA COM2 1\r\n") != NULL, "should enable GGA"); + ASSERT_TRUE(strstr(tx, "GPRMC COM2 1\r\n") != NULL, "should enable RMC"); + ASSERT_TRUE(strstr(tx, "GPTHS COM2 0.2\r\n") != NULL, "should enable THS at 5Hz"); + ASSERT_TRUE(strstr(tx, "SAVECONFIG\r\n") == NULL, "should NOT save config (NVM wear)"); + ASSERT_TRUE(strstr(tx, "VERSIONA\r\n") != NULL, "should query version"); + + /* Verify command order: UNLOG should come before GPGGA */ + const char *unlog_pos = strstr(tx, "UNLOG\r\n"); + const char *gpgga_pos = strstr(tx, "GPGGA COM2 1\r\n"); + ASSERT_TRUE(unlog_pos < gpgga_pos, "UNLOG should precede GPGGA"); + + PASS(); +} + +static void test_init_no_baseline(void) +{ + TEST("init: baseline=0 skips LENGTH command"); + spy_reset(); + mock_uart_tx_clear(); + + const char *ver_resp = "#VERSIONA,79,GPS,FINE,2326,378237000,15434,0,18,889;\"UM982\"\r\n"; + mock_uart_rx_load(&huart5, (const uint8_t *)ver_resp, (uint16_t)strlen(ver_resp)); + + UM982_GPS_t init_gps; + um982_init(&init_gps, &huart5, 0.0f, 0.0f); + + const char *tx = (const char *)mock_uart_tx_buf; + ASSERT_TRUE(strstr(tx, "CONFIG HEADING LENGTH") == NULL, "should NOT send LENGTH"); + PASS(); +} + +static void test_init_fails_no_version(void) +{ + TEST("init: fails if no VERSIONA response"); + spy_reset(); + mock_uart_tx_clear(); + + /* Don't load any RX data — init should timeout */ + UM982_GPS_t init_gps; + bool ok = um982_init(&init_gps, &huart5, 50.0f, 3.0f); + + ASSERT_FALSE(ok, "init should fail without version response"); + ASSERT_FALSE(init_gps.initialized, "should not be initialized"); + PASS(); +} + +static void test_nmea_traffic_sets_initialized_without_versiona(void) +{ + TEST("init state: supported NMEA traffic sets initialized"); + reset_gps(); + + ASSERT_FALSE(gps.initialized, "should start uninitialized"); + um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F"); + ASSERT_TRUE(gps.initialized, "supported NMEA should mark communication alive"); + PASS(); +} + +/* ========================= Edge case tests =========================== */ + +static void test_empty_fields_handled(void) +{ + TEST("edge: GGA with empty lat/lon fields"); + reset_gps(); + gps.latitude = 99.99; + gps.longitude = 99.99; + + /* GGA with empty position fields (no fix) */ + um982_parse_sentence(&gps, + "$GNGGA,235959.00,,,,,0,00,99.99,,,,,,*79"); + + ASSERT_EQ_INT(gps.fix_quality, 0, "no fix"); + /* Latitude/longitude should not be updated (fields are empty) */ + ASSERT_NEAR(gps.latitude, 99.99, 0.01, "lat unchanged"); + ASSERT_NEAR(gps.longitude, 99.99, 0.01, "lon unchanged"); + PASS(); +} + +static void test_sentence_too_short(void) +{ + TEST("edge: sentence too short to have formatter"); + reset_gps(); + /* Should not crash */ + um982_parse_sentence(&gps, "$GN"); + um982_parse_sentence(&gps, "$"); + um982_parse_sentence(&gps, ""); + um982_parse_sentence(&gps, NULL); + PASS(); +} + +static void test_line_overflow(void) +{ + TEST("edge: oversized line is dropped"); + reset_gps(); + + /* Create a line longer than UM982_LINE_BUF_SIZE */ + char big[200]; + memset(big, 'X', sizeof(big)); + big[0] = '$'; + big[198] = '\n'; + big[199] = '\0'; + + um982_feed(&gps, (const uint8_t *)big, 199); + /* Should not crash, heading should still be NAN */ + ASSERT_NAN(gps.heading, "no valid data from overflow"); + PASS(); +} + +static void test_process_via_mock_uart(void) +{ + TEST("process: reads from mock UART RX buffer"); + reset_gps(); + mock_set_tick(5000); + + /* Load data into mock UART RX */ + const char *data = "$GNTHS,275.1234,D*18\r\n"; + mock_uart_rx_load(&huart5, (const uint8_t *)data, (uint16_t)strlen(data)); + + /* Call process() which reads from UART */ + um982_process(&gps); + + ASSERT_NEAR(gps.heading, 275.1234, 0.001, "heading via process()"); + ASSERT_EQ_INT(gps.heading_mode, 'D', "mode D"); + PASS(); +} + +/* ========================= PR #68 bug regression tests =============== */ + +/* These tests specifically verify the bugs found in the reverted PR #68 */ + +static void test_regression_sentence_id_with_gn_prefix(void) +{ + TEST("regression: GN-prefixed GGA is correctly identified"); + reset_gps(); + + /* PR #68 bug: strncmp(sentence, "GGA", 3) compared "GNG" vs "GGA" — never matched. + * Our fix: skip 2-char talker ID, compare at sentence+3. */ + um982_parse_sentence(&gps, + "$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47"); + + ASSERT_EQ_INT(gps.fix_quality, 1, "GGA should parse with GN prefix"); + ASSERT_NEAR(gps.latitude, 44.069006, 0.001, "latitude should be parsed"); + PASS(); +} + +static void test_regression_longitude_3digit_degrees(void) +{ + TEST("regression: 3-digit longitude degrees parsed correctly"); + reset_gps(); + + /* PR #68 bug: hardcoded 2-digit degrees for longitude. + * 12118.85961 should be 121° 18.85961' = 121.314327° */ + um982_parse_sentence(&gps, + "$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47"); + + ASSERT_NEAR(gps.longitude, -(121.0 + 18.85961/60.0), 0.0001, + "longitude 121° should not be parsed as 12°"); + ASSERT_TRUE(gps.longitude < -100.0, "longitude should be > 100 degrees"); + PASS(); +} + +static void test_regression_hemisphere_no_ptr_corrupt(void) +{ + TEST("regression: hemisphere parsing doesn't corrupt field pointer"); + reset_gps(); + + /* PR #68 bug: GGA/RMC hemisphere cases manually advanced ptr, + * desynchronizing from field counter. Our parser uses proper tokenizer. */ + um982_parse_sentence(&gps, + "$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47"); + + /* After lat/lon, remaining fields should be correct */ + ASSERT_EQ_INT(gps.num_satellites, 12, "sats after hemisphere"); + ASSERT_NEAR(gps.hdop, 0.98, 0.01, "hdop after hemisphere"); + ASSERT_NEAR(gps.altitude, 1113.0, 0.1, "altitude after hemisphere"); + PASS(); +} + +static void test_regression_rmc_also_parsed(void) +{ + TEST("regression: RMC sentence is actually parsed (not dead code)"); + reset_gps(); + + /* PR #68 bug: identifySentence never matched GGA/RMC, so position + * parsing was dead code. */ + um982_parse_sentence(&gps, + "$GNRMC,001031.00,A,4404.13993,N,12118.86023,W,0.146,,100117,,,A*7B"); + + ASSERT_TRUE(gps.latitude > 44.0, "RMC lat should be parsed"); + ASSERT_TRUE(gps.longitude < -121.0, "RMC lon should be parsed"); + ASSERT_NEAR(gps.speed_knots, 0.146, 0.001, "RMC speed"); + PASS(); +} + +/* ========================= Main ====================================== */ + +int main(void) +{ + printf("=== UM982 GPS Driver Tests ===\n\n"); + + printf("--- Checksum ---\n"); + test_checksum_valid(); + test_checksum_valid_ths(); + test_checksum_invalid(); + test_checksum_missing_star(); + test_checksum_null(); + test_checksum_no_dollar(); + + printf("\n--- Coordinate Parsing ---\n"); + test_coord_latitude_north(); + test_coord_latitude_south(); + test_coord_longitude_3digit(); + test_coord_longitude_east(); + test_coord_empty(); + test_coord_null(); + test_coord_no_dot(); + + printf("\n--- GGA Parsing ---\n"); + test_parse_gga_full(); + test_parse_gga_rtk_fixed(); + test_parse_gga_no_fix(); + + printf("\n--- RMC Parsing ---\n"); + test_parse_rmc_valid(); + test_parse_rmc_void(); + + printf("\n--- THS Parsing ---\n"); + test_parse_ths_autonomous(); + test_parse_ths_not_valid(); + test_parse_ths_zero(); + test_parse_ths_360_boundary(); + + printf("\n--- VTG Parsing ---\n"); + test_parse_vtg(); + + printf("\n--- Talker IDs ---\n"); + test_talker_gp(); + test_talker_gl(); + + printf("\n--- Feed / Line Assembly ---\n"); + test_feed_single_sentence(); + test_feed_multiple_sentences(); + test_feed_partial_then_complete(); + test_feed_bad_checksum_rejected(); + test_feed_versiona_response(); + + printf("\n--- Validity / Age ---\n"); + test_heading_valid_within_timeout(); + test_heading_invalid_after_timeout(); + test_heading_invalid_mode_v(); + test_position_valid(); + test_position_invalid_no_fix(); + test_position_age_uses_last_valid_fix(); + test_heading_age(); + + printf("\n--- Send Command ---\n"); + test_send_command_appends_crlf(); + test_send_command_null_safety(); + + printf("\n--- Init Sequence ---\n"); + test_init_sends_correct_commands(); + test_init_no_baseline(); + test_init_fails_no_version(); + test_nmea_traffic_sets_initialized_without_versiona(); + + printf("\n--- Edge Cases ---\n"); + test_empty_fields_handled(); + test_sentence_too_short(); + test_line_overflow(); + test_process_via_mock_uart(); + + printf("\n--- PR #68 Regression ---\n"); + test_regression_sentence_id_with_gn_prefix(); + test_regression_longitude_3digit_degrees(); + test_regression_hemisphere_no_ptr_corrupt(); + test_regression_rmc_also_parsed(); + + printf("\n===============================================\n"); + printf(" Results: %d passed, %d failed (of %d total)\n", + tests_passed, tests_failed, tests_passed + tests_failed); + printf("===============================================\n"); + + return tests_failed > 0 ? 1 : 0; +}