substitutions: device_name: resin-curing-timer outlet_entity: switch.resin_uv_light # Home Assistant UV light outlet. display_model: ESP32-2432S028 # Original ILI9341 CYD; see README for variants. esphome: name: ${device_name} friendly_name: Resin Curing Timer min_version: 2026.9.0 esp32: board: esp32dev framework: type: esp-idf logger: api: id: ha_api encryption: key: !secret api_encryption_key ota: - platform: esphome encryption: key: !secret api_encryption_key wifi: ssid: !secret wifi_ssid password: !secret wifi_password spi: - id: tft_spi clk_pin: GPIO14 mosi_pin: GPIO13 - id: touch_spi clk_pin: GPIO25 mosi_pin: GPIO32 miso_pin: GPIO39 output: - platform: ledc pin: GPIO21 id: backlight_output light: - platform: monochromatic output: backlight_output name: Backlight id: backlight restore_mode: ALWAYS_ON default_transition_length: 0s display: - platform: mipi_spi id: tft model: ${display_model} spi_id: tft_spi auto_clear_enabled: false update_interval: never touchscreen: - platform: xpt2046 id: touch spi_id: touch_spi display: tft cs_pin: GPIO33 interrupt_pin: GPIO36 threshold: 400 # Starting calibration only: verify all four corners on your board. calibration: x_min: 200 x_max: 3900 y_min: 200 y_max: 3900 transform: swap_xy: true mirror_x: true on_touch: - logger.log: format: "Touch x=%d y=%d raw_x=%d raw_y=%d" args: [touch.x, touch.y, touch.x_raw, touch.y_raw] time: - platform: homeassistant id: ha_time text_sensor: - platform: homeassistant id: timer_state entity_id: timer.resin_curing internal: true - platform: homeassistant id: outlet_state entity_id: ${outlet_entity} internal: true - platform: homeassistant id: timer_duration entity_id: timer.resin_curing attribute: duration internal: true # Keep epoch seconds as text: ESPHome numeric sensors use float32, which # loses minute-level precision for contemporary Unix timestamps. - platform: homeassistant id: deadline entity_id: sensor.resin_curing_deadline internal: true script: - id: request_start parameters: minutes: int then: - if: condition: api.connected: state_subscription_only: true then: - homeassistant.event: event: esphome.resin_curing_timer data: controller: ${device_name} command: start minutes: !lambda return to_string(minutes); - id: request_stop then: - if: condition: api.connected: state_subscription_only: true then: - homeassistant.event: event: esphome.resin_curing_timer data: controller: ${device_name} command: stop # Only Home Assistant's reported state drives the status and countdown. interval: - interval: 1s then: - lvgl.label.update: id: status_label text: !lambda |- if (!id(ha_api).is_connected_with_state_subscription()) return std::string("Home Assistant offline"); if (!id(outlet_state).has_state() || (id(outlet_state).state != "on" && id(outlet_state).state != "off")) return std::string("Outlet unavailable"); if (id(outlet_state).state == "on") { if (id(timer_state).state == "active") return std::string("UV light ON | Time remaining"); return std::string("UV light ON | No active timer"); } return std::string("UV light OFF | Choose a time"); - lvgl.label.update: id: countdown_label text: !lambda |- if (!id(ha_api).is_connected_with_state_subscription()) return std::string("--:--"); if (!id(timer_state).has_state() || (id(timer_state).state != "active" && id(timer_state).state != "idle")) return std::string("--:--"); if (id(timer_state).state == "idle") { if (!id(ha_time).now().is_valid()) return std::string("--:--"); return id(ha_time).now().strftime("%H:%M"); } const char *raw = id(deadline).state.c_str(); char *tail; const double end = strtod(raw, &tail); if (!id(ha_time).now().is_valid() || tail == raw || *tail != '\0' || !std::isfinite(end) || end <= 0) return std::string("Syncing..."); const int remaining = std::max(0, static_cast( end - id(ha_time).now().timestamp)); char buffer[16]; snprintf(buffer, sizeof(buffer), "%02d:%02d", remaining / 60, remaining % 60); return std::string(buffer); # Highlight only the preset confirmed by Home Assistant, never a local tap. - lvgl.widget.update: id: preset_30 state: checked: !lambda |- return id(ha_api).is_connected_with_state_subscription() && id(timer_state).state == "active" && id(outlet_state).state == "on" && id(timer_duration).state == "0:30:00"; - lvgl.widget.update: id: preset_45 state: checked: !lambda |- return id(ha_api).is_connected_with_state_subscription() && id(timer_state).state == "active" && id(outlet_state).state == "on" && id(timer_duration).state == "0:45:00"; - lvgl.widget.update: id: preset_60 state: checked: !lambda |- return id(ha_api).is_connected_with_state_subscription() && id(timer_state).state == "active" && id(outlet_state).state == "on" && id(timer_duration).state == "1:00:00"; - lvgl.widget.update: id: preset_90 state: checked: !lambda |- return id(ha_api).is_connected_with_state_subscription() && id(timer_state).state == "active" && id(outlet_state).state == "on" && id(timer_duration).state == "1:30:00"; lvgl: displays: [tft] rotation: 90 touchscreens: [touch] buffer_size: 25% bg_color: 0x1C2638 text_color: 0xD6E8F0 default_font: montserrat_16 theme: button: bg_color: 0x7ED4E6 bg_opa: COVER radius: 30 width: 60 height: 60 border_width: 0 shadow_width: 0 pad_all: 0 text_color: 0x102A3A pressed: bg_color: 0xC4F4FA checked: bg_color: 0xF7D264 text_color: 0x102A3A pages: - id: timer_page bg_color: 0x1C2638 bg_opa: COVER pad_all: 0 scrollbar_mode: "OFF" widgets: - label: text: "RESIN CURING" x: 14 y: 20 text_font: montserrat_14 - button: id: stop_button x: 242 y: 10 width: 64 height: 34 radius: 17 bg_color: 0x343044 text_color: 0xF7C0BE pressed: bg_color: 0x793E48 widgets: - label: text: "STOP" align: CENTER text_font: montserrat_12 on_click: - script.execute: request_stop - label: id: status_label text: "Connecting..." x: 0 y: 114 width: 320 text_align: CENTER text_font: montserrat_12 text_color: 0xBED1DF - label: id: countdown_label text: "--:--" x: 0 y: 49 width: 320 text_align: CENTER text_font: montserrat_48 text_color: 0xA7EFF5 - button: id: preset_30 x: 10 y: 150 widgets: - label: text: "30" align: CENTER text_font: montserrat_24 on_click: - script.execute: id: request_start minutes: 30 - label: text: "minutes" x: 10 y: 217 width: 60 text_align: CENTER text_font: montserrat_12 - button: id: preset_45 x: 90 y: 150 widgets: - label: text: "45" align: CENTER text_font: montserrat_24 on_click: - script.execute: id: request_start minutes: 45 - label: text: "minutes" x: 90 y: 217 width: 60 text_align: CENTER text_font: montserrat_12 - button: id: preset_60 x: 170 y: 150 widgets: - label: text: "60" align: CENTER text_font: montserrat_24 on_click: - script.execute: id: request_start minutes: 60 - label: text: "minutes" x: 170 y: 217 width: 60 text_align: CENTER text_font: montserrat_12 - button: id: preset_90 x: 250 y: 150 widgets: - label: text: "90" align: CENTER text_font: montserrat_24 on_click: - script.execute: id: request_start minutes: 90 - label: text: "minutes" x: 250 y: 217 width: 60 text_align: CENTER text_font: montserrat_12