Community project

GPS de Remo da Margem

ESP32
Photo of GPS de Remo da Margem
Generated with AI

Gustavo

Published October 22, 2026

GPS de Remo da Margem is a long-range wireless GPS tracking system for boats, built around an ESP32 microcontroller and EBYTE E220-900T22D LoRa radio module operating at 915 MHz. The system captures boat position data and transmits it wirelessly to a shore-based receiver, enabling real-time tracking of remote watercraft with extended range beyond standard WiFi or cellular coverage.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for building the boat-mounted transmitter unit. Firmware is included to handle GPS data acquisition, LoRa radio control, and robust frame transmission with error detection. Builders will learn how to properly power the radio module, configure UART communication, manage antenna connections, and validate the system before deployment.

Wiring diagram

Wiring diagram for GPS de Remo da Margem

Gather all the parts

QtyComponent
1

EBYTE E220-900T22D (LLCC68)

Definido; compra pendente. Rádio UART, VCC 5 V, lógica 3,3 V. Pinagem do manual EBYTE v1.0; confirmar revisão recebida.

1

Resistor 10 kohm 1/4 W

10 kohm

Proposto; mantém M0 em zero durante reset.

1

Resistor 10 kohm 1/4 W

10 kohm

Proposto; mantém M1 em zero durante reset.

1

Capacitor eletrolítico 100 uF 10 V

100 uF / 10 V

Proposto; reserva local de corrente no rádio; respeitar polaridade.

1

Capacitor cerâmico 100 nF 50 V

100 nF

Proposto; desacoplamento rápido junto aos pinos 6 e 7 do rádio.

1

Antena 915 MHz 50 ohm SMA macho convencional

Proposta; vertical 2–3 dBi, não RP-SMA; modelo e faixa útil pendentes de confirmação antes da compra. Conexão coaxial, nunca por jumper.

Assemble it in 7 steps

1. Conferir revisão e desconectar a energia

Este é um projeto para revisão de bancada, não um circuito já homologado ou ensaiado. Desconecte USB. Confira a sequência dos sete pinos pela serigrafia do E220: 1 M0, 2 M1, 3 RXD, 4 TXD, 5 AUX, 6 VCC, 7 GND. O site atual chama o pino 5 de TO; o manual v1.0 chama AUX. Não conte pinos olhando uma foto espelhada. Use uma protoboard de 830 pontos, jumpers curtos e placas com pinos já soldados.

  • Se a revisão recebida não corresponder ao manual, não energize até revisar as ligações. Inverter VCC/GND pode destruir o rádio.

2. Preparar os trilhos de alimentação

Use somente USB-C da Super Mini ligado a uma porta USB do Raspberry Pi 4. Com o rádio ainda fora, confirme com multímetro que o pino 5V da Super Mini fornece aproximadamente 5 V quando ligada por USB. Desconecte novamente. Ligue esse pino ao trilho positivo e GND ao trilho negativo. Confira se os trilhos da protoboard são divididos no meio; faça pontes apenas entre segmentos de mesma polaridade. Não ligue 5 V aos GPIOs ou ao pino 3V3.

  • Não ligue outra fonte ao pino 5V enquanto o USB estiver conectado: há risco de retorno de corrente para o Pi. Não alimente o rádio pelo pino 3V3 desta placa.

3. Alimentar e desacoplar o rádio

E220 pino 6 VCC ao trilho de 5 V; pino 7 GND ao trilho negativo. Coloque o cerâmico de 100 nF diretamente entre esses pinos, com terminais curtos. Coloque o eletrolítico de 100 uF em paralelo: terminal + em VCC, terminal - em GND. A faixa no corpo do eletrolítico costuma marcar o negativo. Mantenha os fios de energia curtos, de preferência abaixo de 10 cm.

  • Eletrolítico invertido pode aquecer, vazar ou romper.

4. Cruzar transmissão e recepção UART

E220 RXD pino 3 → GPIO7 (recebe comandos do ESP32). E220 TXD pino 4 → GPIO10 (entrega os pacotes ao ESP32). Todos os sinais são de 3,3 V apesar de VCC ser 5 V. O terra é comum. Não use adaptador serial de 5 V. Não use os pinos 18/19: pertencem ao USB.

5. Ligar controles e sinal de pronto

