Community project

Autonomous Rowing GPS Tracker

ESP32
Photo of Autonomous Rowing GPS Tracker
Generated with AI

Gustavo

Published October 22, 2026

This project builds a GPS-enabled rowing tracker that records position, motion, and performance data during rowing sessions. The ESP32 collects data from a u-blox NEO-M8N GPS module, a 6-axis IMU sensor, and battery monitoring through an ADS1115 ADC, then logs everything to a microSD card for later analysis. The system includes a long-range radio module for potential shore communication and uses a protected lithium battery with charging and power management.

This guide provides a complete parts list, wiring diagram, power distribution schematic, and step-by-step assembly instructions for building the tracker on a breadboard. You'll learn how to integrate multiple I2C and SPI sensors, configure the ESP32 firmware for GPS and IMU data collection, and validate all subsystems before final deployment on the water.

Wiring diagram

Gather all the parts

QtyComponent
1

Breakout NEO-M8N genuíno com entrada regulada 3,3 V e antena casada

PROPOSTO. Exigir entrada que funcione em 3,3 V (não entrada de LDO com queda excessiva), UART TTL 3,3 V e conector RF. GPS-only 10 Hz. Não aceitar NEO-6M ou clone sem comprovação.

1

DFRobot SEN0142 Fermion MPU-6050 6 DOF Sensor Breakout

DFRobot SEN0142 MPU-6050 breakout with 3-5 V board input, I2C interface, onboard I2C pull-ups, and i2cdevlib Arduino example coverage.

1

ADS1115 16-Bit ADC Module

16-bit four-channel I2C analog-to-digital converter with programmable gain amplifier. Common ADS1115 breakouts expose MCU-facing SDA/SCL pins and four analog inputs for single-ended or differential measurements.

1

Boost Converter

Small adjustable MT3608-style DC-DC boost converter module for stepping a lower DC input up to a higher rail such as 5V, 9V, or 12V. It is a power-path module with VIN/VOUT terminals, not a GPIO peripheral.

1

TP4056 Li-Ion/LiPo charger module with protection

TP4056 single-cell Li-Ion/LiPo linear charger module, 5V USB input, 1A charge current (programmable). Common variants ship with DW01 protection. Pair with battery_lipo_storage for the cell.

1

AMS1117 3.3V Regulator

Linear LDO regulator, 5 V (or higher) in -> 3.3 V out, 1 A max. Used to drop a 5 V supply down to 3.3 V for ESP32 / Pico when no on-board regulator is available.

1

Adafruit MicroSD SPI/SDIO Breakout Board (3V Only)

MicroSD card breakout for adding file storage to a microcontroller project, wired over SPI (any MCU) or native SDIO (faster, needs SDIO hardware/firmware support like SAMD51). Board has no onboard regulator or level shifter, so it runs on 3.3V logic and power only — do not connect directly to a 5V system.

1

Módulo MCP23017, A0/A1/A2 acessíveis

PROPOSTO. Expansor 3,3 V; endereço 0x20; GPIO A0=M0, A1=M1, A2=AUX. Não é o driver de solenoides.

1

EBYTE E220-900T22D UART TTL 22 dBm

DEFINIDO, ainda não comprado. 3–5,5 V; projetado em 5 V; TTL 3,3 V. TX típico 110 mA/22 dBm, RX 16,8 mA. Não usar biblioteca SPI LLCC68.

1

Pack Li-ion/LiPo 1S 3,7 V 5000 mAh protegido

PROPOSTO. Pack pronto, capacidade real >=5000 mAh, 4,2 V máximo, proteção incorporada contra curto/sobrecarga/descarga; corrente >=2 A. Não montar células em paralelo na protoboard.

1

Fusível rápido 2 A em porta-fusível isolado

PROPOSTO. Próximo ao positivo protegido do carregador, antes do cabo da carga.

1

Conector polarizado removível da carga

PROPOSTO. Par plugue/tomada no positivo; separar antes de carregar e antes de ligar USB do ESP32. Atua como desligamento interno, sem botão externo.

1

1N5819 Schottky 1 A 40 V

PROPOSTO. Ânodo no boost, cátodo marcado pela faixa na linha 5 V. Bloqueia retorno USB para boost. NÃO impede boost de alimentar VBUS USB: retirar jbat antes de conectar USB.

1

microSDHC 8 GB classe 10 FAT32

DEFINIDO como referência; PROPOSTO cartão genuíno FAT32 em partição única, encaixado em sd. Compatibilidade só após teste de escrita e leitura integral.

