This commit is contained in:
Thomas Peterson 2026-01-29 19:33:12 +01:00
parent d24507263f
commit 631d1eb250
24 changed files with 1966 additions and 22 deletions

View File

@ -1,4 +1,4 @@
idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "Touch.cpp" "Gui.cpp" "Wifi.cpp" "LvglIdle.c" "Gui/WifiSetting.cpp" "Gui/EthSetting.cpp" "Hardware/Eth.cpp" "WidgetManager.cpp" "WidgetConfig.cpp" "SdCard.cpp" "Fonts.cpp"
idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "Touch.cpp" "Gui.cpp" "Wifi.cpp" "LvglIdle.c" "Gui/WifiSetting.cpp" "Gui/EthSetting.cpp" "Hardware/Eth.cpp" "WidgetManager.cpp" "WidgetConfig.cpp" "SdCard.cpp" "Fonts.cpp"
"widgets/Widget.cpp"
"widgets/LabelWidget.cpp"
"widgets/ButtonWidget.cpp"
@ -10,6 +10,7 @@ idf_component_register(SRCS "KnxWorker.cpp" "Nvs.cpp" "main.cpp" "Display.cpp" "
"widgets/PowerFlowWidget.cpp"
"widgets/PowerNodeWidget.cpp"
"widgets/PowerLinkWidget.cpp"
"widgets/ChartWidget.cpp"
"webserver/WebServer.cpp"
"webserver/StaticFileHandlers.cpp"
"webserver/ConfigHandlers.cpp"

543
main/HistoryStore.cpp Normal file
View 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
View 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;
};

View File

