Hi everyone,
Following up on my previous Linear LED Clock project, I wanted to share another ESPHome integration. This project is based on my original Instructables article: Just Hues: an Ultraminimalist Perpetual Monthly LED Calendar. While the original used custom Arduino code, I wanted to integrate this Ultraminimalist LED Calendar fully into Home Assistant using ESPHome.
Instead of numbers, it uses a grid of 37 WS2812B LEDs (arranged in a specific 6-row layout based on my physical build - see mapping in code) to display the structure of the current month using colors:
- Green: Weekdays
- Red: Weekend days (Saturday/Sunday)
- Blue: The current day
The ESPHome integration leverages the addressable_lambda
effect within the light
component, optimized to separate the date calculations (which run infrequently) from the fast visual updates (like the glitter effect).
Key Features Implemented in ESPHome:
- Automatic Calendar Display: Calculates and shows the correct layout for the current month.
- Selectable Start Day: A switch entity in Home Assistant (
Start Day of Week
) allows choosing between Sunday or Monday as the first day of the week, updating the display accordingly. - Brightness Control: A
number
entity (slider) in Home Assistant (Calendar Brightness
) controls the overall brightness of the display. - Glitter Effect: A subtle, randomized “glitter” effect (adds white flashes to random active LEDs) for a bit of visual flair. The frequency and intensity are adjustable in the lambda code.
- Power Control: A simple switch entity in Home Assistant (
Calendar Power
) to turn the display ON or OFF. - Automatic ON State: The calendar turns on automatically after boot once the time is synced from Home Assistant.
- State Restoration: Remembers the selected start day and brightness level across reboots (using
restore_value: yes
).
ESPHome YAML Code:
Here is the complete YAML configuration. It includes detailed comments explaining the different sections and logic.
# ==============================================================================
# ESPHome Configuration: Ultraminimalist LED Calendar
# ==============================================================================
#
# Project Description:
# This configuration controls a 37-LED WS2812B strip arranged in a custom
# 6-row grid to display a minimalist calendar. It shows the current month's
# structure using distinct colors for weekdays, weekend days, and the current day.
# Includes a subtle glitter effect, brightness control via Home Assistant,
# and allows selecting the start day of the week (Sunday or Monday).
#
# Key Features:
# - Custom calendar layout rendering.
# - Dynamic glitter effect.
# - HA controls: Power, Start Day, Brightness.
# - Optimized performance: Separates slow date calculations from fast visual updates.
# - Automatic startup after boot and time sync.
# - Persistent settings (brightness, start day).
# ==============================================================================
esphome:
# --- Device Identification ---
name: ultraminimalist-calendar # Internal hostname (used for mDNS, OTA, API)
friendly_name: "Ultraminimalist Calendar" # Name displayed in Home Assistant Integrations UI
# --- Boot Sequence Actions ---
on_boot:
# Runs relatively early in the boot sequence (higher priority runs first).
# This runs before the network or API might be fully ready.
priority: 10.0
then:
- logger.log: "Device booted. Waiting for time sync."
# Initialize the global base colors vector with black.
# This prevents potential errors if the effect lambda runs before the
# calculation script has populated the vector for the first time.
- lambda: id(g_base_colors).assign(37, Color::BLACK);
# Ensure the LED strip is turned off physically on hardware boot.
# The actual display will be turned on later by on_time_sync or the power switch.
- light.turn_off: calendar_led_strip
# ==============================================================================
# Time Synchronization Component
# ==============================================================================
time:
- platform: homeassistant # Use Home Assistant as the time source via the native API
id: ha_time # Internal ID to reference this time component
# --- Action on Successful Time Sync ---
# This trigger runs once each time the ESP successfully syncs time with Home Assistant
# (e.g., after boot and connection, or after a reconnection).
on_time_sync:
then:
- logger.log: "Time synced. Recalculating calendar and turning ON."
# 1. Perform the initial calculation of base calendar colors now that the date is known.
- script.execute: update_base_calendar_colors
# 2. Turn the light ON using the custom effect.
# The effect lambda will read the target brightness from the global variable.
# This ensures the calendar starts automatically in the desired ON state after boot.
- light.turn_on:
id: calendar_led_strip
effect: "Calendar Display" # Activate our custom display effect
- logger.log: "Calendar activated via on_time_sync."
# --- Daily Calendar Recalculation Trigger ---
# This trigger ensures the calendar display updates for the new day.
on_time:
# Run this sequence every day at 00:02:00 AM (2 minutes past midnight).
# The slight delay ensures the date has definitely changed.
- seconds: 0
minutes: 2
hours: 0
then:
- logger.log: "Daily trigger: Recalculating base calendar colors."
# Execute the script that performs the date-dependent calculations.
- script.execute: update_base_calendar_colors
# Note: We don't need to explicitly call light.turn_on here.
# The 'Calendar Display' effect is already running (if the light is ON).
# It will automatically pick up the newly calculated base colors
# from the global vector during its next update cycle (within 50ms).
# ==============================================================================
# Global Variables
# ==============================================================================
# These variables store state or configuration values accessible across different parts
# of the ESPHome configuration (lambdas, scripts, etc.).
globals:
# --- Color Definitions ---
# Define the colors used for different day types. Can be customized here.
- id: color_weekday # Color for regular weekdays (Monday-Friday or Sunday-Thursday depending on start day)
type: Color
initial_value: '{0, 255, 0}' # Green
- id: color_weekend # Color for weekend days (Saturday/Sunday)
type: Color
initial_value: '{255, 0, 0}' # Red
- id: color_today # Color for highlighting the current day
type: Color
initial_value: '{0, 0, 255}' # Blue
# --- User Configuration Globals ---
- id: start_day_of_week # Stores the user's preference for the first day of the week.
type: int # 0 = Sunday, 1 = Monday
initial_value: '1' # Default to Monday as the start day.
restore_value: yes # Automatically save this value to flash and restore it on reboot.
- id: g_target_brightness_pct # Stores the desired brightness percentage (1-100) set via the HA slider.
type: float
initial_value: '80.0' # Set the default brightness to 80% on first boot.
restore_value: yes # Automatically save and restore this brightness setting across reboots.
# --- Internal State Global (for performance optimization) ---
- id: g_base_colors # Stores the calculated base colors (weekday/weekend/today/off) for each physical LED.
type: std::vector<esphome::Color> # A C++ vector holding Color objects. Size matches num_leds.
# This vector is populated by the 'update_base_calendar_colors' script and read by the fast 'Calendar Display' effect lambda.
# ==============================================================================
# Number Component (Brightness Slider)
# ==============================================================================
# Creates a number entity (displayed as a slider) in Home Assistant for controlling brightness.
number:
- platform: template
id: calendar_brightness_number # Internal ID for this component
name: "Calendar Brightness" # Name displayed for the entity in Home Assistant UI
# Lambda function: Reports the current state of the slider back to Home Assistant.
# Reads the target brightness value stored in the global variable.
lambda: |-
float target_pct = id(g_target_brightness_pct);
// Provide a default if the global is somehow invalid (e.g., first boot before restore)
if (isnan(target_pct)) { target_pct = 80.0f; }
// Clamp the value to the defined min/max range before reporting, just in case.
target_pct = std::max(id(calendar_brightness_number).traits.get_min_value(), std::min(id(calendar_brightness_number).traits.get_max_value(), target_pct));
return target_pct;
# Action executed when the slider's value is changed from Home Assistant
set_action:
- logger.log:
format: "Number set_action: Received target brightness x=%.1f. Storing in global."
args:
- 'x' # 'x' is the special variable holding the value sent from HA (1-100)
# Update the global variable with the new target brightness value
- globals.set:
id: g_target_brightness_pct
value: !lambda 'return x;' # Store the received value 'x'
# Optional: Force an immediate refresh of the light effect.
# This makes the brightness change feel more instantaneous (response < 50ms)
# instead of waiting for the next 50ms effect update cycle.
- if:
condition:
light.is_on: calendar_led_strip # Only refresh if the light is actually on
then:
- logger.log: "Forcing effect refresh after brightness change."
# Re-applying the effect will cause its lambda to run immediately,
# reading the new brightness value from the global.
- light.turn_on:
id: calendar_led_strip
effect: "Calendar Display"
# Configuration for the slider entity in Home Assistant
min_value: 1 # Minimum allowed brightness percentage
max_value: 100 # Maximum allowed brightness percentage
step: 1 # Increment/decrement step size for the slider
unit_of_measurement: "%" # Unit displayed next to the value in HA
mode: slider # Display as a slider (alternatives: box)
# ==============================================================================
# Switch Components
# ==============================================================================
# Creates switch entities in Home Assistant for user control.
switch:
# --- Main Power Switch ---
- platform: template
id: calendar_power_switch # Internal ID
name: "Calendar Power" # Name displayed in HA
# Lambda: Reports the switch state based on the actual state of the internal light component.
lambda: |-
// Return true (ON) if the internal light component is currently on, false (OFF) otherwise.
return id(calendar_led_strip).current_values.is_on();
# Action when the switch is turned ON from HA
turn_on_action:
- logger.log: "Calendar Power Switch ON action started."
# 1. Ensure the base calendar colors are up-to-date (in case the day changed while off)
- script.execute: update_base_calendar_colors
# 2. Turn on the internal light component and activate the custom display effect.
# Brightness is handled by the effect reading the global variable.
- light.turn_on:
id: calendar_led_strip
effect: "Calendar Display"
# Action when the switch is turned OFF from HA
turn_off_action:
- logger.log: "Calendar Power Switch turned OFF."
# Turn off the internal light component, stopping the effect.
- light.turn_off:
id: calendar_led_strip
# --- Start Day of Week Switch ---
- platform: template
id: start_day_of_week_switch # <<< RENAMED ID
name: "Start Day of Week" # <<< RENAMED NAME (Functionality: OFF=Monday, ON=Sunday)
# Lambda: Reports the switch state based on the global variable 'start_day_of_week'.
# Returns true (ON) if the global is 0 (Sunday), false (OFF) if it's 1 (Monday).
lambda: |-
return id(start_day_of_week) == 0;
# Action when switched ON (User wants Sunday as the start day)
turn_on_action:
- logger.log: "Setting start day to Sunday (0)."
# Update the global variable to 0
- globals.set:
id: start_day_of_week
value: '0'
# Trigger the script to recalculate the base calendar colors immediately
# using the new setting. The effect will pick up the changes shortly after.
- script.execute: update_base_calendar_colors
# Action when switched OFF (User wants Monday as the start day)
turn_off_action:
- logger.log: "Setting start day to Monday (1)."
# Update the global variable to 1
- globals.set:
id: start_day_of_week
value: '1'
# Trigger the script to recalculate the base calendar colors immediately
# using the new setting. The effect will pick up the changes shortly after.
- script.execute: update_base_calendar_colors
# ==============================================================================
# Script for Slow Calculations
# ==============================================================================
# Contains the logic for calculating the base calendar colors.
# This runs less frequently (daily, on setting change, on sync) to optimize performance.
script:
- id: update_base_calendar_colors # Internal ID for this script
mode: single # Ensures only one instance runs at a time, preventing conflicts
then:
# Lambda containing the C++ code for the actual calculations
- lambda: |-
// Include necessary C++ headers
#include <ctime> // For time calculations (mktime, tm structure)
#include <vector> // For std::vector
#include <cmath> // For isnan
#include <algorithm>// For std::fill
ESP_LOGD("script_calc", "Executing base calendar color calculation...");
// Ensure the global base colors vector is correctly sized (should match num_leds)
// This also initializes it with black Color objects if it's the very first run.
if (id(g_base_colors).size() != 37) {
id(g_base_colors).assign(37, Color::BLACK);
ESP_LOGD("script_calc", "Initialized/Resized g_base_colors vector to 37.");
}
// Get the current time object from the synced time component
auto current_time_obj = id(ha_time).now();
// Exit the script if the time is somehow not valid at this point
if (!current_time_obj.is_valid()) {
ESP_LOGW("script_calc", "Time not valid during calculation, skipping update.");
// Keep the previously calculated colors in this case
return;
}
// Extract necessary date components
int current_year = current_time_obj.year;
int current_month = current_time_obj.month; // 1-12
int current_day_of_month = current_time_obj.day_of_month; // 1-31
// --- Date Calculations ---
// 1. Calculate the weekday of the 1st day of the current month (0=Sunday, ..., 6=Saturday)
std::tm t_first_day{}; // Create a time structure
t_first_day.tm_year = current_year - 1900; // Years since 1900
t_first_day.tm_mon = current_month - 1; // Months since January (0-11)
t_first_day.tm_mday = 1; // Day of the month (1st)
// Set hour to noon to avoid potential DST issues around midnight
// t_first_day.tm_hour = 12;
// t_first_day.tm_isdst = -1; // Let mktime determine DST
std::mktime(&t_first_day); // mktime calculates the tm_wday (day of week) field
int first_day_wday_c_std = t_first_day.tm_wday; // 0=Sun, 1=Mon, ..., 6=Sat
// 2. Normalize the first day index (0-6) based on the user's 'start_day_of_week' setting.
// This normalized index represents the starting column (0-6) in our conceptual 6x7 grid.
int first_day_normalized_for_grid;
if (id(start_day_of_week) == 0) { // User selected Sunday as start day
// Grid column 0 = Sunday, 1 = Monday, ..., 6 = Saturday
first_day_normalized_for_grid = first_day_wday_c_std;
} else { // User selected Monday as start day (default)
// Grid column 0 = Monday, 1 = Tuesday, ..., 6 = Sunday
// We need to map C std weekday (Sun=0) to our grid index (Mon=0)
first_day_normalized_for_grid = (first_day_wday_c_std == 0) ? 6 : first_day_wday_c_std - 1;
}
// 3. Calculate the number of days in the current month, accounting for leap years.
int days_in_month;
if (current_month == 2) { // February
bool is_leap = (current_year % 4 == 0 && current_year % 100 != 0) || (current_year % 400 == 0);
days_in_month = is_leap ? 29 : 28;
} else if (current_month == 4 || current_month == 6 || current_month == 9 || current_month == 11) { // April, June, Sept, Nov
days_in_month = 30;
} else { // All other months
days_in_month = 31;
}
ESP_LOGD("script_calc", "Month details: Year=%d, Month=%d, Day=%d, 1stDayNorm=%d, DaysInMonth=%d",
current_year, current_month, current_day_of_month, first_day_normalized_for_grid, days_in_month);
// --- LED Mapping Definition ---
// This array maps the conceptual 6x7 grid cell index (0-41, top-left to bottom-right)
// to the physical LED index (0-36) based on the specific hardware wiring/layout.
// A value of -1 indicates that the conceptual cell does not correspond to any physical LED.
const int cell_to_physical_led[42] = {
// Conceptual Grid Row 0 (Top Row)
30, 31, 32, 33, 34, 35, 36,
// Conceptual Grid Row 1
29, 28, 27, 26, 25, 24, 23,
// Conceptual Grid Row 2
16, 17, 18, 19, 20, 21, 22,
// Conceptual Grid Row 3
15, 14, 13, 12, 11, 10, 9,
// Conceptual Grid Row 4
2, 3, 4, 5, 6, 7, 8,
// Conceptual Grid Row 5 (Bottom Row)
1, 0, -1, -1, -1, -1, -1
};
// --- Populate the global base colors vector ---
// Reset the global vector to all black before calculating the new colors for the month.
std::fill(id(g_base_colors).begin(), id(g_base_colors).end(), Color::BLACK);
// Iterate through all 42 conceptual grid cells (6 weeks x 7 days)
for (int cell_idx = 0; cell_idx < 42; ++cell_idx) {
// Find the corresponding physical LED index using the mapping array
int physical_led_idx = cell_to_physical_led[cell_idx];
// Check if this conceptual cell maps to a valid physical LED on our strip
if (physical_led_idx != -1 && physical_led_idx < 37) { // Ensure index is within 0-36 range
// Calculate the conceptual column (0-6) for weekend check
int grid_col = cell_idx % 7;
// Calculate the day number this cell should represent based on the 1st day's position
int day_in_this_cell = cell_idx - first_day_normalized_for_grid + 1;
// Check if this calculated day number is a valid day within the current month
if (day_in_this_cell > 0 && day_in_this_cell <= days_in_month) {
// --- Determine the state and color for this valid day ---
// Is it the current day?
bool is_today = (day_in_this_cell == current_day_of_month);
// Is it a weekend day?
bool is_weekend_day;
if (id(start_day_of_week) == 0) { // Sunday start: Sun(col 0) and Sat(col 6) are weekends
is_weekend_day = (grid_col == 0 || grid_col == 6);
} else { // Monday start: Sat(col 5) and Sun(col 6) are weekends
is_weekend_day = (grid_col == 5 || grid_col == 6);
}
// Assign the appropriate base color (at full intensity, brightness applied later)
Color calculated_base_color = id(color_weekday); // Default to weekday color
if (is_weekend_day) calculated_base_color = id(color_weekend); // Override if weekend
if (is_today) calculated_base_color = id(color_today); // Override if today
// Store the calculated base color in the global vector at the correct physical LED index
id(g_base_colors)[physical_led_idx] = calculated_base_color;
}
// else: This physical LED corresponds to a day outside the current month
// (e.g., leading days from previous month or trailing days for next month).
// It remains black as set by the initial std::fill.
}
// else: This conceptual grid cell does not map to any physical LED, so we ignore it.
} // --- End of conceptual grid cell loop ---
ESP_LOGD("script_calc", "Base calendar colors recalculated and stored in global vector.");
# ==============================================================================
# ESP8266 Platform Configuration
# ==============================================================================
esp8266:
board: d1_mini # Specify the exact board model for correct pin mappings and settings
# ==============================================================================
# WiFi Network Configuration
# ==============================================================================
wifi:
ssid: mySSID # Your WiFi network name (SSID)
password: myPassword # Your WiFi password
# Fallback Access Point (Optional)
# Creates a WiFi network named "CalendarFallbackAP" if the device cannot
# connect to the main network. Useful for initial setup or recovery.
ap:
ssid: "CalendarFallbackAP"
password: "fallbackpassword"
# ==============================================================================
# Logging Configuration
# ==============================================================================
logger:
# Set the default log level. Options: NONE, ERROR, WARN, INFO, DEBUG, VERBOSE, VERY_VERBOSE
# DEBUG is useful for development to see detailed messages (like ESP_LOGD).
# INFO is generally recommended for normal operation.
level: DEBUG
# ==============================================================================
# Native API Configuration
# ==============================================================================
# Enables the highly efficient native ESPHome API for communication with Home Assistant.
api:
# encryption: # Optional: Add encryption key for secure communication
# key: "YOUR_API_ENCRYPTION_KEY"
# password: "" # Optional: Set an API password (legacy, encryption preferred)
# ==============================================================================
# Over-The-Air (OTA) Update Configuration
# ==============================================================================
# Allows updating the device firmware wirelessly via WiFi.
ota:
platform: esphome # Use the standard ESPHome OTA method (recommended)
# password: "" # Optional: Set a password for OTA updates
# ==============================================================================
# Light Component Configuration
# ==============================================================================
# Defines the physical LED strip and its associated effects.
light:
- platform: neopixelbus # Use the NeoPixelBus library for controlling WS281x LEDs
type: GRB # Color order of the LEDs (Green, Red, Blue). Adjust if needed.
variant: WS2812X # Specific chipset variant (WS2812, WS2812B compatible)
pin: D4 # GPIO pin connected to the LED strip's Data input line
num_leds: 37 # Total number of LEDs on the physical strip
id: calendar_led_strip # Internal ID used to reference this light strip in YAML
name: "Internal Calendar LEDs" # Internal name (not typically visible in HA)
internal: true # Hide this base light entity from Home Assistant UI (controlled via switches/number)
default_transition_length: 0s # Disable default fading between states for instant changes
restore_mode: RESTORE_DEFAULT_OFF # Ensure the light state is OFF on initial power-up/reset
# --- Define Light Effects ---
effects:
# Custom lambda effect for displaying the calendar and glitter
- addressable_lambda:
name: "Calendar Display" # Name of the effect
update_interval: 50ms # How often the lambda code runs (fast for smooth glitter)
# --- C++ Lambda Code (Fast Loop) ---
# This code runs every 'update_interval' (50ms) when the effect is active.
# It's responsible for applying glitter and brightness to the pre-calculated base colors.
lambda: |-
// Include necessary C++ headers
#include <vector> // For std::vector
#include <cmath> // For isnan, round, std::max, std::min
#include <stdlib.h> // For rand(), srand()
#include <algorithm>// For std::min, std::max
// --- Random Number Generator Seeding (run only once) ---
// Ensures the random sequence is different each time the device boots.
static bool seeded = false;
if (!seeded) {
srand(::time(NULL)); // Use the global C time() function for seeding
seeded = true;
}
// --- Glitter Effect Parameters ---
const float GLITTER_PROBABILITY_PER_LED = 0.01f; // 1% chance per LED per 50ms cycle
const int GLITTER_BRIGHTNESS_ADD = 255; // Add full white component for intense glitter
// --- Read Target Brightness ---
// Get the desired brightness percentage from the global variable (set by the number entity)
float brightness_pct = id(g_target_brightness_pct);
// Validate the brightness value, providing a default if it's invalid (e.g., NaN on first read)
if (isnan(brightness_pct)) { brightness_pct = 80.0f; } // Default to 80% if invalid
// Clamp the brightness value to the valid range (1-100)
brightness_pct = std::max(1.0f, std::min(100.0f, brightness_pct));
// Convert the percentage (1-100) to the 0-255 scale needed for manual color scaling
uint8_t brightness_0_255 = (uint8_t) round((brightness_pct / 100.0f) * 255.0f);
// --- Safety Check: Ensure Base Colors are Ready ---
// Check if the global vector holding the base calendar colors has been initialized and populated.
if (id(g_base_colors).empty() || id(g_base_colors).size() != it.size()) {
ESP_LOGW("effect_lambda", "Base colors vector not ready (size=%d, expected=%d), skipping draw.", id(g_base_colors).size(), it.size());
// Optionally turn off LEDs completely if base colors are missing
// it.all() = Color::BLACK;
return; // Exit the lambda for this cycle
}
// --- Apply Colors, Glitter, and Brightness to Physical LEDs ---
// Iterate through each physical LED in the strip ('it' represents the strip controller)
for (int i = 0; i < it.size(); ++i) { // it.size() should be 37
// Get the pre-calculated base calendar color for this LED from the global vector
Color base_color = id(g_base_colors)[i];
// Start with the base color; this might be modified by glitter
Color color_before_scaling = base_color;
// --- Apply Glitter Effect ---
// Check if the base color is not black (don't glitter empty spots)
// and if a random chance (based on probability) passes for this LED
if (base_color != Color::BLACK && (rand() / (float)RAND_MAX) < GLITTER_PROBABILITY_PER_LED) {
// Add the glitter brightness component (effectively adding white)
int new_r = base_color.red + GLITTER_BRIGHTNESS_ADD;
int new_g = base_color.green + GLITTER_BRIGHTNESS_ADD;
int new_b = base_color.blue + GLITTER_BRIGHTNESS_ADD;
// Cap the R, G, B values at 255 to prevent overflow
color_before_scaling.red = (uint8_t) std::min(255, new_r);
color_before_scaling.green = (uint8_t) std::min(255, new_g);
color_before_scaling.blue = (uint8_t) std::min(255, new_b);
}
// --- Apply Final Brightness Scaling ---
// Manually scale the R, G, B components of the (potentially glittered) color
// using the target brightness value (converted to 0-255 scale).
// Use uint16_t for intermediate multiplication to prevent overflow before dividing by 255.
uint8_t scaled_r = static_cast<uint8_t>( ( (uint16_t)color_before_scaling.red * brightness_0_255 ) / 255 );
uint8_t scaled_g = static_cast<uint8_t>( ( (uint16_t)color_before_scaling.green * brightness_0_255 ) / 255 );
uint8_t scaled_b = static_cast<uint8_t>( ( (uint16_t)color_before_scaling.blue * brightness_0_255 ) / 255 );
// Set the physical LED ('it[i]') to the final calculated and scaled color
// 'it' is the special object provided by addressable_lambda to control LEDs
it[i] = Color(scaled_r, scaled_g, scaled_b);
} // --- End of LED loop ---
Hardware:
- Wemos D1 Mini (ESP8266)
- 37 x WS2812B LEDs (arranged in a specific layout, see
cell_to_physical_led
map in the code or the original project) - 5V Power Supply suitable for the LEDs
- 3D Printed Enclosure (This is necessary of cours for the calendar display
STL files, photos, and assembly details can be found in the original Instructables article).
This project demonstrates how addressable_lambda
effects combined with scripts and globals in ESPHome can create quite complex and optimized custom displays, integrating a DIY hardware project seamlessly into Home Assistant.
Let me know what you think or if you have any questions!