knxdisplay/main/HistoryStore.cpp
2026-01-30 11:15:56 +01:00

639 lines
19 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() {
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<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;
}
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<int32_t>(nowSec);
series->hasLatest = true;
xSemaphoreGive(mutex_);
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;
}
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<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);
}
xSemaphoreGive(mutex_);
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() {
xSemaphoreTake(mutex_, portMAX_DELAY);
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;
}
xSemaphoreGive(mutex_);
// REMOVED synchronous save from here to avoid blocking UI task
return added;
}
void HistoryStore::performAutoSave() {
xSemaphoreTake(mutex_, portMAX_DELAY);
bool shouldSave = false;
int64_t monoUs = esp_timer_get_time();
if (dirty_ && timeSynced_ && SdCard::instance().isMounted()) {
if (monoUs - lastSaveMonoUs_ >= HISTORY_SAVE_INTERVAL_US) {
shouldSave = true;
lastSaveMonoUs_ = monoUs;
}
}
xSemaphoreGive(mutex_);
if (shouldSave) {
saveToSdCard();
}
}
void HistoryStore::updateTimeOfDay(const struct tm& value) {
xSemaphoreTake(mutex_, portMAX_DELAY);
hour_ = value.tm_hour;
minute_ = value.tm_min;
second_ = value.tm_sec;
hasTime_ = true;
// applyTimeSync calls internal logic, assume called inside lock or extracted
// But applyTimeSync calls clearAll which touches series.
// So we must be careful about locking.
// Let's unlock before applyTimeSync and re-lock inside?
// No, let's just make applyTimeSync assume lock or lock itself?
// applyTimeSync modifies member vars.
// Refactoring for simplicity: lock around the whole block
// BUT applyTimeSync calls settimeofday which is system call (thread safe?)
// We will inline the logic briefly or handle it carefully.
// For now, assume lock held is OK for short duration.
// We need to implement applyTimeSync correctly with locking.
xSemaphoreGive(mutex_);
applyTimeSync();
}
void HistoryStore::updateDate(const struct tm& value) {
xSemaphoreTake(mutex_, portMAX_DELAY);
year_ = value.tm_year;
month_ = value.tm_mon;
day_ = value.tm_mday;
hasDate_ = true;
xSemaphoreGive(mutex_);
applyTimeSync();
}
void HistoryStore::updateDateTime(const struct tm& value) {
xSemaphoreTake(mutex_, portMAX_DELAY);
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;
xSemaphoreGive(mutex_);
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() {
xSemaphoreTake(mutex_, portMAX_DELAY);
if (!hasDate_ || !hasTime_) {
xSemaphoreGive(mutex_);
return false;
}
int64_t epoch = buildEpoch();
if (epoch <= 0) {
ESP_LOGW(TAG, "Invalid KNX time/date for sync");
xSemaphoreGive(mutex_);
return false;
}
bool wasSynced = timeSynced_;
timeSynced_ = true;
baseEpoch_ = epoch;
baseMono_ = esp_timer_get_time() / 1000000LL;
// Release lock for system call (optional but safer)
xSemaphoreGive(mutex_);
struct timeval tv = {};
tv.tv_sec = static_cast<time_t>(epoch);
settimeofday(&tv, nullptr);
xSemaphoreTake(mutex_, portMAX_DELAY);
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;
xSemaphoreGive(mutex_);
ESP_LOGI(TAG, "Time synced: %ld", static_cast<long>(epoch));
return !wasSynced;
}
void HistoryStore::clearAll() {
// Assumes lock is held by caller
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;
xSemaphoreTake(mutex_, portMAX_DELAY);
if (!timeSynced_) {
xSemaphoreGive(mutex_);
return;
}
xSemaphoreGive(mutex_); // Release to open file
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;
}
// Allocate temp buffer for one series to minimize lock time
// Size: Header + Fine + Coarse
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;
}
// Copy to temp buffer
HistoryFileSeriesHeader* sh = (HistoryFileSeriesHeader*)tempBuf;
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;
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_);
// Write to file (unlocked)
if (fwrite(tempBuf, 1, seriesDataSize, f) != seriesDataSize) {
ESP_LOGW(TAG, "Failed to write series data");
break;
}
}
free(tempBuf);
xSemaphoreTake(mutex_, portMAX_DELAY);
dirty_ = false;
dataEpoch_ = true;
xSemaphoreGive(mutex_);
fclose(f);
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;
}
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<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);
}
}
dirty_ = false;
dataEpoch_ = true;
xSemaphoreGive(mutex_);
fclose(f);
ESP_LOGI(TAG, "History loaded");
}