From 90dd9d38b290b8d43b2b3e2dbf79d7d435b6d6d3 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Sat, 12 Sep 2026 11:11:12 +0200 Subject: [PATCH] Replace light presets with shared hue, temperature and brightness sliders --- diagnostics/control_ui.py | 2 +- diagnostics/run_ui_test.py | 3 + docs/ACCEPTATIE.md | 17 + docs/LICHTREGELAARS_TESTRESULTATEN.md | 35 ++ docs/TEGELS.md | 38 ++ home-like-2432s028.yaml | 512 +++----------------------- light_controls.h | 216 +++++++++++ tests/test_light_controls.cpp | 23 ++ tools/export_bundle.py | 2 +- 9 files changed, 389 insertions(+), 459 deletions(-) create mode 100644 docs/LICHTREGELAARS_TESTRESULTATEN.md create mode 100644 light_controls.h create mode 100644 tests/test_light_controls.cpp diff --git a/diagnostics/control_ui.py b/diagnostics/control_ui.py index bfb7d1b..6441285 100644 --- a/diagnostics/control_ui.py +++ b/diagnostics/control_ui.py @@ -7,7 +7,7 @@ from aioesphomeapi import APIClient, LogLevel async def main(): parser = argparse.ArgumentParser() - parser.add_argument('action', choices=['touch_diagnostics', 'end_touch_diagnostics', 'ui_state']) + parser.add_argument('action', choices=['touch_diagnostics', 'end_touch_diagnostics', 'ui_state', 'light_controls_preview']) parser.add_argument('--host', default='cyd-2432s028.local') parser.add_argument('--name', default='cyd-2432s028', help='Verwachte DEVICE_NAME') parser.add_argument('--secrets', type=Path, default=Path(__file__).resolve().parents[1] / 'secrets.yaml') diff --git a/diagnostics/run_ui_test.py b/diagnostics/run_ui_test.py index 0d7e655..2fa6349 100644 --- a/diagnostics/run_ui_test.py +++ b/diagnostics/run_ui_test.py @@ -24,6 +24,7 @@ async def main(): failures = [] passes = [] frames = [] + slider_pass = [] def log(msg): text = msg.message.decode(errors='replace') if isinstance(msg.message, bytes) else msg.message if any(tag in text for tag in ('ui_test', 'UI_TEST', '[health', 'took a long')): @@ -32,6 +33,7 @@ async def main(): passes.append(text) match = re.search(r'frames=(\d+)', text) if match: frames.append(int(match[1])) + if 'Light sliders: PASS' in text: slider_pass.append(text) if 'FAIL' in text: failures.append(text) if 'UI_TEST COMPLETE' in text: done.set() client.subscribe_logs(log, log_level=LogLevel.LOG_LEVEL_DEBUG, dump_config=False) @@ -39,6 +41,7 @@ async def main(): await asyncio.wait_for(done.wait(), timeout=45) assert len(passes) == 10, f'Expected 10 page checks, got {len(passes)}' assert not failures, failures + assert slider_pass, "Missing light slider event/capability check" assert len(frames) == 10 and frames[-1] - frames[0] >= 40, f'Render callbacks stalled: {frames}' print('PASS: 10 page checks and 50 overlay render cycles completed', flush=True) finally: diff --git a/docs/ACCEPTATIE.md b/docs/ACCEPTATIE.md index c8e786b..1dc7617 100644 --- a/docs/ACCEPTATIE.md +++ b/docs/ACCEPTATIE.md @@ -52,7 +52,11 @@ python -m unittest discover -s tests -p 'test_*.py' c++ -std=c++17 -Wall -Wextra -pedantic tests/test_cyd_ui.cpp -o /tmp/test_cyd_ui /tmp/test_cyd_ui c++ -std=c++17 -Wall -Wextra -pedantic tests/test_touch_filter.cpp -o /tmp/test_touch_filter +c++ -std=c++17 -Wall -Wextra -pedantic tests/test_light_controls.cpp -o /tmp/test_light_controls +/tmp/test_light_controls /tmp/test_touch_filter +c++ -std=c++17 -Wall -Wextra -pedantic tests/test_light_controls.cpp -o /tmp/test_light_controls +/tmp/test_light_controls python -m esphome config device.yaml python -m esphome compile device.yaml ``` @@ -79,3 +83,16 @@ Lokale profielbestanden veilig bewaard: Geef de eigenaar de drie lokale YAML-bestanden en de meetbestanden via een passend privé-kanaal. Gebruik voor een andere eigenaar de schone repository of starter-ZIP, zodat die eigen sleutels en eigen paneelkalibratie krijgt. + +## Lichtkaart + +- RGB+wit-lamp: drie schuiven; RGB-only: kleur en helderheid; wit-only: + wittemperatuur en helderheid. Een gewone dimmer behoudt de helderheidskaart. +- Sleep langzaam over de regenboog, laat los en controleer de fysieke lamp. + Herhaal voor wittemperatuur en helderheid. Helderheid behoudt de kleurmodus. +- Heropen de kaart en controleer de ontvangen waarden. Test twee lampen met + verschillende temperatuurbereiken; er mag geen waarde van de andere lamp lekken. +- Firmware-renderdiagnose bevat `Light sliders: PASS`: twintig preview-events + per schuif, precies één commit bij loslaten, geen dubbele commit, geen commit + na een afgebroken aanraking en vier capabilitycombinaties. Dit gebeurt met + vervangen callbacks en bedient geen echte lampen. diff --git a/docs/LICHTREGELAARS_TESTRESULTATEN.md b/docs/LICHTREGELAARS_TESTRESULTATEN.md new file mode 100644 index 0000000..4f5c1bb --- /dev/null +++ b/docs/LICHTREGELAARS_TESTRESULTATEN.md @@ -0,0 +1,35 @@ +# Lichtregelaars — 12 september 2026 + +Gedeelde `light_controls.h` vervangt de kleurpresets in de CYD- en +Guition-profielen. Hue, wittemperatuur en helderheid staan op één kaart; +capabilities bepalen welke schuiven verschijnen. + +## Uitgevoerd + +- 23 Python-regressietests op de Guition-branch geslaagd. +- C++-tests voor touchguard, touchfilter en nieuwe lichtwaardenparser geslaagd. +- Beide persoonlijke profielen volledig gebouwd met ESPHome 2026.6.2. +- Guition: RAM statisch 74.380 bytes; firmware 1.349.559 bytes. +- CYD: RAM statisch 70.820 bytes; firmware 1.291.519 bytes (70,4% app-partitie). +- Guition via OTA geflasht en apparaatidentiteit via versleutelde API bevestigd. +- Op Guition `Light sliders: PASS`: twintig preview-events per regelaar, + één commit bij loslaten, geen dubbele commit, geen commit na press-lost, + vier capabilitycombinaties. Callbacks vervangen tijdens deze test: geen lampacties. +- Tien pagina-/geometriecontroles en vijftig overlay-rendercycli geslaagd. + Frames 6–69; vrije heap circa 6.514.320 bytes. +- Veilige voorbeeldkaart uit LVGL als screenshot opgehaald en visueel gecontroleerd. + Eerste screenshotpoging verloor de API-verbinding; herhaling met USB-logging + voltooide alle 240 rijen zonder exception/backtrace in de capture. +- Neutrale starterbundel met gedeelde header succesvol geëxporteerd. + +De CYD-build vond een lokale PlatformIO-esptool-koppeling naar versie 4.9, +terwijl de platformmetadata 5.3 vereiste. De lokale editable installatie is +hersteld naar de al aanwezige 5.3-package; de volledige build slaagt daarna. +Dit is een lokale toolchainreparatie, geen wijziging van bord/flashinstellingen. + +## Grenzen + +Een succesvolle build is geen fysieke CYD-test: alleen de Guition was aangesloten. +Schuifevents in de firmwaretest zijn synthetisch; menselijke touchbediening en +werkelijke HA-lampacties moeten apart worden gecontroleerd. De eigenaar vond de +voorbeeldkaart er goed uitzien; de HA-koppeling ontbrak op dat moment nog. diff --git a/docs/TEGELS.md b/docs/TEGELS.md index 46b00ed..001ba2a 100644 --- a/docs/TEGELS.md +++ b/docs/TEGELS.md @@ -49,6 +49,44 @@ De voorbeeldconfig vult ook de overige velden in. Laat die staan tenzij je ze bewust gebruikt. `STATE_ENTITY` kan bij een scène/script een echte lamp of schakelaar volgen; een scène heeft zelf geen betrouwbare aan/uitstatus. +## Kleur, wittemperatuur en helderheid + +Een RGB-/kleurenlamp met `LONGPRESS: "slider"` opent direct de lampkaart. +Deze bevat maximaal drie horizontale schuiven: + +- **Kleur:** een doorlopende regenboog (0–360° hue, 100% verzadiging). +- **Wittemperatuur:** warm naar koel, binnen de minimum-/maximum-Kelvinwaarden + die de lamp in Home Assistant meldt. +- **Helderheid:** 1–100%; aan/uit blijft via de tegel beschikbaar. + +De kleur- en witschuif verschijnen alleen als `supported_color_modes` deze +ondersteunt. Een uitsluitend dimbare lamp houdt de gewone helderheidskaart. +Er zijn geen extra tegelinstellingen nodig. Gebruik voor een lamp dezelfde +`ENTITY` en `STATE_ENTITY`. De kleurkaart volgt `hs_color`, `color_temp_kelvin`, +`min_color_temp_kelvin` en `max_color_temp_kelvin` van de bediende entiteit. +Ontbreekt het temperatuurbereik, dan wacht de witschuif op HA-gegevens. + +De kaart neemt bij openen de laatst ontvangen lampwaarden over. Slepen toont +lokaal de gekozen waarde; pas loslaten verstuurt één opdracht. Kleur kiest de +kleurmodus, wittemperatuur kiest de witmodus. Helderheid verandert de kleurmodus +niet. De regenboog regelt hue; pastel/verzadiging is geen aparte regelaar. +De hue-lijst wordt via ESPHome `data_template` naar HA gestuurd; zie de +[officiële API-documentatie](https://esphome.io/components/api/#homeassistantaction-action). + +De gedeelde implementatie staat in `light_controls.h`. Beide schermprofielen +gebruiken deze; de Guition heeft grotere regelaars, de CYD een compacte kaart. +`diagnostics/run_ui_test.py` controleert ook de schuifevents met vervangen +callbacks: geen echte HA-acties. Voor een veilige 40-secondenvoorbeeldkaart: + +```sh +python diagnostics/control_ui.py light_controls_preview --host guition-wallbox.local --name guition-wallbox +``` + +Gebruik voor CYD de eigen host en naam. Tijdens deze voorbeeldkaart worden +lichtopdrachten onderdrukt en verdwijnt de kaart vanzelf. Test vervolgens de +werkelijke lamp: lang indrukken, kleur kiezen, wit kiezen, helderheid aanpassen, +sluiten en heropenen. Controleer de fysieke lamp en de waarden in HA. + ## Voorbeelden Wijzig deze sleutels in de bestaande map; plak niet nogmaals `substitutions:`. diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index 260f600..0d3a78d 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -774,6 +774,7 @@ external_components: esphome: includes: - cyd_ui.h + - light_controls.h name: ${DEVICE_NAME} friendly_name: ${DEVICE_FRIENDLY_NAME} on_boot: @@ -788,6 +789,13 @@ esphome: lv_obj_set_style_pad_column(id(vacuum_actions), 4, 0); lv_obj_set_style_pad_row(id(vacuum_speeds), 4, 0); lv_obj_set_style_pad_column(id(vacuum_speeds), 4, 0); + - lambda: |- + static const char *entities[] = {"${TILE1_ENTITY}", "${TILE2_ENTITY}", "${TILE3_ENTITY}", "${TILE4_ENTITY}", "${TILE5_ENTITY}", "${TILE6_ENTITY}", "${TILE7_ENTITY}", "${TILE9_ENTITY}"}; + light_controls::subscribe(entities, 8); + light_controls::setup(id(color_detail_overlay), id(sublabel)->get_lv_font(), atoi("${DISPLAY_W}"), atoi("${DISPLAY_H}")); + light_controls::commits[0] = [](int value) { id(set_light_hue).execute(value); }; + light_controls::commits[1] = [](int value) { id(set_light_color_temperature).execute(value); }; + light_controls::commits[2] = [](int value) { id(color_brightness_commit).execute(value); }; - light.turn_on: id: back_light brightness: !lambda "return atoi(\"${MAX_BRIGHTNESS}\") / 100.0f;" @@ -858,6 +866,9 @@ api: ESP_LOGI("ui_diag", "home=%d calibration=%d grid=%d,%d,%d,%d next=%d,%d,%d,%d", lv_screen_active() == id(home_page)->obj, lv_screen_active() == id(touch_cal_page)->obj, grid.x1, grid.y1, grid.x2, grid.y2, nav.x1, nav.y1, nav.x2, nav.y2); + - action: light_controls_preview + then: + - script.execute: light_controls_preview - action: ui_self_test then: - script.execute: ui_self_test @@ -3536,7 +3547,6 @@ lvgl: scrollable: false on_click: - lvgl.widget.hide: color_detail_overlay - - lvgl.widget.show: brightness_overlay widgets: - label: align: CENTER @@ -3547,6 +3557,9 @@ lvgl: - label: id: color_detail_title + width: 210 + long_mode: DOT + text_align: CENTER align: TOP_MID y: 16 text: "Light Color" @@ -3575,334 +3588,6 @@ lvgl: text_color: 0xFFFFFF clickable: false - - obj: - id: color_mode_tabs - align: TOP_MID - y: 58 - width: 190 - height: 28 - radius: 14 - bg_color: 0x3C3C40 - bg_opa: COVER - border_width: 0 - scrollable: false - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - widgets: - - obj: - id: color_tab_indicator - x: 2 - y: 2 - width: 93 - height: 24 - radius: 12 - bg_color: 0x8E8E93 - bg_opa: COVER - border_width: 0 - clickable: false - scrollable: false - - obj: - x: 0 - y: 0 - width: 95 - height: 28 - bg_opa: TRANSP - border_width: 0 - clickable: true - scrollable: false - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - on_click: - - script.execute: - id: show_color_detail_tab - tab: 0 - widgets: - - label: - id: color_tab_label - align: CENTER - text: "Color" - text_font: sublabel_big - text_color: 0xFFFFFF - clickable: false - - obj: - x: 95 - y: 0 - width: 95 - height: 28 - bg_opa: TRANSP - border_width: 0 - clickable: true - scrollable: false - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - on_click: - - script.execute: - id: show_color_detail_tab - tab: 1 - widgets: - - label: - id: temp_tab_label - align: CENTER - text: "Temp." - text_font: sublabel_big - text_color: 0xFFFFFF - clickable: false - - - obj: - id: color_palette_card - align: TOP_MID - y: 98 - width: 286 - height: 48 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - widgets: - - obj: - id: color_preset_red - x: 14 - y: 8 - width: 30 - height: 30 - radius: 999 - bg_color: 0xFF3B30 - bg_opa: COVER - border_width: 0 - clickable: true - on_click: - - script.execute: { id: set_light_color_name, color: "red" } - - obj: - id: color_preset_orange - x: 61 - y: 8 - width: 30 - height: 30 - radius: 999 - bg_color: 0xFF9F0A - bg_opa: COVER - border_width: 0 - clickable: true - on_click: - - script.execute: { id: set_light_color_name, color: "orange" } - - obj: - id: color_preset_yellow - x: 108 - y: 8 - width: 30 - height: 30 - radius: 999 - bg_color: 0xFFD60A - bg_opa: COVER - border_width: 0 - clickable: true - on_click: - - script.execute: { id: set_light_color_name, color: "yellow" } - - obj: - id: color_preset_green - x: 155 - y: 8 - width: 30 - height: 30 - radius: 999 - bg_color: 0x30D158 - bg_opa: COVER - border_width: 0 - clickable: true - on_click: - - script.execute: { id: set_light_color_name, color: "green" } - - obj: - id: color_preset_blue - x: 202 - y: 8 - width: 30 - height: 30 - radius: 999 - bg_color: 0x0A84FF - bg_opa: COVER - border_width: 0 - clickable: true - on_click: - - script.execute: { id: set_light_color_name, color: "blue" } - - obj: - id: color_preset_purple - x: 249 - y: 8 - width: 30 - height: 30 - radius: 999 - bg_color: 0xBF5AF2 - bg_opa: COVER - border_width: 0 - clickable: true - on_click: - - script.execute: { id: set_light_color_name, color: "purple" } - - - obj: - id: temp_palette_card - align: TOP_MID - y: 98 - width: 286 - height: 48 - bg_opa: TRANSP - border_width: 0 - scrollable: false - hidden: true - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - widgets: - - obj: - id: temp_preset_2700 - x: 24 - y: 8 - width: 62 - height: 30 - radius: 15 - bg_color: 0xFFD4A3 - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - on_click: - - script.execute: { id: set_light_color_temperature, kelvin: 2700 } - widgets: - - label: - align: CENTER - text: "2700K" - text_font: sublabel_big - text_color: 0x3A2A18 - clickable: false - - obj: - id: temp_preset_4000 - x: 112 - y: 8 - width: 62 - height: 30 - radius: 15 - bg_color: 0xFFE8C8 - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - on_click: - - script.execute: { id: set_light_color_temperature, kelvin: 4000 } - widgets: - - label: - align: CENTER - text: "4000K" - text_font: sublabel_big - text_color: 0x3A2A18 - clickable: false - - obj: - id: temp_preset_6500 - x: 200 - y: 8 - width: 62 - height: 30 - radius: 15 - bg_color: 0xEAF4FF - bg_opa: COVER - border_width: 0 - clickable: true - scrollable: false - scrollbar_mode: 'OFF' - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - on_click: - - script.execute: { id: set_light_color_temperature, kelvin: 6500 } - widgets: - - label: - align: CENTER - text: "6500K" - text_font: sublabel_big - text_color: 0x1E2A36 - clickable: false - - - obj: - id: color_brightness_card - align: TOP_MID - y: 165 - width: 188 - height: 46 - bg_opa: TRANSP - border_width: 0 - scrollable: false - pad_left: 0 - pad_right: 0 - pad_top: 0 - pad_bottom: 0 - widgets: - - label: - x: 13 - y: 6 - text: "Brightness" - text_font: sublabel - text_color: 0xD1D1D6 - hidden: true - clickable: false - - slider: - id: color_brightness_slider - x: 0 - y: 0 - width: 188 - height: 46 - min_value: 1 - max_value: 100 - value: 100 - bg_color: 0x939391 - bg_opa: COVER - radius: 23 - border_width: 0 - indicator: - bg_color: 0xFEC600 - bg_opa: COVER - radius: 23 - knob: - bg_opa: 0% - border_width: 0 - shadow_width: 0 - pad_all: 10 - on_value: - - script.execute: - id: color_brightness_preview - value: !lambda "return x;" - on_release: - - script.execute: - id: color_brightness_commit - value: !lambda "return lv_slider_get_value(id(color_brightness_slider));" - - label: - id: color_brightness_value - align: RIGHT_MID - x: -13 - text: "100 %" - text_font: sublabel_big - text_color: 0xFFFFFF - hidden: true - clickable: false - # ---------- CLIMATE DETAIL OVERLAY ---------- - obj: id: climate_detail_overlay @@ -4769,6 +4454,7 @@ script: mode: single then: - logger.log: "UI_TEST START: page/overlay render stress, no HA actions" + - lambda: 'ESP_LOGI("ui_test", "Light sliders: %s", light_controls::self_test() ? "PASS" : "FAIL");' - lambda: 'id(calibration_active) = false;' - lvgl.page.show: home_page - script.execute: wake_display @@ -5093,129 +4779,36 @@ script: entity_id: !lambda "return id(active_entity);" hvac_mode: !lambda "return id(active_climate_mode);" + # Isolated 40-second preview; every light commit is suppressed in the helper. + - id: light_controls_preview + mode: single + then: + - script.execute: wake_display + - lambda: |- + light_controls::demo = true; + light_controls::fallback.minimum = 2000; + light_controls::fallback.maximum = 6500; + light_controls::fallback.kelvin = 3000; + light_controls::fallback.hue = 200; + light_controls::open("diagnostic", true, true, 75); + lv_label_set_text(id(color_detail_title), "Voorbeeld lamp"); + - lvgl.widget.hide: color_close_button + - lvgl.widget.hide: color_done_button + - lvgl.widget.show: color_detail_overlay + - delay: 40s + - lvgl.widget.hide: color_detail_overlay + - lvgl.widget.show: color_close_button + - lvgl.widget.show: color_done_button + - lambda: 'light_controls::demo = false; light_controls::fallback = light_controls::State{};' + - id: open_color_detail mode: restart then: - lambda: |- lv_label_set_text(id(color_detail_title), id(active_title).c_str()); - - char buf[12]; - snprintf(buf, sizeof(buf), "%d %%", id(active_brightness_pct)); - lv_label_set_text(id(color_brightness_value), buf); - lv_slider_set_value(id(color_brightness_slider), id(active_brightness_pct), LV_ANIM_OFF); - - bool show_tabs = id(active_supports_color) && id(active_supports_temperature); - if (show_tabs) { - lv_obj_clear_flag(id(color_mode_tabs), LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(id(color_mode_tabs), LV_OBJ_FLAG_HIDDEN); - } - - if (atoi("${DISPLAY_W}") < atoi("${DISPLAY_H}")) { - int palette_y = show_tabs ? 96 : 76; - lv_obj_set_size(id(color_palette_card), 150, 86); - lv_obj_set_y(id(color_palette_card), palette_y); - lv_obj_set_pos(id(color_preset_red), 13, 4); - lv_obj_set_pos(id(color_preset_orange), 60, 4); - lv_obj_set_pos(id(color_preset_yellow), 107, 4); - lv_obj_set_pos(id(color_preset_green), 13, 52); - lv_obj_set_pos(id(color_preset_blue), 60, 52); - lv_obj_set_pos(id(color_preset_purple), 107, 52); - - lv_obj_set_size(id(temp_palette_card), 150, 126); - lv_obj_set_y(id(temp_palette_card), palette_y); - lv_obj_set_pos(id(temp_preset_2700), 44, 4); - lv_obj_set_pos(id(temp_preset_4000), 44, 48); - lv_obj_set_pos(id(temp_preset_6500), 44, 92); - - lv_obj_set_y(id(color_brightness_card), 256); - } else { - int palette_y = show_tabs ? 98 : 78; - lv_obj_set_size(id(color_palette_card), 286, 48); - lv_obj_set_y(id(color_palette_card), palette_y); - lv_obj_set_pos(id(color_preset_red), 14, 8); - lv_obj_set_pos(id(color_preset_orange), 61, 8); - lv_obj_set_pos(id(color_preset_yellow), 108, 8); - lv_obj_set_pos(id(color_preset_green), 155, 8); - lv_obj_set_pos(id(color_preset_blue), 202, 8); - lv_obj_set_pos(id(color_preset_purple), 249, 8); - - lv_obj_set_size(id(temp_palette_card), 286, 48); - lv_obj_set_y(id(temp_palette_card), palette_y); - lv_obj_set_pos(id(temp_preset_2700), 24, 8); - lv_obj_set_pos(id(temp_preset_4000), 112, 8); - lv_obj_set_pos(id(temp_preset_6500), 200, 8); - - lv_obj_set_y(id(color_brightness_card), 165); - } - - id(active_color_detail_tab) = id(active_supports_color) ? 0 : 1; + light_controls::open(id(active_entity), id(active_supports_color), id(active_supports_temperature), id(active_brightness_pct)); - lvgl.widget.hide: brightness_overlay - lvgl.widget.show: color_detail_overlay - - script.execute: - id: show_color_detail_tab - tab: !lambda "return id(active_color_detail_tab);" - - - id: show_color_detail_tab - mode: restart - parameters: - tab: int - then: - - lambda: |- - if (tab == 0 && !id(active_supports_color) && id(active_supports_temperature)) { - tab = 1; - } else if (tab == 1 && !id(active_supports_temperature) && id(active_supports_color)) { - tab = 0; - } - id(active_color_detail_tab) = tab; - - bool show_tabs = id(active_supports_color) && id(active_supports_temperature); - if (show_tabs) { - lv_obj_clear_flag(id(color_mode_tabs), LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(id(color_mode_tabs), LV_OBJ_FLAG_HIDDEN); - } - - if (tab == 0 && id(active_supports_color)) { - lv_obj_clear_flag(id(color_palette_card), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(temp_palette_card), LV_OBJ_FLAG_HIDDEN); - if (show_tabs) lv_obj_set_x(id(color_tab_indicator), 2); - } else if (tab == 1 && id(active_supports_temperature)) { - lv_obj_add_flag(id(color_palette_card), LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(id(temp_palette_card), LV_OBJ_FLAG_HIDDEN); - if (show_tabs) lv_obj_set_x(id(color_tab_indicator), 95); - } else { - lv_obj_add_flag(id(color_palette_card), LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(id(temp_palette_card), LV_OBJ_FLAG_HIDDEN); - } - - lv_obj_set_style_text_color( - id(color_tab_label), - id(active_supports_color) ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x8E8E93), - 0 - ); - lv_obj_set_style_text_color( - id(temp_tab_label), - id(active_supports_temperature) ? lv_color_hex(0xFFFFFF) : lv_color_hex(0x8E8E93), - 0 - ); - if (tab == 0 && id(active_supports_color)) { - lv_obj_set_style_text_color(id(color_tab_label), lv_color_hex(0xFFFFFF), 0); - lv_obj_set_style_text_color(id(temp_tab_label), lv_color_hex(0xD1D1D6), 0); - } else if (tab == 1 && id(active_supports_temperature)) { - lv_obj_set_style_text_color(id(color_tab_label), lv_color_hex(0xD1D1D6), 0); - lv_obj_set_style_text_color(id(temp_tab_label), lv_color_hex(0xFFFFFF), 0); - } - - - id: color_brightness_preview - parameters: - value: int - then: - - lambda: |- - id(active_brightness_pct) = value; - char buf[12]; - snprintf(buf, sizeof(buf), "%d %%", value); - lv_label_set_text(id(color_brightness_value), buf); - id: color_brightness_commit parameters: @@ -5224,7 +4817,7 @@ script: - lambda: 'id(active_brightness_pct) = value;' - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_entity).rfind("light.", 0) == 0;' then: - homeassistant.action: action: light.turn_on @@ -5232,37 +4825,36 @@ script: entity_id: !lambda "return id(active_entity);" brightness_pct: !lambda "return value;" - - id: set_light_color_name - mode: queued + - id: set_light_hue parameters: - color: string + value: int then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_color) && id(active_entity).rfind("light.", 0) == 0;' then: - homeassistant.action: action: light.turn_on data: entity_id: !lambda "return id(active_entity);" - color_name: !lambda "return color;" - brightness_pct: !lambda "return id(active_brightness_pct);" + data_template: + hs_color: "{{ [hue | int, 100] }}" + variables: + hue: !lambda "return light_controls::clamp(value, 0, 360);" - id: set_light_color_temperature - mode: queued parameters: kelvin: int then: - if: condition: - lambda: 'return std::string("${DIRECT_ACTIONS}") == "true";' + lambda: 'return std::string("${DIRECT_ACTIONS}") == "true" && id(active_supports_temperature) && id(active_entity).rfind("light.", 0) == 0 && light_controls::active->temperature_ready();' then: - homeassistant.action: action: light.turn_on data: entity_id: !lambda "return id(active_entity);" - color_temp_kelvin: !lambda "return kelvin;" - brightness_pct: !lambda "return id(active_brightness_pct);" + color_temp_kelvin: !lambda "return light_controls::clamp(kelvin, light_controls::active->minimum, light_controls::active->maximum);" - id: slider_preview parameters: @@ -5873,7 +5465,13 @@ script: condition: lambda: 'return id(active_control_kind) != "climate_temperature";' then: - - lvgl.widget.show: brightness_overlay + - if: + condition: + lambda: 'return id(active_supports_color) || id(active_supports_temperature);' + then: + - script.execute: open_color_detail + else: + - lvgl.widget.show: brightness_overlay # UI refresh (values + time + styles) + render - id: ui_refresh diff --git a/light_controls.h b/light_controls.h new file mode 100644 index 0000000..c64a346 --- /dev/null +++ b/light_controls.h @@ -0,0 +1,216 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace light_controls { +inline int clamp(int value, int low, int high) { return std::max(low, std::min(high, value)); } +// HA serializes list attributes as either JSON lists or Python tuples. +inline bool first_number(const std::string &text, float &value) { + const char *p = text.c_str(); + while (*p == ' ' || *p == '[' || *p == '(') ++p; + char *end; + float parsed = std::strtof(p, &end); + if (end == p || !std::isfinite(parsed)) return false; + value = parsed; + return true; +} +struct State { + const char *entity = ""; + int hue = 0, kelvin = 3000, minimum = 0, maximum = 0; + bool temperature_ready() const { return minimum >= 1000 && maximum <= 15000 && maximum > minimum; } + void update(unsigned attribute, const std::string &text) { + float number; + if (!first_number(text, number)) return; + if (attribute == 0 && number >= 0 && number <= 360) hue = clamp(std::lround(number), 0, 360); + if (attribute == 1 && number >= 1000 && number <= 15000) kelvin = std::lround(number); + if (attribute == 2) minimum = number >= 1000 && number <= 15000 ? std::lround(number) : 0; + if (attribute == 3) maximum = number >= 1000 && number <= 15000 ? std::lround(number) : 0; + } +}; +} + +#ifndef LIGHT_CONTROLS_TEST +#include "esphome/components/api/api_server.h" +#include "lvgl.h" +#include +namespace light_controls { +inline State states[8]; +inline State fallback; +inline State *active = &fallback; +struct Row { lv_obj_t *box{}, *slider{}, *value{}; bool dirty = false; unsigned index{}; }; +inline Row rows[3]; +inline std::function commits[3]; +inline bool ready = false; +inline bool demo = false; +inline void state_updated(State *state); +inline int top, spacing; +inline void subscribe(const char *const *entities, size_t count) { + static const char *attributes[] = {"hs_color", "color_temp_kelvin", "min_color_temp_kelvin", "max_color_temp_kelvin"}; + size_t slot = 0; + for (size_t i = 0; i < count && slot < 8; ++i) { + if (std::string(entities[i]).rfind("light.", 0) != 0) continue; + bool duplicate = false; + for (size_t j = 0; j < slot; ++j) if (std::string(states[j].entity) == entities[i]) duplicate = true; + if (duplicate) continue; + State *state = &states[slot++]; + state->entity = entities[i]; + for (unsigned a = 0; a < 4; ++a) + esphome::api::global_api_server->subscribe_home_assistant_state(state->entity, attributes[a], + [state, a](esphome::StringRef value) { state->update(a, std::string(value.c_str(), value.size())); state_updated(state); }); + } +} +inline void preview(Row &row) { + int value = lv_slider_get_value(row.slider); + if (row.index == 0) { + lv_label_set_text_fmt(row.value, "%d°", value); + lv_obj_set_style_bg_color(row.slider, lv_color_hsv_to_rgb(value % 360, 100, 100), LV_PART_KNOB); + } else if (row.index == 1) { + lv_label_set_text_fmt(row.value, "%d K", value); + } else lv_label_set_text_fmt(row.value, "%d %%", value); +} +inline void event(lv_event_t *e) { + auto &row = *static_cast(lv_event_get_user_data(e)); + auto code = lv_event_get_code(e); + if (code == LV_EVENT_VALUE_CHANGED) { row.dirty = true; preview(row); } + if (code == LV_EVENT_PRESS_LOST) row.dirty = false; + if (code == LV_EVENT_RELEASED && row.dirty) { + row.dirty = false; + int value = lv_slider_get_value(row.slider); + if (row.index == 0) active->hue = value; + if (row.index == 1) { + if (!active->temperature_ready()) return; + value = clamp(value, active->minimum, active->maximum); + active->kelvin = value; + } + if (!demo && commits[row.index]) commits[row.index](value); + } +} +inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) { + auto *obj = lv_obj_create(parent); + lv_obj_remove_style_all(obj); + lv_obj_remove_flag(obj, static_cast(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE)); + lv_obj_set_pos(obj, x, y); lv_obj_set_size(obj, w, h); + return obj; +} +inline void setup(lv_obj_t *parent, const lv_font_t *font, int width, int height) { + if (ready) return; + ready = true; + bool large = width >= 480; + top = large ? 94 : 55; spacing = large ? 110 : 58; + int margin = large ? 24 : 16, w = width - 2 * margin; + int track_x = 14, track_y = large ? 43 : 28, track_h = large ? 28 : 14; + int track_w = w - 28; + const char *names[] = {"Kleur", "Wittemperatuur", "Helderheid"}; + const uint32_t rainbow[] = {0xFF0000, 0xFFFF00, 0x00FF00, 0x00FFFF, 0x0000FF, 0xFF00FF, 0xFF0000}; + for (unsigned i = 0; i < 3; ++i) { + auto &row = rows[i]; row.index = i; + row.box = plain(parent, margin, top + i * spacing, w, spacing - 2); + lv_obj_set_style_text_font(row.box, font, 0); + lv_obj_set_style_text_color(row.box, lv_color_hex(0xEEEEF2), 0); + auto *label = lv_label_create(row.box); lv_label_set_text(label, names[i]); + lv_obj_set_pos(label, 2, 0); + row.value = lv_label_create(row.box); lv_obj_align(row.value, LV_ALIGN_TOP_RIGHT, -2, 0); + unsigned segments = i == 0 ? 6 : 1; + for (unsigned n = 0; n < segments; ++n) { + int start = n * track_w / segments, end = (n + 1) * track_w / segments; + auto *stripe = plain(row.box, track_x + start, track_y, end - start, track_h); + lv_obj_set_style_bg_opa(stripe, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(stripe, lv_color_hex(i == 0 ? rainbow[n] : i == 1 ? 0xFF9C32 : 0x40404A), 0); + lv_obj_set_style_bg_grad_color(stripe, lv_color_hex(i == 0 ? rainbow[n + 1] : i == 1 ? 0xC6E6FF : 0xFFFFFF), 0); + lv_obj_set_style_bg_grad_dir(stripe, LV_GRAD_DIR_HOR, 0); + } + row.slider = lv_slider_create(row.box); + lv_obj_remove_style_all(row.slider); + lv_obj_remove_flag(row.slider, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_pos(row.slider, track_x, track_y); lv_obj_set_size(row.slider, track_w, track_h); + lv_obj_set_ext_click_area(row.slider, large ? 12 : 8); + lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_bg_opa(row.slider, LV_OPA_TRANSP, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(row.slider, LV_OPA_COVER, LV_PART_KNOB); + lv_obj_set_style_bg_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB); + lv_obj_set_style_radius(row.slider, LV_RADIUS_CIRCLE, LV_PART_KNOB); + lv_obj_set_style_pad_all(row.slider, large ? 5 : 6, LV_PART_KNOB); + lv_obj_set_style_border_width(row.slider, 2, LV_PART_KNOB); + lv_obj_set_style_border_color(row.slider, lv_color_hex(0xFFFFFF), LV_PART_KNOB); + lv_slider_set_range(row.slider, i == 0 ? 0 : i == 1 ? 2000 : 1, i == 0 ? 360 : i == 1 ? 6500 : 100); + lv_obj_add_event_cb(row.slider, event, LV_EVENT_ALL, &row); + preview(row); + } +} +// Late capability attributes may arrive after opening the card. Enable the +// waiting temperature slider without disturbing an in-progress gesture. +inline void state_updated(State *state) { + if (!ready || state != active || !state->temperature_ready()) return; + auto &row = rows[1]; + if (!lv_obj_has_state(row.slider, LV_STATE_DISABLED)) return; + lv_slider_set_range(row.slider, state->minimum, state->maximum); + lv_slider_set_value(row.slider, state->kelvin, LV_ANIM_OFF); + lv_obj_remove_state(row.slider, LV_STATE_DISABLED); + preview(row); row.dirty = false; +} +inline void open(const std::string &entity, bool color, bool temperature, int brightness) { + active = &fallback; + for (auto &state : states) if (entity == state.entity) { active = &state; break; } + int y = top; + for (unsigned i = 0; i < 3; ++i) { + auto &row = rows[i]; + bool visible = i == 0 ? color : i == 1 ? temperature : true; + if (!visible) { lv_obj_add_flag(row.box, LV_OBJ_FLAG_HIDDEN); continue; } + lv_obj_remove_flag(row.box, LV_OBJ_FLAG_HIDDEN); lv_obj_set_y(row.box, y); y += spacing; + lv_obj_remove_state(row.slider, LV_STATE_DISABLED); + if (i == 1 && !active->temperature_ready()) lv_obj_add_state(row.slider, LV_STATE_DISABLED); + if (i == 1 && active->temperature_ready()) lv_slider_set_range(row.slider, active->minimum, active->maximum); + lv_slider_set_value(row.slider, i == 0 ? active->hue : i == 1 ? active->kelvin : clamp(brightness, 1, 100), LV_ANIM_OFF); + preview(row); row.dirty = false; + if (i == 1 && !active->temperature_ready()) lv_label_set_text(row.value, "Even wachten"); + } +} +inline bool self_test() { + auto *previous = active; + State saved_fallback = fallback; + int saved_values[3]; bool saved_hidden[3]; + std::function saved[3]; + int calls[3] = {0, 0, 0}; + for (unsigned i = 0; i < 3; ++i) { + saved[i] = commits[i]; saved_values[i] = lv_slider_get_value(rows[i].slider); + saved_hidden[i] = lv_obj_has_flag(rows[i].box, LV_OBJ_FLAG_HIDDEN); + commits[i] = [&, i](int value) { if (value == (i == 0 ? 180 : i == 1 ? 4000 : 65)) ++calls[i]; }; + } + fallback.minimum = 2200; fallback.maximum = 5000; + open("diagnostic", true, true, 50); + bool ok = true; + for (unsigned i = 0; i < 3; ++i) { + auto *slider = rows[i].slider; + lv_slider_set_value(slider, i == 0 ? 180 : i == 1 ? 4000 : 65, LV_ANIM_OFF); + for (int n = 0; n < 20; ++n) lv_obj_send_event(slider, LV_EVENT_VALUE_CHANGED, nullptr); + ok &= calls[i] == 0; + lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr); + lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr); + ok &= calls[i] == 1; + lv_obj_send_event(slider, LV_EVENT_VALUE_CHANGED, nullptr); + lv_obj_send_event(slider, LV_EVENT_PRESS_LOST, nullptr); + lv_obj_send_event(slider, LV_EVENT_RELEASED, nullptr); + ok &= calls[i] == 1; + } + for (unsigned mask = 0; mask < 4; ++mask) { + open("diagnostic", mask & 1, mask & 2, 50); + ok &= lv_obj_has_flag(rows[0].box, LV_OBJ_FLAG_HIDDEN) == !(mask & 1); + ok &= lv_obj_has_flag(rows[1].box, LV_OBJ_FLAG_HIDDEN) == !(mask & 2); + ok &= !lv_obj_has_flag(rows[2].box, LV_OBJ_FLAG_HIDDEN); + } + fallback = saved_fallback; + open(previous->entity, !saved_hidden[0], !saved_hidden[1], saved_values[2]); + active = previous; + for (unsigned i = 0; i < 3; ++i) { + commits[i] = saved[i]; + lv_slider_set_value(rows[i].slider, saved_values[i], LV_ANIM_OFF); + preview(rows[i]); rows[i].dirty = false; + } + return ok; +} + +} +#endif diff --git a/tests/test_light_controls.cpp b/tests/test_light_controls.cpp new file mode 100644 index 0000000..38c9d7f --- /dev/null +++ b/tests/test_light_controls.cpp @@ -0,0 +1,23 @@ +#define LIGHT_CONTROLS_TEST +#include "../light_controls.h" +#include +int main() { + using namespace light_controls; + float value = -1; + for (auto text : {"[120.5, 100]", "(120.5, 80.0)", "120.5"}) { + assert(first_number(text, value)); assert(value == 120.5f); + } + for (auto text : {"None", "unknown", "unavailable", "[]", "nan", "inf", ""}) assert(!first_number(text, value)); + State a, b; + a.update(0, "[239.6, 80]"); assert(a.hue == 240); assert(b.hue == 0); + a.update(0, "None"); a.update(0, "[720, 100]"); assert(a.hue == 240); + a.update(1, "2700"); a.update(1, "unavailable"); assert(a.kelvin == 2700); + assert(!a.temperature_ready()); a.update(2, "2200"); assert(!a.temperature_ready()); + a.update(3, "5000"); assert(a.temperature_ready()); + assert(clamp(1800, a.minimum, a.maximum) == 2200); + assert(clamp(6500, a.minimum, a.maximum) == 5000); + a.update(3, "2000"); assert(!a.temperature_ready()); + a.update(3, "1e30"); assert(!a.temperature_ready()); + a.update(3, "5000"); assert(a.temperature_ready()); + assert(clamp(-1, 0, 360) == 0); assert(clamp(999, 0, 360) == 360); +} diff --git a/tools/export_bundle.py b/tools/export_bundle.py index e6ca1e9..a00bc01 100644 --- a/tools/export_bundle.py +++ b/tools/export_bundle.py @@ -6,7 +6,7 @@ import zipfile ROOT = Path(__file__).resolve().parents[1] FILES = ['.gitignore', 'README.md', 'AGENTS.md', 'requirements.txt', 'device.example.yaml', - 'calibration.example.yaml', 'secrets.yaml.example', 'cyd_ui.h', 'LICENSE', + 'calibration.example.yaml', 'secrets.yaml.example', 'cyd_ui.h', 'light_controls.h', 'LICENSE', 'TILE_CONFIGURATION.md', 'CYD_STABILITY.md', 'TEST_RESULTS.md'] FOLDERS = ['docs', 'tools', 'tests', 'components', 'diagnostics', 'fonts'] SUFFIXES = {'.py', '.cpp', '.h', '.md', '.ttf', '.txt'}