1

Antena 915 MHz 50 ohm SMA macho

PROPOSTA. Para SMA-K do E220, sem pino central incompatível. Conectar antes de energizar. Sem ganho arbitrário no cálculo de alcance.

1

Antena GPS L1 ativa compatível com breakout M8N

PROPOSTA. Comprar junto com breakout e alimentação de antena documentada; conexão RF ao conector do breakout, não ao GPIO.

1

Resistor 100 kΩ 1%

PROPOSTO. 100 kΩ 1%

1

Resistor 100 kΩ 1%

PROPOSTO. 100 kΩ 1%

1

Resistor 4,7 kΩ

PROPOSTO. 4,7 kΩ

1

Resistor 4,7 kΩ

PROPOSTO. 4,7 kΩ

1

Resistor 10 kΩ

PROPOSTO. 10 kΩ

1

Resistor 10 kΩ

PROPOSTO. 10 kΩ

1

Resistor 10 kΩ

PROPOSTO. 10 kΩ

1

Capacitor 470 µF 10 V eletrolítico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 470 µF 10 V eletrolítico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 220 µF 6,3 V eletrolítico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 100 µF 6,3 V eletrolítico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 100 nF cerâmico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 100 nF cerâmico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 100 nF cerâmico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 100 nF cerâmico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 100 nF cerâmico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Capacitor 100 nF cerâmico

PROPOSTO. Próximo da carga; eletrolítico tem polaridade.

1

Conector USB-C de recarga do TP4056 (integrado ao módulo)

Representação do conector que já vem no carregador; não é outro módulo. Não ligar VBUS ao barramento 5 V do ESP32. Entrada somente de fonte USB externa com carga desligada.

Assemble it in 10 steps

1. Separar peças e revisar antes de energizar

Este é um protótipo de engenharia, não uma unidade liberada para água. Use a Super Mini ESP32-C3, o breakout GPS com entrada 3,3 V comprovada, SEN0142 MPU-6050, módulo ADS1115, MCP23017 e adaptador SD exclusivamente 3,3 V. Separe os componentes sobre bancada seca. O cartão vai no encaixe do adaptador; suas ligações no desenho são internas ao encaixe.

  • Não substituir o leitor SD por um módulo de entrada 5 V sem revisar o circuito; pode falhar em 3,3 V ou aplicar tensão excessiva aos sinais.

2. Montar a alimentação fora da protoboard

Use pack pronto 1S protegido, nunca células soltas em paralelo. Positivo/negativo do pack vão a B+/B- do TP4056. OUT+ passa pelo fusível 2 A e pelo conector removível jbat até VIN+ do boost. OUT- é o terra do sistema. Não una B- a OUT- por outro fio, pois isso contorna a proteção do carregador. Use fios de potência curtos e conexões isoladas; não conduza corrente da bateria por contatos frágeis de protoboard.

  • A polaridade dos conectores de bateria varia entre fabricantes; confira com multímetro antes de encaixar.
  • Para recarregar: separar jbat e só então ligar USB ao TP4056. Não carregar em caixa fechada, molhada ou sem supervisão.

3. Ajustar e medir os dois barramentos

Com a eletrônica ainda desconectada, ligue o ânodo do 1N5819 ao VOUT+ do boost e o cátodo com faixa ao barramento 5 V. Ajuste o boost para obter cerca de 5,0 V depois do diodo sob carga. Nunca permita mais de 5,5 V, inclusive sem carga. O 5 V alimenta o pino 5V da Super Mini, o rádio e VIN do módulo AMS1117. A saída do AMS1117 deve medir 3,3 V e alimenta os periféricos. Não ligue essa saída ao pino 3V3 da Super Mini: são dois reguladores separados com terra comum.

  • Somente prossiga após testar 5 V e 3,3 V com multímetro e carga. O E220 precisa de pelo menos 5,0 V para garantir a potência nominal.
  • Retire jbat antes de conectar USB ao ESP32: o diodo não impede o boost de enviar energia à porta USB do computador.

4. Montar o barramento de sensores

Ligue SDA de MPU-6050, ADS1115 e MCP23017 a GPIO0 (dados). SCL de todos vai a GPIO1 (relógio). Alimente todos em EXT3V3 e una seus terras ao GND. MCP A0/A1/A2 no GND seleciona 0x20; RESET sobe a EXT3V3 pelo resistor de 10 kΩ. ADS ADDR vai a GND para 0x48; IMU usa 0x68. Os resistores de 4,7 kΩ puxam SDA/SCL para EXT3V3; confira a resistência equivalente com os pull-ups já presentes nos módulos.

  • Use fios de sinal até cerca de 15 cm. Confirme que nenhum pull-up termina em 5 V e que a resistência equivalente de cada linha não fica abaixo de aproximadamente 1 kΩ.

