MQTT Tracker · SIM7080G
XIAO nRF52840 · GPS + LTE-M → MQTTS
Tracker che pubblica la posizione (u-blox DAN-F10N dual-band, parser binario UBX NAV-PVT) su mqtt.vislagps.com via TLS 8883, con SIM7080G in LTE-M. MQTT via TinyGSM + PubSubClient (socket TLS del modem) — la via collaudata, perché lo stack SMQTT nativo del SIM7080 dà 'operation not allowed'. Batch di posizioni + OLED di stato.
Seeed XIAO nRF52840 Arduino · Seeed nRF52 core
Componenti usati
nRF52840
MCU · BLE Cortex-M4F
Nordic Semiconductor
SIM7080G
Modem LTE-M / NB-IoT
SIMCom
DAN-F10N
GNSS dual-band L1/L5
u-blox
SSD1306
OLED 128×64 (SW I2C)
—
mqtt_tracker_sim7080.ino
307 righe GitHub
// VISLA — XIAO nRF52840 + SIM7080G (LTE-M) + GPS u-blox DAN-F10N (dual-band L1+L5) : tracker MQTTS
// >>> Layer MQTT via TinyGSM + PubSubClient (socket TLS del modem, AT+CASSLCFG/CAOPEN sotto il cofano).
// Il comando NATIVO SMQTT (SMCONF/SMCONN) su questa fw SIM7080 1951B16 dà sempre "+CME ERROR: operation
// not allowed" → vicolo cieco. TinyGsmClientSecure+PubSubClient è la via COLLAUDATA (vedi lilygo-t-sim7070g
// firmware-mqtt-test): TLS su 8883 senza upload CA (il modem accetta il cert Let's Encrypt). GPS/buffer identici.
// Adattato per Nordic nRF52840. Pubblica visla/<IMEI>/pos {batch[...]} su mqtt.vislagps.com:8883 (TLS).
// HW: GPS u-blox su UARTE1 (D0/D10), OLED SW-I2C (D5/D4), modem SIM7080G su Serial1 (D6=TX D7=RX), batteria XIAO.
// ⚠️ L'IMEI del SIM7080G (es. 860016049515153) va registrato nel broker mosquitto (password + ACL visla/<imei>/*).
#define TINY_GSM_MODEM_SIM7080
#define TINY_GSM_RX_BUFFER 1024
#include <Arduino.h>
#include <Adafruit_TinyUSB.h>
#include <U8x8lib.h>
#include <TinyGsmClient.h>
#include <PubSubClient.h>
// GPS via UBX NAV-PVT (parser binario, niente TinyGPS++): pos+vel+tempo+n.sat+accuracy in un colpo.
struct Pt { uint32_t epoch; float lat, lon, spd, pdop, hacc; uint8_t sats; }; // hacc = accuracy orizz. (m)
// Fix corrente decodificato da NAV-PVT
struct Fix {
bool valid, timeValid;
double lat, lon;
float spd; // knots
float pdop, hacc; // pDOP · horizontal accuracy (m)
uint8_t sats;
uint16_t year; uint8_t month, day, hour, min, sec;
uint8_t jam, spoof; // SEC-SIG: jammingState (0-3) · spoofingState (0-3) → antifurto
float plh; // NAV-PL: protection level orizzontale (m) = accuracy garantita 95%
} fix;
// ===== HW =====
U8X8_SSD1306_128X64_NONAME_SW_I2C oled(D5, D4, U8X8_PIN_NONE);
Uart gpsSerial(NRF_UARTE1, UARTE1_IRQn, D0, D10);
extern "C" { void UARTE1_IRQHandler() { gpsSerial.IrqHandler(); } }
#define BUZZER A3
#define BUTTON D1
#ifndef VBAT_ENABLE
#define VBAT_ENABLE 14
#endif
#ifndef PIN_VBAT
#define PIN_VBAT 31
#endif
// ===== Rete / broker =====
#define APN "iot.1nce.net"
#define MQTT_HOST "mqtt.vislagps.com"
#define MQTT_PORT 8883
// ⚠️ Password per-device legata all'IMEI del modem (registrata nel broker mosquitto). Username = IMEI.
#define MQTT_PASS "2de8a9d5b4c3f0eb7e9d"
#define SAMPLE_MS 1000 // campiona GPS ogni 1s (TEST: traccia densa per giudicare accuracy)
#define PUB_PERIOD_MS 5000 // pubblica batch ogni 5s
#define BUF_MAX 600 // ring buffer store-and-forward (~10 min @1Hz di tolleranza ai buchi)
#define BATCH_MAX 12 // max punti per batch (12*~90B + envelope < 2048 buffer PubSubClient)
// ===== Modem / MQTT (TinyGSM + PubSubClient) =====
TinyGsm modem(Serial1);
TinyGsmClientSecure secureClient(modem); // TLS via SIM7080 (CAOPEN SSL)
PubSubClient mqtt(secureClient);
// ===== Stato =====
String imei="?", topicPub, topicStatus;
float batV=0; int batLevel=0, rssi=99;
bool displayOn=true;
int lastBtn=HIGH;
uint32_t lastPub=0, lastDbg=0, lastSample=0;
// ===== Ring buffer store-and-forward =====
Pt buf[BUF_MAX]; int bufHead=0, bufCount=0;
void bufPush(const Pt& p){
buf[(bufHead+bufCount)%BUF_MAX]=p;
if(bufCount<BUF_MAX) bufCount++; else bufHead=(bufHead+1)%BUF_MAX; // pieno: sovrascrive il più vecchio
}
Pt& bufAt(int i){ return buf[(bufHead+i)%BUF_MAX]; }
void bufPop(int n){ bufHead=(bufHead+n)%BUF_MAX; bufCount-=n; }
// Unix epoch da data/ora GPS (UTC), 0 se non valido
long daysFromCivil(int y, unsigned m, unsigned d){
y -= m <= 2; long era=(y>=0?y:y-399)/400; unsigned yoe=y-era*400;
unsigned doy=(153*(m+(m>2?-3:9))+2)/5 + d-1; unsigned doe=yoe*365+yoe/4-yoe/100+doy;
return era*146097 + (long)doe - 719468;
}
uint32_t gpsEpoch(){
if(!fix.timeValid || fix.year<2020) return 0;
long days=daysFromCivil(fix.year,fix.month,fix.day);
return (uint32_t)(days*86400L + fix.hour*3600L + fix.min*60L + fix.sec);
}
// ===== Batteria =====
float readVbat(){ analogRead(PIN_VBAT); int mx=0; for(int i=0;i<250;i++){ int r=analogRead(PIN_VBAT); if(r>mx)mx=r; delay(1); } return mx*(3.6f/4096.0f)*(1510.0f/510.0f); } // MAX su 250ms = tensione a RIPOSO
int lipoPercent(float v){ float p=123.0f-123.0f/powf(1.0f+powf(v/3.7f,80.0f),0.165f);
if(p>100)p=100; if(p<0)p=0; return (int)(p+0.5f); }
// ===== Parser UBX NAV-PVT (class 0x01 id 0x07, 92 byte). Aggiorna fix{} a ogni epoca. =====
// Il DAN-F10N di DEFAULT manda NMEA ($GNGGA/$GNRMC/$GxGSV...) a 38400 → parso quello: NON serve configurarlo
// in UBX (che richiederebbe il filo D10→GPS RX + chiavi CFG che il F10 spesso NAK-a). GGA=pos/sat/fix, RMC=vel/data.
uint32_t gpsRx=0, gpsUbx=0, gpsNmea=0; // DIAG: byte grezzi dal GPS su D0 (0 = cablaggio TX rotto)
// ddmm.mmmm + emisfero → gradi decimali
static double nmeaCoord(const char* s, char hemi){
if(!s || !*s) return 0;
double v=atof(s); int deg=(int)(v/100); double m=v-deg*100; double d=deg+m/60.0;
if(hemi=='S'||hemi=='W') d=-d; return d;
}
// estrae il campo n-esimo (0-based) da una frase NMEA (separatori ',' fino a '*')
static bool nmeaField(const char* buf, int n, char* out, int outsz){
int f=0, k=0;
for(const char* p=buf; ; p++){
if(*p==',' || *p=='*' || *p==0){ if(f==n){ out[k]=0; return true; } if(*p==0||*p=='*') return false; f++; k=0; continue; }
if(f==n && k<outsz-1) out[k++]=*p;
}
}
void parseNMEA(const char* buf){
if(strstr(buf,"GGA")){
char la[16],ns[4],lo[16],ew[4],f[12];
nmeaField(buf,2,la,sizeof(la)); nmeaField(buf,3,ns,sizeof(ns));
nmeaField(buf,4,lo,sizeof(lo)); nmeaField(buf,5,ew,sizeof(ew));
nmeaField(buf,6,f,sizeof(f)); int q=atoi(f);
if(nmeaField(buf,7,f,sizeof(f))) fix.sats=atoi(f);
if(nmeaField(buf,8,f,sizeof(f))) fix.pdop=atof(f); // HDOP (uso come "hdop")
fix.valid = (q>=1);
if(q>=1){ fix.lat=nmeaCoord(la, ns[0]); fix.lon=nmeaCoord(lo, ew[0]); }
fix.hacc = fix.pdop*5.0f; // stima grezza (NMEA non dà hAcc)
char tm[12]; if(nmeaField(buf,1,tm,sizeof(tm)) && strlen(tm)>=6){
fix.hour=(tm[0]-'0')*10+(tm[1]-'0'); fix.min=(tm[2]-'0')*10+(tm[3]-'0'); fix.sec=(tm[4]-'0')*10+(tm[5]-'0'); }
}
else if(strstr(buf,"RMC")){
char st[4],sp[12],dt[12];
nmeaField(buf,2,st,sizeof(st)); // status A=valid V=void
if(nmeaField(buf,7,sp,sizeof(sp))) fix.spd=atof(sp); // velocità in nodi
if(nmeaField(buf,9,dt,sizeof(dt)) && strlen(dt)>=6){
fix.day=(dt[0]-'0')*10+(dt[1]-'0'); fix.month=(dt[2]-'0')*10+(dt[3]-'0'); fix.year=2000+(dt[4]-'0')*10+(dt[5]-'0');
fix.timeValid = (st[0]=='A'); }
}
}
void pump(){
static char line[100]; static uint8_t idx=0;
while(gpsSerial.available()){
char c=gpsSerial.read();
gpsRx++; if(c=='$') gpsNmea++;
if(c=='\n'){ line[idx]=0; if(idx>5) parseNMEA(line); idx=0; }
else if(c!='\r'){ if(idx<sizeof(line)-1) line[idx++]=c; else idx=0; }
}
}
// ===== OLED =====
void row(int y,const char* s){ if(displayOn){ oled.clearLine(y); oled.drawString(0,y,s); } }
void beep(int f,int d){ long h=500000L/f,c=(long)d*1000L/(h*2);
for(long i=0;i<c;i++){digitalWrite(BUZZER,HIGH);delayMicroseconds(h);digitalWrite(BUZZER,LOW);delayMicroseconds(h);} }
// ===== Payload JSON (a mano) =====
// Batch di n punti: envelope (imei,battery,volt,rssi,jam,spoof,pl) + array batch[{t,lat,lon,spd,sats,hdop,hacc}]
String buildBatch(int n){
String j="{\"imei\":\""+imei+"\",\"battery\":"+String(batLevel)+",\"volt\":"+String(batV,2)+",\"rssi\":"+String(rssi)+
",\"jam\":"+String(fix.jam)+",\"spoof\":"+String(fix.spoof)+",\"pl\":"+String(fix.plh,1)+",\"batch\":[";
for(int i=0;i<n;i++){ Pt&p=bufAt(i);
if(i) j+=",";
j+="{\"t\":"+String(p.epoch)+",\"lat\":"+String(p.lat,6)+",\"lon\":"+String(p.lon,6)+
",\"spd\":"+String(p.spd,1)+",\"sats\":"+String(p.sats)+
",\"hdop\":"+String(p.pdop,1)+",\"hacc\":"+String(p.hacc,1)+"}"; // hdop=pDOP · hacc=accuracy(m)
}
j+="]}";
return j;
}
// ===== Rete cellulare (TinyGSM) =====
bool netConnect(){
Serial.println("init modem (TinyGSM SIM7080)...");
modem.testAT(2000);
modem.init();
modem.sendAT("+CMEE=2"); modem.waitResponse(2000);
modem.sendAT("+CNMP=38"); modem.waitResponse(3000); // network mode: LTE only
modem.sendAT("+CMNB=1"); modem.waitResponse(3000); // preferred: Cat-M (LTE-M)
imei = modem.getIMEI();
if(imei.length()<15) imei="000000000000000";
topicPub="visla/"+imei+"/pos"; topicStatus="visla/"+imei+"/status";
Serial.println("IMEI: "+imei+" topic: "+topicPub);
{ char b[20]; snprintf(b,sizeof(b),"IMEI %s",imei.substring(7).c_str()); row(2,b); }
Serial.print("attendo rete..."); row(4,"attendo rete..");
if(!modem.waitForNetwork(90000)){ Serial.println(" NO RETE"); row(4,"NO RETE"); return false; }
Serial.println(" ok op="+modem.getOperator()+" CSQ="+String(modem.getSignalQuality()));
Serial.print("PDP/GPRS..."); row(4,"PDP..");
if(!modem.gprsConnect(APN,"","")){ Serial.println(" FALLITO"); row(4,"PDP FAIL"); return false; }
IPAddress ip=modem.localIP();
Serial.printf(" ok IP=%d.%d.%d.%d\n", ip[0],ip[1],ip[2],ip[3]);
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setKeepAlive(60);
mqtt.setBufferSize(2048); // batch grandi (fino a ~1.5KB)
return true;
}
// ===== MQTT (PubSubClient su TinyGsmClientSecure) =====
bool mqttConnect(){
if(!modem.isGprsConnected()){ Serial.println("GPRS giù → rialzo PDP"); if(!netConnect()) return false; }
String cid="visla-"+imei;
Serial.printf("MQTT -> %s:%d TLS clientId=%s user=%s ...\n", MQTT_HOST, MQTT_PORT, cid.c_str(), imei.c_str());
// connect con auth (user=IMEI) + LWT: "offline" retained sul topic status
bool ok = mqtt.connect(cid.c_str(), imei.c_str(), MQTT_PASS, topicStatus.c_str(), 1, true, "offline");
if(ok){
Serial.println("MQTT CONNESSO");
mqtt.publish(topicStatus.c_str(), (const uint8_t*)"online", 6, true); // retained
return true;
}
Serial.printf("MQTT FAIL state=%d\n", mqtt.state()); // -2=connect failed, -4=timeout, 5=not authorized
return false;
}
// ===== Config u-blox DAN-F10N (UBX-CFG-VALSET, layer RAM+BBR) — F10 SPG 6.00 =====
void ubxSend(uint8_t cls,uint8_t id,const uint8_t* p,uint16_t n){
uint8_t h[6]={0xB5,0x62,cls,id,(uint8_t)(n&0xFF),(uint8_t)(n>>8)};
uint8_t a=0,b=0;
for(int i=2;i<6;i++){ a+=h[i]; b+=a; }
for(int i=0;i<n;i++){ a+=p[i]; b+=a; }
gpsSerial.write(h,6); gpsSerial.write(p,n);
gpsSerial.write(a); gpsSerial.write(b); gpsSerial.flush();
}
// VALSET di una singola chiave (valore little-endian, vlen byte). layers=0x03 RAM|BBR
void cfgKey(uint32_t key,uint32_t val,uint8_t vlen){
uint8_t p[12]={0x00,0x03,0x00,0x00}; // version, layers, reserved
p[4]=key; p[5]=key>>8; p[6]=key>>16; p[7]=key>>24; // key ID (LE)
for(uint8_t i=0;i<vlen;i++) p[8+i]=(val>>(8*i))&0xFF; // value (LE)
ubxSend(0x06,0x8A,p,8+vlen); delay(40);
}
void gpsInit(){
delay(300); // lascia bootare il modulo dopo begin()
// NIENTE config UBX: il modulo resta in NMEA default (GGA/RMC/...) che parsiamo direttamente.
// Provo SOLO il dynamic model automotive (NON spegne l'NMEA): se il filo D10→GPS RX c'è viene applicato,
// altrimenti è innocuo. Nessun comando che disabiliti l'NMEA (che ci serve).
cfgKey(0x20110021,4,1); // CFG-NAVSPG-DYNMODEL = 4 (automotive) — opzionale, non rompe l'NMEA
}
// (ubxSend/cfgKey restano definiti ma quasi inutilizzati — il path GPS ora è NMEA)
void setup(){
pinMode(BUZZER,OUTPUT); digitalWrite(BUZZER,LOW); pinMode(BUTTON,INPUT_PULLUP);
pinMode(VBAT_ENABLE,OUTPUT); digitalWrite(VBAT_ENABLE,LOW);
analogReference(AR_DEFAULT); analogReadResolution(12);
Serial.begin(115200); Serial1.begin(115200); gpsSerial.begin(38400); // DAN-F10N default 38400 baud
delay(2000); // lascia risalire la USB CDC dopo il reset-on-open (log non persi)
// ===== PROBE UART (~4s): byte grezzi modem+GPS. SILENZIO su entrambi = alimentazione/GND. =====
Serial.println("\n=== PROBE UART (modem @115200 / GPS @38400) — 4s ===");
for(int k=0;k<4;k++){
while(Serial1.available()) Serial1.read();
Serial1.print("AT\r\n");
String mr=""; uint32_t t=millis(); while(millis()-t<300){ while(Serial1.available()) mr+=(char)Serial1.read(); }
mr.replace("\r","\\r"); mr.replace("\n","\\n");
int gn=0; uint8_t gf=0; bool gg=false; t=millis();
while(millis()-t<600){ while(gpsSerial.available()){ uint8_t b=gpsSerial.read(); if(!gg){gf=b;gg=true;} gn++; } }
Serial.printf("[%2d] MODEM: %d byte [%s] | GPS: %d byte primo=0x%02X %s\n",
k, mr.length(), mr.length()?mr.c_str():"--SILENZIO--", gn, gf, gn?"":"--SILENZIO--");
}
Serial.println("=== fine probe ===\n");
gpsInit(); // configura DAN-F10N: automotive + dual-band L1+L5
oled.begin(); oled.setFont(u8x8_font_chroma48medium8_r);
oled.clear(); oled.drawString(0,0," VISLA MQTT"); row(2,"init modem.."); beep(2000,120);
netConnect();
row(4,"mqtt connect.."); mqttConnect();
bool up=mqtt.connected();
row(4, up?"MQTT ON":"MQTT FAIL");
beep(up?3000:1000, 200);
}
void loop(){
pump();
if(mqtt.connected()) mqtt.loop(); // keepalive + RX MQTT
// bottone: toggle OLED
int btn=digitalRead(BUTTON);
if(lastBtn==HIGH && btn==LOW){ displayOn=!displayOn; oled.setPowerSave(displayOn?0:1);
if(displayOn){ oled.clear(); oled.drawString(0,0," VISLA MQTT"); } beep(displayOn?2600:1500,80); delay(60); }
lastBtn=btn;
// batteria + display ogni 3s
if(millis()-lastDbg>3000){ lastDbg=millis();
batV=readVbat(); batLevel=lipoPercent(batV);
Serial.print("IMEI="); Serial.print(imei);
Serial.print(" mqtt="); Serial.print(mqtt.connected()?"UP":"down");
Serial.print(" bat="); Serial.print(batLevel); Serial.print("% "); Serial.print(batV,2); Serial.print("V");
Serial.print(" sats="); Serial.print(fix.sats);
Serial.print(" fix="); Serial.print(fix.valid?"YES":"no");
Serial.print(" hacc="); Serial.print(fix.hacc,1);
Serial.print("m buf="); Serial.print(bufCount);
Serial.print(" | gpsRx="); Serial.print(gpsRx); Serial.print(" ubx="); Serial.print(gpsUbx); Serial.print(" nmea="); Serial.print(gpsNmea);
Serial.println();
char b[20]; snprintf(b,sizeof(b),"Sat:%02d %s",fix.sats,fix.valid?"FIX":"---"); row(4,b);
char bb[20]; snprintf(bb,sizeof(bb),"Bat %d.%02dV %d%%",(int)batV,(int)((batV-(int)batV)*100+0.5f),batLevel); row(6,bb);
}
// campiona GPS nel buffer (store-and-forward)
if(millis()-lastSample>=SAMPLE_MS){ lastSample=millis();
uint32_t e=gpsEpoch();
if(fix.valid && e){
Pt p; p.epoch=e; p.lat=fix.lat; p.lon=fix.lon;
p.spd=fix.spd; p.sats=fix.sats; p.pdop=fix.pdop; p.hacc=fix.hacc;
bufPush(p);
}
}
// publish batch ogni 5s (svuota il backlog accumulato durante i buchi di copertura)
if(millis()-lastPub>=PUB_PERIOD_MS){ lastPub=millis();
if(!mqtt.connected()){ row(7,"reconnect.."); mqttConnect(); }
if(mqtt.connected() && bufCount>0){ rssi=modem.getSignalQuality();
int n = bufCount<BATCH_MAX ? bufCount : BATCH_MAX;
String j=buildBatch(n);
bool ok=mqtt.publish(topicPub.c_str(), (const uint8_t*)j.c_str(), j.length(), false);
Serial.printf("PUB %s: %d pt (buf=%d) %u B\n", ok?"OK":"FAIL", n, bufCount, (unsigned)j.length());
if(ok) bufPop(n); // rimuovi solo i punti inviati con successo
char s[20]; snprintf(s,sizeof(s),"TX%s %dpt buf%d",ok?"":"!",n,bufCount); row(7,s);
}
}
}