V Visla Hardware
Firmware / Tag Dashboard OLED

Tag Dashboard OLED

XIAO nRF54LM20A · GPS + modem su schermo
testato al banco · 2026-07

Dashboard su OLED Expansion: legge il GPS DAN-F10N (fix / satelliti / coordinate) e interroga il modem EG800Q-EU (operatore / segnale / registrazione), più la batteria via nPM1300. Usa tutte e 3 le UART del nRF54LM20A insieme — GPS su Serial1, modem su Serial ri-pinnata su D8/D9, OLED su Wire — con l'OLED come console.

Seeed XIAO nRF54LM20A Arduino · core nrf54l15clean
Componenti usati
nRF54LM20A
MCU · Cortex-M33 128 MHz
Nordic Semiconductor
DAN-F10N
GNSS dual-band L1/L5
u-blox
EG800Q-EU
Modem LTE Cat-1 bis
Quectel
nPM1300
PMIC · batteria
Nordic Semiconductor
SSD1306
OLED 128×64 (Expansion)
tag_dashboard.ino
264 righe GitHub
/*
 * VISLA TAG — dashboard su OLED Expansion (SSD1306 128x64).
 * Legge GPS (DAN-F10N) e interroga il modem (EG800Q-EU) e mostra a schermo
 * fix/satelliti/coordinate + operatore/segnale/registrazione.
 *
 * nRF54LM20A ha 3 periferiche seriali raggiungibili sull'header (tutte P1):
 *   UARTE21 = Serial1  -> GPS   (D6/D7, connettore Grove) @38400
 *   TWIM22  = Wire     -> OLED  (D4/D5) I2C 0x3C
 *   UARTE20 = Serial   -> MODEM (ri-pinnata D8/D9) @115200  [niente console USB]
 * L'OLED e' la nostra "console": lo stato lo vediamo lì.
 */
#include <U8g2lib.h>
#include <Wire.h>
#include "npm1300.h"    // batteria via nPM1300 (bus TWIM23, separato dall'OLED)

// OLED full-buffer su HW I2C (Wire = TWIM22, D4/D5)
U8G2_SSD1306_128X64_NONAME_F_HW_I2C oled(U8G2_R0, U8X8_PIN_NONE);

#define GPS   Serial1     // UARTE21 D6/D7 (Grove)
#define MODEM Serial      // UARTE20 ri-pinnata su D8/D9

// ---- stato GPS ----
double gLat = 0, gLon = 0;
int gFix = 0, gSats = 0;
bool gValid = false;
int gsvTot[6] = {0};           // sat in vista per costellazione
uint32_t gGpsBytes = 0, gLastFixMs = 0;
char line[120];
int ln = 0;

// ---- stato modem ----
int mCsq = 99, mReg = -1, mAct = -1;
char mOper[24] = "---";
uint32_t mBytes = 0;
bool mSeen = false;

uint32_t bootMs = 0;

// % batteria dall'nPM1300 (LiPo: 3.30V=0%, 4.20V=100%). -1 se non leggibile
static int battPct() {
  int32_t mv = npm1300_read_vbat_mv();
  if (mv <= 0) return -1;
  int pct = (mv - 3300) * 100 / (4200 - 3300);
  if (pct < 0) pct = 0;
  if (pct > 100) pct = 100;
  return pct;
}

static int talkerIdx(char a, char b) {
  if (a == 'G' && b == 'P') return 0;
  if (a == 'G' && b == 'L') return 1;
  if (a == 'G' && b == 'A') return 2;
  if ((a == 'G' && b == 'B') || (a == 'B' && b == 'D')) return 3;
  if (a == 'G' && b == 'Q') return 4;
  return -1;
}

// estrae il campo idx (0-based) da una frase NMEA in out[]
static void field(const char *s, int idx, char *out, int outsz) {
  int f = 0, o = 0;
  out[0] = 0;
  for (int i = 0; s[i]; i++) {
    char c = s[i];
    if (c == ',' || c == '*') {
      if (f == idx) { out[o] = 0; return; }
      f++; o = 0;
      if (c == '*') break;
    } else if (f == idx && o < outsz - 1) {
      out[o++] = c;
    }
  }
  if (f == idx) out[o] = 0;
}

