364 lines
10 KiB
YAML
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
|
|
|