This commit is contained in:
Thomas Peterson 2026-01-30 19:07:52 +01:00
parent bef0d5504b
commit 27613db808
15 changed files with 224 additions and 492 deletions

View File

@ -25,7 +25,7 @@ static void widget_manager_timer_cb(lv_timer_t* timer)
(void)timer; (void)timer;
// Debug: Log every 100th call to verify timer is running // Debug: Log every 100th call to verify timer is running
static uint32_t callCount = 0; static uint32_t callCount = 0;
if (++callCount % 100 == 0) { if (++callCount % 10 == 0) {
ESP_LOGI("Gui", "Timer tick %lu", callCount); ESP_LOGI("Gui", "Timer tick %lu", callCount);
} }
WidgetManager::instance().loop(); WidgetManager::instance().loop();
@ -40,7 +40,7 @@ void Gui::create()
// TEMP: Disabled long press handler for testing // TEMP: Disabled long press handler for testing
// lv_obj_add_event_cb(lv_scr_act(), screen_long_press_handler, // lv_obj_add_event_cb(lv_scr_act(), screen_long_press_handler,
// LV_EVENT_LONG_PRESSED, NULL); // LV_EVENT_LONG_PRESSED, NULL);
lv_timer_create(widget_manager_timer_cb, 10, nullptr); lv_timer_create(widget_manager_timer_cb, 50, nullptr);
esp_lv_adapter_unlock(); esp_lv_adapter_unlock();
} }

View File