// ddmm.mmmm + emisfero -> gradi decimali
static double nmeaDeg(const char *v, const char *h) {
  if (strlen(v) < 3) return 0;
  double val = atof(v);
  int deg = (int)(val / 100);
  double m = val - deg * 100;
  double d = deg + m / 60.0;
  if (h[0] == 'S' || h[0] == 'W') d = -d;
  return d;
}

static void parseNMEA(const char *s) {
  if (s[0] != '$' || strlen(s) < 7) return;
  const char *t = s + 3;
  char f[16];
  if (!strncmp(t, "GGA", 3)) {
    field(s, 6, f, sizeof f); gFix = atoi(f);
    field(s, 7, f, sizeof f); gSats = atoi(f);
    gValid = gFix > 0;
    if (gValid) {
      char la[16], ns[4], lo[16], ew[4];
      field(s, 2, la, sizeof la); field(s, 3, ns, sizeof ns);
      field(s, 4, lo, sizeof lo); field(s, 5, ew, sizeof ew);
      gLat = nmeaDeg(la, ns);
      gLon = nmeaDeg(lo, ew);
      gLastFixMs = millis();
    }
  } else if (!strncmp(t, "GSV", 3)) {
    field(s, 3, f, sizeof f);
    int idx = talkerIdx(s[1], s[2]);
    if (idx >= 0) gsvTot[idx] = atoi(f);
  }
}

static void feedGPS() {
  while (GPS.available()) {
    char c = GPS.read();
    gGpsBytes++;
    if (c == '\n') { line[ln] = 0; parseNMEA(line); ln = 0; }
    else if (c != '\r' && ln < (int)sizeof(line) - 1) { line[ln++] = c; }
  }
}

// invia un comando AT, raccoglie la risposta, TENENDO VIVO il GPS nel frattempo
static void modemCmd(const char *cmd, char *resp, int rsz, uint32_t to) {
  while (MODEM.available()) MODEM.read();
  MODEM.print(cmd);
  MODEM.print("\r\n");
  int o = 0;
  resp[0] = 0;
  uint32_t st = millis();
  while (millis() - st < to) {
    feedGPS();
    while (MODEM.available()) {
      char c = MODEM.read();
      mBytes++;
      if (o < rsz - 1) resp[o++] = c;
    }
    resp[o] = 0;
    if (strstr(resp, "OK") || strstr(resp, "ERROR")) break;
    delay(2);
  }
  resp[o] = 0;
  if (o > 0) mSeen = true;
}

static void pollModem() {
  char r[180];
  modemCmd("AT+CSQ", r, sizeof r, 400);
  char *p = strstr(r, "+CSQ:");
  if (p) mCsq = atoi(p + 5);

  modemCmd("AT+COPS?", r, sizeof r, 800);
  p = strstr(r, "+COPS:");
  if (p) {
    char *q = strchr(p, '"');
    if (q) {
      q++; int i = 0;
      while (*q && *q != '"' && i < 22) mOper[i++] = *q++;
      mOper[i] = 0;
      char *c2 = strchr(q, ',');
      if (c2) mAct = atoi(c2 + 1);
    }
  }

  modemCmd("AT+CEREG?", r, sizeof r, 400);
  p = strstr(r, "+CEREG:");
  if (p) { char *c = strchr(p, ','); if (c) mReg = atoi(c + 1); }
}

// formatta un grado decimale SENZA %f (molti core non linkano il float printf)
static void fmtCoord(double v, char *out) {
  char sign = (v < 0) ? '-' : ' ';
  if (v < 0) v = -v;
  int deg = (int)v;
  long frac = (long)((v - deg) * 100000.0 + 0.5);
  if (frac >= 100000) { deg++; frac -= 100000; }
  sprintf(out, "%c%d.%05ld", sign, deg, frac);
}

