#:########################################################################################:#
# 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.1 2026-07-30 Redesigned job tiles: big icon in left quarter, title and
#                    status text left-aligned in right three-quarters.
# 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.1"

  # 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<int>(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<unsigned long long>(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<unsigned long>(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 = "Complete";
                status_sub = elapsed;
              } else if (!completed_time.empty() && completed_time != "Never" && completed_time != "unknown") {
                status = "Complete";
                status_sub = 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<int64_t>(due_ts) - now_ts;
                const int hours = static_cast<int>((remaining + 3599) / 3600);
                if (hours <= hour_limit) {
                  status = "Due: " + std::to_string(hours) + (hours == 1 ? " Hour" : " Hours");
                } else {
                  const int days = static_cast<int>((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, left-aligned in the right 3/4.
            const int text_x = x + 71;

            it.printf(
              text_x,
              y + 40,
              id(font_job_title),
              foreground,
              TextAlign::CENTER_LEFT,
              "%s",
              title_line_1
            );

            it.printf(
              text_x,
              y + 70,
              id(font_job_title),
              foreground,
              TextAlign::CENTER_LEFT,
              "%s",
              title_line_2
            );

            it.printf(
              text_x,
              y + 96,
              id(font_job_status),
              foreground,
              TextAlign::CENTER_LEFT,
              "%s",
              status.c_str()
            );

            if (!status_sub.empty()) {
              it.printf(
                text_x,
                y + 120,
                id(font_job_status_sub),
                foreground,
                TextAlign::CENTER_LEFT,
                "%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, water TV plant.
          draw_job(
            8, 204, 2,
            "Dishwasher", "Unpacked",
            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,
            "Rubbish", "Removed",
            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, 4,
            "Water TV", "Pot Plant",
            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
          );

          // Row 3: water table plant, dryer, 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, 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
          );

          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<long long>(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<int64_t>(due_ts) - now_ts;
              const int hours = static_cast<int>((remaining + 3599) / 3600);
              if (hours <= hour_limit) {
                append_text("hours");
                append_number(hours);
              } else {
                const int days = static_cast<int>((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<uint32_t>(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<unsigned long>(signature_hash)
            );
          } else {
            ESP_LOGI(
              "display",
              "Jobs content unchanged (hash 0x%08lX); e-paper refresh skipped",
              static_cast<unsigned long>(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
