Watch the feed by the declared keepalive and HA by the API link (0.2.26)

Since 0.2.20 the manager repeats a layout every 120 s, while the firmware
treated anything older than 95 s (sized for the original 25 s keepalive) as
stale: on a quiet screen every card read "Niet beschikbaar" and the header
"HA niet verbonden" for 25-45 s of each two-minute round, on both boards.

"HA niet verbonden" now follows ESPHome's own api_is_connected(); the feed
watchdog uses the cadence the layout message declares ("keepalive", seconds,
120 s when absent) and tolerates one missed round plus loop slack before
showing "ESP Screens niet actief". The tick redraws once when either flips.
Firmware 0.2.22 for CYD and Guition.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 15:45:17 +02:00
parent ea7bf3387b
commit 5d6651d68b
12 changed files with 53 additions and 11 deletions
+19 -2
View File
@@ -10,6 +10,7 @@
#include "esphome/components/json/json_util.h" #include "esphome/components/json/json_util.h"
#include "esphome/components/api/api_server.h" #include "esphome/components/api/api_server.h"
#include "esphome/core/hal.h" #include "esphome/core/hal.h"
#include "esphome/core/util.h"
#include "esphome/core/time.h" #include "esphome/core/time.h"
#include "lvgl.h" #include "lvgl.h"
#include <functional> #include <functional>
@@ -42,6 +43,9 @@ inline std::string timer_text(const Tile &t);
inline std::string last_run_text(uint32_t epoch); inline std::string last_run_text(uint32_t epoch);
inline int active_index = -1; inline int active_index = -1;
inline uint32_t last_received = 0; inline uint32_t last_received = 0;
// Seconds between the manager's full repeats; every layout message declares it (app
// 0.2.26+). Older managers get the 120 s that app 0.2.20 introduced.
inline uint32_t keepalive_seconds = 120;
inline std::function<void()> layout_changed, refresh, dismiss, settings_changed; inline std::function<void()> layout_changed, refresh, dismiss, settings_changed;
inline esphome::ESPPreferenceObject settings_preference; inline esphome::ESPPreferenceObject settings_preference;
inline void load_settings() { inline void load_settings() {
@@ -98,7 +102,13 @@ inline bool has_icon_glyph(uint32_t codepoint) {
for (auto &w : widgets) if (w.icon_font) { lv_font_glyph_dsc_t dsc; return lv_font_get_glyph_dsc(w.icon_font, &dsc, codepoint, 0); } for (auto &w : widgets) if (w.icon_font) { lv_font_glyph_dsc_t dsc; return lv_font_get_glyph_dsc(w.icon_font, &dsc, codepoint, 0); }
return false; return false;
} }
inline bool fresh() { return model.ready() && esphome::millis() - last_received < 95000; } // Home Assistant's API link as ESPHome itself tracks it: gone the moment the socket drops,
// back the moment HA reconnects, no guessing from message age.
inline bool ha_connected() { return esphome::api_is_connected(); }
// The manager repeats the whole layout every keepalive; one missed round plus its 20 s
// loop slack and the sending itself are tolerated before the feed counts as gone.
inline bool feed_alive() { return esphome::millis() - last_received < keepalive_seconds * 2000 + 60000; }
inline bool fresh() { return model.ready() && ha_connected() && feed_alive(); }
inline float number(JsonVariant value, float fallback = NAN) { inline float number(JsonVariant value, float fallback = NAN) {
if (!value.is<float>() && !value.is<int>()) return fallback; if (!value.is<float>() && !value.is<int>()) return fallback;
float n = value.as<float>(); float n = value.as<float>();
@@ -141,6 +151,8 @@ inline std::string receive(const std::string &payload) {
if(!root["swipe_pages"].isNull() && !root["swipe_pages"].is<bool>())return false; if(!root["swipe_pages"].isNull() && !root["swipe_pages"].is<bool>())return false;
if(!root["rotation"].isNull() && (!root["rotation"].is<unsigned>() || if(!root["rotation"].isNull() && (!root["rotation"].is<unsigned>() ||
root["rotation"].as<unsigned>()>270 || root["rotation"].as<unsigned>()%90!=0))return false; root["rotation"].as<unsigned>()>270 || root["rotation"].as<unsigned>()%90!=0))return false;
if(!root["keepalive"].isNull() && (!root["keepalive"].is<unsigned>() ||
root["keepalive"].as<unsigned>()<5 || root["keepalive"].as<unsigned>()>3600))return false;
inbox = string(root["inbox"], 160); inbox = string(root["inbox"], 160);
bool changed = false; bool changed = false;
if (!model.set_layout(entities, string(root["title"], 96), changed)) return false; if (!model.set_layout(entities, string(root["title"], 96), changed)) return false;
@@ -158,6 +170,7 @@ inline std::string receive(const std::string &payload) {
} }
if(rotation_changed && settings_changed)settings_changed(); if(rotation_changed && settings_changed)settings_changed();
if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); } if (changed) { active_index = -1; for (auto &w : widgets) w.cached_active = -1; if (dismiss) dismiss(); }
if (root["keepalive"].is<unsigned>()) keepalive_seconds = root["keepalive"].as<unsigned>();
last_received = esphome::millis(); last_received = esphome::millis();
if (layout_changed) layout_changed(); if (layout_changed) layout_changed();
if (refresh) refresh(); if (refresh) refresh();
@@ -1255,7 +1268,7 @@ inline void set_busy(Widgets &w,bool busy,bool large){
inline void render(lv_obj_t *room) { inline void render(lv_obj_t *room) {
if (!enabled) return; if (!enabled) return;
room_label=room; room_label=room;
label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !fresh() ? "HA niet verbonden" : model.title); label(room, !model.configured ? "Kies tegels in HA" : !model.ready() ? "Tegels laden..." : !ha_connected() ? "HA niet verbonden" : !feed_alive() ? "ESP Screens niet actief" : model.title);
for (size_t slot = 0; slot < 6; ++slot) { for (size_t slot = 0; slot < 6; ++slot) {
auto &w=widgets[slot]; auto &w=widgets[slot];
if(!w.tile || w.index>=model.count)continue; if(!w.tile || w.index>=model.count)continue;
@@ -1549,6 +1562,7 @@ inline void show_page(int &page, lv_obj_t *previous, lv_obj_t *next, lv_obj_t *n
} }
inline uint32_t last_live_second=0; inline uint32_t last_live_second=0;
inline bool was_fresh=false;
inline void tick() { inline void tick() {
if(detail_root && !lv_obj_has_flag(detail_root,LV_OBJ_FLAG_HIDDEN) && detail_index<model.count){ 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()); auto &t=model.tiles[detail_index];bool waiting=t.awaiting_action(esphome::millis());
@@ -1563,6 +1577,9 @@ inline void tick() {
} }
if(!enabled)return; if(!enabled)return;
bool redraw=false; bool redraw=false;
// HA dropping or returning and the feed timing out change every card at once.
bool now_fresh=fresh();
if(now_fresh!=was_fresh){was_fresh=now_fresh;redraw=true;}
for(auto &t:model.tiles)if(t.pending && !t.loading(esphome::millis())){t.pending=false;redraw=true;} for(auto &t:model.tiles)if(t.pending && !t.loading(esphome::millis())){t.pending=false;redraw=true;}
// A -/+ edit goes out as one call once the finger rests; a value HA never reports is dropped after a while. // A -/+ edit goes out as one call once the finger rests; a value HA never reports is dropped after a while.
for(size_t i=0;i<model.count;++i){ for(size_t i=0;i<model.count;++i){
+4 -2
View File
@@ -137,8 +137,10 @@ alleen hun waarde; detailmenu’s en standby wisselen niet van pagina.
Er staan zes tegels op een pagina. Bij maximaal zes verdwijnen Vorige/Volgende. Er staan zes tegels op een pagina. Bij maximaal zes verdwijnen Vorige/Volgende.
De standby-tijd is standaard tien minuten. Bij offline apparaten blokkeert het De standby-tijd is standaard tien minuten. Bij offline apparaten blokkeert het
scherm acties. Als de app/HA langer dan circa 95 seconden geen updates geeft, scherm acties. Valt de verbinding met Home Assistant weg, dan meldt het scherm
wordt de bediening geblokkeerd totdat de gegevens opnieuw ontvangen zijn. direct "HA niet verbonden"; stuurt de app twee rondes (circa vijf minuten) niets,
dan "ESP Screens niet actief". In beide gevallen is de bediening geblokkeerd
totdat de gegevens opnieuw ontvangen zijn.
Je mag indelingen opslaan terwijl een scherm offline is. De app verstuurt ze Je mag indelingen opslaan terwijl een scherm offline is. De app verstuurt ze
zodra het scherm terugkomt. De app moet blijven draaien voor actuele tegeldata. zodra het scherm terugkomt. De app moet blijven draaien voor actuele tegeldata.
+14
View File
@@ -177,6 +177,20 @@ staan iconen in de browser uit het midden); draai daarna `generate_packages.py`.
De substituties `MDI_GLYPH_*` zijn vervallen: `TILEn_ICON` in handmatige De substituties `MDI_GLYPH_*` zijn vervallen: `TILEn_ICON` in handmatige
profielen moet uit de set komen. Geen gewijzigde preferences of sleutels. profielen moet uit de set komen. Geen gewijzigde preferences of sleutels.
### Compatibiliteit 0.2.26 / firmware 0.2.22
Opslagversie en tegelprotocol blijven 1. Het layoutbericht krijgt het additieve veld
`keepalive` (seconden, 5–3600): het interval waarin de app de hele indeling herhaalt
(`KEEPALIVE_SECONDS` in `server.py`, 120 s sinds 0.2.20). De firmware leidt daar zijn
bewaking van de gegevensstroom uit af (twee rondes plus 60 s) en meldt daarna
"ESP Screens niet actief"; "HA niet verbonden" komt nu van ESPHome's eigen
API-verbindingsstatus (`api_is_connected()`), niet meer uit de leeftijd van het laatste
bericht. Zonder het veld rekent firmware 0.2.22 met 120 s; firmware tot 0.2.21 negeert
het veld en houdt zijn vaste 95 s, wat met een keepalive van 120 s de bekende
"HA niet verbonden"-flikkering geeft. Verander `KEEPALIVE_SECONDS` daarom alleen samen
met een firmwareflash van bestaande schermen, of houd hem onder 40 s. Geen gewijzigde
preferences of sleutels.
### Compatibiliteit 0.2.24 / firmware 0.2.20 ### Compatibiliteit 0.2.24 / firmware 0.2.20
Alleen intern: `Tile::forecast` en `Tile::hours` zijn vectoren (voorheen vaste arrays in Alleen intern: `Tile::forecast` en `Tile::hours` zijn vectoren (voorheen vaste arrays in
+1 -1
View File
@@ -1,7 +1,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. # 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. # Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.21" SCREEN_FIRMWARE_VERSION: "0.2.22"
DYNAMIC_TILES: "false" DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true" DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "guition-wallbox" DEVICE_NAME: "guition-wallbox"
+1 -1
View File
@@ -58,7 +58,7 @@
############################################################ ############################################################
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.21" SCREEN_FIRMWARE_VERSION: "0.2.22"
DYNAMIC_TILES: "false" DYNAMIC_TILES: "false"
DASHBOARD_INBOX_INTERNAL: "true" DASHBOARD_INBOX_INTERNAL: "true"
DEVICE_NAME: "cyd-new" DEVICE_NAME: "cyd-new"
+1 -1
View File
@@ -59,7 +59,7 @@
############################################################ ############################################################
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.21" SCREEN_FIRMWARE_VERSION: "0.2.22"
DYNAMIC_TILES: "true" DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false" DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "cyd-new" DEVICE_NAME: "cyd-new"
+1 -1
View File
@@ -2,7 +2,7 @@
# Guition ESP32-S3-4848S040 / 4-inch EU wallbox, 480x480 IPS + GT911. # 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. # Use guition-device.yaml; see docs/GUITION.md. CYD hardware is a separate profile.
substitutions: substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.21" SCREEN_FIRMWARE_VERSION: "0.2.22"
DYNAMIC_TILES: "true" DYNAMIC_TILES: "true"
DASHBOARD_INBOX_INTERNAL: "false" DASHBOARD_INBOX_INTERNAL: "false"
DEVICE_NAME: "guition-new" DEVICE_NAME: "guition-new"
+4
View File
@@ -1,3 +1,7 @@
## 0.2.26 (firmware 0.2.22)
- **"HA niet verbonden" om de twee minuten opgelost**: sinds 0.2.20 herhaalt de app de indeling elke 120 s, maar de firmware verwachtte binnen 95 s een bericht (gemaakt voor de oude 25 s). Op een rustig scherm stonden alle tegels daardoor 25 tot 45 s per ronde op "Niet beschikbaar". De firmware gebruikt nu ESPHome's eigen API-verbindingsstatus voor "HA niet verbonden" (direct bij wegvallen en terugkeren van Home Assistant) en bewaakt de gegevensstroom apart met het interval dat de app zelf declareert: het layoutbericht bevat `keepalive` (seconden). Pas na twee gemiste rondes plus marge meldt het scherm "ESP Screens niet actief". Zonder het veld (oudere app) rekent de firmware met 120 s. Firmware 0.2.22 voor beide borden; firmware tot 0.2.21 negeert het veld en houdt zijn 95 s.
## 0.2.25 (firmware 0.2.21) ## 0.2.25 (firmware 0.2.21)
- **− / + in één beweging**: snel drie keer tikken telt drie stappen (van 20 naar 17); de touch-guard hield een tweede tik op dezelfde knop binnen 600 ms tegen. De − / + knoppen gebruiken nu een eigen korte guard (150 ms, alleen tegen stuiteren) en stappen ook door zolang je ze vasthoudt (drie per seconde). Na 700 ms rust gaat nog steeds één opdracht naar Home Assistant. - **− / + in één beweging**: snel drie keer tikken telt drie stappen (van 20 naar 17); de touch-guard hield een tweede tik op dezelfde knop binnen 600 ms tegen. De − / + knoppen gebruiken nu een eigen korte guard (150 ms, alleen tegen stuiteren) en stappen ook door zolang je ze vasthoudt (drie per seconde). Na 700 ms rust gaat nog steeds één opdracht naar Home Assistant.
+1 -1
View File
@@ -16,7 +16,7 @@ WEEKDAYS = ['ma', 'di', 'wo', 'do', 'vr', 'za', 'zo']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'} REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer. # Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.21' FIRMWARE_VERSION = '0.2.22'
ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split()) ATTRS = frozenset('brightness percentage current_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind. # Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted']) BOOL_ATTRS = frozenset(['is_volume_muted'])
+4 -1
View File
@@ -21,6 +21,8 @@ LOG = logging.getLogger('screen_manager')
REGISTRY_EVENTS = ('entity_registry_updated', 'device_registry_updated', 'area_registry_updated') REGISTRY_EVENTS = ('entity_registry_updated', 'device_registry_updated', 'area_registry_updated')
SCREEN_ENTITY_NAMES = {'Tegelinstellingen', 'Schermfirmware', 'Guition schermtype', 'Apparaatnaam', 'IP-adres'} SCREEN_ENTITY_NAMES = {'Tegelinstellingen', 'Schermfirmware', 'Guition schermtype', 'Apparaatnaam', 'IP-adres'}
# Full repeat of a screen's layout and states. Every layout message declares it, so the
# firmware sizes its feed watchdog from this one constant (firmware 0.2.22+).
KEEPALIVE_SECONDS = 120 KEEPALIVE_SECONDS = 120
class HomeAssistant: class HomeAssistant:
@@ -312,7 +314,8 @@ class Manager:
if needed: if needed:
self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels" self.status[inbox]=f"Indeling bewaard; firmware {needed}+ nodig voor deze tegels"
return return
messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']]}] messages = [{'v': 1, 'op': 'layout', 'inbox': inbox, 'title': layout['title'], 'entities': [t['entity'] for t in layout['tiles']],
'keepalive': KEEPALIVE_SECONDS}]
if 'settings' in layout: if 'settings' in layout:
messages[0]['settings'] = {k:v for k,v in layout['settings'].items() if k not in ('swipe_pages','rotation')} messages[0]['settings'] = {k:v for k,v in layout['settings'].items() if k not in ('swipe_pages','rotation')}
messages[0]['swipe_pages'] = layout['settings'].get('swipe_pages',False) messages[0]['swipe_pages'] = layout['settings'].get('swipe_pages',False)
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager name: ESP Screen Manager
version: "0.2.25" version: "0.2.26"
slug: esp_screen_manager slug: esp_screen_manager
description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen. description: Kies tegels voor je CYD en Guition touchscreen, zonder opnieuw flashen.
url: https://github.com/MaxGramser/homeassistant_espscreen url: https://github.com/MaxGramser/homeassistant_espscreen
+2
View File
@@ -5,6 +5,7 @@ import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'screen_manager/app')) sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'screen_manager/app'))
from server import KEEPALIVE_SECONDS
from core import validate_layout, state_message, packets from core import validate_layout, state_message, packets
from firmware import Firmware from firmware import Firmware
from test_portal import ManagerTests from test_portal import ManagerTests
@@ -101,6 +102,7 @@ class SpecialTileSync(unittest.IsolatedAsyncioTestCase):
await m.sync_one('text.screen',m.layouts['text.screen']) await m.sync_one('text.screen',m.layouts['text.screen'])
sent=[message for _,message in m.ha.messages] sent=[message for _,message in m.ha.messages]
self.assertEqual(sent[0]['entities'],['screen.clock','sun.sun']) self.assertEqual(sent[0]['entities'],['screen.clock','sun.sun'])
self.assertEqual(sent[0]['keepalive'],KEEPALIVE_SECONDS,'the screen sizes its feed watchdog from the declared cadence')
self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Klok')) self.assertEqual((sent[1]['state'],sent[1]['name']),('ok','Klok'))
self.assertEqual(sent[2]['x'],{'rise':'05:15','set':'17:50'},'fake HA has no time zone: UTC') self.assertEqual(sent[2]['x'],{'rise':'05:15','set':'17:50'},'fake HA has no time zone: UTC')
m.ha.states['sensor.screen_firmware']={'state':'0.2.13'};m.ha.messages.clear() m.ha.states['sensor.screen_firmware']={'state':'0.2.13'};m.ha.messages.clear()