#:########################################################################################:# # TITLE: ESP reTERMINAL E1001 DASHBOARD # # zorruno.com layout v1.1 2026 # #:########################################################################################:# # REPO: # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-reterminal-e1001.yaml #:########################################################################################:# # VERSIONS: # V0.9.3 2026-07-30 Redesigned job tiles: icon left, title + status right; # COMPLETED caps; renamed jobs and swapped grid positions; # right-aligned sub-status; spacing tweaks. # V0.8.0 2026-07-30 Removed the Appliances page and page-selection state; both white # buttons now show and check the single seven-job Jobs page. # V0.7.0 2026-07-30 4+3 job layout with plant watering jobs; smaller 190 px tiles # V0.6.2 2026-07-25 Standardized MQTT payloads: "ON"/"OFF" → "On"/"Off" # V0.6.1 2026-07-20 Allowed GPIO4/GPIO5 to be shared by the EXT1 deep-sleep # wake source and the normal white-button binary sensors. # V0.6 2026-07-20 Added open/closed-eye panel-state indicator, retained MQTT awake/sleep # status publishing, and inverse black tiles for Due, Overdue and Missed/Skipped jobs. # V0.5 2026-07-20 Added five-minute deep sleep, timer and dual white-button wake, # retained MQTT sleep control, wake-source page selection, persistent # per-page display hashes and short post-button interaction timeout. # V0.4 2026-07-20 Improved Jobs page with large Material Design job icons, # state icons, two-line centred job names and larger status text. # V0.3 2026-07-20 Added two-page Jobs and Appliances dashboard, white-button page # selection, rounded tiles, full date header, Home Assistant job and # appliance data, inverse alert tiles, and change-only display refreshes. # V0.2 2026-07-20 Enabled ESP-IDF experimental features required by ESPHome # 2026.7.0 for OTA support with 32MB flash. # V0.1 2026-07-20 Initial ESPHome bring-up, Home Assistant API connection, # E1001 e-paper display test matrix and physical button setup. #:########################################################################################:# # HARDWARE: # - Seeed Studio reTerminal E1001 # - ESP32-S3 # - 32MB flash # - 8MB octal PSRAM # - 7.5 inch monochrome e-paper display # - 800x480 resolution # - Green refresh button: GPIO3 # - Right white button: GPIO4 # - Left white button: GPIO5 # - Onboard green LED: GPIO6, inverted # - Display SPI clock: GPIO7 # - Display SPI MOSI: GPIO9 # - Display CS: GPIO10 # - Display DC: GPIO11 # - Display reset: GPIO12 # - Display busy: GPIO13, inverted #:########################################################################################:# # OPERATION: # - The device sleeps for five minutes, then wakes and checks the Jobs page. # - Either white button can wake the ESP32-S3 through EXT1 ANY_LOW. # - Either white button shows and checks View Road Jobs and resets the activity timeout. # - The Jobs display hash survives deep sleep. The e-paper panel refreshes only when the # Jobs content or visible awake/sleep indicator changes. # - Two open eyes indicate an interactive/kept-awake panel. Two closed eyes are written to # the e-paper panel before entering deep sleep. # - After a button interaction, the device remains awake briefly for further interaction. # - Home Assistant entity data is imported through the encrypted ESPHome native API. # - MQTT is used only for retained deep-sleep control and retained awake/sleep status. # # MQTT COMMANDS: # - ${mqtt_command_topic}/deepsleep = ON # - Allow the device to return to deep sleep. # - ${mqtt_command_topic}/deepsleep = OFF # - Prevent deep sleep for OTA updates and development. # - Publish these commands as retained messages. # MQTT STATUS: # - ${mqtt_status_topic}/state = awake/sleeping (retained) # - ${mqtt_status_topic}/deepsleep = ON/OFF (retained) # # - Deep sleep is prevented by default until the retained MQTT command is received. # - If MQTT cannot be reached after the boot timeout, the device uses a battery-protecting # fallback and permits the normal five-minute sleep cycle for that wake. #:########################################################################################:# #:########################################################################################:# # SUBSTITUTIONS: # Specific device variable substitutions # If NOT using a secrets file, replace the passwords etc with quoted values. #:########################################################################################:# substitutions: # Device Naming device_name: "esp-reterminal-e1001" friendly_name: "reTerminal E1001 Dashboard" description_comment: "Seeed Studio reTerminal E1001 Home Assistant Jobs dashboard with five-minute deep sleep, button wake and panel-state eyes. (Layout V2.0)" device_area: "Lounge" # Project Naming project_name: "SeeedStudio.reTerminal-E1001" project_version: "v0.9.3" # Passwords and Secrets api_key: !secret esp-api_key ota_pass: !secret esp-ota_pass mqtt_command_main_topic: !secret mqtt_command_main_topic mqtt_status_main_topic: !secret mqtt_status_main_topic # MQTT Local Controls mqtt_device_name: "reterminal-e1001" mqtt_command_topic: "${mqtt_command_main_topic}/${mqtt_device_name}" mqtt_status_topic: "${mqtt_status_main_topic}/${mqtt_device_name}" # Device Settings log_level: "INFO" display_check_interval: "30s" job_hours_display_limit: "48" # Deep Sleep Settings sleep_interval: "5min" mqtt_connect_timeout: "30s" api_connect_timeout: "30s" ha_data_timeout: "25s" timer_wake_awake_seconds: "8" button_wake_awake_seconds: "30" mqtt_enable_awake_seconds: "15" #:########################################################################################:# # PACKAGES: # Included Common Packages # https://esphome.io/components/packages.html #:########################################################################################:# packages: #### WIFI, Network DHCP, Fallback AP, Safemode #### common_wifi: !include file: common/network_common_dhcp.yaml vars: local_device_name: "${device_name}" local_ota_pass: "${ota_pass}" #### HOME ASSISTANT API #### common_api: !include file: common/api_common.yaml #file: common/api_common_noencryption.yaml vars: local_api_key: "${api_key}" #### MQTT #### # MQTT is used only for retained deep-sleep control and status. # Home Assistant entity states still arrive through the native API. common_mqtt: !include file: common/mqtt_common.yaml vars: local_device_name: "${device_name}" #### WEB PORTAL #### #common_webportal: !include common/webportal_common_nopass.yaml #### SNTP #### # Home Assistant provides local time through the native API below. #common_sntp: !include common/sntp_common.yaml #### DIAGNOSTICS SENSORS #### #diag_basic: !include common/include_basic_diag_sensors.yaml #diag_more: !include common/include_more_diag_sensors.yaml #diag_debug: !include common/include_debug_diag_sensors.yaml #diag_resetcount: !include common/include_resetcount_diag_sensors.yaml #:########################################################################################:# # ESPHOME: # https://esphome.io/components/esphome.html #:########################################################################################:# esphome: name: "${device_name}" friendly_name: "${friendly_name}" comment: "${description_comment}" area: "${device_area}" min_version: 2026.7.0 project: name: "${project_name}" version: "${project_version}" on_boot: #:####################################################################################:# # EARLY WAKE-SOURCE DETECTION # #:####################################################################################:# - priority: 800 then: - lambda: |- id(wakeup_cause) = static_cast(esp_sleep_get_wakeup_cause()); id(wakeup_pin_mask) = esp_sleep_get_ext1_wakeup_status(); id(woke_by_button) = id(wakeup_cause) == ESP_SLEEP_WAKEUP_EXT1; // A physical-button wake is interactive, so show open eyes. A short // timer maintenance wake keeps the closed-eye icon unless the device // is explicitly kept awake through MQTT. id(panel_awake_indicator) = id(woke_by_button); ESP_LOGI( "wakeup", "Wake cause=%d EXT1 mask=0x%llX", id(wakeup_cause), static_cast(id(wakeup_pin_mask)) ); #:####################################################################################:# # DEFAULT TO STAYING AWAKE UNTIL MQTT CONTROL IS RECEIVED # #:####################################################################################:# - priority: 600 then: - deep_sleep.prevent: deep_sleep_1 - lambda: |- id(sleep_allowed) = false; id(boot_sequence_complete) = false; id(last_activity_ms) = millis(); id(awake_timeout_ms) = ${button_wake_awake_seconds} * 1000UL; - logger.log: "Deep sleep prevented by default while waiting for retained MQTT control." #:####################################################################################:# # NETWORK, HOME ASSISTANT DATA AND DISPLAY CHECK # #:####################################################################################:# - priority: -100 then: - wait_until: condition: mqtt.connected: timeout: "${mqtt_connect_timeout}" - if: condition: not: mqtt.connected: then: - logger.log: "MQTT unavailable after ${mqtt_connect_timeout}; allowing fallback sleep for battery protection." - lambda: |- id(sleep_allowed) = true; - deep_sleep.allow: deep_sleep_1 else: # Allow time for the retained deepsleep command to be delivered. - delay: 2s - wait_until: condition: api.connected: timeout: "${api_connect_timeout}" - wait_until: condition: lambda: |- if (!id(homeassistant_time).now().is_valid()) return false; return id(job_dog_feeding_morning_state).has_state() && id(job_dog_feeding_evening_state).has_state() && id(job_dog_depooping_state).has_state() && id(job_dishwasher_unpacked_state).has_state() && id(job_rubbish_removed_state).has_state() && id(job_water_tv_pot_plant_state).has_state() && id(job_water_table_pot_plant_state).has_state() && id(job_empty_dryer_state).has_state() && id(job_empty_washing_machine_state).has_state() && id(job_dog_feeding_morning_due_ts).has_state() && id(job_dog_feeding_evening_due_ts).has_state() && id(job_dog_depooping_due_ts).has_state() && id(job_dishwasher_unpacked_due_ts).has_state() && id(job_rubbish_removed_due_ts).has_state() && id(job_water_tv_pot_plant_due_ts).has_state() && id(job_water_table_pot_plant_due_ts).has_state() && id(job_empty_dryer_due_ts).has_state() && id(job_empty_washing_machine_due_ts).has_state(); timeout: "${ha_data_timeout}" - display.page.show: page_jobs - if: condition: lambda: |- return id(homeassistant_time).now().is_valid(); then: - script.execute: id: refresh_display_if_changed force: false - script.wait: refresh_display_if_changed else: - logger.log: "Home Assistant time/data were unavailable; preserving the existing e-paper image." - if: condition: mqtt.connected: then: - mqtt.publish: topic: "${mqtt_status_topic}/state" payload: "awake" qos: 1 retain: true - lambda: |- id(boot_sequence_complete) = true; id(last_activity_ms) = millis(); id(awake_timeout_ms) = (id(woke_by_button) ? ${button_wake_awake_seconds} : ${timer_wake_awake_seconds}) * 1000UL; ESP_LOGI( "deep_sleep", "Boot display check complete; inactivity window is %lu seconds", static_cast(id(awake_timeout_ms) / 1000UL) ); #:########################################################################################:# # ESP PLATFORM AND FRAMEWORK: # https://esphome.io/components/esp32.html #:########################################################################################:# esp32: board: esp32-s3-devkitc-1 flash_size: 32MB framework: type: arduino advanced: enable_idf_experimental_features: true #:########################################################################################:# # PSRAM: # https://esphome.io/components/psram.html #:########################################################################################:# psram: mode: octal speed: 80MHz #:########################################################################################:# # LOGGER: # The E1001 uses its CH340K USB-to-UART bridge on UART0. # https://esphome.io/components/logger.html #:########################################################################################:# logger: level: "${log_level}" baud_rate: 115200 hardware_uart: UART0 #:########################################################################################:# # SPI: # E-paper display bus # https://esphome.io/components/spi.html #:########################################################################################:# spi: id: epaper_spi clk_pin: GPIO7 mosi_pin: GPIO9 #:########################################################################################:# # HOME ASSISTANT TIME: # Used for the date header and job countdowns. # https://esphome.io/components/time/homeassistant.html #:########################################################################################:# time: - platform: homeassistant id: homeassistant_time on_time_sync: then: - script.execute: id: refresh_display_if_changed force: false #:########################################################################################:# # GLOBALS: # Persistent display hash, wake information and sleep-control state # https://esphome.io/components/globals.html #:########################################################################################:# globals: # The persistent Jobs hash is updated only when the visible content really changes, # avoiding a flash write on every five-minute wake. - id: last_jobs_signature_hash type: uint32_t restore_value: yes initial_value: "0" - id: display_update_required type: bool restore_value: no initial_value: "false" - id: wakeup_cause type: int restore_value: no initial_value: "0" - id: wakeup_pin_mask type: uint64_t restore_value: no initial_value: "0" - id: woke_by_button type: bool restore_value: no initial_value: "false" # Desired icon currently rendered in the display header. This is included in the # Jobs signature, so changing eye state refreshes only when required. - id: panel_awake_indicator type: bool restore_value: no initial_value: "false" - id: sleep_allowed type: bool restore_value: no initial_value: "false" - id: boot_sequence_complete type: bool restore_value: no initial_value: "false" - id: last_activity_ms type: uint32_t restore_value: no initial_value: "0" - id: awake_timeout_ms type: uint32_t restore_value: no initial_value: "30000" #:########################################################################################:# # FONTS: # Bold, compact fonts for clear monochrome e-paper rendering. # https://esphome.io/components/font.html #:########################################################################################:# font: - file: type: gfonts family: Inter weight: 700 id: font_header size: 31 - file: type: gfonts family: Inter weight: 700 id: font_date size: 18 - file: type: gfonts family: Inter weight: 700 id: font_job_title size: 25 - file: type: gfonts family: Inter weight: 700 id: font_job_status size: 23 - file: type: gfonts family: Inter weight: 700 id: font_job_status_sub size: 16 #:########################################################################################:# # JOB AND STATUS ICONS: # Material Design Icons are downloaded and embedded at compile time. # https://esphome.io/components/image/file.html #:########################################################################################:# image: #:######################################################################################:# # Job icons # #:######################################################################################:# - platform: file file: mdi:dog-side id: icon_job_dog type: BINARY transparency: chroma_key resize: 50x50 - platform: file file: mdi:emoticon-poop id: icon_job_poop type: BINARY transparency: chroma_key resize: 50x50 - platform: file file: mdi:dishwasher id: icon_job_dishwasher type: BINARY transparency: chroma_key resize: 50x50 - platform: file file: mdi:trash-can id: icon_job_rubbish type: BINARY transparency: chroma_key resize: 50x50 - platform: file file: mdi:watering-can id: icon_job_watering type: BINARY transparency: chroma_key resize: 50x50 - platform: file file: mdi:tumble-dryer id: icon_job_dryer type: BINARY transparency: chroma_key resize: 50x50 - platform: file file: mdi:washing-machine id: icon_job_washmachine type: BINARY transparency: chroma_key resize: 50x50 #:######################################################################################:# # State icons # #:######################################################################################:# - platform: file file: mdi:check-circle id: icon_status_complete type: BINARY transparency: chroma_key resize: 30x30 - platform: file file: mdi:clock-outline id: icon_status_due type: BINARY transparency: chroma_key resize: 30x30 - platform: file file: mdi:alert-octagon id: icon_status_overdue type: BINARY transparency: chroma_key resize: 30x30 - platform: file file: mdi:close-circle id: icon_status_missed type: BINARY transparency: chroma_key resize: 30x30 - platform: file file: mdi:help-circle-outline id: icon_status_unknown type: BINARY transparency: chroma_key resize: 30x30 #:########################################################################################:# # DISPLAY: # Single Jobs page with rounded status tiles. # The display itself never runs on a blind polling interval. All refreshes pass through # refresh_display_if_changed before the physical e-paper update occurs. # https://esphome.io/components/display/waveshare_epaper.html #:########################################################################################:# display: - platform: waveshare_epaper id: epaper_display spi_id: epaper_spi model: 7.50inv2 cs_pin: GPIO10 dc_pin: GPIO11 reset_pin: number: GPIO12 inverted: false busy_pin: number: GPIO13 inverted: true update_interval: never pages: #:####################################################################################:# # PAGE 1 - VIEW ROAD JOBS # #:####################################################################################:# - id: page_jobs lambda: |- const int screen_width = 800; const int header_y = 58; const int radius = 9; const int normal_border = 2; const int emphasis_border = 4; const int hour_limit = ${job_hours_display_limit}; const auto now = id(homeassistant_time).now(); const int64_t now_ts = now.is_valid() ? now.timestamp : 0; auto fill_rounded = [&](int x, int y, int w, int h, int r, Color colour) { it.filled_rectangle(x + r, y, w - (2 * r), h, colour); it.filled_rectangle(x, y + r, w, h - (2 * r), colour); it.filled_circle(x + r, y + r, r, colour); it.filled_circle(x + w - r - 1, y + r, r, colour); it.filled_circle(x + r, y + h - r - 1, r, colour); it.filled_circle(x + w - r - 1, y + h - r - 1, r, colour); }; auto rounded_tile = [&](int x, int y, int w, int h, bool inverse, int border) { fill_rounded(x, y, w, h, radius, COLOR_ON); if (!inverse) { int inner_radius = radius - border; if (inner_radius < 2) inner_radius = 2; fill_rounded( x + border, y + border, w - (2 * border), h - (2 * border), inner_radius, COLOR_OFF ); } }; auto draw_panel_state_icon = [&](int centre_x, int centre_y) { if (id(panel_awake_indicator)) { // Two open eyes with pupils. it.circle(centre_x - 9, centre_y, 6, COLOR_ON); it.circle(centre_x + 9, centre_y, 6, COLOR_ON); it.filled_circle(centre_x - 9, centre_y, 2, COLOR_ON); it.filled_circle(centre_x + 9, centre_y, 2, COLOR_ON); } else { // Two closed eyelids, each drawn as a shallow downward curve. it.line(centre_x - 16, centre_y - 2, centre_x - 10, centre_y + 2, COLOR_ON); it.line(centre_x - 10, centre_y + 2, centre_x - 4, centre_y - 2, COLOR_ON); it.line(centre_x + 2, centre_y - 2, centre_x + 8, centre_y + 2, COLOR_ON); it.line(centre_x + 8, centre_y + 2, centre_x + 14, centre_y - 2, COLOR_ON); } }; auto make_date = [&]() -> std::string { if (!now.is_valid()) return "Waiting for Home Assistant time"; const char *weekdays[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }; const char *months[] = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }; const int day = now.day_of_month; const int last_two = day % 100; const char *suffix = "th"; if (last_two < 11 || last_two > 13) { if ((day % 10) == 1) suffix = "st"; else if ((day % 10) == 2) suffix = "nd"; else if ((day % 10) == 3) suffix = "rd"; } char buffer[72]; snprintf( buffer, sizeof(buffer), "%s, %d%s %s %d", weekdays[now.day_of_week - 1], day, suffix, months[now.month - 1], now.year ); return std::string(buffer); }; auto draw_job_icon = [&](int icon_type, int x, int y, Color foreground, Color background) { if (icon_type == 0) { it.image(x, y, id(icon_job_dog), foreground, background); } else if (icon_type == 1) { it.image(x, y, id(icon_job_poop), foreground, background); } else if (icon_type == 2) { it.image(x, y, id(icon_job_dishwasher), foreground, background); } else if (icon_type == 3) { it.image(x, y, id(icon_job_rubbish), foreground, background); } else if (icon_type == 4) { it.image(x, y, id(icon_job_watering), foreground, background); } else if (icon_type == 5) { it.image(x, y, id(icon_job_dryer), foreground, background); } else { it.image(x, y, id(icon_job_washmachine), foreground, background); } }; auto draw_state_icon = [&](const std::string &state, int x, int y, Color foreground, Color background) { if (state == "Complete") { it.image(x, y, id(icon_status_complete), foreground, background); } else if (state == "Overdue") { it.image(x, y, id(icon_status_overdue), foreground, background); } else if (state == "Missed" || state == "Skipped") { it.image(x, y, id(icon_status_missed), foreground, background); } else if (state == "Due" || state == "Not Due") { it.image(x, y, id(icon_status_due), foreground, background); } else { it.image(x, y, id(icon_status_unknown), foreground, background); } }; auto draw_job = [&](int x, int y, int icon_type, const char *title_line_1, const char *title_line_2, const std::string &state, float due_ts, const std::string &completed_time, const std::string &elapsed) { const int w = 252; const int h = 132; std::string status = "Unavailable"; std::string status_sub; bool inverse = false; int border = normal_border; if (state == "Complete") { if (!elapsed.empty() && elapsed != "unknown" && elapsed != "Never") { status = "COMPLETED"; status_sub = elapsed; } else if (!completed_time.empty() && completed_time != "Never" && completed_time != "unknown") { status = "COMPLETED"; status_sub = completed_time; } else { status = "COMPLETED"; } } else if (state == "Not Due") { if (now_ts > 0 && !isnan(due_ts) && due_ts > now_ts) { const int64_t remaining = static_cast(due_ts) - now_ts; const int hours = static_cast((remaining + 3599) / 3600); if (hours <= hour_limit) { status = "Due: " + std::to_string(hours) + (hours == 1 ? " Hour" : " Hours"); } else { const int days = static_cast((remaining + 86399) / 86400); status = "Due: " + std::to_string(days) + (days == 1 ? " Day" : " Days"); } } else { status = "Not Due"; } } else if (state == "Due") { status = "DUE NOW"; inverse = true; } else if (state == "Overdue") { status = "OVERDUE"; inverse = true; } else if (state == "Missed") { status = "MISSED"; inverse = true; } else if (state == "Skipped") { status = "SKIPPED"; inverse = true; } else if (!state.empty()) { status = state; } rounded_tile(x, y, w, h, inverse, border); const Color foreground = inverse ? COLOR_OFF : COLOR_ON; const Color background = inverse ? COLOR_ON : COLOR_OFF; // Big job icon in the left quarter of the tile. draw_job_icon(icon_type, x + 6, y + 41, foreground, background); // Two-line job title and status in the right 3/4. const int text_x = x + 71; it.printf( text_x, y + 30, id(font_job_title), foreground, TextAlign::CENTER_LEFT, "%s", title_line_1 ); it.printf( text_x, y + 56, id(font_job_title), foreground, TextAlign::CENTER_LEFT, "%s", title_line_2 ); it.printf( text_x, y + 88, id(font_job_status), foreground, TextAlign::CENTER_LEFT, "%s", status.c_str() ); if (!status_sub.empty()) { it.printf( x + w - 6, y + 112, id(font_job_status_sub), foreground, TextAlign::RIGHT, "%s", status_sub.c_str() ); } }; // Header. it.print(14, 28, id(font_header), TextAlign::CENTER_LEFT, "View Road Jobs"); draw_panel_state_icon(448, 28); const std::string date_text = make_date(); it.printf( screen_width - 14, 29, id(font_date), TextAlign::CENTER_RIGHT, "%s", date_text.c_str() ); it.line(8, header_y, screen_width - 9, header_y); // Three-row, three-column nine-job grid. // Row 1: three dog jobs. draw_job( 8, 66, 0, "Dog Feeding", "Morning", id(job_dog_feeding_morning_state).state, id(job_dog_feeding_morning_due_ts).state, id(job_dog_feeding_morning_completed_time).state, id(job_dog_feeding_morning_elapsed).state ); draw_job( 272, 66, 0, "Dog Feeding", "Evening", id(job_dog_feeding_evening_state).state, id(job_dog_feeding_evening_due_ts).state, id(job_dog_feeding_evening_completed_time).state, id(job_dog_feeding_evening_elapsed).state ); draw_job( 536, 66, 1, "Dog", "De-pooping", id(job_dog_depooping_state).state, id(job_dog_depooping_due_ts).state, id(job_dog_depooping_completed_time).state, id(job_dog_depooping_elapsed).state ); // Row 2: dishwasher, rubbish, dryer. draw_job( 8, 204, 2, "Unpack the", "dishwasher", id(job_dishwasher_unpacked_state).state, id(job_dishwasher_unpacked_due_ts).state, id(job_dishwasher_unpacked_completed_time).state, id(job_dishwasher_unpacked_elapsed).state ); draw_job( 272, 204, 3, "Take out", "rubbish", id(job_rubbish_removed_state).state, id(job_rubbish_removed_due_ts).state, id(job_rubbish_removed_completed_time).state, id(job_rubbish_removed_elapsed).state ); draw_job( 536, 204, 5, "Empty the", "Dryer", id(job_empty_dryer_state).state, id(job_empty_dryer_due_ts).state, id(job_empty_dryer_completed_time).state, id(job_empty_dryer_elapsed).state ); // Row 3: water table plant, water TV pot plant, washing machine. draw_job( 8, 342, 4, "Water Table", "Pot Plant", id(job_water_table_pot_plant_state).state, id(job_water_table_pot_plant_due_ts).state, id(job_water_table_pot_plant_completed_time).state, id(job_water_table_pot_plant_elapsed).state ); draw_job( 272, 342, 4, "Water TV", "Pot Plant", id(job_empty_dryer_state).state, id(job_empty_dryer_due_ts).state, id(job_empty_dryer_completed_time).state, id(job_empty_dryer_elapsed).state ); draw_job( 536, 342, 6, "Empty", "Washer", id(job_empty_washing_machine_state).state, id(job_empty_washing_machine_due_ts).state, id(job_empty_washing_machine_completed_time).state, id(job_empty_washing_machine_elapsed).state ); #:########################################################################################:# # DISPLAY CHANGE DETECTION: # Builds a signature from the visible Jobs content. The display is not physically refreshed # when the visible wording, style and eye indicator would remain unchanged. # https://esphome.io/components/script.html #:########################################################################################:# script: - id: refresh_display_if_changed mode: restart parameters: force: bool then: # Debounce bursts of Home Assistant state restoration. - delay: 750ms - lambda: |- const auto now = id(homeassistant_time).now(); const int64_t now_ts = now.is_valid() ? now.timestamp : 0; const int hour_limit = ${job_hours_display_limit}; const char *signature_prefix = "jobs-v0.9.0"; std::string signature = signature_prefix; auto append_text = [&](const std::string &value) { signature += "|"; signature += value; }; auto append_number = [&](int64_t value) { char buffer[32]; snprintf(buffer, sizeof(buffer), "|%lld", static_cast(value)); signature += buffer; }; // The visible header changes once per day, not every minute. append_text(id(panel_awake_indicator) ? "eyes_open" : "eyes_closed"); if (now.is_valid()) { append_number(now.year); append_number(now.day_of_year); } else { append_text("no_time"); } auto append_job = [&](const std::string &state, float due_ts, const std::string &completed_time, const std::string &elapsed) { append_text(state); if (state == "Complete") { if (!elapsed.empty() && elapsed != "unknown" && elapsed != "Never") { append_text(elapsed); } else { append_text(completed_time); } } else if (state == "Not Due" && now_ts > 0 && !isnan(due_ts) && due_ts > now_ts) { const int64_t remaining = static_cast(due_ts) - now_ts; const int hours = static_cast((remaining + 3599) / 3600); if (hours <= hour_limit) { append_text("hours"); append_number(hours); } else { const int days = static_cast((remaining + 86399) / 86400); append_text("days"); append_number(days); } } }; append_job( id(job_dog_feeding_morning_state).state, id(job_dog_feeding_morning_due_ts).state, id(job_dog_feeding_morning_completed_time).state, id(job_dog_feeding_morning_elapsed).state ); append_job( id(job_dog_feeding_evening_state).state, id(job_dog_feeding_evening_due_ts).state, id(job_dog_feeding_evening_completed_time).state, id(job_dog_feeding_evening_elapsed).state ); append_job( id(job_dog_depooping_state).state, id(job_dog_depooping_due_ts).state, id(job_dog_depooping_completed_time).state, id(job_dog_depooping_elapsed).state ); append_job( id(job_dishwasher_unpacked_state).state, id(job_dishwasher_unpacked_due_ts).state, id(job_dishwasher_unpacked_completed_time).state, id(job_dishwasher_unpacked_elapsed).state ); append_job( id(job_rubbish_removed_state).state, id(job_rubbish_removed_due_ts).state, id(job_rubbish_removed_completed_time).state, id(job_rubbish_removed_elapsed).state ); append_job( id(job_water_tv_pot_plant_state).state, id(job_water_tv_pot_plant_due_ts).state, id(job_water_tv_pot_plant_completed_time).state, id(job_water_tv_pot_plant_elapsed).state ); append_job( id(job_water_table_pot_plant_state).state, id(job_water_table_pot_plant_due_ts).state, id(job_water_table_pot_plant_completed_time).state, id(job_water_table_pot_plant_elapsed).state ); append_job( id(job_empty_dryer_state).state, id(job_empty_dryer_due_ts).state, id(job_empty_dryer_completed_time).state, id(job_empty_dryer_elapsed).state ); append_job( id(job_empty_washing_machine_state).state, id(job_empty_washing_machine_due_ts).state, id(job_empty_washing_machine_completed_time).state, id(job_empty_washing_machine_elapsed).state ); // Persistent 32-bit FNV-1a hash for the Jobs page. uint32_t signature_hash = 2166136261UL; for (const unsigned char character : signature) { signature_hash ^= static_cast(character); signature_hash *= 16777619UL; } id(display_update_required) = force || id(last_jobs_signature_hash) == 0 || signature_hash != id(last_jobs_signature_hash); if (id(display_update_required)) { id(last_jobs_signature_hash) = signature_hash; ESP_LOGI( "display", "Visible Jobs content changed; refreshing display (hash 0x%08lX)", static_cast(signature_hash) ); } else { ESP_LOGI( "display", "Jobs content unchanged (hash 0x%08lX); e-paper refresh skipped", static_cast(signature_hash) ); } - if: condition: lambda: |- return id(display_update_required); then: - light.turn_on: onboard_led - component.update: epaper_display - delay: 300ms - light.turn_off: onboard_led - globals.set: id: display_update_required value: "false" #:########################################################################################:# # PERIODIC DISPLAY AND SLEEP CHECKS: # The display check matters while MQTT OFF keeps the device awake. The sleep controller # uses inactivity rather than a fixed run_duration, so every button press resets the timer. # https://esphome.io/components/interval.html #:########################################################################################:# interval: - interval: "${display_check_interval}" then: - if: condition: lambda: |- return id(boot_sequence_complete); then: - script.execute: id: refresh_display_if_changed force: false - interval: 2s then: - if: condition: lambda: |- if (!id(boot_sequence_complete)) return false; if (!id(sleep_allowed)) return false; if (id(button_left).state || id(button_right).state) return false; const uint32_t elapsed = millis() - id(last_activity_ms); return elapsed >= id(awake_timeout_ms); then: - logger.log: "Inactivity period complete; preparing to enter five-minute deep sleep." # Avoid immediately waking again if the user is only just releasing a button. - delay: 350ms - if: condition: lambda: |- return id(sleep_allowed) && !id(button_left).state && !id(button_right).state; then: # Write the closed-eye icon before power-down. If the display already # shows closed eyes and nothing else changed, the hash check skips the # physical e-paper refresh. - lambda: |- id(panel_awake_indicator) = false; - script.execute: id: refresh_display_if_changed force: false - script.wait: refresh_display_if_changed - mqtt.publish: topic: "${mqtt_status_topic}/state" payload: "sleeping" qos: 1 retain: true - delay: 350ms - deep_sleep.enter: deep_sleep_1 #:########################################################################################:# # MQTT COMMANDS: # Retained ON allows sleep; retained OFF prevents sleep for OTA/development. # This extends common/mqtt_common.yaml with device-specific subscriptions. #:########################################################################################:# mqtt: discovery: false on_message: - topic: "${mqtt_command_topic}/deepsleep" payload: "On" then: - lambda: |- id(sleep_allowed) = true; id(last_activity_ms) = millis(); id(awake_timeout_ms) = ${mqtt_enable_awake_seconds} * 1000UL; - deep_sleep.allow: deep_sleep_1 - logger.log: "Deep sleep ALLOWED by retained MQTT command." - mqtt.publish: topic: "${mqtt_status_topic}/deepsleep" payload: "On" qos: 1 retain: true - topic: "${mqtt_command_topic}/deepsleep" payload: "Off" then: - lambda: |- id(sleep_allowed) = false; id(panel_awake_indicator) = true; - deep_sleep.prevent: deep_sleep_1 - logger.log: "Deep sleep PREVENTED by retained MQTT command." - mqtt.publish: topic: "${mqtt_status_topic}/deepsleep" payload: "Off" qos: 1 retain: true - mqtt.publish: topic: "${mqtt_status_topic}/state" payload: "awake" qos: 1 retain: true - if: condition: lambda: |- return id(boot_sequence_complete); then: - script.execute: id: refresh_display_if_changed force: false #:########################################################################################:# # DEEP SLEEP: # Five-minute timer wake plus either active-low white button through ESP32-S3 EXT1. # GPIO4 = right; GPIO5 = left. # https://esphome.io/components/deep_sleep.html #:########################################################################################:# deep_sleep: id: deep_sleep_1 sleep_duration: "${sleep_interval}" esp32_ext1_wakeup: pins: - number: GPIO4 allow_other_uses: true - number: GPIO5 allow_other_uses: true mode: ANY_LOW #:########################################################################################:# # HOME ASSISTANT JOB STATE IMPORTS: # Jobs Tracker states and display attributes # https://esphome.io/components/text_sensor/homeassistant.html # https://esphome.io/components/sensor/homeassistant.html #:########################################################################################:# text_sensor: # Device diagnostics retained from the initial build. - platform: wifi_info ip_address: id: wifi_ip_address name: "${friendly_name} IP Address" entity_category: diagnostic #:######################################################################################:# # Job states and completion times # #:######################################################################################:# - platform: homeassistant id: job_dog_feeding_morning_state entity_id: sensor.jobs_tracker_dog_feeding_morning internal: true - platform: homeassistant id: job_dog_feeding_morning_completed_time entity_id: sensor.jobs_tracker_dog_feeding_morning attribute: last_completed_time internal: true - platform: homeassistant id: job_dog_feeding_morning_elapsed entity_id: sensor.jobs_tracker_dog_feeding_morning attribute: completion_elapsed_human internal: true - platform: homeassistant id: job_dog_feeding_evening_state entity_id: sensor.jobs_tracker_dog_feeding_evening internal: true - platform: homeassistant id: job_dog_feeding_evening_completed_time entity_id: sensor.jobs_tracker_dog_feeding_evening attribute: last_completed_time internal: true - platform: homeassistant id: job_dog_feeding_evening_elapsed entity_id: sensor.jobs_tracker_dog_feeding_evening attribute: completion_elapsed_human internal: true - platform: homeassistant id: job_dog_depooping_state entity_id: sensor.jobs_tracker_dog_depooping internal: true - platform: homeassistant id: job_dog_depooping_completed_time entity_id: sensor.jobs_tracker_dog_depooping attribute: last_completed_time internal: true - platform: homeassistant id: job_dog_depooping_elapsed entity_id: sensor.jobs_tracker_dog_depooping attribute: completion_elapsed_human internal: true - platform: homeassistant id: job_dishwasher_unpacked_state entity_id: sensor.jobs_tracker_dishwasher_unpacked internal: true - platform: homeassistant id: job_dishwasher_unpacked_completed_time entity_id: sensor.jobs_tracker_dishwasher_unpacked attribute: last_completed_time internal: true - platform: homeassistant id: job_dishwasher_unpacked_elapsed entity_id: sensor.jobs_tracker_dishwasher_unpacked attribute: completion_elapsed_human internal: true - platform: homeassistant id: job_rubbish_removed_state entity_id: sensor.jobs_tracker_rubbish_removed internal: true - platform: homeassistant id: job_rubbish_removed_completed_time entity_id: sensor.jobs_tracker_rubbish_removed attribute: last_completed_time internal: true - platform: homeassistant id: job_rubbish_removed_elapsed entity_id: sensor.jobs_tracker_rubbish_removed attribute: completion_elapsed_human internal: true #:######################################################################################:# # Plant watering job states and completion times # #:######################################################################################:# - platform: homeassistant id: job_water_tv_pot_plant_state entity_id: sensor.jobs_tracker_water_tv_pot_plant internal: true - platform: homeassistant id: job_water_tv_pot_plant_completed_time entity_id: sensor.jobs_tracker_water_tv_pot_plant attribute: last_completed_time internal: true - platform: homeassistant id: job_water_tv_pot_plant_elapsed entity_id: sensor.jobs_tracker_water_tv_pot_plant attribute: completion_elapsed_human internal: true - platform: homeassistant id: job_empty_dryer_state entity_id: sensor.jobs_tracker_empty_dryer internal: true - platform: homeassistant id: job_empty_dryer_completed_time entity_id: sensor.jobs_tracker_empty_dryer attribute: last_completed_time internal: true - platform: homeassistant id: job_empty_dryer_elapsed entity_id: sensor.jobs_tracker_empty_dryer attribute: completion_elapsed_human internal: true - platform: homeassistant id: job_empty_washing_machine_state entity_id: sensor.jobs_tracker_empty_washing_machine internal: true - platform: homeassistant id: job_empty_washing_machine_completed_time entity_id: sensor.jobs_tracker_empty_washing_machine attribute: last_completed_time internal: true - platform: homeassistant id: job_empty_washing_machine_elapsed entity_id: sensor.jobs_tracker_empty_washing_machine attribute: completion_elapsed_human internal: true - platform: homeassistant id: job_water_table_pot_plant_state entity_id: sensor.jobs_tracker_water_table_pot_plant internal: true - platform: homeassistant id: job_water_table_pot_plant_completed_time entity_id: sensor.jobs_tracker_water_table_pot_plant attribute: last_completed_time internal: true - platform: homeassistant id: job_water_table_pot_plant_elapsed entity_id: sensor.jobs_tracker_water_table_pot_plant attribute: completion_elapsed_human internal: true #:########################################################################################:# # HOME ASSISTANT NUMERIC ATTRIBUTE IMPORTS: # Job due timestamps #:########################################################################################:# sensor: #:######################################################################################:# # Job current due timestamps # #:######################################################################################:# - platform: homeassistant id: job_dog_feeding_morning_due_ts entity_id: sensor.jobs_tracker_dog_feeding_morning attribute: current_due_ts internal: true - platform: homeassistant id: job_dog_feeding_evening_due_ts entity_id: sensor.jobs_tracker_dog_feeding_evening attribute: current_due_ts internal: true - platform: homeassistant id: job_dog_depooping_due_ts entity_id: sensor.jobs_tracker_dog_depooping attribute: current_due_ts internal: true - platform: homeassistant id: job_dishwasher_unpacked_due_ts entity_id: sensor.jobs_tracker_dishwasher_unpacked attribute: current_due_ts internal: true - platform: homeassistant id: job_rubbish_removed_due_ts entity_id: sensor.jobs_tracker_rubbish_removed attribute: current_due_ts internal: true #:######################################################################################:# # Plant watering job due timestamps # #:######################################################################################:# - platform: homeassistant id: job_water_tv_pot_plant_due_ts entity_id: sensor.jobs_tracker_water_tv_pot_plant attribute: current_due_ts internal: true - platform: homeassistant id: job_water_table_pot_plant_due_ts entity_id: sensor.jobs_tracker_water_table_pot_plant attribute: current_due_ts internal: true - platform: homeassistant id: job_empty_dryer_due_ts entity_id: sensor.jobs_tracker_empty_dryer attribute: current_due_ts internal: true - platform: homeassistant id: job_empty_washing_machine_due_ts entity_id: sensor.jobs_tracker_empty_washing_machine attribute: current_due_ts internal: true #:########################################################################################:# # OUTPUTS: # Onboard green LED # https://esphome.io/components/output/gpio.html #:########################################################################################:# output: - platform: gpio id: onboard_led_output pin: GPIO6 inverted: true #:########################################################################################:# # LIGHTS: # https://esphome.io/components/light/binary.html #:########################################################################################:# light: - platform: binary id: onboard_led name: "${friendly_name} Onboard LED" output: onboard_led_output restore_mode: ALWAYS_OFF entity_category: diagnostic #:########################################################################################:# # PHYSICAL BUTTONS: # Buttons reset the post-interaction timeout. During deep sleep the white buttons are also # EXT1 wake sources. Both white buttons show and check the Jobs page. # https://esphome.io/components/binary_sensor/gpio.html #:########################################################################################:# binary_sensor: - platform: gpio id: button_refresh name: "${friendly_name} Green Refresh Button" pin: number: GPIO3 mode: input: true pullup: true inverted: true filters: - delayed_on: 20ms - delayed_off: 20ms on_press: then: - lambda: |- id(panel_awake_indicator) = true; id(last_activity_ms) = millis(); id(awake_timeout_ms) = ${button_wake_awake_seconds} * 1000UL; - script.execute: id: refresh_display_if_changed force: true - platform: gpio id: button_right name: "${friendly_name} Right Button - Jobs" pin: number: GPIO4 allow_other_uses: true mode: input: true pullup: true inverted: true filters: - delayed_on: 20ms - delayed_off: 20ms on_press: then: - lambda: |- id(panel_awake_indicator) = true; id(last_activity_ms) = millis(); id(awake_timeout_ms) = ${button_wake_awake_seconds} * 1000UL; - display.page.show: page_jobs - script.execute: id: refresh_display_if_changed force: false - platform: gpio id: button_left name: "${friendly_name} Left Button - Jobs" pin: number: GPIO5 allow_other_uses: true mode: input: true pullup: true inverted: true filters: - delayed_on: 20ms - delayed_off: 20ms on_press: then: - lambda: |- id(panel_awake_indicator) = true; id(last_activity_ms) = millis(); id(awake_timeout_ms) = ${button_wake_awake_seconds} * 1000UL; - display.page.show: page_jobs - script.execute: id: refresh_display_if_changed force: false #:########################################################################################:# # HOME ASSISTANT CONTROLS: # Manual refresh and Jobs display button # https://esphome.io/components/button/template.html #:########################################################################################:# button: - platform: template id: refresh_display_button name: "${friendly_name} Refresh Display" icon: "mdi:refresh" on_press: then: - lambda: |- id(panel_awake_indicator) = true; id(last_activity_ms) = millis(); id(awake_timeout_ms) = ${button_wake_awake_seconds} * 1000UL; - script.execute: id: refresh_display_if_changed force: true - platform: template id: show_jobs_page_button name: "${friendly_name} Show Jobs Page" icon: "mdi:clipboard-check-outline" on_press: then: - lambda: |- id(panel_awake_indicator) = true; id(last_activity_ms) = millis(); id(awake_timeout_ms) = ${button_wake_awake_seconds} * 1000UL; - display.page.show: page_jobs - script.execute: id: refresh_display_if_changed force: false