Integrar el Comic Dashboard en HA

Integrar el Comic Dashboard en HA

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!

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!

*Algún precio puede haber cambiado desde la ultima revision

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:

  1. Conecta la pantalla a tu ordenador a través del puerto USB-C.
  2. 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”).
  3. 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.
  4. 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.
  5. 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);
  1. 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"
  1. 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.
  2. Ahora sí, pulsa en “Save” e “Install”. Selecciona “Manual download” y espera a que se compile el código. 
  3. Cuando termine, selecciona la opción “Modern format” para descargar el fichero ‘.bin’ correspondiente.
  4. Conecta el reTerminal 1002 con el cable USB-C de datos por el puerto que trae en la parte posterior a tu ordenador.
  5. Ahora ve a la página de ESPHome y pulsa en “Connect”. En la ventana emergente selecciona tu placa y pulsa en “Conectar”.
  6. Ahora pulsa en “Install” y selecciona el fichero ‘.bin’ obtenido en el paso 9. De nuevo, pulsa en “Install”.
  7. 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’.  
  8. 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!