544 lines
16 KiB
C++
544 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() {
|
|
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<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");
|
|
}
|