#include "HistoryStore.hpp" #include "SdCard.hpp" #include "esp_log.h" #include "esp_timer.h" #include #include #include #include static const char* TAG = "HistoryStore"; static constexpr uint32_t HISTORY_MAGIC = 0x4B584831; // KXH1 static constexpr uint16_t HISTORY_VERSION = 1; static constexpr const char* HISTORY_FILE = "/sdcard/knx_history.bin"; static constexpr int64_t HISTORY_SAVE_INTERVAL_US = 300000000; // 5 minutes #pragma pack(push, 1) struct HistoryFileHeader { uint32_t magic; uint16_t version; uint16_t seriesCount; uint16_t fineCapacity; uint16_t coarseCapacity; uint32_t fineInterval; uint32_t coarseInterval; }; struct HistoryFileSeriesHeader { uint16_t groupAddr; uint8_t source; uint8_t reserved; uint16_t fineCount; uint16_t fineHead; uint16_t coarseCount; uint16_t coarseHead; uint8_t hasLatest; uint8_t reserved2[3]; int32_t latestTs; float latestValue; }; #pragma pack(pop) HistoryStore& HistoryStore::instance() { static HistoryStore inst; return inst; } HistoryStore::HistoryStore() { mutex_ = xSemaphoreCreateMutex(); } HistoryStore::~HistoryStore() { if (mutex_) { vSemaphoreDelete(mutex_); } } bool HistoryStore::isNumericSource(TextSource source) { return source == TextSource::KNX_DPT_TEMP || source == TextSource::KNX_DPT_PERCENT || source == TextSource::KNX_DPT_POWER || source == TextSource::KNX_DPT_ENERGY || source == TextSource::KNX_DPT_DECIMALFACTOR; } bool HistoryStore::keysEqual(const SeriesKey& a, const SeriesKey& b) { return a.addr == b.addr && a.source == b.source; } HistoryStore::HistorySeries* HistoryStore::findSeries(uint16_t groupAddr, TextSource source) { SeriesKey key{groupAddr, source}; for (size_t i = 0; i < series_.size(); ++i) { if (series_[i].active && keysEqual(series_[i].key, key)) { return &series_[i]; } } return nullptr; } const HistoryStore::HistorySeries* HistoryStore::findSeries(uint16_t groupAddr, TextSource source) const { SeriesKey key{groupAddr, source}; for (size_t i = 0; i < series_.size(); ++i) { if (series_[i].active && keysEqual(series_[i].key, key)) { return &series_[i]; } } return nullptr; } HistoryStore::HistorySeries* HistoryStore::findSeriesByKey(const SeriesKey& key) { for (size_t i = 0; i < series_.size(); ++i) { if (keysEqual(series_[i].key, key)) { return &series_[i]; } } return nullptr; } const HistoryStore::HistorySeries* HistoryStore::findSeriesByKey(const SeriesKey& key) const { for (size_t i = 0; i < series_.size(); ++i) { if (keysEqual(series_[i].key, key)) { return &series_[i]; } } return nullptr; } void HistoryStore::configureFromConfig(const GuiConfig& config) { xSemaphoreTake(mutex_, portMAX_DELAY); std::array needed = {}; size_t neededCount = 0; auto addSeries = [&](uint16_t addr, TextSource source) { if (addr == 0 || !isNumericSource(source)) return; SeriesKey key{addr, source}; for (size_t i = 0; i < neededCount; ++i) { if (keysEqual(needed[i], key)) return; } if (neededCount >= HISTORY_MAX_SERIES) { ESP_LOGW(TAG, "History series limit reached"); return; } needed[neededCount++] = key; }; for (size_t s = 0; s < config.screenCount; ++s) { const ScreenConfig& screen = config.screens[s]; for (size_t i = 0; i < screen.widgetCount; ++i) { const WidgetConfig& w = screen.widgets[i]; if (w.type != WidgetType::CHART) continue; uint8_t count = w.chartSeriesCount; if (count > CHART_MAX_SERIES) count = CHART_MAX_SERIES; for (uint8_t si = 0; si < count; ++si) { addSeries(w.chartKnxAddress[si], w.chartTextSource[si]); } } } std::array keep = {}; bool changed = false; size_t activeCount = 0; for (size_t i = 0; i < neededCount; ++i) { const SeriesKey& key = needed[i]; HistorySeries* existing = findSeriesByKey(key); if (existing) { size_t idx = static_cast(existing - series_.data()); if (!existing->active) { changed = true; } keep[idx] = true; continue; } HistorySeries* slot = nullptr; for (size_t si = 0; si < series_.size(); ++si) { if (!keep[si] && !series_[si].active && series_[si].key.addr == 0) { slot = &series_[si]; keep[si] = true; break; } } if (!slot) { for (size_t si = 0; si < series_.size(); ++si) { if (!keep[si] && !series_[si].active) { slot = &series_[si]; keep[si] = true; break; } } } if (!slot) { ESP_LOGW(TAG, "History series limit reached"); continue; } slot->key = key; slot->fine.clear(); slot->coarse.clear(); slot->hasLatest = false; slot->latestValue = 0.0f; slot->latestTs = 0; slot->lastFineSampleTs = 0; slot->lastCoarseSampleTs = 0; changed = true; } for (size_t i = 0; i < series_.size(); ++i) { bool nextActive = keep[i]; if (series_[i].active != nextActive) { changed = true; } series_[i].active = nextActive; if (nextActive) { activeCount++; } } seriesCount_ = activeCount; if (changed) { dirty_ = true; } xSemaphoreGive(mutex_); } bool HistoryStore::isTracked(uint16_t groupAddr, TextSource source) const { xSemaphoreTake(mutex_, portMAX_DELAY); bool tracked = findSeries(groupAddr, source) != nullptr; xSemaphoreGive(mutex_); return tracked; } bool HistoryStore::updateLatest(uint16_t groupAddr, TextSource source, float value) { xSemaphoreTake(mutex_, portMAX_DELAY); HistorySeries* series = findSeries(groupAddr, source); if (!series) { xSemaphoreGive(mutex_); return false; } int64_t nowSec = now(); series->latestValue = value; series->latestTs = static_cast(nowSec); series->hasLatest = true; xSemaphoreGive(mutex_); return true; } int64_t HistoryStore::now() const { return (int64_t)time(nullptr); } int32_t HistoryStore::periodSeconds(ChartPeriod period) const { switch (period) { case ChartPeriod::HOUR_1: return 3600; case ChartPeriod::HOUR_3: return 3 * 3600; case ChartPeriod::HOUR_5: return 5 * 3600; case ChartPeriod::HOUR_12: return 12 * 3600; case ChartPeriod::HOUR_24: return 24 * 3600; case ChartPeriod::MONTH_1: return HISTORY_MONTH_SECONDS; default: return 3600; } } bool HistoryStore::fillChartSeries(uint16_t groupAddr, TextSource source, ChartPeriod period, int32_t* outValues, size_t outCount) const { if (!outValues || outCount == 0) return false; if (outCount > CHART_POINT_COUNT) outCount = CHART_POINT_COUNT; for (size_t i = 0; i < outCount; ++i) { outValues[i] = NO_POINT; } xSemaphoreTake(mutex_, portMAX_DELAY); const HistorySeries* series = findSeries(groupAddr, source); if (!series) { xSemaphoreGive(mutex_); return false; } int64_t nowSec = now(); int32_t window = periodSeconds(period); if (window <= 0) { xSemaphoreGive(mutex_); return false; } int64_t start = nowSec - window; std::array sums = {}; std::array counts = {}; auto accumulate = [&](const HistoryPoint& p) { if (p.ts < start || p.ts > nowSec) return; size_t bucket = static_cast(((p.ts - start) * outCount) / window); if (bucket >= outCount) bucket = outCount - 1; sums[bucket] += p.value; counts[bucket]++; }; if (period == ChartPeriod::MONTH_1) { series->coarse.forEach(accumulate); } else { series->fine.forEach(accumulate); } if (series->hasLatest) { HistoryPoint latest{series->latestTs, series->latestValue}; accumulate(latest); } xSemaphoreGive(mutex_); bool hasData = false; int32_t lastValidValue = NO_POINT; for (size_t i = 0; i < outCount; ++i) { if (counts[i] > 0) { float avg = sums[i] / counts[i]; int32_t val = static_cast(lrintf(avg)); outValues[i] = val; lastValidValue = val; hasData = true; } else { outValues[i] = lastValidValue; if (lastValidValue != NO_POINT) { hasData = true; } } } return hasData; } bool HistoryStore::tick() { xSemaphoreTake(mutex_, portMAX_DELAY); int64_t nowSec = now(); // Only collect data if time is roughly synced (after 2020) if (nowSec < 1577836800LL) { xSemaphoreGive(mutex_); return false; } bool added = false; for (size_t i = 0; i < series_.size(); ++i) { HistorySeries& series = series_[i]; if (!series.active || !series.hasLatest) continue; if (series.fine.count == 0 || nowSec - series.lastFineSampleTs >= HISTORY_FINE_INTERVAL) { series.fine.push({static_cast(nowSec), series.latestValue}); series.lastFineSampleTs = static_cast(nowSec); added = true; } if (series.coarse.count == 0 || nowSec - series.lastCoarseSampleTs >= HISTORY_COARSE_INTERVAL) { series.coarse.push({static_cast(nowSec), series.latestValue}); series.lastCoarseSampleTs = static_cast(nowSec); added = true; } } if (added) { dirty_ = true; } xSemaphoreGive(mutex_); return added; } void HistoryStore::performAutoSave() { xSemaphoreTake(mutex_, portMAX_DELAY); bool shouldSave = false; int64_t nowSec = now(); int64_t monoUs = esp_timer_get_time(); if (dirty_ && nowSec > 1577836800LL && SdCard::instance().isMounted()) { if (monoUs - lastSaveMonoUs_ >= HISTORY_SAVE_INTERVAL_US) { shouldSave = true; lastSaveMonoUs_ = monoUs; } } xSemaphoreGive(mutex_); if (shouldSave) { saveToSdCard(); } } void HistoryStore::clearAll() { xSemaphoreTake(mutex_, portMAX_DELAY); for (size_t i = 0; i < series_.size(); ++i) { HistorySeries& series = series_[i]; if (!series.active) continue; series.fine.clear(); series.coarse.clear(); series.hasLatest = false; series.latestValue = 0.0f; series.latestTs = 0; series.lastFineSampleTs = 0; series.lastCoarseSampleTs = 0; } xSemaphoreGive(mutex_); } void HistoryStore::saveToSdCard() { if (!SdCard::instance().isMounted()) return; // Double check time sync before save if (now() < 1577836800LL) return; FILE* f = fopen(HISTORY_FILE, "wb"); if (!f) { ESP_LOGW(TAG, "Failed to open history file for writing"); return; } xSemaphoreTake(mutex_, portMAX_DELAY); HistoryFileHeader header = {}; header.magic = HISTORY_MAGIC; header.version = HISTORY_VERSION; uint16_t activeCount = 0; for (const auto& series : series_) { if (series.active) { activeCount++; } } header.seriesCount = activeCount; header.fineCapacity = HISTORY_FINE_CAP; header.coarseCapacity = HISTORY_COARSE_CAP; header.fineInterval = HISTORY_FINE_INTERVAL; header.coarseInterval = HISTORY_COARSE_INTERVAL; xSemaphoreGive(mutex_); if (fwrite(&header, sizeof(header), 1, f) != 1) { fclose(f); ESP_LOGW(TAG, "Failed to write history header"); return; } size_t seriesDataSize = sizeof(HistoryFileSeriesHeader) + sizeof(HistoryPoint) * (HISTORY_FINE_CAP + HISTORY_COARSE_CAP); uint8_t* tempBuf = (uint8_t*)malloc(seriesDataSize); if (!tempBuf) { fclose(f); ESP_LOGE(TAG, "Failed to allocate temp buffer for save"); return; } for (size_t i = 0; i < series_.size(); ++i) { xSemaphoreTake(mutex_, portMAX_DELAY); const HistorySeries& series = series_[i]; if (!series.active) { xSemaphoreGive(mutex_); continue; } HistoryFileSeriesHeader* sh = (HistoryFileSeriesHeader*)tempBuf; sh->groupAddr = series.key.addr; sh->source = static_cast(series.key.source); sh->fineCount = static_cast(series.fine.count); sh->fineHead = static_cast(series.fine.head); sh->coarseCount = static_cast(series.coarse.count); sh->coarseHead = static_cast(series.coarse.head); sh->hasLatest = series.hasLatest ? 1 : 0; sh->latestTs = series.latestTs; sh->latestValue = series.latestValue; uint8_t* dataPtr = tempBuf + sizeof(HistoryFileSeriesHeader); memcpy(dataPtr, series.fine.points.data(), sizeof(HistoryPoint) * HISTORY_FINE_CAP); dataPtr += sizeof(HistoryPoint) * HISTORY_FINE_CAP; memcpy(dataPtr, series.coarse.points.data(), sizeof(HistoryPoint) * HISTORY_COARSE_CAP); xSemaphoreGive(mutex_); if (fwrite(tempBuf, 1, seriesDataSize, f) != seriesDataSize) { ESP_LOGW(TAG, "Failed to write series data"); break; } } free(tempBuf); xSemaphoreTake(mutex_, portMAX_DELAY); dirty_ = false; xSemaphoreGive(mutex_); fclose(f); ESP_LOGI(TAG, "History saved (%d series)", static_cast(activeCount)); } void HistoryStore::loadFromSdCard() { if (!SdCard::instance().isMounted()) return; FILE* f = fopen(HISTORY_FILE, "rb"); if (!f) return; HistoryFileHeader header = {}; if (fread(&header, sizeof(header), 1, f) != 1) { fclose(f); return; } if (header.magic != HISTORY_MAGIC || header.version != HISTORY_VERSION) { fclose(f); ESP_LOGW(TAG, "History header mismatch"); return; } if (header.fineCapacity != HISTORY_FINE_CAP || header.coarseCapacity != HISTORY_COARSE_CAP || header.fineInterval != HISTORY_FINE_INTERVAL || header.coarseInterval != HISTORY_COARSE_INTERVAL) { fclose(f); ESP_LOGW(TAG, "History config mismatch"); return; } xSemaphoreTake(mutex_, portMAX_DELAY); for (uint16_t i = 0; i < header.seriesCount; ++i) { HistoryFileSeriesHeader sh = {}; if (fread(&sh, sizeof(sh), 1, f) != 1) { break; } HistorySeries* series = findSeries(sh.groupAddr, static_cast(sh.source)); if (series) { series->fine.count = sh.fineCount > HISTORY_FINE_CAP ? HISTORY_FINE_CAP : sh.fineCount; series->fine.head = sh.fineHead >= HISTORY_FINE_CAP ? 0 : sh.fineHead; series->coarse.count = sh.coarseCount > HISTORY_COARSE_CAP ? HISTORY_COARSE_CAP : sh.coarseCount; series->coarse.head = sh.coarseHead >= HISTORY_COARSE_CAP ? 0 : sh.coarseHead; series->hasLatest = sh.hasLatest != 0; series->latestTs = sh.latestTs; series->latestValue = sh.latestValue; if (fread(series->fine.points.data(), sizeof(HistoryPoint), HISTORY_FINE_CAP, f) != HISTORY_FINE_CAP) { break; } if (fread(series->coarse.points.data(), sizeof(HistoryPoint), HISTORY_COARSE_CAP, f) != HISTORY_COARSE_CAP) { break; } } else { fseek(f, sizeof(HistoryPoint) * (HISTORY_FINE_CAP + HISTORY_COARSE_CAP), SEEK_CUR); } } dirty_ = false; xSemaphoreGive(mutex_); fclose(f); ESP_LOGI(TAG, "History loaded"); }