Fixes
This commit is contained in:
parent
d24507263f
commit
631d1eb250
@ -1,4 +1,4 @@
|
||||
idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "Touch.cpp" "Gui.cpp" "Wifi.cpp" "LvglIdle.c" "Gui/WifiSetting.cpp" "Gui/EthSetting.cpp" "Hardware/Eth.cpp" "WidgetManager.cpp" "WidgetConfig.cpp" "SdCard.cpp" "Fonts.cpp"
|
||||
idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "Touch.cpp" "Gui.cpp" "Wifi.cpp" "LvglIdle.c" "Gui/WifiSetting.cpp" "Gui/EthSetting.cpp" "Hardware/Eth.cpp" "WidgetManager.cpp" "WidgetConfig.cpp" "SdCard.cpp" "Fonts.cpp"
|
||||
"widgets/Widget.cpp"
|
||||
"widgets/LabelWidget.cpp"
|
||||
"widgets/ButtonWidget.cpp"
|
||||
@ -10,6 +10,7 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "
|
||||
"widgets/PowerFlowWidget.cpp"
|
||||
"widgets/PowerNodeWidget.cpp"
|
||||
"widgets/PowerLinkWidget.cpp"
|
||||
"widgets/ChartWidget.cpp"
|
||||
"webserver/WebServer.cpp"
|
||||
"webserver/StaticFileHandlers.cpp"
|
||||
"webserver/ConfigHandlers.cpp"
|
||||
|
||||
543
main/HistoryStore.cpp
Normal file
543
main/HistoryStore.cpp
Normal file
@ -0,0 +1,543 @@
|
||||
#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");
|
||||
}
|
||||
127
main/HistoryStore.hpp
Normal file
127
main/HistoryStore.hpp
Normal file
@ -0,0 +1,127 @@
|
||||
#pragma once
|
||||
|
||||
#include "WidgetConfig.hpp"
|
||||
#include <array>
|
||||
#include <climits>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
|
||||
class HistoryStore {
|
||||
public:
|
||||
static HistoryStore& instance();
|
||||
|
||||
void configureFromConfig(const GuiConfig& config);
|
||||
bool updateLatest(uint16_t groupAddr, TextSource source, float value);
|
||||
bool isTracked(uint16_t groupAddr, TextSource source) const;
|
||||
bool tick();
|
||||
|
||||
bool fillChartSeries(uint16_t groupAddr, TextSource source, ChartPeriod period,
|
||||
int32_t* outValues, size_t outCount) const;
|
||||
|
||||
int64_t now() const;
|
||||
bool isTimeSynced() const { return timeSynced_; }
|
||||
|
||||
void updateTimeOfDay(const struct tm& value);
|
||||
void updateDate(const struct tm& value);
|
||||
void updateDateTime(const struct tm& value);
|
||||
|
||||
void loadFromSdCard();
|
||||
void saveToSdCard();
|
||||
|
||||
static constexpr size_t CHART_POINT_COUNT = 120;
|
||||
static constexpr int32_t NO_POINT = INT32_MAX;
|
||||
|
||||
private:
|
||||
HistoryStore();
|
||||
|
||||
struct SeriesKey {
|
||||
uint16_t addr = 0;
|
||||
TextSource source = TextSource::STATIC;
|
||||
};
|
||||
|
||||
struct HistoryPoint {
|
||||
int32_t ts = 0;
|
||||
float value = 0.0f;
|
||||
};
|
||||
|
||||
template <size_t N>
|
||||
struct RingBuffer {
|
||||
std::array<HistoryPoint, N> points = {};
|
||||
size_t count = 0;
|
||||
size_t head = 0;
|
||||
|
||||
void clear() {
|
||||
count = 0;
|
||||
head = 0;
|
||||
}
|
||||
|
||||
void push(const HistoryPoint& point) {
|
||||
points[head] = point;
|
||||
head = (head + 1) % N;
|
||||
if (count < N) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
template <typename Fn>
|
||||
void forEach(Fn&& fn) const {
|
||||
if (count == 0) return;
|
||||
size_t start = (head + N - count) % N;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
const HistoryPoint& p = points[(start + i) % N];
|
||||
fn(p);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static constexpr size_t HISTORY_MAX_SERIES = 12;
|
||||
static constexpr size_t HISTORY_FINE_CAP = 720;
|
||||
static constexpr size_t HISTORY_COARSE_CAP = 720;
|
||||
static constexpr int32_t HISTORY_FINE_INTERVAL = 120;
|
||||
static constexpr int32_t HISTORY_COARSE_INTERVAL = 3600;
|
||||
static constexpr int32_t HISTORY_MONTH_SECONDS = 30 * 24 * 3600;
|
||||
|
||||
struct HistorySeries {
|
||||
SeriesKey key;
|
||||
bool active = false;
|
||||
bool hasLatest = false;
|
||||
float latestValue = 0.0f;
|
||||
int32_t latestTs = 0;
|
||||
RingBuffer<HISTORY_FINE_CAP> fine;
|
||||
RingBuffer<HISTORY_COARSE_CAP> coarse;
|
||||
int32_t lastFineSampleTs = 0;
|
||||
int32_t lastCoarseSampleTs = 0;
|
||||
};
|
||||
|
||||
static bool isNumericSource(TextSource source);
|
||||
static bool keysEqual(const SeriesKey& a, const SeriesKey& b);
|
||||
|
||||
int32_t periodSeconds(ChartPeriod period) const;
|
||||
int64_t buildEpoch() const;
|
||||
bool applyTimeSync();
|
||||
void clearAll();
|
||||
|
||||
HistorySeries* findSeries(uint16_t groupAddr, TextSource source);
|
||||
const HistorySeries* findSeries(uint16_t groupAddr, TextSource source) const;
|
||||
HistorySeries* findSeriesByKey(const SeriesKey& key);
|
||||
const HistorySeries* findSeriesByKey(const SeriesKey& key) const;
|
||||
|
||||
std::array<HistorySeries, HISTORY_MAX_SERIES> series_ = {};
|
||||
size_t seriesCount_ = 0;
|
||||
|
||||
bool timeSynced_ = false;
|
||||
bool hasDate_ = false;
|
||||
bool hasTime_ = false;
|
||||
int year_ = 0;
|
||||
int month_ = 0;
|
||||
int day_ = 0;
|
||||
int hour_ = 0;
|
||||
int minute_ = 0;
|
||||
int second_ = 0;
|
||||
int64_t baseEpoch_ = 0;
|
||||
int64_t baseMono_ = 0;
|
||||
|
||||
bool dirty_ = false;
|
||||
int64_t lastSaveMonoUs_ = 0;
|
||||
bool dataEpoch_ = false;
|
||||
};
|
||||
@ -28,7 +28,7 @@ KnxWorker::KnxWorker() {}
|
||||
namespace {
|
||||
constexpr char kKnxNvsNamespace[] = "knx";
|
||||
constexpr char kKnxSerialKey[] = "serial_bau";
|
||||
constexpr uint8_t kKnxHardwareType[6] = {0x00, 0x00, 0xAB, 0xCE, 0x03, 0x00};
|
||||
constexpr uint8_t kKnxHardwareType[6] = {0x00, 0x00, 0xAB, 0xCE, 0x04, 0x00};
|
||||
constexpr uint16_t kKnxHardwareVersion = 1;
|
||||
|
||||
bool loadKnxBauNumber(uint32_t& outValue) {
|
||||
@ -153,6 +153,27 @@ void KnxWorker::init() {
|
||||
TextSource::KNX_DPT_ENERGY);
|
||||
}
|
||||
|
||||
struct tm timeTm = {};
|
||||
KNXValue timeValue(timeTm);
|
||||
if (go.tryValue(timeValue, DPT_TimeOfDay)) {
|
||||
WidgetManager::instance().onKnxTime(groupAddr, static_cast<struct tm>(timeValue),
|
||||
KnxTimeType::TIME);
|
||||
}
|
||||
|
||||
struct tm dateTm = {};
|
||||
KNXValue dateValue(dateTm);
|
||||
if (go.tryValue(dateValue, DPT_Date)) {
|
||||
WidgetManager::instance().onKnxTime(groupAddr, static_cast<struct tm>(dateValue),
|
||||
KnxTimeType::DATE);
|
||||
}
|
||||
|
||||
struct tm dateTimeTm = {};
|
||||
KNXValue dateTimeValue(dateTimeTm);
|
||||
if (go.tryValue(dateTimeValue, DPT_DateTime)) {
|
||||
WidgetManager::instance().onKnxTime(groupAddr, static_cast<struct tm>(dateTimeValue),
|
||||
KnxTimeType::DATETIME);
|
||||
}
|
||||
|
||||
KNXValue textValue = "";
|
||||
if (go.tryValue(textValue, DPT_String_8859_1) ||
|
||||
go.tryValue(textValue, DPT_String_ASCII)) {
|
||||
|
||||
@ -50,6 +50,22 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
||||
|
||||
// Hierarchy
|
||||
buf[pos++] = static_cast<uint8_t>(parentId);
|
||||
|
||||
// Chart properties
|
||||
buf[pos++] = chartPeriod;
|
||||
buf[pos++] = chartSeriesCount;
|
||||
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
|
||||
buf[pos++] = chartKnxAddress[i] & 0xFF;
|
||||
buf[pos++] = (chartKnxAddress[i] >> 8) & 0xFF;
|
||||
}
|
||||
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
|
||||
buf[pos++] = static_cast<uint8_t>(chartTextSource[i]);
|
||||
}
|
||||
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
|
||||
buf[pos++] = chartSeriesColor[i].r;
|
||||
buf[pos++] = chartSeriesColor[i].g;
|
||||
buf[pos++] = chartSeriesColor[i].b;
|
||||
}
|
||||
}
|
||||
|
||||
void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||
@ -98,6 +114,22 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||
|
||||
// Hierarchy
|
||||
parentId = static_cast<int8_t>(buf[pos++]);
|
||||
|
||||
// Chart properties
|
||||
chartPeriod = buf[pos++];
|
||||
chartSeriesCount = buf[pos++];
|
||||
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
|
||||
chartKnxAddress[i] = buf[pos] | (buf[pos + 1] << 8);
|
||||
pos += 2;
|
||||
}
|
||||
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
|
||||
chartTextSource[i] = static_cast<TextSource>(buf[pos++]);
|
||||
}
|
||||
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
|
||||
chartSeriesColor[i].r = buf[pos++];
|
||||
chartSeriesColor[i].g = buf[pos++];
|
||||
chartSeriesColor[i].b = buf[pos++];
|
||||
}
|
||||
}
|
||||
|
||||
WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const char* labelText) {
|
||||
@ -244,6 +276,10 @@ void GuiConfig::clear() {
|
||||
standbyEnabled = false;
|
||||
standbyScreenId = 0xFF;
|
||||
standbyMinutes = 0;
|
||||
knxTimeAddress = 0;
|
||||
knxDateAddress = 0;
|
||||
knxDateTimeAddress = 0;
|
||||
knxNightModeAddress = 0;
|
||||
for (size_t i = 0; i < MAX_SCREENS; i++) {
|
||||
screens[i].clear(static_cast<uint8_t>(i), nullptr);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ static constexpr size_t MAX_WIDGETS = 32;
|
||||
static constexpr size_t MAX_SCREENS = 8;
|
||||
static constexpr size_t MAX_TEXT_LEN = 32;
|
||||
static constexpr size_t MAX_SCREEN_NAME_LEN = 24;
|
||||
static constexpr size_t CHART_MAX_SERIES = 3;
|
||||
|
||||
enum class WidgetType : uint8_t {
|
||||
LABEL = 0,
|
||||
@ -20,6 +21,7 @@ enum class WidgetType : uint8_t {
|
||||
POWERFLOW = 6,
|
||||
POWERNODE = 7,
|
||||
POWERLINK = 8,
|
||||
CHART = 9,
|
||||
};
|
||||
|
||||
enum class IconPosition : uint8_t {
|
||||
@ -40,6 +42,15 @@ enum class ButtonAction : uint8_t {
|
||||
BACK = 2,
|
||||
};
|
||||
|
||||
enum class ChartPeriod : uint8_t {
|
||||
HOUR_1 = 0,
|
||||
HOUR_3 = 1,
|
||||
HOUR_5 = 2,
|
||||
HOUR_12 = 3,
|
||||
HOUR_24 = 4,
|
||||
MONTH_1 = 5,
|
||||
};
|
||||
|
||||
// Text source: static text or KNX group address
|
||||
enum class TextSource : uint8_t {
|
||||
STATIC = 0, // Static text
|
||||
@ -50,6 +61,9 @@ enum class TextSource : uint8_t {
|
||||
KNX_DPT_POWER = 5, // KNX Power (DPT 14.056)
|
||||
KNX_DPT_ENERGY = 6, // KNX Energy (DPT 13.013)
|
||||
KNX_DPT_DECIMALFACTOR = 7, // KNX Decimal Factor (DPT 5.005)
|
||||
KNX_DPT_TIME = 8, // KNX Time of day (DPT 10.001)
|
||||
KNX_DPT_DATE = 9, // KNX Date (DPT 11.001)
|
||||
KNX_DPT_DATETIME = 10, // KNX DateTime (DPT 19.001)
|
||||
};
|
||||
|
||||
enum class TextAlign : uint8_t {
|
||||
@ -126,8 +140,15 @@ struct WidgetConfig {
|
||||
// Hierarchy
|
||||
int8_t parentId; // ID of parent widget (-1 = root/screen)
|
||||
|
||||
// Chart properties
|
||||
uint8_t chartPeriod;
|
||||
uint8_t chartSeriesCount;
|
||||
uint16_t chartKnxAddress[CHART_MAX_SERIES];
|
||||
TextSource chartTextSource[CHART_MAX_SERIES];
|
||||
Color chartSeriesColor[CHART_MAX_SERIES];
|
||||
|
||||
// Serialization size (fixed for NVS storage)
|
||||
static constexpr size_t SERIALIZED_SIZE = 78;
|
||||
static constexpr size_t SERIALIZED_SIZE = 98;
|
||||
|
||||
void serialize(uint8_t* buf) const;
|
||||
void deserialize(const uint8_t* buf);
|
||||
@ -175,6 +196,10 @@ struct GuiConfig {
|
||||
bool standbyEnabled;
|
||||
uint8_t standbyScreenId;
|
||||
uint16_t standbyMinutes;
|
||||
uint16_t knxTimeAddress;
|
||||
uint16_t knxDateAddress;
|
||||
uint16_t knxDateTimeAddress;
|
||||
uint16_t knxNightModeAddress;
|
||||
|
||||
void clear();
|
||||
ScreenConfig* findScreen(uint8_t id);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#include "WidgetManager.hpp"
|
||||
#include "widgets/WidgetFactory.hpp"
|
||||
#include "HistoryStore.hpp"
|
||||
#include "SdCard.hpp"
|
||||
#include "esp_lv_adapter.h"
|
||||
#include "esp_log.h"
|
||||
@ -170,6 +171,8 @@ void WidgetManager::createDefaultConfig() {
|
||||
|
||||
void WidgetManager::init() {
|
||||
loadFromSdCard();
|
||||
HistoryStore::instance().configureFromConfig(config_);
|
||||
HistoryStore::instance().loadFromSdCard();
|
||||
if (config_.findScreen(config_.startScreenId)) {
|
||||
activeScreenId_ = config_.startScreenId;
|
||||
} else if (config_.screenCount > 0) {
|
||||
@ -257,6 +260,7 @@ void WidgetManager::saveToSdCard() {
|
||||
}
|
||||
|
||||
void WidgetManager::applyConfig() {
|
||||
HistoryStore::instance().configureFromConfig(config_);
|
||||
if (!config_.findScreen(activeScreenId_)) {
|
||||
if (config_.findScreen(config_.startScreenId)) {
|
||||
activeScreenId_ = config_.startScreenId;
|
||||
@ -665,6 +669,10 @@ void WidgetManager::loop() {
|
||||
|
||||
processUiQueue();
|
||||
|
||||
if (HistoryStore::instance().tick()) {
|
||||
refreshChartWidgets();
|
||||
}
|
||||
|
||||
if (didUiNav) return;
|
||||
|
||||
if (!config_.standbyEnabled || config_.standbyMinutes == 0) return;
|
||||
@ -805,6 +813,16 @@ void WidgetManager::onKnxText(uint16_t groupAddr, const char* text) {
|
||||
enqueueUiEvent(event);
|
||||
}
|
||||
|
||||
void WidgetManager::onKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type) {
|
||||
UiEvent event = {};
|
||||
event.type = UiEventType::KNX_TIME;
|
||||
event.groupAddr = groupAddr;
|
||||
event.timeType = type;
|
||||
event.timeValue = value;
|
||||
cacheKnxTime(groupAddr, type, value);
|
||||
enqueueUiEvent(event);
|
||||
}
|
||||
|
||||
bool WidgetManager::enqueueUiEvent(const UiEvent& event) {
|
||||
if (!uiQueue_) return false;
|
||||
return xQueueSend(uiQueue_, &event, 0) == pdTRUE;
|
||||
@ -831,10 +849,18 @@ void WidgetManager::processUiQueue() {
|
||||
case UiEventType::KNX_TEXT:
|
||||
applyKnxText(event.groupAddr, event.text);
|
||||
break;
|
||||
case UiEventType::KNX_TIME:
|
||||
applyKnxTime(event.groupAddr, event.timeValue, event.timeType);
|
||||
break;
|
||||
}
|
||||
processed++;
|
||||
}
|
||||
|
||||
if (chartRefreshPending_) {
|
||||
refreshChartWidgetsLocked();
|
||||
chartRefreshPending_ = false;
|
||||
}
|
||||
|
||||
esp_lv_adapter_unlock();
|
||||
}
|
||||
|
||||
@ -848,6 +874,22 @@ void WidgetManager::applyCachedValuesToWidgets() {
|
||||
TextSource source = widget->getTextSource();
|
||||
if (source == TextSource::STATIC) continue;
|
||||
|
||||
if (source == TextSource::KNX_DPT_TIME ||
|
||||
source == TextSource::KNX_DPT_DATE ||
|
||||
source == TextSource::KNX_DPT_DATETIME) {
|
||||
KnxTimeType type = KnxTimeType::TIME;
|
||||
if (source == TextSource::KNX_DPT_DATE) {
|
||||
type = KnxTimeType::DATE;
|
||||
} else if (source == TextSource::KNX_DPT_DATETIME) {
|
||||
type = KnxTimeType::DATETIME;
|
||||
}
|
||||
struct tm tmValue = {};
|
||||
if (getCachedKnxTime(addr, type, &tmValue)) {
|
||||
widget->onKnxTime(tmValue, source);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source == TextSource::KNX_DPT_SWITCH) {
|
||||
bool state = false;
|
||||
if (getCachedKnxSwitch(addr, &state)) {
|
||||
@ -873,6 +915,21 @@ void WidgetManager::applyCachedValuesToWidgets() {
|
||||
}
|
||||
}
|
||||
|
||||
void WidgetManager::refreshChartWidgetsLocked() {
|
||||
for (auto& widget : widgets_) {
|
||||
if (!widget) continue;
|
||||
if (widget->getType() == WidgetType::CHART) {
|
||||
widget->onHistoryUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WidgetManager::refreshChartWidgets() {
|
||||
if (esp_lv_adapter_lock(-1) != ESP_OK) return;
|
||||
refreshChartWidgetsLocked();
|
||||
esp_lv_adapter_unlock();
|
||||
}
|
||||
|
||||
void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource source) {
|
||||
for (auto& widget : widgets_) {
|
||||
if (widget && widget->getKnxAddress() == groupAddr &&
|
||||
@ -880,6 +937,10 @@ void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource so
|
||||
widget->onKnxValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (HistoryStore::instance().updateLatest(groupAddr, source, value)) {
|
||||
chartRefreshPending_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
|
||||
@ -888,6 +949,10 @@ void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
|
||||
widget->onKnxSwitch(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (config_.knxNightModeAddress != 0 && groupAddr == config_.knxNightModeAddress) {
|
||||
nightMode_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
void WidgetManager::applyKnxText(uint16_t groupAddr, const char* text) {
|
||||
@ -898,6 +963,48 @@ void WidgetManager::applyKnxText(uint16_t groupAddr, const char* text) {
|
||||
}
|
||||
}
|
||||
|
||||
void WidgetManager::applyKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type) {
|
||||
bool updated = false;
|
||||
switch (type) {
|
||||
case KnxTimeType::TIME:
|
||||
if (config_.knxTimeAddress != 0 && groupAddr == config_.knxTimeAddress) {
|
||||
HistoryStore::instance().updateTimeOfDay(value);
|
||||
updated = true;
|
||||
}
|
||||
break;
|
||||
case KnxTimeType::DATE:
|
||||
if (config_.knxDateAddress != 0 && groupAddr == config_.knxDateAddress) {
|
||||
HistoryStore::instance().updateDate(value);
|
||||
updated = true;
|
||||
}
|
||||
break;
|
||||
case KnxTimeType::DATETIME:
|
||||
if (config_.knxDateTimeAddress != 0 && groupAddr == config_.knxDateTimeAddress) {
|
||||
HistoryStore::instance().updateDateTime(value);
|
||||
updated = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
TextSource source = TextSource::STATIC;
|
||||
if (type == KnxTimeType::TIME) source = TextSource::KNX_DPT_TIME;
|
||||
else if (type == KnxTimeType::DATE) source = TextSource::KNX_DPT_DATE;
|
||||
else if (type == KnxTimeType::DATETIME) source = TextSource::KNX_DPT_DATETIME;
|
||||
|
||||
if (source != TextSource::STATIC) {
|
||||
for (auto& widget : widgets_) {
|
||||
if (widget && widget->getKnxAddress() == groupAddr &&
|
||||
widget->getTextSource() == source) {
|
||||
widget->onKnxTime(value, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
chartRefreshPending_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void WidgetManager::cacheKnxValue(uint16_t groupAddr, TextSource source, float value) {
|
||||
if (groupAddr == 0) return;
|
||||
portENTER_CRITICAL(&knxCacheMux_);
|
||||
@ -999,6 +1106,37 @@ void WidgetManager::cacheKnxText(uint16_t groupAddr, const char* text) {
|
||||
portEXIT_CRITICAL(&knxCacheMux_);
|
||||
}
|
||||
|
||||
void WidgetManager::cacheKnxTime(uint16_t groupAddr, KnxTimeType type, const struct tm& value) {
|
||||
if (groupAddr == 0) return;
|
||||
portENTER_CRITICAL(&knxCacheMux_);
|
||||
|
||||
size_t freeIndex = KNX_CACHE_SIZE;
|
||||
for (size_t i = 0; i < KNX_CACHE_SIZE; ++i) {
|
||||
auto& entry = knxTimeCache_[i];
|
||||
if (entry.valid) {
|
||||
if (entry.groupAddr == groupAddr && entry.type == type) {
|
||||
entry.value = value;
|
||||
portEXIT_CRITICAL(&knxCacheMux_);
|
||||
return;
|
||||
}
|
||||
} else if (freeIndex == KNX_CACHE_SIZE) {
|
||||
freeIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
size_t index = freeIndex;
|
||||
if (index == KNX_CACHE_SIZE) {
|
||||
index = knxTimeCacheNext_;
|
||||
knxTimeCacheNext_ = (knxTimeCacheNext_ + 1) % KNX_CACHE_SIZE;
|
||||
}
|
||||
auto& entry = knxTimeCache_[index];
|
||||
entry.groupAddr = groupAddr;
|
||||
entry.type = type;
|
||||
entry.value = value;
|
||||
entry.valid = true;
|
||||
portEXIT_CRITICAL(&knxCacheMux_);
|
||||
}
|
||||
|
||||
bool WidgetManager::getCachedKnxValue(uint16_t groupAddr, TextSource source, float* out) const {
|
||||
if (groupAddr == 0 || out == nullptr) return false;
|
||||
bool found = false;
|
||||
@ -1045,6 +1183,21 @@ bool WidgetManager::getCachedKnxText(uint16_t groupAddr, char* out, size_t outSi
|
||||
return found;
|
||||
}
|
||||
|
||||
bool WidgetManager::getCachedKnxTime(uint16_t groupAddr, KnxTimeType type, struct tm* out) const {
|
||||
if (groupAddr == 0 || out == nullptr) return false;
|
||||
bool found = false;
|
||||
portENTER_CRITICAL(&knxCacheMux_);
|
||||
for (const auto& entry : knxTimeCache_) {
|
||||
if (entry.valid && entry.groupAddr == groupAddr && entry.type == type) {
|
||||
*out = entry.value;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
portEXIT_CRITICAL(&knxCacheMux_);
|
||||
return found;
|
||||
}
|
||||
|
||||
bool WidgetManager::isNumericTextSource(TextSource source) {
|
||||
return source == TextSource::KNX_DPT_TEMP ||
|
||||
source == TextSource::KNX_DPT_PERCENT ||
|
||||
@ -1059,6 +1212,16 @@ static uint32_t parseHexColor(const char* colorStr) {
|
||||
return strtoul(colorStr + 1, nullptr, 16);
|
||||
}
|
||||
|
||||
static Color defaultChartColor(size_t index) {
|
||||
static const Color kChartColors[CHART_MAX_SERIES] = {
|
||||
{239, 99, 81},
|
||||
{125, 211, 176},
|
||||
{94, 162, 239}
|
||||
};
|
||||
if (index >= CHART_MAX_SERIES) return kChartColors[0];
|
||||
return kChartColors[index];
|
||||
}
|
||||
|
||||
void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
if (!root) {
|
||||
@ -1073,6 +1236,12 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||
cJSON_AddNumberToObject(standby, "screen", config_.standbyScreenId);
|
||||
cJSON_AddNumberToObject(standby, "minutes", config_.standbyMinutes);
|
||||
|
||||
cJSON* knx = cJSON_AddObjectToObject(root, "knx");
|
||||
cJSON_AddNumberToObject(knx, "time", config_.knxTimeAddress);
|
||||
cJSON_AddNumberToObject(knx, "date", config_.knxDateAddress);
|
||||
cJSON_AddNumberToObject(knx, "dateTime", config_.knxDateTimeAddress);
|
||||
cJSON_AddNumberToObject(knx, "night", config_.knxNightModeAddress);
|
||||
|
||||
cJSON* screens = cJSON_AddArrayToObject(root, "screens");
|
||||
|
||||
for (uint8_t s = 0; s < config_.screenCount; s++) {
|
||||
@ -1155,6 +1324,24 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||
|
||||
cJSON_AddNumberToObject(widget, "parentId", w.parentId);
|
||||
|
||||
if (w.type == WidgetType::CHART) {
|
||||
cJSON* chart = cJSON_AddObjectToObject(widget, "chart");
|
||||
cJSON_AddNumberToObject(chart, "period", w.chartPeriod);
|
||||
cJSON* series = cJSON_AddArrayToObject(chart, "series");
|
||||
uint8_t seriesCount = w.chartSeriesCount;
|
||||
if (seriesCount > CHART_MAX_SERIES) seriesCount = CHART_MAX_SERIES;
|
||||
for (uint8_t si = 0; si < seriesCount; ++si) {
|
||||
cJSON* s = cJSON_CreateObject();
|
||||
cJSON_AddNumberToObject(s, "knxAddr", w.chartKnxAddress[si]);
|
||||
cJSON_AddNumberToObject(s, "textSrc", static_cast<int>(w.chartTextSource[si]));
|
||||
char colorStr[8];
|
||||
snprintf(colorStr, sizeof(colorStr), "#%02X%02X%02X",
|
||||
w.chartSeriesColor[si].r, w.chartSeriesColor[si].g, w.chartSeriesColor[si].b);
|
||||
cJSON_AddStringToObject(s, "color", colorStr);
|
||||
cJSON_AddItemToArray(series, s);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(widgets, widget);
|
||||
}
|
||||
|
||||
@ -1203,6 +1390,13 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
w.targetScreen = 0;
|
||||
w.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
|
||||
w.isContainer = false;
|
||||
w.chartPeriod = static_cast<uint8_t>(ChartPeriod::HOUR_1);
|
||||
w.chartSeriesCount = 1;
|
||||
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
|
||||
w.chartKnxAddress[i] = 0;
|
||||
w.chartTextSource[i] = TextSource::KNX_DPT_TEMP;
|
||||
w.chartSeriesColor[i] = defaultChartColor(i);
|
||||
}
|
||||
|
||||
cJSON* id = cJSON_GetObjectItem(widget, "id");
|
||||
if (cJSON_IsNumber(id)) w.id = id->valueint;
|
||||
@ -1317,6 +1511,47 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
w.parentId = -1; // Default to root
|
||||
}
|
||||
|
||||
cJSON* chart = cJSON_GetObjectItem(widget, "chart");
|
||||
if (cJSON_IsObject(chart)) {
|
||||
cJSON* period = cJSON_GetObjectItem(chart, "period");
|
||||
if (cJSON_IsNumber(period)) {
|
||||
int periodVal = period->valueint;
|
||||
if (periodVal < 0) periodVal = 0;
|
||||
if (periodVal > static_cast<int>(ChartPeriod::MONTH_1)) {
|
||||
periodVal = static_cast<int>(ChartPeriod::MONTH_1);
|
||||
}
|
||||
w.chartPeriod = static_cast<uint8_t>(periodVal);
|
||||
}
|
||||
|
||||
cJSON* series = cJSON_GetObjectItem(chart, "series");
|
||||
if (cJSON_IsArray(series)) {
|
||||
uint8_t idx = 0;
|
||||
cJSON* item = nullptr;
|
||||
cJSON_ArrayForEach(item, series) {
|
||||
if (idx >= CHART_MAX_SERIES) break;
|
||||
cJSON* sAddr = cJSON_GetObjectItem(item, "knxAddr");
|
||||
if (cJSON_IsNumber(sAddr)) {
|
||||
w.chartKnxAddress[idx] = sAddr->valueint;
|
||||
}
|
||||
cJSON* sSrc = cJSON_GetObjectItem(item, "textSrc");
|
||||
if (cJSON_IsNumber(sSrc)) {
|
||||
TextSource src = static_cast<TextSource>(sSrc->valueint);
|
||||
if (isNumericTextSource(src)) {
|
||||
w.chartTextSource[idx] = src;
|
||||
}
|
||||
}
|
||||
cJSON* sColor = cJSON_GetObjectItem(item, "color");
|
||||
if (cJSON_IsString(sColor)) {
|
||||
w.chartSeriesColor[idx] = Color::fromHex(parseHexColor(sColor->valuestring));
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
if (idx > 0) {
|
||||
w.chartSeriesCount = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
screen.widgetCount++;
|
||||
}
|
||||
|
||||
@ -1437,6 +1672,21 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* knx = cJSON_GetObjectItem(root, "knx");
|
||||
if (cJSON_IsObject(knx)) {
|
||||
cJSON* timeAddr = cJSON_GetObjectItem(knx, "time");
|
||||
if (cJSON_IsNumber(timeAddr)) newConfig->knxTimeAddress = timeAddr->valueint;
|
||||
|
||||
cJSON* dateAddr = cJSON_GetObjectItem(knx, "date");
|
||||
if (cJSON_IsNumber(dateAddr)) newConfig->knxDateAddress = dateAddr->valueint;
|
||||
|
||||
cJSON* dateTimeAddr = cJSON_GetObjectItem(knx, "dateTime");
|
||||
if (cJSON_IsNumber(dateTimeAddr)) newConfig->knxDateTimeAddress = dateTimeAddr->valueint;
|
||||
|
||||
cJSON* nightAddr = cJSON_GetObjectItem(knx, "night");
|
||||
if (cJSON_IsNumber(nightAddr)) newConfig->knxNightModeAddress = nightAddr->valueint;
|
||||
}
|
||||
|
||||
if (newConfig->screenCount == 0) {
|
||||
cJSON_Delete(root);
|
||||
return false;
|
||||
|
||||
@ -7,8 +7,15 @@
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/portmacro.h"
|
||||
#include <array>
|
||||
#include <ctime>
|
||||
#include <memory>
|
||||
|
||||
enum class KnxTimeType : uint8_t {
|
||||
TIME = 0,
|
||||
DATE = 1,
|
||||
DATETIME = 2,
|
||||
};
|
||||
|
||||
class WidgetManager {
|
||||
public:
|
||||
static WidgetManager& instance();
|
||||
@ -43,6 +50,7 @@ public:
|
||||
void onKnxValue(uint16_t groupAddr, float value);
|
||||
void onKnxSwitch(uint16_t groupAddr, bool value);
|
||||
void onKnxText(uint16_t groupAddr, const char* text);
|
||||
void onKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type);
|
||||
|
||||
// Button action handler
|
||||
void handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target);
|
||||
@ -66,6 +74,7 @@ private:
|
||||
KNX_VALUE = 0,
|
||||
KNX_SWITCH = 1,
|
||||
KNX_TEXT = 2,
|
||||
KNX_TIME = 3,
|
||||
};
|
||||
|
||||
struct UiEvent {
|
||||
@ -75,6 +84,8 @@ private:
|
||||
float value;
|
||||
bool state;
|
||||
char text[UI_EVENT_TEXT_LEN];
|
||||
KnxTimeType timeType;
|
||||
struct tm timeValue;
|
||||
};
|
||||
|
||||
static constexpr size_t KNX_CACHE_SIZE = MAX_WIDGETS * MAX_SCREENS;
|
||||
@ -98,6 +109,13 @@ private:
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
struct KnxTimeCacheEntry {
|
||||
uint16_t groupAddr = 0;
|
||||
KnxTimeType type = KnxTimeType::TIME;
|
||||
struct tm value = {};
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
void loadFromSdCard();
|
||||
void saveToSdCard();
|
||||
void destroyAllWidgets();
|
||||
@ -108,13 +126,18 @@ private:
|
||||
void applyKnxValue(uint16_t groupAddr, float value, TextSource source);
|
||||
void applyKnxSwitch(uint16_t groupAddr, bool value);
|
||||
void applyKnxText(uint16_t groupAddr, const char* text);
|
||||
void applyKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type);
|
||||
void cacheKnxValue(uint16_t groupAddr, TextSource source, float value);
|
||||
void cacheKnxSwitch(uint16_t groupAddr, bool value);
|
||||
void cacheKnxText(uint16_t groupAddr, const char* text);
|
||||
void cacheKnxTime(uint16_t groupAddr, KnxTimeType type, const struct tm& value);
|
||||
bool getCachedKnxValue(uint16_t groupAddr, TextSource source, float* out) const;
|
||||
bool getCachedKnxSwitch(uint16_t groupAddr, bool* out) const;
|
||||
bool getCachedKnxText(uint16_t groupAddr, char* out, size_t outSize) const;
|
||||
bool getCachedKnxTime(uint16_t groupAddr, KnxTimeType type, struct tm* out) const;
|
||||
static bool isNumericTextSource(TextSource source);
|
||||
void refreshChartWidgetsLocked();
|
||||
void refreshChartWidgets();
|
||||
|
||||
void createDefaultConfig();
|
||||
void applyScreen(uint8_t screenId);
|
||||
@ -137,6 +160,8 @@ private:
|
||||
bool standbyWakePending_ = false;
|
||||
uint8_t standbyWakeTarget_ = 0xFF;
|
||||
bool navPending_ = false;
|
||||
bool chartRefreshPending_ = false;
|
||||
bool nightMode_ = false;
|
||||
ButtonAction navAction_ = ButtonAction::KNX;
|
||||
uint8_t navTargetScreen_ = 0xFF;
|
||||
int64_t navRequestUs_ = 0;
|
||||
@ -152,8 +177,10 @@ private:
|
||||
std::array<KnxNumericCacheEntry, KNX_CACHE_SIZE> knxNumericCache_ = {};
|
||||
std::array<KnxSwitchCacheEntry, KNX_CACHE_SIZE> knxSwitchCache_ = {};
|
||||
std::array<KnxTextCacheEntry, KNX_CACHE_SIZE> knxTextCache_ = {};
|
||||
std::array<KnxTimeCacheEntry, KNX_CACHE_SIZE> knxTimeCache_ = {};
|
||||
size_t knxNumericCacheNext_ = 0;
|
||||
size_t knxSwitchCacheNext_ = 0;
|
||||
size_t knxTextCacheNext_ = 0;
|
||||
size_t knxTimeCacheNext_ = 0;
|
||||
mutable portMUX_TYPE knxCacheMux_ = {};
|
||||
};
|
||||
|
||||
209
main/widgets/ChartWidget.cpp
Normal file
209
main/widgets/ChartWidget.cpp
Normal file
@ -0,0 +1,209 @@
|
||||
#include "ChartWidget.hpp"
|
||||
#include "../Fonts.hpp"
|
||||
#include "lvgl.h"
|
||||
#include <algorithm>
|
||||
|
||||
ChartWidget::ChartWidget(const WidgetConfig& config)
|
||||
: Widget(config) {
|
||||
}
|
||||
|
||||
lv_obj_t* ChartWidget::create(lv_obj_t* parent) {
|
||||
return nullptr;
|
||||
obj_ = lv_obj_create(parent);
|
||||
if (!obj_) return nullptr;
|
||||
lv_obj_remove_style_all(obj_);
|
||||
|
||||
int32_t width = config_.width > 0 ? config_.width : 240;
|
||||
int32_t height = config_.height > 0 ? config_.height : 160;
|
||||
lv_obj_set_pos(obj_, config_.x, config_.y);
|
||||
lv_obj_set_size(obj_, width, height);
|
||||
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
const int32_t yAxisWidth = 48;
|
||||
const int32_t xAxisHeight = 26;
|
||||
int32_t chartWidth = width - yAxisWidth;
|
||||
int32_t chartHeight = height - xAxisHeight;
|
||||
if (chartWidth < 20) chartWidth = 20;
|
||||
if (chartHeight < 20) chartHeight = 20;
|
||||
|
||||
yScale_ = lv_scale_create(obj_);
|
||||
if (yScale_) {
|
||||
lv_scale_set_mode(yScale_, LV_SCALE_MODE_VERTICAL_LEFT);
|
||||
lv_scale_set_total_tick_count(yScale_, 5);
|
||||
lv_scale_set_major_tick_every(yScale_, 1);
|
||||
lv_scale_set_label_show(yScale_, true);
|
||||
lv_scale_set_range(yScale_, 0, 100);
|
||||
lv_obj_set_pos(yScale_, 0, 0);
|
||||
lv_obj_set_size(yScale_, yAxisWidth, chartHeight);
|
||||
}
|
||||
|
||||
xScale_ = lv_scale_create(obj_);
|
||||
if (xScale_) {
|
||||
lv_scale_set_mode(xScale_, LV_SCALE_MODE_HORIZONTAL_BOTTOM);
|
||||
lv_scale_set_label_show(xScale_, true);
|
||||
lv_obj_set_pos(xScale_, yAxisWidth, chartHeight);
|
||||
lv_obj_set_size(xScale_, chartWidth, xAxisHeight);
|
||||
}
|
||||
|
||||
chart_ = lv_chart_create(obj_);
|
||||
if (!chart_) {
|
||||
return obj_;
|
||||
}
|
||||
lv_obj_set_pos(chart_, yAxisWidth, 0);
|
||||
lv_obj_set_size(chart_, chartWidth, chartHeight);
|
||||
lv_obj_clear_flag(chart_, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_chart_set_type(chart_, LV_CHART_TYPE_LINE);
|
||||
lv_chart_set_point_count(chart_, HistoryStore::CHART_POINT_COUNT);
|
||||
lv_chart_set_div_line_count(chart_, 4, 6);
|
||||
|
||||
uint8_t count = config_.chartSeriesCount;
|
||||
if (count > CHART_MAX_SERIES) count = CHART_MAX_SERIES;
|
||||
|
||||
for (uint8_t i = 0; i < count; ++i) {
|
||||
lv_color_t color = lv_color_make(
|
||||
config_.chartSeriesColor[i].r,
|
||||
config_.chartSeriesColor[i].g,
|
||||
config_.chartSeriesColor[i].b);
|
||||
series_[i] = lv_chart_add_series(chart_, color, LV_CHART_AXIS_PRIMARY_Y);
|
||||
if (series_[i]) {
|
||||
lv_chart_set_series_ext_y_array(chart_, series_[i], seriesData_[i].data());
|
||||
std::fill(seriesData_[i].begin(), seriesData_[i].end(), HistoryStore::NO_POINT);
|
||||
}
|
||||
}
|
||||
|
||||
//applyAxisLabels();
|
||||
//refreshData();
|
||||
return obj_;
|
||||
}
|
||||
|
||||
void ChartWidget::applyStyle() {
|
||||
if (!obj_) return;
|
||||
|
||||
Widget::applyStyle();
|
||||
lv_obj_set_style_border_width(obj_, 0, 0);
|
||||
lv_obj_set_style_pad_all(obj_, 0, 0);
|
||||
|
||||
if (chart_) {
|
||||
lv_obj_set_style_border_width(chart_, 0, 0);
|
||||
lv_obj_set_style_pad_all(chart_, 6, 0);
|
||||
lv_obj_set_style_line_width(chart_, 2, LV_PART_ITEMS);
|
||||
}
|
||||
|
||||
const lv_color_t textColor = lv_color_make(
|
||||
config_.textColor.r, config_.textColor.g, config_.textColor.b);
|
||||
const lv_font_t* axisFont = Fonts::bySizeIndex(0);
|
||||
if (yScale_) {
|
||||
lv_obj_set_style_text_color(yScale_, textColor, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_text_font(yScale_, axisFont, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_opa(yScale_, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(yScale_, 0, 0);
|
||||
}
|
||||
if (xScale_) {
|
||||
lv_obj_set_style_text_color(xScale_, textColor, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_text_font(xScale_, axisFont, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_bg_opa(xScale_, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(xScale_, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void ChartWidget::onHistoryUpdate() {
|
||||
refreshData();
|
||||
}
|
||||
|
||||
void ChartWidget::refreshData() {
|
||||
if (!chart_) return;
|
||||
|
||||
uint8_t count = config_.chartSeriesCount;
|
||||
if (count > CHART_MAX_SERIES) count = CHART_MAX_SERIES;
|
||||
|
||||
bool hasAny = false;
|
||||
int32_t globalMin = 0;
|
||||
int32_t globalMax = 0;
|
||||
|
||||
for (uint8_t i = 0; i < count; ++i) {
|
||||
if (!series_[i]) continue;
|
||||
|
||||
HistoryStore::instance().fillChartSeries(
|
||||
config_.chartKnxAddress[i],
|
||||
config_.chartTextSource[i],
|
||||
static_cast<ChartPeriod>(config_.chartPeriod),
|
||||
seriesData_[i].data(),
|
||||
seriesData_[i].size());
|
||||
|
||||
for (size_t j = 0; j < seriesData_[i].size(); ++j) {
|
||||
int32_t value = seriesData_[i][j];
|
||||
if (value == HistoryStore::NO_POINT) continue;
|
||||
if (!hasAny) {
|
||||
globalMin = value;
|
||||
globalMax = value;
|
||||
hasAny = true;
|
||||
} else {
|
||||
globalMin = std::min(globalMin, value);
|
||||
globalMax = std::max(globalMax, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAny) {
|
||||
int32_t range = globalMax - globalMin;
|
||||
if (range < 1) range = 1;
|
||||
int32_t pad = range / 10;
|
||||
if (pad < 1) pad = 1;
|
||||
int32_t minVal = globalMin - pad;
|
||||
int32_t maxVal = globalMax + pad;
|
||||
lv_chart_set_axis_range(chart_, LV_CHART_AXIS_PRIMARY_Y, minVal, maxVal);
|
||||
if (yScale_) {
|
||||
lv_scale_set_range(yScale_, minVal, maxVal);
|
||||
}
|
||||
} else {
|
||||
lv_chart_set_axis_range(chart_, LV_CHART_AXIS_PRIMARY_Y, 0, 100);
|
||||
if (yScale_) {
|
||||
lv_scale_set_range(yScale_, 0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
lv_chart_refresh(chart_);
|
||||
}
|
||||
|
||||
const char** ChartWidget::labelsForPeriod(ChartPeriod period, uint8_t* count) {
|
||||
static const char* k1h[] = { "-60m", "-45m", "-30m", "-15m", "0", nullptr };
|
||||
static const char* k3h[] = { "-3h", "-2h", "-1h", "-30m", "0", nullptr };
|
||||
static const char* k5h[] = { "-5h", "-4h", "-3h", "-2h", "-1h", "0", nullptr };
|
||||
static const char* k12h[] = { "-12h", "-9h", "-6h", "-3h", "0", nullptr };
|
||||
static const char* k24h[] = { "-24h", "-18h", "-12h", "-6h", "0", nullptr };
|
||||
static const char* k1m[] = { "-30d", "-21d", "-14d", "-7d", "0", nullptr };
|
||||
|
||||
switch (period) {
|
||||
case ChartPeriod::HOUR_1:
|
||||
if (count) *count = 5;
|
||||
return k1h;
|
||||
case ChartPeriod::HOUR_3:
|
||||
if (count) *count = 5;
|
||||
return k3h;
|
||||
case ChartPeriod::HOUR_5:
|
||||
if (count) *count = 6;
|
||||
return k5h;
|
||||
case ChartPeriod::HOUR_12:
|
||||
if (count) *count = 5;
|
||||
return k12h;
|
||||
case ChartPeriod::HOUR_24:
|
||||
if (count) *count = 5;
|
||||
return k24h;
|
||||
case ChartPeriod::MONTH_1:
|
||||
if (count) *count = 5;
|
||||
return k1m;
|
||||
default:
|
||||
if (count) *count = 5;
|
||||
return k1h;
|
||||
}
|
||||
}
|
||||
|
||||
void ChartWidget::applyAxisLabels() {
|
||||
if (!xScale_) return;
|
||||
uint8_t count = 5;
|
||||
const char** labels = labelsForPeriod(static_cast<ChartPeriod>(config_.chartPeriod), &count);
|
||||
lv_scale_set_total_tick_count(xScale_, count);
|
||||
lv_scale_set_major_tick_every(xScale_, 1);
|
||||
lv_scale_set_text_src(xScale_, labels);
|
||||
}
|
||||
25
main/widgets/ChartWidget.hpp
Normal file
25
main/widgets/ChartWidget.hpp
Normal file
@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "Widget.hpp"
|
||||
#include "../HistoryStore.hpp"
|
||||
#include <array>
|
||||
|
||||
class ChartWidget : public Widget {
|
||||
public:
|
||||
explicit ChartWidget(const WidgetConfig& config);
|
||||
|
||||
lv_obj_t* create(lv_obj_t* parent) override;
|
||||
void applyStyle() override;
|
||||
void onHistoryUpdate() override;
|
||||
|
||||
private:
|
||||
void refreshData();
|
||||
void applyAxisLabels();
|
||||
static const char** labelsForPeriod(ChartPeriod period, uint8_t* count);
|
||||
|
||||
lv_obj_t* chart_ = nullptr;
|
||||
lv_obj_t* yScale_ = nullptr;
|
||||
lv_obj_t* xScale_ = nullptr;
|
||||
lv_chart_series_t* series_[CHART_MAX_SERIES] = {};
|
||||
std::array<std::array<int32_t, HistoryStore::CHART_POINT_COUNT>, CHART_MAX_SERIES> seriesData_ = {};
|
||||
};
|
||||
@ -229,3 +229,47 @@ void LabelWidget::onKnxText(const char* text) {
|
||||
|
||||
set_label_text_if_changed(label, text);
|
||||
}
|
||||
|
||||
void LabelWidget::onKnxTime(const struct tm& value, TextSource source) {
|
||||
lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
|
||||
if (label == nullptr) return;
|
||||
if (config_.textSource != source) return;
|
||||
if (source != TextSource::KNX_DPT_TIME &&
|
||||
source != TextSource::KNX_DPT_DATE &&
|
||||
source != TextSource::KNX_DPT_DATETIME) {
|
||||
return;
|
||||
}
|
||||
|
||||
int year = value.tm_year;
|
||||
if (year > 0 && year < 1900) {
|
||||
year += 1900;
|
||||
}
|
||||
int month = value.tm_mon;
|
||||
if (month < 1 || month > 12) {
|
||||
if (month >= 0 && month <= 11) {
|
||||
month += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const char* fmt = config_.text;
|
||||
if (!fmt || fmt[0] == '\0' || strchr(fmt, '%') == nullptr) {
|
||||
if (source == TextSource::KNX_DPT_TIME) {
|
||||
fmt = "%02d:%02d:%02d";
|
||||
} else if (source == TextSource::KNX_DPT_DATE) {
|
||||
fmt = "%02d.%02d.%04d";
|
||||
} else {
|
||||
fmt = "%02d.%02d.%04d %02d:%02d:%02d";
|
||||
}
|
||||
}
|
||||
|
||||
char buf[32];
|
||||
if (source == TextSource::KNX_DPT_TIME) {
|
||||
snprintf(buf, sizeof(buf), fmt, value.tm_hour, value.tm_min, value.tm_sec);
|
||||
} else if (source == TextSource::KNX_DPT_DATE) {
|
||||
snprintf(buf, sizeof(buf), fmt, value.tm_mday, month, year);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), fmt, value.tm_mday, month, year,
|
||||
value.tm_hour, value.tm_min, value.tm_sec);
|
||||
}
|
||||
set_label_text_if_changed(label, buf);
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ public:
|
||||
void onKnxValue(float value) override;
|
||||
void onKnxSwitch(bool value) override;
|
||||
void onKnxText(const char* text) override;
|
||||
void onKnxTime(const struct tm& value, TextSource source) override;
|
||||
|
||||
private:
|
||||
lv_obj_t* container_ = nullptr;
|
||||
|
||||
@ -85,7 +85,6 @@ lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) {
|
||||
lv_obj_set_flex_align(obj_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_all(obj_, 6, 0);
|
||||
lv_obj_set_style_pad_gap(obj_, 2, 0);
|
||||
|
||||
if (labelText_[0] != '\0') {
|
||||
labelLabel_ = lv_label_create(obj_);
|
||||
lv_label_set_text(labelLabel_, labelText_);
|
||||
|
||||
@ -37,6 +37,14 @@ void Widget::onKnxText(const char* /*text*/) {
|
||||
// Default: do nothing
|
||||
}
|
||||
|
||||
void Widget::onKnxTime(const struct tm& /*value*/, TextSource /*source*/) {
|
||||
// Default: do nothing
|
||||
}
|
||||
|
||||
void Widget::onHistoryUpdate() {
|
||||
// Default: do nothing
|
||||
}
|
||||
|
||||
void Widget::applyCommonStyle() {
|
||||
if (obj_ == nullptr) return;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
#include "../WidgetConfig.hpp"
|
||||
#include "lvgl.h"
|
||||
#include <ctime>
|
||||
|
||||
class Widget {
|
||||
public:
|
||||
@ -44,6 +45,8 @@ public:
|
||||
virtual void onKnxValue(float value);
|
||||
virtual void onKnxSwitch(bool value);
|
||||
virtual void onKnxText(const char* text);
|
||||
virtual void onKnxTime(const struct tm& value, TextSource source);
|
||||
virtual void onHistoryUpdate();
|
||||
|
||||
protected:
|
||||
// Common style helper functions
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
#include "PowerFlowWidget.hpp"
|
||||
#include "PowerNodeWidget.hpp"
|
||||
#include "PowerLinkWidget.hpp"
|
||||
#include "ChartWidget.hpp"
|
||||
|
||||
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
||||
if (!config.visible) return nullptr;
|
||||
@ -31,6 +32,8 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
||||
return std::make_unique<PowerNodeWidget>(config);
|
||||
case WidgetType::POWERLINK:
|
||||
return std::make_unique<PowerLinkWidget>(config);
|
||||
case WidgetType::CHART:
|
||||
return std::make_unique<ChartWidget>(config);
|
||||
default:
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@ -481,6 +481,49 @@
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.widget-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.widget-chart .chart-title {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.widget-chart .chart-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
border-radius: 10px;
|
||||
background: rgba(7, 12, 18, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.widget-chart .chart-canvas::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.widget-chart .chart-line {
|
||||
position: absolute;
|
||||
inset: 18% 8%;
|
||||
background: linear-gradient(120deg, rgba(239, 99, 81, 0.0) 0%, rgba(239, 99, 81, 0.6) 45%, rgba(94, 162, 239, 0.8) 70%, rgba(125, 211, 176, 0.9) 100%);
|
||||
border-radius: 999px;
|
||||
height: 2px;
|
||||
top: 45%;
|
||||
}
|
||||
|
||||
.properties h4 {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
@ -519,6 +562,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chart-series {
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px dashed rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.prop-row input[type="checkbox"] {
|
||||
width: auto;
|
||||
flex: none;
|
||||
@ -634,6 +683,10 @@
|
||||
<span class="element-title">LED</span>
|
||||
<span class="element-sub">Status</span>
|
||||
</button>
|
||||
<button class="element-btn" onclick="addWidget('chart')">
|
||||
<span class="element-title">Chart</span>
|
||||
<span class="element-sub">Verlauf</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -710,6 +763,28 @@
|
||||
<input type="number" id="standbyMinutes" min="0" value="5">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="--delay: 0.3s;">
|
||||
<div class="panel-header">
|
||||
<h3>KNX Zeit</h3>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label for="knxTimeAddr">Uhrzeit</label>
|
||||
<select id="knxTimeAddr"></select>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label for="knxDateAddr">Datum</label>
|
||||
<select id="knxDateAddr"></select>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label for="knxDateTimeAddr">Datum+Zeit</label>
|
||||
<select id="knxDateTimeAddr"></select>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label for="knxNightAddr">Nachtmodus</label>
|
||||
<select id="knxNightAddr"></select>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="canvas-area">
|
||||
@ -738,7 +813,8 @@
|
||||
const WIDGET_TYPES = {
|
||||
LABEL: 0,
|
||||
BUTTON: 1,
|
||||
LED: 2
|
||||
LED: 2,
|
||||
CHART: 9
|
||||
};
|
||||
|
||||
const BUTTON_ACTIONS = {
|
||||
@ -750,13 +826,15 @@
|
||||
const TYPE_KEYS = {
|
||||
0: 'label',
|
||||
1: 'button',
|
||||
2: 'led'
|
||||
2: 'led',
|
||||
9: 'chart'
|
||||
};
|
||||
|
||||
const TYPE_LABELS = {
|
||||
label: 'Label',
|
||||
button: 'Button',
|
||||
led: 'LED'
|
||||
led: 'LED',
|
||||
chart: 'Chart'
|
||||
};
|
||||
|
||||
const textSources = {
|
||||
@ -767,7 +845,10 @@
|
||||
4: 'KNX Text',
|
||||
5: 'KNX Leistung (DPT 14.056)',
|
||||
6: 'KNX Energie (DPT 13.013)',
|
||||
7: 'KNX Dezimalfaktor (DPT 5.005)'
|
||||
7: 'KNX Dezimalfaktor (DPT 5.005)',
|
||||
8: 'KNX Uhrzeit (DPT 10.001)',
|
||||
9: 'KNX Datum (DPT 11.001)',
|
||||
10: 'KNX Datum & Uhrzeit (DPT 19.001)'
|
||||
};
|
||||
|
||||
const textSourceGroups = [
|
||||
@ -775,15 +856,19 @@
|
||||
{ label: 'DPT 1.x', values: [2] },
|
||||
{ label: 'DPT 5.x', values: [3, 7] },
|
||||
{ label: 'DPT 9.x', values: [1] },
|
||||
{ label: 'DPT 10.x', values: [8] },
|
||||
{ label: 'DPT 11.x', values: [9] },
|
||||
{ label: 'DPT 13.x', values: [6] },
|
||||
{ label: 'DPT 14.x', values: [5] },
|
||||
{ label: 'DPT 16.x', values: [4] }
|
||||
{ label: 'DPT 16.x', values: [4] },
|
||||
{ label: 'DPT 19.x', values: [10] }
|
||||
];
|
||||
|
||||
const sourceOptions = {
|
||||
label: [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
label: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
button: [0],
|
||||
led: [0, 2]
|
||||
led: [0, 2],
|
||||
chart: [1, 3, 5, 6, 7]
|
||||
};
|
||||
|
||||
const fontSizes = [14, 18, 22, 28, 36, 48];
|
||||
@ -795,7 +880,10 @@
|
||||
4: '%s',
|
||||
5: '%.1f W',
|
||||
6: '%.0f kWh',
|
||||
7: '%d'
|
||||
7: '%d',
|
||||
8: '%02d:%02d:%02d',
|
||||
9: '%02d.%02d.%04d',
|
||||
10: '%02d.%02d.%04d %02d:%02d:%02d'
|
||||
};
|
||||
|
||||
const WIDGET_DEFAULTS = {
|
||||
@ -849,12 +937,36 @@
|
||||
knxAddr: 0,
|
||||
action: BUTTON_ACTIONS.KNX,
|
||||
targetScreen: 0
|
||||
},
|
||||
chart: {
|
||||
w: 320,
|
||||
h: 200,
|
||||
text: 'Chart',
|
||||
textSrc: 0,
|
||||
fontSize: 1,
|
||||
textColor: '#E7EDF3',
|
||||
bgColor: '#16202c',
|
||||
bgOpacity: 255,
|
||||
radius: 12,
|
||||
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
|
||||
isToggle: false,
|
||||
knxAddrWrite: 0,
|
||||
knxAddr: 0,
|
||||
action: BUTTON_ACTIONS.KNX,
|
||||
targetScreen: 0,
|
||||
chart: {
|
||||
period: 0,
|
||||
series: [
|
||||
{ knxAddr: 0, textSrc: 1, color: '#EF6351' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let config = {
|
||||
startScreen: 0,
|
||||
standby: { enabled: false, screen: -1, minutes: 5 },
|
||||
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
|
||||
screens: []
|
||||
};
|
||||
let selectedWidget = null;
|
||||
@ -882,6 +994,7 @@
|
||||
const key = typeKeyFor(widget.type);
|
||||
if (key === 'button') return { w: 60, h: 30 };
|
||||
if (key === 'led') return { w: 20, h: 20 };
|
||||
if (key === 'chart') return { w: 160, h: 120 };
|
||||
return { w: 40, h: 20 };
|
||||
}
|
||||
|
||||
@ -921,6 +1034,36 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (defaults.chart) {
|
||||
const maxSeries = 3;
|
||||
if (!w.chart) {
|
||||
w.chart = {
|
||||
period: defaults.chart.period ?? 0,
|
||||
series: (defaults.chart.series || []).map(s => ({ ...s }))
|
||||
};
|
||||
} else {
|
||||
if (w.chart.period === undefined || w.chart.period === null) {
|
||||
w.chart.period = defaults.chart.period ?? 0;
|
||||
}
|
||||
if (!Array.isArray(w.chart.series)) {
|
||||
w.chart.series = [];
|
||||
}
|
||||
if (w.chart.series.length === 0 && defaults.chart.series) {
|
||||
w.chart.series = defaults.chart.series.map(s => ({ ...s }));
|
||||
}
|
||||
for (let i = 0; i < w.chart.series.length && i < maxSeries; i++) {
|
||||
const fallback = (defaults.chart.series && defaults.chart.series[i]) || defaults.chart.series?.[0] || { knxAddr: 0, textSrc: 1, color: '#EF6351' };
|
||||
const entry = w.chart.series[i];
|
||||
if (entry.knxAddr === undefined || entry.knxAddr === null) entry.knxAddr = fallback.knxAddr;
|
||||
if (entry.textSrc === undefined || entry.textSrc === null) entry.textSrc = fallback.textSrc;
|
||||
if (!entry.color) entry.color = fallback.color;
|
||||
}
|
||||
if (w.chart.series.length > maxSeries) {
|
||||
w.chart.series = w.chart.series.slice(0, maxSeries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (w.visible === undefined || w.visible === null) w.visible = true;
|
||||
if (w.x === undefined || w.x === null) w.x = 100;
|
||||
if (w.y === undefined || w.y === null) w.y = 100;
|
||||
@ -984,8 +1127,40 @@
|
||||
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
|
||||
}
|
||||
}
|
||||
if (w.chart && Array.isArray(w.chart.series)) {
|
||||
w.chart.series.forEach((series) => {
|
||||
if (typeof series.knxAddr === 'number' && series.knxAddr > 0) {
|
||||
if (!gaSet.has(series.knxAddr) && addrByIndex.has(series.knxAddr)) {
|
||||
series.knxAddr = addrByIndex.get(series.knxAddr);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (config.knx) {
|
||||
if (typeof config.knx.time === 'number' && config.knx.time > 0) {
|
||||
if (!gaSet.has(config.knx.time) && addrByIndex.has(config.knx.time)) {
|
||||
config.knx.time = addrByIndex.get(config.knx.time);
|
||||
}
|
||||
}
|
||||
if (typeof config.knx.date === 'number' && config.knx.date > 0) {
|
||||
if (!gaSet.has(config.knx.date) && addrByIndex.has(config.knx.date)) {
|
||||
config.knx.date = addrByIndex.get(config.knx.date);
|
||||
}
|
||||
}
|
||||
if (typeof config.knx.dateTime === 'number' && config.knx.dateTime > 0) {
|
||||
if (!gaSet.has(config.knx.dateTime) && addrByIndex.has(config.knx.dateTime)) {
|
||||
config.knx.dateTime = addrByIndex.get(config.knx.dateTime);
|
||||
}
|
||||
}
|
||||
if (typeof config.knx.night === 'number' && config.knx.night > 0) {
|
||||
if (!gaSet.has(config.knx.night) && addrByIndex.has(config.knx.night)) {
|
||||
config.knx.night = addrByIndex.get(config.knx.night);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateKnxProgButton() {
|
||||
@ -1029,6 +1204,7 @@
|
||||
config = {
|
||||
startScreen: 0,
|
||||
standby: { enabled: false, screen: -1, minutes: 5 },
|
||||
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
|
||||
screens: [
|
||||
{
|
||||
id: 0,
|
||||
@ -1044,6 +1220,14 @@
|
||||
if (!config.standby) {
|
||||
config.standby = { enabled: false, screen: -1, minutes: 5 };
|
||||
}
|
||||
if (!config.knx) {
|
||||
config.knx = { time: 0, date: 0, dateTime: 0, night: 0 };
|
||||
} else {
|
||||
if (config.knx.time === undefined) config.knx.time = 0;
|
||||
if (config.knx.date === undefined) config.knx.date = 0;
|
||||
if (config.knx.dateTime === undefined) config.knx.dateTime = 0;
|
||||
if (config.knx.night === undefined) config.knx.night = 0;
|
||||
}
|
||||
|
||||
nextWidgetId = 0;
|
||||
nextScreenId = 0;
|
||||
@ -1085,6 +1269,7 @@
|
||||
renderScreenList();
|
||||
renderScreenSettings();
|
||||
renderNavSettings();
|
||||
renderKnxSettings();
|
||||
renderCanvas();
|
||||
renderTree();
|
||||
renderProperties();
|
||||
@ -1158,6 +1343,30 @@
|
||||
standbySelect.value = config.standby.screen ?? -1;
|
||||
}
|
||||
|
||||
function renderKnxSettings() {
|
||||
const timeSelect = document.getElementById('knxTimeAddr');
|
||||
const dateSelect = document.getElementById('knxDateAddr');
|
||||
const dateTimeSelect = document.getElementById('knxDateTimeAddr');
|
||||
const nightSelect = document.getElementById('knxNightAddr');
|
||||
if (!timeSelect || !dateSelect || !dateTimeSelect || !nightSelect) return;
|
||||
|
||||
const options = ['<option value=\"0\">-- Keine --</option>'];
|
||||
knxAddresses.forEach((a) => {
|
||||
options.push(`<option value=\"${a.addr}\">GA ${a.addrStr} (GO${a.index})</option>`);
|
||||
});
|
||||
const html = options.join('');
|
||||
|
||||
timeSelect.innerHTML = html;
|
||||
dateSelect.innerHTML = html;
|
||||
dateTimeSelect.innerHTML = html;
|
||||
nightSelect.innerHTML = html;
|
||||
|
||||
timeSelect.value = config.knx?.time ?? 0;
|
||||
dateSelect.value = config.knx?.date ?? 0;
|
||||
dateTimeSelect.value = config.knx?.dateTime ?? 0;
|
||||
nightSelect.value = config.knx?.night ?? 0;
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
const canvas = document.getElementById('canvas');
|
||||
const screen = getActiveScreen();
|
||||
@ -1221,6 +1430,34 @@
|
||||
} else {
|
||||
el.style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
|
||||
}
|
||||
} else if (w.type === WIDGET_TYPES.CHART) {
|
||||
el.className += ' widget-chart';
|
||||
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1).toFixed(2);
|
||||
el.style.background = hexToRgba(w.bgColor, alpha);
|
||||
el.style.borderRadius = (w.radius * canvasScale) + 'px';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'chart-title';
|
||||
title.style.color = w.textColor;
|
||||
title.textContent = w.text || 'Chart';
|
||||
|
||||
const chartCanvas = document.createElement('div');
|
||||
chartCanvas.className = 'chart-canvas';
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = 'chart-line';
|
||||
chartCanvas.appendChild(line);
|
||||
|
||||
el.appendChild(title);
|
||||
el.appendChild(chartCanvas);
|
||||
|
||||
if (w.shadow && w.shadow.enabled) {
|
||||
const sx = (w.shadow.x || 0) * canvasScale;
|
||||
const sy = (w.shadow.y || 0) * canvasScale;
|
||||
const blur = (w.shadow.blur || 0) * canvasScale;
|
||||
const spread = (w.shadow.spread || 0) * canvasScale;
|
||||
el.style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedWidget === w.id) {
|
||||
@ -1381,6 +1618,64 @@
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
} else if (key === 'chart') {
|
||||
const chartConfig = w.chart || { period: 0, series: [] };
|
||||
const chartSeries = (Array.isArray(chartConfig.series) && chartConfig.series.length)
|
||||
? chartConfig.series
|
||||
: [{ knxAddr: 0, textSrc: 1, color: '#EF6351' }];
|
||||
const seriesCount = Math.max(1, Math.min(chartSeries.length, 3));
|
||||
const period = chartConfig.period ?? 0;
|
||||
const periodOptions = [
|
||||
{ value: 0, label: '1h' },
|
||||
{ value: 1, label: '3h' },
|
||||
{ value: 2, label: '5h' },
|
||||
{ value: 3, label: '12h' },
|
||||
{ value: 4, label: '24h' },
|
||||
{ value: 5, label: '1 Monat' }
|
||||
].map((opt) => `<option value="${opt.value}" ${period == opt.value ? 'selected' : ''}>${opt.label}</option>`).join('');
|
||||
|
||||
const seriesHtml = chartSeries.slice(0, seriesCount).map((series, idx) => {
|
||||
const srcOptions = buildTextSourceOptions(sourceOptions.chart, series.textSrc ?? 1);
|
||||
const knxSeriesOptions = knxAddresses.map((a) =>
|
||||
`<option value="${a.addr}" ${series.knxAddr == a.addr ? 'selected' : ''}>GA ${a.addrStr} (GO${a.index})</option>`
|
||||
).join('');
|
||||
return `
|
||||
<div class="chart-series">
|
||||
<div class="prop-row"><label>Serie ${idx + 1}</label>
|
||||
<input type="color" id="pchartcolor${idx}" value="${series.color || '#EF6351'}" onchange="updateChartSeries(${idx}, 'color')">
|
||||
</div>
|
||||
<div class="prop-row"><label>Quelle</label>
|
||||
<select id="pchartsrc${idx}" onchange="updateChartSeries(${idx}, 'textSrc')">
|
||||
${srcOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row"><label>KNX Objekt</label>
|
||||
<select id="pchartaddr${idx}" onchange="updateChartSeries(${idx}, 'knxAddr')">
|
||||
<option value="0" ${series.knxAddr == 0 ? 'selected' : ''}>-- Waehlen --</option>
|
||||
${knxSeriesOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
contentSection = `
|
||||
<h4>Chart</h4>
|
||||
<div class="prop-row"><label>Titel</label><input type="text" id="ptext" value="${w.text}" onchange="updateProp('text')"></div>
|
||||
<div class="prop-row"><label>Zeitraum</label>
|
||||
<select id="pchartperiod" onchange="updateChartPeriod()">
|
||||
${periodOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="prop-row"><label>Serien</label>
|
||||
<select id="pchartcount" onchange="updateChartCount()">
|
||||
<option value="1" ${seriesCount === 1 ? 'selected' : ''}>1</option>
|
||||
<option value="2" ${seriesCount === 2 ? 'selected' : ''}>2</option>
|
||||
<option value="3" ${seriesCount === 3 ? 'selected' : ''}>3</option>
|
||||
</select>
|
||||
</div>
|
||||
${seriesHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
let typographySection = '';
|
||||
@ -1545,6 +1840,50 @@
|
||||
renderTree();
|
||||
}
|
||||
|
||||
function updateChartPeriod() {
|
||||
const screen = getActiveScreen();
|
||||
const w = screen.widgets.find(x => x.id === selectedWidget);
|
||||
if (!w || !w.chart) return;
|
||||
w.chart.period = parseInt(document.getElementById('pchartperiod').value, 10);
|
||||
}
|
||||
|
||||
function updateChartCount() {
|
||||
const screen = getActiveScreen();
|
||||
const w = screen.widgets.find(x => x.id === selectedWidget);
|
||||
if (!w || !w.chart) return;
|
||||
const desired = parseInt(document.getElementById('pchartcount').value, 10);
|
||||
const colors = ['#EF6351', '#7DD3B0', '#5EA2EF'];
|
||||
if (!Array.isArray(w.chart.series)) w.chart.series = [];
|
||||
while (w.chart.series.length < desired && w.chart.series.length < 3) {
|
||||
const idx = w.chart.series.length;
|
||||
w.chart.series.push({ knxAddr: 0, textSrc: 1, color: colors[idx] || '#EF6351' });
|
||||
}
|
||||
if (w.chart.series.length > desired) {
|
||||
w.chart.series = w.chart.series.slice(0, desired);
|
||||
}
|
||||
renderProperties();
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
function updateChartSeries(idx, prop) {
|
||||
const screen = getActiveScreen();
|
||||
const w = screen.widgets.find(x => x.id === selectedWidget);
|
||||
if (!w || !w.chart || !Array.isArray(w.chart.series)) return;
|
||||
const series = w.chart.series[idx];
|
||||
if (!series) return;
|
||||
|
||||
if (prop === 'color') {
|
||||
series.color = document.getElementById(`pchartcolor${idx}`).value;
|
||||
} else if (prop === 'textSrc') {
|
||||
series.textSrc = parseInt(document.getElementById(`pchartsrc${idx}`).value, 10);
|
||||
} else if (prop === 'knxAddr') {
|
||||
series.knxAddr = parseInt(document.getElementById(`pchartaddr${idx}`).value, 10);
|
||||
}
|
||||
|
||||
renderCanvas();
|
||||
renderTree();
|
||||
}
|
||||
|
||||
function updateBgColor() {
|
||||
const screen = getActiveScreen();
|
||||
screen.bgColor = document.getElementById('bgColor').value;
|
||||
@ -1554,7 +1893,10 @@
|
||||
function addWidget(type) {
|
||||
const screen = getActiveScreen();
|
||||
const defaults = WIDGET_DEFAULTS[type];
|
||||
const typeValue = type === 'label' ? WIDGET_TYPES.LABEL : (type === 'button' ? WIDGET_TYPES.BUTTON : WIDGET_TYPES.LED);
|
||||
let typeValue = WIDGET_TYPES.LABEL;
|
||||
if (type === 'button') typeValue = WIDGET_TYPES.BUTTON;
|
||||
else if (type === 'led') typeValue = WIDGET_TYPES.LED;
|
||||
else if (type === 'chart') typeValue = WIDGET_TYPES.CHART;
|
||||
|
||||
const w = {
|
||||
id: nextWidgetId++,
|
||||
@ -1579,6 +1921,13 @@
|
||||
targetScreen: defaults.targetScreen
|
||||
};
|
||||
|
||||
if (defaults.chart) {
|
||||
w.chart = {
|
||||
period: defaults.chart.period ?? 0,
|
||||
series: (defaults.chart.series || []).map(s => ({ ...s }))
|
||||
};
|
||||
}
|
||||
|
||||
screen.widgets.push(w);
|
||||
selectWidget(w.id);
|
||||
}
|
||||
@ -1927,6 +2276,22 @@
|
||||
config.standby.minutes = Number.isNaN(val) ? 0 : val;
|
||||
});
|
||||
|
||||
document.getElementById('knxTimeAddr').addEventListener('change', (e) => {
|
||||
config.knx.time = parseInt(e.target.value, 10) || 0;
|
||||
});
|
||||
|
||||
document.getElementById('knxDateAddr').addEventListener('change', (e) => {
|
||||
config.knx.date = parseInt(e.target.value, 10) || 0;
|
||||
});
|
||||
|
||||
document.getElementById('knxDateTimeAddr').addEventListener('change', (e) => {
|
||||
config.knx.dateTime = parseInt(e.target.value, 10) || 0;
|
||||
});
|
||||
|
||||
document.getElementById('knxNightAddr').addEventListener('change', (e) => {
|
||||
config.knx.night = parseInt(e.target.value, 10) || 0;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => document.body.classList.add('loaded'));
|
||||
loadKnxProgMode();
|
||||
loadConfig();
|
||||
|
||||
@ -30,6 +30,45 @@
|
||||
<input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" min="0" v-model.number="store.config.standby.minutes">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="text-[12px] uppercase tracking-[0.08em] text-[#3a5f88]">KNX Zeit</div>
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<label class="text-[12px] text-muted">Uhrzeit</label>
|
||||
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.knx.time">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`time-${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<label class="text-[12px] text-muted">Datum</label>
|
||||
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.knx.date">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`date-${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<label class="text-[12px] text-muted">Datum+Zeit</label>
|
||||
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.knx.dateTime">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`dt-${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<label class="text-[12px] text-muted">Nachtmodus</label>
|
||||
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.knx.night">
|
||||
<option :value="0">-- Keine --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`night-${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-[18px] py-3 border-t border-border flex justify-end gap-2.5">
|
||||
<button class="border border-border bg-panel-2 text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#e4ebf2] active:translate-y-0.5" @click="emit('close')">Schliessen</button>
|
||||
|
||||
@ -34,6 +34,10 @@
|
||||
<span class="text-[13px] font-semibold">Power Node</span>
|
||||
<span class="text-[11px] text-muted mt-0.5 block">Element</span>
|
||||
</button>
|
||||
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('chart')">
|
||||
<span class="text-[13px] font-semibold">Chart</span>
|
||||
<span class="text-[11px] text-muted mt-0.5 block">Verlauf</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@ -208,6 +208,46 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="key === 'chart'">
|
||||
<h4 :class="headingClass">Chart</h4>
|
||||
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="w.text"></div>
|
||||
<div :class="rowClass"><label :class="labelClass">Zeitraum</label>
|
||||
<select :class="inputClass" v-model.number="w.chart.period">
|
||||
<option v-for="opt in chartPeriods" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div :class="rowClass"><label :class="labelClass">Serien</label>
|
||||
<select :class="inputClass" v-model.number="chartSeriesCount">
|
||||
<option :value="1">1</option>
|
||||
<option :value="2">2</option>
|
||||
<option :value="3">3</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-for="(series, idx) in chartSeries" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">Serie {{ idx + 1 }}</label>
|
||||
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="series.color">
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">Quelle</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="series.textSrc">
|
||||
<optgroup v-for="group in groupedSources(sourceOptions.chart)" :key="group.label" :label="group.label">
|
||||
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-muted">
|
||||
<label class="w-[70px] text-[11px] text-muted">KNX</label>
|
||||
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="series.knxAddr">
|
||||
<option :value="0">-- Waehlen --</option>
|
||||
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
|
||||
GA {{ addr.addrStr }} (GO{{ addr.index }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Typography -->
|
||||
<template v-if="key === 'label'">
|
||||
<h4 :class="headingClass">Typo</h4>
|
||||
@ -379,7 +419,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useEditorStore } from '../stores/editor';
|
||||
import { typeKeyFor } from '../utils';
|
||||
import { sourceOptions, textSources, textSourceGroups, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants';
|
||||
import { sourceOptions, textSources, textSourceGroups, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES, chartPeriods } from '../constants';
|
||||
import IconPicker from './IconPicker.vue';
|
||||
|
||||
const store = useEditorStore();
|
||||
@ -445,6 +485,33 @@ const powerNodeValue = computed({
|
||||
}
|
||||
});
|
||||
|
||||
const chartSeries = computed(() => w.value?.chart?.series ?? []);
|
||||
const chartSeriesCount = computed({
|
||||
get() {
|
||||
const count = chartSeries.value.length || 1;
|
||||
return Math.max(1, Math.min(count, 3));
|
||||
},
|
||||
set(value) {
|
||||
if (!w.value || !w.value.chart) return;
|
||||
const target = Math.max(1, Math.min(value, 3));
|
||||
const colors = ['#EF6351', '#7DD3B0', '#5EA2EF'];
|
||||
if (!Array.isArray(w.value.chart.series)) {
|
||||
w.value.chart.series = [];
|
||||
}
|
||||
while (w.value.chart.series.length < target) {
|
||||
const idx = w.value.chart.series.length;
|
||||
w.value.chart.series.push({
|
||||
knxAddr: 0,
|
||||
textSrc: 1,
|
||||
color: colors[idx] || '#EF6351'
|
||||
});
|
||||
}
|
||||
if (w.value.chart.series.length > target) {
|
||||
w.value.chart.series = w.value.chart.series.slice(0, target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
||||
const rowClass = 'flex items-center gap-2.5 mb-2';
|
||||
const labelClass = 'w-[90px] text-[12px] text-muted';
|
||||
|
||||
@ -110,6 +110,21 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isChart">
|
||||
<div class="w-full h-full flex flex-col gap-2">
|
||||
<div class="text-[11px] uppercase tracking-[0.12em] opacity-80">
|
||||
{{ widget.text || 'Chart' }}
|
||||
</div>
|
||||
<div class="flex-1 rounded-[10px] bg-black/20 relative overflow-hidden">
|
||||
<div class="absolute inset-0 opacity-30" style="background-image: linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px); background-size: 24px 24px;"></div>
|
||||
<svg class="absolute inset-0" viewBox="0 0 100 40" preserveAspectRatio="none">
|
||||
<path d="M0,30 L15,22 L30,26 L45,14 L60,18 L75,10 L100,16" fill="none" stroke="rgba(239,99,81,0.8)" stroke-width="2" />
|
||||
<path d="M0,34 L20,28 L40,32 L60,20 L80,24 L100,18" fill="none" stroke="rgba(125,211,176,0.8)" stroke-width="2" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Icon-only Widget -->
|
||||
<template v-else-if="isIcon">
|
||||
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
|
||||
@ -183,6 +198,7 @@ const isButtonContainer = computed(() => isButton.value && props.widget.isContai
|
||||
const isPowerFlow = computed(() => props.widget.type === WIDGET_TYPES.POWERFLOW);
|
||||
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
|
||||
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
|
||||
const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
|
||||
|
||||
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
|
||||
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
||||
|
||||
@ -11,7 +11,8 @@ export const WIDGET_TYPES = {
|
||||
TABPAGE: 5,
|
||||
POWERFLOW: 6,
|
||||
POWERNODE: 7,
|
||||
POWERLINK: 8
|
||||
POWERLINK: 8,
|
||||
CHART: 9
|
||||
};
|
||||
|
||||
export const ICON_POSITIONS = {
|
||||
@ -42,7 +43,8 @@ export const TYPE_KEYS = {
|
||||
5: 'tabpage',
|
||||
6: 'powerflow',
|
||||
7: 'powernode',
|
||||
8: 'powerlink'
|
||||
8: 'powerlink',
|
||||
9: 'chart'
|
||||
};
|
||||
|
||||
export const TYPE_LABELS = {
|
||||
@ -54,7 +56,8 @@ export const TYPE_LABELS = {
|
||||
tabpage: 'Seite',
|
||||
powerflow: 'Power Flow',
|
||||
powernode: 'Power Node',
|
||||
powerlink: 'Power Link'
|
||||
powerlink: 'Power Link',
|
||||
chart: 'Chart'
|
||||
};
|
||||
|
||||
|
||||
@ -66,7 +69,10 @@ export const textSources = {
|
||||
4: 'KNX Text',
|
||||
5: 'KNX Leistung (DPT 14.056)',
|
||||
6: 'KNX Energie (DPT 13.013)',
|
||||
7: 'KNX Dezimalfaktor (DPT 5.005)'
|
||||
7: 'KNX Dezimalfaktor (DPT 5.005)',
|
||||
8: 'KNX Uhrzeit (DPT 10.001)',
|
||||
9: 'KNX Datum (DPT 11.001)',
|
||||
10: 'KNX Datum & Uhrzeit (DPT 19.001)'
|
||||
};
|
||||
|
||||
export const textSourceGroups = [
|
||||
@ -74,20 +80,33 @@ export const textSourceGroups = [
|
||||
{ label: 'DPT 1.x', values: [2] },
|
||||
{ label: 'DPT 5.x', values: [3, 7] },
|
||||
{ label: 'DPT 9.x', values: [1] },
|
||||
{ label: 'DPT 10.x', values: [8] },
|
||||
{ label: 'DPT 11.x', values: [9] },
|
||||
{ label: 'DPT 13.x', values: [6] },
|
||||
{ label: 'DPT 14.x', values: [5] },
|
||||
{ label: 'DPT 16.x', values: [4] }
|
||||
{ label: 'DPT 16.x', values: [4] },
|
||||
{ label: 'DPT 19.x', values: [10] }
|
||||
];
|
||||
|
||||
export const sourceOptions = {
|
||||
label: [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
label: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
button: [0],
|
||||
led: [0, 2],
|
||||
icon: [0, 2],
|
||||
powernode: [0, 1, 2, 3, 4, 5, 6, 7],
|
||||
powerlink: [0, 1, 3, 5, 6, 7]
|
||||
powerlink: [0, 1, 3, 5, 6, 7],
|
||||
chart: [1, 3, 5, 6, 7]
|
||||
};
|
||||
|
||||
export const chartPeriods = [
|
||||
{ value: 0, label: '1h' },
|
||||
{ value: 1, label: '3h' },
|
||||
{ value: 2, label: '5h' },
|
||||
{ value: 3, label: '12h' },
|
||||
{ value: 4, label: '24h' },
|
||||
{ value: 5, label: '1 Monat' }
|
||||
];
|
||||
|
||||
export const ICON_DEFAULTS = {
|
||||
iconCodepoint: 0,
|
||||
iconPosition: 0,
|
||||
@ -104,7 +123,10 @@ export const defaultFormats = {
|
||||
4: '%s',
|
||||
5: '%.1f W',
|
||||
6: '%.0f kWh',
|
||||
7: '%d'
|
||||
7: '%d',
|
||||
8: '%02d:%02d:%02d',
|
||||
9: '%02d.%02d.%04d',
|
||||
10: '%02d.%02d.%04d %02d:%02d:%02d'
|
||||
};
|
||||
|
||||
export const WIDGET_DEFAULTS = {
|
||||
@ -306,5 +328,33 @@ export const WIDGET_DEFAULTS = {
|
||||
iconPosition: 0,
|
||||
iconSize: 0,
|
||||
iconGap: 6
|
||||
},
|
||||
chart: {
|
||||
w: 360,
|
||||
h: 220,
|
||||
text: 'Chart',
|
||||
textSrc: 0,
|
||||
fontSize: 1,
|
||||
textAlign: TEXT_ALIGNS.LEFT,
|
||||
textColor: '#E7EDF3',
|
||||
bgColor: '#16202c',
|
||||
bgOpacity: 255,
|
||||
radius: 12,
|
||||
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
|
||||
isToggle: false,
|
||||
knxAddrWrite: 0,
|
||||
knxAddr: 0,
|
||||
action: 0,
|
||||
targetScreen: 0,
|
||||
iconCodepoint: 0,
|
||||
iconPosition: 0,
|
||||
iconSize: 1,
|
||||
iconGap: 0,
|
||||
chart: {
|
||||
period: 0,
|
||||
series: [
|
||||
{ knxAddr: 0, textSrc: 1, color: '#EF6351' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -7,6 +7,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
const config = reactive({
|
||||
startScreen: 0,
|
||||
standby: { enabled: false, screen: -1, minutes: 5 },
|
||||
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
|
||||
screens: []
|
||||
});
|
||||
|
||||
@ -46,8 +47,40 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
|
||||
}
|
||||
}
|
||||
if (w.chart && Array.isArray(w.chart.series)) {
|
||||
w.chart.series.forEach((series) => {
|
||||
if (typeof series.knxAddr === 'number' && series.knxAddr > 0) {
|
||||
if (!gaSet.has(series.knxAddr) && addrByIndex.has(series.knxAddr)) {
|
||||
series.knxAddr = addrByIndex.get(series.knxAddr);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (config.knx) {
|
||||
if (typeof config.knx.time === 'number' && config.knx.time > 0) {
|
||||
if (!gaSet.has(config.knx.time) && addrByIndex.has(config.knx.time)) {
|
||||
config.knx.time = addrByIndex.get(config.knx.time);
|
||||
}
|
||||
}
|
||||
if (typeof config.knx.date === 'number' && config.knx.date > 0) {
|
||||
if (!gaSet.has(config.knx.date) && addrByIndex.has(config.knx.date)) {
|
||||
config.knx.date = addrByIndex.get(config.knx.date);
|
||||
}
|
||||
}
|
||||
if (typeof config.knx.dateTime === 'number' && config.knx.dateTime > 0) {
|
||||
if (!gaSet.has(config.knx.dateTime) && addrByIndex.has(config.knx.dateTime)) {
|
||||
config.knx.dateTime = addrByIndex.get(config.knx.dateTime);
|
||||
}
|
||||
}
|
||||
if (typeof config.knx.night === 'number' && config.knx.night > 0) {
|
||||
if (!gaSet.has(config.knx.night) && addrByIndex.has(config.knx.night)) {
|
||||
config.knx.night = addrByIndex.get(config.knx.night);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeScreen = computed(() => {
|
||||
@ -176,11 +209,20 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
];
|
||||
config.startScreen = 0;
|
||||
config.standby = { enabled: false, screen: -1, minutes: 5 };
|
||||
config.knx = { time: 0, date: 0, dateTime: 0, night: 0 };
|
||||
}
|
||||
|
||||
if (!config.standby) {
|
||||
config.standby = { enabled: false, screen: -1, minutes: 5 };
|
||||
}
|
||||
if (!config.knx) {
|
||||
config.knx = { time: 0, date: 0, dateTime: 0, night: 0 };
|
||||
} else {
|
||||
if (config.knx.time === undefined) config.knx.time = 0;
|
||||
if (config.knx.date === undefined) config.knx.date = 0;
|
||||
if (config.knx.dateTime === undefined) config.knx.dateTime = 0;
|
||||
if (config.knx.night === undefined) config.knx.night = 0;
|
||||
}
|
||||
mapLegacyKnxAddresses();
|
||||
|
||||
// Recalculate IDs
|
||||
@ -277,6 +319,7 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
case 'powerflow': typeValue = WIDGET_TYPES.POWERFLOW; break;
|
||||
case 'powernode': typeValue = WIDGET_TYPES.POWERNODE; break;
|
||||
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
|
||||
case 'chart': typeValue = WIDGET_TYPES.CHART; break;
|
||||
default: typeValue = WIDGET_TYPES.LABEL;
|
||||
}
|
||||
|
||||
@ -352,6 +395,13 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
iconSize: defaults.iconSize || 1,
|
||||
iconGap: defaults.iconGap || 8
|
||||
};
|
||||
|
||||
if (defaults.chart) {
|
||||
w.chart = {
|
||||
period: defaults.chart.period ?? 0,
|
||||
series: (defaults.chart.series || []).map(s => ({ ...s }))
|
||||
};
|
||||
}
|
||||
|
||||
activeScreen.value.widgets.push(w);
|
||||
selectedWidgetId.value = w.id;
|
||||
|
||||
@ -18,6 +18,7 @@ export function minSizeFor(widget) {
|
||||
if (key === 'powerflow') return { w: 240, h: 180 };
|
||||
if (key === 'powernode') return { w: 70, h: 70 };
|
||||
if (key === 'powerlink') return { w: 1, h: 1 };
|
||||
if (key === 'chart') return { w: 160, h: 120 };
|
||||
return { w: 40, h: 20 };
|
||||
}
|
||||
|
||||
@ -49,6 +50,36 @@ export function normalizeWidget(w, nextWidgetIdRef) {
|
||||
});
|
||||
}
|
||||
|
||||
if (defaults.chart) {
|
||||
const maxSeries = 3;
|
||||
if (!w.chart) {
|
||||
w.chart = {
|
||||
period: defaults.chart.period ?? 0,
|
||||
series: (defaults.chart.series || []).map(s => ({ ...s }))
|
||||
};
|
||||
} else {
|
||||
if (w.chart.period === undefined || w.chart.period === null) {
|
||||
w.chart.period = defaults.chart.period ?? 0;
|
||||
}
|
||||
if (!Array.isArray(w.chart.series)) {
|
||||
w.chart.series = [];
|
||||
}
|
||||
if (w.chart.series.length === 0 && defaults.chart.series) {
|
||||
w.chart.series = defaults.chart.series.map(s => ({ ...s }));
|
||||
}
|
||||
for (let i = 0; i < w.chart.series.length && i < maxSeries; i++) {
|
||||
const fallback = (defaults.chart.series && defaults.chart.series[i]) || defaults.chart.series?.[0] || { knxAddr: 0, textSrc: 1, color: '#EF6351' };
|
||||
const entry = w.chart.series[i];
|
||||
if (entry.knxAddr === undefined || entry.knxAddr === null) entry.knxAddr = fallback.knxAddr;
|
||||
if (entry.textSrc === undefined || entry.textSrc === null) entry.textSrc = fallback.textSrc;
|
||||
if (!entry.color) entry.color = fallback.color;
|
||||
}
|
||||
if (w.chart.series.length > maxSeries) {
|
||||
w.chart.series = w.chart.series.slice(0, maxSeries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (w.visible === undefined || w.visible === null) w.visible = true;
|
||||
if (w.x === undefined || w.x === null) w.x = 100;
|
||||
if (w.y === undefined || w.y === null) w.y = 100;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user