@ -1,43 +1,14 @@
#include "HistoryStore.hpp" #include "HistoryStore.hpp"
#include "SdCard.hpp"
#include "esp_log.h" #include "esp_log.h"
#include "esp_timer.h"
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstring> #include <ctime>
#include <sys/time.h> #include <cmath>
static const char* TAG = "HistoryStore"; 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) // HistoryStore is effectively disabled to save resources and prevent hangs.
struct HistoryFileHeader { // It now acts as a simple pass-through for the latest value (for real-time chart indication)
uint32_t magic; // but does not record history or write to SD card.
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() { HistoryStore& HistoryStore::instance() {
static HistoryStore inst; static HistoryStore inst;
@ -115,10 +86,7 @@ void HistoryStore::configureFromConfig(const GuiConfig& config) {
for (size_t i = 0; i < neededCount; ++i) { for (size_t i = 0; i < neededCount; ++i) {
if (keysEqual(needed[i], key)) return; if (keysEqual(needed[i], key)) return;
} }
if (neededCount >= HISTORY_MAX_SERIES) { if (neededCount >= HISTORY_MAX_SERIES) return;
ESP_LOGW(TAG, "History series limit reached");
return;
}
needed[neededCount++] = key; needed[neededCount++] = key;
}; };
@ -136,69 +104,32 @@ void HistoryStore::configureFromConfig(const GuiConfig& config) {
} }
std::array<bool, HISTORY_MAX_SERIES> keep = {}; std::array<bool, HISTORY_MAX_SERIES> keep = {};
bool changed = false;
size_t activeCount = 0;
for (size_t i = 0; i < neededCount; ++i) { for (size_t i = 0; i < neededCount; ++i) {
const SeriesKey& key = needed[i]; const SeriesKey& key = needed[i];
HistorySeries* existing = findSeriesByKey(key); HistorySeries* existing = findSeriesByKey(key);
if (existing) { if (existing) {
size_t idx = static_cast<size_t>(existing - series_.data()); size_t idx = static_cast<size_t>(existing - series_.data());
if (!existing->active) {
changed = true;
}
keep[idx] = true; keep[idx] = true;
continue; continue;
} }
// Find empty slot
HistorySeries* slot = nullptr; HistorySeries* slot = nullptr;
for (size_t si = 0; si < series_.size(); ++si) { for (size_t si = 0; si < series_.size(); ++si) {
if (!keep[si] && !series_[si].active && series_[si].key.addr == 0) { if (!keep[si] && !series_[si].active) {
slot = &series_[si]; slot = &series_[si];
keep[si] = true; keep[si] = true;
break; break;
} }
} }
if (!slot) { if (slot) {
for (size_t si = 0; si < series_.size(); ++si) { slot->key = key;
if (!keep[si] && !series_[si].active) { slot->active = true;
slot = &series_[si]; slot->hasLatest = false;
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) { for (size_t i = 0; i < series_.size(); ++i) {
bool nextActive = keep[i]; series_[i].active = keep[i];
if (series_[i].active != nextActive) {
changed = true;
}
series_[i].active = nextActive;
if (nextActive) {
activeCount++;
}
}
seriesCount_ = activeCount;
if (changed) {
dirty_ = true;
} }
xSemaphoreGive(mutex_); xSemaphoreGive(mutex_);
} }
@ -217,10 +148,7 @@ bool HistoryStore::updateLatest(uint16_t groupAddr, TextSource source, float val
xSemaphoreGive(mutex_); xSemaphoreGive(mutex_);
return false; return false;
} }
int64_t nowSec = now();
series->latestValue = value; series->latestValue = value;
series->latestTs = static_cast<int32_t>(nowSec);
series->hasLatest = true; series->hasLatest = true;
xSemaphoreGive(mutex_); xSemaphoreGive(mutex_);
return true; return true;
@ -231,298 +159,56 @@ int64_t HistoryStore::now() const {
} }
int32_t HistoryStore::periodSeconds(ChartPeriod period) const { int32_t HistoryStore::periodSeconds(ChartPeriod period) const {
switch (period) { return 3600; // Dummy
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, bool HistoryStore::fillChartSeries(uint16_t groupAddr, TextSource source, ChartPeriod period,
int32_t* outValues, size_t outCount) const { int32_t* outValues, size_t outCount) const {
if (!outValues || outCount == 0) return false; if (!outValues || outCount == 0) return false;
if (outCount > CHART_POINT_COUNT) outCount = CHART_POINT_COUNT;
for (size_t i = 0; i < outCount; ++i) { // Just return the latest value repeated, effectively a flat line
outValues[i] = NO_POINT;
}
xSemaphoreTake(mutex_, portMAX_DELAY); xSemaphoreTake(mutex_, portMAX_DELAY);
const HistorySeries* series = findSeries(groupAddr, source); const HistorySeries* series = findSeries(groupAddr, source);
if (!series) { int32_t val = NO_POINT;
xSemaphoreGive(mutex_); if (series && series->hasLatest) {
return false; val = static_cast<int32_t>(lrintf(series->latestValue));
}
int64_t nowSec = now();
int32_t window = periodSeconds(period);
if (window <= 0) {
xSemaphoreGive(mutex_);
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);
} }
xSemaphoreGive(mutex_); xSemaphoreGive(mutex_);
bool hasData = false;
int32_t lastValidValue = NO_POINT;
for (size_t i = 0; i < outCount; ++i) { for (size_t i = 0; i < outCount; ++i) {
if (counts[i] > 0) { outValues[i] = val;
float avg = sums[i] / counts[i];
int32_t val = static_cast<int32_t>(lrintf(avg));
outValues[i] = val;
lastValidValue = val;
hasData = true;
} else {
outValues[i] = lastValidValue;
if (lastValidValue != NO_POINT) {
hasData = true;
}
}
} }
return (val != NO_POINT);
return hasData;
} }
bool HistoryStore::tick() { bool HistoryStore::tick() {
xSemaphoreTake(mutex_, portMAX_DELAY); return false; // Disabled
int64_t nowSec = now();
// Only collect data if time is roughly synced (after 2020)
if (nowSec < 1577836800LL) {
xSemaphoreGive(mutex_);
return false;
}
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;
}
xSemaphoreGive(mutex_);
return added;
} }
void HistoryStore::performAutoSave() { void HistoryStore::performAutoSave() {
xSemaphoreTake(mutex_, portMAX_DELAY); // Disabled
bool shouldSave = false; }
int64_t nowSec = now();
int64_t monoUs = esp_timer_get_time();
if (dirty_ && nowSec > 1577836800LL && SdCard::instance().isMounted()) {
if (monoUs - lastSaveMonoUs_ >= HISTORY_SAVE_INTERVAL_US) {
shouldSave = true;
lastSaveMonoUs_ = monoUs;
}
}
xSemaphoreGive(mutex_);
if (shouldSave) { void HistoryStore::updateTimeOfDay(const tm& value) {
saveToSdCard(); // Disabled
} }
void HistoryStore::updateDate(const tm& value) {
// Disabled
}
void HistoryStore::updateDateTime(const tm& value) {
// Disabled
} }
void HistoryStore::clearAll() { void HistoryStore::clearAll() {
xSemaphoreTake(mutex_, portMAX_DELAY); // Disabled
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;
}
xSemaphoreGive(mutex_);
} }
void HistoryStore::saveToSdCard() { void HistoryStore::saveToSdCard() {
if (!SdCard::instance().isMounted()) return; // Disabled
// Double check time sync before save
if (now() < 1577836800LL) return;
FILE* f = fopen(HISTORY_FILE, "wb");
if (!f) {
ESP_LOGW(TAG, "Failed to open history file for writing");
return;
}
xSemaphoreTake(mutex_, portMAX_DELAY);
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;
xSemaphoreGive(mutex_);
if (fwrite(&header, sizeof(header), 1, f) != 1) {
fclose(f);
ESP_LOGW(TAG, "Failed to write history header");
return;
}
size_t seriesDataSize = sizeof(HistoryFileSeriesHeader) +
sizeof(HistoryPoint) * (HISTORY_FINE_CAP + HISTORY_COARSE_CAP);
uint8_t* tempBuf = (uint8_t*)malloc(seriesDataSize);
if (!tempBuf) {
fclose(f);
ESP_LOGE(TAG, "Failed to allocate temp buffer for save");
return;
}
for (size_t i = 0; i < series_.size(); ++i) {
xSemaphoreTake(mutex_, portMAX_DELAY);
const HistorySeries& series = series_[i];
if (!series.active) {
xSemaphoreGive(mutex_);
continue;
}
HistoryFileSeriesHeader* sh = (HistoryFileSeriesHeader*)tempBuf;
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;
uint8_t* dataPtr = tempBuf + sizeof(HistoryFileSeriesHeader);
memcpy(dataPtr, series.fine.points.data(), sizeof(HistoryPoint) * HISTORY_FINE_CAP);
dataPtr += sizeof(HistoryPoint) * HISTORY_FINE_CAP;
memcpy(dataPtr, series.coarse.points.data(), sizeof(HistoryPoint) * HISTORY_COARSE_CAP);
xSemaphoreGive(mutex_);
if (fwrite(tempBuf, 1, seriesDataSize, f) != seriesDataSize) {
ESP_LOGW(TAG, "Failed to write series data");
break;
}
}
free(tempBuf);
xSemaphoreTake(mutex_, portMAX_DELAY);
dirty_ = false;
xSemaphoreGive(mutex_);
fclose(f);
ESP_LOGI(TAG, "History saved (%d series)", static_cast<int>(activeCount));
} }
void HistoryStore::loadFromSdCard() { void HistoryStore::loadFromSdCard() {
if (!SdCard::instance().isMounted()) return; // Disabled
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;
}
xSemaphoreTake(mutex_, portMAX_DELAY);
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);
}
}
dirty_ = false;
xSemaphoreGive(mutex_);
fclose(f);
ESP_LOGI(TAG, "History loaded");
} }