@ -28,7 +28,7 @@ KnxWorker::KnxWorker() {}
namespace {
constexpr char kKnxNvsNamespace[] = "knx";
constexpr char kKnxSerialKey[] = "serial_bau";
constexpr uint8_t kKnxHardwareType[6] = {0x00, 0x00, 0xAB, 0xCE, 0x03, 0x00};
constexpr uint8_t kKnxHardwareType[6] = {0x00, 0x00, 0xAB, 0xCE, 0x04, 0x00};
constexpr uint16_t kKnxHardwareVersion = 1;
bool loadKnxBauNumber(uint32_t& outValue) {
@ -153,6 +153,27 @@ void KnxWorker::init() {
TextSource::KNX_DPT_ENERGY);
}
struct tm timeTm = {};
KNXValue timeValue(timeTm);
if (go.tryValue(timeValue, DPT_TimeOfDay)) {
WidgetManager::instance().onKnxTime(groupAddr, static_cast<struct tm>(timeValue),
KnxTimeType::TIME);
}
struct tm dateTm = {};
KNXValue dateValue(dateTm);
if (go.tryValue(dateValue, DPT_Date)) {
WidgetManager::instance().onKnxTime(groupAddr, static_cast<struct tm>(dateValue),
KnxTimeType::DATE);
}
struct tm dateTimeTm = {};
KNXValue dateTimeValue(dateTimeTm);
if (go.tryValue(dateTimeValue, DPT_DateTime)) {
WidgetManager::instance().onKnxTime(groupAddr, static_cast<struct tm>(dateTimeValue),
KnxTimeType::DATETIME);
}
KNXValue textValue = "";
if (go.tryValue(textValue, DPT_String_8859_1) ||
go.tryValue(textValue, DPT_String_ASCII)) {

View File

@ -50,6 +50,22 @@ void WidgetConfig::serialize(uint8_t* buf) const {
// Hierarchy
buf[pos++] = static_cast<uint8_t>(parentId);
// Chart properties
buf[pos++] = chartPeriod;
buf[pos++] = chartSeriesCount;
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
buf[pos++] = chartKnxAddress[i] & 0xFF;
buf[pos++] = (chartKnxAddress[i] >> 8) & 0xFF;
}
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
buf[pos++] = static_cast<uint8_t>(chartTextSource[i]);
}
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
buf[pos++] = chartSeriesColor[i].r;
buf[pos++] = chartSeriesColor[i].g;
buf[pos++] = chartSeriesColor[i].b;
}
}
void WidgetConfig::deserialize(const uint8_t* buf) {
@ -98,6 +114,22 @@ void WidgetConfig::deserialize(const uint8_t* buf) {
// Hierarchy
parentId = static_cast<int8_t>(buf[pos++]);
// Chart properties
chartPeriod = buf[pos++];
chartSeriesCount = buf[pos++];
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
chartKnxAddress[i] = buf[pos] | (buf[pos + 1] << 8);
pos += 2;
}
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
chartTextSource[i] = static_cast<TextSource>(buf[pos++]);
}
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
chartSeriesColor[i].r = buf[pos++];
chartSeriesColor[i].g = buf[pos++];
chartSeriesColor[i].b = buf[pos++];
}
}
WidgetConfig WidgetConfig::createLabel(uint8_t id, int16_t x, int16_t y, const char* labelText) {
@ -244,6 +276,10 @@ void GuiConfig::clear() {
standbyEnabled = false;
standbyScreenId = 0xFF;
standbyMinutes = 0;
knxTimeAddress = 0;
knxDateAddress = 0;
knxDateTimeAddress = 0;
knxNightModeAddress = 0;
for (size_t i = 0; i < MAX_SCREENS; i++) {
screens[i].clear(static_cast<uint8_t>(i), nullptr);
}

View File

@ -9,6 +9,7 @@ static constexpr size_t MAX_WIDGETS = 32;
static constexpr size_t MAX_SCREENS = 8;
static constexpr size_t MAX_TEXT_LEN = 32;
static constexpr size_t MAX_SCREEN_NAME_LEN = 24;
static constexpr size_t CHART_MAX_SERIES = 3;
enum class WidgetType : uint8_t {
LABEL = 0,
@ -20,6 +21,7 @@ enum class WidgetType : uint8_t {
POWERFLOW = 6,
POWERNODE = 7,
POWERLINK = 8,
CHART = 9,
};
enum class IconPosition : uint8_t {
@ -40,6 +42,15 @@ enum class ButtonAction : uint8_t {
BACK = 2,
};
enum class ChartPeriod : uint8_t {
HOUR_1 = 0,
HOUR_3 = 1,
HOUR_5 = 2,
HOUR_12 = 3,
HOUR_24 = 4,
MONTH_1 = 5,
};
// Text source: static text or KNX group address
enum class TextSource : uint8_t {
STATIC = 0, // Static text
@ -50,6 +61,9 @@ enum class TextSource : uint8_t {
KNX_DPT_POWER = 5, // KNX Power (DPT 14.056)
KNX_DPT_ENERGY = 6, // KNX Energy (DPT 13.013)
KNX_DPT_DECIMALFACTOR = 7, // KNX Decimal Factor (DPT 5.005)
KNX_DPT_TIME = 8, // KNX Time of day (DPT 10.001)
KNX_DPT_DATE = 9, // KNX Date (DPT 11.001)
KNX_DPT_DATETIME = 10, // KNX DateTime (DPT 19.001)
};
enum class TextAlign : uint8_t {
@ -126,8 +140,15 @@ struct WidgetConfig {
// Hierarchy
int8_t parentId; // ID of parent widget (-1 = root/screen)
// Chart properties
uint8_t chartPeriod;
uint8_t chartSeriesCount;
uint16_t chartKnxAddress[CHART_MAX_SERIES];
TextSource chartTextSource[CHART_MAX_SERIES];
Color chartSeriesColor[CHART_MAX_SERIES];
// Serialization size (fixed for NVS storage)
static constexpr size_t SERIALIZED_SIZE = 78;
static constexpr size_t SERIALIZED_SIZE = 98;
void serialize(uint8_t* buf) const;
void deserialize(const uint8_t* buf);
@ -175,6 +196,10 @@ struct GuiConfig {
bool standbyEnabled;
uint8_t standbyScreenId;
uint16_t standbyMinutes;
uint16_t knxTimeAddress;
uint16_t knxDateAddress;
uint16_t knxDateTimeAddress;
uint16_t knxNightModeAddress;
void clear();
ScreenConfig* findScreen(uint8_t id);

View File

@ -1,5 +1,6 @@
#include "WidgetManager.hpp"
#include "widgets/WidgetFactory.hpp"
#include "HistoryStore.hpp"
#include "SdCard.hpp"
#include "esp_lv_adapter.h"
#include "esp_log.h"
@ -170,6 +171,8 @@ void WidgetManager::createDefaultConfig() {
void WidgetManager::init() {
loadFromSdCard();
HistoryStore::instance().configureFromConfig(config_);
HistoryStore::instance().loadFromSdCard();
if (config_.findScreen(config_.startScreenId)) {
activeScreenId_ = config_.startScreenId;
} else if (config_.screenCount > 0) {
@ -257,6 +260,7 @@ void WidgetManager::saveToSdCard() {
}
void WidgetManager::applyConfig() {
HistoryStore::instance().configureFromConfig(config_);
if (!config_.findScreen(activeScreenId_)) {
if (config_.findScreen(config_.startScreenId)) {
activeScreenId_ = config_.startScreenId;
@ -665,6 +669,10 @@ void WidgetManager::loop() {
processUiQueue();
if (HistoryStore::instance().tick()) {
refreshChartWidgets();
}
if (didUiNav) return;
if (!config_.standbyEnabled || config_.standbyMinutes == 0) return;
@ -805,6 +813,16 @@ void WidgetManager::onKnxText(uint16_t groupAddr, const char* text) {
enqueueUiEvent(event);
}
void WidgetManager::onKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type) {
UiEvent event = {};
event.type = UiEventType::KNX_TIME;
event.groupAddr = groupAddr;
event.timeType = type;
event.timeValue = value;
cacheKnxTime(groupAddr, type, value);
enqueueUiEvent(event);
}
bool WidgetManager::enqueueUiEvent(const UiEvent& event) {
if (!uiQueue_) return false;
return xQueueSend(uiQueue_, &event, 0) == pdTRUE;
@ -831,10 +849,18 @@ void WidgetManager::processUiQueue() {
case UiEventType::KNX_TEXT:
applyKnxText(event.groupAddr, event.text);
break;
case UiEventType::KNX_TIME:
applyKnxTime(event.groupAddr, event.timeValue, event.timeType);
break;
}
processed++;
}
if (chartRefreshPending_) {
refreshChartWidgetsLocked();
chartRefreshPending_ = false;
}
esp_lv_adapter_unlock();
}
@ -848,6 +874,22 @@ void WidgetManager::applyCachedValuesToWidgets() {
TextSource source = widget->getTextSource();
if (source == TextSource::STATIC) continue;
if (source == TextSource::KNX_DPT_TIME ||
source == TextSource::KNX_DPT_DATE ||
source == TextSource::KNX_DPT_DATETIME) {
KnxTimeType type = KnxTimeType::TIME;
if (source == TextSource::KNX_DPT_DATE) {
type = KnxTimeType::DATE;
} else if (source == TextSource::KNX_DPT_DATETIME) {
type = KnxTimeType::DATETIME;
}
struct tm tmValue = {};
if (getCachedKnxTime(addr, type, &tmValue)) {
widget->onKnxTime(tmValue, source);
}
continue;
}
if (source == TextSource::KNX_DPT_SWITCH) {
bool state = false;
if (getCachedKnxSwitch(addr, &state)) {
@ -873,6 +915,21 @@ void WidgetManager::applyCachedValuesToWidgets() {
}
}
void WidgetManager::refreshChartWidgetsLocked() {
for (auto& widget : widgets_) {
if (!widget) continue;
if (widget->getType() == WidgetType::CHART) {
widget->onHistoryUpdate();
}
}
}
void WidgetManager::refreshChartWidgets() {
if (esp_lv_adapter_lock(-1) != ESP_OK) return;
refreshChartWidgetsLocked();
esp_lv_adapter_unlock();
}
void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource source) {
for (auto& widget : widgets_) {
if (widget && widget->getKnxAddress() == groupAddr &&
@ -880,6 +937,10 @@ void WidgetManager::applyKnxValue(uint16_t groupAddr, float value, TextSource so
widget->onKnxValue(value);
}
}
if (HistoryStore::instance().updateLatest(groupAddr, source, value)) {
chartRefreshPending_ = true;
}
}
void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
@ -888,6 +949,10 @@ void WidgetManager::applyKnxSwitch(uint16_t groupAddr, bool value) {
widget->onKnxSwitch(value);
}
}
if (config_.knxNightModeAddress != 0 && groupAddr == config_.knxNightModeAddress) {
nightMode_ = value;
}
}
void WidgetManager::applyKnxText(uint16_t groupAddr, const char* text) {
@ -898,6 +963,48 @@ void WidgetManager::applyKnxText(uint16_t groupAddr, const char* text) {
}
}
void WidgetManager::applyKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type) {
bool updated = false;
switch (type) {
case KnxTimeType::TIME:
if (config_.knxTimeAddress != 0 && groupAddr == config_.knxTimeAddress) {
HistoryStore::instance().updateTimeOfDay(value);
updated = true;
}
break;
case KnxTimeType::DATE:
if (config_.knxDateAddress != 0 && groupAddr == config_.knxDateAddress) {
HistoryStore::instance().updateDate(value);
updated = true;
}
break;
case KnxTimeType::DATETIME:
if (config_.knxDateTimeAddress != 0 && groupAddr == config_.knxDateTimeAddress) {
HistoryStore::instance().updateDateTime(value);
updated = true;
}
break;
}
TextSource source = TextSource::STATIC;
if (type == KnxTimeType::TIME) source = TextSource::KNX_DPT_TIME;
else if (type == KnxTimeType::DATE) source = TextSource::KNX_DPT_DATE;
else if (type == KnxTimeType::DATETIME) source = TextSource::KNX_DPT_DATETIME;
if (source != TextSource::STATIC) {
for (auto& widget : widgets_) {
if (widget && widget->getKnxAddress() == groupAddr &&
widget->getTextSource() == source) {
widget->onKnxTime(value, source);
}
}
}
if (updated) {
chartRefreshPending_ = true;
}
}
void WidgetManager::cacheKnxValue(uint16_t groupAddr, TextSource source, float value) {
if (groupAddr == 0) return;
portENTER_CRITICAL(&knxCacheMux_);
@ -999,6 +1106,37 @@ void WidgetManager::cacheKnxText(uint16_t groupAddr, const char* text) {
portEXIT_CRITICAL(&knxCacheMux_);
}
void WidgetManager::cacheKnxTime(uint16_t groupAddr, KnxTimeType type, const struct tm& value) {
if (groupAddr == 0) return;
portENTER_CRITICAL(&knxCacheMux_);
size_t freeIndex = KNX_CACHE_SIZE;
for (size_t i = 0; i < KNX_CACHE_SIZE; ++i) {
auto& entry = knxTimeCache_[i];
if (entry.valid) {
if (entry.groupAddr == groupAddr && entry.type == type) {
entry.value = value;
portEXIT_CRITICAL(&knxCacheMux_);
return;
}
} else if (freeIndex == KNX_CACHE_SIZE) {
freeIndex = i;
}
}
size_t index = freeIndex;
if (index == KNX_CACHE_SIZE) {
index = knxTimeCacheNext_;
knxTimeCacheNext_ = (knxTimeCacheNext_ + 1) % KNX_CACHE_SIZE;
}
auto& entry = knxTimeCache_[index];
entry.groupAddr = groupAddr;
entry.type = type;
entry.value = value;
entry.valid = true;
portEXIT_CRITICAL(&knxCacheMux_);
}
bool WidgetManager::getCachedKnxValue(uint16_t groupAddr, TextSource source, float* out) const {
if (groupAddr == 0 || out == nullptr) return false;
bool found = false;
@ -1045,6 +1183,21 @@ bool WidgetManager::getCachedKnxText(uint16_t groupAddr, char* out, size_t outSi
return found;
}
bool WidgetManager::getCachedKnxTime(uint16_t groupAddr, KnxTimeType type, struct tm* out) const {
if (groupAddr == 0 || out == nullptr) return false;
bool found = false;
portENTER_CRITICAL(&knxCacheMux_);
for (const auto& entry : knxTimeCache_) {
if (entry.valid && entry.groupAddr == groupAddr && entry.type == type) {
*out = entry.value;
found = true;
break;
}
}
portEXIT_CRITICAL(&knxCacheMux_);
return found;
}
bool WidgetManager::isNumericTextSource(TextSource source) {
return source == TextSource::KNX_DPT_TEMP ||
source == TextSource::KNX_DPT_PERCENT ||
@ -1059,6 +1212,16 @@ static uint32_t parseHexColor(const char* colorStr) {
return strtoul(colorStr + 1, nullptr, 16);
}
static Color defaultChartColor(size_t index) {
static const Color kChartColors[CHART_MAX_SERIES] = {
{239, 99, 81},
{125, 211, 176},
{94, 162, 239}
};
if (index >= CHART_MAX_SERIES) return kChartColors[0];
return kChartColors[index];
}
void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON* root = cJSON_CreateObject();
if (!root) {
@ -1073,6 +1236,12 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON_AddNumberToObject(standby, "screen", config_.standbyScreenId);
cJSON_AddNumberToObject(standby, "minutes", config_.standbyMinutes);
cJSON* knx = cJSON_AddObjectToObject(root, "knx");
cJSON_AddNumberToObject(knx, "time", config_.knxTimeAddress);
cJSON_AddNumberToObject(knx, "date", config_.knxDateAddress);
cJSON_AddNumberToObject(knx, "dateTime", config_.knxDateTimeAddress);
cJSON_AddNumberToObject(knx, "night", config_.knxNightModeAddress);
cJSON* screens = cJSON_AddArrayToObject(root, "screens");
for (uint8_t s = 0; s < config_.screenCount; s++) {
@ -1155,6 +1324,24 @@ void WidgetManager::getConfigJson(char* buf, size_t bufSize) const {
cJSON_AddNumberToObject(widget, "parentId", w.parentId);
if (w.type == WidgetType::CHART) {
cJSON* chart = cJSON_AddObjectToObject(widget, "chart");
cJSON_AddNumberToObject(chart, "period", w.chartPeriod);
cJSON* series = cJSON_AddArrayToObject(chart, "series");
uint8_t seriesCount = w.chartSeriesCount;
if (seriesCount > CHART_MAX_SERIES) seriesCount = CHART_MAX_SERIES;
for (uint8_t si = 0; si < seriesCount; ++si) {
cJSON* s = cJSON_CreateObject();
cJSON_AddNumberToObject(s, "knxAddr", w.chartKnxAddress[si]);
cJSON_AddNumberToObject(s, "textSrc", static_cast<int>(w.chartTextSource[si]));
char colorStr[8];
snprintf(colorStr, sizeof(colorStr), "#%02X%02X%02X",
w.chartSeriesColor[si].r, w.chartSeriesColor[si].g, w.chartSeriesColor[si].b);
cJSON_AddStringToObject(s, "color", colorStr);
cJSON_AddItemToArray(series, s);
}
}
cJSON_AddItemToArray(widgets, widget);
}
@ -1203,6 +1390,13 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
w.targetScreen = 0;
w.textAlign = static_cast<uint8_t>(TextAlign::CENTER);
w.isContainer = false;
w.chartPeriod = static_cast<uint8_t>(ChartPeriod::HOUR_1);
w.chartSeriesCount = 1;
for (size_t i = 0; i < CHART_MAX_SERIES; ++i) {
w.chartKnxAddress[i] = 0;
w.chartTextSource[i] = TextSource::KNX_DPT_TEMP;
w.chartSeriesColor[i] = defaultChartColor(i);
}
cJSON* id = cJSON_GetObjectItem(widget, "id");
if (cJSON_IsNumber(id)) w.id = id->valueint;
@ -1317,6 +1511,47 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
w.parentId = -1; // Default to root
}
cJSON* chart = cJSON_GetObjectItem(widget, "chart");
if (cJSON_IsObject(chart)) {
cJSON* period = cJSON_GetObjectItem(chart, "period");
if (cJSON_IsNumber(period)) {
int periodVal = period->valueint;
if (periodVal < 0) periodVal = 0;
if (periodVal > static_cast<int>(ChartPeriod::MONTH_1)) {
periodVal = static_cast<int>(ChartPeriod::MONTH_1);
}
w.chartPeriod = static_cast<uint8_t>(periodVal);
}
cJSON* series = cJSON_GetObjectItem(chart, "series");
if (cJSON_IsArray(series)) {
uint8_t idx = 0;
cJSON* item = nullptr;
cJSON_ArrayForEach(item, series) {
if (idx >= CHART_MAX_SERIES) break;
cJSON* sAddr = cJSON_GetObjectItem(item, "knxAddr");
if (cJSON_IsNumber(sAddr)) {
w.chartKnxAddress[idx] = sAddr->valueint;
}
cJSON* sSrc = cJSON_GetObjectItem(item, "textSrc");
if (cJSON_IsNumber(sSrc)) {
TextSource src = static_cast<TextSource>(sSrc->valueint);
if (isNumericTextSource(src)) {
w.chartTextSource[idx] = src;
}
}
cJSON* sColor = cJSON_GetObjectItem(item, "color");
if (cJSON_IsString(sColor)) {
w.chartSeriesColor[idx] = Color::fromHex(parseHexColor(sColor->valuestring));
}
idx++;
}
if (idx > 0) {
w.chartSeriesCount = idx;
}
}
}
screen.widgetCount++;
}
@ -1437,6 +1672,21 @@ bool WidgetManager::updateConfigFromJson(const char* json) {
}
}
cJSON* knx = cJSON_GetObjectItem(root, "knx");
if (cJSON_IsObject(knx)) {
cJSON* timeAddr = cJSON_GetObjectItem(knx, "time");
if (cJSON_IsNumber(timeAddr)) newConfig->knxTimeAddress = timeAddr->valueint;
cJSON* dateAddr = cJSON_GetObjectItem(knx, "date");
if (cJSON_IsNumber(dateAddr)) newConfig->knxDateAddress = dateAddr->valueint;
cJSON* dateTimeAddr = cJSON_GetObjectItem(knx, "dateTime");
if (cJSON_IsNumber(dateTimeAddr)) newConfig->knxDateTimeAddress = dateTimeAddr->valueint;
cJSON* nightAddr = cJSON_GetObjectItem(knx, "night");
if (cJSON_IsNumber(nightAddr)) newConfig->knxNightModeAddress = nightAddr->valueint;
}
if (newConfig->screenCount == 0) {
cJSON_Delete(root);
return false;

View File

@ -7,8 +7,15 @@
#include "freertos/queue.h"
#include "freertos/portmacro.h"
#include <array>
#include <ctime>
#include <memory>
enum class KnxTimeType : uint8_t {
TIME = 0,
DATE = 1,
DATETIME = 2,
};
class WidgetManager {
public:
static WidgetManager& instance();
@ -43,6 +50,7 @@ public:
void onKnxValue(uint16_t groupAddr, float value);
void onKnxSwitch(uint16_t groupAddr, bool value);
void onKnxText(uint16_t groupAddr, const char* text);
void onKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type);
// Button action handler
void handleButtonAction(const WidgetConfig& cfg, lv_obj_t* target);
@ -66,6 +74,7 @@ private:
KNX_VALUE = 0,
KNX_SWITCH = 1,
KNX_TEXT = 2,
KNX_TIME = 3,
};
struct UiEvent {
@ -75,6 +84,8 @@ private:
float value;
bool state;
char text[UI_EVENT_TEXT_LEN];
KnxTimeType timeType;
struct tm timeValue;
};
static constexpr size_t KNX_CACHE_SIZE = MAX_WIDGETS * MAX_SCREENS;
@ -98,6 +109,13 @@ private:
bool valid = false;
};
struct KnxTimeCacheEntry {
uint16_t groupAddr = 0;
KnxTimeType type = KnxTimeType::TIME;
struct tm value = {};
bool valid = false;
};
void loadFromSdCard();
void saveToSdCard();
void destroyAllWidgets();
@ -108,13 +126,18 @@ private:
void applyKnxValue(uint16_t groupAddr, float value, TextSource source);
void applyKnxSwitch(uint16_t groupAddr, bool value);
void applyKnxText(uint16_t groupAddr, const char* text);
void applyKnxTime(uint16_t groupAddr, const struct tm& value, KnxTimeType type);
void cacheKnxValue(uint16_t groupAddr, TextSource source, float value);
void cacheKnxSwitch(uint16_t groupAddr, bool value);
void cacheKnxText(uint16_t groupAddr, const char* text);
void cacheKnxTime(uint16_t groupAddr, KnxTimeType type, const struct tm& value);
bool getCachedKnxValue(uint16_t groupAddr, TextSource source, float* out) const;
bool getCachedKnxSwitch(uint16_t groupAddr, bool* out) const;
bool getCachedKnxText(uint16_t groupAddr, char* out, size_t outSize) const;
bool getCachedKnxTime(uint16_t groupAddr, KnxTimeType type, struct tm* out) const;
static bool isNumericTextSource(TextSource source);
void refreshChartWidgetsLocked();
void refreshChartWidgets();
void createDefaultConfig();
void applyScreen(uint8_t screenId);
@ -137,6 +160,8 @@ private:
bool standbyWakePending_ = false;
uint8_t standbyWakeTarget_ = 0xFF;
bool navPending_ = false;
bool chartRefreshPending_ = false;
bool nightMode_ = false;
ButtonAction navAction_ = ButtonAction::KNX;
uint8_t navTargetScreen_ = 0xFF;
int64_t navRequestUs_ = 0;
@ -152,8 +177,10 @@ private:
std::array<KnxNumericCacheEntry, KNX_CACHE_SIZE> knxNumericCache_ = {};
std::array<KnxSwitchCacheEntry, KNX_CACHE_SIZE> knxSwitchCache_ = {};
std::array<KnxTextCacheEntry, KNX_CACHE_SIZE> knxTextCache_ = {};
std::array<KnxTimeCacheEntry, KNX_CACHE_SIZE> knxTimeCache_ = {};
size_t knxNumericCacheNext_ = 0;
size_t knxSwitchCacheNext_ = 0;
size_t knxTextCacheNext_ = 0;
size_t knxTimeCacheNext_ = 0;
mutable portMUX_TYPE knxCacheMux_ = {};
};

View 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);
}

View 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_ = {};
};

View File

@ -229,3 +229,47 @@ void LabelWidget::onKnxText(const char* text) {
set_label_text_if_changed(label, text);
}
void LabelWidget::onKnxTime(const struct tm& value, TextSource source) {
lv_obj_t* label = textLabel_ ? textLabel_ : obj_;
if (label == nullptr) return;
if (config_.textSource != source) return;
if (source != TextSource::KNX_DPT_TIME &&
source != TextSource::KNX_DPT_DATE &&
source != TextSource::KNX_DPT_DATETIME) {
return;
}
int year = value.tm_year;
if (year > 0 && year < 1900) {
year += 1900;
}
int month = value.tm_mon;
if (month < 1 || month > 12) {
if (month >= 0 && month <= 11) {
month += 1;
}
}
const char* fmt = config_.text;
if (!fmt || fmt[0] == '\0' || strchr(fmt, '%') == nullptr) {
if (source == TextSource::KNX_DPT_TIME) {
fmt = "%02d:%02d:%02d";
} else if (source == TextSource::KNX_DPT_DATE) {
fmt = "%02d.%02d.%04d";
} else {
fmt = "%02d.%02d.%04d %02d:%02d:%02d";
}
}
char buf[32];
if (source == TextSource::KNX_DPT_TIME) {
snprintf(buf, sizeof(buf), fmt, value.tm_hour, value.tm_min, value.tm_sec);
} else if (source == TextSource::KNX_DPT_DATE) {
snprintf(buf, sizeof(buf), fmt, value.tm_mday, month, year);
} else {
snprintf(buf, sizeof(buf), fmt, value.tm_mday, month, year,
value.tm_hour, value.tm_min, value.tm_sec);
}
set_label_text_if_changed(label, buf);
}

View File

@ -13,6 +13,7 @@ public:
void onKnxValue(float value) override;
void onKnxSwitch(bool value) override;
void onKnxText(const char* text) override;
void onKnxTime(const struct tm& value, TextSource source) override;
private:
lv_obj_t* container_ = nullptr;

View File

@ -85,7 +85,6 @@ lv_obj_t* PowerNodeWidget::create(lv_obj_t* parent) {
lv_obj_set_flex_align(obj_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(obj_, 6, 0);
lv_obj_set_style_pad_gap(obj_, 2, 0);
if (labelText_[0] != '\0') {
labelLabel_ = lv_label_create(obj_);
lv_label_set_text(labelLabel_, labelText_);

View File

@ -37,6 +37,14 @@ void Widget::onKnxText(const char* /*text*/) {
// Default: do nothing
}
void Widget::onKnxTime(const struct tm& /*value*/, TextSource /*source*/) {
// Default: do nothing
}
void Widget::onHistoryUpdate() {
// Default: do nothing
}
void Widget::applyCommonStyle() {
if (obj_ == nullptr) return;

View File

@ -2,6 +2,7 @@
#include "../WidgetConfig.hpp"
#include "lvgl.h"
#include <ctime>
class Widget {
public:
@ -44,6 +45,8 @@ public:
virtual void onKnxValue(float value);
virtual void onKnxSwitch(bool value);
virtual void onKnxText(const char* text);
virtual void onKnxTime(const struct tm& value, TextSource source);
virtual void onHistoryUpdate();
protected:
// Common style helper functions

View File

@ -8,6 +8,7 @@
#include "PowerFlowWidget.hpp"
#include "PowerNodeWidget.hpp"
#include "PowerLinkWidget.hpp"
#include "ChartWidget.hpp"
std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
if (!config.visible) return nullptr;
@ -31,6 +32,8 @@ std::unique_ptr<Widget> WidgetFactory::create(const WidgetConfig& config) {
return std::make_unique<PowerNodeWidget>(config);
case WidgetType::POWERLINK:
return std::make_unique<PowerLinkWidget>(config);
case WidgetType::CHART:
return std::make_unique<ChartWidget>(config);
default:
return nullptr;
}

View File

@ -481,6 +481,49 @@
border-radius: 999px;
}
.widget-chart {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.widget-chart .chart-title {
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.widget-chart .chart-canvas {
position: relative;
flex: 1;
border-radius: 10px;
background: rgba(7, 12, 18, 0.35);
overflow: hidden;
}
.widget-chart .chart-canvas::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
background-size: 24px 24px;
opacity: 0.4;
}
.widget-chart .chart-line {
position: absolute;
inset: 18% 8%;
background: linear-gradient(120deg, rgba(239, 99, 81, 0.0) 0%, rgba(239, 99, 81, 0.6) 45%, rgba(94, 162, 239, 0.8) 70%, rgba(125, 211, 176, 0.9) 100%);
border-radius: 999px;
height: 2px;
top: 45%;
}
.properties h4 {
font-size: 12px;
letter-spacing: 0.08em;
@ -519,6 +562,12 @@
cursor: pointer;
}
.chart-series {
padding-top: 8px;
margin-top: 8px;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
}
.prop-row input[type="checkbox"] {
width: auto;
flex: none;
@ -634,6 +683,10 @@
<span class="element-title">LED</span>
<span class="element-sub">Status</span>
</button>
<button class="element-btn" onclick="addWidget('chart')">
<span class="element-title">Chart</span>
<span class="element-sub">Verlauf</span>
</button>
</div>
</section>
@ -710,6 +763,28 @@
<input type="number" id="standbyMinutes" min="0" value="5">
</div>
</section>
<section class="panel" style="--delay: 0.3s;">
<div class="panel-header">
<h3>KNX Zeit</h3>
</div>
<div class="control-row">
<label for="knxTimeAddr">Uhrzeit</label>
<select id="knxTimeAddr"></select>
</div>
<div class="control-row">
<label for="knxDateAddr">Datum</label>
<select id="knxDateAddr"></select>
</div>
<div class="control-row">
<label for="knxDateTimeAddr">Datum+Zeit</label>
<select id="knxDateTimeAddr"></select>
</div>
<div class="control-row">
<label for="knxNightAddr">Nachtmodus</label>
<select id="knxNightAddr"></select>
</div>
</section>
</aside>
<main class="canvas-area">
@ -738,7 +813,8 @@
const WIDGET_TYPES = {
LABEL: 0,
BUTTON: 1,
LED: 2
LED: 2,
CHART: 9
};
const BUTTON_ACTIONS = {
@ -750,13 +826,15 @@
const TYPE_KEYS = {
0: 'label',
1: 'button',
2: 'led'
2: 'led',
9: 'chart'
};
const TYPE_LABELS = {
label: 'Label',
button: 'Button',
led: 'LED'
led: 'LED',
chart: 'Chart'
};
const textSources = {
@ -767,7 +845,10 @@
4: 'KNX Text',
5: 'KNX Leistung (DPT 14.056)',
6: 'KNX Energie (DPT 13.013)',
7: 'KNX Dezimalfaktor (DPT 5.005)'
7: 'KNX Dezimalfaktor (DPT 5.005)',
8: 'KNX Uhrzeit (DPT 10.001)',
9: 'KNX Datum (DPT 11.001)',
10: 'KNX Datum & Uhrzeit (DPT 19.001)'
};
const textSourceGroups = [
@ -775,15 +856,19 @@
{ label: 'DPT 1.x', values: [2] },
{ label: 'DPT 5.x', values: [3, 7] },
{ label: 'DPT 9.x', values: [1] },
{ label: 'DPT 10.x', values: [8] },
{ label: 'DPT 11.x', values: [9] },
{ label: 'DPT 13.x', values: [6] },
{ label: 'DPT 14.x', values: [5] },
{ label: 'DPT 16.x', values: [4] }
{ label: 'DPT 16.x', values: [4] },
{ label: 'DPT 19.x', values: [10] }
];
const sourceOptions = {
label: [0, 1, 2, 3, 4, 5, 6, 7],
label: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
button: [0],
led: [0, 2]
led: [0, 2],
chart: [1, 3, 5, 6, 7]
};
const fontSizes = [14, 18, 22, 28, 36, 48];
@ -795,7 +880,10 @@
4: '%s',
5: '%.1f W',
6: '%.0f kWh',
7: '%d'
7: '%d',
8: '%02d:%02d:%02d',
9: '%02d.%02d.%04d',
10: '%02d.%02d.%04d %02d:%02d:%02d'
};
const WIDGET_DEFAULTS = {
@ -849,12 +937,36 @@
knxAddr: 0,
action: BUTTON_ACTIONS.KNX,
targetScreen: 0
},
chart: {
w: 320,
h: 200,
text: 'Chart',
textSrc: 0,
fontSize: 1,
textColor: '#E7EDF3',
bgColor: '#16202c',
bgOpacity: 255,
radius: 12,
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: BUTTON_ACTIONS.KNX,
targetScreen: 0,
chart: {
period: 0,
series: [
{ knxAddr: 0, textSrc: 1, color: '#EF6351' }
]
}
}
};
let config = {
startScreen: 0,
standby: { enabled: false, screen: -1, minutes: 5 },
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
screens: []
};
let selectedWidget = null;
@ -882,6 +994,7 @@
const key = typeKeyFor(widget.type);
if (key === 'button') return { w: 60, h: 30 };
if (key === 'led') return { w: 20, h: 20 };
if (key === 'chart') return { w: 160, h: 120 };
return { w: 40, h: 20 };
}
@ -921,6 +1034,36 @@
});
}
if (defaults.chart) {
const maxSeries = 3;
if (!w.chart) {
w.chart = {
period: defaults.chart.period ?? 0,
series: (defaults.chart.series || []).map(s => ({ ...s }))
};
} else {
if (w.chart.period === undefined || w.chart.period === null) {
w.chart.period = defaults.chart.period ?? 0;
}
if (!Array.isArray(w.chart.series)) {
w.chart.series = [];
}
if (w.chart.series.length === 0 && defaults.chart.series) {
w.chart.series = defaults.chart.series.map(s => ({ ...s }));
}
for (let i = 0; i < w.chart.series.length && i < maxSeries; i++) {
const fallback = (defaults.chart.series && defaults.chart.series[i]) || defaults.chart.series?.[0] || { knxAddr: 0, textSrc: 1, color: '#EF6351' };
const entry = w.chart.series[i];
if (entry.knxAddr === undefined || entry.knxAddr === null) entry.knxAddr = fallback.knxAddr;
if (entry.textSrc === undefined || entry.textSrc === null) entry.textSrc = fallback.textSrc;
if (!entry.color) entry.color = fallback.color;
}
if (w.chart.series.length > maxSeries) {
w.chart.series = w.chart.series.slice(0, maxSeries);
}
}
}
if (w.visible === undefined || w.visible === null) w.visible = true;
if (w.x === undefined || w.x === null) w.x = 100;
if (w.y === undefined || w.y === null) w.y = 100;
@ -984,8 +1127,40 @@
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
}
}
if (w.chart && Array.isArray(w.chart.series)) {
w.chart.series.forEach((series) => {
if (typeof series.knxAddr === 'number' && series.knxAddr > 0) {
if (!gaSet.has(series.knxAddr) && addrByIndex.has(series.knxAddr)) {
series.knxAddr = addrByIndex.get(series.knxAddr);
}
}
});
}
});
});
if (config.knx) {
if (typeof config.knx.time === 'number' && config.knx.time > 0) {
if (!gaSet.has(config.knx.time) && addrByIndex.has(config.knx.time)) {
config.knx.time = addrByIndex.get(config.knx.time);
}
}
if (typeof config.knx.date === 'number' && config.knx.date > 0) {
if (!gaSet.has(config.knx.date) && addrByIndex.has(config.knx.date)) {
config.knx.date = addrByIndex.get(config.knx.date);
}
}
if (typeof config.knx.dateTime === 'number' && config.knx.dateTime > 0) {
if (!gaSet.has(config.knx.dateTime) && addrByIndex.has(config.knx.dateTime)) {
config.knx.dateTime = addrByIndex.get(config.knx.dateTime);
}
}
if (typeof config.knx.night === 'number' && config.knx.night > 0) {
if (!gaSet.has(config.knx.night) && addrByIndex.has(config.knx.night)) {
config.knx.night = addrByIndex.get(config.knx.night);
}
}
}
}
function updateKnxProgButton() {
@ -1029,6 +1204,7 @@
config = {
startScreen: 0,
standby: { enabled: false, screen: -1, minutes: 5 },
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
screens: [
{
id: 0,
@ -1044,6 +1220,14 @@
if (!config.standby) {
config.standby = { enabled: false, screen: -1, minutes: 5 };
}
if (!config.knx) {
config.knx = { time: 0, date: 0, dateTime: 0, night: 0 };
} else {
if (config.knx.time === undefined) config.knx.time = 0;
if (config.knx.date === undefined) config.knx.date = 0;
if (config.knx.dateTime === undefined) config.knx.dateTime = 0;
if (config.knx.night === undefined) config.knx.night = 0;
}
nextWidgetId = 0;
nextScreenId = 0;
@ -1085,6 +1269,7 @@
renderScreenList();
renderScreenSettings();
renderNavSettings();
renderKnxSettings();
renderCanvas();
renderTree();
renderProperties();
@ -1158,6 +1343,30 @@
standbySelect.value = config.standby.screen ?? -1;
}
function renderKnxSettings() {
const timeSelect = document.getElementById('knxTimeAddr');
const dateSelect = document.getElementById('knxDateAddr');
const dateTimeSelect = document.getElementById('knxDateTimeAddr');
const nightSelect = document.getElementById('knxNightAddr');
if (!timeSelect || !dateSelect || !dateTimeSelect || !nightSelect) return;
const options = ['<option value=\"0\">-- Keine --</option>'];
knxAddresses.forEach((a) => {
options.push(`<option value=\"${a.addr}\">GA ${a.addrStr} (GO${a.index})</option>`);
});
const html = options.join('');
timeSelect.innerHTML = html;
dateSelect.innerHTML = html;
dateTimeSelect.innerHTML = html;
nightSelect.innerHTML = html;
timeSelect.value = config.knx?.time ?? 0;
dateSelect.value = config.knx?.date ?? 0;
dateTimeSelect.value = config.knx?.dateTime ?? 0;
nightSelect.value = config.knx?.night ?? 0;
}
function renderCanvas() {
const canvas = document.getElementById('canvas');
const screen = getActiveScreen();
@ -1221,6 +1430,34 @@
} else {
el.style.boxShadow = 'inset 0 0 0 1px rgba(255,255,255,0.12)';
}
} else if (w.type === WIDGET_TYPES.CHART) {
el.className += ' widget-chart';
const alpha = clamp((w.bgOpacity ?? 255) / 255, 0, 1).toFixed(2);
el.style.background = hexToRgba(w.bgColor, alpha);
el.style.borderRadius = (w.radius * canvasScale) + 'px';
const title = document.createElement('div');
title.className = 'chart-title';
title.style.color = w.textColor;
title.textContent = w.text || 'Chart';
const chartCanvas = document.createElement('div');
chartCanvas.className = 'chart-canvas';
const line = document.createElement('div');
line.className = 'chart-line';
chartCanvas.appendChild(line);
el.appendChild(title);
el.appendChild(chartCanvas);
if (w.shadow && w.shadow.enabled) {
const sx = (w.shadow.x || 0) * canvasScale;
const sy = (w.shadow.y || 0) * canvasScale;
const blur = (w.shadow.blur || 0) * canvasScale;
const spread = (w.shadow.spread || 0) * canvasScale;
el.style.boxShadow = `${sx}px ${sy}px ${blur}px ${spread}px ${w.shadow.color}`;
}
}
if (selectedWidget === w.id) {
@ -1381,6 +1618,64 @@
</div>
` : ''}
`;
} else if (key === 'chart') {
const chartConfig = w.chart || { period: 0, series: [] };
const chartSeries = (Array.isArray(chartConfig.series) && chartConfig.series.length)
? chartConfig.series
: [{ knxAddr: 0, textSrc: 1, color: '#EF6351' }];
const seriesCount = Math.max(1, Math.min(chartSeries.length, 3));
const period = chartConfig.period ?? 0;
const periodOptions = [
{ value: 0, label: '1h' },
{ value: 1, label: '3h' },
{ value: 2, label: '5h' },
{ value: 3, label: '12h' },
{ value: 4, label: '24h' },
{ value: 5, label: '1 Monat' }
].map((opt) => `<option value="${opt.value}" ${period == opt.value ? 'selected' : ''}>${opt.label}</option>`).join('');
const seriesHtml = chartSeries.slice(0, seriesCount).map((series, idx) => {
const srcOptions = buildTextSourceOptions(sourceOptions.chart, series.textSrc ?? 1);
const knxSeriesOptions = knxAddresses.map((a) =>
`<option value="${a.addr}" ${series.knxAddr == a.addr ? 'selected' : ''}>GA ${a.addrStr} (GO${a.index})</option>`
).join('');
return `
<div class="chart-series">
<div class="prop-row"><label>Serie ${idx + 1}</label>
<input type="color" id="pchartcolor${idx}" value="${series.color || '#EF6351'}" onchange="updateChartSeries(${idx}, 'color')">
</div>
<div class="prop-row"><label>Quelle</label>
<select id="pchartsrc${idx}" onchange="updateChartSeries(${idx}, 'textSrc')">
${srcOptions}
</select>
</div>
<div class="prop-row"><label>KNX Objekt</label>
<select id="pchartaddr${idx}" onchange="updateChartSeries(${idx}, 'knxAddr')">
<option value="0" ${series.knxAddr == 0 ? 'selected' : ''}>-- Waehlen --</option>
${knxSeriesOptions}
</select>
</div>
</div>
`;
}).join('');
contentSection = `
<h4>Chart</h4>
<div class="prop-row"><label>Titel</label><input type="text" id="ptext" value="${w.text}" onchange="updateProp('text')"></div>
<div class="prop-row"><label>Zeitraum</label>
<select id="pchartperiod" onchange="updateChartPeriod()">
${periodOptions}
</select>
</div>
<div class="prop-row"><label>Serien</label>
<select id="pchartcount" onchange="updateChartCount()">
<option value="1" ${seriesCount === 1 ? 'selected' : ''}>1</option>
<option value="2" ${seriesCount === 2 ? 'selected' : ''}>2</option>
<option value="3" ${seriesCount === 3 ? 'selected' : ''}>3</option>
</select>
</div>
${seriesHtml}
`;
}
let typographySection = '';
@ -1545,6 +1840,50 @@
renderTree();
}
function updateChartPeriod() {
const screen = getActiveScreen();
const w = screen.widgets.find(x => x.id === selectedWidget);
if (!w || !w.chart) return;
w.chart.period = parseInt(document.getElementById('pchartperiod').value, 10);
}
function updateChartCount() {
const screen = getActiveScreen();
const w = screen.widgets.find(x => x.id === selectedWidget);
if (!w || !w.chart) return;
const desired = parseInt(document.getElementById('pchartcount').value, 10);
const colors = ['#EF6351', '#7DD3B0', '#5EA2EF'];
if (!Array.isArray(w.chart.series)) w.chart.series = [];
while (w.chart.series.length < desired && w.chart.series.length < 3) {
const idx = w.chart.series.length;
w.chart.series.push({ knxAddr: 0, textSrc: 1, color: colors[idx] || '#EF6351' });
}
if (w.chart.series.length > desired) {
w.chart.series = w.chart.series.slice(0, desired);
}
renderProperties();
renderCanvas();
}
function updateChartSeries(idx, prop) {
const screen = getActiveScreen();
const w = screen.widgets.find(x => x.id === selectedWidget);
if (!w || !w.chart || !Array.isArray(w.chart.series)) return;
const series = w.chart.series[idx];
if (!series) return;
if (prop === 'color') {
series.color = document.getElementById(`pchartcolor${idx}`).value;
} else if (prop === 'textSrc') {
series.textSrc = parseInt(document.getElementById(`pchartsrc${idx}`).value, 10);
} else if (prop === 'knxAddr') {
series.knxAddr = parseInt(document.getElementById(`pchartaddr${idx}`).value, 10);
}
renderCanvas();
renderTree();
}
function updateBgColor() {
const screen = getActiveScreen();
screen.bgColor = document.getElementById('bgColor').value;
@ -1554,7 +1893,10 @@
function addWidget(type) {
const screen = getActiveScreen();
const defaults = WIDGET_DEFAULTS[type];
const typeValue = type === 'label' ? WIDGET_TYPES.LABEL : (type === 'button' ? WIDGET_TYPES.BUTTON : WIDGET_TYPES.LED);
let typeValue = WIDGET_TYPES.LABEL;
if (type === 'button') typeValue = WIDGET_TYPES.BUTTON;
else if (type === 'led') typeValue = WIDGET_TYPES.LED;
else if (type === 'chart') typeValue = WIDGET_TYPES.CHART;
const w = {
id: nextWidgetId++,
@ -1579,6 +1921,13 @@
targetScreen: defaults.targetScreen
};
if (defaults.chart) {
w.chart = {
period: defaults.chart.period ?? 0,
series: (defaults.chart.series || []).map(s => ({ ...s }))
};
}
screen.widgets.push(w);
selectWidget(w.id);
}
@ -1927,6 +2276,22 @@
config.standby.minutes = Number.isNaN(val) ? 0 : val;
});
document.getElementById('knxTimeAddr').addEventListener('change', (e) => {
config.knx.time = parseInt(e.target.value, 10) || 0;
});
document.getElementById('knxDateAddr').addEventListener('change', (e) => {
config.knx.date = parseInt(e.target.value, 10) || 0;
});
document.getElementById('knxDateTimeAddr').addEventListener('change', (e) => {
config.knx.dateTime = parseInt(e.target.value, 10) || 0;
});
document.getElementById('knxNightAddr').addEventListener('change', (e) => {
config.knx.night = parseInt(e.target.value, 10) || 0;
});
requestAnimationFrame(() => document.body.classList.add('loaded'));
loadKnxProgMode();
loadConfig();

View File

@ -30,6 +30,45 @@
<input class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" type="number" min="0" v-model.number="store.config.standby.minutes">
</div>
</div>
<div class="flex flex-col gap-2.5">
<div class="text-[12px] uppercase tracking-[0.08em] text-[#3a5f88]">KNX Zeit</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Uhrzeit</label>
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.knx.time">
<option :value="0">-- Keine --</option>
<option v-for="addr in store.knxAddresses" :key="`time-${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Datum</label>
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.knx.date">
<option :value="0">-- Keine --</option>
<option v-for="addr in store.knxAddresses" :key="`date-${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Datum+Zeit</label>
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.knx.dateTime">
<option :value="0">-- Keine --</option>
<option v-for="addr in store.knxAddresses" :key="`dt-${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
<div class="flex items-center justify-between gap-2.5">
<label class="text-[12px] text-muted">Nachtmodus</label>
<select class="flex-1 bg-panel-2 border border-border rounded-lg px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent" v-model.number="store.config.knx.night">
<option :value="0">-- Keine --</option>
<option v-for="addr in store.knxAddresses" :key="`night-${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</div>
</div>
<div class="px-[18px] py-3 border-t border-border flex justify-end gap-2.5">
<button class="border border-border bg-panel-2 text-text px-3.5 py-2 rounded-[10px] text-[13px] font-semibold transition hover:-translate-y-0.5 hover:bg-[#e4ebf2] active:translate-y-0.5" @click="emit('close')">Schliessen</button>

View File

@ -34,6 +34,10 @@
<span class="text-[13px] font-semibold">Power Node</span>
<span class="text-[11px] text-muted mt-0.5 block">Element</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('chart')">
<span class="text-[13px] font-semibold">Chart</span>
<span class="text-[11px] text-muted mt-0.5 block">Verlauf</span>
</button>
</div>
</section>

View File

@ -208,6 +208,46 @@
</template>
</template>
<template v-if="key === 'chart'">
<h4 :class="headingClass">Chart</h4>
<div :class="rowClass"><label :class="labelClass">Titel</label><input :class="inputClass" type="text" v-model="w.text"></div>
<div :class="rowClass"><label :class="labelClass">Zeitraum</label>
<select :class="inputClass" v-model.number="w.chart.period">
<option v-for="opt in chartPeriods" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div :class="rowClass"><label :class="labelClass">Serien</label>
<select :class="inputClass" v-model.number="chartSeriesCount">
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
</select>
</div>
<div v-for="(series, idx) in chartSeries" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Serie {{ idx + 1 }}</label>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="series.color">
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">Quelle</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="series.textSrc">
<optgroup v-for="group in groupedSources(sourceOptions.chart)" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup>
</select>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted">
<label class="w-[70px] text-[11px] text-muted">KNX</label>
<select class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model.number="series.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in store.knxAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
</div>
</template>
<!-- Typography -->
<template v-if="key === 'label'">
<h4 :class="headingClass">Typo</h4>
@ -379,7 +419,7 @@
import { computed, ref } from 'vue';
import { useEditorStore } from '../stores/editor';
import { typeKeyFor } from '../utils';
import { sourceOptions, textSources, textSourceGroups, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES } from '../constants';
import { sourceOptions, textSources, textSourceGroups, fontSizes, BUTTON_ACTIONS, defaultFormats, WIDGET_TYPES, chartPeriods } from '../constants';
import IconPicker from './IconPicker.vue';
const store = useEditorStore();
@ -445,6 +485,33 @@ const powerNodeValue = computed({
}
});
const chartSeries = computed(() => w.value?.chart?.series ?? []);
const chartSeriesCount = computed({
get() {
const count = chartSeries.value.length || 1;
return Math.max(1, Math.min(count, 3));
},
set(value) {
if (!w.value || !w.value.chart) return;
const target = Math.max(1, Math.min(value, 3));
const colors = ['#EF6351', '#7DD3B0', '#5EA2EF'];
if (!Array.isArray(w.value.chart.series)) {
w.value.chart.series = [];
}
while (w.value.chart.series.length < target) {
const idx = w.value.chart.series.length;
w.value.chart.series.push({
knxAddr: 0,
textSrc: 1,
color: colors[idx] || '#EF6351'
});
}
if (w.value.chart.series.length > target) {
w.value.chart.series = w.value.chart.series.slice(0, target);
}
}
});
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
const rowClass = 'flex items-center gap-2.5 mb-2';
const labelClass = 'w-[90px] text-[12px] text-muted';

View File

@ -110,6 +110,21 @@
</div>
</template>
<template v-else-if="isChart">
<div class="w-full h-full flex flex-col gap-2">
<div class="text-[11px] uppercase tracking-[0.12em] opacity-80">
{{ widget.text || 'Chart' }}
</div>
<div class="flex-1 rounded-[10px] bg-black/20 relative overflow-hidden">
<div class="absolute inset-0 opacity-30" style="background-image: linear-gradient(to right, rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.1) 1px, transparent 1px); background-size: 24px 24px;"></div>
<svg class="absolute inset-0" viewBox="0 0 100 40" preserveAspectRatio="none">
<path d="M0,30 L15,22 L30,26 L45,14 L60,18 L75,10 L100,16" fill="none" stroke="rgba(239,99,81,0.8)" stroke-width="2" />
<path d="M0,34 L20,28 L40,32 L60,20 L80,24 L100,18" fill="none" stroke="rgba(125,211,176,0.8)" stroke-width="2" />
</svg>
</div>
</div>
</template>
<!-- Icon-only Widget -->
<template v-else-if="isIcon">
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
@ -183,6 +198,7 @@ const isButtonContainer = computed(() => isButton.value && props.widget.isContai
const isPowerFlow = computed(() => props.widget.type === WIDGET_TYPES.POWERFLOW);
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);

View File

@ -11,7 +11,8 @@ export const WIDGET_TYPES = {
TABPAGE: 5,
POWERFLOW: 6,
POWERNODE: 7,
POWERLINK: 8
POWERLINK: 8,
CHART: 9
};
export const ICON_POSITIONS = {
@ -42,7 +43,8 @@ export const TYPE_KEYS = {
5: 'tabpage',
6: 'powerflow',
7: 'powernode',
8: 'powerlink'
8: 'powerlink',
9: 'chart'
};
export const TYPE_LABELS = {
@ -54,7 +56,8 @@ export const TYPE_LABELS = {
tabpage: 'Seite',
powerflow: 'Power Flow',
powernode: 'Power Node',
powerlink: 'Power Link'
powerlink: 'Power Link',
chart: 'Chart'
};
@ -66,7 +69,10 @@ export const textSources = {
4: 'KNX Text',
5: 'KNX Leistung (DPT 14.056)',
6: 'KNX Energie (DPT 13.013)',
7: 'KNX Dezimalfaktor (DPT 5.005)'
7: 'KNX Dezimalfaktor (DPT 5.005)',
8: 'KNX Uhrzeit (DPT 10.001)',
9: 'KNX Datum (DPT 11.001)',
10: 'KNX Datum & Uhrzeit (DPT 19.001)'
};
export const textSourceGroups = [
@ -74,20 +80,33 @@ export const textSourceGroups = [
{ label: 'DPT 1.x', values: [2] },
{ label: 'DPT 5.x', values: [3, 7] },
{ label: 'DPT 9.x', values: [1] },
{ label: 'DPT 10.x', values: [8] },
{ label: 'DPT 11.x', values: [9] },
{ label: 'DPT 13.x', values: [6] },
{ label: 'DPT 14.x', values: [5] },
{ label: 'DPT 16.x', values: [4] }
{ label: 'DPT 16.x', values: [4] },
{ label: 'DPT 19.x', values: [10] }
];
export const sourceOptions = {
label: [0, 1, 2, 3, 4, 5, 6, 7],
label: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
button: [0],
led: [0, 2],
icon: [0, 2],
powernode: [0, 1, 2, 3, 4, 5, 6, 7],
powerlink: [0, 1, 3, 5, 6, 7]
powerlink: [0, 1, 3, 5, 6, 7],
chart: [1, 3, 5, 6, 7]
};
export const chartPeriods = [
{ value: 0, label: '1h' },
{ value: 1, label: '3h' },
{ value: 2, label: '5h' },
{ value: 3, label: '12h' },
{ value: 4, label: '24h' },
{ value: 5, label: '1 Monat' }
];
export const ICON_DEFAULTS = {
iconCodepoint: 0,
iconPosition: 0,
@ -104,7 +123,10 @@ export const defaultFormats = {
4: '%s',
5: '%.1f W',
6: '%.0f kWh',
7: '%d'
7: '%d',
8: '%02d:%02d:%02d',
9: '%02d.%02d.%04d',
10: '%02d.%02d.%04d %02d:%02d:%02d'
};
export const WIDGET_DEFAULTS = {
@ -306,5 +328,33 @@ export const WIDGET_DEFAULTS = {
iconPosition: 0,
iconSize: 0,
iconGap: 6
},
chart: {
w: 360,
h: 220,
text: 'Chart',
textSrc: 0,
fontSize: 1,
textAlign: TEXT_ALIGNS.LEFT,
textColor: '#E7EDF3',
bgColor: '#16202c',
bgOpacity: 255,
radius: 12,
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 0,
chart: {
period: 0,
series: [
{ knxAddr: 0, textSrc: 1, color: '#EF6351' }
]
}
}
};

View File

@ -7,6 +7,7 @@ export const useEditorStore = defineStore('editor', () => {
const config = reactive({
startScreen: 0,
standby: { enabled: false, screen: -1, minutes: 5 },
knx: { time: 0, date: 0, dateTime: 0, night: 0 },
screens: []
});
@ -46,8 +47,40 @@ export const useEditorStore = defineStore('editor', () => {
w.knxAddrWrite = addrByIndex.get(w.knxAddrWrite);
}
}
if (w.chart && Array.isArray(w.chart.series)) {
w.chart.series.forEach((series) => {
if (typeof series.knxAddr === 'number' && series.knxAddr > 0) {
if (!gaSet.has(series.knxAddr) && addrByIndex.has(series.knxAddr)) {
series.knxAddr = addrByIndex.get(series.knxAddr);
}
}
});
}
});
});
if (config.knx) {
if (typeof config.knx.time === 'number' && config.knx.time > 0) {
if (!gaSet.has(config.knx.time) && addrByIndex.has(config.knx.time)) {
config.knx.time = addrByIndex.get(config.knx.time);
}
}
if (typeof config.knx.date === 'number' && config.knx.date > 0) {
if (!gaSet.has(config.knx.date) && addrByIndex.has(config.knx.date)) {
config.knx.date = addrByIndex.get(config.knx.date);
}
}
if (typeof config.knx.dateTime === 'number' && config.knx.dateTime > 0) {
if (!gaSet.has(config.knx.dateTime) && addrByIndex.has(config.knx.dateTime)) {
config.knx.dateTime = addrByIndex.get(config.knx.dateTime);
}
}
if (typeof config.knx.night === 'number' && config.knx.night > 0) {
if (!gaSet.has(config.knx.night) && addrByIndex.has(config.knx.night)) {
config.knx.night = addrByIndex.get(config.knx.night);
}
}
}
}
const activeScreen = computed(() => {
@ -176,11 +209,20 @@ export const useEditorStore = defineStore('editor', () => {
];
config.startScreen = 0;
config.standby = { enabled: false, screen: -1, minutes: 5 };
config.knx = { time: 0, date: 0, dateTime: 0, night: 0 };
}
if (!config.standby) {
config.standby = { enabled: false, screen: -1, minutes: 5 };
}
if (!config.knx) {
config.knx = { time: 0, date: 0, dateTime: 0, night: 0 };
} else {
if (config.knx.time === undefined) config.knx.time = 0;
if (config.knx.date === undefined) config.knx.date = 0;
if (config.knx.dateTime === undefined) config.knx.dateTime = 0;
if (config.knx.night === undefined) config.knx.night = 0;
}
mapLegacyKnxAddresses();
// Recalculate IDs
@ -277,6 +319,7 @@ export const useEditorStore = defineStore('editor', () => {
case 'powerflow': typeValue = WIDGET_TYPES.POWERFLOW; break;
case 'powernode': typeValue = WIDGET_TYPES.POWERNODE; break;
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
case 'chart': typeValue = WIDGET_TYPES.CHART; break;
default: typeValue = WIDGET_TYPES.LABEL;
}
@ -353,6 +396,13 @@ export const useEditorStore = defineStore('editor', () => {
iconGap: defaults.iconGap || 8
};
if (defaults.chart) {
w.chart = {
period: defaults.chart.period ?? 0,
series: (defaults.chart.series || []).map(s => ({ ...s }))
};
}
activeScreen.value.widgets.push(w);
selectedWidgetId.value = w.id;

View File

@ -18,6 +18,7 @@ export function minSizeFor(widget) {
if (key === 'powerflow') return { w: 240, h: 180 };
if (key === 'powernode') return { w: 70, h: 70 };
if (key === 'powerlink') return { w: 1, h: 1 };
if (key === 'chart') return { w: 160, h: 120 };
return { w: 40, h: 20 };
}
@ -49,6 +50,36 @@ export function normalizeWidget(w, nextWidgetIdRef) {
});
}
if (defaults.chart) {
const maxSeries = 3;
if (!w.chart) {
w.chart = {
period: defaults.chart.period ?? 0,
series: (defaults.chart.series || []).map(s => ({ ...s }))
};
} else {
if (w.chart.period === undefined || w.chart.period === null) {
w.chart.period = defaults.chart.period ?? 0;
}
if (!Array.isArray(w.chart.series)) {
w.chart.series = [];
}
if (w.chart.series.length === 0 && defaults.chart.series) {
w.chart.series = defaults.chart.series.map(s => ({ ...s }));
}
for (let i = 0; i < w.chart.series.length && i < maxSeries; i++) {
const fallback = (defaults.chart.series && defaults.chart.series[i]) || defaults.chart.series?.[0] || { knxAddr: 0, textSrc: 1, color: '#EF6351' };
const entry = w.chart.series[i];
if (entry.knxAddr === undefined || entry.knxAddr === null) entry.knxAddr = fallback.knxAddr;
if (entry.textSrc === undefined || entry.textSrc === null) entry.textSrc = fallback.textSrc;
if (!entry.color) entry.color = fallback.color;
}
if (w.chart.series.length > maxSeries) {
w.chart.series = w.chart.series.slice(0, maxSeries);
}
}
}
if (w.visible === undefined || w.visible === null) w.visible = true;
if (w.x === undefined || w.x === null) w.x = 100;
if (w.y === undefined || w.y === null) w.y = 100;