feat: add SD-backed stats archive and dedicated /stats web console

This commit is contained in:
Jared Dohrman
2026-04-15 17:12:32 +10:00
parent a1aa318352
commit f67aaebe7e
27 changed files with 3862 additions and 588 deletions
+243
View File
@@ -0,0 +1,243 @@
#include "ArchiveStorage.h"
#include <MeshCore.h>
#ifndef ARCHIVE_DEBUG
#if defined(MQTT_DEBUG) && MQTT_DEBUG
#define ARCHIVE_DEBUG 1
#else
#define ARCHIVE_DEBUG 0
#endif
#endif
#if ARCHIVE_DEBUG
#define ARCHIVE_LOG(fmt, ...) Serial.printf("[ARCHIVE] " fmt "\n", ##__VA_ARGS__)
#else
#define ARCHIVE_LOG(...) do { } while (0)
#endif
namespace {
#if defined(ESP32)
const char* cardTypeName(uint8_t type) {
switch (type) {
case CARD_MMC:
return "mmc";
case CARD_SD:
return "sdsc";
case CARD_SDHC:
return "sdhc";
default:
return "unknown";
}
}
#endif
} // namespace
ArchiveStorage::ArchiveStorage()
: _attempted(false), _mounted(false), _mount_failed(false), _supported(false), _card_type(0), _spi_bus(HSPI),
_cs_pin(0xFF), _sck_pin(0xFF),
_miso_pin(0xFF), _mosi_pin(0xFF), _card_size_bytes(0), _total_bytes(0), _used_bytes(0)
#if defined(ESP32)
, _spi(nullptr)
#endif
{
}
namespace {
bool mountArchiveSd(SPIClass* spi,
uint8_t spi_bus,
uint8_t cs_pin,
uint8_t sck_pin,
uint8_t miso_pin,
uint8_t mosi_pin) {
if (spi == nullptr) {
return false;
}
#if defined(P_BOARD_IMU_CS)
pinMode(P_BOARD_IMU_CS, OUTPUT);
digitalWrite(P_BOARD_IMU_CS, HIGH);
#endif
pinMode(cs_pin, OUTPUT);
digitalWrite(cs_pin, HIGH);
spi->begin(sck_pin, miso_pin, mosi_pin, cs_pin);
return SD.begin(cs_pin, *spi, 10000000U);
}
} // namespace
void ArchiveStorage::begin() {
if (_attempted) {
return;
}
_attempted = true;
#if defined(ESP32)
_supported = resolvePins();
if (!_supported) {
ARCHIVE_LOG("unsupported: no SD pin mapping");
return;
}
ARCHIVE_LOG("init bus=%u cs=%u sck=%u miso=%u mosi=%u",
static_cast<unsigned>(_spi_bus),
static_cast<unsigned>(_cs_pin),
static_cast<unsigned>(_sck_pin),
static_cast<unsigned>(_miso_pin),
static_cast<unsigned>(_mosi_pin));
_spi = new SPIClass(_spi_bus);
if (_spi == nullptr) {
_mount_failed = true;
ARCHIVE_LOG("mount failed: SPI alloc failed");
return;
}
if (!mountArchiveSd(_spi, _spi_bus, _cs_pin, _sck_pin, _miso_pin, _mosi_pin)) {
_mount_failed = true;
ARCHIVE_LOG("mount failed bus=%u cs=%u sck=%u miso=%u mosi=%u",
static_cast<unsigned>(_spi_bus),
static_cast<unsigned>(_cs_pin),
static_cast<unsigned>(_sck_pin),
static_cast<unsigned>(_miso_pin),
static_cast<unsigned>(_mosi_pin));
return;
}
_mounted = true;
if (!SD.exists(getFsStatsPath())) {
SD.mkdir(getFsStatsPath());
}
refreshCardInfo();
ARCHIVE_LOG("mounted type=%s card=%llu total=%llu used=%llu path=%s",
getCardTypeName(),
static_cast<unsigned long long>(_card_size_bytes),
static_cast<unsigned long long>(_total_bytes),
static_cast<unsigned long long>(_used_bytes),
getLogicalStatsPath());
#else
_supported = false;
#endif
}
bool ArchiveStorage::recover() {
#if defined(ESP32)
if (!_supported || _spi == nullptr) {
return false;
}
ARCHIVE_LOG("recover begin bus=%u cs=%u", static_cast<unsigned>(_spi_bus), static_cast<unsigned>(_cs_pin));
SD.end();
_spi->end();
_mounted = false;
_mount_failed = false;
_card_size_bytes = 0;
_total_bytes = 0;
_used_bytes = 0;
if (!mountArchiveSd(_spi, _spi_bus, _cs_pin, _sck_pin, _miso_pin, _mosi_pin)) {
_mount_failed = true;
ARCHIVE_LOG("recover failed bus=%u cs=%u", static_cast<unsigned>(_spi_bus), static_cast<unsigned>(_cs_pin));
return false;
}
_mounted = true;
if (!SD.exists(getFsStatsPath())) {
SD.mkdir(getFsStatsPath());
}
refreshCardInfo();
ARCHIVE_LOG("recover mounted type=%s card=%llu total=%llu used=%llu path=%s",
getCardTypeName(),
static_cast<unsigned long long>(_card_size_bytes),
static_cast<unsigned long long>(_total_bytes),
static_cast<unsigned long long>(_used_bytes),
getLogicalStatsPath());
return true;
#else
return false;
#endif
}
bool ArchiveStorage::isSupported() const {
return _supported;
}
FILESYSTEM* ArchiveStorage::getFS() {
#if defined(ESP32)
return _mounted ? &SD : nullptr;
#else
return nullptr;
#endif
}
const char* ArchiveStorage::getCardTypeName() const {
#if defined(ESP32)
if (!_mounted) {
return "unavailable";
}
return cardTypeName(_card_type);
#else
return "unsupported";
#endif
}
#if defined(ESP32)
bool ArchiveStorage::resolvePins() {
#if defined(P_BOARD_SPI_SCK) && defined(P_BOARD_SPI_MISO) && defined(P_BOARD_SPI_MOSI) && defined(P_BOARD_SPI_CS)
_sck_pin = static_cast<uint8_t>(P_BOARD_SPI_SCK);
_miso_pin = static_cast<uint8_t>(P_BOARD_SPI_MISO);
_mosi_pin = static_cast<uint8_t>(P_BOARD_SPI_MOSI);
_cs_pin = static_cast<uint8_t>(P_BOARD_SPI_CS);
#if defined(TBEAM_SUPREME_SX1262) && defined(FSPI)
_spi_bus = FSPI;
#endif
return true;
#elif defined(TBEAM_SUPREME_SX1262)
// T-Beam S3 Supreme SD/IMU shared SPI bus pins.
_sck_pin = 36;
_miso_pin = 37;
_mosi_pin = 35;
_cs_pin = 47;
#if defined(FSPI)
_spi_bus = FSPI;
#endif
return true;
#elif defined(HAS_SDCARD) && defined(SDCARD_CS)
_cs_pin = static_cast<uint8_t>(SDCARD_CS);
#if defined(SPI_SCK) && defined(SPI_MISO) && defined(SPI_MOSI)
_sck_pin = static_cast<uint8_t>(SPI_SCK);
_miso_pin = static_cast<uint8_t>(SPI_MISO);
_mosi_pin = static_cast<uint8_t>(SPI_MOSI);
return true;
#elif defined(P_LORA_SCLK) && defined(P_LORA_MISO) && defined(P_LORA_MOSI)
_sck_pin = static_cast<uint8_t>(P_LORA_SCLK);
_miso_pin = static_cast<uint8_t>(P_LORA_MISO);
_mosi_pin = static_cast<uint8_t>(P_LORA_MOSI);
return true;
#endif
#endif
return false;
}
void ArchiveStorage::refreshCardInfo() {
if (!_mounted) {
_card_type = 0;
_card_size_bytes = 0;
_total_bytes = 0;
_used_bytes = 0;
return;
}
if (_card_type == 0) {
_card_type = SD.cardType();
}
_card_size_bytes = SD.cardSize();
_total_bytes = SD.totalBytes();
_used_bytes = SD.usedBytes();
if (_total_bytes == 0) {
_total_bytes = _card_size_bytes;
}
}
#endif
+59
View File
@@ -0,0 +1,59 @@
#pragma once
#include <Arduino.h>
#include <helpers/IdentityStore.h>
#if defined(ESP32)
#include <FS.h>
#include <SD.h>
#include <SPI.h>
#endif
class ArchiveStorage {
public:
ArchiveStorage();
void begin();
bool recover();
bool isSupported() const;
bool isAttempted() const { return _attempted; }
bool isMounted() const { return _mounted; }
bool hadMountFailure() const { return _mount_failed; }
const char* getLogicalName() const { return "archive"; }
const char* getLogicalStatsPath() const { return "archive:/stats"; }
const char* getFsStatsPath() const { return "/stats"; }
FILESYSTEM* getFS();
uint64_t getCardSizeBytes() const { return _card_size_bytes; }
uint64_t getTotalBytes() const { return _total_bytes; }
uint64_t getUsedBytes() const { return _used_bytes; }
const char* getCardTypeName() const;
uint8_t getChipSelectPin() const { return _cs_pin; }
private:
#if defined(ESP32)
bool resolvePins();
void refreshCardInfo();
#endif
bool _attempted;
bool _mounted;
bool _mount_failed;
bool _supported;
uint8_t _card_type;
uint8_t _spi_bus;
uint8_t _cs_pin;
uint8_t _sck_pin;
uint8_t _miso_pin;
uint8_t _mosi_pin;
uint64_t _card_size_bytes;
uint64_t _total_bytes;
uint64_t _used_bytes;
#if defined(ESP32)
SPIClass* _spi;
#endif
};
+126
View File
@@ -0,0 +1,126 @@
#include "NetworkPrefs.h"
#include <helpers/TxtDataHelpers.h>
#include <string.h>
namespace {
struct LegacyWebPrefsV1 {
uint32_t magic;
uint8_t web_enabled;
uint8_t web_stats_enabled;
uint8_t wifi_powersave;
uint8_t reserved;
char wifi_ssid[33];
char wifi_pwd[65];
};
bool loadLegacyWebWifiPrefs(FILESYSTEM* fs, NetworkPrefs& prefs) {
if (fs == nullptr || !fs->exists("/web_prefs")) {
return false;
}
#if defined(RP2040_PLATFORM)
File file = fs->open("/web_prefs", "r");
#else
File file = fs->open("/web_prefs");
#endif
if (!file) {
return false;
}
if (static_cast<size_t>(file.size()) < sizeof(LegacyWebPrefsV1)) {
file.close();
return false;
}
LegacyWebPrefsV1 legacy{};
bool ok = file.read(reinterpret_cast<uint8_t*>(&legacy), sizeof(legacy)) == sizeof(legacy);
file.close();
if (!ok || legacy.magic != 0x57454250) {
return false;
}
prefs.wifi_powersave = legacy.wifi_powersave <= 2 ? legacy.wifi_powersave : 0;
StrHelper::strncpy(prefs.wifi_ssid, legacy.wifi_ssid, sizeof(prefs.wifi_ssid));
StrHelper::strncpy(prefs.wifi_pwd, legacy.wifi_pwd, sizeof(prefs.wifi_pwd));
return true;
}
} // namespace
void NetworkPrefsStore::setDefaults(NetworkPrefs& prefs) {
memset(&prefs, 0, sizeof(prefs));
prefs.magic = kMagic;
prefs.wifi_powersave = 0;
}
bool NetworkPrefsStore::load(FILESYSTEM* fs, NetworkPrefs& prefs,
uint8_t legacy_wifi_powersave,
const char* legacy_wifi_ssid,
const char* legacy_wifi_pwd) {
setDefaults(prefs);
if (fs == nullptr) {
return false;
}
if (!fs->exists(kFilename)) {
prefs.wifi_powersave = legacy_wifi_powersave <= 2 ? legacy_wifi_powersave : 0;
if (legacy_wifi_ssid != nullptr) {
StrHelper::strncpy(prefs.wifi_ssid, legacy_wifi_ssid, sizeof(prefs.wifi_ssid));
}
if (legacy_wifi_pwd != nullptr) {
StrHelper::strncpy(prefs.wifi_pwd, legacy_wifi_pwd, sizeof(prefs.wifi_pwd));
}
loadLegacyWebWifiPrefs(fs, prefs);
save(fs, prefs);
return false;
}
#if defined(RP2040_PLATFORM)
File file = fs->open(kFilename, "r");
#else
File file = fs->open(kFilename);
#endif
if (!file) {
return false;
}
NetworkPrefs persisted{};
size_t bytes_to_read = min(static_cast<size_t>(file.size()), sizeof(persisted));
bool ok = bytes_to_read >= sizeof(persisted.magic) &&
file.read(reinterpret_cast<uint8_t*>(&persisted), bytes_to_read) == bytes_to_read;
file.close();
if (!ok || persisted.magic != kMagic) {
fs->remove(kFilename);
save(fs, prefs);
return false;
}
prefs = persisted;
if (prefs.wifi_powersave > 2) {
prefs.wifi_powersave = 0;
}
return true;
}
bool NetworkPrefsStore::save(FILESYSTEM* fs, const NetworkPrefs& prefs) {
if (fs == nullptr) {
return false;
}
if (fs->exists(kFilename) && !fs->remove(kFilename)) {
return false;
}
#if defined(RP2040_PLATFORM)
File file = fs->open(kFilename, "w");
#else
File file = fs->open(kFilename, "w", true);
#endif
if (!file) {
return false;
}
bool ok = file.write(reinterpret_cast<const uint8_t*>(&prefs), sizeof(prefs)) == sizeof(prefs);
file.close();
return ok;
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <helpers/IdentityStore.h>
#include <stdint.h>
struct NetworkPrefs {
uint32_t magic;
uint8_t wifi_powersave;
uint8_t reserved[3];
char wifi_ssid[33];
char wifi_pwd[65];
};
class NetworkPrefsStore {
public:
static void setDefaults(NetworkPrefs& prefs);
static bool load(FILESYSTEM* fs, NetworkPrefs& prefs,
uint8_t legacy_wifi_powersave = 0,
const char* legacy_wifi_ssid = nullptr,
const char* legacy_wifi_pwd = nullptr);
static bool save(FILESYSTEM* fs, const NetworkPrefs& prefs);
private:
static constexpr uint32_t kMagic = 0x4E455450;
static constexpr const char* kFilename = "/network_prefs";
};
+299
View File
@@ -0,0 +1,299 @@
#include "NetworkService.h"
#include <helpers/TxtDataHelpers.h>
#include <string.h>
#include <time.h>
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#include <esp_sntp.h>
#endif
namespace {
#if defined(ESP_PLATFORM)
constexpr unsigned long kWifiRetryMillis = 15000;
constexpr unsigned long kWifiConnectTimeoutMillis = 45000;
constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z
int getWifiQualityPercent(int rssi_dbm) {
if (rssi_dbm <= -100) {
return 0;
}
if (rssi_dbm >= -50) {
return 100;
}
return 2 * (rssi_dbm + 100);
}
const char* getWifiQualityLabel(int rssi_dbm) {
if (rssi_dbm >= -60) {
return "excellent";
}
if (rssi_dbm >= -67) {
return "good";
}
if (rssi_dbm >= -75) {
return "fair";
}
return "poor";
}
#endif
} // namespace
NetworkService::NetworkService()
: _fs(nullptr), _prefs{}, _wifi_started(false), _sntp_started(false), _have_time_sync(false), _last_wifi_attempt(0) {
NetworkPrefsStore::setDefaults(_prefs);
}
void NetworkService::begin(FILESYSTEM* fs,
uint8_t legacy_wifi_powersave,
const char* legacy_wifi_ssid,
const char* legacy_wifi_pwd) {
_fs = fs;
NetworkPrefsStore::load(_fs, _prefs, legacy_wifi_powersave, legacy_wifi_ssid, legacy_wifi_pwd);
}
void NetworkService::end() {
#if defined(ESP_PLATFORM)
if (_wifi_started) {
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
}
#endif
_wifi_started = false;
_sntp_started = false;
_have_time_sync = false;
_last_wifi_attempt = 0;
}
void NetworkService::loop(bool network_required) {
#if defined(ESP_PLATFORM)
ensureWifi(network_required);
updateTimeSync();
#else
(void)network_required;
#endif
}
bool NetworkService::savePrefs() {
return NetworkPrefsStore::save(_fs, _prefs);
}
bool NetworkService::setWifiSSID(const char* ssid) {
if (ssid == nullptr) {
return false;
}
StrHelper::strncpy(_prefs.wifi_ssid, ssid, sizeof(_prefs.wifi_ssid));
bool ok = savePrefs();
reconnectWifi();
return ok;
}
bool NetworkService::setWifiPassword(const char* pwd) {
if (pwd == nullptr) {
return false;
}
StrHelper::strncpy(_prefs.wifi_pwd, pwd, sizeof(_prefs.wifi_pwd));
bool ok = savePrefs();
reconnectWifi();
return ok;
}
bool NetworkService::setWifiPowerSave(const char* mode) {
if (mode == nullptr) {
return false;
}
uint8_t next_mode;
if (strcmp(mode, "none") == 0) {
next_mode = 0;
} else if (strcmp(mode, "min") == 0) {
next_mode = 1;
} else if (strcmp(mode, "max") == 0) {
next_mode = 2;
} else {
return false;
}
_prefs.wifi_powersave = next_mode;
bool ok = savePrefs();
#if defined(ESP_PLATFORM)
if (_wifi_started) {
ok = WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)) && ok;
}
#endif
return ok;
}
const char* NetworkService::getWifiPowerSave() const {
#if defined(ESP_PLATFORM)
return getPowerSaveLabel(_prefs.wifi_powersave);
#else
return "unsupported";
#endif
}
void NetworkService::formatWifiStatusReply(char* reply, size_t reply_size) const {
#if defined(ESP_PLATFORM)
const char* status = "disconnected";
const char* state = "disconnected";
wl_status_t wifi_status = WiFi.status();
if (_prefs.wifi_ssid[0] == 0) {
status = "unconfigured";
state = "unconfigured";
} else if (wifi_status == WL_CONNECTED) {
status = "connected";
state = "connected";
} else if (_wifi_started) {
status = "connecting";
}
switch (wifi_status) {
case WL_IDLE_STATUS:
state = "idle";
break;
case WL_NO_SSID_AVAIL:
state = "no_ssid";
break;
case WL_SCAN_COMPLETED:
state = "scan_completed";
break;
case WL_CONNECTED:
state = "connected";
break;
case WL_CONNECT_FAILED:
state = "connect_failed";
break;
case WL_CONNECTION_LOST:
state = "connection_lost";
break;
case WL_DISCONNECTED:
state = "disconnected";
break;
default:
state = "unknown";
break;
}
if (wifi_status == WL_CONNECTED) {
const int rssi_dbm = WiFi.RSSI();
snprintf(reply, reply_size,
"> ssid:%s status:%s code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s",
_prefs.wifi_ssid, status, static_cast<int>(wifi_status), state, WiFi.localIP().toString().c_str(),
rssi_dbm, getWifiQualityPercent(rssi_dbm), getWifiQualityLabel(rssi_dbm));
} else {
snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-",
status, static_cast<int>(wifi_status), state);
}
#else
snprintf(reply, reply_size, "> wifi:unsupported");
#endif
}
void NetworkService::reconnectWifi() {
#if defined(ESP_PLATFORM)
if (_wifi_started) {
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
}
#endif
_wifi_started = false;
_sntp_started = false;
_have_time_sync = false;
_last_wifi_attempt = 0;
}
bool NetworkService::isWifiConnected() const {
#if defined(ESP_PLATFORM)
return _wifi_started && WiFi.status() == WL_CONNECTED;
#else
return false;
#endif
}
#if defined(ESP_PLATFORM)
wifi_ps_type_t NetworkService::toEspPowerSave(uint8_t mode) {
switch (mode) {
case 1:
return WIFI_PS_MIN_MODEM;
case 2:
return WIFI_PS_MAX_MODEM;
default:
return WIFI_PS_NONE;
}
}
const char* NetworkService::getPowerSaveLabel(uint8_t mode) {
switch (mode) {
case 1:
return "min";
case 2:
return "max";
default:
return "none";
}
}
void NetworkService::ensureWifi(bool network_required) {
if (!network_required) {
if (_wifi_started) {
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
_wifi_started = false;
_sntp_started = false;
_have_time_sync = false;
}
return;
}
if (_prefs.wifi_ssid[0] == 0) {
reconnectWifi();
return;
}
if (WiFi.status() == WL_CONNECTED) {
return;
}
unsigned long now_ms = millis();
wl_status_t status = WiFi.status();
if (_wifi_started) {
if (_last_wifi_attempt != 0 && status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) {
return;
}
if (now_ms - _last_wifi_attempt < kWifiRetryMillis) {
return;
}
}
if (!_wifi_started) {
WiFi.mode(WIFI_STA);
WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave));
_wifi_started = true;
}
_last_wifi_attempt = now_ms;
WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd);
}
void NetworkService::updateTimeSync() {
bool prev_have_time_sync = _have_time_sync;
if (!_wifi_started || WiFi.status() != WL_CONNECTED) {
_have_time_sync = false;
return;
}
if (!_sntp_started) {
configTzTime("UTC0", "au.pool.ntp.org", "time.google.com", "time.cloudflare.com");
_sntp_started = true;
}
sntp_sync_status_t sync_status = sntp_get_sync_status();
time_t now = time(nullptr);
bool sane_time = now >= kMinSaneEpoch;
bool sync_ready = sync_status == SNTP_SYNC_STATUS_COMPLETED || sync_status == SNTP_SYNC_STATUS_IN_PROGRESS;
_have_time_sync = sane_time && (sync_ready || prev_have_time_sync);
}
#endif
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <Arduino.h>
#include <helpers/IdentityStore.h>
#include <helpers/NetworkStateProvider.h>
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#endif
#include "NetworkPrefs.h"
class NetworkService : public NetworkStateProvider {
public:
NetworkService();
void begin(FILESYSTEM* fs,
uint8_t legacy_wifi_powersave = 0,
const char* legacy_wifi_ssid = nullptr,
const char* legacy_wifi_pwd = nullptr);
void end();
void loop(bool network_required);
bool setWifiSSID(const char* ssid);
bool setWifiPassword(const char* pwd);
const char* getWifiSSID() const { return _prefs.wifi_ssid; }
bool setWifiPowerSave(const char* mode);
const char* getWifiPowerSave() const;
void formatWifiStatusReply(char* reply, size_t reply_size) const;
void reconnectWifi();
bool isWifiConnected() const override;
bool hasTimeSync() const override { return _have_time_sync; }
private:
#if defined(ESP_PLATFORM)
static wifi_ps_type_t toEspPowerSave(uint8_t mode);
static const char* getPowerSaveLabel(uint8_t mode);
void ensureWifi(bool network_required);
void updateTimeSync();
#endif
bool savePrefs();
FILESYSTEM* _fs;
NetworkPrefs _prefs;
bool _wifi_started;
bool _sntp_started;
bool _have_time_sync;
unsigned long _last_wifi_attempt;
};
+8
View File
@@ -0,0 +1,8 @@
#pragma once
class NetworkStateProvider {
public:
virtual ~NetworkStateProvider() = default;
virtual bool isWifiConnected() const = 0;
virtual bool hasTimeSync() const = 0;
};
+889
View File
@@ -0,0 +1,889 @@
#include "StatsHistory.h"
#include <stdio.h>
#include <string.h>
#if defined(ESP32)
#include <esp_heap_caps.h>
#endif
#ifndef ARCHIVE_DEBUG
#if defined(MQTT_DEBUG) && MQTT_DEBUG
#define ARCHIVE_DEBUG 1
#else
#define ARCHIVE_DEBUG 0
#endif
#endif
#if ARCHIVE_DEBUG
#define ARCHIVE_LOG(fmt, ...) Serial.printf("[ARCHIVE] " fmt "\n", ##__VA_ARGS__)
#else
#define ARCHIVE_LOG(...) do { } while (0)
#endif
namespace {
constexpr uint32_t kSummaryFlushIntervalMs = 5UL * 60UL * 1000UL;
constexpr uint32_t kEventFlushIntervalMs = 60UL * 1000UL;
constexpr size_t kMaxSeriesPoints = 64;
constexpr size_t kSummaryRestoreWindowBytes = 16384;
constexpr size_t kEventsRestoreWindowBytes = 4096;
struct HistoryCapacityBucket {
size_t sample_capacity;
size_t event_capacity;
};
HistoryCapacityBucket getHistoryCapacityBucket(bool want_psram) {
#if defined(ESP32)
if (!want_psram) {
return {96, 32};
}
const uint32_t psram_size = ESP.getPsramSize();
if (psram_size >= (8UL * 1024UL * 1024UL)) {
return {720, 288};
}
if (psram_size >= (4UL * 1024UL * 1024UL)) {
return {480, 192};
}
return {240, 96};
#else
(void)want_psram;
return {96, 32};
#endif
}
template <typename T>
T* allocHistoryBuffer(size_t count) {
#if defined(ESP32)
if (psramFound()) {
void* ptr = heap_caps_calloc(count, sizeof(T), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (ptr != nullptr) {
return static_cast<T*>(ptr);
}
}
return static_cast<T*>(calloc(count, sizeof(T)));
#else
return static_cast<T*>(calloc(count, sizeof(T)));
#endif
}
void freeHistoryBuffer(void* ptr) {
#if defined(ESP32)
if (ptr != nullptr) {
free(ptr);
}
#else
free(ptr);
#endif
}
File openArchiveRead(FILESYSTEM* fs, const char* filename) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(RP2040_PLATFORM)
return fs->open(filename, "r");
#else
return fs->open(filename, FILE_READ);
#endif
}
File openArchiveAppend(FILESYSTEM* fs, const char* filename) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(RP2040_PLATFORM)
return fs->open(filename, "a");
#else
return fs->open(filename, FILE_APPEND, true);
#endif
}
File openArchiveWrite(FILESYSTEM* fs, const char* filename) {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
if (fs->exists(filename)) {
fs->remove(filename);
}
return fs->open(filename, FILE_O_WRITE);
#elif defined(RP2040_PLATFORM)
return fs->open(filename, "w");
#else
if (fs->exists(filename)) {
fs->remove(filename);
}
return fs->open(filename, FILE_WRITE);
#endif
}
File openArchiveReadWithRecovery(ArchiveStorage* archive, const char* filename) {
if (archive == nullptr) {
return File();
}
FILESYSTEM* fs = archive->getFS();
if (fs == nullptr) {
return File();
}
File file = openArchiveRead(fs, filename);
if (file) {
return file;
}
if (!archive->recover()) {
return File();
}
fs = archive->getFS();
return fs != nullptr ? openArchiveRead(fs, filename) : File();
}
File openArchiveAppendWithRecovery(ArchiveStorage* archive, const char* filename) {
if (archive == nullptr) {
return File();
}
FILESYSTEM* fs = archive->getFS();
if (fs == nullptr) {
return File();
}
File file = openArchiveAppend(fs, filename);
if (file) {
return file;
}
if (!archive->recover()) {
return File();
}
fs = archive->getFS();
return fs != nullptr ? openArchiveAppend(fs, filename) : File();
}
File openArchiveWriteWithRecovery(ArchiveStorage* archive, const char* filename) {
if (archive == nullptr) {
return File();
}
FILESYSTEM* fs = archive->getFS();
if (fs == nullptr) {
return File();
}
File file = openArchiveWrite(fs, filename);
if (file) {
return file;
}
if (!archive->recover()) {
return File();
}
fs = archive->getFS();
return fs != nullptr ? openArchiveWrite(fs, filename) : File();
}
int buildPointValue(const HistorySample& sample, const HistorySample* previous, const char* series) {
if (strcmp(series, "battery") == 0) {
return static_cast<int>(sample.battery_mv);
}
if (strcmp(series, "memory") == 0) {
return static_cast<int>(sample.heap_free);
}
if (strcmp(series, "signal") == 0) {
return static_cast<int>(sample.last_rssi_x4);
}
if (strcmp(series, "packets") == 0) {
if (previous == nullptr) {
return 0;
}
const uint32_t curr_total = sample.packets_sent + sample.packets_recv;
const uint32_t prev_total = previous->packets_sent + previous->packets_recv;
return static_cast<int>(curr_total >= prev_total ? (curr_total - prev_total) : 0);
}
return 0;
}
const char* seriesTitle(const char* series) {
if (strcmp(series, "battery") == 0) {
return "Battery";
}
if (strcmp(series, "memory") == 0) {
return "Heap Free";
}
if (strcmp(series, "packets") == 0) {
return "Packet Activity";
}
if (strcmp(series, "signal") == 0) {
return "Signal";
}
return "";
}
const char* seriesUnit(const char* series) {
if (strcmp(series, "battery") == 0) {
return "mV";
}
if (strcmp(series, "memory") == 0) {
return "bytes";
}
if (strcmp(series, "packets") == 0) {
return "pkts";
}
if (strcmp(series, "signal") == 0) {
return "rssi_x4";
}
return "";
}
uint32_t sampleAgeSecs(const HistorySample& sample, uint32_t now_epoch_secs, uint32_t now_uptime_secs) {
if (sample.epoch_secs > 0 && now_epoch_secs >= sample.epoch_secs) {
return now_epoch_secs - sample.epoch_secs;
}
if (now_uptime_secs >= sample.uptime_secs) {
return now_uptime_secs - sample.uptime_secs;
}
return sample.uptime_secs;
}
uint8_t eventTypeFromName(const char* name) {
if (name == nullptr || name[0] == 0) {
return 0;
}
if (strcmp(name, "boot") == 0) return HISTORY_EVENT_BOOT;
if (strcmp(name, "web_started") == 0) return HISTORY_EVENT_WEB_STARTED;
if (strcmp(name, "web_stopped") == 0) return HISTORY_EVENT_WEB_STOPPED;
if (strcmp(name, "mqtt_connected") == 0) return HISTORY_EVENT_MQTT_CONNECTED;
if (strcmp(name, "mqtt_disconnected") == 0) return HISTORY_EVENT_MQTT_DISCONNECTED;
if (strcmp(name, "archive_mounted") == 0) return HISTORY_EVENT_ARCHIVE_MOUNTED;
if (strcmp(name, "archive_unavailable") == 0) return HISTORY_EVENT_ARCHIVE_UNAVAILABLE;
if (strcmp(name, "low_memory") == 0) return HISTORY_EVENT_LOW_MEMORY;
if (strcmp(name, "stats_enabled") == 0) return HISTORY_EVENT_STATS_ENABLED;
if (strcmp(name, "stats_disabled") == 0) return HISTORY_EVENT_STATS_DISABLED;
return 0;
}
} // namespace
StatsHistory::StatsHistory()
: _enabled(false), _psram_backed(false), _degraded(false), _summary_dirty(false), _next_summary_flush_ms(0),
_next_event_flush_ms(0), _sample_capacity(0), _sample_head(0), _sample_count(0), _event_capacity(0),
_event_head(0), _event_count(0), _samples(nullptr), _events(nullptr), _archive(nullptr), _restored_sample_count(0),
_pending_event_count(0) {
}
StatsHistory::~StatsHistory() {
freeHistoryBuffer(_samples);
freeHistoryBuffer(_events);
}
void StatsHistory::begin(bool enabled, ArchiveStorage* archive) {
_archive = archive;
_enabled = enabled;
ensureBuffers();
if (_enabled && _sample_count == 0 && isArchiveAvailable()) {
restoreSummaryLog();
}
_next_summary_flush_ms = millis() + kSummaryFlushIntervalMs;
_next_event_flush_ms = millis() + kEventFlushIntervalMs;
}
void StatsHistory::setArchive(ArchiveStorage* archive) {
_archive = archive;
}
void StatsHistory::setEnabled(bool enabled) {
if (enabled && !ensureBuffers()) {
_enabled = false;
return;
}
_enabled = enabled;
if (_enabled && _sample_count == 0 && isArchiveAvailable()) {
restoreSummaryLog();
}
}
bool StatsHistory::isArchiveAvailable() const {
return _archive != nullptr && _archive->isMounted();
}
bool StatsHistory::ensureBuffers() {
if (_samples != nullptr && _events != nullptr) {
return true;
}
#if defined(ESP32)
const bool want_psram = psramFound();
#else
const bool want_psram = false;
#endif
const HistoryCapacityBucket bucket = getHistoryCapacityBucket(want_psram);
_sample_capacity = bucket.sample_capacity;
_event_capacity = bucket.event_capacity;
_samples = allocHistoryBuffer<HistorySample>(_sample_capacity);
_events = allocHistoryBuffer<HistoryEvent>(_event_capacity);
_psram_backed = want_psram;
_degraded = !want_psram;
if (_samples == nullptr || _events == nullptr) {
freeHistoryBuffer(_samples);
freeHistoryBuffer(_events);
_samples = allocHistoryBuffer<HistorySample>(64);
_events = allocHistoryBuffer<HistoryEvent>(24);
_sample_capacity = (_samples != nullptr) ? 64 : 0;
_event_capacity = (_events != nullptr) ? 24 : 0;
_psram_backed = false;
_degraded = true;
}
return _samples != nullptr && _events != nullptr;
}
void StatsHistory::storeSample(const HistorySample& sample, bool mark_dirty) {
if (!ensureBuffers()) {
return;
}
_samples[_sample_head] = sample;
_sample_head = (_sample_head + 1) % _sample_capacity;
if (_sample_count < _sample_capacity) {
_sample_count++;
}
if (mark_dirty) {
_summary_dirty = true;
}
}
bool StatsHistory::parseSummaryLine(const char* line, HistorySample& sample) const {
if (line == nullptr || line[0] == 0) {
return false;
}
unsigned long epoch_secs = 0;
unsigned long uptime_secs = 0;
unsigned battery_mv = 0;
unsigned queue_len = 0;
int last_rssi_x4 = 0;
int last_snr_x4 = 0;
unsigned long packets_sent = 0;
unsigned long packets_recv = 0;
unsigned long heap_free = 0;
unsigned long psram_free = 0;
unsigned error_flags = 0;
unsigned recv_errors = 0;
unsigned neighbour_count = 0;
unsigned direct_dups = 0;
unsigned flood_dups = 0;
unsigned flags = 0;
const int parsed = sscanf(line,
"%lu,%lu,%u,%u,%d,%d,%lu,%lu,%lu,%lu,%u,%u,%u,%u,%u,%u",
&epoch_secs,
&uptime_secs,
&battery_mv,
&queue_len,
&last_rssi_x4,
&last_snr_x4,
&packets_sent,
&packets_recv,
&heap_free,
&psram_free,
&error_flags,
&recv_errors,
&neighbour_count,
&direct_dups,
&flood_dups,
&flags);
if (parsed != 16) {
return false;
}
memset(&sample, 0, sizeof(sample));
sample.epoch_secs = static_cast<uint32_t>(epoch_secs);
sample.uptime_secs = static_cast<uint32_t>(uptime_secs);
sample.packets_sent = static_cast<uint32_t>(packets_sent);
sample.packets_recv = static_cast<uint32_t>(packets_recv);
sample.heap_free = static_cast<uint32_t>(heap_free);
sample.heap_min = static_cast<uint32_t>(heap_free);
sample.psram_free = static_cast<uint32_t>(psram_free);
sample.psram_min = static_cast<uint32_t>(psram_free);
sample.battery_mv = static_cast<uint16_t>(battery_mv);
sample.queue_len = static_cast<uint16_t>(queue_len);
sample.error_flags = static_cast<uint16_t>(error_flags);
sample.recv_errors = static_cast<uint16_t>(recv_errors);
sample.neighbour_count = static_cast<uint16_t>(neighbour_count);
sample.direct_dups = static_cast<uint16_t>(direct_dups);
sample.flood_dups = static_cast<uint16_t>(flood_dups);
sample.last_rssi_x4 = static_cast<int16_t>(last_rssi_x4);
sample.last_snr_x4 = static_cast<int16_t>(last_snr_x4);
sample.flags = static_cast<uint8_t>(flags);
sample.battery_pct = -1;
return true;
}
bool StatsHistory::restoreSummaryLog() {
if (!isArchiveAvailable() || _sample_capacity == 0) {
return false;
}
FILESYSTEM* fs = _archive->getFS();
if (fs == nullptr || !fs->exists("/stats/summary.log")) {
return false;
}
File file = openArchiveReadWithRecovery(_archive, "/stats/summary.log");
if (!file) {
ARCHIVE_LOG("summary restore open failed path=%s", "/stats/summary.log");
return false;
}
_restored_sample_count = 0;
const size_t size = static_cast<size_t>(file.size());
const size_t start = (size > kSummaryRestoreWindowBytes) ? (size - kSummaryRestoreWindowBytes) : 0;
if (start > 0 && !file.seek(start)) {
file.close();
return false;
}
char line[192];
size_t line_len = 0;
bool skip_partial = (start > 0);
while (file.available()) {
const int raw = file.read();
if (raw < 0) {
break;
}
const char ch = static_cast<char>(raw);
if (skip_partial) {
if (ch == '\n') {
skip_partial = false;
}
continue;
}
if (ch == '\r') {
continue;
}
if (ch == '\n') {
line[line_len] = 0;
HistorySample sample{};
if (parseSummaryLine(line, sample)) {
storeSample(sample, false);
_restored_sample_count++;
}
line_len = 0;
continue;
}
if (line_len + 1 < sizeof(line)) {
line[line_len++] = ch;
}
}
if (!skip_partial && line_len > 0) {
line[line_len] = 0;
HistorySample sample{};
if (parseSummaryLine(line, sample)) {
storeSample(sample, false);
_restored_sample_count++;
}
}
file.close();
return _restored_sample_count > 0;
}
bool StatsHistory::parseEventLine(const char* line, HistoryEvent& event) const {
if (line == nullptr || line[0] == 0) {
return false;
}
unsigned long epoch_secs = 0;
unsigned long uptime_secs = 0;
char type_name[32];
int value = 0;
memset(type_name, 0, sizeof(type_name));
const int parsed = sscanf(line, "%lu,%lu,%31[^,],%d", &epoch_secs, &uptime_secs, type_name, &value);
if (parsed != 4) {
return false;
}
const uint8_t type = eventTypeFromName(type_name);
if (type == 0) {
return false;
}
memset(&event, 0, sizeof(event));
event.type = type;
event.epoch_secs = static_cast<uint32_t>(epoch_secs);
event.uptime_secs = static_cast<uint32_t>(uptime_secs);
event.value = static_cast<int16_t>(value);
return true;
}
bool StatsHistory::restoreEventsLog() {
if (!isArchiveAvailable() || _event_capacity == 0) {
return false;
}
FILESYSTEM* fs = _archive->getFS();
if (fs == nullptr || !fs->exists("/stats/events.log")) {
return false;
}
File file = openArchiveRead(fs, "/stats/events.log");
if (!file) {
return false;
}
const size_t size = static_cast<size_t>(file.size());
const size_t start = (size > kEventsRestoreWindowBytes) ? (size - kEventsRestoreWindowBytes) : 0;
if (start > 0 && !file.seek(start)) {
file.close();
return false;
}
char line[160];
size_t line_len = 0;
bool skip_partial = (start > 0);
while (file.available()) {
const int raw = file.read();
if (raw < 0) {
break;
}
const char ch = static_cast<char>(raw);
if (skip_partial) {
if (ch == '\n') {
skip_partial = false;
}
continue;
}
if (ch == '\r') {
continue;
}
if (ch == '\n') {
line[line_len] = 0;
HistoryEvent event{};
if (parseEventLine(line, event)) {
storeEvent(event, false);
}
line_len = 0;
continue;
}
if (line_len + 1 < sizeof(line)) {
line[line_len++] = ch;
}
}
if (!skip_partial && line_len > 0) {
line[line_len] = 0;
HistoryEvent event{};
if (parseEventLine(line, event)) {
storeEvent(event, false);
}
}
file.close();
return _event_count > 0;
}
void StatsHistory::pushSample(const HistorySample& sample) {
if (!_enabled || !ensureBuffers()) {
return;
}
storeSample(sample, true);
}
void StatsHistory::appendPendingEvent(const HistoryEvent& event) {
if (_pending_event_count < (sizeof(_pending_events) / sizeof(_pending_events[0]))) {
_pending_events[_pending_event_count++] = event;
return;
}
memmove(&_pending_events[0], &_pending_events[1], sizeof(_pending_events) - sizeof(_pending_events[0]));
_pending_events[(sizeof(_pending_events) / sizeof(_pending_events[0])) - 1] = event;
}
void StatsHistory::storeEvent(const HistoryEvent& event, bool queue_pending) {
if (!ensureBuffers()) {
return;
}
_events[_event_head] = event;
_event_head = (_event_head + 1) % _event_capacity;
if (_event_count < _event_capacity) {
_event_count++;
}
if (queue_pending) {
appendPendingEvent(event);
}
}
void StatsHistory::recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptime_secs, int16_t value) {
if (!ensureBuffers()) {
return;
}
HistoryEvent event{};
event.type = type;
event.epoch_secs = epoch_secs;
event.uptime_secs = uptime_secs;
event.value = value;
storeEvent(event, true);
}
void StatsHistory::maybeFlush(uint32_t now_ms) {
if (!_enabled || !isArchiveAvailable()) {
return;
}
if (_summary_dirty && (now_ms >= _next_summary_flush_ms || _pending_event_count >= 8)) {
flushSummaryLog();
_summary_dirty = false;
_next_summary_flush_ms = now_ms + kSummaryFlushIntervalMs;
}
if (_pending_event_count > 0 && now_ms >= _next_event_flush_ms) {
flushEventsLog();
_next_event_flush_ms = now_ms + kEventFlushIntervalMs;
}
}
void StatsHistory::flushSummaryLog() {
if (_archive == nullptr || _sample_count == 0) {
return;
}
FILESYSTEM* fs = _archive->getFS();
if (fs == nullptr) {
return;
}
HistorySample latest{};
if (!getSampleFromOldest(_sample_count - 1, latest)) {
return;
}
File file = openArchiveAppendWithRecovery(_archive, "/stats/summary.log");
if (!file) {
ARCHIVE_LOG("summary open failed path=%s", "/stats/summary.log");
return;
}
char line[256];
snprintf(line, sizeof(line),
"%lu,%lu,%u,%u,%d,%d,%u,%u,%u,%u,%u,%u,%u,%u,%u,%u\n",
static_cast<unsigned long>(latest.epoch_secs),
static_cast<unsigned long>(latest.uptime_secs),
static_cast<unsigned>(latest.battery_mv),
static_cast<unsigned>(latest.queue_len),
static_cast<int>(latest.last_rssi_x4),
static_cast<int>(latest.last_snr_x4),
static_cast<unsigned>(latest.packets_sent),
static_cast<unsigned>(latest.packets_recv),
static_cast<unsigned>(latest.heap_free),
static_cast<unsigned>(latest.psram_free),
static_cast<unsigned>(latest.error_flags),
static_cast<unsigned>(latest.recv_errors),
static_cast<unsigned>(latest.neighbour_count),
static_cast<unsigned>(latest.direct_dups),
static_cast<unsigned>(latest.flood_dups),
static_cast<unsigned>(latest.flags));
const size_t written = file.print(line);
file.flush();
file.close();
ARCHIVE_LOG("summary flushed path=%s bytes=%u sample_count=%u",
"/stats/summary.log",
static_cast<unsigned>(written),
static_cast<unsigned>(_sample_count));
writeMetaFile();
}
void StatsHistory::flushEventsLog() {
if (_archive == nullptr || _pending_event_count == 0) {
return;
}
FILESYSTEM* fs = _archive->getFS();
if (fs == nullptr) {
return;
}
File file = openArchiveAppendWithRecovery(_archive, "/stats/events.log");
if (!file) {
ARCHIVE_LOG("events open failed path=%s", "/stats/events.log");
return;
}
size_t total_written = 0;
for (size_t i = 0; i < _pending_event_count; ++i) {
const HistoryEvent& event = _pending_events[i];
char line[160];
snprintf(line, sizeof(line),
"%lu,%lu,%s,%d\n",
static_cast<unsigned long>(event.epoch_secs),
static_cast<unsigned long>(event.uptime_secs),
getEventTypeName(event.type),
static_cast<int>(event.value));
total_written += file.print(line);
}
file.flush();
file.close();
ARCHIVE_LOG("events flushed path=%s bytes=%u count=%u",
"/stats/events.log",
static_cast<unsigned>(total_written),
static_cast<unsigned>(_pending_event_count));
_pending_event_count = 0;
writeMetaFile();
}
void StatsHistory::writeMetaFile() const {
if (_archive == nullptr) {
return;
}
FILESYSTEM* fs = _archive->getFS();
if (fs == nullptr) {
return;
}
File file = openArchiveWriteWithRecovery(_archive, "/stats/meta.json");
if (!file) {
ARCHIVE_LOG("meta open failed path=%s", "/stats/meta.json");
return;
}
char json[256];
snprintf(json, sizeof(json),
"{\"logical\":\"%s\",\"path\":\"%s\",\"samples\":{\"count\":%u,\"capacity\":%u},"
"\"events\":{\"count\":%u,\"capacity\":%u},\"psram\":%s,\"degraded\":%s}\n",
_archive->getLogicalName(),
_archive->getLogicalStatsPath(),
static_cast<unsigned>(_sample_count),
static_cast<unsigned>(_sample_capacity),
static_cast<unsigned>(_event_count),
static_cast<unsigned>(_event_capacity),
_psram_backed ? "true" : "false",
_degraded ? "true" : "false");
const size_t written = file.print(json);
file.flush();
file.close();
ARCHIVE_LOG("meta flushed path=%s bytes=%u",
"/stats/meta.json",
static_cast<unsigned>(written));
}
bool StatsHistory::getSampleFromOldest(size_t index, HistorySample& sample) const {
if (_samples == nullptr || index >= _sample_count) {
return false;
}
const size_t oldest = (_sample_head + _sample_capacity - _sample_count) % _sample_capacity;
const size_t slot = (oldest + index) % _sample_capacity;
sample = _samples[slot];
return true;
}
bool StatsHistory::buildSeriesJson(const char* series, char* buffer, size_t buffer_size, uint32_t now_epoch_secs, uint32_t now_uptime_secs) const {
if (buffer == nullptr || buffer_size == 0 || series == nullptr) {
return false;
}
buffer[0] = 0;
if (seriesTitle(series)[0] == 0) {
return false;
}
if (_sample_count == 0) {
snprintf(buffer, buffer_size,
"{\"series\":\"%s\",\"title\":\"%s\",\"unit\":\"%s\",\"interval_secs\":%lu,\"current\":0,\"oldest_age_secs\":0,\"latest_age_secs\":0,\"points\":[]}",
series,
seriesTitle(series),
seriesUnit(series),
static_cast<unsigned long>(kSampleIntervalSecs));
return true;
}
const size_t points = min(_sample_count, kMaxSeriesPoints);
const size_t step = (_sample_count > kMaxSeriesPoints) ? ((_sample_count + kMaxSeriesPoints - 1) / kMaxSeriesPoints) : 1;
size_t offset = 0;
HistorySample sample{};
HistorySample previous{};
bool have_previous = false;
int current_value = 0;
offset += snprintf(&buffer[offset], buffer_size - offset,
"{\"series\":\"%s\",\"title\":\"%s\",\"unit\":\"%s\",\"interval_secs\":%lu,\"current\":",
series,
seriesTitle(series),
seriesUnit(series),
static_cast<unsigned long>(kSampleIntervalSecs));
getSampleFromOldest(_sample_count - 1, sample);
if (strcmp(series, "packets") == 0 && _sample_count >= 2) {
getSampleFromOldest(_sample_count - 2, previous);
current_value = buildPointValue(sample, &previous, series);
} else {
current_value = buildPointValue(sample, nullptr, series);
}
size_t last_index = 0;
for (size_t i = 0, emitted = 0; i < _sample_count && emitted < points; i += step, ++emitted) {
last_index = i;
}
HistorySample oldest_sample{};
HistorySample latest_emitted_sample{};
const bool have_oldest_sample = getSampleFromOldest(0, oldest_sample);
const bool have_latest_emitted_sample = getSampleFromOldest(last_index, latest_emitted_sample);
const uint32_t oldest_age_secs = have_oldest_sample ? sampleAgeSecs(oldest_sample, now_epoch_secs, now_uptime_secs) : 0;
const uint32_t latest_age_secs = have_latest_emitted_sample ? sampleAgeSecs(latest_emitted_sample, now_epoch_secs, now_uptime_secs) : 0;
offset += snprintf(&buffer[offset], buffer_size - offset,
"%d,\"oldest_age_secs\":%lu,\"latest_age_secs\":%lu,\"points\":[",
current_value,
static_cast<unsigned long>(oldest_age_secs),
static_cast<unsigned long>(latest_age_secs));
size_t emitted = 0;
for (size_t i = 0; i < _sample_count && emitted < points; i += step, ++emitted) {
if (!getSampleFromOldest(i, sample)) {
break;
}
int value = buildPointValue(sample, have_previous ? &previous : nullptr, series);
offset += snprintf(&buffer[offset], buffer_size - offset,
"%s[%lu,%d]",
emitted == 0 ? "" : ",",
static_cast<unsigned long>(sample.uptime_secs),
value);
previous = sample;
have_previous = true;
if (offset + 24 >= buffer_size) {
break;
}
}
snprintf(&buffer[offset], buffer_size - offset, "]}");
return true;
}
bool StatsHistory::getRecentEvent(size_t reverse_index, HistoryEvent& event) const {
if (_events == nullptr || reverse_index >= _event_count) {
return false;
}
const size_t newest = (_event_head + _event_capacity - 1) % _event_capacity;
const size_t slot = (newest + _event_capacity - reverse_index) % _event_capacity;
event = _events[slot];
return true;
}
const char* StatsHistory::getEventTypeName(uint8_t type) {
switch (type) {
case HISTORY_EVENT_BOOT:
return "boot";
case HISTORY_EVENT_WEB_STARTED:
return "web_started";
case HISTORY_EVENT_WEB_STOPPED:
return "web_stopped";
case HISTORY_EVENT_MQTT_CONNECTED:
return "mqtt_connected";
case HISTORY_EVENT_MQTT_DISCONNECTED:
return "mqtt_disconnected";
case HISTORY_EVENT_ARCHIVE_MOUNTED:
return "archive_mounted";
case HISTORY_EVENT_ARCHIVE_UNAVAILABLE:
return "archive_unavailable";
case HISTORY_EVENT_LOW_MEMORY:
return "low_memory";
case HISTORY_EVENT_STATS_ENABLED:
return "stats_enabled";
case HISTORY_EVENT_STATS_DISABLED:
return "stats_disabled";
default:
return "unknown";
}
}
+132
View File
@@ -0,0 +1,132 @@
#pragma once
#include <Arduino.h>
#include <MeshCore.h>
#include "ArchiveStorage.h"
struct HistorySample {
uint32_t epoch_secs;
uint32_t uptime_secs;
uint32_t packets_sent;
uint32_t packets_recv;
uint32_t heap_free;
uint32_t heap_min;
uint32_t psram_free;
uint32_t psram_min;
uint16_t battery_mv;
uint16_t queue_len;
uint16_t error_flags;
uint16_t recv_errors;
uint16_t neighbour_count;
uint16_t direct_dups;
uint16_t flood_dups;
int16_t last_rssi_x4;
int16_t last_snr_x4;
int16_t noise_floor;
uint8_t flags;
int8_t battery_pct;
uint16_t reserved;
};
struct HistoryEvent {
uint32_t epoch_secs;
uint32_t uptime_secs;
uint8_t type;
int16_t value;
uint8_t reserved;
};
enum HistorySampleFlags : uint8_t {
HISTORY_FLAG_EXTERNAL_POWER = 1 << 0,
HISTORY_FLAG_CHARGING = 1 << 1,
HISTORY_FLAG_VBUS = 1 << 2,
HISTORY_FLAG_WIFI_CONNECTED = 1 << 3,
HISTORY_FLAG_MQTT_CONNECTED = 1 << 4,
HISTORY_FLAG_WEB_ENABLED = 1 << 5,
HISTORY_FLAG_WEB_PANEL_UP = 1 << 6,
HISTORY_FLAG_ARCHIVE_MOUNTED = 1 << 7,
};
enum HistoryEventType : uint8_t {
HISTORY_EVENT_BOOT = 1,
HISTORY_EVENT_WEB_STARTED = 2,
HISTORY_EVENT_WEB_STOPPED = 3,
HISTORY_EVENT_MQTT_CONNECTED = 4,
HISTORY_EVENT_MQTT_DISCONNECTED = 5,
HISTORY_EVENT_ARCHIVE_MOUNTED = 6,
HISTORY_EVENT_ARCHIVE_UNAVAILABLE = 7,
HISTORY_EVENT_LOW_MEMORY = 8,
HISTORY_EVENT_STATS_ENABLED = 9,
HISTORY_EVENT_STATS_DISABLED = 10,
};
class StatsHistory {
public:
static constexpr uint32_t kSampleIntervalSecs = 60;
static constexpr uint32_t kArchiveSummaryIntervalSecs = 300;
StatsHistory();
~StatsHistory();
void begin(bool enabled, ArchiveStorage* archive);
void setArchive(ArchiveStorage* archive);
void setEnabled(bool enabled);
bool isEnabled() const { return _enabled; }
bool isRecentHistoryAvailable() const { return _samples != nullptr; }
bool isPsramBacked() const { return _psram_backed; }
bool isDegraded() const { return _degraded; }
bool isArchiveAvailable() const;
bool hasArchiveRestore() const { return _restored_sample_count > 0; }
size_t getSampleCapacity() const { return _sample_capacity; }
size_t getSampleCount() const { return _sample_count; }
size_t getEventCapacity() const { return _event_capacity; }
size_t getEventCount() const { return _event_count; }
size_t getRestoredSampleCount() const { return _restored_sample_count; }
void pushSample(const HistorySample& sample);
void recordEvent(uint8_t type, uint32_t epoch_secs, uint32_t uptime_secs, int16_t value = 0);
void maybeFlush(uint32_t now_ms);
bool buildSeriesJson(const char* series, char* buffer, size_t buffer_size, uint32_t now_epoch_secs, uint32_t now_uptime_secs) const;
bool getRecentEvent(size_t reverse_index, HistoryEvent& event) const;
static const char* getEventTypeName(uint8_t type);
static constexpr uint32_t getSampleIntervalSecs() { return kSampleIntervalSecs; }
static constexpr uint32_t getArchiveSummaryIntervalSecs() { return kArchiveSummaryIntervalSecs; }
private:
bool ensureBuffers();
bool restoreSummaryLog();
bool restoreEventsLog();
bool parseSummaryLine(const char* line, HistorySample& sample) const;
bool parseEventLine(const char* line, HistoryEvent& event) const;
void storeSample(const HistorySample& sample, bool mark_dirty);
void storeEvent(const HistoryEvent& event, bool queue_pending);
void appendPendingEvent(const HistoryEvent& event);
void flushSummaryLog();
void flushEventsLog();
void writeMetaFile() const;
bool getSampleFromOldest(size_t index, HistorySample& sample) const;
bool _enabled;
bool _psram_backed;
bool _degraded;
bool _summary_dirty;
uint32_t _next_summary_flush_ms;
uint32_t _next_event_flush_ms;
size_t _sample_capacity;
size_t _sample_head;
size_t _sample_count;
size_t _event_capacity;
size_t _event_head;
size_t _event_count;
HistorySample* _samples;
HistoryEvent* _events;
ArchiveStorage* _archive;
size_t _restored_sample_count;
HistoryEvent _pending_events[16];
size_t _pending_event_count;
};
+19 -3
View File
@@ -26,7 +26,7 @@
#define P_BOARD_SPI_MOSI 35 //SPI for SD Card and QMI8653 (IMU)
#define P_BOARD_SPI_MISO 37 //SPI for SD Card and QMI8653 (IMU)
#define P_BOARD_SPI_SCK 36 //SPI for SD Card and QMI8653 (IMU)
#define P_BPARD_SPI_CS 47 //Pin for SD Card CS
#define P_BOARD_SPI_CS 47 //Pin for SD Card CS
#define P_BOARD_IMU_CS 34 //Pin for QMI8653 (IMU) CS
#define P_BOARD_IMU_INT 33 //IMU Int pin
@@ -154,8 +154,24 @@ public:
esp_deep_sleep_start(); // CPU halts here and never returns!
}
uint16_t getBattMilliVolts(){
return PMU->getBattVoltage();
uint16_t getBattMilliVolts() override {
return PMU != NULL ? PMU->getBattVoltage() : 0;
}
int getBatteryPercent() override {
return (PMU != NULL && PMU->isBatteryConnect()) ? PMU->getBatteryPercent() : -1;
}
bool isCharging() override {
return PMU != NULL && PMU->isCharging();
}
bool isVbusPresent() override {
return PMU != NULL && PMU->isVbusIn();
}
bool isExternalPowered() override {
return isVbusPresent();
}
const char* getManufacturerName() const{
+8 -7
View File
@@ -2,6 +2,7 @@
#ifdef WITH_MQTT_UPLINK
#include <stddef.h>
#include <helpers/TxtDataHelpers.h>
#include <string.h>
@@ -17,15 +18,16 @@ void MQTTPrefsStore::setDefaults(MQTTPrefs& prefs) {
prefs.raw_enabled = 0;
prefs.status_enabled = 1;
prefs.tx_enabled = 0;
prefs.web_enabled = 1;
prefs.wifi_powersave = 0;
prefs.deprecated_web_enabled = 0;
prefs.deprecated_web_stats_enabled = 0;
prefs.legacy_wifi_powersave = 0;
prefs.status_interval_ms = kFixedStatusIntervalMs;
StrHelper::strncpy(prefs.iata, MQTT_DEFAULT_IATA, sizeof(prefs.iata));
#ifdef WIFI_SSID
StrHelper::strncpy(prefs.wifi_ssid, WIFI_SSID, sizeof(prefs.wifi_ssid));
StrHelper::strncpy(prefs.legacy_wifi_ssid, WIFI_SSID, sizeof(prefs.legacy_wifi_ssid));
#endif
#ifdef WIFI_PWD
StrHelper::strncpy(prefs.wifi_pwd, WIFI_PWD, sizeof(prefs.wifi_pwd));
StrHelper::strncpy(prefs.legacy_wifi_pwd, WIFI_PWD, sizeof(prefs.legacy_wifi_pwd));
#endif
}
@@ -58,10 +60,9 @@ bool MQTTPrefsStore::load(FILESYSTEM* fs, MQTTPrefs& prefs) {
return false;
}
prefs = persisted;
if (prefs.wifi_powersave > 2) {
prefs.wifi_powersave = 0;
if (prefs.legacy_wifi_powersave > 2) {
prefs.legacy_wifi_powersave = 0;
}
prefs.web_enabled = prefs.web_enabled ? 1 : 0;
prefs.status_interval_ms = kFixedStatusIntervalMs;
prefs.enabled_mask &= 0x07;
return true;
+6 -4
View File
@@ -16,14 +16,16 @@ struct MQTTPrefs {
uint8_t raw_enabled;
uint8_t status_enabled;
uint8_t tx_enabled;
uint8_t web_enabled;
uint8_t wifi_powersave;
uint8_t deprecated_web_enabled;
uint8_t legacy_wifi_powersave;
uint32_t status_interval_ms;
char iata[8];
char wifi_ssid[33];
char wifi_pwd[65];
char legacy_wifi_ssid[33];
char legacy_wifi_pwd[65];
char owner_public_key[65];
char owner_email[96];
uint8_t deprecated_web_stats_enabled;
uint8_t reserved[3];
};
class MQTTPrefsStore {
+23 -354
View File
@@ -11,7 +11,6 @@
#include <esp_idf_version.h>
#include <esp_heap_caps.h>
#include <esp_system.h>
#include <esp_sntp.h>
#include <helpers/TxtDataHelpers.h>
#include <ctype.h>
#include <string.h>
@@ -35,21 +34,13 @@
#if MQTT_DEBUG
#define LOG_CAT(tag, fmt, ...) Serial.printf("[" tag "] " fmt "\n", ##__VA_ARGS__)
#define WIFI_LOG(fmt, ...) LOG_CAT("WIFI", fmt, ##__VA_ARGS__)
#define WEB_LOG(fmt, ...) LOG_CAT("WEB", fmt, ##__VA_ARGS__)
#define TIME_LOG(fmt, ...) LOG_CAT("TIME", fmt, ##__VA_ARGS__)
#define MQTT_LOG(fmt, ...) LOG_CAT("MQTT", fmt, ##__VA_ARGS__)
#else
#define LOG_CAT(...) do { } while (0)
#define WIFI_LOG(...) do { } while (0)
#define WEB_LOG(...) do { } while (0)
#define TIME_LOG(...) do { } while (0)
#define MQTT_LOG(...) do { } while (0)
#endif
namespace {
constexpr unsigned long kWifiRetryMillis = 15000;
constexpr unsigned long kWifiConnectTimeoutMillis = 45000;
constexpr unsigned long kBrokerRetryBaseMillis = 10000;
constexpr unsigned long kBrokerRetryMaxMillis = 300000;
constexpr size_t kBrokerTokenSize = 640;
@@ -57,29 +48,6 @@ constexpr time_t kTokenLifetimeSecs = 3600;
constexpr time_t kTokenRefreshSlackSecs = 300;
constexpr time_t kMinSaneEpoch = 1735689600; // 2025-01-01T00:00:00Z
int getWifiQualityPercent(int rssi_dbm) {
if (rssi_dbm <= -100) {
return 0;
}
if (rssi_dbm >= -50) {
return 100;
}
return 2 * (rssi_dbm + 100);
}
const char* getWifiQualityLabel(int rssi_dbm) {
if (rssi_dbm >= -60) {
return "excellent";
}
if (rssi_dbm >= -67) {
return "good";
}
if (rssi_dbm >= -75) {
return "fair";
}
return "poor";
}
unsigned long getBrokerRetryDelayMillis(uint8_t failures) {
unsigned long delay_ms = kBrokerRetryBaseMillis;
if (failures > 0) {
@@ -106,20 +74,6 @@ void freeScratchBuffer(void* ptr) {
}
}
const char* getWifiStateLabel(const MQTTPrefs& prefs, bool wifi_started) {
if (prefs.wifi_ssid[0] == 0) {
return "off";
}
wl_status_t status = WiFi.status();
if (status == WL_CONNECTED) {
return "up";
}
if (wifi_started) {
return "conn";
}
return "down";
}
#if MQTT_DEBUG
void logMqttMemorySnapshot(const char* phase, const char* broker_label = nullptr) {
MQTT_LOG("mem phase=%s broker=%s uptime_ms=%lu heap_free=%u heap_min=%u heap_max=%u psram_free=%u psram_min=%u "
@@ -150,10 +104,8 @@ const MQTTUplink::BrokerSpec MQTTUplink::kBrokerSpecs[3] = {
};
MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity)
: _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _wifi_started(false), _sntp_started(false),
_have_time_sync(false), _last_wifi_attempt(0), _last_status_publish(0), _last_status{},
_node_name(nullptr),
_web_runner(nullptr)
: _fs(nullptr), _rtc(&rtc), _identity(&identity), _running(false), _last_status_publish(0), _last_status{},
_node_name(nullptr), _network(nullptr)
{
memset(_device_id, 0, sizeof(_device_id));
MQTTPrefsStore::setDefaults(_prefs);
@@ -164,11 +116,6 @@ MQTTUplink::MQTTUplink(mesh::RTCClock& rtc, mesh::LocalIdentity& identity)
MQTT_LOG("uplink init");
}
void MQTTUplink::setWebCommandRunner(MQTTWebCommandRunner* runner) {
_web_runner = runner;
_web_panel.setCommandRunner(runner);
}
bool MQTTUplink::savePrefs() {
return MQTTPrefsStore::save(_fs, _prefs);
}
@@ -203,24 +150,8 @@ bool MQTTUplink::isActive() const {
return _running && hasEnabledBroker();
}
void MQTTUplink::reconnectWifi() {
WIFI_LOG("reset");
stopWebServer();
for (BrokerState& broker : _brokers) {
destroyBroker(broker);
}
if (_wifi_started) {
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
}
_wifi_started = false;
_sntp_started = false;
_have_time_sync = false;
_last_wifi_attempt = 0;
}
bool MQTTUplink::sendStatusNow() {
if (!_running || !_have_time_sync || WiFi.status() != WL_CONNECTED) {
if (!_running || _network == nullptr || !_network->hasTimeSync() || !_network->isWifiConnected()) {
return false;
}
@@ -327,28 +258,6 @@ void MQTTUplink::escapeJsonString(const char* input, char* output, size_t output
output[oi] = 0;
}
wifi_ps_type_t MQTTUplink::toEspPowerSave(uint8_t mode) {
switch (mode) {
case 1:
return WIFI_PS_MIN_MODEM;
case 2:
return WIFI_PS_MAX_MODEM;
default:
return WIFI_PS_NONE;
}
}
const char* MQTTUplink::getPowerSaveLabel(uint8_t mode) {
switch (mode) {
case 1:
return "min";
case 2:
return "max";
default:
return "none";
}
}
void MQTTUplink::refreshIdentityStrings() {
bytesToHexUpper(_identity->pub_key, PUB_KEY_SIZE, _device_id, sizeof(_device_id));
for (BrokerState& broker : _brokers) {
@@ -388,7 +297,7 @@ void MQTTUplink::refreshBrokerState(BrokerState& broker) {
bool MQTTUplink::refreshToken(BrokerState& broker) {
time_t now = time(nullptr);
if (now < kMinSaneEpoch) {
TIME_LOG("%s token skipped: clock not ready (%lu)", broker.spec->label, static_cast<unsigned long>(now));
MQTT_LOG("%s token skipped: clock not ready (%lu)", broker.spec->label, static_cast<unsigned long>(now));
return false;
}
@@ -685,94 +594,6 @@ void MQTTUplink::handleMqttEvent(void* handler_args, esp_event_base_t, int32_t e
}
}
void MQTTUplink::stopWebServer() {
_web_panel.stop();
}
void MQTTUplink::ensureWebServer() {
if (_web_runner == nullptr || _prefs.web_enabled == 0 || !_wifi_started || WiFi.status() != WL_CONNECTED) {
stopWebServer();
return;
}
_web_panel.start();
}
void MQTTUplink::ensureWifi() {
if (_prefs.wifi_ssid[0] == 0) {
WIFI_LOG("disabled: no ssid");
stopWebServer();
reconnectWifi();
return;
}
if (!hasEnabledBroker() && _web_runner == nullptr) {
WIFI_LOG("disabled: no mqtt endpoints and no web runner");
stopWebServer();
if (_wifi_started) {
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
_wifi_started = false;
_sntp_started = false;
_have_time_sync = false;
}
return;
}
if (WiFi.status() == WL_CONNECTED) {
return;
}
unsigned long now_ms = millis();
wl_status_t status = WiFi.status();
if (_wifi_started) {
if (_last_wifi_attempt != 0 && status == WL_IDLE_STATUS && now_ms - _last_wifi_attempt < kWifiConnectTimeoutMillis) {
return;
}
if (now_ms - _last_wifi_attempt < kWifiRetryMillis) {
return;
}
}
if (!_wifi_started) {
WiFi.mode(WIFI_STA);
WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave));
_wifi_started = true;
WIFI_LOG("sta start powersaving=%s", getPowerSaveLabel(_prefs.wifi_powersave));
} else {
WIFI_LOG("retry status=%d", static_cast<int>(status));
}
_last_wifi_attempt = now_ms;
WIFI_LOG("begin ssid=%s", _prefs.wifi_ssid);
WiFi.begin(_prefs.wifi_ssid, _prefs.wifi_pwd);
}
void MQTTUplink::updateTimeSync() {
bool prev_have_time_sync = _have_time_sync;
if (!_wifi_started || WiFi.status() != WL_CONNECTED) {
_have_time_sync = false;
if (prev_have_time_sync != _have_time_sync) {
TIME_LOG("sntp lost");
}
return;
}
if (!_sntp_started) {
configTzTime("UTC0", "au.pool.ntp.org", "time.google.com", "time.cloudflare.com");
_sntp_started = true;
TIME_LOG("sntp start servers=au.pool.ntp.org,time.google.com,time.cloudflare.com");
}
sntp_sync_status_t sync_status = sntp_get_sync_status();
time_t now = time(nullptr);
bool sane_time = now >= kMinSaneEpoch;
bool sync_ready = sync_status == SNTP_SYNC_STATUS_COMPLETED || sync_status == SNTP_SYNC_STATUS_IN_PROGRESS;
_have_time_sync = sane_time && (sync_ready || prev_have_time_sync);
if (prev_have_time_sync != _have_time_sync) {
TIME_LOG("sntp %s epoch=%lu status=%ld", _have_time_sync ? "ready" : "waiting",
static_cast<unsigned long>(now), static_cast<long>(sync_status));
}
}
void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) {
if (broker.spec == nullptr) {
return;
@@ -787,7 +608,7 @@ void MQTTUplink::ensureBroker(BrokerState& broker, bool allow_new_connect) {
return;
}
if (!_have_time_sync || WiFi.status() != WL_CONNECTED) {
if (_network == nullptr || !_network->hasTimeSync() || !_network->isWifiConnected()) {
return;
}
@@ -899,24 +720,15 @@ void MQTTUplink::begin(FILESYSTEM* fs) {
refreshIdentityStrings();
_running = true;
_last_status_publish = millis();
MQTT_LOG("begin iata=%s enabled_mask=0x%02X wifi_ssid=%s", _prefs.iata, _prefs.enabled_mask, _prefs.wifi_ssid);
MQTT_LOG("begin iata=%s enabled_mask=0x%02X", _prefs.iata, _prefs.enabled_mask);
}
void MQTTUplink::end() {
MQTT_LOG("end");
publishStatus(false);
stopWebServer();
for (BrokerState& broker : _brokers) {
destroyBroker(broker);
}
if (_wifi_started) {
WiFi.disconnect(true, true);
WiFi.mode(WIFI_OFF);
}
_wifi_started = false;
_sntp_started = false;
_have_time_sync = false;
_last_wifi_attempt = 0;
_running = false;
}
@@ -926,13 +738,6 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) {
}
_last_status = snapshot;
ensureWifi();
updateTimeSync();
ensureWebServer();
if (_web_panel.isRunning() && _web_panel.shouldAutoLock(millis())) {
WEB_LOG("idle lock");
_web_panel.lockSession();
}
BrokerState* active_connecting_broker = nullptr;
for (BrokerState& broker : _brokers) {
@@ -958,7 +763,7 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) {
}
}
if (_prefs.status_enabled && hasEnabledBroker() && _have_time_sync &&
if (_prefs.status_enabled && hasEnabledBroker() && _network != nullptr && _network->hasTimeSync() &&
millis() - _last_status_publish >= _prefs.status_interval_ms) {
publishStatus(true);
_last_status_publish = millis();
@@ -966,7 +771,8 @@ void MQTTUplink::loop(const MQTTStatusSnapshot& snapshot) {
}
void MQTTUplink::publishPacket(const mesh::Packet& packet, bool is_tx, int rssi, float snr, int score, int duration) {
if (!_running || !_have_time_sync || !hasEnabledBroker() || !_prefs.packets_enabled) {
if (!_running || _network == nullptr || !_network->hasTimeSync() || !_network->isWifiConnected() || !hasEnabledBroker() ||
!_prefs.packets_enabled) {
return;
}
if (is_tx && !_prefs.tx_enabled) {
@@ -1035,7 +841,7 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const {
if (broker->connected) {
return "up";
}
if (WiFi.status() != WL_CONNECTED || !_have_time_sync) {
if (_network == nullptr || !_network->isWifiConnected() || !_network->hasTimeSync()) {
return "wait";
}
if (broker->client != nullptr) {
@@ -1048,36 +854,13 @@ void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const {
};
snprintf(reply, reply_size, "> wifi:%s ntp:%s iata:%s eastmesh-au:%s letsmesh-eu:%s letsmesh-us:%s status:%s tx:%s",
getWifiStateLabel(_prefs, _wifi_started), _have_time_sync ? "up" : "wait", _prefs.iata,
(_network != nullptr && _network->isWifiConnected()) ? "up" : "down",
(_network != nullptr && _network->hasTimeSync()) ? "up" : "wait",
_prefs.iata,
broker_state(kEastmeshBit), broker_state(kLetsmeshEuBit), broker_state(kLetsmeshUsBit),
_prefs.status_enabled ? "on" : "off", _prefs.tx_enabled ? "on" : "off");
}
void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const {
#if WITH_WEB_PANEL
if (_web_runner == nullptr) {
snprintf(reply, reply_size, "> web:off");
return;
}
if (_prefs.web_enabled == 0) {
snprintf(reply, reply_size, "> web:off");
return;
}
if (!_web_panel.isRunning() || !_wifi_started || WiFi.status() != WL_CONNECTED) {
snprintf(reply, reply_size, "> web:down");
return;
}
snprintf(reply, reply_size, "> web:up url:https://%s/ auth:%s", WiFi.localIP().toString().c_str(),
_web_panel.hasSessionToken() ? "unlocked" : "locked");
#else
(void)reply_size;
snprintf(reply, reply_size, "> web:unsupported");
#endif
}
bool MQTTUplink::setEndpointEnabled(uint8_t bit, bool enabled) {
uint8_t next_mask = _prefs.enabled_mask & 0x07;
if (enabled) {
@@ -1117,20 +900,6 @@ bool MQTTUplink::setTxEnabled(bool enabled) {
return savePrefs();
}
bool MQTTUplink::setWebEnabled(bool enabled) {
#if WITH_WEB_PANEL
_prefs.web_enabled = enabled ? 1 : 0;
bool ok = savePrefs();
if (_prefs.web_enabled != 0) {
ensureWebServer();
}
return ok;
#else
(void)enabled;
return false;
#endif
}
bool MQTTUplink::setIata(const char* iata) {
if (iata == nullptr || *iata == 0) {
return false;
@@ -1147,54 +916,6 @@ bool MQTTUplink::setIata(const char* iata) {
return savePrefs();
}
bool MQTTUplink::setWifiPowerSave(const char* mode) {
if (mode == nullptr) {
return false;
}
uint8_t next_mode;
if (strcmp(mode, "none") == 0) {
next_mode = 0;
} else if (strcmp(mode, "min") == 0) {
next_mode = 1;
} else if (strcmp(mode, "max") == 0) {
next_mode = 2;
} else {
return false;
}
_prefs.wifi_powersave = next_mode;
bool ok = savePrefs();
if (_wifi_started) {
ok = WiFi.setSleep(toEspPowerSave(_prefs.wifi_powersave)) && ok;
}
return ok;
}
const char* MQTTUplink::getWifiPowerSave() const {
return getPowerSaveLabel(_prefs.wifi_powersave);
}
bool MQTTUplink::setWifiSSID(const char* ssid) {
if (ssid == nullptr) {
return false;
}
StrHelper::strncpy(_prefs.wifi_ssid, ssid, sizeof(_prefs.wifi_ssid));
bool ok = savePrefs();
reconnectWifi();
return ok;
}
bool MQTTUplink::setWifiPassword(const char* pwd) {
if (pwd == nullptr) {
return false;
}
StrHelper::strncpy(_prefs.wifi_pwd, pwd, sizeof(_prefs.wifi_pwd));
bool ok = savePrefs();
reconnectWifi();
return ok;
}
bool MQTTUplink::setOwnerPublicKey(const char* owner_public_key) {
if (owner_public_key == nullptr) {
return false;
@@ -1227,66 +948,21 @@ bool MQTTUplink::setOwnerEmail(const char* owner_email) {
return savePrefs();
}
void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const {
const char* status = "disconnected";
const char* state = "disconnected";
wl_status_t wifi_status = WiFi.status();
if (_prefs.wifi_ssid[0] == 0) {
status = "unconfigured";
state = "unconfigured";
} else if (wifi_status == WL_CONNECTED) {
status = "connected";
state = "connected";
} else if (_wifi_started) {
status = "connecting";
}
switch (wifi_status) {
case WL_IDLE_STATUS:
state = "idle";
break;
case WL_NO_SSID_AVAIL:
state = "no_ssid";
break;
case WL_SCAN_COMPLETED:
state = "scan_completed";
break;
case WL_CONNECTED:
state = "connected";
break;
case WL_CONNECT_FAILED:
state = "connect_failed";
break;
case WL_CONNECTION_LOST:
state = "connection_lost";
break;
case WL_DISCONNECTED:
state = "disconnected";
break;
default:
state = "unknown";
break;
}
if (wifi_status == WL_CONNECTED) {
const int rssi_dbm = WiFi.RSSI();
snprintf(reply, reply_size,
"> ssid:%s status:%s code:%d state:%s ip:%s rssi:%d quality:%d%% signal:%s",
_prefs.wifi_ssid, status, static_cast<int>(wifi_status), state, WiFi.localIP().toString().c_str(),
rssi_dbm, getWifiQualityPercent(rssi_dbm), getWifiQualityLabel(rssi_dbm));
} else {
snprintf(reply, reply_size, "> ssid:%s status:%s code:%d state:%s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "-",
status, static_cast<int>(wifi_status), state);
bool MQTTUplink::isAnyBrokerConnected() const {
for (const BrokerState& broker : _brokers) {
if (broker.spec != nullptr && broker.connected) {
return true;
}
}
return false;
}
#else
MQTTUplink::MQTTUplink(mesh::RTCClock&, mesh::LocalIdentity&)
: _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _wifi_started(false), _sntp_started(false),
_have_time_sync(false), _last_wifi_attempt(0), _last_status_publish(0), _last_status{},
_node_name(nullptr),
_web_runner(nullptr) {
: _fs(nullptr), _rtc(nullptr), _identity(nullptr), _running(false), _last_status_publish(0), _last_status{},
_node_name(nullptr), _network(nullptr) {
MQTTPrefsStore::setDefaults(_prefs);
}
bool MQTTUplink::savePrefs() { return false; }
@@ -1295,25 +971,18 @@ void MQTTUplink::end() {}
void MQTTUplink::loop(const MQTTStatusSnapshot&) {}
void MQTTUplink::publishPacket(const mesh::Packet&, bool, int, float, int, int) {}
void MQTTUplink::formatStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); }
void MQTTUplink::formatWebStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); }
bool MQTTUplink::setEndpointEnabled(uint8_t, bool) { return false; }
bool MQTTUplink::isEndpointEnabled(uint8_t) const { return false; }
bool MQTTUplink::setPacketsEnabled(bool) { return false; }
bool MQTTUplink::setRawEnabled(bool) { return false; }
bool MQTTUplink::setStatusEnabled(bool) { return false; }
bool MQTTUplink::setTxEnabled(bool) { return false; }
bool MQTTUplink::setWebEnabled(bool) { return false; }
bool MQTTUplink::setIata(const char*) { return false; }
bool MQTTUplink::setWifiPowerSave(const char*) { return false; }
const char* MQTTUplink::getWifiPowerSave() const { return "unsupported"; }
bool MQTTUplink::isActive() const { return false; }
bool MQTTUplink::setWifiSSID(const char*) { return false; }
bool MQTTUplink::setWifiPassword(const char*) { return false; }
bool MQTTUplink::setOwnerPublicKey(const char*) { return false; }
bool MQTTUplink::setOwnerEmail(const char*) { return false; }
void MQTTUplink::formatWifiStatusReply(char* reply, size_t reply_size) const { snprintf(reply, reply_size, "> unsupported"); }
void MQTTUplink::reconnectWifi() {}
bool MQTTUplink::sendStatusNow() { return false; }
bool MQTTUplink::isAnyBrokerConnected() const { return false; }
#endif
+4 -31
View File
@@ -3,23 +3,16 @@
#ifdef WITH_MQTT_UPLINK
#include <Mesh.h>
#include <helpers/CommonCLI.h>
#include <helpers/web/WebPanelServer.h>
#include <helpers/NetworkStateProvider.h>
#include <target.h>
#include "JWTHelper.h"
#include "MQTTPrefs.h"
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#if !defined(WITH_WEB_PANEL)
#define WITH_WEB_PANEL 1
#endif
#include <mqtt_client.h>
#endif
using MQTTWebCommandRunner = WebPanelCommandRunner;
struct MQTTStatusSnapshot {
int battery_mv;
uint32_t uptime_secs;
@@ -49,7 +42,6 @@ public:
bool isActive() const;
void formatStatusReply(char* reply, size_t reply_size) const;
void formatWebStatusReply(char* reply, size_t reply_size) const;
bool setEndpointEnabled(uint8_t bit, bool enabled);
bool isEndpointEnabled(uint8_t bit) const;
@@ -61,24 +53,16 @@ public:
bool isStatusEnabled() const { return _prefs.status_enabled != 0; }
bool setTxEnabled(bool enabled);
bool isTxEnabled() const { return _prefs.tx_enabled != 0; }
bool setWebEnabled(bool enabled);
bool isWebEnabled() const { return _prefs.web_enabled != 0; }
bool setIata(const char* iata);
const char* getIata() const { return _prefs.iata; }
void setNodeNameSource(const char* node_name) { _node_name = node_name; }
void setWebCommandRunner(MQTTWebCommandRunner* runner);
bool setWifiSSID(const char* ssid);
bool setWifiPassword(const char* pwd);
const char* getWifiSSID() const { return _prefs.wifi_ssid; }
bool setWifiPowerSave(const char* mode);
const char* getWifiPowerSave() const;
bool setOwnerPublicKey(const char* owner_public_key);
const char* getOwnerPublicKey() const { return _prefs.owner_public_key; }
bool setOwnerEmail(const char* owner_email);
const char* getOwnerEmail() const { return _prefs.owner_email; }
void formatWifiStatusReply(char* reply, size_t reply_size) const;
void reconnectWifi();
bool sendStatusNow();
bool isAnyBrokerConnected() const;
void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; }
private:
#if defined(ESP_PLATFORM)
@@ -114,16 +98,11 @@ private:
mesh::LocalIdentity* _identity;
MQTTPrefs _prefs;
bool _running;
bool _wifi_started;
bool _sntp_started;
bool _have_time_sync;
unsigned long _last_wifi_attempt;
unsigned long _last_status_publish;
MQTTStatusSnapshot _last_status;
char _device_id[65];
const char* _node_name;
MQTTWebCommandRunner* _web_runner;
WebPanelServer _web_panel;
NetworkStateProvider* _network;
#if defined(ESP_PLATFORM)
static constexpr uint8_t kEastmeshBit = 0x01;
@@ -135,10 +114,6 @@ private:
BrokerState _brokers[3];
static void handleMqttEvent(void* handler_args, esp_event_base_t base, int32_t event_id, void* event_data);
void ensureWebServer();
void stopWebServer();
void ensureWifi();
void updateTimeSync();
bool hasEnabledBroker() const;
static uint8_t normalizeEnabledMask(uint8_t mask);
void formatTopic(char* dst, size_t dst_size, const char* leaf) const;
@@ -155,8 +130,6 @@ private:
int buildPacketJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, float snr,
int score, int duration) const;
int buildRawJson(char* buffer, size_t buffer_size, const mesh::Packet& packet, bool is_tx, int rssi, float snr) const;
static wifi_ps_type_t toEspPowerSave(uint8_t mode);
static const char* getPowerSaveLabel(uint8_t mode);
static void escapeJsonString(const char* input, char* output, size_t output_size);
static void makeSafeToken(const char* input, char* output, size_t output_size);
static void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size);
File diff suppressed because it is too large Load Diff
+15
View File
@@ -17,6 +17,20 @@ public:
virtual ~WebPanelCommandRunner() = default;
virtual void runWebCommand(const char* command, char* reply, size_t reply_size) = 0;
virtual const char* getWebAdminPassword() const = 0;
virtual bool isWebStatsEnabled() const { return false; }
virtual bool formatWebStatsSummaryJson(char* reply, size_t reply_size) {
if (reply != nullptr && reply_size > 0) {
reply[0] = 0;
}
return false;
}
virtual bool formatWebStatsSeriesJson(const char* series, char* reply, size_t reply_size) {
(void)series;
if (reply != nullptr && reply_size > 0) {
reply[0] = 0;
}
return false;
}
};
class WebPanelServer {
@@ -45,6 +59,7 @@ private:
static esp_err_t handleIndex(httpd_req_t* req);
static esp_err_t handleApp(httpd_req_t* req);
static esp_err_t handleStatsPage(httpd_req_t* req);
static esp_err_t handleLogin(httpd_req_t* req);
static esp_err_t handleCommand(httpd_req_t* req);
static esp_err_t handleStats(httpd_req_t* req);
+68
View File
@@ -0,0 +1,68 @@
#include "WebPrefs.h"
#include <string.h>
void WebPrefsStore::setDefaults(WebPrefs& prefs) {
memset(&prefs, 0, sizeof(prefs));
prefs.magic = kMagic;
prefs.web_enabled = 1;
prefs.web_stats_enabled = 1;
}
bool WebPrefsStore::load(FILESYSTEM* fs, WebPrefs& prefs) {
setDefaults(prefs);
if (fs == nullptr) {
return false;
}
if (!fs->exists(kFilename)) {
save(fs, prefs);
return false;
}
#if defined(RP2040_PLATFORM)
File file = fs->open(kFilename, "r");
#else
File file = fs->open(kFilename);
#endif
if (!file) {
return false;
}
WebPrefs persisted{};
size_t bytes_to_read = min(static_cast<size_t>(file.size()), sizeof(persisted));
bool ok = bytes_to_read >= sizeof(persisted.magic) &&
file.read(reinterpret_cast<uint8_t*>(&persisted), bytes_to_read) == bytes_to_read;
file.close();
if (!ok || persisted.magic != kMagic) {
fs->remove(kFilename);
save(fs, prefs);
return false;
}
prefs = persisted;
prefs.web_enabled = prefs.web_enabled ? 1 : 0;
prefs.web_stats_enabled = prefs.web_stats_enabled ? 1 : 0;
return true;
}
bool WebPrefsStore::save(FILESYSTEM* fs, const WebPrefs& prefs) {
if (fs == nullptr) {
return false;
}
if (fs->exists(kFilename) && !fs->remove(kFilename)) {
return false;
}
#if defined(RP2040_PLATFORM)
File file = fs->open(kFilename, "w");
#else
File file = fs->open(kFilename, "w", true);
#endif
if (!file) {
return false;
}
bool ok = file.write(reinterpret_cast<const uint8_t*>(&prefs), sizeof(prefs)) == sizeof(prefs);
file.close();
return ok;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <helpers/IdentityStore.h>
#include <stdint.h>
struct WebPrefs {
uint32_t magic;
uint8_t web_enabled;
uint8_t web_stats_enabled;
uint8_t reserved[2];
};
class WebPrefsStore {
public:
static void setDefaults(WebPrefs& prefs);
static bool load(FILESYSTEM* fs, WebPrefs& prefs);
static bool save(FILESYSTEM* fs, const WebPrefs& prefs);
private:
static constexpr uint32_t kMagic = 0x57454250;
static constexpr const char* kFilename = "/web_prefs";
};
+85
View File
@@ -0,0 +1,85 @@
#include "WebService.h"
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
#include <WiFi.h>
#endif
WebService::WebService() : _fs(nullptr), _prefs{}, _runner(nullptr), _network(nullptr) {
WebPrefsStore::setDefaults(_prefs);
}
void WebService::begin(FILESYSTEM* fs) {
_fs = fs;
WebPrefsStore::load(_fs, _prefs);
}
void WebService::end() {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
_panel.stop();
#endif
}
void WebService::loop() {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
ensureWebServer();
if (_panel.isRunning() && _panel.shouldAutoLock(millis())) {
_panel.lockSession();
}
#endif
}
void WebService::setCommandRunner(WebPanelCommandRunner* runner) {
_runner = runner;
_panel.setCommandRunner(runner);
}
bool WebService::savePrefs() {
return WebPrefsStore::save(_fs, _prefs);
}
bool WebService::setWebEnabled(bool enabled) {
_prefs.web_enabled = enabled ? 1 : 0;
bool ok = savePrefs();
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
if (_prefs.web_enabled != 0) {
ensureWebServer();
} else {
_panel.stop();
}
#endif
return ok;
}
bool WebService::setWebStatsEnabled(bool enabled) {
_prefs.web_stats_enabled = enabled ? 1 : 0;
return savePrefs();
}
void WebService::formatWebStatusReply(char* reply, size_t reply_size) const {
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
if (_runner == nullptr || _prefs.web_enabled == 0) {
snprintf(reply, reply_size, "> web:off");
return;
}
if (!_panel.isRunning() || _network == nullptr || !_network->isWifiConnected()) {
snprintf(reply, reply_size, "> web:down");
return;
}
snprintf(reply, reply_size, "> web:up url:https://%s/ auth:%s", WiFi.localIP().toString().c_str(),
_panel.hasSessionToken() ? "unlocked" : "locked");
#else
snprintf(reply, reply_size, "> web:unsupported");
#endif
}
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
void WebService::ensureWebServer() {
if (_runner == nullptr || _prefs.web_enabled == 0 || _network == nullptr || !_network->isWifiConnected()) {
_panel.stop();
return;
}
_panel.start();
}
#endif
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
#include <helpers/IdentityStore.h>
#include <helpers/NetworkStateProvider.h>
#include "WebPanelServer.h"
#include "WebPrefs.h"
class WebService {
public:
WebService();
void begin(FILESYSTEM* fs);
void end();
void loop();
void setCommandRunner(WebPanelCommandRunner* runner);
void setNetworkStateProvider(NetworkStateProvider* network) { _network = network; }
bool setWebEnabled(bool enabled);
bool isWebEnabled() const { return _prefs.web_enabled != 0; }
bool setWebStatsEnabled(bool enabled);
bool isWebStatsEnabled() const { return _prefs.web_stats_enabled != 0; }
void formatWebStatusReply(char* reply, size_t reply_size) const;
bool isPanelRunning() const { return _panel.isRunning(); }
bool isPanelUnlocked() const { return _panel.hasSessionToken(); }
private:
#if defined(ESP_PLATFORM) && WITH_WEB_PANEL
void ensureWebServer();
#endif
bool savePrefs();
FILESYSTEM* _fs;
WebPrefs _prefs;
WebPanelCommandRunner* _runner;
NetworkStateProvider* _network;
WebPanelServer _panel;
};