View File

@ -24,6 +24,11 @@ public:
int64_t now() const; int64_t now() const;
bool isTimeSynced() const { return now() > 1577836800LL; } bool isTimeSynced() const { return now() > 1577836800LL; }
void updateTimeOfDay(const tm& value);
void updateDate(const tm& value);
void updateDateTime(const tm& value);
void clearAll();
void loadFromSdCard(); void loadFromSdCard();
void saveToSdCard(); void saveToSdCard();
@ -74,9 +79,9 @@ private:
} }
}; };
static constexpr size_t HISTORY_MAX_SERIES = 12; static constexpr size_t HISTORY_MAX_SERIES = 4;
static constexpr size_t HISTORY_FINE_CAP = 720; static constexpr size_t HISTORY_FINE_CAP = 360;
static constexpr size_t HISTORY_COARSE_CAP = 720; static constexpr size_t HISTORY_COARSE_CAP = 360;
static constexpr int32_t HISTORY_FINE_INTERVAL = 120; static constexpr int32_t HISTORY_FINE_INTERVAL = 120;
static constexpr int32_t HISTORY_COARSE_INTERVAL = 3600; static constexpr int32_t HISTORY_COARSE_INTERVAL = 3600;
static constexpr int32_t HISTORY_MONTH_SECONDS = 30 * 24 * 3600; static constexpr int32_t HISTORY_MONTH_SECONDS = 30 * 24 * 3600;
@ -97,7 +102,6 @@ private:
static bool keysEqual(const SeriesKey& a, const SeriesKey& b); static bool keysEqual(const SeriesKey& a, const SeriesKey& b);
int32_t periodSeconds(ChartPeriod period) const; int32_t periodSeconds(ChartPeriod period) const;
void clearAll();
HistorySeries* findSeries(uint16_t groupAddr, TextSource source); HistorySeries* findSeries(uint16_t groupAddr, TextSource source);
const HistorySeries* findSeries(uint16_t groupAddr, TextSource source) const; const HistorySeries* findSeries(uint16_t groupAddr, TextSource source) const;

View File

@ -5,7 +5,7 @@
#include <cstring> #include <cstring>
// Maximum number of widgets // Maximum number of widgets
static constexpr size_t MAX_WIDGETS = 32; static constexpr size_t MAX_WIDGETS = 64;
static constexpr size_t MAX_SCREENS = 8; static constexpr size_t MAX_SCREENS = 8;
static constexpr size_t MAX_TEXT_LEN = 32; static constexpr size_t MAX_TEXT_LEN = 32;
static constexpr size_t MAX_SCREEN_NAME_LEN = 24; static constexpr size_t MAX_SCREEN_NAME_LEN = 24;

View File

