Hoy vamos a integrar el Comic Dashboard en HA, un atractivo panel de control que hemos creado usando una pantalla de tinta electrónica a todo color!
Índice
reTerminal E1002
Si sientes que estás teniendo un «déjà vu», tiene una explicación. Hace poco ya te hablé del reTerminal E1001 de Seeed Studio, una pantalla de tinta electrónica muy fácil de integrar en Home Assistant y con el mejor hardware que he probado en este tipo de dispositivos.

Precisamente por eso, no voy a volver a hablarte de sus especificaciones y todo lo que trae (ya que, básicamente, es lo mismo). La diferencia es que esta pantalla nos permite crear paneles de control a todo color, por lo que vamos a hacer algo divertido con ella!
Comic Dashboard
Siempre me han gustado las pantallas de tinta electrónica por lo bien que se integran en la decoración, pero nunca había tenido una a color. Antes de que cayese en mis manos, ya había decidido que quería crear un panel de control inspirado en la estética «Comic».
El resultado ha sido este «cuadro digital», que además de cambiar la imagen de fondo en función del día de la semana, me indica la fecha y el próximo evento en el calendario (aunque, por supuesto, puedes usarlo para mostrar los datos que quieras).

Configuración en ESPHome
Sigue estos pasos para integrar el Comic Dashboard en HA:
- Conecta la pantalla a tu ordenador a través del puerto USB-C.
- En Home Assistant, accede al complemento de ESPHome desde el menú lateral y pulsa en “New device”. Pulsa en “Continue” y dale un nombre (por ejemplo, “reTerminal 1002”).
- Pulsa en “Next” y a continuación selecciona la opción “ESP32-S3” como tipo de dispositivo. Verás que en el fondo se ha creado un nuevo dispositivo.
- Pulsa en “Skip” y haz clic en “Edit”, sobre el bloque de tu dispositivo. Copia el código que aparece y consérvalo, ya que vas a necesitar alguna parte del mismo.
- Copia el siguiente código y úsalo para reemplazar el código anterior en ESPHome.
substitutions:
# Device customization
# Personalización del dispositivo
name: reterminal-1002
friendly_name: reTerminal 1002
# Sleep mode configuration
# Configuración del sleep mode
run_duration: 60s
sleep_duration: 21600s # 6h
# Calendar
# Calendario
calendar: calendar.aguacatec_sesiones
# Weather
# Weather
weather: weather.openweathermap
# Images
# Imágenes
image_monday: https://aguacatec.es/wp-content/uploads/2025/11/comic_goku-1.png
image_tuesday: https://aguacatec.es/wp-content/uploads/2025/11/comic_iron_man-1.png
image_wednesday: https://aguacatec.es/wp-content/uploads/2025/11/comic_mario_bros-1.png
image_thursday: https://aguacatec.es/wp-content/uploads/2025/11/comic_joker-1.png
image_friday: https://aguacatec.es/wp-content/uploads/2025/11/comic_guardians_of_the_galaxay-1.png
image_saturday: https://aguacatec.es/wp-content/uploads/2025/11/comic_zelda-1.png
image_sunday: https://aguacatec.es/wp-content/uploads/2025/11/comic_star_wars-1.png
# Quotes
# Frases
quote_monday: 'Fuerza con el lunes!'
quote_tuesday: 'Ánimo, la semana pasa volando!'
quote_wednesday: 'Ya estamos a mitad de camino!'
quote_thursday: 'Sonríe, ya casi es finde!'
quote_friday: 'Es viernes y el cuerpo lo sabe!'
quote_saturday: 'Buen día para una excursión!'
quote_sunday: 'Nada como un domingo en familia...'
# Other settings
# Otros ajustes
allowed_characters: " |¿?¡!#%'()+,-./:µ³°0123456789ABCDEFGHIJKLMNOPQRSTUVWYZabcdefghijklmnopqrstuvwxyzáéíóúÁÉÍÓÚ"
################################################################################################################
esphome:
name: ${name}
friendly_name: ${friendly_name}
on_boot:
- priority: 600
then:
- output.turn_on: bsp_battery_enable
- delay: 500ms
- component.update: battery_voltage
- component.update: battery_level
- wait_until:
condition:
lambda: 'return id(calendar_time).state.length() >= 16;'
timeout: 30s
- component.update: epaper_display
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
advanced:
enable_idf_experimental_features: true
flash_size: 32MB
psram:
mode: octal
speed: 80MHz
logger:
api:
encryption:
key: "b3sdaffasddfflN8rs80ha324344"
ota:
- platform: esphome
password: "63fdsfsfad22886b4a434343gdffd"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Reterminal-1002 Fallback Hotspot"
password: "4RxzsdfsdX234feds"
captive_portal:
deep_sleep:
id: deep_sleep_1
run_duration: ${run_duration}
sleep_duration: ${sleep_duration}
wakeup_pin: GPIO3
wakeup_pin_mode: INVERT_WAKEUP
font:
- file: "gfonts://Bangers"
id: font_quote
size: 30
glyphs: ${allowed_characters}
- file: "gfonts://Kanit"
id: font_calendar
size: 18
glyphs: ${allowed_characters}
- file: "gfonts://Bangers"
id: font_battery_level
size: 20
glyphs: ${allowed_characters}
- file: "gfonts://Bangers"
id: font_date_weekday
size: 100
glyphs: ${allowed_characters}
- file: "gfonts://Bangers"
id: font_date_day
size: 200
glyphs: ${allowed_characters}
- file: "gfonts://Bangers"
id: font_date_month
size: 80
glyphs: ${allowed_characters}
i2c:
sda: GPIO19
scl: GPIO20
image:
- file: mdi:battery-70
id: icon_battery
resize: 25x25
type: BINARY
transparency: chroma_key
- file: ${image_monday}
id: background_image_monday
resize: 800x480
type: RGB565
- file: ${image_tuesday}
id: background_image_tuesday
resize: 800x480
type: RGB565
- file: ${image_wednesday}
id: background_image_wednesday
resize: 800x480
type: RGB565
- file: ${image_thursday}
id: background_image_thursday
resize: 800x480
type: RGB565
- file: ${image_friday}
id: background_image_friday
resize: 800x480
type: RGB565
- file: ${image_saturday}
id: background_image_saturday
resize: 800x480
type: RGB565
- file: ${image_sunday}
id: background_image_sunday
resize: 800x480
type: RGB565
# Weather
- file: mdi:white-balance-sunny
id: icon_weather_sunny
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-night
id: icon_weather_clear_night
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-cloudy
id: icon_weather_cloudy
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-partly-cloudy
id: icon_weather_partlycloudy
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-rainy
id: icon_weather_rainy
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-pouring
id: icon_weather_pouring
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-snowy
id: icon_weather_snowy
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-snowy-rainy
id: icon_weather_snowy_rainy
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-fog
id: icon_weather_fog
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-lightning
id: icon_weather_lightning
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-lightning-rainy
id: icon_weather_lightning_rainy
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-hail
id: icon_weather_hail
resize: 150x150
type: BINARY
transparency: chroma_key
- file: mdi:weather-windy
id: icon_weather_windy
resize: 150x150
type: BINARY
transparency: chroma_key
output:
- platform: gpio
pin: GPIO21
id: bsp_battery_enable
sensor:
- platform: sht4x
id: sht4x_component
temperature:
name: "Temperature"
id: sht4x_temperature
humidity:
name: "Humidity"
id: sht4x_humidity
address: 0x44
update_interval: 90s
- platform: adc
pin: GPIO1
name: "Battery Voltage"
id: battery_voltage
attenuation: 12db
internal: true
update_interval: 90s
filters:
- multiply: 2.0
- platform: template
name: "Battery Level"
id: battery_level
unit_of_measurement: "%"
icon: "mdi:battery"
device_class: battery
state_class: measurement
lambda: 'return id(battery_voltage).state;'
update_interval: never
filters:
- calibrate_linear:
- 4.15 -> 100.0
- 3.96 -> 90.0
- 3.91 -> 80.0
- 3.85 -> 70.0
- 3.80 -> 60.0
- 3.75 -> 50.0
- 3.68 -> 40.0
- 3.58 -> 30.0
- 3.49 -> 20.0
- 3.41 -> 10.0
- 3.30 -> 5.0
- 3.27 -> 0.0
- clamp:
min_value: 0
max_value: 100
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
text_sensor:
- platform: homeassistant
id: calendar_event
entity_id: ${calendar}
attribute: message
internal: true
- platform: homeassistant
id: calendar_time
entity_id: ${calendar}
attribute: start_time
internal: true
- platform: homeassistant
id: weather_condition
entity_id: ${weather}
internal: true
time:
- platform: homeassistant
id: ha_time
display:
- platform: epaper_spi
id: epaper_display
model: Seeed-reTerminal-E1002
update_interval: never
lambda: |-
// COLORS
const auto BLACK = Color(0, 0, 0, 0);
const auto YELLOW = Color(255, 255, 0);
const auto TRANSPARENT = Color(0, 0, 0, 0);
// GETTING WEEKDAY
auto now = id(ha_time).now();
struct tm timeinfo = now.to_c_tm();
int weekday_num = timeinfo.tm_wday; // 0=Domingo, 1=Lunes, ..., 6=Sábado
// WEEKDAY BACKGROUND
if (weekday_num == 0) {
it.image(0, 0, id(background_image_sunday));
} else if (weekday_num == 1) {
it.image(0, 0, id(background_image_monday));
} else if (weekday_num == 2) {
it.image(0, 0, id(background_image_tuesday));
} else if (weekday_num == 3) {
it.image(0, 0, id(background_image_wednesday));
} else if (weekday_num == 4) {
it.image(0, 0, id(background_image_thursday));
} else if (weekday_num == 5) {
it.image(0, 0, id(background_image_friday));
} else if (weekday_num == 6) {
it.image(0, 0, id(background_image_saturday));
}
// TOP BAR
it.filled_rectangle(0, 0, it.get_width(), 40, BLACK);
// CALENDAR
if (id(calendar_time).state.length() >= 16) {
std::string date = id(calendar_time).state.substr(8, 2);
std::string month = id(calendar_time).state.substr(5, 2);
std::string hour = id(calendar_time).state.substr(11, 2);
std::string minute = id(calendar_time).state.substr(14, 2);
std::map<std::string, std::string> month_names = {
{"01", "ene"}, {"02", "feb"}, {"03", "mar"}, {"04", "abr"},
{"05", "may"}, {"06", "jun"}, {"07", "jul"}, {"08", "ago"},
{"09", "sep"}, {"10", "oct"}, {"11", "nov"}, {"12", "dic"}
};
std::string month_name = month_names[month];
it.printf(15, -2, id(font_calendar), TextAlign::TOP_LEFT, "%s", id(calendar_event).state.c_str());
it.printf(15, 13, id(font_calendar), TextAlign::TOP_LEFT, "%s de %s, a las %s:%sh", date.c_str(), month_name.c_str(), hour.c_str(), minute.c_str());
}
// QUOTE & BATTERY LEVEL
const char* quote;
if (weekday_num == 0) {
quote = "${quote_sunday}";
} else if (weekday_num == 1) {
quote = "${quote_monday}";
} else if (weekday_num == 2) {
quote = "${quote_tuesday}";
} else if (weekday_num == 3) {
quote = "${quote_wednesday}";
} else if (weekday_num == 4) {
quote = "${quote_thursday}";
} else if (weekday_num == 5) {
quote = "${quote_friday}";
} else if (weekday_num == 6) {
quote = "${quote_saturday}";
}
it.printf(it.get_width() - 90, 5, id(font_quote), TextAlign::TOP_RIGHT, "%s", quote);
it.image(it.get_width() - 30, 7, id(icon_battery));
it.printf(it.get_width() - 80, 9, id(font_battery_level), "| %.0f%%", id(battery_level).state);
// WEATHER & DATE
std::string weather_state = id(weather_condition).state;
//Color weather_color_bg = BLACK;
//Color weather_color_fg = YELLOW;
// Mapeo de estados de clima a iconos y colores
if (weather_state == "sunny") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_sunny), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_sunny), YELLOW, TRANSPARENT);
} else if (weather_state == "clear-night") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_clear_night), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_clear_night), Color(200, 200, 255), TRANSPARENT);
} else if (weather_state == "cloudy") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_cloudy), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_cloudy), Color(180, 180, 180), TRANSPARENT);
} else if (weather_state == "partlycloudy") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_partlycloudy), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_partlycloudy), Color(255, 220, 100), TRANSPARENT);
} else if (weather_state == "rainy") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_rainy), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_rainy), Color(100, 150, 255), TRANSPARENT);
} else if (weather_state == "pouring") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_pouring), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_pouring), Color(50, 100, 200), TRANSPARENT);
} else if (weather_state == "snowy") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_snowy), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_snowy), Color(220, 240, 255), TRANSPARENT);
} else if (weather_state == "snowy-rainy") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_snowy_rainy), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_snowy_rainy), Color(150, 200, 255), TRANSPARENT);
} else if (weather_state == "fog") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_fog), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_fog), Color(160, 160, 160), TRANSPARENT);
} else if (weather_state == "lightning") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_lightning), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_lightning), Color(255, 255, 0), TRANSPARENT);
} else if (weather_state == "lightning-rainy") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_lightning_rainy), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_lightning_rainy), Color(200, 200, 0), TRANSPARENT);
} else if (weather_state == "hail") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_hail), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_hail), Color(200, 220, 255), TRANSPARENT);
} else if (weather_state == "windy" || weather_state == "windy-variant") {
it.image(it.get_width()/2 + 230, 120, id(icon_weather_windy), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_windy), Color(150, 220, 255), TRANSPARENT);
} else {
// Por defecto si el estado es desconocido
it.image(it.get_width()/2 + 230, 120, id(icon_weather_sunny), BLACK, TRANSPARENT);
it.image(it.get_width()/2 + 225, 115, id(icon_weather_sunny), YELLOW, TRANSPARENT);
}
const char *weekday_names[] = {"Dom","Lun","Mar","Mie","Jue","Vie","Sab"};
const char *wday = weekday_names[timeinfo.tm_wday];
const char *months[] = {"Ene","Feb","Mar","Abr","May","Jun","Jul","Ago","Sep","Oct","Nov","Dic"};
const char *month = months[timeinfo.tm_mon];
char day[3];
strftime(day, sizeof(day), "%d", &timeinfo);
char year[3];
strftime(year, sizeof(year), "%y", &timeinfo);
it.printf(it.get_width()/2 + 215, 135, id(font_date_weekday), BLACK, TextAlign::CENTER, "%s", wday);
it.printf(it.get_width()/2 + 205, 260, id(font_date_day), BLACK, TextAlign::CENTER, "%s", day);
it.printf(it.get_width()/2 + 205, 375, id(font_date_month), BLACK, TextAlign::CENTER, "%s '%s", month, year);
it.printf(it.get_width()/2 + 210, 130, id(font_date_weekday), TextAlign::CENTER, "%s", wday);
it.printf(it.get_width()/2 + 200, 255, id(font_date_day), TextAlign::CENTER, "%s", day);
it.printf(it.get_width()/2 + 200, 370, id(font_date_month), TextAlign::CENTER, "%s '%s", month, year);
- Parte importante. En este código no se han incluido las credenciales para que el dispositivo se conecte a tu WiFi y tu instancia de Home Assistant y tienes que incluirlas manualmente. En concreto, me refiero a las siguientes líneas del código que has copiado en el paso 4.
# Enable Home Assistant API
api:
encryption:
key: "bg6hash6sjdjsdjk02hh0qnQeYVwm123vdfKE8BP5"
ota:
- platform: esphome
password: "asddasda27aab65a48484502b332f"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Assist Fallback Hotspot"
password: "ZsasdasdHGP2234"
- Lo que tienes que hacer es, encontrar las líneas correspondientes en el código (está al principio) y añadir la información correspondiente.
- Ahora sí, pulsa en “Save” e “Install”. Selecciona “Manual download” y espera a que se compile el código.
- Cuando termine, selecciona la opción “Modern format” para descargar el fichero ‘.bin’ correspondiente.
- Conecta el reTerminal 1002 con el cable USB-C de datos por el puerto que trae en la parte posterior a tu ordenador.
- Ahora ve a la página de ESPHome y pulsa en “Connect”. En la ventana emergente selecciona tu placa y pulsa en “Conectar”.
- Ahora pulsa en “Install” y selecciona el fichero ‘.bin’ obtenido en el paso 9. De nuevo, pulsa en “Install”.
- Vuelve a Home Assistant y ve a Configuración > Dispositivos y servicios. Lo normal es que tu dispositivo haya sido descubierto y aparezca en la parte superior, esperando únicamente a que pulses el botón de “Configurar”. De lo contrario pulsa en el botón de “Añadir integración”, busca “ESPHome” e introduce la IP de tu placa en el campo ‘Host’.
- Listo! Ya has terminado de integrar el Comic Dashboard en HA y puedes consultar las siguientes entidades!
Personalización del dispositivo
Creo que esta vez definitivamente he conseguido un dispositivo treméndamente fácil de personalizar. Básicamente sólo necesitar adaptar las primeras líneas del código, sin que necesites saber nada más de ESPHome!
- Name y Friendly_name. Indica aquí el nombre que quieres darle al dispositivo (recuerda que el ‘name’ no puede contener mayúsculas, espacios o caracteres especiales).
- Run_duration y Sleep_duration. Indica aquí el tiempo en segundos durante los cuales, respectivamente, va a estar activa y «dormida» la pantalla, para maximizar la duración de la batería.
- Calendar y Weather. Indica las entidades de HA que quieres utilizar para que proporcione el próximo evento de tu calendario y la condición climática (por ejemplo, «soleado», «nublado», etc.)
- Images y Quotes. Personaliza en este apartado la imagen y frase que se va a mostrar cada día de la semana.
Preparación de las imágenes
Probablemente, esta ha sido la parte que más me ha constado resolver para integrar el Comic Dashboard en HA, pero ya está resuelto! Por un lado, ha sido clave gestionar la memoria flash del dispositivo y las prioridades de carga de los componentes. No obstante, puedes olvidarte de esto, ya que se ha incluido en el código anterior.
No obstante, para que la pantalla muestre las imágenes con la máxima calidad, también he seguido estas pautas (que te sugiero tener en cuenta, si vas a utilizar tus propias imágenes).
Generación de imágenes
Todas las imágenes que he utilizado en el panel han sido generadas con IA (en concreto, usando Nano Banana de Google). La instrucción clave ha sido indicarle «Crea una imagen de 800×480 pixels, estilo comic…» y lo que tú quieras.
Por ejemplo, puedes descargar varias imágenes de cómics que te gusten desde Google, y adjuntárselas para que te haga una composición con este ‘prompt’.
También puedes pedir directamente que te genere una imagen completa.

Compresión de las imágenes
Este paso es crítico para que las imágenes se visualicen con la mayor calidad. En primer lugar, asegúrate de guardarlas con una resolución de 800×480 pixels, para que no ocupen más espacio de la cuenta. Preferentemente, guárdala en ‘JPG’ con baja calidad, para reducir el peso del fichero.
Luego entra en esta página, pulsa en «Seleccionar archivo» y adjunta tu imagen. En principio no tienes que tocar nada, pero asegúrate de que estén marcados estos parámetros.

Cuando termine de procesar la imagen, descarga la versión bajo el apartado «Output». Aunque parezca que ha perdido calidad, en realidad la ha optimizado para nuestra pantalla. Utiliza esta versión en tu panel!


