knxdisplay/main/HistoryStore.cpp
2026-01-30 08:51:59 +01:00

543 lines
16 KiB
C++

#include "HistoryStore.hpp"
#include "SdCard.hpp"
#include "esp_log.h"
#include "esp_timer.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <sys/time.h>
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<SeriesKey, HISTORY_MAX_SERIES> 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<bool, HISTORY_MAX_SERIES> 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<size_t>(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<int32_t>(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<float, CHART_POINT_COUNT> sums = {};
std::array<uint16_t, CHART_POINT_COUNT> counts = {};
auto accumulate = [&](const HistoryPoint& p) {
if (p.ts < start || p.ts > nowSec) return;
size_t bucket = static_cast<size_t>(((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<int32_t>(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<int32_t>(nowSec), series.latestValue});
series.lastFineSampleTs = static_cast<int32_t>(nowSec);
added = true;
}
if (series.coarse.count == 0 || nowSec - series.lastCoarseSampleTs >= HISTORY_COARSE_INTERVAL) {
series.coarse.push({static_cast<int32_t>(nowSec), series.latestValue});
series.lastCoarseSampleTs = static_cast<int32_t>(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<int64_t>(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<time_t>(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<long>(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<uint8_t>(series.key.source);
sh.fineCount = static_cast<uint16_t>(series.fine.count);
sh.fineHead = static_cast<uint16_t>(series.fine.head);
sh.coarseCount = static_cast<uint16_t>(series.coarse.count);
sh.coarseHead = static_cast<uint16_t>(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<int>(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;
}
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<TextSource>(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");
}