@ -347,25 +347,29 @@ void WidgetManager::applyScreenLocked(uint8_t screenId) {
} }
lv_display_t* disp = lv_display_get_default(); lv_display_t* disp = lv_display_get_default();
bool invEnabled = true;
if (disp) { if (disp) {
invEnabled = lv_display_is_invalidation_enabled(disp);
lv_display_enable_invalidation(disp, false); lv_display_enable_invalidation(disp, false);
} }
// Reset all input devices BEFORE destroying widgets // Reset all input devices
ESP_LOGI(TAG, "Resetting input devices...");
lv_indev_t* indev = lv_indev_get_next(nullptr); lv_indev_t* indev = lv_indev_get_next(nullptr);
while (indev) { while (indev) {
lv_indev_reset(indev, nullptr); lv_indev_reset(indev, nullptr);
indev = lv_indev_get_next(indev); indev = lv_indev_get_next(indev);
} }
// Now destroy C++ widgets (which deletes LVGL objects) // SAFE DESTRUCTION:
ESP_LOGI(TAG, "Destroying widgets..."); // 1. Mark all C++ widgets as "LVGL object already gone"
destroyAllWidgets(); for (auto& widget : widgets_) {
if (widget) widget->clearLvglObject();
}
// 2. Delete all LVGL objects on layers we use
lv_obj_clean(lv_scr_act()); lv_obj_clean(lv_scr_act());
ESP_LOGI(TAG, "Widgets destroyed"); lv_obj_clean(lv_layer_top());
// 3. Now destroy C++ objects (their destructors won't call lv_obj_delete)
destroyAllWidgets();
ESP_LOGI(TAG, "Creating new widgets for screen '%s' (%d widgets)...", ESP_LOGI(TAG, "Creating new widgets for screen '%s' (%d widgets)...",
screen->name, screen->widgetCount); screen->name, screen->widgetCount);
@ -373,13 +377,9 @@ void WidgetManager::applyScreenLocked(uint8_t screenId) {
createAllWidgets(*screen, root); createAllWidgets(*screen, root);
ESP_LOGI(TAG, "Widgets created"); ESP_LOGI(TAG, "Widgets created");
applyCachedValuesToWidgets(); applyCachedValuesToWidgets();
#if LV_USE_OBJ_NAME
ESP_LOGI(TAG, "Flex containers for screen '%s':", screen->name);
dump_flex_objects(root, 0);
#endif
if (disp) { if (disp) {
lv_display_enable_invalidation(disp, invEnabled); lv_display_enable_invalidation(disp, true);
} }
lv_obj_invalidate(lv_scr_act()); lv_obj_invalidate(lv_scr_act());
@ -404,6 +404,16 @@ void WidgetManager::showModalScreenLocked(const ScreenConfig& screen) {
indev = lv_indev_get_next(indev); indev = lv_indev_get_next(indev);
} }
// SAFE DESTRUCTION
for (auto& widget : widgets_) {
if (widget) widget->clearLvglObject();
}
lv_obj_clean(lv_scr_act()); // Should we clean screen when showing modal?
// Actually, usually modal is ON TOP of screen.
// But our current WidgetManager destroys screen widgets when showing modal!
// That's why we clean.
lv_obj_clean(lv_layer_top());
destroyAllWidgets(); destroyAllWidgets();
lv_disp_t* disp = lv_disp_get_default(); lv_disp_t* disp = lv_disp_get_default();
@ -513,22 +523,18 @@ void WidgetManager::closeModalLocked() {
indev = lv_indev_get_next(indev); indev = lv_indev_get_next(indev);
} }
// SAFE DESTRUCTION
for (auto& widget : widgets_) {
if (widget) widget->clearLvglObject();
}
lv_obj_clean(lv_layer_top());
// Destroy widgets first // Destroy widgets first
printf("WM: closeModal destroying widgets...\n"); printf("WM: closeModal destroying widgets...\n");
fflush(stdout); fflush(stdout);
destroyAllWidgets(); destroyAllWidgets();
if (modalDimmer_) { modalDimmer_ = nullptr;
if (lv_obj_is_valid(modalDimmer_)) lv_obj_delete(modalDimmer_);
modalDimmer_ = nullptr;
}
if (lv_obj_is_valid(modalContainer_)) {
lv_obj_delete(modalContainer_);
} else {
printf("WM: Warning: modalContainer_ was invalid!\n");
fflush(stdout);
}
modalContainer_ = nullptr; modalContainer_ = nullptr;
modalScreenId_ = SCREEN_ID_NONE; modalScreenId_ = SCREEN_ID_NONE;

View File

@ -40,8 +40,8 @@ public:
// Initialize LVGL adapter // Initialize LVGL adapter
esp_lv_adapter_config_t cfg = ESP_LV_ADAPTER_DEFAULT_CONFIG(); esp_lv_adapter_config_t cfg = ESP_LV_ADAPTER_DEFAULT_CONFIG();
cfg.stack_in_psram = true; cfg.stack_in_psram = false; // Use internal RAM for stack
cfg.task_stack_size = 16 * 1024; cfg.task_stack_size = 32 * 1024;
ESP_ERROR_CHECK(esp_lv_adapter_init(&cfg)); ESP_ERROR_CHECK(esp_lv_adapter_init(&cfg));
// Register display // Register display

View File

