From 631d1eb250981391d7f857ad08d8a47c1c8978f2 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Thu, 29 Jan 2026 19:33:12 +0100 Subject: [PATCH] Fixes --- main/CMakeLists.txt | 3 +- main/HistoryStore.cpp | 543 ++++++++++++++++++ main/HistoryStore.hpp | 127 ++++ main/KnxWorker.cpp | 23 +- main/WidgetConfig.cpp | 36 ++ main/WidgetConfig.hpp | 27 +- main/WidgetManager.cpp | 250 ++++++++ main/WidgetManager.hpp | 27 + main/widgets/ChartWidget.cpp | 209 +++++++ main/widgets/ChartWidget.hpp | 25 + main/widgets/LabelWidget.cpp | 44 ++ main/widgets/LabelWidget.hpp | 1 + main/widgets/PowerNodeWidget.cpp | 1 - main/widgets/Widget.cpp | 8 + main/widgets/Widget.hpp | 3 + main/widgets/WidgetFactory.cpp | 3 + sdcard_content/webseite/index.html | 383 +++++++++++- .../src/components/SettingsModal.vue | 39 ++ web-interface/src/components/SidebarLeft.vue | 4 + web-interface/src/components/SidebarRight.vue | 69 ++- .../src/components/WidgetElement.vue | 16 + web-interface/src/constants.js | 66 ++- web-interface/src/stores/editor.js | 50 ++ web-interface/src/utils.js | 31 + 24 files changed, 1966 insertions(+), 22 deletions(-) create mode 100644 main/HistoryStore.cpp create mode 100644 main/HistoryStore.hpp create mode 100644 main/widgets/ChartWidget.cpp create mode 100644 main/widgets/ChartWidget.hpp diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index e0d75c2..7fa3ae3 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -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" diff --git a/main/HistoryStore.cpp b/main/HistoryStore.cpp new file mode 100644 index 0000000..6708d7e --- /dev/null +++ b/main/HistoryStore.cpp @@ -0,0 +1,543 @@ +#include "HistoryStore.hpp" +#include "SdCard.hpp" +#include "esp_log.h" +#include "esp_timer.h" +#include +#include +#include +#include + +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 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 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(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(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 sums = {}; + std::array counts = {}; + + auto accumulate = [&](const HistoryPoint& p) { + if (p.ts < start || p.ts > nowSec) return; + size_t bucket = static_cast(((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(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(nowSec), series.latestValue}); + series.lastFineSampleTs = static_cast(nowSec); + added = true; + } + + if (series.coarse.count == 0 || nowSec - series.lastCoarseSampleTs >= HISTORY_COARSE_INTERVAL) { + series.coarse.push({static_cast(nowSec), series.latestValue}); + series.lastCoarseSampleTs = static_cast(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(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(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(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(series.key.source); + sh.fineCount = static_cast(series.fine.count); + sh.fineHead = static_cast(series.fine.head); + sh.coarseCount = static_cast(series.coarse.count); + sh.coarseHead = static_cast(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(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(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"); +} diff --git a/main/HistoryStore.hpp b/main/HistoryStore.hpp new file mode 100644 index 0000000..5dabf1b --- /dev/null +++ b/main/HistoryStore.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include "WidgetConfig.hpp" +#include +#include +#include +#include + +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 + struct RingBuffer { + std::array 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 + 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 fine; + RingBuffer 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 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; +}; diff --git a/main/KnxWorker.cpp b/main/KnxWorker.cpp index b33d80f..574e340 100644 --- a/main/KnxWorker.cpp +++ b/main/KnxWorker.cpp @@ -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(timeValue), + KnxTimeType::TIME); + } + + struct tm dateTm = {}; + KNXValue dateValue(dateTm); + if (go.tryValue(dateValue, DPT_Date)) { + WidgetManager::instance().onKnxTime(groupAddr, static_cast(dateValue), + KnxTimeType::DATE); + } + + struct tm dateTimeTm = {}; + KNXValue dateTimeValue(dateTimeTm); + if (go.tryValue(dateTimeValue, DPT_DateTime)) { + WidgetManager::instance().onKnxTime(groupAddr, static_cast(dateTimeValue), + KnxTimeType::DATETIME); + } + KNXValue textValue = ""; if (go.tryValue(textValue, DPT_String_8859_1) || go.tryValue(textValue, DPT_String_ASCII)) { diff --git a/main/WidgetConfig.cpp b/main/WidgetConfig.cpp index f4d0fbe..bd2ac4a 100644 --- a/main/WidgetConfig.cpp +++ b/main/WidgetConfig.cpp @@ -50,6 +50,22 @@ void WidgetConfig::serialize(uint8_t* buf) const { // Hierarchy buf[pos++] = static_cast(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(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(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(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(i), nullptr); } diff --git a/main/WidgetConfig.hpp b/main/WidgetConfig.hpp index 9debde6..08d18fb 100644 --- a/main/WidgetConfig.hpp +++ b/main/WidgetConfig.hpp @@ -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); diff --git a/main/WidgetManager.cpp b/main/WidgetManager.cpp index 3f72223..484d5c9 100644 --- a/main/WidgetManager.cpp +++ b/main/WidgetManager.cpp @@ -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(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(TextAlign::CENTER); w.isContainer = false; + w.chartPeriod = static_cast(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(ChartPeriod::MONTH_1)) { + periodVal = static_cast(ChartPeriod::MONTH_1); + } + w.chartPeriod = static_cast(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(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; diff --git a/main/WidgetManager.hpp b/main/WidgetManager.hpp index a21f899..bb43cc4 100644 --- a/main/WidgetManager.hpp +++ b/main/WidgetManager.hpp @@ -7,8 +7,15 @@ #include "freertos/queue.h" #include "freertos/portmacro.h" #include +#include #include +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 knxNumericCache_ = {}; std::array knxSwitchCache_ = {}; std::array knxTextCache_ = {}; + std::array knxTimeCache_ = {}; size_t knxNumericCacheNext_ = 0; size_t knxSwitchCacheNext_ = 0; size_t knxTextCacheNext_ = 0; + size_t knxTimeCacheNext_ = 0; mutable portMUX_TYPE knxCacheMux_ = {}; }; diff --git a/main/widgets/ChartWidget.cpp b/main/widgets/ChartWidget.cpp new file mode 100644 index 0000000..62432a8 --- /dev/null +++ b/main/widgets/ChartWidget.cpp @@ -0,0 +1,209 @@ +#include "ChartWidget.hpp" +#include "../Fonts.hpp" +#include "lvgl.h" +#include + +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(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(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); +} diff --git a/main/widgets/ChartWidget.hpp b/main/widgets/ChartWidget.hpp new file mode 100644 index 0000000..072f2bb --- /dev/null +++ b/main/widgets/ChartWidget.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "Widget.hpp" +#include "../HistoryStore.hpp" +#include + +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, CHART_MAX_SERIES> seriesData_ = {}; +}; diff --git a/main/widgets/LabelWidget.cpp b/main/widgets/LabelWidget.cpp index cfbbcba..06e041f 100644 --- a/main/widgets/LabelWidget.cpp +++ b/main/widgets/LabelWidget.cpp @@ -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); +} diff --git a/main/widgets/LabelWidget.hpp b/main/widgets/LabelWidget.hpp index 12fe68d..97f3fd0 100644 --- a/main/widgets/LabelWidget.hpp +++ b/main/widgets/LabelWidget.hpp @@ -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; diff --git a/main/widgets/PowerNodeWidget.cpp b/main/widgets/PowerNodeWidget.cpp index 4382a4c..9b2625d 100644 --- a/main/widgets/PowerNodeWidget.cpp +++ b/main/widgets/PowerNodeWidget.cpp @@ -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_); diff --git a/main/widgets/Widget.cpp b/main/widgets/Widget.cpp index 834bacb..6811ef1 100644 --- a/main/widgets/Widget.cpp +++ b/main/widgets/Widget.cpp @@ -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; diff --git a/main/widgets/Widget.hpp b/main/widgets/Widget.hpp index 7f44f38..e84b7d3 100644 --- a/main/widgets/Widget.hpp +++ b/main/widgets/Widget.hpp @@ -2,6 +2,7 @@ #include "../WidgetConfig.hpp" #include "lvgl.h" +#include 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 diff --git a/main/widgets/WidgetFactory.cpp b/main/widgets/WidgetFactory.cpp index 8c08a89..414c6fc 100644 --- a/main/widgets/WidgetFactory.cpp +++ b/main/widgets/WidgetFactory.cpp @@ -8,6 +8,7 @@ #include "PowerFlowWidget.hpp" #include "PowerNodeWidget.hpp" #include "PowerLinkWidget.hpp" +#include "ChartWidget.hpp" std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { if (!config.visible) return nullptr; @@ -31,6 +32,8 @@ std::unique_ptr WidgetFactory::create(const WidgetConfig& config) { return std::make_unique(config); case WidgetType::POWERLINK: return std::make_unique(config); + case WidgetType::CHART: + return std::make_unique(config); default: return nullptr; } diff --git a/sdcard_content/webseite/index.html b/sdcard_content/webseite/index.html index 2bccd7b..6381748 100644 --- a/sdcard_content/webseite/index.html +++ b/sdcard_content/webseite/index.html @@ -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 @@ LED Status + @@ -710,6 +763,28 @@ + +
+
+