E220 M0 pino 1 → GPIO4; M1 pino 2 → GPIO5. Um resistor de 10 kohm entre M0 e GND e outro entre M1 e GND mantém os controles definidos durante reset. E220 AUX pino 5 → GPIO6 (ocupado em zero, pronto em um). Não coloque pull-up de 5 V em AUX. GPIO2, GPIO8 e GPIO9 ficam livres de conexões externas.

6. Conectar a antena sem usar a protoboard

Rosqueie a antena de 915 MHz e 50 ohm no SMA-K do E220. A proposta é SMA macho convencional com pino central, não RP-SMA. Centro e blindagem são as duas ligações coaxiais representadas no esquema; não solde jumpers de RF na protoboard. Fixe a antena vertical e alivie o esforço mecânico para não arrancar o conector.

  • Nunca opere um transmissor sem antena ou carga adequada. Modelo, faixa útil e encaixe físico ainda precisam ser confirmados na compra.

7. Revisar antes do primeiro uso

Com tudo desligado, confira continuidade de GND, polaridade dos capacitores e ausência de curto sustentado entre 5 V e GND. Revise a tabela de conexões no guia. Depois conecte USB, meça VCC/GND do rádio e seus sinais com instrumento adequado: alimentação próxima de 5 V, sinais até 3,3 V. Se houver aquecimento, queda de tensão ou sinais de 5 V, desligue imediatamente. Mantenha a montagem seca e afastada da borda da água.

  • Protoboard é para bancada seca; não é instalação definitiva na margem. Alcance de 3 km e conformidade Anatel permanecem pendentes.

Review all connections

1. Connections between "radio" and "ESP32"

FunctionradioESP32
digitalM0GPIO 4
digitalM1GPIO 5
digitalAUXGPIO 6
uartRXDGPIO 7
uartTXDGPIO 10
powerVCC5V
groundGNDGND
groundSMA_SHELLGND

2. Connections between "r_m0" and "ESP32"

Functionr_m0ESP32
digital1 → EBYTE E220-900T22D (LLCC68) M0EXT
ground2GND

3. Connections between "r_m1" and "ESP32"

Functionr_m1ESP32
digital1 → EBYTE E220-900T22D (LLCC68) M1EXT
ground2GND

4. Connections between "c_bulk" and "ESP32"

Functionc_bulkESP32
power+ → EBYTE E220-900T22D (LLCC68) VCCEXT
ground-GND

5. Connections between "c_hf" and "ESP32"

Functionc_hfESP32
power1 → EBYTE E220-900T22D (LLCC68) VCCEXT
ground2GND

6. Connections between "antenna" and "ESP32"

FunctionantennaESP32
analogCENTER → EBYTE E220-900T22D (LLCC68) SMA_CENTEREXT
groundSHELL → EBYTE E220-900T22D (LLCC68) SMA_SHELLEXT

Deploy the firmware

#include <Arduino.h>
#include <HardwareSerial.h>
#include <HWCDC.h>
#include <stdarg.h>
#include <string.h>
#include "remo_protocol.h"

