Files
Home-Assistant/esphome/resin-curing-timer.yaml
2026-10-17 15:19:53 -04:00

364 lines
10 KiB
YAML

substitutions:
device_name: resin-curing-timer
outlet_entity: switch.resin_uv_light # Home Assistant UV light outlet.
display_model: ESP32-2432S028-7789 # 2.8" CYD with ST7789V (USB-C + micro-USB).
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_iot_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
dimensions: 240x320 # Native portrait size; LVGL rotates to 320x240 landscape.
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<int>(
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 # Landscape; LVGL also rotates touch coordinates.
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
# All four time presets share one horizontal row on the 320x240 page.
- 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