From 5c948377d5a5747b503f562064de9b56be0b23dd Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Jul 2026 18:56:52 -0700 Subject: [PATCH] feat: scaffold classic+ variant as a baseline fork of the classic display --- firmware/components/display-classic-plus.yaml | 577 ++++++++++++++++++ .../waveshare-thermostat-classic-plus.yaml | 54 ++ 2 files changed, 631 insertions(+) create mode 100644 firmware/components/display-classic-plus.yaml create mode 100644 firmware/waveshare-thermostat-classic-plus.yaml diff --git a/firmware/components/display-classic-plus.yaml b/firmware/components/display-classic-plus.yaml new file mode 100644 index 0000000..d70b34d --- /dev/null +++ b/firmware/components/display-classic-plus.yaml @@ -0,0 +1,577 @@ +lvgl: + id: lvgl_comp + displays: + - lcd_display + touchscreens: + - touch_gt911 + pages: + - id: page_home + widgets: + - obj: + width: 100% + height: 100% + pad_all: 0 + border_width: 0 + outline_width: 0 + scrollable: false + widgets: + - label: + align: top_left + x: 70 + y: 20 + text: "-- --" # static placeholder clock — live time is a fast-follow, no time: component this pass + text_font: montserrat_16 + - obj: + id: mode_toggle + align: top_mid + y: 12 + width: 400px + height: 44px + radius: 22 + bg_color: 0x333333 + border_width: 0 + outline_width: 0 + pad_all: 4 + scrollable: false + layout: + type: flex + flex_flow: row + flex_align_cross: center + flex_align_track: center + flex_align_main: space_evenly + widgets: + - button: + id: toggle_temp_btn + width: 120px + height: 36px + radius: 18 + bg_color: 0xFFFFFF + on_click: + - lvgl.widget.show: temp_view + - lvgl.widget.hide: status_view + - lvgl.widget.update: + id: toggle_temp_btn + bg_color: 0xFFFFFF + - lvgl.widget.update: + id: toggle_status_btn + bg_opa: transp + widgets: + - label: + text: "Temp" + text_color: 0x000000 + text_font: montserrat_16 + - button: + id: toggle_status_btn + width: 120px + height: 36px + radius: 18 + bg_opa: transp + on_click: + - lvgl.widget.show: status_view + - lvgl.widget.hide: temp_view + - lvgl.widget.update: + id: toggle_status_btn + bg_color: 0xFFFFFF + - lvgl.widget.update: + id: toggle_temp_btn + bg_opa: transp + widgets: + - label: + text: "Status" + text_font: montserrat_16 + - button: + width: 120px + height: 36px + radius: 18 + bg_opa: transp + widgets: + - label: + text: "My Home" + text_font: montserrat_16 + - label: + align: top_right + x: -16 + y: 20 + text: "Firecrest" + text_font: montserrat_16 + - obj: + id: temp_view + width: 100% + height: 100% + pad_all: 0 + border_width: 0 + outline_width: 0 + scrollable: false + bg_opa: transp + # LVGL's base obj is clickable by default (obj->flags = LV_OBJ_FLAG_CLICKABLE in + # lv_obj_create) and lv_obj_hit_test() requires that flag before checking geometry. + # Being a full-screen, topmost sibling of mode_toggle, this container was silently + # swallowing every tap over the toggle bar (no on_click, so nothing visibly happened) + # before the touch search could reach mode_toggle underneath — same z-order class of + # bug as the bg_opa fix above, but for touch capture instead of rendering. + clickable: false + widgets: + - arc: + id: dial_bg_arc + align: center + y: -20 + width: 400px + height: 400px + start_angle: 315 + end_angle: 225 + min_value: 0 + max_value: 1 + value: 0 + adjustable: false + # arc_color/arc_width are flattened to top-level (MAIN part) rather than nested + # under a "main:" key — an explicit "main:" sub-block silently suppresses the + # implicit top-level MAIN-part properties (align/width/height/x/y), verified via + # isolated test compile. indicator:/knob: below are unaffected since those are + # genuinely separate, non-MAIN parts that require nesting. + arc_color: 0x444444 + arc_width: 10 + indicator: + arc_opa: transp + knob: + arc_opa: transp + - arc: + id: heat_arc + align: center + y: -20 + width: 400px + height: 400px + start_angle: 315 + end_angle: 225 + min_value: 60 + max_value: 90 + value: 70 + adjustable: true + adv_hittest: true # LVGL's default touch hit-test just uses each widget's full + # bounding square (any topmost widget under the touch point + # wins), which is why the radius difference alone didn't fix + # touch disambiguation. adv_hittest switches to LVGL's precise + # ring-based hit test (see LV_EVENT_HIT_TEST in lv_arc.c), + # which actually respects each arc's own radius. + arc_opa: transp # flattened, see dial_bg_arc comment above + indicator: + arc_color: 0xFF3B30 + arc_width: 10 + knob: + bg_color: 0xFF3B30 + on_value: + then: + - climate.control: + id: main_thermostat + target_temperature_low: !lambda "return (x - 32.0f) * 5.0f / 9.0f;" + - lvgl.label.update: + id: heat_to_value_label + text: + format: "%.0f" + args: ["x"] + - arc: + id: cool_arc + align: center + y: -20 + width: 360px # smaller radius than heat_arc (400px) so the two rings are physically + height: 360px # distinct. LVGL's adv_hittest arc hit test only accepts touches within + # the arc's own ring band (radius - arc_width to radius, in its angular + # span) — confirmed via lv_arc.c LV_EVENT_HIT_TEST. Same-radius arcs + # occupy an identical ring band, so adv_hittest alone CANNOT disambiguate + # them (verified on hardware: making both 400px left only the topmost, + # cool_arc, draggable). Differing radii are required, not optional. + start_angle: 315 + end_angle: 225 + mode: REVERSE + min_value: 60 + max_value: 90 + value: 75 + adjustable: true + adv_hittest: true # see heat_arc comment above — precise ring-based hit test + arc_opa: transp # flattened, see dial_bg_arc comment above + indicator: + arc_color: 0x0A84FF + arc_width: 10 + knob: + bg_color: 0x0A84FF + on_value: + then: + - climate.control: + id: main_thermostat + target_temperature_high: !lambda "return (x - 32.0f) * 5.0f / 9.0f;" + - lvgl.label.update: + id: cool_to_value_label + text: + format: "%.0f" + args: ["x"] + - obj: + align: center + y: -40 + width: 200px + height: size_content + border_width: 0 + outline_width: 0 + scrollable: false + layout: + type: flex + flex_flow: column + flex_align_cross: center + flex_align_track: center + flex_align_main: center + widgets: + - label: + id: home_mode_label + text: "Mode: --" + text_font: montserrat_20 + on_click: + lvgl.widget.show: mode_select_overlay + - label: + id: home_temp_label + text: "-- °F" + text_font: montserrat_48 + - button: + align: bottom_left + x: 24 + y: -24 + width: 90px + height: 90px + radius: 45 + on_click: + climate.control: + id: main_thermostat + preset: away + widgets: + - label: + align: center + y: -10 + text: "Away" + text_font: montserrat_16 + - button: + align: bottom_mid + x: -110 + y: -24 + width: 90px + height: 60px + radius: 30 + widgets: + - label: + id: heat_to_value_label + align: center + text: "70" + text_font: montserrat_20 + - button: + align: bottom_mid + x: 110 + y: -24 + width: 90px + height: 60px + radius: 30 + widgets: + - label: + id: cool_to_value_label + align: center + text: "75" + text_font: montserrat_20 + - label: + id: outside_temp_label + align: bottom_right + x: -24 + y: -24 + text: "-- °" + text_font: montserrat_20 + - obj: + id: status_view + hidden: true + width: 100% + height: 100% + pad_all: 16 + border_width: 0 + outline_width: 0 + scrollable: false + bg_opa: transp + clickable: false # see temp_view comment above — same touch-capture fix + layout: + type: flex + flex_flow: column + flex_align_cross: center + flex_align_track: center + flex_align_main: space_evenly + widgets: + - label: + id: status_condition_label + text: "--" + text_font: montserrat_32 + - label: + id: status_temp_label + text: "Outside: -- °F" + text_font: montserrat_24 + - label: + id: status_humidity_label + text: "Humidity: -- %" + text_font: montserrat_20 + - label: + id: status_high_label + text: "High: -- °F" + text_font: montserrat_20 + - label: + id: status_low_label + text: "Low: -- °F" + text_font: montserrat_20 + - label: + id: status_precip_label + text: "Precipitation: -- %" + text_font: montserrat_20 + - id: page_sensors + widgets: + - obj: + width: 100% + height: 510px + pad_all: 16 + border_width: 0 + outline_width: 0 + scrollable: false + layout: + type: flex + flex_flow: column + flex_align_cross: center + flex_align_track: center + flex_align_main: space_evenly + widgets: + - label: + id: sensors_temp_label + text: "Temperature: -- °F" + text_font: montserrat_28 + - label: + id: sensors_humidity_label + text: "Humidity: -- %" + text_font: montserrat_28 + - label: + id: sensors_pressure_label + text: "Pressure: -- hPa" + text_font: montserrat_28 + - id: page_settings + widgets: + - obj: + width: 100% + height: 510px + pad_all: 16 + border_width: 0 + outline_width: 0 + scrollable: false + layout: + type: flex + flex_flow: column + flex_align_cross: center + flex_align_track: start + flex_align_main: start + widgets: + - label: + id: settings_ip_label + text: "IP: --" + text_font: montserrat_20 + - label: + id: settings_uptime_label + text: "Uptime: -- min" + text_font: montserrat_20 + - label: + text: "Manual relay override (bypasses thermostat):" + text_font: montserrat_16 + - obj: + width: 100% + height: size_content + border_width: 0 + outline_width: 0 + scrollable: false + layout: + type: flex + flex_flow: row_wrap + flex_align_cross: start + flex_align_track: start + flex_align_main: start + widgets: + - checkbox: + text: "Fan" + on_click: + switch.toggle: relay_fan + - checkbox: + text: "Cool 1" + on_click: + switch.toggle: relay_cool1 + - checkbox: + text: "Cool 2" + on_click: + switch.toggle: relay_cool2 + - checkbox: + text: "Heat 1" + on_click: + switch.toggle: relay_heat1 + - checkbox: + text: "Heat 2" + on_click: + switch.toggle: relay_heat2 + - id: page_schedule + widgets: + - obj: + width: 100% + height: 510px + pad_all: 16 + border_width: 0 + outline_width: 0 + scrollable: false + layout: + type: flex + flex_flow: column + flex_align_cross: center + flex_align_track: center + flex_align_main: space_evenly + widgets: + - label: + text: "Preset" + text_font: montserrat_28 + - obj: + width: 100% + height: size_content + border_width: 0 + outline_width: 0 + scrollable: false + layout: + type: flex + flex_flow: row + flex_align_cross: center + flex_align_track: center + flex_align_main: space_evenly + widgets: + - button: + width: 300px + height: 100px + on_click: + climate.control: + id: main_thermostat + preset: home + widgets: + - label: + text: "Home" + text_font: montserrat_28 + - button: + width: 300px + height: 100px + on_click: + climate.control: + id: main_thermostat + preset: away + widgets: + - label: + text: "Away" + text_font: montserrat_28 + - label: + text: "Time-based scheduling runs in Home Assistant, not on this device." + text_font: montserrat_16 + top_layer: + widgets: + - button: + id: hamburger_btn + align: top_left + x: 16 + y: 16 + width: 60px + height: 60px + radius: 8 + on_click: + lvgl.widget.show: nav_menu_overlay + widgets: + - label: + text: "=" + text_font: montserrat_28 + - obj: + id: nav_menu_overlay + hidden: true + align: top_left + x: 16 + y: 90px + width: 280px + height: size_content + pad_all: 8 + radius: 8 + layout: + type: flex + flex_flow: column + flex_align_cross: stretch + flex_align_track: start + flex_align_main: start + widgets: + - button: + on_click: + - lvgl.page.show: page_home + - lvgl.widget.hide: nav_menu_overlay + widgets: + - label: + text: "Home" + text_font: montserrat_20 + - button: + on_click: + - lvgl.page.show: page_sensors + - lvgl.widget.hide: nav_menu_overlay + widgets: + - label: + text: "Sensors" + text_font: montserrat_20 + - button: + on_click: + - lvgl.page.show: page_settings + - lvgl.widget.hide: nav_menu_overlay + widgets: + - label: + text: "Settings" + text_font: montserrat_20 + - button: + on_click: + - lvgl.page.show: page_schedule + - lvgl.widget.hide: nav_menu_overlay + widgets: + - label: + text: "Schedule" + text_font: montserrat_20 + - obj: + id: mode_select_overlay + hidden: true + align: center + width: 280px + height: size_content + pad_all: 8 + radius: 8 + layout: + type: flex + flex_flow: column + flex_align_cross: stretch + flex_align_track: start + flex_align_main: start + widgets: + - button: + on_click: + - climate.control: + id: main_thermostat + mode: HEAT + - lvgl.widget.hide: mode_select_overlay + widgets: + - label: + text: "Heat" + text_font: montserrat_20 + - button: + on_click: + - climate.control: + id: main_thermostat + mode: COOL + - lvgl.widget.hide: mode_select_overlay + widgets: + - label: + text: "Cool" + text_font: montserrat_20 + - button: + on_click: + - climate.control: + id: main_thermostat + mode: "OFF" + - lvgl.widget.hide: mode_select_overlay + widgets: + - label: + text: "Off" + text_font: montserrat_20 diff --git a/firmware/waveshare-thermostat-classic-plus.yaml b/firmware/waveshare-thermostat-classic-plus.yaml new file mode 100644 index 0000000..3151e19 --- /dev/null +++ b/firmware/waveshare-thermostat-classic-plus.yaml @@ -0,0 +1,54 @@ +substitutions: + name: firecrest-classic-plus + friendly_name: "Firecrest Classic+" + +esphome: + name: ${name} + friendly_name: ${friendly_name} + platformio_options: + board_build.flash_mode: dio + +esp32: + board: esp32-s3-devkitc-1 + variant: esp32s3 + flash_size: 16MB + framework: + type: esp-idf + sdkconfig_options: + CONFIG_ESP32S3_DATA_CACHE_64KB: y + CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: y + +psram: + mode: octal + speed: 80MHz + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: "${friendly_name} Fallback" + password: "thermostat1234" + +api: + encryption: + key: !secret ha_api_key + +ota: + - platform: esphome + password: !secret ota_password + +logger: + level: DEBUG + hardware_uart: UART0 # this board's "UART" USB-C port (used for flashing) also does log printing + # per Waveshare's docs — ESPHome defaults S3 boards to USB_SERIAL_JTAG, + # a different physical port, which is why logs went silent + +packages: + panel: !include components/panel.yaml + display: !include components/display-classic-plus.yaml + sensors: !include components/sensors.yaml + relays: !include components/relays.yaml + climate: !include components/climate.yaml + diagnostics: !include components/diagnostics.yaml + weather: !include components/weather.yaml + # status_mirror: !include components/status-mirror.yaml # added in Task 3