V Visla Hardware
Firmware / Visla v2 Hybrid

Visla v2 Hybrid

LilyGO T-A7670G · modem + GPS + dashboard WiFi
testato al banco · 2026-06

Firmware da banco del Visla v2: ESP32 dual-core — il core 1 gestisce modem A7670 + GPS + state machine, il core 0 serve una dashboard web (WebServer) via WiFi con OTA e mDNS. Non per produzione a batteria (no deep sleep, ~150 mA continui), ma comodo per testare modem e dashboard insieme.

LilyGO T-A7670G R2 (ESP32) Arduino · ESP32
Componenti usati
ESP32
MCU dual-core · WiFi/BT
Espressif
A7670G
Modem LTE Cat-1 + GNSS
SIMCom
visla_v2_hybrid.ino
606 righe GitHub
// Visla v2 — HYBRID test bench (no deep sleep)
//   Core 1 (Arduino loop): modem + GPS + state machine
//   Core 0 (FreeRTOS task): WiFi + WebServer (read-only dashboard)
//
// NOT for battery production use — no deep sleep, consumes ~150mA continuous.
// Useful per testare modem+dashboard insieme su banco.

#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <WebServer.h>
#include <ArduinoOTA.h>
#include <ESPmDNS.h>

#define IIC_SDA       21
#define IIC_SCL       22
#define MODEM_TX      26
#define MODEM_RX      27
#define MODEM_PWR     4
#define BOARD_PWR     12
#define QMI_ADDR      0x6B
#define BAT_ADC       35

#define MOTION_THRESH_MG       15
#define VP_INTERVAL_MS         10000
#define HTBT_INTERVAL_MS       30000

#define APN          "iot.1nce.net"
#define TCP_HOST     "gps.vislagps.com"
#define TCP_PORT     5050

// Multiple known WiFi networks — board tries each in order
struct WiFiCred { const char* ssid; const char* pass; };
const WiFiCred KNOWN_WIFIS[] = {
  {"Positive Waves", "GoodLife!"},
  {"Molinella", "Fastweb10"},
};
#define WIFI_CRED_COUNT (sizeof(KNOWN_WIFIS) / sizeof(WiFiCred))

// Soft-AP fallback (sempre attivo, anche in mobilità)
#define AP_SSID    "Visla-V2"
#define AP_PASS    "vislapass"   // min 8 char

HardwareSerial SerialAT(1);
WebServer server(80);
WiFiMulti wifiMulti;

// === Shared state (volatile, written by core 1, read by core 0) ===
volatile float    g_bat_v = 0;
volatile int      g_bat_pct = 0;
volatile int      g_csq = 99;
volatile bool     g_gps_valid = false;
volatile int      g_gps_sat = 0;
volatile float    g_gps_hdop = 99.9;
double            g_gps_lat = 0;        // double not atomic; OK for display
double            g_gps_lon = 0;
volatile uint32_t g_v4_sent = 0;
volatile uint32_t g_vp_sent = 0;
volatile uint32_t g_htbt_sent = 0;
volatile uint32_t g_last_publish_ms = 0;
volatile int16_t  g_ax = 0, g_ay = 0, g_az = 0;
volatile uint32_t g_motion_events = 0;
volatile float    g_last_dmag = 0;
volatile uint32_t g_last_motion_ms = 0;
volatile bool     g_modem_ok = false;
volatile bool     g_net_ok = false;
volatile bool     g_tcp_ok = false;
char              g_imei[20] = "";

struct GpsData {
  bool valid;
  double latitude, longitude;
  float speed_kmh, course, altitude, hdop;
  int year, month, day, hour, minute, second;
  int satellites;
};

