#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() = default; 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) { 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; } } bool HistoryStore::isTracked(uint16_t groupAddr, TextSource source) const { return findSeries(groupAddr, source) != nullptr; } bool HistoryStore::updateLatest(uint16_t groupAddr, TextSource source, float value) { HistorySeries* series = findSeries(groupAddr, source); if (!series) return false; int64_t nowSec = now(); series->latestValue = value; series->latestTs = static_cast(nowSec); series->hasLatest = true; return true; } int64_t HistoryStore::now() const { int64_t monoSec = esp_timer_get_time() / 1000000LL; if (!timeSynced_) return monoSec; return baseEpoch_ + (monoSec - baseMono_); } 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; } const HistorySeries* series = findSeries(groupAddr, source); if (!series) return false; int64_t nowSec = now(); int32_t window = periodSeconds(period); if (window <= 0) 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); } bool hasData = false; for (size_t i = 0; i < outCount; ++i) { if (counts[i] > 0) { float avg = sums[i] / counts[i]; outValues[i] = static_cast(lrintf(avg)); hasData = true; } } return hasData; } bool HistoryStore::tick() { int64_t nowSec = now(); 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; if (timeSynced_) dataEpoch_ = true; } int64_t monoUs = esp_timer_get_time(); if (dirty_ && timeSynced_ && SdCard::instance().isMounted()) { if (monoUs - lastSaveMonoUs_ >= HISTORY_SAVE_INTERVAL_US) { saveToSdCard(); lastSaveMonoUs_ = monoUs; } } return added; } void HistoryStore::updateTimeOfDay(const struct tm& value) { hour_ = value.tm_hour; minute_ = value.tm_min; second_ = value.tm_sec; hasTime_ = true; applyTimeSync(); } void HistoryStore::updateDate(const struct tm& value) { year_ = value.tm_year; month_ = value.tm_mon; day_ = value.tm_mday; hasDate_ = true; applyTimeSync(); } void HistoryStore::updateDateTime(const struct tm& value) { year_ = value.tm_year; month_ = value.tm_mon; day_ = value.tm_mday; hour_ = value.tm_hour; minute_ = value.tm_min; second_ = value.tm_sec; hasDate_ = true; hasTime_ = true; applyTimeSync(); } int64_t HistoryStore::buildEpoch() const { if (year_ < 1990 || month_ < 1 || month_ > 12 || day_ < 1 || day_ > 31) return -1; struct tm combined = {}; combined.tm_year = year_ - 1900; combined.tm_mon = month_ - 1; combined.tm_mday = day_; combined.tm_hour = hour_; combined.tm_min = minute_; combined.tm_sec = second_; combined.tm_isdst = -1; time_t epoch = mktime(&combined); if (epoch < 0) return -1; return static_cast(epoch); } bool HistoryStore::applyTimeSync() { if (!hasDate_ || !hasTime_) return false; int64_t epoch = buildEpoch(); if (epoch <= 0) { ESP_LOGW(TAG, "Invalid KNX time/date for sync"); return false; } bool wasSynced = timeSynced_; timeSynced_ = true; baseEpoch_ = epoch; baseMono_ = esp_timer_get_time() / 1000000LL; struct timeval tv = {}; tv.tv_sec = static_cast(epoch); settimeofday(&tv, nullptr); if (!wasSynced) { bool hasData = false; for (size_t i = 0; i < series_.size(); ++i) { const HistorySeries& series = series_[i]; if (!series.active) continue; if (series.fine.count > 0 || series.coarse.count > 0 || series.hasLatest) { hasData = true; break; } } if (!dataEpoch_ && hasData) { clearAll(); } dataEpoch_ = true; } dirty_ = true; ESP_LOGI(TAG, "Time synced: %ld", static_cast(epoch)); return !wasSynced; } void HistoryStore::clearAll() { 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; } } void HistoryStore::saveToSdCard() { if (!SdCard::instance().isMounted()) return; if (!timeSynced_) return; FILE* f = fopen(HISTORY_FILE, "wb"); if (!f) { ESP_LOGW(TAG, "Failed to open history file for writing"); return; } 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; if (fwrite(&header, sizeof(header), 1, f) != 1) { fclose(f); ESP_LOGW(TAG, "Failed to write history header"); return; } for (size_t i = 0; i < series_.size(); ++i) { const HistorySeries& series = series_[i]; if (!series.active) continue; HistoryFileSeriesHeader sh = {}; 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; if (fwrite(&sh, sizeof(sh), 1, f) != 1) { fclose(f); ESP_LOGW(TAG, "Failed to write history series header"); return; } if (fwrite(series.fine.points.data(), sizeof(HistoryPoint), HISTORY_FINE_CAP, f) != HISTORY_FINE_CAP) { fclose(f); ESP_LOGW(TAG, "Failed to write fine history data"); return; } if (fwrite(series.coarse.points.data(), sizeof(HistoryPoint), HISTORY_COARSE_CAP, f) != HISTORY_COARSE_CAP) { fclose(f); ESP_LOGW(TAG, "Failed to write coarse history data"); return; } } fclose(f); dirty_ = false; dataEpoch_ = true; ESP_LOGI(TAG, "History saved (%d series)", static_cast(activeCount)); } void HistoryStore::loadFromSdCard() { return; 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; } 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); } } fclose(f); dirty_ = false; dataEpoch_ = true; ESP_LOGI(TAG, "History loaded"); }