This commit is contained in:
Thomas Peterson 2026-02-09 10:31:41 +01:00
parent 3dfd2b461d
commit 752b944b5c
7 changed files with 227 additions and 8 deletions

View File

@ -1033,6 +1033,13 @@ void WidgetManager::applyCachedValuesToWidgets() {
} }
} }
if (widget->getType() == WidgetType::BUTTON) {
bool state = false;
if (addr != 0 && getCachedKnxSwitch(addr, &state)) {
widget->onKnxSwitch(state);
}
}
// Secondary address (left value) // Secondary address (left value)
uint16_t addr2 = widget->getKnxAddress2(); uint16_t addr2 = widget->getKnxAddress2();
TextSource source2 = widget->getTextSource2(); TextSource source2 = widget->getTextSource2();

View File

@ -240,6 +240,127 @@ void ButtonWidget::applyStyle() {
} }
} }
void ButtonWidget::onKnxSwitch(bool value) {
cachedPrimaryValue_ = value ? 1.0f : 0.0f;
hasCachedPrimary_ = true;
if (obj_ && config_.isToggle) {
if (value) {
lv_obj_add_state(obj_, LV_STATE_CHECKED);
} else {
lv_obj_clear_state(obj_, LV_STATE_CHECKED);
}
}
evaluateConditions(cachedPrimaryValue_, cachedSecondaryValue_, cachedTertiaryValue_);
}
bool ButtonWidget::evaluateConditions(float primaryValue, float secondaryValue, float tertiaryValue) {
if (config_.conditionCount == 0) return false;
const StyleCondition* bestMatch = nullptr;
uint8_t bestPriority = 255;
for (uint8_t i = 0; i < config_.conditionCount && i < MAX_CONDITIONS; ++i) {
const StyleCondition& cond = config_.conditions[i];
if (!cond.enabled) continue;
float checkValue = 0.0f;
bool hasValue = false;
switch (cond.source) {
case ConditionSource::PRIMARY:
checkValue = primaryValue;
hasValue = hasCachedPrimary_;
break;
case ConditionSource::SECONDARY:
checkValue = secondaryValue;
hasValue = hasCachedSecondary_;
break;
case ConditionSource::TERTIARY:
checkValue = tertiaryValue;
hasValue = hasCachedTertiary_;
break;
}
if (!hasValue) continue;
bool matches = false;
switch (cond.op) {
case ConditionOp::LESS:
matches = checkValue < cond.threshold;
break;
case ConditionOp::LESS_EQUAL:
matches = checkValue <= cond.threshold;
break;
case ConditionOp::EQUAL:
matches = checkValue == cond.threshold;
break;
case ConditionOp::GREATER_EQUAL:
matches = checkValue >= cond.threshold;
break;
case ConditionOp::GREATER:
matches = checkValue > cond.threshold;
break;
case ConditionOp::NOT_EQUAL:
matches = checkValue != cond.threshold;
break;
}
if (matches && cond.priority < bestPriority) {
bestMatch = &cond;
bestPriority = cond.priority;
}
}
if (!bestMatch) {
return false;
}
if (bestMatch->style.iconCodepoint != 0 &&
bestMatch->style.iconCodepoint != currentConditionIcon_) {
updateIcon(bestMatch->style.iconCodepoint);
currentConditionIcon_ = bestMatch->style.iconCodepoint;
}
if (bestMatch->style.flags & ConditionStyle::FLAG_USE_TEXT_COLOR) {
lv_color_t color = lv_color_make(
bestMatch->style.textColor.r,
bestMatch->style.textColor.g,
bestMatch->style.textColor.b);
if (label_) {
lv_obj_set_style_text_color(label_, color, 0);
}
if (iconLabel_) {
lv_obj_set_style_text_color(iconLabel_, color, 0);
}
}
if (bestMatch->style.flags & ConditionStyle::FLAG_USE_BG_COLOR) {
lv_obj_set_style_bg_color(obj_, lv_color_make(
bestMatch->style.bgColor.r,
bestMatch->style.bgColor.g,
bestMatch->style.bgColor.b), 0);
}
if (bestMatch->style.flags & ConditionStyle::FLAG_USE_BG_OPACITY) {
lv_obj_set_style_bg_opa(obj_, bestMatch->style.bgOpacity, 0);
}
if (bestMatch->style.flags & ConditionStyle::FLAG_HIDE) {
lv_obj_add_flag(obj_, LV_OBJ_FLAG_HIDDEN);
if (shadowObj_ && lv_obj_is_valid(shadowObj_)) {
lv_obj_add_flag(shadowObj_, LV_OBJ_FLAG_HIDDEN);
}
} else {
lv_obj_clear_flag(obj_, LV_OBJ_FLAG_HIDDEN);
if (shadowObj_ && lv_obj_is_valid(shadowObj_)) {
lv_obj_clear_flag(shadowObj_, LV_OBJ_FLAG_HIDDEN);
}
}
return true;
}
bool ButtonWidget::isChecked() const { bool ButtonWidget::isChecked() const {
if (obj_ == nullptr) return false; if (obj_ == nullptr) return false;
return (lv_obj_get_state(obj_) & LV_STATE_CHECKED) != 0; return (lv_obj_get_state(obj_) & LV_STATE_CHECKED) != 0;
@ -288,3 +409,10 @@ void ButtonWidget::applyFakeShadowStyle() {
lv_obj_set_style_radius(shadowObj_, config_.borderRadius + config_.shadow.spread, 0); lv_obj_set_style_radius(shadowObj_, config_.borderRadius + config_.shadow.spread, 0);
} }
} }
void ButtonWidget::updateIcon(uint32_t codepoint) {
if (!iconLabel_ || codepoint == 0) return;
char iconText[5];
encodeUtf8(codepoint, iconText);
lv_label_set_text(iconLabel_, iconText);
}

