Complete IoT monitoring platform for Acuvim II power meters via ESP32. Firmware (Phases 1-7): - ESP32-WROVER-B (TTGO T-Call v1.4) with RS485 Modbus RTU - WiFi STA+AP concurrent mode with GSM/GPRS failover - Transport abstraction layer with 4 priority modes - MQTT protocol with 20 commands, LWT, QoS, exponential backoff - SD card offline buffering with JSONL rotation and non-blocking drain - OTA firmware updates with dual partition rollback protection - Watchdog timer, crash loop detection, Acuvim health monitoring - Captive portal provisioning with AP mode Console backend (Phase 8): - .NET 10 minimal API with PostgreSQL + EF Core - JWT authentication, SignalR real-time updates - MQTTnet 5.x bridge service with health monitoring - Device, telemetry, firmware, alert, group management - Rate limiting, security headers, Swagger/OpenAPI Frontend (Phase 9): - React 18 + TypeScript + Vite with Ant Design 5 - ECharts telemetry visualization, TanStack Query - SignalR live updates, device management UI - Dashboard, fleet management, firmware deployment Testing & Production (Phase 10): - 28 firmware unit tests (Modbus, JSON, config, version) - 23 xUnit backend tests (device, telemetry, command, alert) - Docker Compose with nginx, TLS MQTT, PostgreSQL - Production deployment, commissioning, and troubleshooting docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
288 lines
9.5 KiB
C++
288 lines
9.5 KiB
C++
#include <Arduino.h>
|
|
#include <WiFi.h>
|
|
#include <ArduinoJson.h>
|
|
#include <esp_ota_ops.h>
|
|
#include <esp_task_wdt.h>
|
|
#include <esp_system.h>
|
|
#include <Preferences.h>
|
|
#include "pin_config.h"
|
|
#include "config_manager.h"
|
|
#include "wifi_manager.h"
|
|
#include "gsm_manager.h"
|
|
#include "transport_manager.h"
|
|
#include "mqtt_client.h"
|
|
#include "acuvim_reader.h"
|
|
#include "sd_manager.h"
|
|
#include "web_server.h"
|
|
#include "ota_manager.h"
|
|
#include "heartbeat_manager.h"
|
|
#include "health_monitor.h"
|
|
#include "command_handler.h"
|
|
|
|
ConfigManager configMgr;
|
|
WifiManager wifiMgr;
|
|
HardwareSerial modemSerial(1);
|
|
GsmManager gsmMgr(modemSerial);
|
|
TransportManager transportMgr;
|
|
MqttClient mqttClient;
|
|
AcuvimReader acuvim;
|
|
SdManager sdMgr;
|
|
OtaManager otaMgr;
|
|
HeartbeatManager heartbeatMgr;
|
|
HealthMonitor healthMon;
|
|
CommandHandler cmdHandler;
|
|
WebPortal webPortal;
|
|
WiFiClient wifiNetClient;
|
|
|
|
unsigned long lastPollMs = 0;
|
|
bool sleepScheduled = false;
|
|
|
|
void checkCrashLoop() {
|
|
esp_reset_reason_t reason = esp_reset_reason();
|
|
bool wasCrash = (reason == ESP_RST_PANIC || reason == ESP_RST_INT_WDT ||
|
|
reason == ESP_RST_TASK_WDT || reason == ESP_RST_WDT);
|
|
|
|
Preferences prefs;
|
|
prefs.begin("acuvim_sys", false);
|
|
if (wasCrash) {
|
|
uint8_t crashes = prefs.getUChar("crash_count", 0) + 1;
|
|
prefs.putUChar("crash_count", crashes);
|
|
Serial.printf("[Main] Crash detected (count: %d)\n", crashes);
|
|
if (crashes >= 5) {
|
|
Serial.println("[Main] Crash loop detected, performing factory reset");
|
|
prefs.putUChar("crash_count", 0);
|
|
prefs.end();
|
|
configMgr.reset();
|
|
configMgr.save();
|
|
ESP.restart();
|
|
}
|
|
} else {
|
|
prefs.putUChar("crash_count", 0);
|
|
}
|
|
prefs.end();
|
|
}
|
|
|
|
void printAcuvimData(const AcuvimData& d) {
|
|
Serial.println("========================================");
|
|
Serial.println(" Acuvim II Readings");
|
|
Serial.println("========================================");
|
|
|
|
Serial.printf(" Va: %7.1f V Vb: %7.1f V Vc: %7.1f V\n", d.voltage_a, d.voltage_b, d.voltage_c);
|
|
Serial.printf(" Ia: %7.2f A Ib: %7.2f A Ic: %7.2f A\n", d.current_a, d.current_b, d.current_c);
|
|
Serial.printf(" P: %7.3f kW Q: %7.3f kVAR S: %7.3f kVA\n", d.active_power, d.reactive_power, d.apparent_power);
|
|
Serial.printf(" PF: %7.3f F: %7.2f Hz\n", d.power_factor, d.frequency);
|
|
Serial.printf(" Import: %.1f kWh Export: %.1f kWh\n", d.import_active_energy, d.export_active_energy);
|
|
Serial.printf(" Read: %lu ms Success: %lu Errors: %lu Heap: %lu\n",
|
|
acuvim.getLastReadDurationMs(), acuvim.getSuccessCount(),
|
|
acuvim.getErrorCount(), (unsigned long)ESP.getFreeHeap());
|
|
Serial.println("========================================\n");
|
|
}
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
while (!Serial && millis() < 3000) {
|
|
;
|
|
}
|
|
|
|
Serial.println();
|
|
Serial.println("========================================");
|
|
Serial.println(" Tau Acuvim II Monitor");
|
|
Serial.printf(" Firmware: %s\n", FW_VERSION);
|
|
Serial.printf(" Build: %s %s\n", __DATE__, __TIME__);
|
|
Serial.println("========================================");
|
|
Serial.println();
|
|
|
|
pinMode(LED_PIN, OUTPUT);
|
|
digitalWrite(LED_PIN, LOW);
|
|
|
|
// --- OTA self-test validation ---
|
|
{
|
|
const esp_partition_t* running = esp_ota_get_running_partition();
|
|
esp_ota_img_states_t ota_state;
|
|
if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) {
|
|
if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
|
|
Serial.println("[OTA] New firmware pending validation - marking valid after successful boot");
|
|
esp_ota_mark_app_valid_cancel_rollback();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Crash loop detection ---
|
|
checkCrashLoop();
|
|
|
|
// --- Watchdog (30s timeout, panic on expiry) ---
|
|
esp_task_wdt_init(30, true);
|
|
esp_task_wdt_add(NULL);
|
|
|
|
// --- Config ---
|
|
configMgr.begin();
|
|
DeviceConfig& cfg = configMgr.get();
|
|
|
|
// --- Modbus ---
|
|
acuvim.begin(cfg.modbus_slave_addr, cfg.modbus_baud_rate);
|
|
|
|
// --- SD Card ---
|
|
sdMgr.begin(cfg.device_id);
|
|
sdMgr.setRetentionDays(cfg.sd_retention_days);
|
|
|
|
// --- GSM ---
|
|
gsmMgr.begin();
|
|
|
|
// --- Transport ---
|
|
transportMgr.begin(wifiMgr, gsmMgr, configMgr, &wifiNetClient);
|
|
|
|
// --- Health Monitor ---
|
|
healthMon.begin(configMgr, mqttClient, acuvim);
|
|
|
|
// --- Command Handler ---
|
|
cmdHandler.begin(configMgr, wifiMgr, gsmMgr, transportMgr,
|
|
mqttClient, acuvim, otaMgr, sdMgr, healthMon);
|
|
|
|
// --- MQTT ---
|
|
mqttClient.begin(transportMgr.getClient());
|
|
mqttClient.setConfig(cfg);
|
|
mqttClient.onCommand([](const String& topic, const String& payload) {
|
|
cmdHandler.handleCommand(topic, payload);
|
|
});
|
|
|
|
// --- Transport change callback ---
|
|
transportMgr.onTransportChange([](TransportType type) {
|
|
if (type == TransportType::NONE) {
|
|
Serial.println("[Main] No transport available");
|
|
mqttClient.disconnect();
|
|
return;
|
|
}
|
|
Serial.printf("[Main] Transport changed to %s, reconnecting MQTT...\n",
|
|
TransportManager::transportTypeName(type));
|
|
mqttClient.disconnect();
|
|
mqttClient.begin(transportMgr.getClient());
|
|
mqttClient.setConfig(configMgr.get());
|
|
mqttClient.connect();
|
|
heartbeatMgr.sendNow();
|
|
});
|
|
|
|
// --- WiFi: start AP first (always available for configuration) ---
|
|
wifiMgr.startAP(cfg.device_id);
|
|
|
|
// --- WiFi: STA connection ---
|
|
if (cfg.wifi_enabled && cfg.wifi_ssid[0] != '\0') {
|
|
wifiMgr.onConnect([]() {
|
|
Serial.println("[Main] WiFi connected");
|
|
transportMgr.forceEvaluate();
|
|
});
|
|
wifiMgr.onDisconnect([]() {
|
|
Serial.println("[Main] WiFi lost");
|
|
transportMgr.forceEvaluate();
|
|
});
|
|
wifiMgr.begin(cfg.wifi_ssid, cfg.wifi_password);
|
|
} else {
|
|
Serial.println("[Main] WiFi STA disabled or not configured");
|
|
}
|
|
|
|
// --- GSM: connect if enabled and modem available ---
|
|
if (gsmMgr.isModemAvailable() && cfg.gsm_enabled && cfg.gsm_apn[0] != '\0') {
|
|
gsmMgr.connect(cfg.gsm_apn, cfg.gsm_user, cfg.gsm_pass);
|
|
transportMgr.forceEvaluate();
|
|
}
|
|
|
|
// --- OTA Manager ---
|
|
otaMgr.begin(configMgr, transportMgr, gsmMgr);
|
|
|
|
// --- Heartbeat Manager ---
|
|
heartbeatMgr.begin(configMgr, transportMgr, mqttClient, acuvim,
|
|
wifiMgr, gsmMgr, sdMgr, otaMgr);
|
|
|
|
// --- Web Portal ---
|
|
webPortal.begin(configMgr, wifiMgr, mqttClient, acuvim, gsmMgr, transportMgr, sdMgr, otaMgr);
|
|
|
|
Serial.printf("[Main] Poll interval: %d s (GSM: %d s)\n",
|
|
cfg.poll_interval_sec, cfg.gsm_poll_interval_sec);
|
|
Serial.println("[Main] Setup complete\n");
|
|
}
|
|
|
|
void loop() {
|
|
esp_task_wdt_reset();
|
|
|
|
wifiMgr.loop();
|
|
transportMgr.loop();
|
|
mqttClient.loop();
|
|
sdMgr.loop();
|
|
otaMgr.loop();
|
|
heartbeatMgr.loop();
|
|
webPortal.loop();
|
|
|
|
// Device registration on first MQTT connect
|
|
if (mqttClient.isConnected() && !cmdHandler.isRegistered()) {
|
|
cmdHandler.sendRegistration();
|
|
}
|
|
|
|
if (mqttClient.isConnected() && sdMgr.hasQueuedData()) {
|
|
sdMgr.drainBatch(mqttClient, 5);
|
|
}
|
|
|
|
DeviceConfig& cfg = configMgr.get();
|
|
unsigned long intervalMs;
|
|
if (transportMgr.getActiveTransport() == TransportType::GSM) {
|
|
intervalMs = (unsigned long)cfg.gsm_poll_interval_sec * 1000;
|
|
} else {
|
|
intervalMs = (unsigned long)cfg.poll_interval_sec * 1000;
|
|
}
|
|
|
|
unsigned long now = millis();
|
|
if (now - lastPollMs < intervalMs) {
|
|
return;
|
|
}
|
|
lastPollMs = now;
|
|
|
|
digitalWrite(LED_PIN, HIGH);
|
|
|
|
AcuvimData data;
|
|
if (acuvim.readAll(data)) {
|
|
printAcuvimData(data);
|
|
webPortal.setLatestData(data);
|
|
cmdHandler.setLatestData(data);
|
|
healthMon.evaluate(data);
|
|
|
|
if (mqttClient.isConnected()) {
|
|
const char* connType = TransportManager::transportTypeName(
|
|
transportMgr.getActiveTransport());
|
|
int signal = transportMgr.getSignalStrength();
|
|
const char* opName = nullptr;
|
|
String opStr;
|
|
if (transportMgr.getActiveTransport() == TransportType::GSM) {
|
|
opStr = gsmMgr.getOperator();
|
|
opName = opStr.c_str();
|
|
}
|
|
mqttClient.publishTelemetry(data, connType, signal, opName);
|
|
} else if (sdMgr.isAvailable()) {
|
|
sdMgr.bufferTelemetry(data);
|
|
}
|
|
} else {
|
|
Serial.printf("[Main] Read failed - error: 0x%02X, consecutive: %lu, total: %lu\n",
|
|
acuvim.getLastError(),
|
|
acuvim.getConsecutiveErrors(),
|
|
acuvim.getErrorCount());
|
|
healthMon.evaluate(data);
|
|
}
|
|
|
|
digitalWrite(LED_PIN, LOW);
|
|
|
|
// Deep sleep support
|
|
if (cfg.sleep_duration_min > 0 && !sleepScheduled) {
|
|
unsigned long wakeMs = (unsigned long)cfg.wake_duration_sec * 1000;
|
|
if (millis() > wakeMs) {
|
|
sleepScheduled = true;
|
|
Serial.printf("[Main] Entering deep sleep for %d minutes\n", cfg.sleep_duration_min);
|
|
heartbeatMgr.sendNow();
|
|
delay(500);
|
|
mqttClient.disconnect();
|
|
wifiMgr.disconnect();
|
|
gsmMgr.powerOff();
|
|
esp_task_wdt_delete(NULL);
|
|
uint64_t sleepMicros = (uint64_t)cfg.sleep_duration_min * 60 * 1000000;
|
|
esp_sleep_enable_timer_wakeup(sleepMicros);
|
|
esp_deep_sleep_start();
|
|
}
|
|
}
|
|
}
|