KNX Zeit

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
@@ -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 = ['']; + knxAddresses.forEach((a) => { + options.push(``); + }); + 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 @@ ` : ''} `; + } 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) => ``).join(''); + + const seriesHtml = chartSeries.slice(0, seriesCount).map((series, idx) => { + const srcOptions = buildTextSourceOptions(sourceOptions.chart, series.textSrc ?? 1); + const knxSeriesOptions = knxAddresses.map((a) => + `` + ).join(''); + return ` +
+
+ +
+
+ +
+
+ +
+
+ `; + }).join(''); + + contentSection = ` +

Chart

+
+
+ +
+
+ +
+ ${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(); diff --git a/web-interface/src/components/SettingsModal.vue b/web-interface/src/components/SettingsModal.vue index 257d08b..083aec4 100644 --- a/web-interface/src/components/SettingsModal.vue +++ b/web-interface/src/components/SettingsModal.vue @@ -30,6 +30,45 @@ +
+
KNX Zeit
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/web-interface/src/components/SidebarLeft.vue b/web-interface/src/components/SidebarLeft.vue index c133b53..ffbe199 100644 --- a/web-interface/src/components/SidebarLeft.vue +++ b/web-interface/src/components/SidebarLeft.vue @@ -34,6 +34,10 @@ Power Node Element +
diff --git a/web-interface/src/components/SidebarRight.vue b/web-interface/src/components/SidebarRight.vue index 2c0a0e9..a350f61 100644 --- a/web-interface/src/components/SidebarRight.vue +++ b/web-interface/src/components/SidebarRight.vue @@ -208,6 +208,46 @@ + + + +