@ -123,9 +123,8 @@ void ButtonWidget::setupFlexLayout() {
void ButtonWidget::applyTextAlignment() { void ButtonWidget::applyTextAlignment() {
if (label_ == nullptr) return; if (label_ == nullptr) return;
lv_obj_set_width(label_, LV_PCT(100));
lv_obj_set_style_text_align(label_, toLvTextAlign(config_.textAlign), 0); lv_obj_set_style_text_align(label_, toLvTextAlign(config_.textAlign), 0);
lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); lv_obj_center(label_);
} }
lv_obj_t* ButtonWidget::create(lv_obj_t* parent) { lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
@ -137,23 +136,23 @@ lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this); lv_obj_add_event_cb(obj_, clickCallback, LV_EVENT_CLICKED, this);
bool hasIcon = !config_.isContainer && if (config_.isContainer) {
config_.iconCodepoint > 0 && Fonts::hasIconFont(); ESP_LOGI(TAG, "Created container button %d", config_.id);
return obj_;
}
if (!config_.isContainer) { bool hasIcon = config_.iconCodepoint > 0 && Fonts::hasIconFont();
if (hasIcon) {
// Create container for flex layout if (hasIcon) {
contentContainer_ = lv_obj_create(obj_); // Create container for flex layout
if (contentContainer_ == nullptr) { contentContainer_ = lv_obj_create(obj_);
return obj_; // Continue without icon container if (contentContainer_) {
}
set_obj_name(contentContainer_, "Button", config_.id, "content"); set_obj_name(contentContainer_, "Button", config_.id, "content");
lv_obj_remove_style_all(contentContainer_); lv_obj_remove_style_all(contentContainer_);
lv_obj_set_size(contentContainer_, LV_PCT(100), LV_PCT(100)); lv_obj_set_size(contentContainer_, LV_PCT(100), LV_PCT(100));
lv_obj_center(contentContainer_); lv_obj_center(contentContainer_);
lv_obj_clear_flag(contentContainer_, LV_OBJ_FLAG_CLICKABLE); // Pass clicks to parent lv_obj_clear_flag(contentContainer_, LV_OBJ_FLAG_CLICKABLE);
// Create icon label
bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) || bool iconFirst = (config_.iconPosition == static_cast<uint8_t>(IconPosition::LEFT) ||
config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP)); config_.iconPosition == static_cast<uint8_t>(IconPosition::TOP));
@ -166,7 +165,6 @@ lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(iconLabel_, LV_OBJ_FLAG_CLICKABLE);
} }
// Create text label
label_ = lv_label_create(contentContainer_); label_ = lv_label_create(contentContainer_);
set_obj_name(label_, "Button", config_.id, "text"); set_obj_name(label_, "Button", config_.id, "text");
lv_label_set_text(label_, config_.text); lv_label_set_text(label_, config_.text);
@ -182,14 +180,16 @@ lv_obj_t* ButtonWidget::create(lv_obj_t* parent) {
} }
setupFlexLayout(); setupFlexLayout();
} else {
// Simple button without icon
label_ = lv_label_create(obj_);
set_obj_name(label_, "Button", config_.id, "text");
lv_label_set_text(label_, config_.text);
applyTextAlignment();
lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE);
} }
} else {
// Simple button without icon
label_ = lv_label_create(obj_);
set_obj_name(label_, "Button", config_.id, "text");
lv_label_set_text(label_, config_.text);
lv_label_set_long_mode(label_, LV_LABEL_LONG_WRAP);
lv_obj_set_width(label_, LV_SIZE_CONTENT);
applyTextAlignment();
lv_obj_clear_flag(label_, LV_OBJ_FLAG_CLICKABLE);
} }
ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)", ESP_LOGI(TAG, "Created button '%s' at %d,%d (icon: 0x%lx)",

View File