View File

@ -9,6 +9,8 @@ public:
lv_obj_t* create(lv_obj_t* parent) override; lv_obj_t* create(lv_obj_t* parent) override;
void applyStyle() override; void applyStyle() override;
void onKnxSwitch(bool value) override;
bool evaluateConditions(float primaryValue, float secondaryValue, float tertiaryValue) override;
// Check if button is in checked state // Check if button is in checked state
bool isChecked() const; bool isChecked() const;
@ -23,6 +25,7 @@ private:
void applyTextAlignment(); void applyTextAlignment();
void createFakeShadow(lv_obj_t* parent); void createFakeShadow(lv_obj_t* parent);
void applyFakeShadowStyle(); void applyFakeShadowStyle();
void updateIcon(uint32_t codepoint);
static int encodeUtf8(uint32_t codepoint, char* buf); static int encodeUtf8(uint32_t codepoint, char* buf);
static void clickCallback(lv_event_t* e); static void clickCallback(lv_event_t* e);
}; };

View File

@ -57,7 +57,7 @@
<script setup> <script setup>
import { computed, defineAsyncComponent } from 'vue'; import { computed, defineAsyncComponent } from 'vue';
import { useEditorStore } from '../../../stores/editor'; import { useEditorStore } from '../../../stores/editor';
import { fontSizes, ICON_POSITIONS } from '../../../constants'; import { fontSizes, iconFontSizes, ICON_POSITIONS } from '../../../constants';
import { getBaseStyle, justifyForAlign, getShadowStyle, getBorderStyle, clamp, hexToRgba } from '../shared/utils'; import { getBaseStyle, justifyForAlign, getShadowStyle, getBorderStyle, clamp, hexToRgba } from '../shared/utils';
// Lazy import to avoid circular dependency // Lazy import to avoid circular dependency
@ -105,7 +105,7 @@ const contentStyle = computed(() => {
const iconStyle = computed(() => { const iconStyle = computed(() => {
const s = props.scale; const s = props.scale;
const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1; const sizeIdx = props.widget.iconSize ?? props.widget.fontSize ?? 1;
const size = fontSizes[sizeIdx] || 18; const size = iconFontSizes[sizeIdx] || 18;
return { return {
fontSize: `${size * s}px`, fontSize: `${size * s}px`,
color: props.widget.textColor color: props.widget.textColor

View File

@ -51,7 +51,7 @@
<div :class="rowClass"> <div :class="rowClass">
<label :class="labelClass">Icon-Gr.</label> <label :class="labelClass">Icon-Gr.</label>
<select :class="inputClass" v-model.number="widget.iconSize"> <select :class="inputClass" v-model.number="widget.iconSize">
<option v-for="(size, idx) in fontSizes" :key="idx" :value="idx">{{ size }}</option> <option v-for="(size, idx) in iconFontSizes" :key="idx" :value="idx">{{ size }}</option>
</select> </select>
</div> </div>
<div :class="rowClass"> <div :class="rowClass">
@ -104,24 +104,100 @@
</option> </option>
</select> </select>
</div> </div>
<div :class="rowClass"><label :class="labelClass">KNX Status</label>
<select :class="inputClass" v-model.number="widget.knxAddr">
<option :value="0">-- Waehlen --</option>
<option v-for="addr in readableAddresses" :key="`${addr.addr}-${addr.index}`" :value="addr.addr">
GA {{ addr.addrStr }} (GO{{ addr.index }})
</option>
</select>
</div>
<div :class="noteClass">Tipp: Status-GA sollte DPT 1.x sein. Bedingungen z. B. <code>eq 1</code> = EIN, <code>eq 0</code> = AUS.</div>
</template> </template>
<!-- Conditions -->
<h4 :class="headingClass">Bedingungen</h4>
<div :class="rowClass">
<label :class="labelClass">Anzahl</label>
<select :class="inputClass" v-model.number="conditionCount">
<option :value="0">Keine</option>
<option :value="1">1</option>
<option :value="2">2</option>
<option :value="3">3</option>
</select>
</div>
<div v-for="(cond, idx) in conditions" :key="idx" class="border border-border rounded-lg px-2.5 py-2 mb-2 bg-panel-2">
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Wenn</label>
<select class="w-[60px] bg-white border border-border rounded-md px-2 py-1 text-[11px]" v-model="cond.op">
<option value="lt">&lt;</option>
<option value="lte">&lt;=</option>
<option value="eq">=</option>
<option value="gte">&gt;=</option>
<option value="gt">&gt;</option>
<option value="neq">!=</option>
</select>
<input class="flex-1 bg-white border border-border rounded-md px-2 py-1 text-[11px]" type="number" step="0.1" v-model.number="cond.threshold" placeholder="Schwelle">
</div>
<div class="flex items-center gap-2 mb-2 text-[11px] text-muted">
<label class="w-[50px]">Icon</label>
<button class="flex-1 bg-white border border-border rounded-md px-2.5 py-1 text-[11px] flex items-center justify-center gap-1 cursor-pointer hover:bg-[#e4ebf2]" @click="$emit('open-condition-icon-picker', idx)">
<span v-if="cond.icon" class="material-symbols-outlined text-[16px]">{{ String.fromCodePoint(cond.icon) }}</span>
<span v-else>Kein Icon</span>
</button>
<button v-if="cond.icon" class="w-6 h-6 rounded-md border border-red-200 bg-[#f7dede] text-[#b3261e] grid place-items-center text-[11px] cursor-pointer hover:bg-[#f2cfcf]" @click="cond.icon = 0">x</button>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted">
<label class="w-[50px]">Farbe</label>
<input class="h-[22px] w-[32px] cursor-pointer border-0 bg-transparent p-0" type="color" v-model="cond.textColor">
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useEditorStore } from '../../../stores/editor'; import { useEditorStore } from '../../../stores/editor';
import { sourceOptions, textSources, textSourceGroups, BUTTON_ACTIONS, fontSizes } from '../../../constants'; import { sourceOptions, textSources, textSourceGroups, BUTTON_ACTIONS, fontSizes, iconFontSizes } from '../../../constants';
import { rowClass, labelClass, inputClass, headingClass, colorInputClass, iconSelectClass, iconRemoveClass } from '../shared/styles'; import { rowClass, labelClass, inputClass, headingClass, colorInputClass, iconSelectClass, iconRemoveClass, noteClass } from '../shared/styles';
defineProps({ const props = defineProps({
widget: { type: Object, required: true } widget: { type: Object, required: true }
}); });
defineEmits(['open-icon-picker']); defineEmits(['open-icon-picker', 'open-condition-icon-picker']);
const store = useEditorStore(); const store = useEditorStore();
const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write)); const writeableAddresses = computed(() => store.knxAddresses.filter(a => a.write));
const readableAddresses = computed(() => store.knxAddresses);
const conditions = computed(() => props.widget?.conditions ?? []);
const conditionCount = computed({
get() {
return conditions.value.length || 0;
},
set(value) {
if (!props.widget) return;
const target = Math.max(0, Math.min(value, 3));
if (!Array.isArray(props.widget.conditions)) {
props.widget.conditions = [];
}
while (props.widget.conditions.length < target) {
props.widget.conditions.push({
source: 'primary',
threshold: 1,
op: 'eq',
priority: props.widget.conditions.length,
icon: 0,
textColor: '#FFFFFF'
});
}
if (props.widget.conditions.length > target) {
props.widget.conditions = props.widget.conditions.slice(0, target);
}
}
});
function groupedSources(options) { function groupedSources(options) {
const allowed = new Set(options || []); const allowed = new Set(options || []);

View File

@ -231,7 +231,8 @@ export const WIDGET_DEFAULTS = {
iconCodepoint: 0, iconCodepoint: 0,
iconPosition: 0, iconPosition: 0,
iconSize: 1, iconSize: 1,
iconGap: 8 iconGap: 8,
conditions: []
}, },
led: { led: {
w: 60, w: 60,

View File

@ -402,6 +402,10 @@ export const useEditorStore = defineStore('editor', () => {
iconPositionY: defaults.iconPositionY || 8 iconPositionY: defaults.iconPositionY || 8
}; };
if (defaults.conditions !== undefined) {
w.conditions = [];
}
if (defaults.chart) { if (defaults.chart) {
w.chart = { w.chart = {
period: defaults.chart.period ?? 0, period: defaults.chart.period ?? 0,