// === I2C / IMU ===
uint8_t i2cRead8(uint8_t a, uint8_t r) {
  Wire.beginTransmission(a); Wire.write(r); Wire.endTransmission(false);
  Wire.requestFrom(a, (uint8_t)1);
  return Wire.available() ? Wire.read() : 0xFF;
}
void i2cWrite8(uint8_t a, uint8_t r, uint8_t v) {
  Wire.beginTransmission(a); Wire.write(r); Wire.write(v); Wire.endTransmission();
}
bool qmi_setup() {
  if (i2cRead8(QMI_ADDR, 0x00) != 0x05) return false;
  i2cWrite8(QMI_ADDR, 0x60, 0xB0); delay(50);
  i2cWrite8(QMI_ADDR, 0x02, 0x60);
  i2cWrite8(QMI_ADDR, 0x03, 0x35);
  i2cWrite8(QMI_ADDR, 0x08, 0x01);
  delay(80);
  Wire.beginTransmission(QMI_ADDR); Wire.write(0x35); Wire.endTransmission(false);
  Wire.requestFrom(QMI_ADDR, (uint8_t)6);
  for (int i = 0; i < 6; i++) Wire.read();
  delay(20);
  return true;
}
void qmi_read(int16_t &ax, int16_t &ay, int16_t &az) {
  Wire.beginTransmission(QMI_ADDR); Wire.write(0x35); Wire.endTransmission(false);
  Wire.requestFrom(QMI_ADDR, (uint8_t)6);
  ax = Wire.read() | (Wire.read()<<8);
  ay = Wire.read() | (Wire.read()<<8);
  az = Wire.read() | (Wire.read()<<8);
}

// === Battery ===
float read_battery_voltage() {
  uint32_t sum_mv = 0;
  const int N = 16;
  for (int i = 0; i < N; i++) { sum_mv += analogReadMilliVolts(BAT_ADC); delayMicroseconds(200); }
  return ((sum_mv / N) * 2.0f) / 1000.0f;
}
int volt_to_pct(float v) {
  int p = (int)((v - 3.3f) / (4.2f - 3.3f) * 100.0f);
  if (p > 100) p = 100; if (p < 0) p = 0; return p;
}

// === Modem ===
String sendAT(const String &cmd, unsigned long timeout = 1000, const char *expect = "OK") {
  while (SerialAT.available()) SerialAT.read();
  SerialAT.println(cmd);
  String resp = ""; unsigned long start = millis();
  while (millis() - start < timeout) {
    while (SerialAT.available()) resp += (char)SerialAT.read();
    if (resp.indexOf(expect) >= 0) break;
    if (resp.indexOf("ERROR") >= 0) break;
    delay(10);
  }
  return resp;
}

bool modem_power_on() {
  pinMode(MODEM_PWR, OUTPUT);
  digitalWrite(MODEM_PWR, LOW); delay(100);
  digitalWrite(MODEM_PWR, HIGH); delay(1500);
  digitalWrite(MODEM_PWR, LOW);
  SerialAT.begin(115200, SERIAL_8N1, MODEM_RX, MODEM_TX);
  for (int i = 0; i < 30; i++) {
    if (sendAT("AT", 1000).indexOf("OK") >= 0) return true;
    delay(1000);
  }
  return false;
}

// Close any stale socket/network session from previous run (modem keeps state
// across ESP32 reset — without cleanup, NETOPEN/CIPOPEN return zombie state).
// Full modem firmware reset — kills any zombie TCP/PDP state across ESP32 reboot.
// Slower than clean_state (~15s) but reliable.
void modem_full_reset() {
  Serial.println("[modem] CFUN=1,1 full reset...");
  sendAT("AT+CFUN=1,1", 1000);  // soft reset modem firmware
  delay(8000);
  for (int i = 0; i < 20; i++) {
    if (sendAT("AT", 500).indexOf("OK") >= 0) {
      Serial.println("[modem] re-booted OK after CFUN reset");
      sendAT("ATE0", 500);
      return;
    }
    delay(1000);
  }
  Serial.println("[modem] WARN: no AT response after CFUN reset");
}

void modem_clean_state() {
  Serial.println("Cleaning stale modem state...");
  sendAT("AT+CIPCLOSE=0", 2000);  // close socket 0 if open
  sendAT("AT+NETCLOSE", 5000);    // close PDP context
  delay(500);
}