@ -1,9 +1,6 @@
#include "ClockWidget.hpp" #include "ClockWidget.hpp"
#include <cmath> #include <cmath>
#include <initializer_list>
#ifndef PI
#define PI 3.14159265358979323846f
#endif
ClockWidget::ClockWidget(const WidgetConfig& config) ClockWidget::ClockWidget(const WidgetConfig& config)
: Widget(config) : Widget(config)
@ -20,20 +17,31 @@ lv_obj_t* ClockWidget::create(lv_obj_t* parent) {
config_.width > 0 ? config_.width : 200, config_.width > 0 ? config_.width : 200,
config_.height > 0 ? config_.height : 200); config_.height > 0 ? config_.height : 200);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_CLICKABLE);
// Create hands as rectangles
auto createHand = [&](lv_obj_t*& hand, const char* name) {
hand = lv_obj_create(obj_);
lv_obj_remove_style_all(hand);
lv_obj_add_flag(hand, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_clear_flag(hand, LV_OBJ_FLAG_CLICKABLE);
// Set pivot to bottom center (will be set in applyStyle implicitly by size?)
// We will set alignment there.
};
createHand(hourHand_, "Hour");
createHand(minuteHand_, "Minute");
createHand(secondHand_, "Second");
// Create hands
hourHand_ = lv_line_create(obj_);
minuteHand_ = lv_line_create(obj_);
secondHand_ = lv_line_create(obj_);
centerDot_ = lv_obj_create(obj_); centerDot_ = lv_obj_create(obj_);
// Style center dot
lv_obj_remove_style_all(centerDot_); lv_obj_remove_style_all(centerDot_);
lv_obj_set_size(centerDot_, 8, 8); lv_obj_add_flag(centerDot_, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_clear_flag(centerDot_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(centerDot_, 12, 12);
lv_obj_set_style_radius(centerDot_, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_radius(centerDot_, LV_RADIUS_CIRCLE, 0);
lv_obj_center(centerDot_); lv_obj_center(centerDot_);
// Initial update to set positions (will be updated by loop) // Initial update
time_t now; time_t now;
time(&now); time(&now);
struct tm t; struct tm t;
@ -46,7 +54,7 @@ lv_obj_t* ClockWidget::create(lv_obj_t* parent) {
void ClockWidget::applyStyle() { void ClockWidget::applyStyle() {
if (!obj_) return; if (!obj_) return;
// Face style (background) // Face style
if (config_.bgOpacity > 0) { if (config_.bgOpacity > 0) {
lv_obj_set_style_bg_color(obj_, lv_color_make( lv_obj_set_style_bg_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0); config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
@ -54,7 +62,7 @@ void ClockWidget::applyStyle() {
} }
if (config_.borderRadius > 0) { if (config_.borderRadius > 0) {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0); // Usually 50% for circle lv_obj_set_style_radius(obj_, config_.borderRadius, 0);
} else { } else {
lv_obj_set_style_radius(obj_, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_radius(obj_, LV_RADIUS_CIRCLE, 0);
} }
@ -63,31 +71,62 @@ void ClockWidget::applyStyle() {
lv_obj_set_style_border_color(obj_, lv_color_make( lv_obj_set_style_border_color(obj_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0); config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
// Hand colors
lv_color_t handColor = lv_color_make( lv_color_t handColor = lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b); config_.textColor.r, config_.textColor.g, config_.textColor.b);
lv_color_t secColor = lv_color_make(200, 50, 50); // Red for second hand lv_color_t secColor = lv_color_make(200, 50, 50);
int32_t w = config_.width > 0 ? config_.width : 200;
int32_t h = config_.height > 0 ? config_.height : 200;
int32_t radius = (w < h ? w : h) / 2;
auto configHand = [&](lv_obj_t* hand, int32_t width, int32_t length, lv_color_t color) {
if (!hand) return;
lv_obj_set_size(hand, width, length);
lv_obj_set_style_bg_color(hand, color, 0);
lv_obj_set_style_bg_opa(hand, LV_OPA_COVER, 0);
lv_obj_set_style_radius(hand, width / 2, 0);
// Align to center-bottom of the hand to the center of the clock
// For rotation, we position the hand such that its rotation point is at the clock center.
// We put the hand's bottom-center at the clock's center.
// LVGL Rotation pivot is relative to the object's top-left (0,0) by default?
// No, default pivot is center.
// We want pivot at (width/2, length).
// And we want that pivot point to be at clock center (w/2, h/2).
// Position:
// x = (ClockW - HandW) / 2
// y = (ClockH/2) - HandLength
// This puts the bottom of the hand at the center.
lv_obj_align(hand, LV_ALIGN_CENTER, 0, -length/2);
// Wait, align center puts the *center* of hand at center of clock.
// Hand center is at (w/2, length/2).
// We want hand bottom (w/2, length) to be at clock center.
// So we shift y by -length/2.
// Pivot:
// Set pivot to (width/2, length).
// lv_obj_set_style_transform_pivot_x(hand, width/2, 0);
// lv_obj_set_style_transform_pivot_y(hand, length, 0); // Bottom
// BUT: lv_obj_align(..., 0, -length/2) moves it up.
// Let's verify.
lv_obj_set_style_transform_pivot_x(hand, width / 2, 0);
lv_obj_set_style_transform_pivot_y(hand, length - 2, 0); // Slightly above bottom for overlap
};
configHand(hourHand_, 6, radius * 0.6, handColor);
configHand(minuteHand_, 4, radius * 0.85, handColor);
configHand(secondHand_, 2, radius * 0.9, secColor);
if (hourHand_) {
lv_obj_set_style_line_width(hourHand_, 6, 0);
lv_obj_set_style_line_color(hourHand_, handColor, 0);
lv_obj_set_style_line_rounded(hourHand_, true, 0);
}
if (minuteHand_) {
lv_obj_set_style_line_width(minuteHand_, 4, 0);
lv_obj_set_style_line_color(minuteHand_, handColor, 0);
lv_obj_set_style_line_rounded(minuteHand_, true, 0);
}
if (secondHand_) {
lv_obj_set_style_line_width(secondHand_, 2, 0);
lv_obj_set_style_line_color(secondHand_, secColor, 0);
lv_obj_set_style_line_rounded(secondHand_, true, 0);
}
if (centerDot_) { if (centerDot_) {
lv_obj_set_style_bg_color(centerDot_, handColor, 0); lv_obj_set_style_bg_color(centerDot_, handColor, 0);
lv_obj_set_style_bg_opa(centerDot_, LV_OPA_COVER, 0);
} }
// Force redraw of hands with new style/dimensions // Force update
lastSecond_ = -1; lastSecond_ = -1;
time_t now; time_t now;
time(&now); time(&now);
@ -103,49 +142,21 @@ void ClockWidget::updateHands(const struct tm& t) {
lastMinute_ = t.tm_min; lastMinute_ = t.tm_min;
lastHour_ = t.tm_hour; lastHour_ = t.tm_hour;
int32_t w = lv_obj_get_width(obj_); // Calculate angles in 0.1 degree units (LVGL 9)
int32_t h = lv_obj_get_height(obj_); // Second: 6 deg * 10 = 60 per sec
int32_t cx = w / 2; int32_t angleSec = t.tm_sec * 60;
int32_t cy = h / 2;
int32_t radius = (w < h ? w : h) / 2; // Minute: (6 deg * min + 0.1 deg * sec) * 10 = 60 * min + sec
int32_t angleMin = t.tm_min * 60 + t.tm_sec;
// Hour: (30 deg * hr + 0.5 deg * min) * 10 = 300 * (hr%12) + 5 * min
int32_t angleHour = (t.tm_hour % 12) * 300 + t.tm_min * 5;
// Lengths if (secondHand_) lv_obj_set_style_transform_rotation(secondHand_, angleSec, 0);
int32_t lenSec = radius - 10; if (minuteHand_) lv_obj_set_style_transform_rotation(minuteHand_, angleMin, 0);
int32_t lenMin = radius - 20; if (hourHand_) lv_obj_set_style_transform_rotation(hourHand_, angleHour, 0);
int32_t lenHour = radius * 0.6f;
// Angles (0 is top, clockwise)
// Second: 6 deg per sec
float angleSec = t.tm_sec * 6.0f;
// Minute: 6 deg per min + 0.1 deg per sec
float angleMin = t.tm_min * 6.0f + t.tm_sec * 0.1f;
// Hour: 30 deg per hour + 0.5 deg per min
float angleHour = (t.tm_hour % 12) * 30.0f + t.tm_min * 0.5f;
auto calcPoints = [&](float angle, int32_t len, lv_point_precise_t* p) {
float rad = (angle - 90.0f) * PI / 180.0f;
p[0].x = cx;
p[0].y = cy;
p[1].x = cx + static_cast<int32_t>(cos(rad) * len);
p[1].y = cy + static_cast<int32_t>(sin(rad) * len);
};
if (secondHand_) {
calcPoints(angleSec, lenSec, secondPoints_);
lv_line_set_points(secondHand_, secondPoints_, 2);
}
if (minuteHand_) {
calcPoints(angleMin, lenMin, minutePoints_);
lv_line_set_points(minuteHand_, minutePoints_, 2);
}
if (hourHand_) {
calcPoints(angleHour, lenHour, hourPoints_);
lv_line_set_points(hourHand_, hourPoints_, 2);
}
} }
void ClockWidget::onKnxTime(const struct tm& value, TextSource source) { void ClockWidget::onKnxTime(const struct tm& value, TextSource source) {
// Only accept system time updates or explicitly configured KNX time sources if we supported them
// For now, ClockWidget listens to SYSTEM_TIME via WidgetManager broadcast
updateHands(value); updateHands(value);
} }

View File

@ -20,11 +20,6 @@ private:
lv_obj_t* secondHand_ = nullptr; lv_obj_t* secondHand_ = nullptr;
lv_obj_t* centerDot_ = nullptr; lv_obj_t* centerDot_ = nullptr;
// Persistent point arrays for lines (LVGL does not copy them)
lv_point_precise_t hourPoints_[2];
lv_point_precise_t minutePoints_[2];
lv_point_precise_t secondPoints_[2];
// Cache current time to avoid redrawing if not changed // Cache current time to avoid redrawing if not changed
int lastHour_ = -1; int lastHour_ = -1;
int lastMinute_ = -1; int lastMinute_ = -1;

View File

@ -19,6 +19,7 @@ public:
// Access to LVGL object // Access to LVGL object
lv_obj_t* getLvglObject() const { return obj_; } lv_obj_t* getLvglObject() const { return obj_; }
lv_obj_t* getObj() const { return obj_; } // Alias lv_obj_t* getObj() const { return obj_; } // Alias
void clearLvglObject() { obj_ = nullptr; }
// Widget ID // Widget ID
uint8_t getId() const { return config_.id; } uint8_t getId() const { return config_.id; }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web-interface</title> <title>web-interface</title>
<script type="module" crossorigin src="/assets/index-itIBE803.js"></script> <script type="module" crossorigin src="/assets/index-D_mZROR3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BV9BQMzn.css"> <link rel="stylesheet" crossorigin href="/assets/index-D_QgTvEC.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -15,11 +15,11 @@
<div :class="rowClass"><label :class="labelClass">Sichtbar</label><input class="accent-[var(--accent)]" type="checkbox" v-model="w.visible"></div> <div :class="rowClass"><label :class="labelClass">Sichtbar</label><input class="accent-[var(--accent)]" type="checkbox" v-model="w.visible"></div>
<!-- Content --> <!-- Content -->
<template v-if="key === 'label'"> <template v-if="key === 'label' || key === 'button'">
<h4 :class="headingClass">Inhalt</h4> <h4 :class="headingClass">Inhalt</h4>
<div :class="rowClass"><label :class="labelClass">Quelle</label> <div :class="rowClass"><label :class="labelClass">Quelle</label>
<select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange"> <select :class="inputClass" v-model.number="w.textSrc" @change="handleTextSrcChange">
<optgroup v-for="group in groupedSources(sourceOptions.label)" :key="group.label" :label="group.label"> <optgroup v-for="group in groupedSources(sourceOptions[key])" :key="group.label" :label="group.label">
<option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option> <option v-for="opt in group.values" :key="opt" :value="opt">{{ textSources[opt] }}</option>
</optgroup> </optgroup>
</select> </select>
@ -40,11 +40,6 @@
</template> </template>
</template> </template>
<template v-if="key === 'button'">
<h4 :class="headingClass">Button</h4>
<div :class="rowClass"><label :class="labelClass">Hinweis</label><span :class="noteClass">Text als Label-Child anlegen.</span></div>
</template>
<template v-if="key === 'led'"> <template v-if="key === 'led'">
<h4 :class="headingClass">LED</h4> <h4 :class="headingClass">LED</h4>
<div :class="rowClass"><label :class="labelClass">Modus</label> <div :class="rowClass"><label :class="labelClass">Modus</label>

View File

@ -125,6 +125,19 @@
</div> </div>
</template> </template>
<template v-else-if="isClock">
<div class="relative w-full h-full rounded-full border-2 box-border flex items-center justify-center overflow-hidden" :style="{ borderColor: widget.textColor }">
<!-- Center Dot -->
<div class="absolute w-2 h-2 rounded-full z-10" :style="{ backgroundColor: widget.textColor }"></div>
<!-- Hour Hand -->
<div class="absolute w-1.5 h-[28%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(300deg)' }"></div>
<!-- Minute Hand -->
<div class="absolute w-1 h-[40%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full" :style="{ backgroundColor: widget.textColor, transform: 'rotate(70deg)' }"></div>
<!-- Second Hand -->
<div class="absolute w-0.5 h-[45%] bottom-1/2 left-1/2 -translate-x-1/2 origin-bottom rounded-full bg-[#c83232]" :style="{ transform: 'rotate(140deg)' }"></div>
</div>
</template>
<!-- Icon-only Widget --> <!-- Icon-only Widget -->
<template v-else-if="isIcon"> <template v-else-if="isIcon">
<span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle"> <span class="material-symbols-outlined flex items-center justify-center" :style="iconOnlyStyle">
@ -199,6 +212,7 @@ const isPowerFlow = computed(() => props.widget.type === WIDGET_TYPES.POWERFLOW)
const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE); const isPowerNode = computed(() => props.widget.type === WIDGET_TYPES.POWERNODE);
const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK); const isPowerLink = computed(() => props.widget.type === WIDGET_TYPES.POWERLINK);
const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART); const isChart = computed(() => props.widget.type === WIDGET_TYPES.CHART);
const isClock = computed(() => props.widget.type === WIDGET_TYPES.CLOCK);
const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom... const tabPosition = computed(() => props.widget.iconPosition || 0); // 0=Top, 1=Bottom...
const tabHeight = computed(() => (props.widget.iconSize || 5) * 10); const tabHeight = computed(() => (props.widget.iconSize || 5) * 10);
@ -557,6 +571,16 @@ const computedStyle = computed(() => {
style.outline = `2px dashed ${hexToRgba('#4f8ad9', 0.9)}`; style.outline = `2px dashed ${hexToRgba('#4f8ad9', 0.9)}`;
style.outlineOffset = '2px'; style.outlineOffset = '2px';
} }
} else if (isClock.value) {
if (w.bgOpacity > 0) {
const alpha = clamp(w.bgOpacity / 255, 0, 1).toFixed(2);
style.background = hexToRgba(w.bgColor, alpha);
}
if (w.radius > 0) {
style.borderRadius = `${w.radius * s}px`;
} else {
style.borderRadius = '50%';
}
} else if (isTabView.value) { } else if (isTabView.value) {
style.background = w.bgColor; style.background = w.bgColor;
style.borderRadius = `${w.radius * s}px`; style.borderRadius = `${w.radius * s}px`;