static void draw() {
  oled.clearBuffer();
  oled.setFont(u8g2_font_6x10_tf);
  char b[28];

  oled.drawStr(0, 8, "VISLA TAG");
  int bp = battPct();                              // % batteria in alto a destra
  if (bp >= 0) {
    snprintf(b, sizeof b, "%d%%", bp);
    oled.drawStr(128 - oled.getStrWidth(b), 8, b);
  }
  oled.drawHLine(0, 11, 128);

  int inview = 0;
  for (int i = 0; i < 6; i++) inview += gsvTot[i];
  if (gValid) snprintf(b, sizeof b, "GPS FIX  %d sat", gSats);
  else        snprintf(b, sizeof b, "GPS ..  %d in vista", inview);
  oled.drawStr(0, 22, b);

  if (gValid) {
    char la[16], lo[16];
    fmtCoord(gLat, la);
    fmtCoord(gLon, lo);
    snprintf(b, sizeof b, "%s%s", la, lo);
    oled.drawStr(0, 33, b);
  } else {
    oled.drawStr(0, 33, "cerco fix...");
  }

  snprintf(b, sizeof b, "NET %s", mSeen ? mOper : "(modem?)");
  oled.drawStr(0, 44, b);

  const char *rt = mReg == 1 ? "casa" : mReg == 5 ? "roaming" :
                   mReg == 2 ? "cerco" : mReg == 3 ? "negato" : "--";
  const char *at = mAct == 7 ? "4G" : mAct == 9 ? "NB" : mAct == 0 ? "2G" :
                   mAct == 3 ? "E"  : mAct == 2 ? "3G" : "?";
  if (mCsq == 99 || mCsq < 0) snprintf(b, sizeof b, "%s  no segnale", at);
  else                        snprintf(b, sizeof b, "%s  %d dBm", at, -113 + 2 * mCsq);
  oled.drawStr(0, 55, b);

  snprintf(b, sizeof b, "rete: %s", rt);
  oled.drawStr(0, 64, b);
  snprintf(b, sizeof b, "%lus", (unsigned long)((millis() - bootMs) / 1000));
  oled.drawStr(128 - oled.getStrWidth(b), 64, b);   // uptime in basso a destra

  oled.sendBuffer();
}

void setup() {
  bootMs = millis();

  npm1300_begin();      // ADC batteria nPM1300
  oled.begin();
  oled.setFont(u8g2_font_6x10_tf);
  oled.clearBuffer();
  oled.drawStr(0, 20, "VISLA TAG");
  oled.drawStr(0, 34, "avvio GPS+modem...");
  oled.sendBuffer();

  pinMode(LED_GREEN, OUTPUT); digitalWrite(LED_GREEN, HIGH);  // spento (active-low)
  pinMode(LED_BLUE, OUTPUT);  digitalWrite(LED_BLUE, HIGH);

  GPS.begin(38400);                 // Serial1 UARTE21 (D6/D7)

  MODEM.setPins(PIN_D9, PIN_D8);    // rx=D9, tx=D8 (P1) su UARTE20
  MODEM.begin(115200);
  delay(300);

  char r[120];
  modemCmd("AT",      r, sizeof r, 500);
  modemCmd("ATE0",    r, sizeof r, 500);   // echo off -> risposte pulite
  modemCmd("AT+CMEE=1", r, sizeof r, 500);
}

void loop() {
  feedGPS();
  uint32_t now = millis();
  static uint32_t lastPoll = 0, lastDraw = 0, lastBeat = 0;

  if (now - lastPoll > 3000) { pollModem(); lastPoll = now; }
  if (now - lastDraw > 250)  { draw();      lastDraw = now; }

  // heartbeat: LED verde ON quando c'e' fix GPS, altrimenti lampeggia blu
  if (now - lastBeat > 500) {
    lastBeat = now;
    if (gValid) { digitalWrite(LED_GREEN, LOW); digitalWrite(LED_BLUE, HIGH); }
    else { digitalWrite(LED_GREEN, HIGH); digitalWrite(LED_BLUE, !digitalRead(LED_BLUE)); }
  }
}