bool wait_for_network(unsigned long timeout_ms = 60000) {
  unsigned long start = millis();
  while (millis() - start < timeout_ms) {
    String r = sendAT("AT+CREG?", 2000);
    int idx = r.indexOf("+CREG:");
    if (idx >= 0) {
      int comma = r.indexOf(",", idx);
      if (comma > 0) {
        int stat = r.charAt(comma + 1) - '0';
        if (stat == 1 || stat == 5) return true;
      }
    }
    delay(2000);
  }
  return false;
}

int read_rssi() {
  String r = sendAT("AT+CSQ", 1000);
  int idx = r.indexOf("+CSQ:");
  if (idx < 0) return 99;
  int sp = r.indexOf(" ", idx);
  int c = r.indexOf(",", sp);
  return r.substring(sp+1, c).toInt();
}

bool gps_parse_cgnssinfo(const String& resp, GpsData &g) {
  int idx = resp.indexOf("+CGNSSINFO:");
  if (idx < 0) return false;
  String line = resp.substring(idx + 12);
  int cr = line.indexOf('\r'); if (cr > 0) line = line.substring(0, cr);
  line.trim();
  if (line.startsWith(",,,")) return false;
  String f[18]; int fi=0, s=0;
  for (int i = 0; i <= (int)line.length() && fi < 18; i++) {
    if (i == (int)line.length() || line.charAt(i) == ',') {
      f[fi++] = line.substring(s, i); s = i + 1;
    }
  }
  if (fi < 12 || f[5].length() == 0 || f[7].length() == 0) return false;
  g.satellites = f[1].toInt() + f[2].toInt() + f[3].toInt();
  g.latitude = f[5].toDouble(); if (f[6] == "S") g.latitude = -g.latitude;
  g.longitude = f[7].toDouble(); if (f[8] == "W") g.longitude = -g.longitude;
  if (g.latitude == 0 && g.longitude == 0) return false;
  if (f[9].length() >= 6) {
    g.day = f[9].substring(0,2).toInt();
    g.month = f[9].substring(2,4).toInt();
    g.year = f[9].substring(4,6).toInt();
  }
  if (f[10].length() >= 6) {
    g.hour = f[10].substring(0,2).toInt();
    g.minute = f[10].substring(2,4).toInt();
    g.second = f[10].substring(4,6).toInt();
  }
  g.altitude = f[11].toFloat();
  g.speed_kmh = f[12].toFloat() * 1.852;
  g.course = f[13].toFloat();
  g.hdop = f[15].toFloat();
  g.valid = true;
  return true;
}

bool read_modem_time(GpsData &g) {
  String r = sendAT("AT+CCLK?", 1500);
  int idx = r.indexOf("+CCLK:"); if (idx < 0) return false;
  int q1 = r.indexOf('"', idx), q2 = r.indexOf('"', q1+1);
  if (q1 < 0 || q2 < 0) return false;
  String t = r.substring(q1+1, q2);
  if (t.length() < 17) return false;
  g.year   = t.substring(0,2).toInt();
  g.month  = t.substring(3,5).toInt();
  g.day    = t.substring(6,8).toInt();
  g.hour   = t.substring(9,11).toInt();
  g.minute = t.substring(12,14).toInt();
  g.second = t.substring(15,17).toInt();
  return true;
}

bool try_gps_fix(GpsData &g, unsigned long timeout_ms = 3000) {
  unsigned long start = millis();
  while (millis() - start < timeout_ms) {
    String r = sendAT("AT+CGNSSINFO", 1000);
    if (gps_parse_cgnssinfo(r, g)) return true;
    delay(500);
  }
  g.latitude = 42.7173; g.longitude = 12.1079; g.altitude = 345.0;
  g.satellites = 0; g.hdop = 99.9; g.valid = false;
  if (!read_modem_time(g)) {
    g.hour = 0; g.minute = 0; g.second = 0;
    g.day = 1; g.month = 1; g.year = 26;
  }
  return false;
}

String to_nmea(double coord, bool isLon) {
  double a = fabs(coord); int d = (int)a; double m = (a - d) * 60.0;
  char buf[16];
  snprintf(buf, sizeof(buf), isLon ? "%03d%07.4f" : "%02d%07.4f", d, m);
  return String(buf);
}

