V Visla Hardware
Firmware / MQTT Tracker · SIM7080G

MQTT Tracker · SIM7080G

XIAO nRF52840 · GPS + LTE-M → MQTTS
testato al banco · 2026-06

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);
    }
  }
}