Project Story: I confess, I'm a serial plant killer. I love the idea of plants, but I'm terrible at the practice. My salvation came not from a sudden green thumb, but from discovering two incredible, all-in-one smart devices that made building a solution almost laughably easy. This is the story of how I went from plant murderer to plant whisperer, with zero soldering or wiring required.
My desk desperately needed some life, but my track record with plants was abysmal. I needed a tech solution, but the thought of breadboards, jumper wires, and complex schematics was intimidating. I'm a software person; I want to write code, not debug a loose connection.
My search for a simple solution led me to a revelation. I found two products from Seeed Studio that were exactly what I needed: a complete, self-contained Soil Moisture Sensor and a complete, self-contained E-Paper Display. Each had a powerful XIAO ESP32-C3 already built-in. This was a game-changer. The hardware problem was already solved; all I had to do was bring them to life with software.
The Architecture: A Tale of Two DevicesMy plan was simple and wireless, leveraging the power of these all-in-one gadgets.
- The Sensor: The Seeed Studio XIAO Soil Moisture Sensor would be staked directly into my plant's pot. Its singular job: measure the moisture and report it over Wi-Fi.
- The Display: The Seeed Studio 7.5inch E-Paper Panel would live on my desk. Its job: to act as my personal command center, fetching the plant's status, the weather forecast, and my daily calendar from Home Assistant and displaying it all in a beautiful, calm interface.
This wireless, modular approach meant no clutter and maximum flexibility. The magic tying them together would be Home Assistant and ESPHome.
Software:
- A running Home Assistant instance.
- The ESPHome Add-on for Home Assistant.
Hardware Setup (The 30-Second Step)
- Take the Soil Sensor out of its box.
- Take the E-Paper Display out of its box.
That's it. The hardware build is 100% complete. This is the magic of using truly integrated products. Now, for the fun part.
Software Configuration
The entire project is defined in two simple YAML files within ESPHome.
Step 1: Configure the Soil SensorThis file tells the sensor how to connect to your Wi-Fi and report its data to Home Assistant.
substitutions:
name: "xiao-soil-moisture"
friendly_name: "XIAO Soil Moisture Monitor"
esphome:
name: "${name}"
friendly_name: "${friendly_name}"
name_add_mac_suffix: true
project:
name: "xiao.soil-moisture-monitor"
version: "1.0"
platformio_options:
platform: https://github.com/mnowak32/platform-espressif32.git#boards/seeed_xiao_esp32c6
on_boot:
then:
- output.turn_off: gpio_3_output
- output.turn_on: gpio_14_output
- light.turn_on:
id: pwm_led
brightness: 68% # Set 68% duty cycle
- if:
condition:
lambda: 'return id(wifi_net_status) == 0;'
then:
- logger.log: "The device has not been set to the network"
- deep_sleep.prevent: deep_sleep_control
else:
- logger.log: "The device has been networked"
- delay: 1s
- script.execute: check_moisture_once
esp32:
board: seeed_xiao_esp32c6
variant: ESP32C6
flash_size: 4MB
framework:
type: esp-idf
version: "5.2.1"
platform_version: 6.6.0
sdkconfig_options:
CONFIG_ESPTOOLPY_FLASHSIZE_4MB: y
# LED Yellow D10 18
# LED RED D9 20
# LED Green D8 19
# button D2 2
# Battery D0 0
# PWM out D3 21
# Soil sensor D1 1
output:
- platform: gpio
pin: GPIO18
id: yellow_led_output
- platform: gpio
pin: GPIO19
id: green_led_output
- platform: gpio
pin: GPIO20
id: red_led_output
- platform: ledc
pin: GPIO21
id: pwm_output
frequency: 200kHz # Set the frequency to 200kHz
- platform: gpio
pin: GPIO3
id: gpio_3_output
- platform: gpio
pin: GPIO14
id: gpio_14_output
light:
- platform: binary
id: yellow_led
output: yellow_led_output
- platform: binary
id: green_led
output: green_led_output
- platform: binary
id: red_led
output: red_led_output
- platform: monochromatic
output: pwm_output
id: pwm_led
name: "200kHz PWM"
internal: true
default_transition_length: 0s
script:
- id: red_led_blink
mode: restart
then:
- repeat:
count: 10
then:
- light.turn_on: red_led
- delay: 500ms
- light.turn_off: red_led
- delay: 500ms
- id: green_led_blink
mode: restart
then:
- repeat:
count: 10
then:
- light.turn_on: green_led
- delay: 500ms
- light.turn_off: green_led
- delay: 500ms
- id: fast_blink_green
then:
- repeat:
count: 5
then:
- light.turn_on: green_led
- delay: 200ms
- light.turn_off: green_led
- delay: 200ms
- id: fast_blink_red
then:
- repeat:
count: 5
then:
- light.turn_on: red_led
- delay: 200ms
- light.turn_off: red_led
- delay: 200ms
- id: red_led_blink_3_times
then:
- repeat:
count: 1
then:
- light.turn_on: red_led
- delay: 1000ms
- light.turn_off: red_led
- delay: 100ms
- id: yellow_led_blink_3_times
then:
- repeat:
count: 1
then:
- light.turn_on: yellow_led
- delay: 1000ms
- light.turn_off: yellow_led
- delay: 100ms
- id: green_led_blink_3_times
then:
- repeat:
count: 1
then:
- light.turn_on: green_led
- delay: 1000ms
- light.turn_off: green_led
- delay: 100ms
- id: do_calibration
then:
- deep_sleep.prevent: deep_sleep_control
- logger.log: "Starting calibration"
- script.execute: red_led_blink
- delay: 10s
- script.stop: red_led_blink
- lambda: |-
float sum = 0;
for (int i = 0; i < 10; i++) {
id(soil_sensor).update();
sum += id(soil_sensor).state;
delay(200);
}
id(dry_value) = sum / 10.0;
ESP_LOGI("calibration", "Dry value: %f", id(dry_value));
- delay: 3s
- script.execute: green_led_blink
- delay: 10s
- script.stop: green_led_blink
- lambda: |-
float sum = 0;
for (int i = 0; i < 10; i++) {
id(soil_sensor).update();
sum += id(soil_sensor).state;
delay(200);
}
id(wet_value) = sum / 10.0;
ESP_LOGI("calibration", "Wet value: %f", id(wet_value));
- delay: 3s
- lambda: |-
if (id(dry_value) > id(wet_value)) {
ESP_LOGI("calibration", "Calibration success");
id(fast_blink_green).execute();
} else {
ESP_LOGW("calibration", "Calibration failed");
id(fast_blink_red).execute();
}
- delay: 3s
- script.execute: check_moisture_once
- delay: 3s
- deep_sleep.enter: deep_sleep_control
- id: check_moisture_once
then:
- lambda: |-
for(int i = 0; i < 10; i++){
id(soil_sensor).update();
delay(200);
}
float moisture = id(soil_sensor).state;
ESP_LOGI("moisture_check", "Moisture reading: %f", moisture);
float Diff = id(dry_value) - id(wet_value);
ESP_LOGI("moisture_check", "Diff is: %f", Diff);
ESP_LOGI("moisture_check", "ref_dry Diff is: %f",id(dry_value) - Diff * id(ref_dry));
ESP_LOGI("moisture_check", "ref_wet Diff is: %f",id(dry_value) - Diff * id(ref_wet));
if (moisture >= (id(dry_value) - Diff * id(ref_dry))) { // The drier -> the higher the voltage
id(red_led_blink_3_times).execute();
id(deep_sleep_control).set_sleep_duration(900000);
} else if(moisture > (id(dry_value) - Diff * id(ref_wet)) && moisture < (id(dry_value) - Diff * id(ref_dry))){
id(yellow_led_blink_3_times).execute();
id(deep_sleep_control).set_sleep_duration(3600000);
}else{
// moisture > (id(dry_value) - Diff * id(ref_wet))
id(green_led_blink_3_times).execute();
id(deep_sleep_control).set_sleep_duration(28800000);
}
globals:
- id: button_press_count
type: int
restore_value: no
initial_value: '0'
- id: dry_value
type: float
restore_value: yes
initial_value: '2.75'
- id: wet_value
type: float
restore_value: yes
initial_value: '1.2'
- id: wifi_net_status
type: int
restore_value: yes
initial_value: "0"
- id: ref_dry
type: float
restore_value: no
initial_value: "0.23"
- id: ref_wet
type: float
restore_value: no
initial_value: "0.58"
binary_sensor:
- platform: gpio
pin:
number: GPIO2
mode: INPUT_PULLUP
allow_other_uses: true
id: my_button
on_press:
- lambda: |-
id(button_press_count)++;
- delay: 1s # Delay 1 second to see if the button is pressed 3 times in a row
- lambda: |-
if (id(button_press_count) == 3) {
id(button_press_count) = 0;
id(do_calibration).execute(); // Trigger calibration process
} else if (id(button_press_count) == 1) {
id(button_press_count) = 0;
id(check_moisture_once).execute(); // Perform an ADC decision
} else {
id(button_press_count) = 0;
}
# interval:
# - interval: 10s
# then:
# - script.execute: check_moisture_once
# Deep sleep configuration
deep_sleep:
id: deep_sleep_control
run_duration: 120s
sleep_duration: 180min
wakeup_pin:
number: GPIO2
inverted: true
allow_other_uses: true
mode: INPUT_PULLUP
external_components:
- source: github://pr#7942
components: [ "adc" ]
- source:
type: git
url: https://github.com/ackPeng/esphome.git
ref: api
components: [ api ]
refresh: 0s
sensor:
- platform: adc
id: soil_sensor
pin: GPIO1
name: "Soil moisture measurement"
update_interval: 4s
internal: true
attenuation: 12db
- platform: adc
pin: GPIO0
name: "Battery measurement"
attenuation: 12db
# internal: true
filters: # When the battery drops below 1V, it is dead.
- lambda: |-
if (x < 1.2) {
return 0.0;
}else if(x > 1.5){
return 1.0 * 100.0;
}else {
return ((x - 1.2) / (1.5 - 1.2)) * 100.0;
}
unit_of_measurement: "%"
update_interval: 5s
force_update: True
- platform: wifi_signal
name: "wifi singnal strength"
update_interval: 10s
# text_sensor:
# - platform: template
# name: "Soil Moisture Status" # ✅ Status displayed on the HA panel
# id: soil_status
# # internal: true
# lambda: |-
# float value = id(soil_sensor).state;
# float Diff = id(dry_value) - id(wet_value);
# if (value >= (id(dry_value) - Diff * id(ref_dry))) {
# //id(red_led_blink_3_times).execute();
# return {"Dry"};
# } else if (value > (id(dry_value) - Diff * id(ref_wet)) && value < (id(dry_value) - Diff * id(ref_dry))) {
# //id(yellow_led_blink_3_times).execute();
# return {"Almost Dry"};
# } else {
# //id(green_led_blink_3_times).execute();
# return {"Normal Moisture"};
# }
# update_interval: 5s
text_sensor:
- platform: template
name: "Soil Moisture Status"
id: soil_status
lambda: |-
float value = id(soil_sensor).state;
float Diff = id(dry_value) - id(wet_value);
if (value >= (id(dry_value) - Diff * id(ref_dry))) {
return {"Dry"};
} else if (value > (id(dry_value) - Diff * id(ref_wet)) && value < (id(dry_value) - Diff * id(ref_dry))) {
return {"Almost Dry"};
} else {
return {"Normal Moisture"};
}
update_interval: never # 不让自动触发上报,我们自己控制
interval:
- interval: 5s
then:
- text_sensor.template.publish:
id: soil_status
state: !lambda |-
return "";
- delay: 10ms
- text_sensor.template.publish:
id: soil_status
state: !lambda |-
float value = id(soil_sensor).state;
float Diff = id(dry_value) - id(wet_value);
if (value >= (id(dry_value) - Diff * id(ref_dry))) {
id(deep_sleep_control).set_sleep_duration(900000);
return "Dry";
} else if (value > (id(dry_value) - Diff * id(ref_wet)) && value < (id(dry_value) - Diff * id(ref_dry))) {
id(deep_sleep_control).set_sleep_duration(3600000);
return "Almost Dry";
} else {
id(deep_sleep_control).set_sleep_duration(28800000);
return "Normal Moisture";
}
# Enable logging
logger:
improv_serial:
# Enable Home Assistant API
api:
ota:
- platform: esphome
wifi:
on_connect:
then:
- if:
condition:
lambda: 'return id(wifi_net_status) == 0;'
then:
- logger.log: "The device has not been configured yet, but now it is successfully configured"
- globals.set:
id: wifi_net_status
value: '1'
- delay: 5s
- deep_sleep.allow: deep_sleep_control
else:
- logger.log: "The device has been networked"
on_disconnect:
then:
- globals.set:
id: wifi_net_status
value: '0'
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "XIAO-Soil-Moisture-Monitor"
captive_portal:
Step 2: Configure the E-Paper DashboardThis more detailed file tells the display what information to fetch and how to draw it on the screen.
esphome:
name: dashboard
friendly_name: dashboard
esp32:
board: esp32-c3-devkitm-1
framework:
type: arduino
# Enable logging
logger:
# Enable Home Assistant API
api:
ota:
- platform: esphome
globals:
- id: wifi_status
type: int
restore_value: no
initial_value: "0"
- id: first_update_done
type: bool
restore_value: no
initial_value: "false"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
on_connect:
then:
- lambda: |-
id(wifi_status) = 1;
on_disconnect:
then:
- lambda: |-
id(wifi_status) = 0;
captive_portal:
script:
- id: update_display
then:
- component.update: my_display
interval:
# Condition: wifi connected && data retrieved && first time
- interval: 10s # Check every second
then:
- if:
condition:
and:
- wifi.connected:
- lambda: "return !id(ha_calendar_event_1).state.empty();"
- lambda: "return !id(first_update_done);"
then:
- lambda: |-
ESP_LOGD("Display", "Updating Display...");
- script.execute: update_display # Refresh immediately
- lambda: "id(first_update_done) = true;"
image:
- file: image/wifi.jpg
type: BINARY
id: esphome_logo
resize: 400x240
invert_alpha: true
# Connect to Home Assistant to get time
time:
- platform: homeassistant
id: homeassistant_time
text_sensor:
- platform: homeassistant
id: ha_calendar_event_1
entity_id: calendar.calendar
attribute: "message"
- platform: homeassistant
id: ha_calendar_start_time_1
entity_id: calendar.calendar
attribute: "start_time"
- platform: homeassistant
id: ha_calendar_end_time_1
entity_id: calendar.calendar
attribute: "end_time"
- platform: homeassistant
id: ha_calendar_event_2
entity_id: calendar.calendar2
attribute: "message"
- platform: homeassistant
id: ha_calendar_start_time_2
entity_id: calendar.calendar2
attribute: "start_time"
- platform: homeassistant
id: ha_calendar_end_time_2
entity_id: calendar.calendar2
attribute: "end_time"
- platform: homeassistant
id: ha_calendar_event_3
entity_id: calendar.calendar3
attribute: "message"
- platform: homeassistant
id: ha_calendar_start_time_3
entity_id: calendar.calendar3
attribute: "start_time"
- platform: homeassistant
id: ha_calendar_end_time_3
entity_id: calendar.calendar3
attribute: "end_time"
- platform: homeassistant
entity_id: weather.home
id: myWeather
- platform: homeassistant
entity_id: weather.home
id: temp
attribute: "temperature"
- platform: homeassistant
entity_id: sensor.xiao_soil_moisture_b4c0f4_soil_moisture_status
id: soil
- platform: homeassistant
entity_id: weather.home
id: wind_bearing
attribute: "wind_bearing"
- platform: homeassistant
entity_id: weather.home
id: wind
attribute: "wind_speed"
font:
- file: "fonts/Montserrat-Black.ttf"
id: web_font
size: 20
- file: "fonts/Montserrat-Black.ttf"
id: data_font
size: 30
- file: "fonts/Montserrat-Black.ttf"
id: sensor_font
size: 22
- file: "gfonts://Inter@700" #
id: font1
size: 24
- file: 'fonts/materialdesignicons-webfont.ttf' # Directory to save ttf file
id: font_mdi_large
size: 200
glyphs: &mdi-weather-glyphs # https://pictogrammers.com/library/mdi/
- "\U000F050F" # Thermometer
- "\U000F058E" # Humidity
- "\U000F059D" # Wind speed
- "\U000F0D60" # Atmospheric pressure
- "\U000F0590" # Cloudy weather
- "\U000F0596" # Rainy weather
- "\U000F0598" # Snowy weather
- "\U000F0599" # Sunny weather
- file: 'fonts/materialdesignicons-webfont.ttf'
id: font_weather # Copy the above icon and change the size to 40
size: 200
glyphs: *mdi-weather-glyphs
- file: 'fonts/materialdesignicons-webfont.ttf'
id: img_font_sensor # Copy the above icon and change the size to 40
size: 70
glyphs: *mdi-weather-glyphs
spi:
clk_pin: GPIO8
mosi_pin: GPIO10
display:
- platform: waveshare_epaper
id: my_display
cs_pin: GPIO3
dc_pin: GPIO5
busy_pin: GPIO4
reset_pin: GPIO2
model: 7.50inv2
update_interval: 70s
lambda: |-
if(id(wifi_status) == 0){
it.image(180, 0, id(esphome_logo));
it.print(230, 300, id(data_font), "WI-FI CONNECTING");
}else{
// Draw weather images here
std::string weather_string = id(myWeather).state.c_str();
if(weather_string == "rainy" || weather_string == "lightning" || weather_string == "pouring"){
// Draw rainy weather image
it.printf(120, 85, id(font_weather), TextAlign::CENTER, "\U000F0596");
}else if(weather_string == "snowy"){
// Draw snowy weather image
it.printf(120, 85, id(font_weather), TextAlign::CENTER, "\U000F0598");
}else if(weather_string == "sunny" || weather_string == "windy"){
// Draw sunny weather image
it.printf(120, 85, id(font_weather), TextAlign::CENTER, "\U000F0599");
}else{
// Draw cloudy weather image
it.printf(120, 85, id(font_weather), TextAlign::CENTER, "\U000F0590");
}
auto time_now = id(homeassistant_time).now();
// Month conversion
const char* months[] = {
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
};
const char* month_str = months[time_now.month - 1]; // Month index starts from 0
// Get the day
int day = time_now.day_of_month;
// Draw the date
it.printf(220, 110, id(data_font), "%s %d", month_str, day);
// Get the day of the week
const char* days[] = {"Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"};
const char* day_of_week = days[time_now.day_of_week];
it.printf(220, 70, id(data_font), "%s", day_of_week);
int x = 20, y = 180, w = 180, h = 120, r = 10, thickness = 4;
// Draw four borders
it.filled_rectangle(x + r, y, w - 2 * r, thickness); // Top border
it.filled_rectangle(x + r, y + h - thickness, w - 2 * r, thickness); // Bottom border
it.filled_rectangle(x, y + r, thickness, h - 2 * r); // Left border
it.filled_rectangle(x + w - thickness, y + r, thickness, h - 2 * r); // Right border
// Draw four rounded corners
it.filled_circle(x + r, y + r, r); // Top-left corner
it.filled_circle(x + w - r, y + r, r); // Top-right corner
it.filled_circle(x + r, y + h - r, r); // Bottom-left corner
it.filled_circle(x + w - r, y + h - r, r); // Bottom-right corner
// Fill the inside with black to form a border
it.filled_rectangle(x + thickness, y + thickness, w - 2 * thickness, h - 2 * thickness, COLOR_OFF);
// Temperature
it.printf(x+10, y+10, id(sensor_font), "Temperature");
it.printf(x+35, y+75, id(img_font_sensor), TextAlign::CENTER, "\U000F050F");
// Get temperature data
it.printf(x+58,y+65, id(data_font), "%s°C", id(temp).state.c_str());
x = 220;
y = 180;
// Draw four borders
it.filled_rectangle(x + r, y, w - 2 * r, thickness); // Top border
it.filled_rectangle(x + r, y + h - thickness, w - 2 * r, thickness); // Bottom border
it.filled_rectangle(x, y + r, thickness, h - 2 * r); // Left border
it.filled_rectangle(x + w - thickness, y + r, thickness, h - 2 * r); // Right border
// Draw four rounded corners
it.filled_circle(x + r, y + r, r); // Top-left corner
it.filled_circle(x + w - r, y + r, r); // Top-right corner
it.filled_circle(x + r, y + h - r, r); // Bottom-left corner
it.filled_circle(x + w - r, y + h - r, r); // Bottom-right corner
// Fill the inside with black to form a border
it.filled_rectangle(x + thickness, y + thickness, w - 2 * thickness, h - 2 * thickness, COLOR_OFF);
// Soil Sensor
it.printf(x+10, y+10, id(sensor_font), "Soil Sensor");
it.printf(x+45, y+75, id(img_font_sensor), TextAlign::CENTER, "\U000F058E");
// Get soil sensor data
it.printf(x+75,y+65, id(data_font), "%s%", id(soil).state.c_str());
x = 20;
y = 320;
// Draw four borders
it.filled_rectangle(x + r, y, w - 2 * r, thickness); // Top border
it.filled_rectangle(x + r, y + h - thickness, w - 2 * r, thickness); // Bottom border
it.filled_rectangle(x, y + r, thickness, h - 2 * r); // Left border
it.filled_rectangle(x + w - thickness, y + r, thickness, h - 2 * r); // Right border
// Draw four rounded corners
it.filled_circle(x + r, y + r, r); // Top-left corner
it.filled_circle(x + w - r, y + r, r); // Top-right corner
it.filled_circle(x + r, y + h - r, r); // Bottom-left corner
it.filled_circle(x + w - r, y + h - r, r); // Bottom-right corner
// Fill the inside with black to form a border
it.filled_rectangle(x + thickness, y + thickness, w - 2 * thickness, h - 2 * thickness, COLOR_OFF);
// wind_bearing
it.printf(x+10, y+10, id(sensor_font), "Wind Bearing");
it.printf(x+45, y+75, id(img_font_sensor), TextAlign::CENTER, "\U000F0D60");
// Get atmospheric wind_bearing data
it.printf(x+85,y+50, id(data_font), "%s", id(wind_bearing).state.c_str());
x = 220;
y = 320;
// Draw four borders
it.filled_rectangle(x + r, y, w - 2 * r, thickness); // Top border
it.filled_rectangle(x + r, y + h - thickness, w - 2 * r, thickness); // Bottom border
it.filled_rectangle(x, y + r, thickness, h - 2 * r); // Left border
it.filled_rectangle(x + w - thickness, y + r, thickness, h - 2 * r); // Right border
// Draw four rounded corners
it.filled_circle(x + r, y + r, r); // Top-left corner
it.filled_circle(x + w - r, y + r, r); // Top-right corner
it.filled_circle(x + r, y + h - r, r); // Bottom-left corner
it.filled_circle(x + w - r, y + h - r, r); // Bottom-right corner
// Fill the inside with black to form a border
it.filled_rectangle(x + thickness, y + thickness, w - 2 * thickness, h - 2 * thickness, COLOR_OFF);
// Wind Speed
it.printf(x+10, y+10, id(sensor_font), "Wind Speed");
it.printf(x+45, y+75, id(img_font_sensor), TextAlign::CENTER, "\U000F059D");
// Get wind speed data
it.printf(x+85,y+50, id(data_font), "%s", id(wind).state.c_str());
it.printf(x+85,y+78, id(sensor_font), "km/h");
// Draw a vertical line
it.filled_rectangle(430, 30, 5, 430);
// Right section
it.printf(540, 40, id(data_font), "Calendar");
// Define event structure
struct Event {
std::string message;
std::string start_time;
std::string end_time;
time_t start_timestamp;
};
// Parse time string to time_t (UNIX timestamp)
auto parse_time = [](const std::string &time_str) -> time_t {
struct tm timeinfo = {};
if (strptime(time_str.c_str(), "%Y-%m-%d %H:%M:%S", &timeinfo) == nullptr) {
return 0; // Invalid time
}
return mktime(&timeinfo);
};
// Create event list
std::vector<Event> events = {
{id(ha_calendar_event_1).state, id(ha_calendar_start_time_1).state, id(ha_calendar_end_time_1).state, parse_time(id(ha_calendar_start_time_1).state)},
{id(ha_calendar_event_2).state, id(ha_calendar_start_time_2).state, id(ha_calendar_end_time_2).state, parse_time(id(ha_calendar_start_time_2).state)},
{id(ha_calendar_event_3).state, id(ha_calendar_start_time_3).state, id(ha_calendar_end_time_3).state, parse_time(id(ha_calendar_start_time_3).state)}
};
ESP_LOGD("myCalendar", "Start Time: %s -> %ld", id(ha_calendar_start_time_1).state.c_str(), parse_time(id(ha_calendar_start_time_1).state));
ESP_LOGD("myCalendar", "Start Time: %s -> %ld", id(ha_calendar_start_time_2).state.c_str(), parse_time(id(ha_calendar_start_time_2).state));
ESP_LOGD("myCalendar", "Start Time: %s -> %ld", id(ha_calendar_start_time_3).state.c_str(), parse_time(id(ha_calendar_start_time_3).state));
// Filter invalid events (start_timestamp == 0)
events.erase(std::remove_if(events.begin(), events.end(), [](const Event &e) { return e.start_timestamp == 0; }), events.end());
// Sort by `start_timestamp` (earliest to latest)
std::sort(events.begin(), events.end(), [](const Event &a, const Event &b) {
return a.start_timestamp < b.start_timestamp;
});
// Define a function to format time
auto format_time = [](std::string time_str) -> std::string {
struct tm timeinfo;
if (strptime(time_str.c_str(), "%Y-%m-%d %H:%M:%S", &timeinfo) == nullptr) {
return "Invalid";
}
char buffer[10];
strftime(buffer, sizeof(buffer), "%I:%M%p", &timeinfo); // Convert to 12-hour format
return std::string(buffer);
};
// Parse date
auto format_date = [](const std::string &time_str) -> std::string {
struct tm timeinfo = {};
if (strptime(time_str.c_str(), "%Y-%m-%d %H:%M:%S", &timeinfo) == nullptr) {
return "Invalid";
}
char buffer[6]; // Need to store "MM-DD\0"
strftime(buffer, sizeof(buffer), "%m-%d", &timeinfo);
return std::string(buffer);
};
// Draw events
int even_x_start_offset = 460;
int even_y_start_offset = 80;
for (const auto &event : events) {
if(even_y_start_offset >= 420){
break;
}
// Format time
std::string formatted_date = format_date(event.start_time);
std::string formatted_start_time = format_time(event.start_time);
std::string formatted_end_time = format_time(event.end_time);
// Combine time range string
std::string time_range = formatted_start_time + " - " + formatted_end_time;
time_range = formatted_date + " " + time_range;
if(formatted_start_time == "Invalid" || formatted_end_time == "Invalid"){
time_range.clear();
}
// Display time range, e.g., "10:00AM - 11:00AM"
it.printf(even_x_start_offset, even_y_start_offset, id(sensor_font), "%s", time_range.c_str());
even_y_start_offset += 30;
// Display event name
it.printf(even_x_start_offset, even_y_start_offset, id(sensor_font), "%s", event.message.c_str());
even_y_start_offset += 40;
}
}
Step 3: My Hard-Won Lessons (Read This to Avoid Headaches!)Even with simple hardware, software can have its quirks. Here's what I learned so you don't have to.
Entity IDs Are Your Enemy!
- The Problem: The dashboard code relies on
entity_id
's from Home Assistant, likesensor.xiao_soil_moisture_b4c0f4_soil_moisture_status
orweather.home
. These will be different for you! Copying my code directly will result in a blank screen or "unavailable" messages. - The Fix: Go to Developer Tools > States in your Home Assistant dashboard. Find the exact entity IDs for your devices and services. Carefully replace every single one in the YAML file. Precision is key.
The Mystery of Font & Icon Paths
- The Problem: ESPHome needs to find your font files (like
Montserrat-Black.ttf
andmaterialdesignicons-webfont.ttf
) during compilation. If the path is wrong, the process will fail with a cryptic error. - The Fix: In your ESPHome config directory (e.g.,
/config/esphome/
), create afonts
folder. Place your.ttf
files inside it. Then, in your YAML, reference them with a relative path likefonts/your_font.ttf
.
The Magic (and Traps) of Icon Glyphs
- The Magic: The Material Design Icon font has thousands of icons. Loading the whole thing would instantly overwhelm the device's memory. The
glyphs:
configuration is a lifesaver—it tells ESPHome to only package the specific icons we list, saving precious RAM. - The Trap: Finding the right code for an icon can be tricky. Go to the [Pictogrammers MDI Library], find your icon (e.g., "water-percent"), click it, and copy the "Codepoint" (e.g.,
F058E
). In your YAML, you must format it as a Unicode string:"\U000F058E"
. Get this format wrong, and it won't work.
The Wi-Fi Connection & First Refresh Race Condition
- The Problem: When the device boots, it might try to fetch data from Home Assistant before the Wi-Fi is fully connected. This will fail, potentially leaving your screen blank or in an error state.
- The Fix: The code implements a robust startup sequence using
wifi_status
andfirst_update_done
flags. It displays a "Connecting..." message first. Only after Wi-Fi is confirmed and the initial data is successfully retrieved does it perform the first full screen refresh. This small detail makes the user experience infinitely better.
The moment the display flickered to life, showing a perfect dashboard, was incredible. A project that I thought would be complex was made simple by choosing the right hardware.
Now, with a single glance, I know:
- If my plant is thirsty.
- If I need an umbrella for my lunch break.
- The current temperature and wind conditions.
- My next meeting on the calendar.
This project was a powerful reminder that technology is at its best when it's accessible. Thanks to these amazing all-in-one products, a sophisticated smart-home device is no longer a complex hardware challenge, but purely a software one.
Happy Hacking!
Comments