String build_v4_alarm(const GpsData &g) {
  char buf[200];
  snprintf(buf, sizeof(buf),
    "*HQ,%s,V4,%02d%02d%02d,%s,%s,%s,%s,%s,000.00,000,%02d%02d%02d,FEFEFEFE#",
    g_imei, g.hour, g.minute, g.second, g.valid ? "A" : "V",
    to_nmea(g.latitude, false).c_str(), g.latitude >= 0 ? "N" : "S",
    to_nmea(g.longitude, true).c_str(), g.longitude >= 0 ? "E" : "W",
    g.day, g.month, g.year);
  return String(buf);
}
String build_vp(const GpsData &g, int bat, int rssi, bool charging) {
  char buf[256];
  const char* status = charging ? "FBFFFFFF" : "FFFFFFFF";
  snprintf(buf, sizeof(buf),
    "*HQ,%s,VP,%02d%02d%02d,%s,%s,%s,%s,%s,%06.2f,%03.0f,%02d%02d%02d,%s,%.1f,%d,%.2f,%d,%d#",
    g_imei, g.hour, g.minute, g.second, g.valid ? "A" : "V",
    to_nmea(g.latitude, false).c_str(), g.latitude >= 0 ? "N" : "S",
    to_nmea(g.longitude, true).c_str(), g.longitude >= 0 ? "E" : "W",
    g.speed_kmh, g.course, g.day, g.month, g.year,
    status, g.altitude, g.satellites, g.hdop, bat, rssi);
  return String(buf);
}
String build_htbt(int bat) {
  char buf[64];
  snprintf(buf, sizeof(buf), "*HQ,%s,HTBT,%d#", g_imei, bat);
  return String(buf);
}

bool tcp_open() {
  String r = sendAT("AT+CIPOPEN=0,\"TCP\",\"" TCP_HOST "\"," + String(TCP_PORT),
                   15000, "+CIPOPEN: 0,0");
  return (r.indexOf("+CIPOPEN: 0,0") >= 0);
}
bool tcp_send(const String &msg) {
  String r = sendAT("AT+CIPSEND=0," + String(msg.length()), 2000, ">");
  if (r.indexOf(">") < 0) return false;
  SerialAT.print(msg); delay(2000);
  while (SerialAT.available()) SerialAT.read();
  return true;
}

// === Dashboard task (core 0) ===
String uptimeStr(uint32_t ms) {
  uint32_t s = ms / 1000;
  uint32_t h = s / 3600; s %= 3600;
  uint32_t m = s / 60; s %= 60;
  char b[32];
  if (h) snprintf(b, sizeof(b), "%uh %um %us", h, m, s);
  else snprintf(b, sizeof(b), "%um %us", m, s);
  return String(b);
}

