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>
166 lines
5.1 KiB
C++
166 lines
5.1 KiB
C++
#include "heartbeat_manager.h"
|
|
#include <ArduinoJson.h>
|
|
#include <Preferences.h>
|
|
#include <esp_system.h>
|
|
#include <time.h>
|
|
|
|
static String getResetReasonString() {
|
|
esp_reset_reason_t reason = esp_reset_reason();
|
|
switch (reason) {
|
|
case ESP_RST_POWERON: return "POWER_ON";
|
|
case ESP_RST_EXT: return "EXTERNAL";
|
|
case ESP_RST_SW: return "SW_RESET";
|
|
case ESP_RST_PANIC: return "PANIC";
|
|
case ESP_RST_INT_WDT: return "INT_WDT";
|
|
case ESP_RST_TASK_WDT: return "TASK_WDT";
|
|
case ESP_RST_WDT: return "WDT";
|
|
case ESP_RST_DEEPSLEEP: return "DEEP_SLEEP";
|
|
case ESP_RST_BROWNOUT: return "BROWNOUT";
|
|
default: return "UNKNOWN";
|
|
}
|
|
}
|
|
|
|
static uint32_t getEpochTime() {
|
|
time_t now;
|
|
time(&now);
|
|
return (now < 1700000000) ? millis() / 1000 : (uint32_t)now;
|
|
}
|
|
|
|
void HeartbeatManager::begin(ConfigManager& config, TransportManager& transport,
|
|
MqttClient& mqtt, AcuvimReader& acuvim,
|
|
WifiManager& wifi, GsmManager& gsm,
|
|
SdManager& sd, OtaManager& ota) {
|
|
_config = &config;
|
|
_transport = &transport;
|
|
_mqtt = &mqtt;
|
|
_acuvim = &acuvim;
|
|
_wifi = &wifi;
|
|
_gsm = &gsm;
|
|
_sd = &sd;
|
|
_ota = &ota;
|
|
|
|
_lastHeartbeatMs = 0;
|
|
_heartbeatCount = 0;
|
|
_sentInitial = false;
|
|
|
|
loadBootCount();
|
|
|
|
Serial.printf("[HB] Boot count: %lu, last reset: %s\n",
|
|
_bootCount, getResetReasonString().c_str());
|
|
}
|
|
|
|
void HeartbeatManager::loadBootCount() {
|
|
Preferences prefs;
|
|
prefs.begin("acuvim_sys", false);
|
|
_bootCount = prefs.getUInt("boot_count", 0) + 1;
|
|
prefs.putUInt("boot_count", _bootCount);
|
|
prefs.end();
|
|
}
|
|
|
|
void HeartbeatManager::loop() {
|
|
if (!_mqtt->isConnected()) return;
|
|
|
|
if (!_sentInitial) {
|
|
_sentInitial = true;
|
|
sendNow();
|
|
return;
|
|
}
|
|
|
|
DeviceConfig& cfg = _config->get();
|
|
unsigned long intervalMs = (unsigned long)cfg.heartbeat_interval_sec * 1000;
|
|
if (millis() - _lastHeartbeatMs >= intervalMs) {
|
|
sendNow();
|
|
}
|
|
}
|
|
|
|
void HeartbeatManager::sendNow() {
|
|
if (!_mqtt->isConnected()) return;
|
|
|
|
String payload = buildPayload();
|
|
|
|
DeviceConfig& cfg = _config->get();
|
|
char topic[128];
|
|
snprintf(topic, sizeof(topic), "%s/%s/heartbeat",
|
|
cfg.mqtt_topic_prefix, cfg.device_id);
|
|
|
|
if (_mqtt->publish(topic, payload.c_str())) {
|
|
_heartbeatCount++;
|
|
_lastHeartbeatMs = millis();
|
|
} else {
|
|
Serial.println("[HB] Publish failed");
|
|
}
|
|
}
|
|
|
|
String HeartbeatManager::buildPayload() {
|
|
JsonDocument doc;
|
|
DeviceConfig& cfg = _config->get();
|
|
|
|
doc["ts"] = getEpochTime();
|
|
doc["dev"] = cfg.device_id;
|
|
doc["fw"] = FW_VERSION;
|
|
doc["up"] = millis() / 1000;
|
|
doc["boot"] = _bootCount;
|
|
doc["hb"] = _heartbeatCount + 1;
|
|
|
|
// Connection info
|
|
JsonObject conn = doc["conn"].to<JsonObject>();
|
|
const char* transportName = TransportManager::transportTypeName(_transport->getActiveTransport());
|
|
conn["type"] = transportName;
|
|
|
|
JsonObject wifiObj = conn["wifi"].to<JsonObject>();
|
|
wifiObj["ssid"] = _wifi->getSSID();
|
|
wifiObj["rssi"] = _wifi->getRSSI();
|
|
wifiObj["ip"] = _wifi->getIPAddress();
|
|
|
|
JsonObject gsmObj = conn["gsm"].to<JsonObject>();
|
|
gsmObj["enabled"] = cfg.gsm_enabled;
|
|
gsmObj["connected"] = _gsm->isConnected();
|
|
gsmObj["signal"] = _gsm->getSignalPercent();
|
|
gsmObj["operator"] = _gsm->getOperator();
|
|
|
|
conn["mqtt"] = _mqtt->isConnected();
|
|
|
|
// Health
|
|
JsonObject health = doc["health"].to<JsonObject>();
|
|
health["heap_free"] = ESP.getFreeHeap();
|
|
health["heap_min"] = ESP.getMinFreeHeap();
|
|
health["psram_free"] = ESP.getFreePsram();
|
|
health["reset_reason"] = getResetReasonString();
|
|
health["uptime_sec"] = millis() / 1000;
|
|
|
|
if (ESP.getMinFreeHeap() < 30000) {
|
|
Serial.printf("[HB] WARNING: Low min free heap: %lu bytes\n",
|
|
(unsigned long)ESP.getMinFreeHeap());
|
|
}
|
|
|
|
// Modbus
|
|
JsonObject modbus = doc["modbus"].to<JsonObject>();
|
|
uint32_t total = _acuvim->getSuccessCount() + _acuvim->getErrorCount();
|
|
modbus["connected"] = _acuvim->getConsecutiveErrors() == 0 && _acuvim->getSuccessCount() > 0;
|
|
modbus["success"] = _acuvim->getSuccessCount();
|
|
modbus["errors"] = _acuvim->getErrorCount();
|
|
modbus["error_rate"] = (total > 0) ?
|
|
serialized(String((float)_acuvim->getErrorCount() * 100.0f / total, 1)) :
|
|
serialized("0.0");
|
|
modbus["last_error"] = _acuvim->getLastError();
|
|
modbus["last_read_ms"] = _acuvim->getLastReadDurationMs();
|
|
|
|
// SD
|
|
JsonObject sd = doc["sd"].to<JsonObject>();
|
|
sd["available"] = _sd->isAvailable();
|
|
sd["queued"] = _sd->getQueuedCount();
|
|
if (_sd->isAvailable()) {
|
|
sd["free_mb"] = (uint32_t)(_sd->getFreeSpace() / 1048576);
|
|
}
|
|
|
|
// OTA
|
|
JsonObject ota = doc["ota"].to<JsonObject>();
|
|
ota["version"] = FW_VERSION;
|
|
ota["partition"] = _ota->getRunningPartition();
|
|
ota["update_available"] = _ota->isUpdateAvailable();
|
|
|
|
String output;
|
|
serializeJson(doc, output);
|
|
return output;
|
|
}
|