Files
zorruno-homeassistant/esphome/esp-reterminal-e1001.yaml
T
2026-07-30 21:38:49 +12:00

1577 lines
56 KiB
YAML

#:########################################################################################:#
# 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