void handleRoot() {
  String html = R"HTML(<!DOCTYPE html><html><head><meta charset="utf-8"><title>Visla v2 Live</title>
<meta http-equiv="refresh" content="2">
<style>body{font-family:-apple-system,sans-serif;background:#0F172A;color:#5DE0E6;padding:20px;max-width:600px;margin:auto}
h1{color:#5DE0E6}.c{background:#1e293b;padding:15px;border-radius:8px;margin:10px 0}
.lbl{color:#94a3b8;font-size:12px}.val{font-size:24px;font-weight:bold}
.ok{color:#22c55e}.warn{color:#f59e0b}.err{color:#ef4444}
.row{display:flex;gap:10px;flex-wrap:wrap}.row .c{flex:1;min-width:140px}
.foot{color:#475569;font-size:11px;margin-top:20px}</style></head><body>
<h1>🛰️ Visla v2 — Live</h1>)HTML";

  char tmp[2048];
  bool charging = g_bat_v > 4.18f;
  int rssi_dbm = (g_csq == 99) ? 0 : -113 + 2*g_csq;

  snprintf(tmp, sizeof(tmp),
    "<div class='row'>"
      "<div class='c'><div class='lbl'>🔋 Battery</div><div class='val'>%d%%</div><div>%.2fV %s</div></div>"
      "<div class='c'><div class='lbl'>📶 SIM CSQ</div><div class='val'>%d</div><div>%d dBm</div></div>"
      "<div class='c'><div class='lbl'>🛰️ GPS</div><div class='val %s'>%s</div><div>sat:%d hdop:%.1f</div></div>"
    "</div>"
    "<div class='c'><div class='lbl'>📍 Coords</div><div>%.6f, %.6f</div></div>"
    "<div class='row'>"
      "<div class='c'><div class='lbl'>🚨 V4 sent</div><div class='val'>%u</div></div>"
      "<div class='c'><div class='lbl'>📍 VP sent</div><div class='val'>%u</div></div>"
      "<div class='c'><div class='lbl'>💓 HTBT sent</div><div class='val'>%u</div></div>"
    "</div>"
    "<div class='c'><div class='lbl'>⏱️ Last publish</div><div>%lus ago</div></div>"
    "<div class='row'>"
      "<div class='c'><div class='lbl'>🏃 Motion Δ</div><div class='val'>%.0fmg</div><div>events: %u</div></div>"
      "<div class='c'><div class='lbl'>📐 Accel</div><div>X:%d Y:%d Z:%d</div></div>"
    "</div>"
    "<div class='row'>"
      "<div class='c'><div class='lbl'>📡 Status</div>"
        "<div class='%s'>Modem: %s</div>"
        "<div class='%s'>Network: %s</div>"
        "<div class='%s'>TCP: %s</div>"
      "</div>"
      "<div class='c'><div class='lbl'>📶 WiFi RSSI</div><div class='val'>%ddBm</div></div>"
      "<div class='c'><div class='lbl'>⏰ Uptime</div><div>%s</div></div>"
    "</div>"
    "<div class='foot'>IMEI: %s | IP %s | JSON: <a href='/api/status' style='color:#5DE0E6'>/api/status</a></div>"
    "</body></html>",
    g_bat_pct, g_bat_v, charging ? "🔌 charging" : "",
    g_csq, rssi_dbm,
    g_gps_valid ? "ok" : "warn", g_gps_valid ? "FIX" : "no fix",
    g_gps_sat, g_gps_hdop,
    g_gps_lat, g_gps_lon,
    g_v4_sent, g_vp_sent, g_htbt_sent,
    (millis() - g_last_publish_ms) / 1000,
    g_last_dmag, g_motion_events,
    g_ax, g_ay, g_az,
    g_modem_ok ? "ok" : "err", g_modem_ok ? "ON" : "OFF",
    g_net_ok ? "ok" : "err", g_net_ok ? "registered" : "no",
    g_tcp_ok ? "ok" : "err", g_tcp_ok ? "open" : "closed",
    WiFi.RSSI(),
    uptimeStr(millis()).c_str(),
    g_imei, WiFi.localIP().toString().c_str());

  html += tmp;
  server.send(200, "text/html", html);
}

void handleApiStatus() {
  char buf[1024];
  snprintf(buf, sizeof(buf),
    "{\"bat_pct\":%d,\"bat_v\":%.2f,\"csq\":%d,"
    "\"gps_valid\":%s,\"gps_sat\":%d,\"gps_hdop\":%.1f,"
    "\"gps_lat\":%.6f,\"gps_lon\":%.6f,"
    "\"v4_sent\":%u,\"vp_sent\":%u,\"htbt_sent\":%u,"
    "\"last_publish_ms\":%lu,"
    "\"motion_events\":%u,\"last_dmag\":%.0f,"
    "\"ax\":%d,\"ay\":%d,\"az\":%d,"
    "\"modem_ok\":%s,\"net_ok\":%s,\"tcp_ok\":%s,"
    "\"imei\":\"%s\",\"uptime_ms\":%lu}",
    g_bat_pct, g_bat_v, g_csq,
    g_gps_valid ? "true" : "false", g_gps_sat, g_gps_hdop,
    g_gps_lat, g_gps_lon,
    g_v4_sent, g_vp_sent, g_htbt_sent,
    millis() - g_last_publish_ms,
    g_motion_events, g_last_dmag,
    g_ax, g_ay, g_az,
    g_modem_ok ? "true" : "false",
    g_net_ok ? "true" : "false",
    g_tcp_ok ? "true" : "false",
    g_imei, millis());
  server.send(200, "application/json", buf);
}

void dashboardTask(void *) {
  Serial.println("[core0] WiFi AP+STA setup...");
  // Dual mode: STA (try to connect home) + AP (always-on hotspot for mobile)
  WiFi.mode(WIFI_AP_STA);

  // 1. AP always on — iPhone connects directly when no home WiFi (e.g. in car/bike)
  WiFi.softAP(AP_SSID, AP_PASS);
  Serial.printf("[core0] AP ready: SSID='%s' pass='%s' IP=%s\n",
                AP_SSID, AP_PASS, WiFi.softAPIP().toString().c_str());

  // 2. Try connect to any known home/mobile WiFi (WiFiMulti)
  for (size_t i = 0; i < WIFI_CRED_COUNT; i++) {
    wifiMulti.addAP(KNOWN_WIFIS[i].ssid, KNOWN_WIFIS[i].pass);
  }
  Serial.printf("[core0] WiFiMulti: %d known networks\n", (int)WIFI_CRED_COUNT);
  if (wifiMulti.run(15000) == WL_CONNECTED) {
    Serial.printf("[core0] STA connected to '%s', IP=%s\n",
                  WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
    if (MDNS.begin("visla-v2")) Serial.println("[core0] mDNS visla-v2.local (STA)");
  } else {
    Serial.println("[core0] No known WiFi — only AP mode active");
  }

  // OTA setup — flash via WiFi without USB cable
  ArduinoOTA.setHostname("visla-v2");
  ArduinoOTA.setPassword("visla");
  ArduinoOTA.onStart([]() { Serial.println("[ota] start"); });
  ArduinoOTA.onEnd([]() { Serial.println("[ota] end"); });
  ArduinoOTA.onError([](ota_error_t e) { Serial.printf("[ota] err %u\n", e); });
  ArduinoOTA.begin();
  Serial.println("[core0] OTA ready (host visla-v2.local, pwd 'visla')");

  server.on("/", handleRoot);
  server.on("/api/status", handleApiStatus);
  server.on("/at", []() {
    String cmd = server.arg("cmd");
    if (cmd.length() == 0) { server.send(400, "text/plain", "usage: /at?cmd=AT"); return; }
    unsigned long t = server.arg("t").toInt(); if (t < 200) t = 2000;
    String r = sendAT(cmd, t);
    server.send(200, "text/plain", r);
  });
  server.on("/reboot", []() {
    server.send(200, "text/plain", "rebooting ESP32 in 500ms...");
    delay(500); ESP.restart();
  });
  server.on("/modem-reset", []() {
    server.send(200, "text/plain", "AT+CFUN=1,1 sent, modem rebooting (~15s)...");
    modem_full_reset();
  });
  server.begin();
  Serial.println("[core0] HTTP server started on :80");

  for (;;) {
    server.handleClient();
    ArduinoOTA.handle();
    vTaskDelay(pdMS_TO_TICKS(5));
  }
}

// === Setup (core 1) ===
void setup() {
  Serial.begin(115200);
  delay(800);
  Serial.println("\n╔════════════════════════════╗");
  Serial.println("║ Visla v2 — HYBRID (no sleep) ║");
  Serial.println("╚════════════════════════════╝");

  pinMode(BOARD_PWR, OUTPUT);
  digitalWrite(BOARD_PWR, HIGH);

  Wire.begin(IIC_SDA, IIC_SCL); delay(20);
  if (!qmi_setup()) {
    Serial.println("⚠️ QMI8658 not found");
  } else {
    Serial.println("✅ IMU OK");
  }

  // Start dashboard task on core 0 BEFORE modem boot (so WiFi/HTTP up early)
  xTaskCreatePinnedToCore(dashboardTask, "dashboard", 8192, NULL, 1, NULL, 0);

  // Boot modem on core 1
  Serial.println("Booting modem...");
  if (modem_power_on()) {
    g_modem_ok = true;
    Serial.println("✅ Modem OK");
    String r = sendAT("AT+CGSN", 2000);
    int s = r.indexOf("\r\n", 5);
    if (s > 0) {
      int e = r.indexOf("\r\n", s+2);
      if (e > s) strncpy(g_imei, r.substring(s+2, e).c_str(), 19);
    }
    Serial.printf("IMEI: %s\n", g_imei);

    if (wait_for_network(60000)) {
      g_net_ok = true;
      Serial.println("✅ Network registered");
      modem_full_reset();   // kill any zombie state (CFUN=1,1)
      // After reset, re-register
      if (!wait_for_network(60000)) {
        Serial.println("❌ Network reg fail after CFUN reset");
      }
      modem_clean_state();  // belt-and-suspenders cleanup
      sendAT("AT+CGDCONT=1,\"IP\",\"" APN "\"", 2000);
      sendAT("AT+NETOPEN", 10000, "+NETOPEN:");
      sendAT("AT+CGNSSPWR=1", 2000);

      if (tcp_open()) {
        g_tcp_ok = true;
        Serial.println("✅ TCP open");
        GpsData g; try_gps_fix(g, 8000);
        g_gps_valid = g.valid; g_gps_sat = g.satellites;
        g_gps_hdop = g.hdop; g_gps_lat = g.latitude; g_gps_lon = g.longitude;
        String v4 = build_v4_alarm(g);
        Serial.printf("🚨 First V4 → %s\n", v4.c_str());
        if (tcp_send(v4)) {
          g_v4_sent++;
          g_last_publish_ms = millis();
        }
      } else {
        Serial.println("❌ TCP open fail");
      }
    } else {
      Serial.println("❌ Network reg fail");
    }
  } else {
    Serial.println("❌ Modem boot fail");
  }

  // Init motion baseline
  qmi_read((int16_t&)g_ax, (int16_t&)g_ay, (int16_t&)g_az);
  g_last_motion_ms = millis();
}

void loop() {
  static uint32_t last_vp = 0, last_htbt = 0, last_imu = 0, last_bat = 0;
  static int16_t prev_ax = g_ax, prev_ay = g_ay, prev_az = g_az;
  uint32_t now = millis();

  // Update battery every 2s
  if (now - last_bat > 2000) {
    last_bat = now;
    g_bat_v = read_battery_voltage();
    g_bat_pct = volt_to_pct(g_bat_v);
  }

  // Motion poll every 100ms
  if (now - last_imu > 100) {
    last_imu = now;
    int16_t ax, ay, az; qmi_read(ax, ay, az);
    g_ax = ax; g_ay = ay; g_az = az;
    int32_t dax = ax - prev_ax, day = ay - prev_ay, daz = az - prev_az;
    float dmag = sqrtf((float)(dax*dax + day*day + daz*daz)) * 0.488f;
    g_last_dmag = dmag;
    prev_ax = ax; prev_ay = ay; prev_az = az;
    if (dmag > MOTION_THRESH_MG) {
      g_motion_events++;
      g_last_motion_ms = now;
    }
  }

  // VP every 10s (only if TCP open)
  if (g_tcp_ok && now - last_vp > VP_INTERVAL_MS) {
    last_vp = now;
    GpsData g; try_gps_fix(g, 2000);
    g_gps_valid = g.valid; g_gps_sat = g.satellites;
    g_gps_hdop = g.hdop; g_gps_lat = g.latitude; g_gps_lon = g.longitude;
    int rssi = read_rssi(); g_csq = rssi;
    int bat = g_bat_pct;
    bool charging = g_bat_v > 4.18f;
    String vp = build_vp(g, bat, rssi, charging);
    Serial.printf("📍 VP → %s\n", vp.c_str());
    if (tcp_send(vp)) {
      g_vp_sent++;
      g_last_publish_ms = millis();
    }
  }

  // HTBT every 30s (only if TCP open)
  if (g_tcp_ok && now - last_htbt > HTBT_INTERVAL_MS) {
    last_htbt = now;
    String h = build_htbt(g_bat_pct);
    Serial.printf("💓 HTBT → %s\n", h.c_str());
    if (tcp_send(h)) {
      g_htbt_sent++;
      g_last_publish_ms = millis();
    }
  }

  delay(50);
}