5. Medir a bateria pelo divisor

Ligue um resistor de 100 kΩ entre jbat.OUT e AIN0 do ADS1115. Outro de 100 kΩ vai de AIN0 ao GND. O capacitor de 100 nF fica entre AIN0 e GND. Assim, a entrada recebe metade da tensão da bateria. A medição é em milivolts; não é uma porcentagem de carga.

6. Conectar GPS e sua antena

GPS TX vai a GPIO20 (recepção do ESP32); GPS RX vai a GPIO21 (configuração enviada pelo ESP32). GPS VCC recebe EXT3V3 e GND recebe o terra comum. Conecte a antena L1 fornecida para o breakout ao conector RF. Confirme antes da compra que a placa, não só o chip, funciona na entrada escolhida de 3,3 V.

  • O GPS proposto é NEO-M8N genuíno em modo apenas GPS. Não trocar por NEO-6M de 5 Hz nem ligar um conector de bússola como se fosse UART.

7. Conectar rádio e antena antes de ligar

E220 VCC recebe 5 V e GND recebe terra comum. TXD vai a GPIO3 (dados recebidos pelo ESP32) e RXD a GPIO10 (dados enviados pelo ESP32). No MCP23017: GPA0 vai a M0, GPA1 a M1 e GPA2 a AUX. Cada M0/M1 tem resistor de 10 kΩ ao GND. O capacitor de 470 µF e o de 100 nF ficam junto a VCC/GND do rádio. Monte a antena SMA 915 MHz de 50 ohms.

  • Não ligar UART TTL de 5 V. Não alimentar o rádio pelo pino 3V3 da Super Mini. Não energizar sem antena.
  • Transmissão vem desabilitada no firmware para revisão de antena, alimentação e condições regulatórias. Homologação Anatel permanece pendente.

8. Conectar armazenamento

SD CLK vai a GPIO4 (relógio); MISO a GPIO5 (leitura); MOSI a GPIO6 (escrita); CS a GPIO7 (seleção). Alimente o adaptador em EXT3V3, nunca em 5 V. Ponha o capacitor de 220 µF junto ao leitor. Insira cartão microSDHC genuíno de 8 GB com uma partição FAT32, com tudo desligado.

  • O capacitor de 100 µF estabiliza EXT3V3. O módulo regulador precisa ter os capacitores locais de entrada/saída previstos pelo fabricante; não usar o chip AMS1117 solto como se fosse módulo pronto.

9. Validar em bancada antes de fechar

Confira fio por fio com a tabela e meça as tensões. Com jbat separado, conecte USB à Super Mini para gravar o firmware. Para ensaio autônomo, retire USB e reconecte jbat. Verifique o arquivo após desligar: CRC, continuidade de sequências, épocas GPS de 100 ms e amostras IMU. Execute os testes descritos em include/docs/TESTES.md. Não aprove o treino se faltar cartão, GPS, bateria ou dados.

10. Fixar e proteger após os testes

Protoboard serve apenas para bancada. Para o barco, fixe os módulos em suporte e use ligações soldadas ou conectores travados em placa perfurada universal, sem PCB própria. Fixe a IMU rigidamente e anote a orientação. Use caixa não metálica estanque com tampa vedada, antena GPS voltada ao céu e LoRa afastada do corpo e do boost; prenda atrás do último remador com retenção secundária. Não deixar cabos nem botões expostos durante o treino.

  • Não usar protoboard solta na água. Estanqueidade, condensação, aquecimento, flutuação e fixação precisam de ensaio separado, sem a bateria dentro.
  • A retirada abrupta da alimentação pode perder o último segundo e corromper FAT32. Faça cópia dos arquivos após cada treino. Não é equipamento de segurança ou navegação certificada.

Review all connections

1. Connections between "battery" and "ESP32"

FunctionbatteryESP32
power+ → TP4056 Li-Ion/LiPo charger module with protection B+EXT
ground- → TP4056 Li-Ion/LiPo charger module with protection B-EXT

2. Connections between "charger" and "ESP32"

FunctionchargerESP32
powerOUT+ → Fusível rápido 2 A em porta-fusível isolado 1EXT
groundOUT-GND

3. Connections between "fuse" and "ESP32"

FunctionfuseESP32
power2 → Conector polarizado removível da carga INEXT

