From 90a6469161671a2c680c15cfa325af1ed6b4e9d9 Mon Sep 17 00:00:00 2001 From: ESPHome Device Builder Date: Mon, 20 Jul 2026 15:58:44 +1200 Subject: [PATCH] Edit esp-reterminal-e1001.yaml --- esphome/esp-reterminal-e1001.yaml | 1259 ++++++++++++++++++++++++++--- 1 file changed, 1169 insertions(+), 90 deletions(-) diff --git a/esphome/esp-reterminal-e1001.yaml b/esphome/esp-reterminal-e1001.yaml index 9aff904..6b2fdcd 100644 --- a/esphome/esp-reterminal-e1001.yaml +++ b/esphome/esp-reterminal-e1001.yaml @@ -6,6 +6,9 @@ # https://home.fox.co.nz/gitea/zorruno/zorruno-homeassistant/src/branch/master/esphome/esp-reterminal-e1001.yaml #:########################################################################################:# # VERSIONS: +# 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, @@ -29,14 +32,20 @@ # - Display reset: GPIO12 # - Display busy: GPIO13, inverted #:########################################################################################:# -# OPERATION NOTES: -# - This first build uses DHCP through common/network_common_dhcp.yaml. -# - Home Assistant connects through the encrypted ESPHome native API. -# - MQTT is not required for importing Home Assistant entity states. -# - The display currently shows a fixed 4x3 test matrix. -# - The green physical button and the Home Assistant button both force a refresh. -# - The left and right white buttons are exposed to Home Assistant for later page control. -# - Deep sleep is intentionally disabled during initial development and OTA testing. +# OPERATION: +# - The left white button selects the View Road Jobs page. +# - The right white button selects the View Road Appliances page. +# - The display is checked every 30 seconds, but the e-paper panel is refreshed only when +# the visible page content has actually changed. +# - Page changes and manual refreshes intentionally force one physical display refresh. +# - Job countdown wording changes only when its displayed hour/day value changes. +# - OVERDUE and MISSED job tiles use a black background with white text. +# - Appliance tiles show OPERATING while active. +# - A completed appliance shows inverse FINISHED for one hour after cycle completion. +# - After one hour, the appliance tile shows Completed at HH:MM with a normal background. +# - Home Assistant entity data is imported through the encrypted ESPHome native API. +# - MQTT is not required on this ESPHome device. +# - Deep sleep remains disabled during dashboard development and OTA testing. #:########################################################################################:# #:########################################################################################:# @@ -48,12 +57,12 @@ substitutions: # Device Naming device_name: "esp-reterminal-e1001" friendly_name: "reTerminal E1001 Dashboard" - description_comment: "Seeed Studio reTerminal E1001 7.5-inch e-paper Home Assistant status dashboard. Initial ESPHome display and connectivity test. (Layout V1.1)" + description_comment: "Seeed Studio reTerminal E1001 two-page Home Assistant jobs and appliance status dashboard. (Layout V1.1)" device_area: "Lounge" # Project Naming project_name: "SeeedStudio.reTerminal-E1001" - project_version: "v0.2" + project_version: "v0.3" # Passwords and Secrets api_key: !secret esp-api_key @@ -61,8 +70,9 @@ substitutions: # Device Settings log_level: "INFO" - update_interval: "60s" - display_update_interval: "5min" + display_check_interval: "30s" + finished_highlight_seconds: "3600" + job_hours_display_limit: "48" #:########################################################################################:# # PACKAGES: @@ -85,7 +95,7 @@ packages: local_api_key: "${api_key}" #### MQTT #### - # MQTT is not needed for the E1001 to import Home Assistant entity states. + # Home Assistant entity states are imported through the ESPHome native API. #common_mqtt: !include # file: common/mqtt_common.yaml # vars: @@ -95,11 +105,10 @@ packages: #common_webportal: !include common/webportal_common_nopass.yaml #### SNTP #### - # Home Assistant time will be added when the dashboard requires a clock. + # Home Assistant provides local time through the native API below. #common_sntp: !include common/sntp_common.yaml #### DIAGNOSTICS SENSORS #### - # Enable these after the first hardware build is confirmed stable. #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 @@ -118,6 +127,19 @@ esphome: name: "${project_name}" version: "${project_version}" + on_boot: + priority: -100 + then: + # Allow Wi-Fi, API entities and Home Assistant time to initialise. + - delay: 15s + - display.page.show: page_jobs + - globals.set: + id: current_page + value: "0" + - script.execute: + id: refresh_display_if_changed + force: true + #:########################################################################################:# # ESP PLATFORM AND FRAMEWORK: # https://esphome.io/components/esp32.html @@ -158,8 +180,44 @@ spi: clk_pin: GPIO7 mosi_pin: GPIO9 +#:########################################################################################:# +# HOME ASSISTANT TIME: +# Used for the date header, job countdowns and appliance completion age. +# 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: +# Display page and change-detection state +# https://esphome.io/components/globals.html +#:########################################################################################:# +globals: + - id: current_page + type: int + restore_value: no + initial_value: "0" + + - id: last_display_signature + type: std::string + restore_value: no + initial_value: '""' + + - id: display_update_required + type: bool + restore_value: no + initial_value: "false" + #:########################################################################################:# # FONTS: +# Bold, compact fonts for clear monochrome e-paper rendering. # https://esphome.io/components/font.html #:########################################################################################:# font: @@ -167,26 +225,49 @@ font: type: gfonts family: Inter weight: 700 - id: font_title - size: 34 + id: font_header + size: 31 - file: type: gfonts family: Inter - weight: 600 - id: font_tile + weight: 700 + id: font_date + size: 18 + + - file: + type: gfonts + family: Inter + weight: 700 + id: font_job_title + size: 22 + + - file: + type: gfonts + family: Inter + weight: 700 + id: font_job_status size: 20 - file: type: gfonts family: Inter - weight: 400 - id: font_small + weight: 700 + id: font_appliance_title size: 16 + - file: + type: gfonts + family: Inter + weight: 700 + id: font_appliance_status + size: 17 + #:########################################################################################:# # DISPLAY: -# Initial fixed 4x3 layout test +# Two pages 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: @@ -205,82 +286,1013 @@ display: number: GPIO13 inverted: true - update_interval: "${display_update_interval}" + update_interval: never - lambda: |- - // Screen and header geometry. - const int screen_width = 800; - const int screen_height = 480; - const int header_height = 62; + 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}; - // Tile matrix geometry: 4 columns x 3 rows. - const int columns = 4; - const int rows = 3; - const int margin_x = 10; - const int margin_bottom = 10; - const int gap_x = 8; - const int gap_y = 8; - const int grid_top = 72; - const int tile_width = 189; - const int tile_height = 126; + const auto now = id(homeassistant_time).now(); + const int64_t now_ts = now.is_valid() ? now.timestamp : 0; - const char *tile_line_1[12] = { - "Dog Feeding", "Dog Feeding", "Dog", "Dishwasher", - "Rubbish", "Washing", "Dryer", "Main Dishwasher", - "Downstairs Dish", "EV Charger", "EV Charger", "3D Printer" - }; + 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); + }; - const char *tile_line_2[12] = { - "Morning", "Evening", "De-pooping", "Unpacked", - "Removed", "Status", "Status", "Status", - "Status", "32A", "16A", "Status" - }; + 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 + ); + } + }; - // Outer screen border and header. - it.rectangle(0, 0, screen_width - 1, screen_height - 1); - it.line(0, header_height, screen_width - 1, header_height); - it.print(16, 31, id(font_title), TextAlign::CENTER_LEFT, - "Home Status"); + auto make_date = [&]() -> std::string { + if (!now.is_valid()) return "Waiting for Home Assistant time"; - if (id(wifi_ip_address).has_state()) { - it.printf(784, 31, id(font_small), TextAlign::CENTER_RIGHT, - "ESPHome | %s", id(wifi_ip_address).state.c_str()); - } else { - it.print(784, 31, id(font_small), TextAlign::CENTER_RIGHT, - "ESPHome | Connecting..."); - } + 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" + }; - // Draw the twelve initial placeholder tiles. - for (int tile = 0; tile < 12; tile++) { - const int column = tile % columns; - const int row = tile / columns; - const int x = margin_x + column * (tile_width + gap_x); - const int y = grid_top + row * (tile_height + gap_y); + 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"; + } - it.rectangle(x, y, tile_width, tile_height); - it.printf(x + (tile_width / 2), y + 46, - id(font_tile), TextAlign::CENTER, - "%s", tile_line_1[tile]); - it.printf(x + (tile_width / 2), y + 82, - id(font_small), TextAlign::CENTER, - "%s", tile_line_2[tile]); - } + 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 = [&](int x, int y, const char *name, + const std::string &state, float due_ts, + const std::string &completed_time) { + const int w = 256; + const int h = 197; + + std::string status = "Unavailable"; + bool inverse = false; + int border = normal_border; + + if (state == "Complete") { + if (!completed_time.empty() && completed_time != "Never" && completed_time != "unknown") { + status = "Completed " + completed_time; + } else { + status = "Complete"; + } + } 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 in " + std::to_string(hours) + (hours == 1 ? " hour" : " hours"); + } else { + const int days = static_cast((remaining + 86399) / 86400); + status = "Due in " + std::to_string(days) + (days == 1 ? " day" : " days"); + } + } else { + status = "Not Due"; + } + } else if (state == "Due") { + status = "DUE"; + border = emphasis_border; + } else if (state == "Overdue") { + status = "OVERDUE"; + inverse = true; + } else if (state == "Missed") { + status = "MISSED"; + inverse = true; + } else if (!state.empty()) { + status = state; + } + + rounded_tile(x, y, w, h, inverse, border); + const Color text_colour = inverse ? COLOR_OFF : COLOR_ON; + + it.printf( + x + (w / 2), + y + 67, + id(font_job_title), + text_colour, + TextAlign::CENTER, + "%s", + name + ); + + it.printf( + x + (w / 2), + y + 132, + id(font_job_status), + text_colour, + TextAlign::CENTER, + "%s", + status.c_str() + ); + }; + + // Header. + it.print(14, 28, id(font_header), TextAlign::CENTER_LEFT, "View Road Jobs"); + 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 tiles on the top row and two centred on the lower row. + draw_job( + 8, 70, + "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 + ); + + draw_job( + 272, 70, + "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 + ); + + draw_job( + 536, 70, + "Dog De-pooping", + id(job_dog_depooping_state).state, + id(job_dog_depooping_due_ts).state, + id(job_dog_depooping_completed_time).state + ); + + draw_job( + 140, 275, + "Dishwasher Unpacked", + id(job_dishwasher_unpacked_state).state, + id(job_dishwasher_unpacked_due_ts).state, + id(job_dishwasher_unpacked_completed_time).state + ); + + draw_job( + 404, 275, + "Rubbish Removed", + id(job_rubbish_removed_state).state, + id(job_rubbish_removed_due_ts).state, + id(job_rubbish_removed_completed_time).state + ); + + #:####################################################################################:# + # PAGE 2 - VIEW ROAD APPLIANCES # + #:####################################################################################:# + - id: page_appliances + lambda: |- + const int screen_width = 800; + const int header_y = 58; + const int radius = 8; + const int normal_border = 2; + const int emphasis_border = 4; + const int finished_seconds = ${finished_highlight_seconds}; + + 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 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 completed_clock = [&](const std::string &stop_local) -> std::string { + if (stop_local.length() >= 16) return stop_local.substr(11, 5); + return "unknown"; + }; + + auto draw_appliance = [&](int x, int y, + const char *title_line_1, + const char *title_line_2, + const std::string &state, + float stop_ts, + float duration_seconds, + const std::string &stop_local) { + const int w = 190; + const int h = 128; + + const bool valid_cycle = + !isnan(stop_ts) && stop_ts > 0 && + !isnan(duration_seconds) && duration_seconds > 0; + + bool recent_finish = false; + if (valid_cycle && now_ts > 0) { + const int64_t age = now_ts - static_cast(stop_ts); + recent_finish = age >= 0 && age < finished_seconds; + } + + std::string status = "Unavailable"; + bool inverse = false; + int border = normal_border; + + if (state == "Operating") { + status = "OPERATING"; + border = emphasis_border; + } else if (valid_cycle && recent_finish) { + status = "FINISHED"; + inverse = true; + } else if (valid_cycle) { + status = "Completed at " + completed_clock(stop_local); + } else if (state == "Standby") { + status = "STANDBY"; + } else if (state == "Off") { + status = "OFF"; + } else if (state == "Finished") { + status = "FINISHED"; + inverse = true; + } else if (!state.empty()) { + status = state; + } + + rounded_tile(x, y, w, h, inverse, border); + const Color text_colour = inverse ? COLOR_OFF : COLOR_ON; + + if (title_line_2[0] == '\0') { + it.printf( + x + (w / 2), y + 39, + id(font_appliance_title), text_colour, TextAlign::CENTER, + "%s", title_line_1 + ); + } else { + it.printf( + x + (w / 2), y + 25, + id(font_appliance_title), text_colour, TextAlign::CENTER, + "%s", title_line_1 + ); + it.printf( + x + (w / 2), y + 49, + id(font_appliance_title), text_colour, TextAlign::CENTER, + "%s", title_line_2 + ); + } + + it.printf( + x + (w / 2), y + 91, + id(font_appliance_status), text_colour, TextAlign::CENTER, + "%s", status.c_str() + ); + }; + + // Header. + it.print(14, 28, id(font_header), TextAlign::CENTER_LEFT, "View Road Appliances"); + 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); + + // Four tiles on rows one and two; two centred tiles on row three. + draw_appliance( + 8, 70, "Washing", "Machine", + id(appliance_washing_state).state, + id(appliance_washing_stop_ts).state, + id(appliance_washing_duration).state, + id(appliance_washing_stop_local).state + ); + + draw_appliance( + 206, 70, "Dryer", "", + id(appliance_dryer_state).state, + id(appliance_dryer_stop_ts).state, + id(appliance_dryer_duration).state, + id(appliance_dryer_stop_local).state + ); + + draw_appliance( + 404, 70, "Main", "Dishwasher", + id(appliance_main_dishwasher_state).state, + id(appliance_main_dishwasher_stop_ts).state, + id(appliance_main_dishwasher_duration).state, + id(appliance_main_dishwasher_stop_local).state + ); + + draw_appliance( + 602, 70, "Downstairs", "Dishwasher", + id(appliance_downstairs_dishwasher_state).state, + id(appliance_downstairs_dishwasher_stop_ts).state, + id(appliance_downstairs_dishwasher_duration).state, + id(appliance_downstairs_dishwasher_stop_local).state + ); + + draw_appliance( + 8, 206, "EV Charger", "32A", + id(appliance_evcharger_32a_state).state, + id(appliance_evcharger_32a_stop_ts).state, + id(appliance_evcharger_32a_duration).state, + id(appliance_evcharger_32a_stop_local).state + ); + + draw_appliance( + 206, 206, "EV Charger", "16A", + id(appliance_evcharger_16a_state).state, + id(appliance_evcharger_16a_stop_ts).state, + id(appliance_evcharger_16a_duration).state, + id(appliance_evcharger_16a_stop_local).state + ); + + draw_appliance( + 404, 206, "Ryobi Charger", "Left", + id(appliance_ryobi_left_state).state, + id(appliance_ryobi_left_stop_ts).state, + id(appliance_ryobi_left_duration).state, + id(appliance_ryobi_left_stop_local).state + ); + + draw_appliance( + 602, 206, "Ryobi Charger", "Right", + id(appliance_ryobi_right_state).state, + id(appliance_ryobi_right_stop_ts).state, + id(appliance_ryobi_right_duration).state, + id(appliance_ryobi_right_stop_local).state + ); + + draw_appliance( + 206, 342, "Pool Pump", "", + id(appliance_pool_pump_state).state, + id(appliance_pool_pump_stop_ts).state, + id(appliance_pool_pump_duration).state, + id(appliance_pool_pump_stop_local).state + ); + + draw_appliance( + 404, 342, "3D Printer", "", + id(appliance_3d_printer_state).state, + id(appliance_3d_printer_stop_ts).state, + id(appliance_3d_printer_duration).state, + id(appliance_3d_printer_stop_local).state + ); #:########################################################################################:# -# WIFI INFORMATION: -# Triggers a display refresh after DHCP has supplied the IP address. -# https://esphome.io/components/text_sensor/wifi_info.html +# DISPLAY CHANGE DETECTION: +# Builds a signature from only the content that is currently visible. The display is not +# physically refreshed when the visible wording and style 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 and page changes. + - 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 int finished_seconds = ${finished_highlight_seconds}; + + std::string signature = id(current_page) == 0 ? "jobs" : "appliances"; + + 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. + if (now.is_valid()) { + append_number(now.year); + append_number(now.day_of_year); + } else { + append_text("no_time"); + } + + if (id(current_page) == 0) { + auto append_job = [&](const std::string &state, float due_ts, + const std::string &completed_time) { + append_text(state); + + if (state == "Complete") { + 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 + ); + 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 + ); + append_job( + id(job_dog_depooping_state).state, + id(job_dog_depooping_due_ts).state, + id(job_dog_depooping_completed_time).state + ); + append_job( + id(job_dishwasher_unpacked_state).state, + id(job_dishwasher_unpacked_due_ts).state, + id(job_dishwasher_unpacked_completed_time).state + ); + append_job( + id(job_rubbish_removed_state).state, + id(job_rubbish_removed_due_ts).state, + id(job_rubbish_removed_completed_time).state + ); + } else { + auto append_appliance = [&](const std::string &state, + float stop_ts, + float duration_seconds, + const std::string &stop_local) { + const bool valid_cycle = + !isnan(stop_ts) && stop_ts > 0 && + !isnan(duration_seconds) && duration_seconds > 0; + + if (state == "Operating") { + append_text("OPERATING"); + return; + } + + if (valid_cycle) { + bool recent_finish = false; + if (now_ts > 0) { + const int64_t age = now_ts - static_cast(stop_ts); + recent_finish = age >= 0 && age < finished_seconds; + } + + append_text(recent_finish ? "FINISHED" : "COMPLETED"); + append_text(stop_local); + return; + } + + append_text(state); + }; + + append_appliance( + id(appliance_washing_state).state, + id(appliance_washing_stop_ts).state, + id(appliance_washing_duration).state, + id(appliance_washing_stop_local).state + ); + append_appliance( + id(appliance_dryer_state).state, + id(appliance_dryer_stop_ts).state, + id(appliance_dryer_duration).state, + id(appliance_dryer_stop_local).state + ); + append_appliance( + id(appliance_main_dishwasher_state).state, + id(appliance_main_dishwasher_stop_ts).state, + id(appliance_main_dishwasher_duration).state, + id(appliance_main_dishwasher_stop_local).state + ); + append_appliance( + id(appliance_downstairs_dishwasher_state).state, + id(appliance_downstairs_dishwasher_stop_ts).state, + id(appliance_downstairs_dishwasher_duration).state, + id(appliance_downstairs_dishwasher_stop_local).state + ); + append_appliance( + id(appliance_evcharger_32a_state).state, + id(appliance_evcharger_32a_stop_ts).state, + id(appliance_evcharger_32a_duration).state, + id(appliance_evcharger_32a_stop_local).state + ); + append_appliance( + id(appliance_evcharger_16a_state).state, + id(appliance_evcharger_16a_stop_ts).state, + id(appliance_evcharger_16a_duration).state, + id(appliance_evcharger_16a_stop_local).state + ); + append_appliance( + id(appliance_ryobi_left_state).state, + id(appliance_ryobi_left_stop_ts).state, + id(appliance_ryobi_left_duration).state, + id(appliance_ryobi_left_stop_local).state + ); + append_appliance( + id(appliance_ryobi_right_state).state, + id(appliance_ryobi_right_stop_ts).state, + id(appliance_ryobi_right_duration).state, + id(appliance_ryobi_right_stop_local).state + ); + append_appliance( + id(appliance_pool_pump_state).state, + id(appliance_pool_pump_stop_ts).state, + id(appliance_pool_pump_duration).state, + id(appliance_pool_pump_stop_local).state + ); + append_appliance( + id(appliance_3d_printer_state).state, + id(appliance_3d_printer_stop_ts).state, + id(appliance_3d_printer_duration).state, + id(appliance_3d_printer_stop_local).state + ); + } + + id(display_update_required) = force || signature != id(last_display_signature); + + if (id(display_update_required)) { + id(last_display_signature) = signature; + ESP_LOGI("display", "Visible display content changed; refreshing page %d", id(current_page) + 1); + } else { + ESP_LOGD("display", "Visible display content unchanged; e-paper refresh skipped"); + } + + - 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 CHECK: +# This checks in-memory values only; it does not refresh the e-paper unless the signature +# differs from the last rendered content. +# https://esphome.io/components/interval.html +#:########################################################################################:# +interval: + - interval: "${display_check_interval}" + then: + - script.execute: + id: refresh_display_if_changed + force: false + +#:########################################################################################:# +# 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 - on_value: - then: - - delay: 2s - - component.update: epaper_display + + #:######################################################################################:# + # 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_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_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_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_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 + + #:######################################################################################:# + # Appliance states and completion-local timestamps # + #:######################################################################################:# + + - platform: homeassistant + id: appliance_washing_state + entity_id: sensor.appliance_monitor_washing_machine_monitor + internal: true + + - platform: homeassistant + id: appliance_washing_stop_local + entity_id: sensor.appliance_monitor_washing_machine_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_dryer_state + entity_id: sensor.appliance_monitor_dryer_monitor + internal: true + + - platform: homeassistant + id: appliance_dryer_stop_local + entity_id: sensor.appliance_monitor_dryer_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_main_dishwasher_state + entity_id: sensor.appliance_monitor_main_dishwasher_monitor + internal: true + + - platform: homeassistant + id: appliance_main_dishwasher_stop_local + entity_id: sensor.appliance_monitor_main_dishwasher_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_downstairs_dishwasher_state + entity_id: sensor.appliance_monitor_downstairs_dishwasher_monitor + internal: true + + - platform: homeassistant + id: appliance_downstairs_dishwasher_stop_local + entity_id: sensor.appliance_monitor_downstairs_dishwasher_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_evcharger_32a_state + entity_id: sensor.appliance_monitor_ev_charger_32a_monitor + internal: true + + - platform: homeassistant + id: appliance_evcharger_32a_stop_local + entity_id: sensor.appliance_monitor_ev_charger_32a_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_evcharger_16a_state + entity_id: sensor.appliance_monitor_ev_charger_16a_monitor + internal: true + + - platform: homeassistant + id: appliance_evcharger_16a_stop_local + entity_id: sensor.appliance_monitor_ev_charger_16a_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_ryobi_left_state + entity_id: sensor.appliance_monitor_ryobicharger_left_monitor + internal: true + + - platform: homeassistant + id: appliance_ryobi_left_stop_local + entity_id: sensor.appliance_monitor_ryobicharger_left_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_ryobi_right_state + entity_id: sensor.appliance_monitor_ryobicharger_right_monitor + internal: true + + - platform: homeassistant + id: appliance_ryobi_right_stop_local + entity_id: sensor.appliance_monitor_ryobicharger_right_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_pool_pump_state + entity_id: sensor.appliance_monitor_pool_pump_monitor + internal: true + + - platform: homeassistant + id: appliance_pool_pump_stop_local + entity_id: sensor.appliance_monitor_pool_pump_monitor + attribute: cycle_stop_local + internal: true + + - platform: homeassistant + id: appliance_3d_printer_state + entity_id: sensor.appliance_monitor_3d_printer_monitor + internal: true + + - platform: homeassistant + id: appliance_3d_printer_stop_local + entity_id: sensor.appliance_monitor_3d_printer_monitor + attribute: cycle_stop_local + internal: true + +#:########################################################################################:# +# HOME ASSISTANT NUMERIC ATTRIBUTE IMPORTS: +# Job due timestamps plus appliance cycle stop times and durations +#:########################################################################################:# +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 + + #:######################################################################################:# + # Appliance cycle stop timestamps and completed cycle durations # + #:######################################################################################:# + + - platform: homeassistant + id: appliance_washing_stop_ts + entity_id: sensor.appliance_monitor_washing_machine_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_washing_duration + entity_id: sensor.appliance_monitor_washing_machine_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_dryer_stop_ts + entity_id: sensor.appliance_monitor_dryer_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_dryer_duration + entity_id: sensor.appliance_monitor_dryer_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_main_dishwasher_stop_ts + entity_id: sensor.appliance_monitor_main_dishwasher_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_main_dishwasher_duration + entity_id: sensor.appliance_monitor_main_dishwasher_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_downstairs_dishwasher_stop_ts + entity_id: sensor.appliance_monitor_downstairs_dishwasher_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_downstairs_dishwasher_duration + entity_id: sensor.appliance_monitor_downstairs_dishwasher_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_evcharger_32a_stop_ts + entity_id: sensor.appliance_monitor_ev_charger_32a_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_evcharger_32a_duration + entity_id: sensor.appliance_monitor_ev_charger_32a_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_evcharger_16a_stop_ts + entity_id: sensor.appliance_monitor_ev_charger_16a_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_evcharger_16a_duration + entity_id: sensor.appliance_monitor_ev_charger_16a_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_ryobi_left_stop_ts + entity_id: sensor.appliance_monitor_ryobicharger_left_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_ryobi_left_duration + entity_id: sensor.appliance_monitor_ryobicharger_left_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_ryobi_right_stop_ts + entity_id: sensor.appliance_monitor_ryobicharger_right_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_ryobi_right_duration + entity_id: sensor.appliance_monitor_ryobicharger_right_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_pool_pump_stop_ts + entity_id: sensor.appliance_monitor_pool_pump_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_pool_pump_duration + entity_id: sensor.appliance_monitor_pool_pump_monitor + attribute: cycle_duration_seconds + internal: true + + - platform: homeassistant + id: appliance_3d_printer_stop_ts + entity_id: sensor.appliance_monitor_3d_printer_monitor + attribute: cycle_stop_ts + internal: true + + - platform: homeassistant + id: appliance_3d_printer_duration + entity_id: sensor.appliance_monitor_3d_printer_monitor + attribute: cycle_duration_seconds + internal: true #:########################################################################################:# # OUTPUTS: @@ -307,7 +1319,7 @@ light: #:########################################################################################:# # PHYSICAL BUTTONS: -# Green refresh button plus left/right page buttons +# Left white selects Jobs; right white selects Appliances; green performs a forced refresh. # https://esphome.io/components/binary_sensor/gpio.html #:########################################################################################:# binary_sensor: @@ -325,14 +1337,13 @@ binary_sensor: - delayed_off: 20ms on_press: then: - - light.turn_on: onboard_led - - component.update: epaper_display - - delay: 300ms - - light.turn_off: onboard_led + - script.execute: + id: refresh_display_if_changed + force: true - platform: gpio id: button_right - name: "${friendly_name} Right Button" + name: "${friendly_name} Right Button - Appliances" pin: number: GPIO4 mode: @@ -342,10 +1353,24 @@ binary_sensor: filters: - delayed_on: 20ms - delayed_off: 20ms + on_press: + then: + - if: + condition: + lambda: |- + return id(current_page) != 1; + then: + - globals.set: + id: current_page + value: "1" + - display.page.show: page_appliances + - script.execute: + id: refresh_display_if_changed + force: true - platform: gpio id: button_left - name: "${friendly_name} Left Button" + name: "${friendly_name} Left Button - Jobs" pin: number: GPIO5 mode: @@ -355,10 +1380,24 @@ binary_sensor: filters: - delayed_on: 20ms - delayed_off: 20ms + on_press: + then: + - if: + condition: + lambda: |- + return id(current_page) != 0; + then: + - globals.set: + id: current_page + value: "0" + - display.page.show: page_jobs + - script.execute: + id: refresh_display_if_changed + force: true #:########################################################################################:# # HOME ASSISTANT CONTROLS: -# Manual display refresh button +# Manual refresh and page-selection buttons # https://esphome.io/components/button/template.html #:########################################################################################:# button: @@ -368,4 +1407,44 @@ button: icon: "mdi:refresh" on_press: then: - - component.update: epaper_display \ No newline at end of file + - 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: + - if: + condition: + lambda: |- + return id(current_page) != 0; + then: + - globals.set: + id: current_page + value: "0" + - display.page.show: page_jobs + - script.execute: + id: refresh_display_if_changed + force: true + + - platform: template + id: show_appliances_page_button + name: "${friendly_name} Show Appliances Page" + icon: "mdi:washing-machine" + on_press: + then: + - if: + condition: + lambda: |- + return id(current_page) != 1; + then: + - globals.set: + id: current_page + value: "1" + - display.page.show: page_appliances + - script.execute: + id: refresh_display_if_changed + force: true \ No newline at end of file