Support twenty runtime tiles and opt-in native LVGL page swipes

This commit is contained in:
Max Gramser
2026-09-12 17:29:58 +02:00
parent 2adf83f297
commit 439dd721f2
17 changed files with 217 additions and 31 deletions
+1
View File
@@ -26,6 +26,7 @@ class TouchGuard {
// A contact that jumps to another target must never become a tile tap.
if (std::abs(x - start_x_) > 18 || std::abs(y - start_y_) > 18) moved_ = true;
}
void consume() { accepted_ = true; moved_ = true; }
bool accept(uint32_t now, int tile) {
return !moved_ && accept_slider(now, tile);
}
+1 -1
View File
@@ -6,7 +6,7 @@
#include <cstdint>
namespace runtime_tiles {
constexpr size_t MAX_TILES = 10;
constexpr size_t MAX_TILES = 20;
inline bool valid_entity(const std::string &entity) {
if (entity.size() > 120) return false;
auto dot = entity.find('.');
+40 -6
View File
@@ -14,6 +14,8 @@
namespace runtime_tiles {
inline bool enabled = false;
inline bool light_theme = false;
inline bool swipe_pages = false;
inline esphome::ESPPreferenceObject swipe_preference;
inline Model model;
inline std::string inbox;
inline const lv_font_t *watch_font = nullptr;
@@ -25,6 +27,9 @@ inline std::function<void()> layout_changed, refresh, dismiss, settings_changed;
inline esphome::ESPPreferenceObject settings_preference;
inline void load_settings() {
settings_preference = esphome::global_preferences->make_preference<screen_settings::Settings>(0x53435231);
swipe_preference = esphome::global_preferences->make_preference<uint32_t>(0x53575031);
uint32_t swipe_saved=0;
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
screen_settings::Settings saved;
if (settings_preference.load(&saved) && saved.valid()) screen_settings::current = saved;
}
@@ -47,7 +52,7 @@ inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
}
inline std::function<void(Tile &)> detail, detail_update;
struct Widgets { lv_obj_t *tile{}, *title{}, *value{}, *circle{}, *icon{}; size_t index{}; int cached_active = -1; lv_obj_t *slider{}, *progress{}; int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}; };
inline std::array<Widgets, MAX_TILES> widgets;
inline std::array<Widgets, 10> widgets;
inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; }
inline float number(JsonVariant value, float fallback = NAN) {
if (!value.is<float>() && !value.is<int>()) return fallback;
@@ -88,9 +93,13 @@ inline std::string receive(const std::string &payload) {
if (!entity.is<const char *>() || entities.size() == MAX_TILES) return false;
entities.push_back(entity.as<std::string>());
}
if(!root["swipe_pages"].isNull() && !root["swipe_pages"].is<bool>())return false;
inbox = string(root["inbox"], 160);
bool changed = false;
if (!model.set_layout(entities, string(root["title"], 96), changed)) return false;
if(root["swipe_pages"].is<bool>() && swipe_pages!=root["swipe_pages"].as<bool>()){
swipe_pages=root["swipe_pages"].as<bool>();uint32_t saved=swipe_pages?1:0;swipe_preference.save(&saved);
}
if (!(settings == screen_settings::current)) {
screen_settings::current = settings;
settings_preference.save(&settings); // ESPHome batches flash writes; no write on keepalive.
@@ -160,7 +169,7 @@ inline std::string receive(const std::string &payload) {
tile.fan_speeds[tile.fan_speed_count++] = string(speed, 48);
}
tile.received = true;
widgets[index].cached_active=-1;
for(auto &w:widgets)if(w.index==index)w.cached_active=-1;
last_received = esphome::millis();
if (refresh) refresh();
if (active_index == static_cast<int>(index) && detail_update) detail_update(tile);
@@ -247,7 +256,9 @@ inline void slider_event(lv_event_t *e){
if(code==LV_EVENT_VALUE_CHANGED && captured_slider==slider)slider_changed=true;
if(code==LV_EVENT_PRESS_LOST && captured_slider==slider){captured_slider=nullptr;slider_changed=false;}
if(code==LV_EVENT_RELEASED && captured_slider==slider){
unsigned index=(uintptr_t)lv_event_get_user_data(e);bool changed=slider_changed;captured_slider=nullptr;slider_changed=false;
unsigned index=(uintptr_t)lv_event_get_user_data(e);
for(auto &w:widgets)if(w.slider==slider){index=w.index;break;}
bool changed=slider_changed;captured_slider=nullptr;slider_changed=false;
if(changed && cyd::touch_guard.accept_slider(esphome::millis(),200+index))commit_slider(index,lv_slider_get_value(slider));
}
}
@@ -274,7 +285,7 @@ inline lv_obj_t *detail_button(const char *text,int x,int y,int width,int height
}
inline void show_detail(unsigned index){
if(index>=model.count)return;detail_index=index;auto &t=model.tiles[index];
if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[index].title,LV_PART_MAIN);
if(!detail_font)detail_font=lv_obj_get_style_text_font(widgets[0].title,LV_PART_MAIN);
if(!detail_root){detail_root=lv_obj_create(lv_screen_active());lv_obj_remove_style_all(detail_root);lv_obj_set_size(detail_root,lv_pct(100),lv_pct(100));lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_SCROLLABLE);}
detail_action_count=0;detail_status=nullptr;detail_badge_status=nullptr;lv_obj_clean(detail_root);lv_obj_remove_flag(detail_root,LV_OBJ_FLAG_HIDDEN);lv_obj_move_foreground(detail_root);
lv_obj_set_style_bg_color(detail_root,lv_color_hex(light_theme?0xE7E7E7:0x202A38),0);lv_obj_set_style_bg_opa(detail_root,LV_OPA_COVER,0);
@@ -414,6 +425,7 @@ inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value,
lv_obj_set_style_bg_color(w.slider,lv_color_hex(0x111111),LV_PART_KNOB);lv_obj_set_style_pad_hor(w.slider,lv_obj_get_height(tile)>80?-10:0,LV_PART_KNOB);lv_obj_set_style_pad_ver(w.slider,lv_obj_get_height(tile)>80?-5:1,LV_PART_KNOB);lv_obj_set_style_radius(w.slider,14,LV_PART_MAIN);lv_obj_set_style_radius(w.slider,14,LV_PART_INDICATOR);lv_obj_set_style_radius(w.slider,3,LV_PART_KNOB);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFCE5B4),LV_PART_MAIN);lv_obj_set_style_bg_color(w.slider,lv_color_hex(0xFFB900),LV_PART_INDICATOR);
lv_obj_set_style_opa(w.slider,LV_OPA_TRANSP,LV_PART_KNOB);
lv_obj_add_flag(w.slider,LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(w.slider,LV_OBJ_FLAG_GESTURE_BUBBLE);
lv_obj_add_event_cb(w.slider,slider_event,LV_EVENT_ALL,(void*)(uintptr_t)index);
lv_obj_add_event_cb(tile, event, LV_EVENT_SHORT_CLICKED, &widgets[index]);
lv_obj_add_event_cb(tile, event, LV_EVENT_LONG_PRESSED, &widgets[index]);
@@ -452,8 +464,10 @@ inline uint32_t domain_accent(const Tile &t) {
inline void render(lv_obj_t *room) {
if (!enabled) return;
label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !fresh() ? "HA niet verbonden" : model.title);
for (size_t i = 0; i < model.count; ++i) {
const auto &t = model.tiles[i]; auto &w = widgets[i];
for (size_t slot = 0; slot < 6; ++slot) {
auto &w=widgets[slot];
if(!w.tile || w.index>=model.count)continue;
const auto &t = model.tiles[w.index];
label(w.title, t.name.empty() ? t.entity : t.name);
label(w.icon, icon_for(t));
std::string value = t.state;
@@ -508,6 +522,26 @@ inline void render(lv_obj_t *room) {
lv_obj_set_style_text_color(w.value, lv_color_hex(light_theme ? 0x616161 : (on ? 0x46525E : 0xF0F4F8)), 0);
}
}
inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *number) {
int pages=std::max(1,(int(model.count)+5)/6);
page=std::clamp(page,0,pages-1);
for(size_t slot=0;slot<widgets.size();++slot){
auto &w=widgets[slot];if(!w.tile)continue;
if(slot<6){w.index=page*6+slot;w.cached_active=-1;}
if(slot<6 && w.index<model.count)lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);
}
for(auto *control:{previous,next,number}){
if(pages>1)lv_obj_remove_flag(control,LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(control,LV_OBJ_FLAG_HIDDEN);
}
if(page==0)lv_obj_add_state(previous,LV_STATE_DISABLED);else lv_obj_remove_state(previous,LV_STATE_DISABLED);
if(page==pages-1)lv_obj_add_state(next,LV_STATE_DISABLED);else lv_obj_remove_state(next,LV_STATE_DISABLED);
std::string caption=std::to_string(page+1)+" / "+std::to_string(pages);lv_label_set_text(number,caption.c_str());
if(refresh)refresh();
}
inline void tick() {
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){
auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
+23
View File
@@ -0,0 +1,23 @@
# Paging en swipe — 0.2.7
Runtime ondersteunt twintig tegels, zes per pagina (vier pagina's). De bestaande
zes zichtbare LVGL-tegels worden hergebruikt; tien extra kaarten worden niet
gealloceerd. Logische entity-index en mini-sliderbinding volgen de actieve pagina.
Handmatige profielen behouden hun oude tien posities.
Swipe is opt-in via swipe_pages. LVGL LV_EVENT_GESTURE handelt horizontale gebaren
af; lv_indev_wait_release plus de TouchGuard voorkomt een actie bij loslaten.
Mini-sliders stoppen GESTURE_BUBBLE. Standby, kalibratie en detailmenu's blokkeren
paginagebaren. Vorige/Volgende blijven bruikbaar, zonder doorrollen aan de grenzen.
De bestaande settings-preference blijft exact gelijk. Swipe krijgt aparte
uint32-preference 0x53575031. Op de draad blijft settings elf velden houden;
swipe_pages is een optioneel top-level veld dat oude firmware negeert. De manager
bewaart het in settings en behoudt het bij een save vanuit een oudere browser.
Meer dan tien tegels opslaan/synchroniseren vereist firmware >=0.2.7. Bij oudere
firmware blijft een grotere indeling bewaard zonder ongeldig doorsturen.
Controles tot nu toe: 46 Python-tests, acht C++-tests en alle vier ESPHome-builds
(Easy/manual, Guition/CYD) geslaagd. Test twintig entities/21 berichten, overflow,
firmwaregate, oude instellingen op de draad en contact consumeren geslaagd.
Fysieke swipeacceptatie en live HA-update volgen hieronder.
+18 -3
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.5"
SCREEN_FIRMWARE_VERSION: "0.2.7"
DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "guition-wallbox"
@@ -629,6 +629,17 @@ esphome:
lv_obj_add_flag(id(vacuum_overlay), LV_OBJ_FLAG_HIDDEN);
};
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *event) {
if(!runtime_tiles::enabled || !runtime_tiles::swipe_pages || id(display_dimmed) || id(calibration_active) ||
!id(active_entity).empty() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root,LV_OBJ_FLAG_HIDDEN)))return;
auto *input=lv_indev_active();if(!input)return;
auto direction=lv_indev_get_gesture_dir(input);
if(direction!=LV_DIR_LEFT && direction!=LV_DIR_RIGHT)return;
cyd::touch_guard.consume();
lv_indev_wait_release(input);
id(tile_page)+=direction==LV_DIR_LEFT?1:-1;
id(show_tile_page).execute();
}, LV_EVENT_GESTURE, nullptr);
runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); };
runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl));
runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl));
@@ -3100,7 +3111,7 @@ lvgl:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 11);'
then:
- lambda: 'id(tile_page) = 0;'
- lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);'
- script.execute: show_tile_page
widgets:
- label:
@@ -3134,7 +3145,7 @@ lvgl:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 12);'
then:
- lambda: 'id(tile_page) = 1;'
- lambda: 'id(tile_page) += 1;'
- script.execute: show_tile_page
widgets:
- label:
@@ -4495,6 +4506,10 @@ script:
- id: show_tile_page
then:
- lambda: |-
if(runtime_tiles::enabled){
runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number));
return;
}
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}};
+18 -3
View File
@@ -58,7 +58,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.5"
SCREEN_FIRMWARE_VERSION: "0.2.7"
DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "cyd-new"
@@ -821,6 +821,17 @@ esphome:
lv_obj_add_flag(id(vacuum_overlay), LV_OBJ_FLAG_HIDDEN);
};
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *event) {
if(!runtime_tiles::enabled || !runtime_tiles::swipe_pages || id(display_dimmed) || id(calibration_active) ||
!id(active_entity).empty() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root,LV_OBJ_FLAG_HIDDEN)))return;
auto *input=lv_indev_active();if(!input)return;
auto direction=lv_indev_get_gesture_dir(input);
if(direction!=LV_DIR_LEFT && direction!=LV_DIR_RIGHT)return;
cyd::touch_guard.consume();
lv_indev_wait_release(input);
id(tile_page)+=direction==LV_DIR_LEFT?1:-1;
id(show_tile_page).execute();
}, LV_EVENT_GESTURE, nullptr);
runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); };
runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl));
runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl));
@@ -3244,7 +3255,7 @@ lvgl:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 11);'
then:
- lambda: 'id(tile_page) = 0;'
- lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);'
- script.execute: show_tile_page
widgets:
- label:
@@ -3278,7 +3289,7 @@ lvgl:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 12);'
then:
- lambda: 'id(tile_page) = 1;'
- lambda: 'id(tile_page) += 1;'
- script.execute: show_tile_page
widgets:
- label:
@@ -4625,6 +4636,10 @@ script:
- id: show_tile_page
then:
- lambda: |-
if(runtime_tiles::enabled){
runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number));
return;
}
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}};
+18 -3
View File
@@ -59,7 +59,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.5"
SCREEN_FIRMWARE_VERSION: "0.2.7"
DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "cyd-new"
@@ -825,6 +825,17 @@ esphome:
lv_obj_add_flag(id(vacuum_overlay), LV_OBJ_FLAG_HIDDEN);
};
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *event) {
if(!runtime_tiles::enabled || !runtime_tiles::swipe_pages || id(display_dimmed) || id(calibration_active) ||
!id(active_entity).empty() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root,LV_OBJ_FLAG_HIDDEN)))return;
auto *input=lv_indev_active();if(!input)return;
auto direction=lv_indev_get_gesture_dir(input);
if(direction!=LV_DIR_LEFT && direction!=LV_DIR_RIGHT)return;
cyd::touch_guard.consume();
lv_indev_wait_release(input);
id(tile_page)+=direction==LV_DIR_LEFT?1:-1;
id(show_tile_page).execute();
}, LV_EVENT_GESTURE, nullptr);
runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); };
runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl));
runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl));
@@ -3177,7 +3188,7 @@ lvgl:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 11);'
then:
- lambda: 'id(tile_page) = 0;'
- lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);'
- script.execute: show_tile_page
widgets:
- label:
@@ -3211,7 +3222,7 @@ lvgl:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 12);'
then:
- lambda: 'id(tile_page) = 1;'
- lambda: 'id(tile_page) += 1;'
- script.execute: show_tile_page
widgets:
- label:
@@ -4558,6 +4569,10 @@ script:
- id: show_tile_page
then:
- lambda: |-
if(runtime_tiles::enabled){
runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number));
return;
}
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}};
+18 -3
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911.
# Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.5"
SCREEN_FIRMWARE_VERSION: "0.2.7"
DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "guition-new"
@@ -633,6 +633,17 @@ esphome:
lv_obj_add_flag(id(vacuum_overlay), LV_OBJ_FLAG_HIDDEN);
};
runtime_tiles::layout_changed = []() { id(show_tile_page).execute(); };
lv_obj_add_event_cb(id(home_page)->obj, [](lv_event_t *event) {
if(!runtime_tiles::enabled || !runtime_tiles::swipe_pages || id(display_dimmed) || id(calibration_active) ||
!id(active_entity).empty() || (runtime_tiles::detail_root && !lv_obj_has_flag(runtime_tiles::detail_root,LV_OBJ_FLAG_HIDDEN)))return;
auto *input=lv_indev_active();if(!input)return;
auto direction=lv_indev_get_gesture_dir(input);
if(direction!=LV_DIR_LEFT && direction!=LV_DIR_RIGHT)return;
cyd::touch_guard.consume();
lv_indev_wait_release(input);
id(tile_page)+=direction==LV_DIR_LEFT?1:-1;
id(show_tile_page).execute();
}, LV_EVENT_GESTURE, nullptr);
runtime_tiles::refresh = []() { if (!id(ui_refresh).is_running()) id(ui_refresh).execute(); };
runtime_tiles::bind(0, id(tile1), id(t1_title), id(t1_value), id(tile1_icon_circle), id(tile1_icon_lbl));
runtime_tiles::bind(1, id(tile2), id(t2_title), id(t2_value), id(tile2_icon_circle), id(tile2_icon_lbl));
@@ -3033,7 +3044,7 @@ lvgl:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 11);'
then:
- lambda: 'id(tile_page) = 0;'
- lambda: 'id(tile_page) = std::max(0, id(tile_page) - 1);'
- script.execute: show_tile_page
widgets:
- label:
@@ -3067,7 +3078,7 @@ lvgl:
condition:
lambda: 'return cyd::touch_guard.accept(millis(), 12);'
then:
- lambda: 'id(tile_page) = 1;'
- lambda: 'id(tile_page) += 1;'
- script.execute: show_tile_page
widgets:
- label:
@@ -4428,6 +4439,10 @@ script:
- id: show_tile_page
then:
- lambda: |-
if(runtime_tiles::enabled){
runtime_tiles::show_page(id(tile_page),id(page_prev),id(page_next),id(page_number));
return;
}
lv_obj_t* tiles[] = {id(tile1), id(tile2), id(tile3), id(tile4), id(tile5),
id(tile6), id(tile7), id(tile8), id(tile9), id(tile10)};
const int rows[] = {${GRID_ROW1_Y}, ${GRID_ROW2_Y}, ${GRID_ROW3_Y}};
+5
View File
@@ -1,3 +1,8 @@
## 0.2.7
- Tot twintig tegels op vier vaste pagina’s; installeer eerst firmware 0.2.7. Zes tegelvakken worden hergebruikt om geheugen te sparen.
- Optionele LVGL-swipes tussen pagina’s via scherminstellingen. Sliders en detailmenu’s wisselen niet van pagina; na een swipe wordt de aanraking geconsumeerd.
## 0.2.6
- Nieuw scherm hergebruikt bestaande ESPHome-wifi-secrets automatisch. De wizard vraagt alleen wifi als secrets.yaml nog niet bestaat. Ontbrekende wifi-sleutels in een bestaand bestand worden gemeld zonder het bestand te overschrijven.
+3 -2
View File
@@ -23,6 +23,7 @@ SETTING_RULES = {
'show_clock': (True, None, None),
'clock_24h': (True, None, None),
'home_on_standby': (False, None, None),
'swipe_pages': (False, None, None),
}
def validate_settings(data):
@@ -54,8 +55,8 @@ def validate_layout(data):
title, tiles = data.get('title'), data.get('tiles')
if not isinstance(title, str) or not title.strip() or len(title.encode()) > 96:
raise ValueError('Geef het scherm een titel van maximaal 96 bytes.')
if not isinstance(tiles, list) or len(tiles) > 10:
raise ValueError('Kies maximaal 10 tegels.')
if not isinstance(tiles, list) or len(tiles) > 20:
raise ValueError('Kies maximaal 20 tegels.')
clean, seen = [], set()
for tile in tiles:
if not isinstance(tile, dict) or not entity_id(tile.get('entity')):
+17 -1
View File
@@ -165,13 +165,25 @@ class Manager:
def inventory(self):
return discover(self.ha.registry, self.ha.states, self.ha.devices, self.ha.areas)
def supports_twenty(self, inbox):
screen=next((s for s in self.inventory()[0] if s['id']==inbox), {})
try:
version=tuple(int(part) for part in screen.get('firmware','').split('.'))
return len(version)==3 and version >= (0,2,7)
except (ValueError, TypeError):
return False
def save(self, inbox, data):
if inbox not in {s['id'] for s in self.inventory()[0]}:
raise ValueError('Dit is geen gekoppeld ESP-scherm. Vernieuw het overzicht.')
layout = validate_layout(data)
if len(layout["tiles"])>10 and not self.supports_twenty(inbox):
raise ValueError("Installeer eerst schermfirmware 0.2.7 of nieuwer voor meer dan tien tegels.")
# A still-open older UI may save tiles without the new optional settings.
if 'settings' not in layout and 'settings' in self.layouts.get(inbox, {}):
layout['settings'] = self.layouts[inbox]['settings'].copy()
if 'settings' in layout and 'swipe_pages' not in data.get('settings',{}):
layout['settings']['swipe_pages']=self.layouts.get(inbox,{}).get('settings',{}).get('swipe_pages',False)
old_tiles = {t['entity']:t for t in self.layouts.get(inbox,{}).get('tiles',[])}
for tile in layout['tiles']:
if 'options' not in tile and 'options' in old_tiles.get(tile['entity'],{}):
@@ -194,9 +206,13 @@ class Manager:
self.ha.changed.set()
async def sync_one(self, inbox, layout, force=False):
if len(layout["tiles"])>10 and not self.supports_twenty(inbox):
self.status[inbox]="Indeling bewaard; firmware 0.2.7+ nodig voor meer dan tien tegels"
return
messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}]
if 'settings' in layout:
messages[0]['settings'] = layout['settings']
messages[0]['settings'] = {k:v for k,v in layout['settings'].items() if k!='swipe_pages'}
messages[0]['swipe_pages'] = layout['settings'].get('swipe_pages',False)
for i,tile in enumerate(layout['tiles']):
message=state_message(i,tile,self.ha.states)
if tile['entity'].startswith('sensor.') and hasattr(self.ha,'history'):
+10 -3
View File
@@ -81,6 +81,7 @@ const settingDefinitions = [
["show_clock", "Klok tonen", "check", true],
["clock_24h", "24-uursklok (uit = 12 uur)", "check", true],
["home_on_standby", "Na standby terug naar pagina 1", "check", false],
["swipe_pages", "Vegen tussen pagina’s (firmware 0.2.7+)", "check", false],
];
function renderSettings() {
const values = {
@@ -223,6 +224,12 @@ function domainBadge(id) {
badge.style.background = background;
return badge;
}
function tileLimit() {
const version=inventory.screens.find(s=>s.id===selected)?.firmware || "";
const match=/^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if(!match)return 10;
return Number(match[1])>0 || Number(match[2])>2 || Number(match[2])===2 && Number(match[3])>=7 ? 20 : 10;
}
function renderPreview() {
const root = $("#layout-preview");
root.replaceChildren();
@@ -231,7 +238,7 @@ function renderPreview() {
const frame = node("section", undefined, "screen-preview");
frame.append(node("small", `Pagina ${page + 1} · ${$("#title").value || "Thuis"}`, "preview-heading"));
const grid = node("div", undefined, "preview-grid");
for (let slot = page * 6; slot < Math.min(page * 6 + 6, 10); slot++) {
for (let slot = page * 6; slot < Math.min(page * 6 + 6, tileLimit()); slot++) {
const tile = layout.tiles[slot];
const card = node("button", undefined, "preview-tile");
if (tile) {
@@ -265,7 +272,7 @@ function renderPreview() {
function renderTiles() {
renderPreview();
$("#tiles").replaceChildren();
$("#count").textContent = `${layout.tiles.length} / 10`;
$("#count").textContent = `${layout.tiles.length} / ${tileLimit()}${tileLimit()===10?" · update firmware voor 20":""}`;
$("#no-tiles").hidden = layout.tiles.length > 0;
layout.tiles.forEach((tile, i) => {
if (i === 6) $("#tiles").append(node("li", "Pagina 2", "page-break"));
@@ -472,7 +479,7 @@ function renderResults() {
description,
node("span", chosen.has(entity.id) ? "✓" : "+", "plus"),
);
b.disabled = chosen.has(entity.id) || layout.tiles.length >= 10;
b.disabled = chosen.has(entity.id) || layout.tiles.length >= tileLimit();
b.onclick = () => {
layout.tiles.push({ entity: entity.id, name: "" });
selectedTile = entity.id;
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.6"
version: "0.2.7"
slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen
+2 -1
View File
@@ -1,3 +1,4 @@
#include "components/smart_display/runtime_model.h"
#include <cassert>
int main(){runtime_tiles::Tile t;t.revision="off";t.begin(100);assert(t.loading(200));assert(t.awaiting_action(200));t.observe("off");assert(!t.confirmed);t.observe("on");assert(t.confirmed);assert(t.loading(999));assert(!t.loading(1100));t.begin(2000);t.observe("on");assert(t.loading(7999));assert(!t.loading(8000));assert(!t.awaiting_action(8000));t.begin(0xFFFFFFF0);assert(t.loading(20));t.begin(100,true);assert(t.loading(1099));assert(!t.awaiting_action(1099));assert(!t.loading(1100));}
#include "components/smart_display/cyd_ui.h"
int main(){cyd::TouchGuard guard;guard.begin(0);guard.consume();assert(!guard.accept(100,1));assert(!guard.accept_slider(100,1));guard.begin(200);assert(guard.accept(300,1));runtime_tiles::Tile t;t.revision="off";t.begin(100);assert(t.loading(200));assert(t.awaiting_action(200));t.observe("off");assert(!t.confirmed);t.observe("on");assert(t.confirmed);assert(t.loading(999));assert(!t.loading(1100));t.begin(2000);t.observe("on");assert(t.loading(7999));assert(!t.loading(8000));assert(!t.awaiting_action(8000));t.begin(0xFFFFFFF0);assert(t.loading(20));t.begin(100,true);assert(t.loading(1099));assert(!t.awaiting_action(1099));assert(!t.loading(1100));}
+3 -3
View File
@@ -20,7 +20,7 @@ class ProtocolTests(unittest.TestCase):
with self.assertRaises(ValueError): packets({'huge':'x'*4096})
def test_layout_rejects_duplicates_unsupported_and_overflow(self):
for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(11)],[{'entity':'light.a;restart'}]]:
for tiles in [[{'entity':'lock.front'}],[{'entity':'light.a'}]*2,[{'entity':f'light.a{i}'} for i in range(21)],[{'entity':'light.a;restart'}]]:
with self.assertRaises(ValueError): validate_layout({'title':'Thuis','tiles':tiles})
self.assertEqual(validate_layout({'title':'Thuis','tiles':[]})['tiles'],[])
@@ -122,11 +122,11 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(ValueError): fresh.save('text.screen',{**saved,'settings':{'brightness':0}})
self.assertEqual(path.read_bytes(),before)
await fresh.sync_one('text.screen',saved)
self.assertEqual(fresh.ha.messages[0][1]['settings'],settings)
self.assertEqual(fresh.ha.messages[0][1]['settings'],{k:v for k,v in settings.items() if k!='swipe_pages'})
await fresh.sync_one('text.screen',saved)
self.assertEqual(len(fresh.ha.messages),2)
await fresh.sync_one('text.screen',saved,True)
self.assertEqual(fresh.ha.messages[2][1]['settings'],settings)
self.assertEqual(fresh.ha.messages[2][1]['settings'],{k:v for k,v in settings.items() if k!='swipe_pages'})
async def test_reorder_aborts_old_batch(self):
with tempfile.TemporaryDirectory() as temp:
+5 -1
View File
@@ -23,6 +23,10 @@ int main() {
assert(!m.accepts(0, "light.a"));
assert(m.set_layout({}, "Leeg", changed)); assert(changed && m.ready() && m.count == 0);
std::vector<std::string> many;
for (int i = 0; i < 11; ++i) many.push_back("light.a" + std::to_string(i));
for (int i = 0; i < 21; ++i) many.push_back("light.a" + std::to_string(i));
assert(!m.set_layout(many, "Te veel", changed)); assert(m.count == 0);
many.pop_back(); assert(m.set_layout(many,"Twintig",changed));
assert(m.count==20 && m.accepts(19,"light.a19"));
for(size_t i=0;i<m.count;++i){m.tiles[i].received=true;m.tiles[i].state="off";}
assert(m.ready());
}
+34
View File
@@ -0,0 +1,34 @@
import json
import tempfile
import unittest
from pathlib import Path
import test_portal
from core import validate_layout, validate_settings, packets
class TwentyTiles(unittest.IsolatedAsyncioTestCase):
async def test_twenty_and_old_firmware_guard(self):
with tempfile.TemporaryDirectory() as tmp:
m=test_portal.ManagerTests().setup_manager(Path(tmp)/'screens.json')
layout={'title':'Twintig','tiles':[{'entity':f'light.item{i}'} for i in range(20)],
'settings':validate_settings({'swipe_pages':True})}
for tile in layout['tiles']:m.ha.states[tile['entity']]={'state':'on','attributes':{}}
with self.assertRaises(ValueError):m.save('text.screen',layout)
m.ha.registry[0]['device_id']='screen-device'
m.ha.registry.append({'entity_id':'sensor.version','platform':'esphome','original_name':'Schermfirmware','device_id':'screen-device'})
m.ha.states['sensor.version']={'state':'0.2.7'}
m.save('text.screen',layout)
await m.sync_one('text.screen',m.layouts['text.screen'])
self.assertEqual(len(m.ha.messages),21)
wire=m.ha.messages[0][1]
self.assertEqual(len(wire['entities']),20)
self.assertEqual(len(wire['settings']),11)
self.assertTrue(wire['swipe_pages'])
for _,message in m.ha.messages:self.assertTrue(packets(message))
old=dict(layout);old['settings']={k:v for k,v in layout['settings'].items() if k!='swipe_pages'}
m.save('text.screen',old)
self.assertTrue(m.layouts['text.screen']['settings']['swipe_pages'])
m.ha.states['sensor.version']['state']='0.2.6'
m.ha.messages.clear()
await m.sync_one('text.screen',m.layouts['text.screen'],True)
self.assertEqual(m.ha.messages,[])
self.assertEqual(len(json.loads(m.path.read_text())['screens']['text.screen']['tiles']),20)