4. Connections between "jbat" and "ESP32"

FunctionjbatESP32
powerOUT → Boost Converter VIN+EXT

5. Connections between "boost" and "ESP32"

FunctionboostESP32
groundVIN-GND
groundVOUT-GND
powerVOUT+ → 1N5819 Schottky 1 A 40 V AEXT

6. Connections between "diode" and "ESP32"

FunctiondiodeESP32
powerK5V

7. Connections between "ldo" and "ESP32"

FunctionldoESP32
powerVIN5V
groundGNDGND
powerVOUT3.3V

8. Connections between "gps" and "ESP32"

FunctiongpsESP32
powerVCC3.3V
groundGNDGND
uartTXGPIO 20
uartRXGPIO 21

9. Connections between "imu" and "ESP32"

FunctionimuESP32
powerVIN3.3V
groundGNDGND
i2cSDAGPIO 0
i2cSCLGPIO 1

10. Connections between "adc" and "ESP32"

FunctionadcESP32
powerVDD3.3V
groundGNDGND
i2cSDAGPIO 0
i2cSCLGPIO 1
groundADDRGND

11. Connections between "io" and "ESP32"

FunctionioESP32
powerVDD3.3V
groundGNDGND
i2cSDAGPIO 0
i2cSCLGPIO 1
groundA0GND
groundA1GND
groundA2GND
digitalGPA0 → EBYTE E220-900T22D UART TTL 22 dBm M0EXT
digitalGPA1 → EBYTE E220-900T22D UART TTL 22 dBm M1EXT

12. Connections between "sd" and "ESP32"

FunctionsdESP32
power3V3.3V
groundGNDGND
spiCLKGPIO 4
spiMISOGPIO 5
spiMOSIGPIO 6
spiCSGPIO 7

13. Connections between "radio" and "ESP32"

FunctionradioESP32
groundGNDGND
powerVCC5V
dataAUX → Módulo MCP23017, A0/A1/A2 acessíveis GPA2EXT
uartTXDGPIO 3
uartRXDGPIO 10

14. Connections between "rrst" and "ESP32"

FunctionrrstESP32
power13.3V
data2 → Módulo MCP23017, A0/A1/A2 acessíveis RESETEXT

15. Connections between "rm0" and "ESP32"

Functionrm0ESP32
data1 → EBYTE E220-900T22D UART TTL 22 dBm M0EXT
ground2GND

16. Connections between "rm1" and "ESP32"

Functionrm1ESP32
data1 → EBYTE E220-900T22D UART TTL 22 dBm M1EXT
ground2GND

17. Connections between "rbat1" and "ESP32"

Functionrbat1ESP32
data1 → Conector polarizado removível da carga OUTEXT
data2 → ADS1115 16-Bit ADC Module AIN0EXT

18. Connections between "rbat2" and "ESP32"

Functionrbat2ESP32
data1 → ADS1115 16-Bit ADC Module AIN0EXT
ground2GND

19. Connections between "cdiv" and "ESP32"

FunctioncdivESP32
power+ → ADS1115 16-Bit ADC Module AIN0EXT
ground-GND

20. Connections between "rsda" and "ESP32"

FunctionrsdaESP32
power13.3V
i2cSDAGPIO 0

21. Connections between "rscl" and "ESP32"

FunctionrsclESP32
power13.3V
i2cSCLGPIO 1

22. Connections between "antlora" and "ESP32"

FunctionantloraESP32
dataRF → EBYTE E220-900T22D UART TTL 22 dBm ANTEXT
groundGNDGND

23. Connections between "antgps" and "ESP32"

FunctionantgpsESP32
dataRF → Breakout NEO-M8N genuíno com entrada regulada 3,3 V e antena casada RFEXT
groundGNDGND

24. Connections between "cboost" and "ESP32"

FunctioncboostESP32
power+5V
ground-GND

25. Connections between "cradio" and "ESP32"

FunctioncradioESP32
power+ → EBYTE E220-900T22D UART TTL 22 dBm VCCEXT
ground-GND

26. Connections between "csd" and "ESP32"

FunctioncsdESP32
power+ → Adafruit MicroSD SPI/SDIO Breakout Board (3V Only) 3VEXT
ground-GND

27. Connections between "crail" and "ESP32"

FunctioncrailESP32
power+3.3V
ground-GND

28. Connections between "cgps" and "ESP32"

FunctioncgpsESP32
power+ → Breakout NEO-M8N genuíno com entrada regulada 3,3 V e antena casada VCCEXT
ground-GND

