Visla v2 Hybrid
LilyGO T-A7670G · modem + GPS + dashboard WiFi
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);
}