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/Widget.cpp"
|
||||||
"widgets/LabelWidget.cpp"
|
"widgets/LabelWidget.cpp"
|
||||||
"widgets/ButtonWidget.cpp"
|
"widgets/ButtonWidget.cpp"
|
||||||
@ -10,6 +10,7 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "
|
|||||||
"widgets/PowerFlowWidget.cpp"
|
"widgets/PowerFlowWidget.cpp"
|
||||||
"widgets/PowerNodeWidget.cpp"
|
"widgets/PowerNodeWidget.cpp"
|
||||||
"widgets/PowerLinkWidget.cpp"
|
"widgets/PowerLinkWidget.cpp"
|
||||||
|
"widgets/ChartWidget.cpp"
|
||||||
"webserver/WebServer.cpp"
|
"webserver/WebServer.cpp"
|
||||||
"webserver/StaticFileHandlers.cpp"
|
"webserver/StaticFileHandlers.cpp"
|
||||||
"webserver/ConfigHandlers.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 {
|
namespace {
|
||||||
constexpr char kKnxNvsNamespace[] = "knx";
|
constexpr char kKnxNvsNamespace[] = "knx";
|
||||||
constexpr char kKnxSerialKey[] = "serial_bau";
|
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;
|
constexpr uint16_t kKnxHardwareVersion = 1;
|
||||||
|
|
||||||
bool loadKnxBauNumber(uint32_t& outValue) {
|
bool loadKnxBauNumber(uint32_t& outValue) {
|
||||||
@ -153,6 +153,27 @@ void KnxWorker::init() {
|
|||||||
TextSource::KNX_DPT_ENERGY);
|
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 = "";
|
KNXValue textValue = "";
|
||||||
if (go.tryValue(textValue, DPT_String_8859_1) ||
|
if (go.tryValue(textValue, DPT_String_8859_1) ||
|
||||||
go.tryValue(textValue, DPT_String_ASCII)) {
|
go.tryValue(textValue, DPT_String_ASCII)) {
|
||||||
|
|||||||
@ -50,6 +50,22 @@ void WidgetConfig::serialize(uint8_t* buf) const {
|
|||||||
|
|
||||||
// Hierarchy
|
// Hierarchy
|
||||||
buf[pos++] = static_cast<uint8_t>(parentId);
|
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) {
|
void WidgetConfig::deserialize(const uint8_t* buf) {
|
||||||
@ -98,6 +114,22 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
|
|||||||
|
|
||||||
// Hierarchy
|
// Hierarchy
|
||||||
parentId = static_cast<int8_t>(buf[pos++]);
|
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) {
|
WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const char* labelText) {
|
||||||
@ -244,6 +276,10 @@ void GuiConfig::clear() {
|
|||||||
standbyEnabled = false;
|
standbyEnabled = false;
|
||||||
standbyScreenId = 0xFF;
|
standbyScreenId = 0xFF;
|
||||||
standbyMinutes = 0;
|
standbyMinutes = 0;
|
||||||
|
knxTimeAddress = 0;
|
||||||
|
knxDateAddress = 0;
|
||||||
|
knxDateTimeAddress = 0;
|
||||||
|
knxNightModeAddress = 0;
|
||||||
for (size_t i = 0; i < MAX_SCREENS; i++) {
|
for (size_t i = 0; i < MAX_SCREENS; i++) {
|
||||||
screens[i].clear(static_cast<uint8_t>(i), nullptr);
|
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_SCREENS = 8;
|
||||||
static constexpr size_t MAX_TEXT_LEN = 32;
|
static constexpr size_t MAX_TEXT_LEN = 32;
|
||||||
static constexpr size_t MAX_SCREEN_NAME_LEN = 24;
|
static constexpr size_t MAX_SCREEN_NAME_LEN = 24;
|
||||||
|
static constexpr size_t CHART_MAX_SERIES = 3;
|
||||||
|
|
||||||
enum class WidgetType : uint8_t {
|
enum class WidgetType : uint8_t {
|
||||||
LABEL = 0,
|
LABEL = 0,
|
||||||
@ -20,6 +21,7 @@ enum class WidgetType : uint8_t {
|
|||||||
POWERFLOW = 6,
|
POWERFLOW = 6,
|
||||||
POWERNODE = 7,
|
POWERNODE = 7,
|
||||||
POWERLINK = 8,
|
POWERLINK = 8,
|
||||||
|
CHART = 9,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class IconPosition : uint8_t {
|
enum class IconPosition : uint8_t {
|
||||||
@ -40,6 +42,15 @@ enum class ButtonAction : uint8_t {
|
|||||||
BACK = 2,
|
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
|
// Text source: static text or KNX group address
|
||||||
enum class TextSource : uint8_t {
|
enum class TextSource : uint8_t {
|
||||||
STATIC = 0, // Static text
|
STATIC = 0, // Static text
|
||||||
@ -50,6 +61,9 @@ enum class TextSource : uint8_t {
|
|||||||
KNX_DPT_POWER = 5, // KNX Power (DPT 14.056)
|
KNX_DPT_POWER = 5, // KNX Power (DPT 14.056)
|
||||||
KNX_DPT_ENERGY = 6, // KNX Energy (DPT 13.013)
|
KNX_DPT_ENERGY = 6, // KNX Energy (DPT 13.013)
|
||||||
KNX_DPT_DECIMALFACTOR = 7, // KNX Decimal Factor (DPT 5.005)
|
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 {
|
enum class TextAlign : uint8_t {
|
||||||
@ -126,8 +140,15 @@ struct WidgetConfig {
|
|||||||
// Hierarchy
|
// Hierarchy
|
||||||
int8_t parentId; // ID of parent widget (-1 = root/screen)
|
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)
|
// 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 serialize(uint8_t* buf) const;
|
||||||
void deserialize(const uint8_t* buf);
|
void deserialize(const uint8_t* buf);
|
||||||
@ -175,6 +196,10 @@ struct GuiConfig {
|
|||||||
bool standbyEnabled;
|
bool standbyEnabled;
|
||||||
uint8_t standbyScreenId;
|
uint8_t standbyScreenId;
|
||||||
uint16_t standbyMinutes;
|
uint16_t standbyMinutes;
|
||||||
|
uint16_t knxTimeAddress;
|
||||||
|
uint16_t knxDateAddress;
|
||||||
|
uint16_t knxDateTimeAddress;
|
||||||
|
uint16_t knxNightModeAddress;
|
||||||
|
|
||||||
void clear();
|
void clear();
|
||||||
ScreenConfig* findScreen(uint8_t id);
|
ScreenConfig* findScreen(uint8_t id);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#include "WidgetManager.hpp"
|
#include "WidgetManager.hpp"
|
||||||
#include "widgets/WidgetFactory.hpp"
|
#include "widgets/WidgetFactory.hpp"
|
||||||
|
#include "HistoryStore.hpp"
|
||||||
#include "SdCard.hpp"
|
#include "SdCard.hpp"
|
||||||
#include "esp_lv_adapter.h"
|
#include "esp_lv_adapter.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
@ -170,6 +171,8 @@ void WidgetManager::createDefaultConfig() {
|
|||||||
|
|
||||||
void WidgetManager::init() {
|
void WidgetManager::init() {
|
||||||
loadFromSdCard();
|
loadFromSdCard();
|
||||||
|
HistoryStore::instance().configureFromConfig(config_);
|
||||||
|
HistoryStore::instance().loadFromSdCard();
|
||||||
if (config_.findScreen(config_.startScreenId)) {
|
if (config_.findScreen(config_.startScreenId)) {
|
||||||
activeScreenId_ = config_.startScreenId;
|
activeScreenId_ = config_.startScreenId;
|
||||||
} else if (config_.screenCount > 0) {
|
} else if (config_.screenCount > 0) {
|
||||||
@ -257,6 +260,7 @@ void WidgetManager::saveToSdCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::applyConfig() {
|
void WidgetManager::applyConfig() {
|
||||||
|
HistoryStore::instance().configureFromConfig(config_);
|
||||||
if (!config_.findScreen(activeScreenId_)) {
|
if (!config_.findScreen(activeScreenId_)) {
|
||||||
if (config_.findScreen(config_.startScreenId)) {
|
if (config_.findScreen(config_.startScreenId)) {
|
||||||
activeScreenId_ = config_.startScreenId;
|
activeScreenId_ = config_.startScreenId;
|
||||||
@ -665,6 +669,10 @@ void WidgetManager::loop() {
|
|||||||
|
|
||||||
processUiQueue();
|
processUiQueue();
|
||||||
|
|
||||||
|
if (HistoryStore::instance().tick()) {
|
||||||
|
refreshChartWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
if (didUiNav) return;
|
if (didUiNav) return;
|
||||||
|
|
||||||
if (!config_.standbyEnabled || config_.standbyMinutes == 0) return;
|
if (!config_.standbyEnabled || config_.standbyMinutes == 0) return;
|
||||||
@ -805,6 +813,16 @@ void WidgetManager::onKnxText(uint16_t groupAddr, const char* text) {
|
|||||||
enqueueUiEvent(event);
|
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) {
|
bool WidgetManager::enqueueUiEvent(const UiEvent& event) {
|
||||||
if (!uiQueue_) return false;
|
if (!uiQueue_) return false;
|
||||||
return xQueueSend(uiQueue_, &event, 0) == pdTRUE;
|
return xQueueSend(uiQueue_, &event, 0) == pdTRUE;
|
||||||
@ -831,10 +849,18 @@ void WidgetManager::processUiQueue() {
|
|||||||
case UiEventType::KNX_TEXT:
|
case UiEventType::KNX_TEXT:
|
||||||
applyKnxText(event.groupAddr, event.text);
|
applyKnxText(event.groupAddr, event.text);
|
||||||
break;
|
break;
|
||||||
|
case UiEventType::KNX_TIME:
|
||||||
|
applyKnxTime(event.groupAddr, event.timeValue, event.timeType);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
processed++;
|
processed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chartRefreshPending_) {
|
||||||
|
refreshChartWidgetsLocked();
|
||||||
|
chartRefreshPending_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
esp_lv_adapter_unlock();
|
esp_lv_adapter_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -848,6 +874,22 @@ void WidgetManager::applyCachedValuesToWidgets() {
|
|||||||
TextSource source = widget->getTextSource();
|
TextSource source = widget->getTextSource();
|
||||||
if (source == TextSource::STATIC) continue;
|
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) {
|
if (source == TextSource::KNX_DPT_SWITCH) {
|
||||||
bool state = false;
|
bool state = false;
|
||||||
if (getCachedKnxSwitch(addr, &state)) {
|
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) {
|
void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource source) {
|
||||||
for (auto& widget : widgets_) {
|
for (auto& widget : widgets_) {
|
||||||
if (widget && widget->getKnxAddress() == groupAddr &&
|
if (widget && widget->getKnxAddress() == groupAddr &&
|
||||||
@ -880,6 +937,10 @@ void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource so
|
|||||||
widget->onKnxValue(value);
|
widget->onKnxValue(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (HistoryStore::instance().updateLatest(groupAddr, source, value)) {
|
||||||
|
chartRefreshPending_ = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
|
void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
|
||||||
@ -888,6 +949,10 @@ void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
|
|||||||
widget->onKnxSwitch(value);
|
widget->onKnxSwitch(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config_.knxNightModeAddress != 0 && groupAddr == config_.knxNightModeAddress) {
|
||||||
|
nightMode_ = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void WidgetManager::applyKnxText(uint16_t groupAddr, const char* text) {
|
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) {
|
void WidgetManager::cacheKnxValue(uint16_t groupAddr, TextSource source, float value) {
|
||||||
if (groupAddr == 0) return;
|
if (groupAddr == 0) return;
|
||||||
portENTER_CRITICAL(&knxCacheMux_);
|
portENTER_CRITICAL(&knxCacheMux_);
|
||||||
@ -999,6 +1106,37 @@ void WidgetManager::cacheKnxText(uint16_t groupAddr, const char* text) {
|
|||||||
portEXIT_CRITICAL(&knxCacheMux_);
|
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 {
|
bool WidgetManager::getCachedKnxValue(uint16_t groupAddr, TextSource source, float* out) const {
|
||||||
if (groupAddr == 0 || out == nullptr) return false;
|
if (groupAddr == 0 || out == nullptr) return false;
|
||||||
bool found = false;
|
bool found = false;
|
||||||
@ -1045,6 +1183,21 @@ bool WidgetManager::getCachedKnxText(uint16_t groupAddr, char* out, size_t outSi
|
|||||||
return found;
|
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) {
|
bool WidgetManager::isNumericTextSource(TextSource source) {
|
||||||
return source == TextSource::KNX_DPT_TEMP ||
|
return source == TextSource::KNX_DPT_TEMP ||
|
||||||
source == TextSource::KNX_DPT_PERCENT ||
|
source == TextSource::KNX_DPT_PERCENT ||
|
||||||
@ -1059,6 +1212,16 @@ static uint32_t parseHexColor(const char* colorStr) {
|
|||||||
return strtoul(colorStr + 1, nullptr, 16);
|
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 {
|
void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
||||||
cJSON* root = cJSON_CreateObject();
|
cJSON* root = cJSON_CreateObject();
|
||||||
if (!root) {
|
if (!root) {
|
||||||
@ -1073,6 +1236,12 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
|
|||||||
cJSON_AddNumberToObject(standby, "screen", config_.standbyScreenId);
|
cJSON_AddNumberToObject(standby, "screen", config_.standbyScreenId);
|
||||||
cJSON_AddNumberToObject(standby, "minutes", config_.standbyMinutes);
|
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");
|
cJSON* screens = cJSON_AddArrayToObject(root, "screens");
|
||||||
|
|
||||||
for (uint8_t s = 0; s < config_.screenCount; s++) {
|
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);
|
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);
|
cJSON_AddItemToArray(widgets, widget);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1203,6 +1390,13 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
w.targetScreen = 0;
|
w.targetScreen = 0;
|
||||||
w.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
|
w.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
|
||||||
w.isContainer = false;
|
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");
|
cJSON* id = cJSON_GetObjectItem(widget, "id");
|
||||||
if (cJSON_IsNumber(id)) w.id = id->valueint;
|
if (cJSON_IsNumber(id)) w.id = id->valueint;
|
||||||
@ -1317,6 +1511,47 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
|
|||||||
w.parentId = -1; // Default to root
|
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++;
|
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) {
|
if (newConfig->screenCount == 0) {
|
||||||
cJSON_Delete(root);
|
cJSON_Delete(root);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -7,8 +7,15 @@
|
|||||||
#include "freertos/queue.h"
|
#include "freertos/queue.h"
|
||||||
#include "freertos/portmacro.h"
|
#include "freertos/portmacro.h"
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <ctime>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
|
enum class KnxTimeType : uint8_t {
|
||||||
|
TIME = 0,
|
||||||
|
DATE = 1,
|
||||||
|
DATETIME = 2,
|
||||||
|
};
|
||||||
|
|
||||||
class WidgetManager {
|
class WidgetManager {
|
||||||
public:
|
public:
|
||||||
static WidgetManager& instance();
|
static WidgetManager& instance();
|
||||||
@ -43,6 +50,7 @@ public:
|
|||||||
void onKnxValue(uint16_t groupAddr, float value);
|
void onKnxValue(uint16_t groupAddr, float value);
|
||||||
void onKnxSwitch(uint16_t groupAddr, bool value);
|
void onKnxSwitch(uint16_t groupAddr, bool value);
|
||||||
void onKnxText(uint16_t groupAddr, const char* text);
|
void onKnxText(uint16_t groupAddr, const char* text);
|
||||||
|
void onKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type);
|
||||||
|
|
||||||
// Button action handler
|
// Button action handler
|
||||||
void handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target);
|
void handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target);
|
||||||
@ -66,6 +74,7 @@ private:
|
|||||||
KNX_VALUE = 0,
|
KNX_VALUE = 0,
|
||||||
KNX_SWITCH = 1,
|
KNX_SWITCH = 1,
|
||||||
KNX_TEXT = 2,
|
KNX_TEXT = 2,
|
||||||
|
KNX_TIME = 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UiEvent {
|
struct UiEvent {
|
||||||
@ -75,6 +84,8 @@ private:
|
|||||||
float value;
|
float value;
|
||||||
bool state;
|
bool state;
|
||||||
char text[UI_EVENT_TEXT_LEN];
|
char text[UI_EVENT_TEXT_LEN];
|
||||||
|
KnxTimeType timeType;
|
||||||
|
struct tm timeValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
static constexpr size_t KNX_CACHE_SIZE = MAX_WIDGETS * MAX_SCREENS;
|
static constexpr size_t KNX_CACHE_SIZE = MAX_WIDGETS * MAX_SCREENS;
|
||||||
@ -98,6 +109,13 @@ private:
|
|||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct KnxTimeCacheEntry {
|
||||||
|
uint16_t groupAddr = 0;
|
||||||
|
KnxTimeType type = KnxTimeType::TIME;
|
||||||
|
struct tm value = {};
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
void loadFromSdCard();
|
void loadFromSdCard();
|
||||||
void saveToSdCard();
|
void saveToSdCard();
|
||||||
void destroyAllWidgets();
|
void destroyAllWidgets();
|
||||||
@ -108,13 +126,18 @@ private:
|
|||||||
void applyKnxValue(uint16_t groupAddr, float value, TextSource source);
|
void applyKnxValue(uint16_t groupAddr, float value, TextSource source);
|
||||||
void applyKnxSwitch(uint16_t groupAddr, bool value);
|
void applyKnxSwitch(uint16_t groupAddr, bool value);
|
||||||
void applyKnxText(uint16_t groupAddr, const char* text);
|
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 cacheKnxValue(uint16_t groupAddr, TextSource source, float value);
|
||||||
void cacheKnxSwitch(uint16_t groupAddr, bool value);
|
void cacheKnxSwitch(uint16_t groupAddr, bool value);
|
||||||
void cacheKnxText(uint16_t groupAddr, const char* text);
|
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 getCachedKnxValue(uint16_t groupAddr, TextSource source, float* out) const;
|
||||||
bool getCachedKnxSwitch(uint16_t groupAddr, bool* out) const;
|
bool getCachedKnxSwitch(uint16_t groupAddr, bool* out) const;
|
||||||
bool getCachedKnxText(uint16_t groupAddr, char* out, size_t outSize) 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);
|
static bool isNumericTextSource(TextSource source);
|
||||||
|
void refreshChartWidgetsLocked();
|
||||||
|
void refreshChartWidgets();
|
||||||
|
|
||||||
void createDefaultConfig();
|
void createDefaultConfig();
|
||||||
void applyScreen(uint8_t screenId);
|
void applyScreen(uint8_t screenId);
|
||||||
@ -137,6 +160,8 @@ private:
|
|||||||
bool standbyWakePending_ = false;
|
bool standbyWakePending_ = false;
|
||||||
uint8_t standbyWakeTarget_ = 0xFF;
|
uint8_t standbyWakeTarget_ = 0xFF;
|
||||||
bool navPending_ = false;
|
bool navPending_ = false;
|
||||||
|
bool chartRefreshPending_ = false;
|
||||||
|
bool nightMode_ = false;
|
||||||
ButtonAction navAction_ = ButtonAction::KNX;
|
ButtonAction navAction_ = ButtonAction::KNX;
|
||||||
uint8_t navTargetScreen_ = 0xFF;
|
uint8_t navTargetScreen_ = 0xFF;
|
||||||
int64_t navRequestUs_ = 0;
|
int64_t navRequestUs_ = 0;
|
||||||
@ -152,8 +177,10 @@ private:
|
|||||||
std::array<KnxNumericCacheEntry, KNX_CACHE_SIZE> knxNumericCache_ = {};
|
std::array<KnxNumericCacheEntry, KNX_CACHE_SIZE> knxNumericCache_ = {};
|
||||||
std::array<KnxSwitchCacheEntry, KNX_CACHE_SIZE> knxSwitchCache_ = {};
|
std::array<KnxSwitchCacheEntry, KNX_CACHE_SIZE> knxSwitchCache_ = {};
|
||||||
std::array<KnxTextCacheEntry, KNX_CACHE_SIZE> knxTextCache_ = {};
|
std::array<KnxTextCacheEntry, KNX_CACHE_SIZE> knxTextCache_ = {};
|
||||||
|
std::array<KnxTimeCacheEntry, KNX_CACHE_SIZE> knxTimeCache_ = {};
|
||||||
size_t knxNumericCacheNext_ = 0;
|
size_t knxNumericCacheNext_ = 0;
|
||||||
size_t knxSwitchCacheNext_ = 0;
|
size_t knxSwitchCacheNext_ = 0;
|
||||||
size_t knxTextCacheNext_ = 0;
|
size_t knxTextCacheNext_ = 0;
|
||||||
|
size_t knxTimeCacheNext_ = 0;
|
||||||
mutable portMUX_TYPE knxCacheMux_ = {};
|
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);
|
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 onKnxValue(float value) override;
|
||||||
void onKnxSwitch(bool value) override;
|
void onKnxSwitch(bool value) override;
|
||||||
void onKnxText(const char* text) override;
|
void onKnxText(const char* text) override;
|
||||||
|
void onKnxTime(const struct tm& value, TextSource source) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
lv_obj_t* container_ = nullptr;
|
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_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_all(obj_, 6, 0);
|
||||||
lv_obj_set_style_pad_gap(obj_, 2, 0);
|
lv_obj_set_style_pad_gap(obj_, 2, 0);
|
||||||
|
|
||||||
if (labelText_[0] != '\0') {
|
if (labelText_[0] != '\0') {
|
||||||
labelLabel_ = lv_label_create(obj_);
|
labelLabel_ = lv_label_create(obj_);
|
||||||
lv_label_set_text(labelLabel_, labelText_);
|
lv_label_set_text(labelLabel_, labelText_);
|
||||||
|
|||||||
@ -37,6 +37,14 @@ void Widget::onKnxText(const char* /*text*/) {
|
|||||||
// Default: do nothing
|
// Default: do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Widget::onKnxTime(const struct tm& /*value*/, TextSource /*source*/) {
|
||||||
|
// Default: do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
void Widget::onHistoryUpdate() {
|
||||||
|
// Default: do nothing
|
||||||
|
}
|
||||||
|
|
||||||
void Widget::applyCommonStyle() {
|
void Widget::applyCommonStyle() {
|
||||||
if (obj_ == nullptr) return;
|
if (obj_ == nullptr) return;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "../WidgetConfig.hpp"
|
#include "../WidgetConfig.hpp"
|
||||||
#include "lvgl.h"
|
#include "lvgl.h"
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
class Widget {
|
class Widget {
|
||||||
public:
|
public:
|
||||||
@ -44,6 +45,8 @@ public:
|
|||||||
virtual void onKnxValue(float value);
|
virtual void onKnxValue(float value);
|
||||||
virtual void onKnxSwitch(bool value);
|
virtual void onKnxSwitch(bool value);
|
||||||
virtual void onKnxText(const char* text);
|
virtual void onKnxText(const char* text);
|
||||||
|
virtual void onKnxTime(const struct tm& value, TextSource source);
|
||||||
|
virtual void onHistoryUpdate();
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
// Common style helper functions
|
// Common style helper functions
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
#include "PowerFlowWidget.hpp"
|
#include "PowerFlowWidget.hpp"
|
||||||
#include "PowerNodeWidget.hpp"
|
#include "PowerNodeWidget.hpp"
|
||||||
#include "PowerLinkWidget.hpp"
|
#include "PowerLinkWidget.hpp"
|
||||||
|
#include "ChartWidget.hpp"
|
||||||
|
|
||||||
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
||||||
if (!config.visible) return nullptr;
|
if (!config.visible) return nullptr;
|
||||||
@ -31,6 +32,8 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
|
|||||||
return std::make_unique<PowerNodeWidget>(config);
|
return std::make_unique<PowerNodeWidget>(config);
|
||||||
case WidgetType::POWERLINK:
|
case WidgetType::POWERLINK:
|
||||||
return std::make_unique<PowerLinkWidget>(config);
|
return std::make_unique<PowerLinkWidget>(config);
|
||||||
|
case WidgetType::CHART:
|
||||||
|
return std::make_unique<ChartWidget>(config);
|
||||||
default:
|
default:
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -481,6 +481,49 @@
|
|||||||
border-radius: 999px;
|
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 {
|
.properties h4 {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
@ -519,6 +562,12 @@
|
|||||||
cursor: pointer;
|
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"] {
|
.prop-row input[type="checkbox"] {
|
||||||
width: auto;
|
width: auto;
|
||||||
flex: none;
|
flex: none;
|
||||||
@ -634,6 +683,10 @@
|
|||||||
<span class="element-title">LED</span>
|
<span class="element-title">LED</span>
|
||||||
<span class="element-sub">Status</span>
|
<span class="element-sub">Status</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="element-btn" onclick="addWidget('chart')">
|
||||||
|
<span class="element-title">Chart</span>
|
||||||
|
<span class="element-sub">Verlauf</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -710,6 +763,28 @@
|
|||||||
<input type="number" id="standbyMinutes" min="0" value="5">
|
<input type="number" id="standbyMinutes" min="0" value="5">
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<main class="canvas-area">
|
<main class="canvas-area">
|
||||||
@ -738,7 +813,8 @@
|
|||||||
const WIDGET_TYPES = {
|
const WIDGET_TYPES = {
|
||||||
LABEL: 0,
|
LABEL: 0,
|
||||||
BUTTON: 1,
|
BUTTON: 1,
|
||||||
LED: 2
|
LED: 2,
|
||||||
|
CHART: 9
|
||||||
};
|
};
|
||||||
|
|
||||||
const BUTTON_ACTIONS = {
|
const BUTTON_ACTIONS = {
|
||||||
@ -750,13 +826,15 @@
|
|||||||
const TYPE_KEYS = {
|
const TYPE_KEYS = {
|
||||||
0: 'label',
|
0: 'label',
|
||||||
1: 'button',
|
1: 'button',
|
||||||
2: 'led'
|
2: 'led',
|
||||||
|
9: 'chart'
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_LABELS = {
|
const TYPE_LABELS = {
|
||||||
label: 'Label',
|
label: 'Label',
|
||||||
button: 'Button',
|
button: 'Button',
|
||||||
led: 'LED'
|
led: 'LED',
|
||||||
|
chart: 'Chart'
|
||||||
};
|
};
|
||||||
|
|
||||||
const textSources = {
|
const textSources = {
|
||||||
@ -767,7 +845,10 @@
|
|||||||
4: 'KNX Text',
|
4: 'KNX Text',
|
||||||
5: 'KNX Leistung (DPT 14.056)',
|
5: 'KNX Leistung (DPT 14.056)',
|
||||||
6: 'KNX Energie (DPT 13.013)',
|
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 = [
|
const textSourceGroups = [
|
||||||
@ -775,15 +856,19 @@
|
|||||||
{ label: 'DPT 1.x', values: [2] },
|
{ label: 'DPT 1.x', values: [2] },
|
||||||
{ label: 'DPT 5.x', values: [3, 7] },
|
{ label: 'DPT 5.x', values: [3, 7] },
|
||||||
{ label: 'DPT 9.x', values: [1] },
|
{ 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 13.x', values: [6] },
|
||||||
{ label: 'DPT 14.x', values: [5] },
|
{ 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 = {
|
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],
|
button: [0],
|
||||||
led: [0, 2]
|
led: [0, 2],
|
||||||
|
chart: [1, 3, 5, 6, 7]
|
||||||
};
|
};
|
||||||
|
|
||||||
const fontSizes = [14, 18, 22, 28, 36, 48];
|
const fontSizes = [14, 18, 22, 28, 36, 48];
|
||||||
@ -795,7 +880,10 @@
|
|||||||
4: '%s',
|
4: '%s',
|
||||||
5: '%.1f W',
|
5: '%.1f W',
|
||||||
6: '%.0f kWh',
|
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 = {
|
const WIDGET_DEFAULTS = {
|
||||||
@ -849,12 +937,36 @@
|
|||||||
knxAddr: 0,
|
knxAddr: 0,
|
||||||
action: BUTTON_ACTIONS.KNX,
|
action: BUTTON_ACTIONS.KNX,
|
||||||
targetScreen: 0
|
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 = {
|
let config = {
|
||||||
startScreen: 0,
|
startScreen: 0,
|
||||||
standby: { enabled: false, screen: -1, minutes: 5 },
|
standby: { enabled: false, screen: -1, minutes: 5 },
|
||||||
|
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
|
||||||
screens: []
|
screens: []
|
||||||
};
|
};
|
||||||
let selectedWidget = null;
|
let selectedWidget = null;
|
||||||
@ -882,6 +994,7 @@
|
|||||||
const key = typeKeyFor(widget.type);
|
const key = typeKeyFor(widget.type);
|
||||||
if (key === 'button') return { w: 60, h: 30 };
|
if (key === 'button') return { w: 60, h: 30 };
|
||||||
if (key === 'led') return { w: 20, h: 20 };
|
if (key === 'led') return { w: 20, h: 20 };
|
||||||
|
if (key === 'chart') return { w: 160, h: 120 };
|
||||||
return { w: 40, h: 20 };
|
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.visible === undefined || w.visible === null) w.visible = true;
|
||||||
if (w.x === undefined || w.x === null) w.x = 100;
|
if (w.x === undefined || w.x === null) w.x = 100;
|
||||||
if (w.y === undefined || w.y === null) w.y = 100;
|
if (w.y === undefined || w.y === null) w.y = 100;
|
||||||
@ -984,8 +1127,40 @@
|
|||||||
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
|
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() {
|
function updateKnxProgButton() {
|
||||||
@ -1029,6 +1204,7 @@
|
|||||||
config = {
|
config = {
|
||||||
startScreen: 0,
|
startScreen: 0,
|
||||||
standby: { enabled: false, screen: -1, minutes: 5 },
|
standby: { enabled: false, screen: -1, minutes: 5 },
|
||||||
|
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
|
||||||
screens: [
|
screens: [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
@ -1044,6 +1220,14 @@
|
|||||||
if (!config.standby) {
|
if (!config.standby) {
|
||||||
config.standby = { enabled: false, screen: -1, minutes: 5 };
|
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;
|
nextWidgetId = 0;
|
||||||
nextScreenId = 0;
|
nextScreenId = 0;
|
||||||
@ -1085,6 +1269,7 @@
|
|||||||
renderScreenList();
|
renderScreenList();
|
||||||
renderScreenSettings();
|
renderScreenSettings();
|
||||||
renderNavSettings();
|
renderNavSettings();
|
||||||
|
renderKnxSettings();
|
||||||
renderCanvas();
|
renderCanvas();
|
||||||
renderTree();
|
renderTree();
|
||||||
renderProperties();
|
renderProperties();
|
||||||
@ -1158,6 +1343,30 @@
|
|||||||
standbySelect.value = config.standby.screen ?? -1;
|
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() {
|
function renderCanvas() {
|
||||||
const canvas = document.getElementById('canvas');
|
const canvas = document.getElementById('canvas');
|
||||||
const screen = getActiveScreen();
|
const screen = getActiveScreen();
|
||||||
@ -1221,6 +1430,34 @@
|
|||||||
} else {
|
} else {
|
||||||
el.style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
|
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) {
|
if (selectedWidget === w.id) {
|
||||||
@ -1381,6 +1618,64 @@
|
|||||||
</div>
|
</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 = '';
|
let typographySection = '';
|
||||||
@ -1545,6 +1840,50 @@
|
|||||||
renderTree();
|
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() {
|
function updateBgColor() {
|
||||||
const screen = getActiveScreen();
|
const screen = getActiveScreen();
|
||||||
screen.bgColor = document.getElementById('bgColor').value;
|
screen.bgColor = document.getElementById('bgColor').value;
|
||||||
@ -1554,7 +1893,10 @@
|
|||||||
function addWidget(type) {
|
function addWidget(type) {
|
||||||
const screen = getActiveScreen();
|
const screen = getActiveScreen();
|
||||||
const defaults = WIDGET_DEFAULTS[type];
|
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 = {
|
const w = {
|
||||||
id: nextWidgetId++,
|
id: nextWidgetId++,
|
||||||
@ -1579,6 +1921,13 @@
|
|||||||
targetScreen: defaults.targetScreen
|
targetScreen: defaults.targetScreen
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (defaults.chart) {
|
||||||
|
w.chart = {
|
||||||
|
period: defaults.chart.period ?? 0,
|
||||||
|
series: (defaults.chart.series || []).map(s => ({ ...s }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
screen.widgets.push(w);
|
screen.widgets.push(w);
|
||||||
selectWidget(w.id);
|
selectWidget(w.id);
|
||||||
}
|
}
|
||||||
@ -1927,6 +2276,22 @@
|
|||||||
config.standby.minutes = Number.isNaN(val) ? 0 : val;
|
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'));
|
requestAnimationFrame(() => document.body.classList.add('loaded'));
|
||||||
loadKnxProgMode();
|
loadKnxProgMode();
|
||||||
loadConfig();
|
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">
|
<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>
|
</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>
|
||||||
<div class="px-[18px] py-3 border-t border-border flex justify-end gap-2.5">
|
<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>
|
<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-[13px] font-semibold">Power Node</span>
|
||||||
<span class="text-[11px] text-muted mt-0.5 block">Element</span>
|
<span class="text-[11px] text-muted mt-0.5 block">Element</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -208,6 +208,46 @@
|
|||||||
</template>
|
</template>
|
||||||
</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 -->
|
<!-- Typography -->
|
||||||
<template v-if="key === 'label'">
|
<template v-if="key === 'label'">
|
||||||
<h4 :class="headingClass">Typo</h4>
|
<h4 :class="headingClass">Typo</h4>
|
||||||
@ -379,7 +419,7 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useEditorStore } from '../stores/editor';
|
import { useEditorStore } from '../stores/editor';
|
||||||
import { typeKeyFor } from '../utils';
|
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';
|
import IconPicker from './IconPicker.vue';
|
||||||
|
|
||||||
const store = useEditorStore();
|
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 writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
|
||||||
const rowClass = 'flex items-center gap-2.5 mb-2';
|
const rowClass = 'flex items-center gap-2.5 mb-2';
|
||||||
const labelClass = 'w-[90px] text-[12px] text-muted';
|
const labelClass = 'w-[90px] text-[12px] text-muted';
|
||||||
|
|||||||
@ -110,6 +110,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 -->
|
<!-- Icon-only Widget -->
|
||||||
<template v-else-if="isIcon">
|
<template v-else-if="isIcon">
|
||||||
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
|
<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 isPowerFlow = computed(() => props.widget.type === WIDGET_TYPES.POWERFLOW);
|
||||||
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
|
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
|
||||||
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
|
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 tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
|
||||||
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
|
||||||
|
|||||||
@ -11,7 +11,8 @@ export const WIDGET_TYPES = {
|
|||||||
TABPAGE: 5,
|
TABPAGE: 5,
|
||||||
POWERFLOW: 6,
|
POWERFLOW: 6,
|
||||||
POWERNODE: 7,
|
POWERNODE: 7,
|
||||||
POWERLINK: 8
|
POWERLINK: 8,
|
||||||
|
CHART: 9
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ICON_POSITIONS = {
|
export const ICON_POSITIONS = {
|
||||||
@ -42,7 +43,8 @@ export const TYPE_KEYS = {
|
|||||||
5: 'tabpage',
|
5: 'tabpage',
|
||||||
6: 'powerflow',
|
6: 'powerflow',
|
||||||
7: 'powernode',
|
7: 'powernode',
|
||||||
8: 'powerlink'
|
8: 'powerlink',
|
||||||
|
9: 'chart'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TYPE_LABELS = {
|
export const TYPE_LABELS = {
|
||||||
@ -54,7 +56,8 @@ export const TYPE_LABELS = {
|
|||||||
tabpage: 'Seite',
|
tabpage: 'Seite',
|
||||||
powerflow: 'Power Flow',
|
powerflow: 'Power Flow',
|
||||||
powernode: 'Power Node',
|
powernode: 'Power Node',
|
||||||
powerlink: 'Power Link'
|
powerlink: 'Power Link',
|
||||||
|
chart: 'Chart'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -66,7 +69,10 @@ export const textSources = {
|
|||||||
4: 'KNX Text',
|
4: 'KNX Text',
|
||||||
5: 'KNX Leistung (DPT 14.056)',
|
5: 'KNX Leistung (DPT 14.056)',
|
||||||
6: 'KNX Energie (DPT 13.013)',
|
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 = [
|
export const textSourceGroups = [
|
||||||
@ -74,20 +80,33 @@ export const textSourceGroups = [
|
|||||||
{ label: 'DPT 1.x', values: [2] },
|
{ label: 'DPT 1.x', values: [2] },
|
||||||
{ label: 'DPT 5.x', values: [3, 7] },
|
{ label: 'DPT 5.x', values: [3, 7] },
|
||||||
{ label: 'DPT 9.x', values: [1] },
|
{ 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 13.x', values: [6] },
|
||||||
{ label: 'DPT 14.x', values: [5] },
|
{ 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 = {
|
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],
|
button: [0],
|
||||||
led: [0, 2],
|
led: [0, 2],
|
||||||
icon: [0, 2],
|
icon: [0, 2],
|
||||||
powernode: [0, 1, 2, 3, 4, 5, 6, 7],
|
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 = {
|
export const ICON_DEFAULTS = {
|
||||||
iconCodepoint: 0,
|
iconCodepoint: 0,
|
||||||
iconPosition: 0,
|
iconPosition: 0,
|
||||||
@ -104,7 +123,10 @@ export const defaultFormats = {
|
|||||||
4: '%s',
|
4: '%s',
|
||||||
5: '%.1f W',
|
5: '%.1f W',
|
||||||
6: '%.0f kWh',
|
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 = {
|
export const WIDGET_DEFAULTS = {
|
||||||
@ -306,5 +328,33 @@ export const WIDGET_DEFAULTS = {
|
|||||||
iconPosition: 0,
|
iconPosition: 0,
|
||||||
iconSize: 0,
|
iconSize: 0,
|
||||||
iconGap: 6
|
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({
|
const config = reactive({
|
||||||
startScreen: 0,
|
startScreen: 0,
|
||||||
standby: { enabled: false, screen: -1, minutes: 5 },
|
standby: { enabled: false, screen: -1, minutes: 5 },
|
||||||
|
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
|
||||||
screens: []
|
screens: []
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -46,8 +47,40 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
|
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(() => {
|
const activeScreen = computed(() => {
|
||||||
@ -176,11 +209,20 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
];
|
];
|
||||||
config.startScreen = 0;
|
config.startScreen = 0;
|
||||||
config.standby = { enabled: false, screen: -1, minutes: 5 };
|
config.standby = { enabled: false, screen: -1, minutes: 5 };
|
||||||
|
config.knx = { time: 0, date: 0, dateTime: 0, night: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.standby) {
|
if (!config.standby) {
|
||||||
config.standby = { enabled: false, screen: -1, minutes: 5 };
|
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();
|
mapLegacyKnxAddresses();
|
||||||
|
|
||||||
// Recalculate IDs
|
// Recalculate IDs
|
||||||
@ -277,6 +319,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
case 'powerflow': typeValue = WIDGET_TYPES.POWERFLOW; break;
|
case 'powerflow': typeValue = WIDGET_TYPES.POWERFLOW; break;
|
||||||
case 'powernode': typeValue = WIDGET_TYPES.POWERNODE; break;
|
case 'powernode': typeValue = WIDGET_TYPES.POWERNODE; break;
|
||||||
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
|
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
|
||||||
|
case 'chart': typeValue = WIDGET_TYPES.CHART; break;
|
||||||
default: typeValue = WIDGET_TYPES.LABEL;
|
default: typeValue = WIDGET_TYPES.LABEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,6 +396,13 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
iconGap: defaults.iconGap || 8
|
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);
|
activeScreen.value.widgets.push(w);
|
||||||
selectedWidgetId.value = w.id;
|
selectedWidgetId.value = w.id;
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export function minSizeFor(widget) {
|
|||||||
if (key === 'powerflow') return { w: 240, h: 180 };
|
if (key === 'powerflow') return { w: 240, h: 180 };
|
||||||
if (key === 'powernode') return { w: 70, h: 70 };
|
if (key === 'powernode') return { w: 70, h: 70 };
|
||||||
if (key === 'powerlink') return { w: 1, h: 1 };
|
if (key === 'powerlink') return { w: 1, h: 1 };
|
||||||
|
if (key === 'chart') return { w: 160, h: 120 };
|
||||||
return { w: 40, h: 20 };
|
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.visible === undefined || w.visible === null) w.visible = true;
|
||||||
if (w.x === undefined || w.x === null) w.x = 100;
|
if (w.x === undefined || w.x === null) w.x = 100;
|
||||||
if (w.y === undefined || w.y === null) w.y = 100;
|
if (w.y === undefined || w.y === null) w.y = 100;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user