29. Connections between "cimu" and "ESP32"

FunctioncimuESP32
power+ → DFRobot SEN0142 Fermion MPU-6050 6 DOF Sensor Breakout VINEXT
ground-GND

30. Connections between "cio" and "ESP32"

FunctioncioESP32
power+ → Módulo MCP23017, A0/A1/A2 acessíveis VDDEXT
ground-GND

31. Connections between "cadc" and "ESP32"

FunctioncadcESP32
power+ → ADS1115 16-Bit ADC Module VDDEXT
ground-GND

32. Connections between "crf" and "ESP32"

FunctioncrfESP32
power+ → EBYTE E220-900T22D UART TTL 22 dBm VCCEXT
ground-GND

33. Connections between "card" and "ESP32"

FunctioncardESP32
powerVDD → Adafruit MicroSD SPI/SDIO Breakout Board (3V Only) 3VEXT
groundVSS → Adafruit MicroSD SPI/SDIO Breakout Board (3V Only) GNDEXT

34. Connections between "usbcharge" and "ESP32"

FunctionusbchargeESP32
powerVBUS → TP4056 Li-Ion/LiPo charger module with protection IN+EXT
groundGND → TP4056 Li-Ion/LiPo charger module with protection IN-EXT

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <HWCDC.h>
#include <Preferences.h>
#include <Adafruit_MCP23X17.h>
#include <Adafruit_ADS1X15.h>
#include <Adafruit_MPU6050.h>
#include <SparkFun_u-blox_GNSS_Arduino_Library.h>
#include <esp_timer.h>
#include <esp_sleep.h>
#include "protocol.h"
#include "shore_bridge.h"
// Wiring revision A checked: UART0 20/21, UART1 3/10, I2C 0/1, SPI 4/5/6/7.

// Do not use Serial: UART0 belongs exclusively to GPS. USB console is explicit.
HWCDC console;
HardwareSerial gpsPort(0),radioPort(1);
SFE_UBLOX_GNSS gps;
Adafruit_MCP23X17 io;
Adafruit_ADS1115 adc;
Adafruit_MPU6050 imu;
QueueHandle_t logQueue;
remo::Data state{};
uint64_t sessionId,started;
uint32_t recordSeq=0,radioSeq=0,lastGps=0,lastImu=0,lastAck=0;
uint32_t prevTow=0,rateStart=0,rateCount=0,rateBad=0;
bool haveTow=false,gpsDetected=false,gpsConfigured=false,imuReady=false;
bool ioReady=false,adcReady=false,radioReady=false,rateOK=false;
float prevSpeed=0;
bool prevGood=false;
volatile bool sdReady=false,shutdownRequested=false,shutdownDone=false;
volatile uint32_t sdErrors=0;
uint32_t lowCount=0,lastBatteryValid=0;
constexpr bool RADIO_TX_ENABLED=false; // Enable only after antenna + legal/RF review.
constexpr uint8_t IO_M0=0,IO_M1=1,IO_AUX=2;
constexpr uint8_t RADIO_CHANNEL=65; // 915.125 MHz = 850.125 + 65
constexpr uint32_t RADIO_INTERVAL_MS=5000; // one 142-byte frame / 5 s
uint64_t nowms(){return (uint64_t)esp_timer_get_time()/1000;}