constexpr int PIN_M0=4, PIN_M1=5, PIN_AUX=6, PIN_RADIO_TX=7, PIN_RADIO_RX=10;
constexpr uint16_t BOAT_ID=1;
constexpr uint8_t CHANNEL=65; // 850.125 + 65 = 915.125 MHz. Proposed, not regulatory approval.
constexpr uint32_t LOSS_MS=5000, STATUS_MS=2000, FRAME_TIMEOUT_MS=500;
HardwareSerial radioUart(1);
HWCDC piUsb; // Explicit ESP32-C3 native USB Serial/JTAG; independent of Serial alias.
bool radioOK=false, seen=false, linkUp=false;
uint32_t lastFresh=0, lastValid=0, lastStatus=0, session=0, sequence=0;
uint32_t sessionsRetired[8]={}, retiredIndex=0;
uint64_t sequenceWindow=0;
uint32_t rxBytes=0, crcErrors=0, headerErrors=0, semanticErrors=0, foreign=0;
uint32_t valid=0, accepted=0, gaps=0, duplicate=0, late=0, staleSession=0;
uint32_t partialTimeout=0, usbDrops=0, auxBusySince=0, auxLong=0;
uint8_t frame[Remo::SIZE];
size_t frameUsed=0;
uint32_t lastByte=0;
// Bounded USB queue; unplugged Pi must never stall radio reception.
char usbQueue[8][1000];
uint16_t usbLen[8]={};
uint8_t qHead=0,qTail=0,qCount=0;
uint16_t qOffset=0;
void emit(const char *fmt, ...) {
  if (qCount==8) { usbDrops++; return; }
  va_list ap; va_start(ap,fmt);
  int n=vsnprintf(usbQueue[qHead],sizeof(usbQueue[0])-1,fmt,ap); va_end(ap);
  if(n<0 || size_t(n)>=sizeof(usbQueue[0])-1) { usbDrops++; return; }
  usbQueue[qHead][n++]='\n'; usbQueue[qHead][n]=0;
  usbLen[qHead]=n; qHead=(qHead+1)%8; qCount++;
}
void drainUSB() {
  if(!piUsb) { // discard partial line too; host resynchronizes at next newline
    if(qCount) { usbDrops+=qCount; qHead=qTail=qCount=0; qOffset=0; }
    return;
  }
  if(!qCount) return;
  int room=piUsb.availableForWrite();
  if(room<=0) return;
  size_t n=usbLen[qTail]-qOffset;
  if(n>size_t(room)) n=room;
  // At most 64 bytes per iteration: service the UART promptly.
  if(n>64) n=64;
  qOffset+=piUsb.write((const uint8_t*)usbQueue[qTail]+qOffset,n);
  if(qOffset==usbLen[qTail]) { qTail=(qTail+1)%8; qCount--; qOffset=0; }
}
bool waitReady(uint32_t timeout=2000) {
  uint32_t start=millis(), highAt=0; bool high=false;
  while(uint32_t(millis()-start)<timeout) {
    if(digitalRead(PIN_AUX)) {
      if(!high) { high=true; highAt=millis(); }
      if(uint32_t(millis()-highAt)>=3) return true;
    } else high=false;
    drainUSB(); delay(1);
  }
  return false;
}
bool mode(bool m0,bool m1) {
  if(!waitReady()) return false;
  digitalWrite(PIN_M0,m0); digitalWrite(PIN_M1,m1);
  delay(20); // includes mode sampling interval; then require AUX stable >2 ms
  return waitReady();
}
bool exchange(const uint8_t *cmd,size_t n,uint8_t *reply,size_t count) {
  if(!waitReady()) return false;
  while(radioUart.available()) radioUart.read();
  radioUart.write(cmd,n); radioUart.flush();
  size_t used=0; uint32_t start=millis();
  while(used<count && uint32_t(millis()-start)<1000) {
    if(radioUart.available()) reply[used++]=radioUart.read();
    drainUSB(); delay(1);
  }
  return used==count && reply[0]==0xc1 && reply[1]==cmd[1] && reply[2]==cmd[2] && waitReady();
}
bool configureRadio() {
  // Both supported EBYTE maps use 9600 8N1 in configuration mode.
  // First READ only: never write an unrecognized map.
  uint8_t read6[]={0xc1,0x00,0x06}, reply[12]={};
  if(!exchange(read6,3,reply,9)) return false;
  uint8_t *r=reply+3;
  int map=0;
  // Accept factory/default 9600 8N1 + 2.4 kbps only, not arbitrary prior configurations.
  if(r[2]==0x62 && (r[3]&0xfc)==0) map=8; // legacy: REG0 at 02, total 8
  else if(r[2]==0 && r[3]==0x62 && (r[4]&0xfc)==0) map=9; // NETID at 02, REG0 at 03
  else {
    emit("{\"event\":\"radio_error\",\"reason\":\"unknown_map_or_nondefault\",\"registers\":\"%02X%02X%02X%02X%02X%02X\"}",r[0],r[1],r[2],r[3],r[4],r[5]);
    return false;
  }
  // Volatile C2: address 0001, NETID 00 if present, 9600 8N1,
  // air 2.4k, 200-byte subpacket, noise RSSI OFF, TX 10dBm,
  // transparent, appended RSSI OFF, LBT OFF, WOR code 3, crypt 0000.
  uint8_t cmd[12]={0xc2,0x00,uint8_t(map)};
  uint8_t legacy[]={0x00,0x01,0x62,0x03,CHANNEL,0x03,0x00,0x00};
  uint8_t netid[]={0x00,0x01,0x00,0x62,0x03,CHANNEL,0x03,0x00,0x00};
  memcpy(cmd+3,map==8 ? legacy : netid,map);
  if(!exchange(cmd,map+3,reply,map+3) || memcmp(cmd+3,reply+3,map)) return false;
  // Read back readable registers (CRYPT reads as 0 and is not proof of key).
  if(!exchange(read6,3,reply,9) || memcmp(cmd+3,reply+3,6)) return false;
  emit("{\"event\":\"radio_config\",\"map_bytes\":%d,\"channel\":%u,\"frequency_mhz\":915.125,\"uart_bps\":9600,\"air_bps\":2400,\"rssi\":null,\"volatile\":true}",map,CHANNEL);
  return mode(false,false);
}
void status() {
  emit("{\"event\":\"status\",\"v\":1,\"uptime_ms\":%lu,\"radio_ok\":%s,\"link\":\"%s\",\"fresh_age_ms\":%ld,\"valid_age_ms\":%ld,\"aux\":%d,\"rx_bytes\":%lu,\"valid\":%lu,\"accepted\":%lu,\"gaps_inferred\":%lu,\"duplicates\":%lu,\"late\":%lu,\"stale_session\":%lu,\"foreign\":%lu,\"crc_errors\":%lu,\"header_errors\":%lu,\"semantic_errors\":%lu,\"partial_timeout\":%lu,\"usb_drops\":%lu,\"aux_long\":%lu,\"rssi_dbm\":null}",
    (unsigned long)millis(), radioOK?"true":"false",linkUp?"up":(seen?"lost":"waiting"),
    seen?long(uint32_t(millis()-lastFresh)):-1L,valid?long(uint32_t(millis()-lastValid)):-1L,
    digitalRead(PIN_AUX),(unsigned long)rxBytes,(unsigned long)valid,(unsigned long)accepted,
    (unsigned long)gaps,(unsigned long)duplicate,(unsigned long)late,(unsigned long)staleSession,
    (unsigned long)foreign,(unsigned long)crcErrors,(unsigned long)headerErrors,(unsigned long)semanticErrors,
    (unsigned long)partialTimeout,(unsigned long)usbDrops,(unsigned long)auxLong);
}
void acceptPacket(const Remo::Telemetry &t) {
  if(t.boat!=BOAT_ID) { foreign++; return; }
  valid++; lastValid=millis();
  bool newSession=!seen || session!=t.session;
  if(newSession) {
    for(uint32_t s:sessionsRetired) if(s==t.session) { staleSession++; return; }
    if(seen) { sessionsRetired[retiredIndex++%8]=session; }
    session=t.session; sequence=t.sequence; sequenceWindow=1;
    emit("{\"event\":\"session\",\"boat\":%u,\"session\":%lu}",t.boat,(unsigned long)session);
  } else {
    uint32_t delta=t.sequence-sequence;
    if(!delta) { duplicate++; return; }
    if(delta>=0x80000000u) {
      uint32_t behind=sequence-t.sequence;
      if(behind<64 && (sequenceWindow & (uint64_t(1)<<behind))) duplicate++;
      else { late++; if(behind<64) sequenceWindow |= uint64_t(1)<<behind; }
      return; // no link recovery from stale frames
    }
    gaps += delta-1;
    sequenceWindow=delta>=64 ? 1 : (sequenceWindow<<delta)|1;
    sequence=t.sequence;
  }
  seen=true; lastFresh=millis(); accepted++;
  if(!linkUp) { linkUp=true; emit("{\"event\":\"link\",\"state\":\"up\"}"); }
  emit("{\"event\":\"telemetry\",\"v\":1,\"boat\":%u,\"session\":%lu,\"seq\":%lu,\"timestamp_ms\":%llu,\"time_base\":\"%s\",\"rx_uptime_ms\":%lu,\"fix\":%s,\"lat_e7\":%ld,\"lon_e7\":%ld,\"speed_cms\":%u,\"course_cdeg\":%u,\"cadence_dspm\":%u,\"battery_mv\":%u,\"distance_cm\":%lu,\"accuracy_cm\":%u,\"satellites\":%u,\"flags\":%u,\"crc_ok\":true,\"rssi_dbm\":null}",
    t.boat,(unsigned long)t.session,(unsigned long)t.sequence,(unsigned long long)t.timestamp_ms,
    (t.flags&Remo::UTC)?"utc":"boat_uptime",(unsigned long)millis(),(t.flags&Remo::FIX)?"true":"false",
    (long)t.lat_e7,(long)t.lon_e7,t.speed_cms,t.course_cdeg,t.cadence_dspm,t.battery_mv,
    (unsigned long)t.distance_cm,t.accuracy_cm,t.satellites,t.flags);
}
void discardFirst() { if(frameUsed) { frameUsed--; memmove(frame,frame+1,frameUsed); } }
void receiveByte(uint8_t b) {
  rxBytes++; lastByte=millis(); frame[frameUsed++]=b;
  // A sliding window recovers from garbage, truncation, and concatenated frames.
  while(frameUsed) {
    if(frame[0]!='R') { discardFirst(); continue; }
    if(frameUsed<2) return;
    if(frame[1]!='M') { discardFirst(); continue; }
    if(frameUsed<6) return;
    if(!Remo::header(frame)) { headerErrors++; discardFirst(); continue; }
    if(frameUsed<Remo::SIZE) return;
    if(Remo::crc32(frame,48)!=Remo::u32(frame+48)) { crcErrors++; discardFirst(); continue; }
    Remo::Telemetry t;
    if(Remo::decode(frame,t)) acceptPacket(t); else semanticErrors++;
    frameUsed=0; return;
  }
}
void setup() {
  // No Wi-Fi, no Bluetooth, no onboard LED/BOOT access.
  digitalWrite(PIN_M0,HIGH); pinMode(PIN_M0,OUTPUT);
  digitalWrite(PIN_M1,HIGH); pinMode(PIN_M1,OUTPUT);
  pinMode(PIN_AUX,INPUT); // output from E220, never driven by MCU
  piUsb.begin(115200); piUsb.setTxTimeoutMs(0);
  radioUart.setRxBufferSize(2048);
  radioUart.begin(9600,SERIAL_8N1,PIN_RADIO_RX,PIN_RADIO_TX);
  delay(1000); // allow supply and module initialization, never wait for USB host
  emit("{\"event\":\"boot\",\"firmware\":\"remo-shore-1.0\",\"protocol\":1}");
  bool self=Remo::crc32((const uint8_t*)"123456789",9)==0xcbf43926u;
  Remo::Telemetry t={1,0x12345678u,7,1760000000123ULL,-235500000,-466300000,325,12345,285,4010,123456,120,12,3}, d;
  uint8_t p[Remo::SIZE]; Remo::encode(t,p);
  self=self && Remo::u32(p+48)==0x9acb2b72u && Remo::decode(p,d) && d.lat_e7==t.lat_e7 && d.timestamp_ms==t.timestamp_ms;
  p[30]^=4; self=self && !Remo::decode(p,d);
  emit("{\"event\":\"self_test\",\"ok\":%s}",self?"true":"false");
  radioOK=self && waitReady() && configureRadio();
  if(!radioOK) {
    digitalWrite(PIN_M0,HIGH); digitalWrite(PIN_M1,HIGH);
    emit("{\"event\":\"radio_error\",\"reason\":\"configuration_failed_radio_held_in_sleep\"}");
  }
}
void loop() {
  // Do not gate UART reads on AUX: low can mean incoming serial data.
  for(int i=0;radioOK && i<256 && radioUart.available();i++) receiveByte(radioUart.read());
  uint32_t now=millis();
  if(frameUsed && uint32_t(now-lastByte)>FRAME_TIMEOUT_MS) { frameUsed=0; partialTimeout++; }
  if(linkUp && uint32_t(now-lastFresh)>LOSS_MS) {
    linkUp=false; emit("{\"event\":\"link\",\"state\":\"lost\",\"timeout_ms\":5000}");
  }
  if(!digitalRead(PIN_AUX)) {
    if(!auxBusySince) auxBusySince=now ? now : 1;
    else if(uint32_t(now-auxBusySince)>2000) { auxLong++; auxBusySince=now; }
  } else auxBusySince=0;
  if(uint32_t(now-lastStatus)>=STATUS_MS) { lastStatus=now; status(); }
  // Host commands are diagnostic only. Never forward USB bytes to the radio.
  static char command[16]; static uint8_t pos=0; static bool overflow=false;
  for(int i=0;i<32 && piUsb.available();i++) {
    int c=piUsb.read();
    if(c=='\r') continue;
    if(c=='\n') {
      command[pos]=0;
      if(!overflow && (!strcmp(command,"STATUS") || !strcmp(command,"PING"))) status();
      pos=0; overflow=false;
    } else if(pos<sizeof(command)-1 && !overflow) command[pos++]=char(c);
    else overflow=true;
  }
  drainUSB(); 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