This commit is contained in:
Thomas Peterson 2026-01-30 17:14:50 +01:00
parent 7e451b4e3b
commit bef0d5504b
13 changed files with 260 additions and 14 deletions

View File

@ -11,6 +11,7 @@ idf_component_register(SRCS "HistoryStore.cpp" "KnxWorker.cpp" "Nvs.cpp" "main.c
"widgets/PowerNodeWidget.cpp" "widgets/PowerNodeWidget.cpp"
"widgets/PowerLinkWidget.cpp" "widgets/PowerLinkWidget.cpp"
"widgets/ChartWidget.cpp" "widgets/ChartWidget.cpp"
"widgets/ClockWidget.cpp"
"webserver/WebServer.cpp" "webserver/WebServer.cpp"
"webserver/StaticFileHandlers.cpp" "webserver/StaticFileHandlers.cpp"
"webserver/ConfigHandlers.cpp" "webserver/ConfigHandlers.cpp"

View File

@ -22,6 +22,7 @@ enum class WidgetType : uint8_t {
POWERNODE = 7, POWERNODE = 7,
POWERLINK = 8, POWERLINK = 8,
CHART = 9, CHART = 9,
CLOCK = 10,
}; };
enum class IconPosition : uint8_t { enum class IconPosition : uint8_t {

View File

@ -0,0 +1,151 @@
#include "ClockWidget.hpp"
#include <cmath>
#ifndef PI
#define PI 3.14159265358979323846f
#endif
ClockWidget::ClockWidget(const WidgetConfig& config)
: Widget(config)
{
}
lv_obj_t* ClockWidget::create(lv_obj_t* parent) {
obj_ = lv_obj_create(parent);
if (!obj_) return nullptr;
lv_obj_remove_style_all(obj_);
lv_obj_set_pos(obj_, config_.x, config_.y);
lv_obj_set_size(obj_,
config_.width > 0 ? config_.width : 200,
config_.height > 0 ? config_.height : 200);
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_SCROLLABLE);
// Create hands
hourHand_ = lv_line_create(obj_);
minuteHand_ = lv_line_create(obj_);
secondHand_ = lv_line_create(obj_);
centerDot_ = lv_obj_create(obj_);
// Style center dot
lv_obj_remove_style_all(centerDot_);
lv_obj_set_size(centerDot_, 8, 8);
lv_obj_set_style_radius(centerDot_, LV_RADIUS_CIRCLE, 0);
lv_obj_center(centerDot_);
// Initial update to set positions (will be updated by loop)
time_t now;
time(&now);
struct tm t;
localtime_r(&now, &t);
updateHands(t);
return obj_;
}
void ClockWidget::applyStyle() {
if (!obj_) return;
// Face style (background)
if (config_.bgOpacity > 0) {
lv_obj_set_style_bg_color(obj_, lv_color_make(
config_.bgColor.r, config_.bgColor.g, config_.bgColor.b), 0);
lv_obj_set_style_bg_opa(obj_, config_.bgOpacity, 0);
}
if (config_.borderRadius > 0) {
lv_obj_set_style_radius(obj_, config_.borderRadius, 0); // Usually 50% for circle
} else {
lv_obj_set_style_radius(obj_, LV_RADIUS_CIRCLE, 0);
}
lv_obj_set_style_border_width(obj_, 2, 0);
lv_obj_set_style_border_color(obj_, lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b), 0);
// Hand colors
lv_color_t handColor = lv_color_make(
config_.textColor.r, config_.textColor.g, config_.textColor.b);
lv_color_t secColor = lv_color_make(200, 50, 50); // Red for second hand
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_) {
lv_obj_set_style_bg_color(centerDot_, handColor, 0);
}
// Force redraw of hands with new style/dimensions
lastSecond_ = -1;
time_t now;
time(&now);
struct tm t;
localtime_r(&now, &t);
updateHands(t);
}
void ClockWidget::updateHands(const struct tm& t) {
if (t.tm_sec == lastSecond_ && t.tm_min == lastMinute_ && t.tm_hour == lastHour_) return;
lastSecond_ = t.tm_sec;
lastMinute_ = t.tm_min;
lastHour_ = t.tm_hour;
int32_t w = lv_obj_get_width(obj_);
int32_t h = lv_obj_get_height(obj_);
int32_t cx = w / 2;
int32_t cy = h / 2;
int32_t radius = (w < h ? w : h) / 2;
// Lengths
int32_t lenSec = radius - 10;
int32_t lenMin = radius - 20;
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) {
// 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);
}

View File

@ -0,0 +1,32 @@
#pragma once
#include "Widget.hpp"
#include <ctime>
class ClockWidget : public Widget {
public:
explicit ClockWidget(const WidgetConfig& config);
lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override;
void onKnxTime(const struct tm& value, TextSource source) override;
private:
void drawFace();
void updateHands(const struct tm& time);
lv_obj_t* hourHand_ = nullptr;
lv_obj_t* minuteHand_ = nullptr;
lv_obj_t* secondHand_ = 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
int lastHour_ = -1;
int lastMinute_ = -1;
int lastSecond_ = -1;
};

View File

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

File diff suppressed because one or more lines are too long

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-DYaUUEn9.js"></script> <script type="module" crossorigin src="/assets/index-itIBE803.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-kFitTaMN.css"> <link rel="stylesheet" crossorigin href="/assets/index-BV9BQMzn.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -14,14 +14,18 @@
<span class="text-[13px] font-semibold">Button</span> <span class="text-[13px] font-semibold">Button</span>
<span class="text-[11px] text-muted mt-0.5 block">Aktion</span> <span class="text-[11px] text-muted mt-0.5 block">Aktion</span>
</button> </button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('led')"> <button :class="btnClass" @click="store.addWidget('led')">
<span class="text-[13px] font-semibold">LED</span> <span class="material-symbols-outlined text-[24px]">light_mode</span>
<span class="text-[11px] text-muted mt-0.5 block">Status</span> <span class="text-[10px]">LED</span>
</button> </button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('icon')"> <button :class="btnClass" @click="store.addWidget('clock')">
<span class="text-[13px] font-semibold">Icon</span> <span class="material-symbols-outlined text-[24px]">schedule</span>
<span class="text-[11px] text-muted mt-0.5 block">Symbol</span> <span class="text-[10px]">Uhr</span>
</button> </button>
<button :class="btnClass" @click="store.addWidget('icon')">
<span class="material-symbols-outlined text-[24px]">image</span>
<span class="text-[10px]">Icon</span>
</button>
<button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('tabview')"> <button class="bg-panel-2 border border-border rounded-xl p-2.5 text-left transition hover:-translate-y-0.5 hover:border-accent" @click="store.addWidget('tabview')">
<span class="text-[13px] font-semibold">Tabs</span> <span class="text-[13px] font-semibold">Tabs</span>
<span class="text-[11px] text-muted mt-0.5 block">Container</span> <span class="text-[11px] text-muted mt-0.5 block">Container</span>

View File

@ -248,6 +248,14 @@
</div> </div>
</template> </template>
<template v-if="key === 'clock'">
<h4 :class="headingClass">Uhr</h4>
<div :class="rowClass"><label :class="labelClass">Hintergrund</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.bgColor"></div>
<div :class="rowClass"><label :class="labelClass">Zeiger</label><input class="h-[30px] w-[44px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="w.textColor"></div>
<div :class="rowClass"><label :class="labelClass">Deckkraft</label><input :class="inputClass" type="number" min="0" max="255" v-model.number="w.bgOpacity"></div>
<div :class="rowClass"><label :class="labelClass">Radius</label><input :class="inputClass" type="number" v-model.number="w.radius"></div>
</template>
<!-- Typography --> <!-- Typography -->
<template v-if="key === 'label'"> <template v-if="key === 'label'">
<h4 :class="headingClass">Typo</h4> <h4 :class="headingClass">Typo</h4>

View File

@ -12,7 +12,8 @@ export const WIDGET_TYPES = {
POWERFLOW: 6, POWERFLOW: 6,
POWERNODE: 7, POWERNODE: 7,
POWERLINK: 8, POWERLINK: 8,
CHART: 9 CHART: 9,
CLOCK: 10
}; };
export const ICON_POSITIONS = { export const ICON_POSITIONS = {
@ -44,7 +45,8 @@ export const TYPE_KEYS = {
6: 'powerflow', 6: 'powerflow',
7: 'powernode', 7: 'powernode',
8: 'powerlink', 8: 'powerlink',
9: 'chart' 9: 'chart',
10: 'clock'
}; };
export const TYPE_LABELS = { export const TYPE_LABELS = {
@ -57,7 +59,8 @@ export const TYPE_LABELS = {
powerflow: 'Power Flow', powerflow: 'Power Flow',
powernode: 'Power Node', powernode: 'Power Node',
powerlink: 'Power Link', powerlink: 'Power Link',
chart: 'Chart' chart: 'Chart',
clock: 'Uhr (Analog)'
}; };
@ -99,7 +102,8 @@ export const sourceOptions = {
icon: [0, 2], icon: [0, 2],
powernode: [0, 1, 2, 3, 4, 5, 6, 7], powernode: [0, 1, 2, 3, 4, 5, 6, 7],
powerlink: [0, 1, 3, 5, 6, 7], powerlink: [0, 1, 3, 5, 6, 7],
chart: [1, 3, 5, 6, 7] chart: [1, 3, 5, 6, 7],
clock: [11]
}; };
export const chartPeriods = [ export const chartPeriods = [
@ -363,5 +367,27 @@ export const WIDGET_DEFAULTS = {
{ knxAddr: 0, textSrc: 1, color: '#EF6351' } { knxAddr: 0, textSrc: 1, color: '#EF6351' }
] ]
} }
},
clock: {
w: 200,
h: 200,
text: '',
textSrc: 11, // System Time
fontSize: 1,
textAlign: TEXT_ALIGNS.CENTER,
textColor: '#FFFFFF',
bgColor: '#16202c',
bgOpacity: 255,
radius: 100, // Circular default
shadow: { enabled: false, x: 0, y: 0, blur: 0, spread: 0, color: '#000000' },
isToggle: false,
knxAddrWrite: 0,
knxAddr: 0,
action: 0,
targetScreen: 0,
iconCodepoint: 0,
iconPosition: 0,
iconSize: 1,
iconGap: 0
} }
}; };

View File

@ -320,6 +320,7 @@ export const useEditorStore = defineStore('editor', () => {
case 'powernode': typeValue = WIDGET_TYPES.POWERNODE; break; case 'powernode': typeValue = WIDGET_TYPES.POWERNODE; break;
case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break; case 'powerlink': typeValue = WIDGET_TYPES.POWERLINK; break;
case 'chart': typeValue = WIDGET_TYPES.CHART; break; case 'chart': typeValue = WIDGET_TYPES.CHART; break;
case 'clock': typeValue = WIDGET_TYPES.CLOCK; break;
default: typeValue = WIDGET_TYPES.LABEL; default: typeValue = WIDGET_TYPES.LABEL;
} }