void refresh() {
 uint32_t now=millis();
 if(!lastBatteryValid || now-lastBatteryValid>3000)state.battery_mv=UINT16_MAX;
 bool freshGps=lastGps && now-lastGps<500;
 state.gps_age_ms=lastGps?now-lastGps:UINT32_MAX;
 state.imu_age_ms=lastImu?now-lastImu:UINT32_MAX;
 state.sd_errors=sdErrors;
 state.flags=(freshGps && state.fix>=3 && prevGood ? remo::GPS_OK:0)
 |(imuReady && state.imu_age_ms<100?remo::IMU_OK:0)
 |(sdReady?remo::SD_OK:0)|(radioReady?remo::RADIO_OK:0)
 |(rateOK && freshGps?remo::GPS_10HZ:0)|(lowCount>=3?remo::LOW_BAT:0)
 |(state.queue_drops?remo::QUEUE_LOSS:0)|(state.utc_valid==7?remo::UTC_OK:0)
 |(gpsConfigured?remo::GPS_CONFIG:0)|(adcReady && state.battery_mv!=UINT16_MAX?remo::ADC_OK:0)
 |(lastAck && now-lastAck<20000?remo::ACK_RECENT:0);
 if(ioReady && !io.digitalRead(IO_AUX))state.flags|=remo::RADIO_BUSY;
}
remo::Frame frame(uint8_t kind,uint32_t seq) {
 refresh(); remo::Frame f{};
 f.header={remo::MAGIC,remo::VERSION,kind,sizeof(f),sessionId,seq,nowms()-started};
 f.data=state; remo::seal(f); return f;
}
void logSample(uint8_t kind) {
 remo::Frame f=frame(kind,recordSeq++);
 if(xQueueSend(logQueue,&f,0)!=pdPASS)state.queue_drops++;
}
void pvt(UBX_NAV_PVT_data_t *p) {
 uint32_t now=millis();
 bool good=p->flags.bits.gnssFixOK && p->fixType==3 && !p->flags3.bits.invalidLlh
 && p->hAcc<=5000 && p->sAcc<=1000 && p->gSpeed>=0;
 if(haveTow) {
  uint32_t dt=(p->iTOW+604800000UL-prevTow)%604800000UL;
  if(dt!=100)rateBad++;
  // Generic distance only: trapezoidal integration of GNSS ground speed.
  // Never bridge loss of fix or time gaps; no rowing-specific model.
  if(good && prevGood && dt>0 && dt<=200)
   state.distance_m+=(prevSpeed+p->gSpeed)*0.5f*dt/1000000.0f;
 }
 prevTow=p->iTOW;haveTow=true;prevSpeed=p->gSpeed;prevGood=good;
 lastGps=now;rateCount++;
 state.itow_ms=p->iTOW;state.lat_e7=p->lat;state.lon_e7=p->lon;
 state.alt_msl_mm=p->hMSL;state.speed_mm_s=p->gSpeed;
 state.hacc_mm=p->hAcc;state.sacc_mm_s=p->sAcc;state.pdop_centi=p->pDOP;
 state.fix=p->fixType;state.satellites=p->numSV;
 state.utc_valid=p->valid.all&7;state.year=p->year;state.month=p->month;
 state.day=p->day;state.hour=p->hour;state.minute=p->min;state.second=p->sec;
 state.utc_nano=p->nano; logSample(remo::GPS);
}
bool auxReady(uint32_t timeout=1500) {
 uint32_t t=millis(),highAt=0;
 do {
  if(io.digitalRead(IO_AUX)) { if(!highAt)highAt=millis(); if(millis()-highAt>=3)return true; }
  else highAt=0;
  delay(1);
 }while(millis()-t<timeout);return false;
}
bool radioInit() {
 if(!ioReady)return false;
 io.digitalWrite(IO_M0,LOW);io.digitalWrite(IO_M1,LOW);
 io.pinMode(IO_M0,OUTPUT);io.pinMode(IO_M1,OUTPUT);io.pinMode(IO_AUX,INPUT);
 radioPort.setTxBufferSize(512);
 radioPort.begin(9600,SERIAL_8N1,3,10);
 delay(100);
 if(!auxReady())return false;
 io.writeGPIOA(3);delay(10);if(!auxReady())return false;
 while(radioPort.available())radioPort.read();
 // Temporary registers: address 0, 9600 8N1, air 2.4k, 200B packets,
 // 22dBm, channel 65, transparent mode, no appended RSSI, key zero.
 const uint8_t cmd[]={0xC2,0,8,0,0,0x62,0,RADIO_CHANNEL,0,0,0};
 radioPort.write(cmd,sizeof(cmd));
 uint8_t reply[11]{};size_t n=0;uint32_t t=millis();
 while(n<sizeof(reply)&&millis()-t<1000) {
  if(radioPort.available())reply[n++]=radioPort.read();else delay(1);
 }
 bool ok=n==11 && reply[0]==0xC1 && reply[1]==0 && reply[2]==8;
 for(int i=3;i<9;i++)ok=ok && reply[i]==cmd[i];
 // Keys are write-only and read back zero; configured zero explicitly.
 if(!auxReady())ok=false;
 io.writeGPIOA(0);delay(10);return auxReady()&&ok;
}
bool gpsInit() {
 // Autobaud without nonvolatile GPS writes. A genuine cold M8N normally uses 9600.
 for(uint32_t baud: {9600UL,38400UL,115200UL}) {
  gpsPort.begin(baud,SERIAL_8N1,20,21);delay(50);
  if(gps.begin(gpsPort,400)){gpsDetected=true;break;}
  gpsPort.end();
 }
 if(!gpsDetected)return false;
 gps.setSerialRate(115200);delay(100);gpsPort.updateBaudRate(115200);delay(50);
 if(!gps.begin(gpsPort,700))return false;
 bool ok=gps.setUART1Output(COM_TYPE_UBX,500);
 // Limit M8N to one constellation to stay within its documented 10Hz rate.
 for(auto id: {SFE_UBLOX_GNSS_ID_GLONASS,SFE_UBLOX_GNSS_ID_BEIDOU,
 SFE_UBLOX_GNSS_ID_GALILEO,SFE_UBLOX_GNSS_ID_SBAS,SFE_UBLOX_GNSS_ID_QZSS,
 SFE_UBLOX_GNSS_ID_IMES}) {
  // Unsupported constellations on an old M8 may NAK. Only disabling supported ones is needed.
  gps.enableGNSS(false,id,400);
 }
 ok=gps.enableGNSS(true,SFE_UBLOX_GNSS_ID_GPS,500)&&ok;
 ok=gps.setNavigationFrequency(10,700)&&ok;
 ok=(gps.getMeasurementRate(500)==100)&&ok;
 ok=(gps.getNavigationRate(500)==1)&&ok;
 ok=gps.setAutoPVTcallbackPtr(pvt,700)&&ok;
 console.printf("GPS configuration: %s. 10 Hz ainda exige medicao de iTOW.\n",ok?"ACK+readback OK":"FALHOU");
 return ok;
}
void receiveAck() {
 static uint8_t buf[sizeof(remo::Frame)];static size_t n=0;
 while(radioPort.available()) {
  uint8_t b=radioPort.read();
  if(n==0 && b!=0x5A)continue;
  if(n==1 && b!=0xA5){n=(b==0x5A)?1:0;continue;}
  buf[n++]=b;
  if(n==sizeof(buf)) {
   remo::Frame f;memcpy(&f,buf,sizeof(f));n=0;
   if(remo::valid(f)&&f.header.kind==remo::ACK&&f.header.session==sessionId
    &&radioSeq>0&&f.header.sequence==radioSeq-1)lastAck=millis();
  }
 }
}
void logger(void*) {
 File file;char path[48];uint32_t part=0,lastMount=0,lastFlush=0;
 uint64_t bytes=0;
 for(;;) {
  if(shutdownDone){vTaskDelay(pdMS_TO_TICKS(50));continue;}
  if(!sdReady && millis()-lastMount>=2000 && !shutdownRequested) {
   lastMount=millis();
   if(file)file.close();SD.end();
   if(SD.begin(7,SPI,4000000)) {
    snprintf(path,sizeof(path),"/R%08lX%08lX_%03lu.bin",(unsigned long)(sessionId>>32),(unsigned long)sessionId,(unsigned long)part++);
    file=SD.open(path,FILE_WRITE);sdReady=(bool)file;bytes=0;
    if(sdReady)console.printf("SD: %s\n",path);
   }
   if(!sdReady)sdErrors++;
  }
  remo::Frame f;
  if(xQueueReceive(logQueue,&f,pdMS_TO_TICKS(5))==pdPASS) {
   if(sdReady) {
    if(file.write((uint8_t*)&f,sizeof(f))!=sizeof(f)) {sdReady=false;sdErrors++;file.close();}
    else bytes+=sizeof(f);
   }else sdErrors++; // Explicit lost-record counter. No claim of storage with broken card.
  }
  if(sdReady && millis()-lastFlush>=1000) {
   file.flush();lastFlush=millis();
   // flush() returns void in Arduino SD. Write success cannot certify media integrity.
   if(bytes>=256ULL*1024*1024) {file.close();sdReady=false;}
  }
  if(shutdownRequested && uxQueueMessagesWaiting(logQueue)==0) {
   if(file){file.flush();file.close();}sdReady=false;shutdownDone=true;
  }
 }
}
void setup() {
 console.begin(115200);delay(200);
 started=nowms();
 Preferences prefs;prefs.begin("remo",false);
 uint32_t boot=prefs.getUInt("boot",0)+1;prefs.putUInt("boot",boot);prefs.end();
 sessionId=((ESP.getEfuseMac()&0xffffffffULL)<<32)|boot;
 state.battery_mv=UINT16_MAX;
 state.ax=state.ay=state.az=state.gx=state.gy=state.gz=state.temperature_c=NAN;
 logQueue=xQueueCreate(512,sizeof(remo::Frame));
 if(!logQueue){console.println("ERRO: sem buffer de gravacao");while(true)delay(1000);}
 Wire.begin(0,1,400000);Wire.setTimeOut(20);
 ioReady=io.begin_I2C(0x20,&Wire);
 adcReady=adc.begin(0x48,&Wire);
 if(adcReady){adc.setGain(GAIN_ONE);adc.setDataRate(RATE_ADS1115_128SPS);}
 imuReady=imu.begin(0x68,&Wire);
 if(imuReady) {
  imu.setAccelerometerRange(MPU6050_RANGE_4_G);imu.setGyroRange(MPU6050_RANGE_500_DEG);
  imu.setFilterBandwidth(MPU6050_BAND_21_HZ);imu.setSampleRateDivisor(19);
 }
 radioReady=radioInit();
 gpsPort.setRxBufferSize(8192);
 gpsConfigured=gpsInit();rateStart=millis();
 SPI.begin(4,5,6,7);
 if(xTaskCreate(logger,"sdwriter",6144,nullptr,1,nullptr)!=pdPASS) {
  console.println("ERRO: tarefa SD");while(true)delay(1000);
 }
 console.printf("sessao=%016llX frame=%u bytes radio TX=%s\n",sessionId,sizeof(remo::Frame),RADIO_TX_ENABLED?"ON":"OFF seguro");
}
void loop() {
 static uint32_t imuAt=0,healthAt=0,radioAt=0,adcAt=0;
 static bool adcPending=false;
 uint32_t now=millis();
 if(shutdownRequested){
  if(shutdownDone) {
   if(ioReady){io.writeGPIOA(3);delay(20);}
   console.println("Bateria baixa: SD fechado. Desconecte a bateria apos o treino.");
   console.flush();esp_deep_sleep_start();
  }
  delay(20);return;
 }
 if(gpsDetected){gps.checkUblox();gps.checkCallbacks();}
 if(imuReady && now-imuAt>=20) {
  if(imuAt && now-imuAt>=40)state.imu_missed+=(now-imuAt)/20-1;
  imuAt=now;sensors_event_t a,g,t;
  if(imu.getEvent(&a,&g,&t)) {
   state.ax=a.acceleration.x;state.ay=a.acceleration.y;state.az=a.acceleration.z;
   state.gx=g.gyro.x;state.gy=g.gyro.y;state.gz=g.gyro.z;state.temperature_c=t.temperature;
   lastImu=now;logSample(remo::IMU);
  }else imuReady=false;
 }
 if(adcReady && !adcPending && now-adcAt>=1000) {
  adcAt=now;adc.startADCReading(ADS1X15_REG_CONFIG_MUX_SINGLE_0,false);adcPending=true;
 }
 if(adcPending && now-adcAt>=10 && adc.conversionComplete()) {
  int16_t raw=adc.getLastConversionResults();adcPending=false;
  float v=adc.computeVolts(raw)*2.0f;
  if(v>2.5f && v<4.5f){state.battery_mv=lroundf(v*1000);lastBatteryValid=now;lowCount=(v<3.35f)?lowCount+1:0;}
  else {state.battery_mv=UINT16_MAX;lowCount=0;}
  if(lowCount>=10) {logSample(remo::HEALTH);shutdownRequested=true;}
 }
 if(adcPending && now-adcAt>200){adcPending=false;state.battery_mv=UINT16_MAX;}
 if(now-rateStart>=10000) {
  state.gps_count_10s=rateCount;state.gps_bad_dt_10s=rateBad;
  rateOK=gpsConfigured && rateCount>=98 && rateCount<=102 && rateBad==0;
  console.printf("10Hz: NAV-PVT=%lu/10s dt!=100ms=%lu resultado=%s quedas=%lu SD=%d bateria=%u mV\n",
   (unsigned long)rateCount,(unsigned long)rateBad,rateOK?"PASS":"FAIL",(unsigned long)state.queue_drops,sdReady,state.battery_mv);
  rateCount=rateBad=0;rateStart=now;
 }
 if(ioReady && radioReady)receiveAck();
 if(now-healthAt>=1000){healthAt=now;logSample(remo::HEALTH);}
 if(now-radioAt>=RADIO_INTERVAL_MS) {
  radioAt=now;
  // No waits/retries on air. GPS + local log never depend on shore ACK.
  if(RADIO_TX_ENABLED && radioReady && io.digitalRead(IO_AUX) && radioPort.availableForWrite()>=(int)sizeof(remo::Frame)) {
   remo::Frame f=frame(remo::TELEMETRY,radioSeq++);
   radioPort.write((uint8_t*)&f,sizeof(f));
  }else state.radio_skips++;
 }
 delay(1);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik