Tau.Acuvim/firmware/src/heartbeat_manager.cpp
Renier Forster 84a0668c54 Initial commit: Tau Acuvim IoT monitoring system
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>
2026-05-16 19:05:32 +02:00

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;
}