--- # Hardware: Guition JC3248W535 # Pinout: # GPIO1 -> gpio_backlight_pwm (output ledc) # GPIO4 -> touchscreen_bus (i2c sda) # GPIO8 -> touchscreen_bus (i2c scl) # GPIO21 -> display_qspi (spi data_pins) # GPIO39 -> display_qspi (spi data_pins) # GPIO40 -> display_qspi (spi data_pins) # GPIO43 -> ext_uart (uart tx_pin) # GPIO44 -> ext_uart (uart rx_pin) # GPIO45 -> main_display (display mipi_spi cs_pin) # GPIO47 -> display_qspi (spi clk_pin) # GPIO48 -> display_qspi (spi data_pins) # I2C: # 0x3B main_touch (touchscreen axs15231) substitutions: device: lvgl-trainer name: Trainer Display area: Basement comment: "${area} | Pain Cave" light_basement: light.mcu_basement_2_light light_workbench: light.mcu_basement_2_workbench_light light_exercise: light.exercise_room fan: light.mcu_dimmer_exercise_room_fan # Pages (480x320, navigation bar at the bottom): # clock, lights, training, fan, stopwatch, status esphome: name: mcu-${device} friendly_name: ${name} area: ${area} comment: ${comment} esp32: variant: esp32s3 flash_size: 16MB framework: advanced: flash_chip: generic type: esp-idf packages: common: !include common/common.yaml ipv6_only: !include common/ipv6_only.yaml i2c: - id: touchscreen_bus sda: 4 scl: 8 psram: mode: octal speed: 80MHz spi: id: display_qspi type: quad clk_pin: 47 data_pins: [21, 48, 40, 39] globals: # stopwatch: accumulated time of finished runs + start of the current run - id: sw_running type: bool initial_value: 'false' - id: sw_start type: uint32_t initial_value: '0' - id: sw_accum type: uint32_t initial_value: '0' output: - platform: ledc id: gpio_backlight_pwm pin: 1 light: - id: display_backlight name: Backlight platform: monochromatic output: gpio_backlight_pwm restore_mode: ALWAYS_ON default_transition_length: 500ms display: - platform: mipi_spi model: AXS15231 data_rate: 40MHz #20mhz is default id: main_display spi_id: display_qspi dimensions: height: 480 width: 320 cs_pin: 45 auto_clear_enabled: false touchscreen: - platform: axs15231 id: main_touch display: main_display i2c_id: touchscreen_bus update_interval: 100ms # interrupt_pin: # number: 3 # ignore_strapping_warning: true # no calibration/transform: the driver defaults to the native 320x480 panel and # LVGL applies its own rotation to touch points on_release: - if: condition: lvgl.is_paused then: - lvgl.resume: - lvgl.widget.redraw: - light.turn_on: id: display_backlight transition_length: 300ms uart: - id: ext_uart tx_pin: 43 rx_pin: 44 baud_rate: 115200 stop_bits: 1 parity: NONE binary_sensor: - platform: homeassistant id: basement_light entity_id: ${light_basement} trigger_on_initial_state: true on_state: - lvgl.widget.update: id: basement_light_btn state: checked: !lambda return x; - platform: homeassistant id: workbench_light entity_id: ${light_workbench} trigger_on_initial_state: true on_state: - lvgl.widget.update: id: workbench_light_btn state: checked: !lambda return x; - platform: homeassistant id: exercise_light entity_id: ${light_exercise} trigger_on_initial_state: true on_state: - lvgl.widget.update: id: exercise_light_btn state: checked: !lambda return x; # brightness and color temperature only make sense while the light is on - if: condition: lambda: return x; then: - lvgl.widget.show: exercise_controls else: - lvgl.widget.hide: exercise_controls - platform: homeassistant id: fan_on entity_id: ${fan} trigger_on_initial_state: true on_state: - script.execute: update_fan_buttons sensor: - platform: homeassistant id: fan_brightness entity_id: ${fan} attribute: brightness on_value: - script.execute: update_fan_buttons text_sensor: # light attributes read as text: they are 'None' while the light is off - platform: homeassistant id: exercise_brightness entity_id: ${light_exercise} attribute: brightness on_value: - if: condition: lambda: return parse_number(x).has_value(); then: - lvgl.slider.update: id: exercise_brightness_slider value: !lambda return roundf(*parse_number(x) * 100.0f / 255.0f); - platform: homeassistant id: exercise_color_temp entity_id: ${light_exercise} attribute: color_temp_kelvin on_value: - if: condition: lambda: return parse_number(x).has_value(); then: - lvgl.slider.update: id: exercise_color_temp_slider value: !lambda return *parse_number(x); # a running automation (e.g. its delay) has current > 0 - platform: homeassistant id: training_start_running entity_id: automation.zwift_enable_fan attribute: current on_value: - script.execute: update_training_status - platform: homeassistant id: training_stop_running entity_id: automation.zwift_disable_fan attribute: current on_value: - script.execute: update_training_status script: - id: update_fan_buttons then: - lambda: |- // fan level rounded to the 10 % steps of the buttons, 0 = off int pct = 0; if (id(fan_on).state && !std::isnan(id(fan_brightness).state)) pct = (int) roundf(id(fan_brightness).state * 10.0f / 255.0f) * 10; const int levels[] = {0, 50, 60, 70, 80, 90, 100}; lv_obj_t *matrix = id(fan_matrix)->obj; for (int i = 0; i < 7; i++) { if (levels[i] == pct) lv_buttonmatrix_set_button_ctrl(matrix, i, LV_BUTTONMATRIX_CTRL_CHECKED); else lv_buttonmatrix_clear_button_ctrl(matrix, i, LV_BUTTONMATRIX_CTRL_CHECKED); } - lvgl.label.update: id: fan_status_label text: !lambda |- if (!id(fan_on).state) return std::string("Ventilátor vypnutý"); if (std::isnan(id(fan_brightness).state)) return std::string("Ventilátor zapnutý"); return str_sprintf("Ventilátor %.0f %%", id(fan_brightness).state * 100.0f / 255.0f); - id: update_training_status then: - lvgl.label.update: id: training_status_label text: !lambda |- if (id(training_start_running).state != "0" && !id(training_start_running).state.empty()) return std::string("Tréning sa spúšťa, ventilátor sa zapne o 10 min"); if (id(training_stop_running).state != "0" && !id(training_stop_running).state.empty()) return std::string("Tréning sa ukončuje, ventilátor sa vypne o 2 min"); return std::string(""); - id: update_stopwatch then: - lvgl.label.update: id: stopwatch_label text: !lambda |- const unsigned ms = id(sw_accum) + (id(sw_running) ? millis() - id(sw_start) : 0); const unsigned s = ms / 1000; if (s >= 3600) return str_sprintf("%u:%02u:%02u", s / 3600, (s / 60) % 60, s % 60); return str_sprintf("%02u:%02u.%u", s / 60, s % 60, (ms / 100) % 10); - lvgl.label.update: id: stopwatch_start_label text: !lambda |- return std::string(id(sw_running) ? "Stop" : "Štart"); - id: update_status then: - lambda: |- // same colors as the other status screens: ok green, waiting orange, details gray static const uint32_t OK = 0x4DD966, WAIT = 0xFFB300; auto set = [](lv_obj_t *label, const std::string &text, uint32_t color) { lv_label_set_text(label, text.c_str()); lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); }; const bool wifi_ok = wifi::global_wifi_component->is_connected(); set(id(status_wifi), wifi_ok ? str_sprintf("WiFi: connected (%d dBm)", wifi::global_wifi_component->wifi_rssi()) : std::string("WiFi: connecting..."), wifi_ok ? OK : WAIT); // IPv6 first (skip link-local), then IPv4 std::string ips; if (wifi_ok) { char buf[network::IP_ADDRESS_BUFFER_SIZE]; for (bool v6 : {true, false}) { for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) { if (!ip.is_set() || ip.is_ip6() != v6) continue; ip.str_to(buf); if (v6 && strncmp(buf, "fe80", 4) == 0) continue; if (!ips.empty()) ips += "\n"; ips += buf; } } } lv_label_set_text(id(status_ips), ips.c_str()); if (ips.empty()) lv_obj_add_flag(id(status_ips), LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(id(status_ips), LV_OBJ_FLAG_HIDDEN); const bool api_ok = api::global_api_server->is_connected(); set(id(status_ha), api_ok ? "Home Assistant: connected" : "Home Assistant: waiting...", api_ok ? OK : WAIT); const uint32_t up = millis() / 1000; lv_label_set_text(id(status_footer), str_sprintf("ESPHome %s · up %ud %02u:%02u:%02u", ESPHOME_VERSION, up / 86400, (up / 3600) % 24, (up / 60) % 60, up % 60).c_str()); interval: - interval: 1s then: - lvgl.label.update: id: clock_label text: !lambda |- auto now = id(ha_time).now(); if (!now.is_valid()) return std::string("--:--:--"); return now.strftime("%H:%M:%S"); - interval: 100ms then: - if: condition: and: - lambda: return id(sw_running); - lvgl.page.is_showing: stopwatch_page then: - script.execute: update_stopwatch - interval: 2s then: - if: condition: lvgl.page.is_showing: status_page then: - script.execute: update_status image: - platform: file file: assets/images/made-for-esphome-white-on-black.svg id: esphome_logo resize: 280x83 type: RGB565 transparency: alpha_channel font: - file: "gfonts://Roboto@500" id: font_digits size: 110 glyphs: "0123456789:.-" - file: "gfonts://Roboto" id: font_ui size: 24 glyphs: &sk_glyphs "ÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽáäčďéíĺľňóôŕšťúýž°·" glyphsets: [GF_Latin_Kernel] - file: "gfonts://Roboto@500" id: font_button size: 34 glyphs: *sk_glyphs glyphsets: [GF_Latin_Kernel] - file: "gfonts://Roboto" id: font_small size: 18 glyphs: *sk_glyphs glyphsets: [GF_Latin_Kernel] lvgl: rotation: 90 buffer_size: 100% log_level: WARN color_depth: 16 default_font: font_ui bg_color: 0x000000 text_color: 0xFFFFFF theme: button: bg_color: 0x2B2F36 radius: 12 checked: bg_color: 0xE08A00 buttonmatrix: bg_opa: TRANSP border_width: 0 items: bg_color: 0x2B2F36 text_color: 0xFFFFFF radius: 12 checked: bg_color: 0xE08A00 slider: bg_color: 0x2B2F36 indicator: bg_color: 0xE08A00 knob: bg_color: 0xFFFFFF top_layer: widgets: - buttonmatrix: id: nav_bar align: bottom_mid width: 100% height: 56 pad_all: 4 pad_column: 6 items: text_font: montserrat_28 rows: - buttons: - id: page_prev text: "" on_press: then: lvgl.page.previous: - id: page_home text: "" on_press: then: lvgl.page.show: clock_page - id: page_next text: "" on_press: then: lvgl.page.next: pages: # 1: clock (default after boot) - id: clock_page widgets: - label: id: clock_label align: CENTER y: -28 text_font: font_digits text: "--:--:--" # 2: basement lights - id: lights_page pad_all: 10 widgets: - button: id: basement_light_btn align: TOP_LEFT width: 148 height: 80 checkable: true widgets: - label: align: CENTER text: "Pivnica" on_click: - homeassistant.action: action: light.toggle data: entity_id: ${light_basement} - button: id: workbench_light_btn align: TOP_MID width: 148 height: 80 checkable: true widgets: - label: align: CENTER text: "Pracovný stôl" on_click: - homeassistant.action: action: light.toggle data: entity_id: ${light_workbench} - button: id: exercise_light_btn align: TOP_RIGHT width: 148 height: 80 checkable: true widgets: - label: align: CENTER text: "Posilka" on_click: - homeassistant.action: action: light.toggle data: entity_id: ${light_exercise} - obj: id: exercise_controls hidden: true y: 110 width: 460 height: 120 pad_all: 0 bg_opa: TRANSP border_width: 0 scrollbar_mode: "OFF" widgets: - label: x: 4 y: 0 text_font: font_small text: "Posilka jas" - slider: id: exercise_brightness_slider x: 14 y: 32 width: 430 height: 16 min_value: 1 max_value: 100 value: 100 on_release: - homeassistant.action: action: light.turn_on data: entity_id: ${light_exercise} brightness_pct: !lambda return (int) x; - label: x: 4 y: 62 text_font: font_small text: "Posilka teplota (2700 – 6500 K)" - slider: id: exercise_color_temp_slider x: 14 y: 94 width: 430 height: 16 min_value: 2700 max_value: 6500 value: 2700 on_release: - homeassistant.action: action: light.turn_on data: entity_id: ${light_exercise} color_temp_kelvin: !lambda return (int) x; # 3: training start / stop - id: training_page pad_all: 10 widgets: - button: align: TOP_LEFT y: 10 width: 222 height: 150 bg_color: 0x1E7D32 widgets: - label: align: CENTER text_font: font_button text: "Štart" on_click: - homeassistant.action: action: automation.trigger data: entity_id: automation.zwift_enable_fan - button: align: TOP_RIGHT y: 10 width: 222 height: 150 bg_color: 0xB71C1C widgets: - label: align: CENTER text_font: font_button text: "Stop" on_click: - homeassistant.action: action: automation.trigger data: entity_id: automation.zwift_disable_fan - label: id: training_status_label align: TOP_MID y: 185 width: 440 text_align: CENTER text_font: font_small text: "" # 4: fan - id: fan_page pad_all: 10 widgets: - buttonmatrix: id: fan_matrix align: TOP_MID width: 460 height: 180 pad_row: 8 pad_column: 8 one_checked: true items: text_font: font_button rows: - buttons: - text: "Vyp" control: checkable: true on_press: - homeassistant.action: action: light.turn_off data: entity_id: ${fan} - text: "50" control: checkable: true on_press: - homeassistant.action: action: light.turn_on data: entity_id: ${fan} brightness_pct: "50" - text: "60" control: checkable: true on_press: - homeassistant.action: action: light.turn_on data: entity_id: ${fan} brightness_pct: "60" - text: "70" control: checkable: true on_press: - homeassistant.action: action: light.turn_on data: entity_id: ${fan} brightness_pct: "70" - buttons: - text: "80" control: checkable: true on_press: - homeassistant.action: action: light.turn_on data: entity_id: ${fan} brightness_pct: "80" - text: "90" control: checkable: true on_press: - homeassistant.action: action: light.turn_on data: entity_id: ${fan} brightness_pct: "90" - text: "100" control: checkable: true on_press: - homeassistant.action: action: light.turn_on data: entity_id: ${fan} brightness_pct: "100" - label: id: fan_status_label align: TOP_MID y: 200 text_font: font_small text: "" # 5: stopwatch - id: stopwatch_page pad_all: 10 widgets: - label: id: stopwatch_label align: TOP_MID y: 10 text_font: font_digits text: "00:00.0" - button: align: TOP_LEFT y: 150 width: 222 height: 90 widgets: - label: id: stopwatch_start_label align: CENTER text_font: font_button text: "Štart" on_click: - lambda: |- if (id(sw_running)) { id(sw_accum) += millis() - id(sw_start); id(sw_running) = false; } else { id(sw_start) = millis(); id(sw_running) = true; } - script.execute: update_stopwatch - button: align: TOP_RIGHT y: 150 width: 222 height: 90 widgets: - label: align: CENTER text_font: font_button text: "Reset" on_click: - lambda: |- id(sw_accum) = 0; id(sw_start) = millis(); - script.execute: update_stopwatch # 6: status - id: status_page on_load: - script.execute: update_status widgets: - image: src: esphome_logo align: TOP_MID y: 8 - label: align: TOP_MID y: 96 text: "${name}" - obj: id: status_lines x: 20 y: 126 width: 440 height: SIZE_CONTENT bg_opa: TRANSP border_width: 0 pad_all: 0 scrollbar_mode: "OFF" layout: type: flex flex_flow: column pad_row: 4 widgets: - label: id: status_wifi text_font: font_small text: "" - label: id: status_ips text_font: font_small text_color: 0x8C8C8C pad_left: 16 text: "" - label: id: status_ha text_font: font_small text: "" - label: id: status_footer text_font: font_small text_color: 0x8C8C8C text: ""